Merge origin/main into pvp-2/gui-glint-lookup

Picks up #11532 and 88a372f808 (four harnesses that failed under parity.py's defaults).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-07 10:32:55 +01:00
commit e36bbcfc63
526 files changed
+36644 -2400

No files matched your search

+8 -7
View File
@@ -234,14 +234,15 @@ jobs:
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
- name: Check launcher DLL imports and command-line startup
working-directory: launcher
shell: pwsh
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
# `$output = & launcher --version` read no output from it (run 42217014588).
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
python ../tools/check_imports.py $launcher
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
python ../tools/check_imports.py "$launcher"
out=$("$launcher" --version)
echo "$out"
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
Generated
+199 -3
View File
@@ -153,6 +153,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -485,6 +491,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
@@ -540,6 +547,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.8"
@@ -946,6 +959,18 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1105,8 +1130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1135,6 +1162,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.2"
@@ -1264,6 +1301,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1660,10 +1706,15 @@ dependencies = [
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"gif",
"mysql",
"quinn",
"redis",
"rusqlite",
"rusty-opus",
"snap",
"tungstenite",
"yaml-rust2",
]
[[package]]
@@ -2268,6 +2319,17 @@ dependencies = [
"redox_syscall 0.9.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
@@ -2323,6 +2385,12 @@ version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lz4_flex"
version = "0.14.0"
@@ -2450,6 +2518,7 @@ dependencies = [
"btoi",
"byteorder",
"bytes",
"chrono",
"crc32fast",
"flate2",
"getrandom 0.3.4",
@@ -2484,7 +2553,7 @@ dependencies = [
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
"unicode-ident",
@@ -2498,7 +2567,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"rustc-hash 1.1.0",
"thiserror 2.0.21",
]
@@ -3354,6 +3423,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -3401,6 +3526,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3539,12 +3673,32 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3619,6 +3773,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@@ -3883,6 +4038,12 @@ dependencies = [
"serde",
]
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4198,13 +4359,23 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.5",
"windows-sys 0.61.2",
]
[[package]]
@@ -4274,6 +4445,9 @@ name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
@@ -4412,6 +4586,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
@@ -4675,6 +4855,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -4728,7 +4914,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
@@ -5471,6 +5657,16 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"hashlink",
]
[[package]]
name = "yasna"
version = "0.6.0"
-10
View File
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Join servers on older versions
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
+4
View File
@@ -212,3 +212,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
+136 -12
View File
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
other swing, landing it while falling: a critical hit, then a sweep from the
ground (give them swords); for PvP benches (crit and sweep particles)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
@@ -165,6 +168,8 @@ struct Options {
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
crits: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
o.no_swing = true;
continue;
}
if arg == "--crits" {
o.crits = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
const SWORD_COOLDOWN_TICKS: u32 = 13;
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
const JUMP_POWER: f64 = 0.42;
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
#[derive(Default)]
struct Crits {
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
jump: Option<f64>,
ground: f64,
/// The next swing is the jumped one.
crit_next: bool,
}
impl Crits {
fn airborne(&self) -> bool {
self.jump.is_some()
}
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
fn tick(
&mut self,
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since: &mut u32,
) -> Option<i32> {
if let Some(v) = self.jump {
pos[1] += v;
if pos[1] <= self.ground {
pos[1] = self.ground;
self.jump = None;
} else {
self.jump = Some((v - 0.08) * 0.98);
}
} else {
self.ground = pos[1];
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
self.jump = Some(JUMP_POWER);
}
}
let falling = self.jump.is_some_and(|v| v < 0.0);
if self.crit_next && !falling {
*since = since.saturating_add(1);
fight_step(pos, target.1);
return None;
}
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
if hit.is_some() {
self.crit_next = !self.crit_next;
}
hit
}
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
fn fight_tick(
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since_attack: &mut u32,
cooldown: u32,
) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
fight_step(pos, at);
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
@@ -1124,6 +1202,7 @@ fn run_bot(
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let mut crits = Crits::default();
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -1181,11 +1260,16 @@ fn run_bot(
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
let hit = if o.crits {
crits.tick(&mut pos, near, &mut since_attack)
} else {
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
};
if let Some(victim) = hit {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
) && send(play::Serverbound::Punch(Empty).encode());
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
@@ -1300,9 +1384,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
// Fight bots stand on the ground; the others fly.
// Fight bots stand on the ground (but in a --crits jump); the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|| o.ground,
),
})
.encode(),
@@ -1600,7 +1685,9 @@ mod tests {
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.filter_map(|tick| {
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
})
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
@@ -1617,7 +1704,7 @@ mod tests {
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
first = Some(tick);
break;
}
@@ -1627,6 +1714,43 @@ mod tests {
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
let mut last_y = pos[1];
let mut hits = Vec::new();
for tick in 0..120u32 {
let hit = crits.tick(&mut pos, target, &mut since);
if hit.is_some() {
hits.push((tick, crits_state(pos[1], last_y)));
}
last_y = pos[1];
}
assert!(hits.len() >= 6, "{hits:?}");
for (i, w) in hits.windows(2).enumerate() {
assert!(
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
"full strength each time: {hits:?}"
);
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
}
// A crit lands in the air, on the way down; a sweep from the ground.
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
assert!(pos[1] >= 64.0);
}
fn crits_state(y: f64, before: f64) -> &'static str {
if y == 64.0 {
"ground"
} else if y < before {
"falling"
} else {
"rising"
}
}
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
+38 -31
View File
@@ -393,38 +393,17 @@ impl FullscreenState {
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
if wanted && window.is_maximized() {
window.set_maximized(false);
window.set_fullscreen(Some(fullscreen_request(
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => enter_fullscreen(
window,
exclusive,
preferred.as_ref(),
)));
} else {
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None if reapply || mode_change => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None => {}
}
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None if reapply || mode_change => enter_fullscreen(
window,
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None => {}
}
}
@@ -602,6 +581,34 @@ fn monitor_current_mode(
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
/// state first: winit keeps a maximized window's client area inside the work area, so a
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
#[cfg(target_os = "windows")]
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen));
}
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
winit::window::Fullscreen::Borderless(
window
.current_monitor()
.or_else(|| window.primary_monitor()),
)
}
/// A window created fullscreen that the system also marked maximized enters it again
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
enter_fullscreen(window, borderless_fullscreen(window));
}
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
@@ -66,6 +66,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -224,6 +224,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.translucent_batching,
false,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -126,6 +126,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
assert!(
+76 -12
View File
@@ -261,6 +261,40 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
device_probe: Option<PendingProbe>,
}
/// What one look at the output devices found: the system default, the
/// fingerprint, and the device list when those say it may have changed.
#[cfg(feature = "audio")]
struct DeviceProbe {
default: Option<String>,
cards: Option<String>,
devices: Option<Vec<String>>,
}
#[cfg(feature = "audio")]
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
/// Looks at the output devices as `should_change_device` did on the render
/// thread: the device list only when the default or the fingerprint moved.
#[cfg(feature = "audio")]
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them; on Windows
// every endpoint, with no fingerprint to skip it); only the fingerprint's
// devices can change the list, and it is cheap.
let devices = (default != default_seen
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
.then(output_devices);
DeviceProbe {
default,
cards,
devices,
}
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
use symphonia::core::{
@@ -1051,6 +1085,8 @@ impl Audio {
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
#[cfg(not(feature = "audio"))]
@@ -1211,6 +1247,8 @@ impl Audio {
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -1232,7 +1270,8 @@ impl Audio {
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
/// at once a second, on a thread of their own: a later tick reads what it
/// found.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
@@ -1246,29 +1285,54 @@ impl Audio {
};
if lost {
log::info!("Horizon: audio device was lost");
self.device_probe = None;
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
if let Some(pending) = &self.device_probe {
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
return false;
};
self.device_probe = None;
return self.devices_changed(probe, current);
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
let pending = PendingProbe::default();
let out = Arc::clone(&pending);
let seen = (self.default_seen.clone(), self.cards_seen.clone());
let started = std::thread::Builder::new()
.name("audio-devices".into())
.spawn(move || {
let probe = probe_devices(seen.0, seen.1);
if let Ok(mut found) = out.lock() {
*found = Some(probe);
}
});
match started {
Ok(_) => self.device_probe = Some(pending),
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
}
false
}
/// The rest of `shouldChangeDevice` with what a look at the devices found,
/// `current` being the device playing now.
#[cfg(feature = "audio")]
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
let DeviceProbe {
default,
cards,
devices,
} = probe;
let Some(devices) = devices else {
return false;
};
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
@@ -538,6 +538,11 @@ impl BlockEntities {
});
Arc::clone(&self.tickers)
}
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
/// the last [`Self::tickers`]: what a diagnostics mod counts.
pub fn ticking_len(&self) -> usize {
self.live.len()
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
+54 -4
View File
@@ -1193,6 +1193,10 @@ impl SuggestLayout {
/// the suggestion list, Tab completion, history).
pub struct ChatScreen {
pub input: TextField,
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
/// has not been edited since. A draft draws grey italic, and the first
/// Backspace clears the whole box instead of one character.
is_draft: bool,
width: i32,
height: i32,
history_buffer: String,
@@ -1300,11 +1304,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
pub fn new(
m: &Measure,
c: &Commands,
initial: &str,
is_draft: bool,
history_len: usize,
width: i32,
height: i32,
@@ -1317,6 +1323,7 @@ impl ChatScreen {
input.set_value(m, initial);
let mut s = Self {
input,
is_draft,
width,
height,
history_buffer: String::new(),
@@ -1353,6 +1360,7 @@ impl ChatScreen {
input.set_value(m, command);
let mut s = Self {
input,
is_draft: false,
width,
height,
history_buffer: String::new(),
@@ -1382,7 +1390,8 @@ impl ChatScreen {
self.input.set_value(m, command);
self.edited(m, c);
}
/// `ChatScreen.resize`: a new screen with the current text.
/// `ChatScreen.resize`: a new screen with the current text, still a draft
/// while it has not been edited (`ChatScreen.resize` keeps the field).
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
@@ -1390,7 +1399,16 @@ impl ChatScreen {
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
Self::new(
m,
c,
&value,
self.is_draft,
self.history_pos,
width,
height,
abilities,
)
} else {
Self::command_block(m, c, &value, width, height)
};
@@ -1402,6 +1420,7 @@ impl ChatScreen {
fn edited(&mut self, m: &Measure, c: &Commands) {
self.allow_suggestions = true;
self.update_command_info(m, c);
self.is_draft = false;
}
/// Ctrl+A/C/V/X in the input box.
pub fn shortcut(
@@ -1415,6 +1434,24 @@ impl ChatScreen {
self.edited(m, c);
}
}
/// Edit the existing input and refresh its command responder when it changes.
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
let before = (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
);
f(&mut self.input);
if before
!= (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
)
{
self.edited(m, c);
}
}
/// Typed text (`charTyped` per character).
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
for ch in text.chars() {
@@ -1729,6 +1766,14 @@ impl ChatScreen {
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
// ChatScreen.keyPressed: while the box holds an unedited draft, the
// first Backspace clears all of it rather than one character.
if self.is_draft && key == K::Backspace {
self.input.set_value(m, "");
self.is_draft = false;
self.edited(m, c);
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
@@ -1862,8 +1907,13 @@ impl ChatScreen {
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input.
/// `CommandSuggestions.formatText`: argument colours over the input. The
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
/// and wins while the draft is unedited: the whole text is grey italic.
fn format(&self, value: &str, offset: usize) -> Text {
if self.is_draft {
return Text::literal(value).with_color(text::GRAY).italic();
}
let gray = Style::color(text::GRAY);
let Some(parse) = &self.parse else {
return Text::literal(value);
+104 -7
View File
@@ -201,7 +201,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -242,6 +242,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
&m,
&commands,
"unfinished draft",
false,
history.len(),
640,
360,
@@ -263,6 +264,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
}
}
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
/// and the first Backspace clears the whole draft instead of one character.
/// Editing the draft (`onEdited`) ends both.
#[test]
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
use winit::keyboard::KeyCode::Backspace;
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
// The input's own text as the game draws it: x 4, y height - 12.
let input_text = |screen: &mut ChatScreen| -> Text {
screen
.draw(&m, (0, 0), 0, i32::MIN)
.into_iter()
.find_map(|draw| match draw {
Draw::Text {
text, x: 4, y: 348, ..
} => Some(text),
_ => None,
})
.expect("the input draws its text")
};
let draft_style = |t: &Text| {
t.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
};
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(screen.value(), "half-written");
assert!(
draft_style(&input_text(&mut screen)),
"the restored draft draws grey italic"
);
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
// eats one character again.
screen.type_text(&m, &c, "!");
assert_eq!(screen.value(), "half-written!");
let typed = input_text(&mut screen);
assert!(
!typed.spans.iter().any(|s| s.style.is_italic()),
"an edited draft is not grey italic"
);
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-written");
// A draft left alone clears whole on its first Backspace.
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
// The flag is gone with it: typing is ordinary text, and Backspace eats
// one character.
screen.type_text(&m, &c, "hi");
assert_eq!(screen.value(), "hi");
let typed = input_text(&mut screen);
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "h");
// A box opened on ordinary text deletes one character at a time.
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
assert!(!draft_style(&input_text(&mut screen)));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-writte");
// A command draft is grey italic as a whole: `formatChat` is registered
// before `CommandSuggestions.formatText`, so it wins over the argument
// colours.
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
assert!(draft_style(&input_text(&mut screen)));
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
@@ -283,7 +371,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -333,7 +421,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
@@ -381,7 +469,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -403,7 +491,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -473,7 +561,7 @@ fn the_chat_option_decides_what_may_be_sent() {
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
@@ -499,7 +587,16 @@ fn restricted_input_says_why_it_will_not_send() {
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
let mut screen = ChatScreen::new(
&m,
&c,
"",
false,
0,
640,
360,
ChatAbilities::new(visibility),
);
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
@@ -11,7 +11,7 @@ use horizon_world::{
use std::{
collections::HashMap,
sync::{
Arc,
Arc, Mutex, OnceLock, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
@@ -108,6 +108,9 @@ pub enum BlockEntity {
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
// A client update can consume transient effects without changing saved
// fields. Most entities never use this; do not allocate their queue.
client_update_fields: OnceLock<Mutex<Compound>>,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
@@ -138,11 +141,55 @@ impl ModBlockEntity {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
client_update_fields: OnceLock::new(),
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// Replace the owner's pending, one-shot client update fields. These
/// fields are not saved, cloned, compared or retained for drawing, and
/// changing them does not change this entity's revision. The owner must
/// explicitly take and merge them into its update tag. Calling this does
/// not schedule a block update.
///
/// An empty batch clears a pending batch without allocating an idle queue.
pub fn set_client_update_fields(&mut self, fields: Compound) {
if fields.is_empty() {
self.clear_client_update_fields();
return;
}
*self
.client_update_fields
.get_or_init(|| Mutex::new(Compound::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner) = fields;
}
/// Discard the owner's pending client update fields, without changing
/// saved data, revision or a queue that has never been used.
pub fn clear_client_update_fields(&mut self) {
if let Some(fields) = self.client_update_fields.get() {
fields
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
/// Atomically take the owner's pending fields at update-tag construction.
/// A loaded chunk and a block change use this same boundary. Construct a
/// tag once, then send that tag to every recipient; another construction
/// sees an empty batch. A save or a clone does not consume this batch.
///
/// This is safe for an update-tag hook receiving only a shared reference,
/// including a saved chunk encoded by a worker thread. If a previous lock
/// holder panicked, preserve its pending data instead of dropping it.
pub fn take_client_update_fields(&self) -> Compound {
self.client_update_fields
.get()
.map_or_else(Compound::new, |fields| {
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
})
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
@@ -706,6 +753,85 @@ pub struct CommandBlock {
mod tests {
use super::*;
#[test]
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "test:machine".into(),
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
..Default::default()
});
let revision = machine.revision();
let saved = machine.fields.clone();
let debug = format!("{machine:?}");
machine.clear_client_update_fields();
machine.set_client_update_fields(Compound::new());
assert!(machine.take_client_update_fields().is_empty());
assert!(machine.client_update_fields.get().is_none());
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
let copy = machine.clone();
assert_eq!(machine, copy, "transient fields are not retained contents");
assert_ne!(machine.revision(), copy.revision());
assert!(copy.client_update_fields.get().is_none());
assert!(copy.take_client_update_fields().is_empty());
assert_eq!(machine.take_client_update_fields(), expected);
machine.set_client_update_fields(expected);
machine.clear_client_update_fields();
assert!(machine.take_client_update_fields().is_empty());
assert_eq!(machine.fields, saved);
assert_eq!(machine.revision(), revision);
assert_eq!(format!("{machine:?}"), debug);
}
#[test]
fn concurrent_client_update_constructions_consume_one_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
let received = std::thread::scope(|scope| {
let handles = (0..16)
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
.collect::<Vec<_>>();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect::<Vec<_>>()
});
let nonempty = received
.into_iter()
.filter(|c| !c.is_empty())
.collect::<Vec<_>>();
assert_eq!(nonempty, [expected]);
}
#[test]
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
std::thread::scope(|scope| {
assert!(
scope
.spawn(|| {
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
panic!("interrupt before consuming the queued batch");
})
.join()
.is_err()
);
});
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
}
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
+14 -1
View File
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
}
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
/// `EntityGetter.getEntityCollisions` gave the moving entity.
pub fn resolve_in_border_with(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
entities: &[Aabb],
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
entities,
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
+22 -1
View File
@@ -10,6 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_telemetry::system::machine_arch;
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// The build's architecture, and the machine's after "on" when it emulates the build.
fn arch_label(build: &str, machine: &str) -> String {
if build == machine {
build.to_owned()
} else {
format!("{build} on {machine}")
}
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
t.lines()
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn an_emulated_build_names_both_architectures() {
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
// On the machine running the tests: native builds name one architecture.
let label = arch_label(std::env::consts::ARCH, machine_arch());
assert!(label.starts_with(std::env::consts::ARCH));
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
@@ -0,0 +1,243 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
/// A layer's texture, RGBA8.
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Draw {
pub image: u16,
pub blend: Blend,
pub brightness: f32,
/// Camera-relative world from the skybox's space.
pub rotation: Mat4,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
&self,
world: i32,
conditions: &Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
+102 -13
View File
@@ -14,6 +14,8 @@ pub struct EffectInstance {
pub amplifier: i32,
pub ambient: bool,
pub visible: bool,
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
pub show_icon: bool,
pub hidden: Option<Box<EffectInstance>>,
}
impl EffectInstance {
@@ -24,9 +26,18 @@ impl EffectInstance {
amplifier: amplifier.clamp(0, 255),
ambient: false,
visible: true,
show_icon: true,
hidden: None,
}
}
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
/// the icon shows exactly when the particles do.
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
self.ambient = ambient;
self.visible = visible;
self.show_icon = visible;
self
}
fn infinite(&self) -> bool {
self.duration == -1
}
@@ -71,6 +82,10 @@ impl EffectInstance {
self.visible = take.visible;
changed = true;
}
if take.show_icon != self.show_icon {
self.show_icon = take.show_icon;
changed = true;
}
changed
}
/// `MobEffectInstance.tickClient`: a client counts its effects down
@@ -98,6 +113,7 @@ impl EffectInstance {
self.amplifier = h.amplifier;
self.ambient = h.ambient;
self.visible = h.visible;
self.show_icon = h.show_icon;
self.hidden = h.hidden;
}
}
@@ -116,8 +132,9 @@ pub enum EffectAction {
}
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
let every = |base: i32| {
let interval = base >> amp;
let interval = base.wrapping_shr(amp as u32);
interval <= 0 || tick % interval == 0
};
match id {
@@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
4i32.wrapping_shl(amp as u32).max(0) as f32,
)),
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
"minecraft:magic",
(6 << amp.min(30)) as f32,
6i32.wrapping_shl(amp as u32) as f32,
)),
_ => None,
}
@@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -168,6 +187,9 @@ pub enum OmenTick {
/// It added this effect, then returned false.
Add(EffectInstance),
}
/// A saved player's effect: id, duration, amplifier, ambient, particles
/// visible, icon shown (`MobEffectInstance.Details`).
pub type SavedEffect = (String, i32, i32, bool, bool, bool);
#[derive(Clone, Debug, Default)]
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
@@ -201,21 +223,22 @@ impl PlayerEffects {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
/// duration, amplifier, ambient, particles visible, icon shown).
pub fn from_saved(saved: &[SavedEffect]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles) in saved {
for (id, duration, amplifier, ambient, particles, icon) in saved {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
e.show_icon = *icon;
effects.add(e);
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
pub fn saved(&self) -> Vec<SavedEffect> {
self.list
.iter()
.map(|e| {
@@ -225,14 +248,15 @@ impl PlayerEffects {
e.amplifier,
e.ambient,
e.visible,
e.show_icon,
)
})
.collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown (always, for these effects).
/// particles visible and the icon shown.
pub fn flags(e: &EffectInstance) -> i8 {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
@@ -494,7 +518,72 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
#[cfg(test)]
mod tests {
use super::*;
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
/// at least 0 (#11386).
#[test]
fn heal_and_harm_shift_like_java() {
let magic = "minecraft:magic";
assert_eq!(
instant("instant_health", 32, 1.0),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
instant("instant_damage", 33, 1.0),
Some(EffectAction::Hurt(magic, 12.0))
);
assert_eq!(
instant("instant_health", 1, 0.5),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
periodic("instant_health", 1, 32),
Some(EffectAction::Heal(4.0))
);
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
assert_eq!(
periodic("instant_health", 1, 29),
Some(EffectAction::Heal(0.0))
);
assert_eq!(
periodic("instant_damage", 1, 30),
Some(EffectAction::Hurt(magic, i32::MIN as f32))
);
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
assert_eq!(periodic("regeneration", 49, 32), None);
assert_eq!(
periodic("regeneration", 50, 32),
Some(EffectAction::Heal(1.0))
);
}
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
/// false)`) shows neither particles nor its icon; a renewal that shows the
/// icon turns it back on (`MobEffectInstance.update`).
#[test]
fn an_effect_without_particles_hides_its_icon() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
let darkness = fx.get("darkness").unwrap();
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
assert_eq!(PlayerEffects::flags(darkness), 0);
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
// Details keeps show_icon apart from show_particles.
let mut speed = EffectInstance::new("speed", 100, 0);
speed.show_icon = false;
let mut fx = PlayerEffects::default();
fx.add(speed);
let saved = fx.saved();
assert_eq!(
saved,
[("minecraft:speed".to_owned(), 100, 0, false, true, false)]
);
let loaded = PlayerEffects::from_saved(&saved);
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
}
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
@@ -546,11 +635,11 @@ mod tests {
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true, true)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true, true)])
.list
.is_empty()
);
+373 -103
View File
@@ -170,6 +170,7 @@ mod hdr_icc;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
mod custom_sky;
mod displace;
pub mod lightning;
mod lod;
@@ -1034,6 +1035,25 @@ pub struct ScreenshotRequest {
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
/// lighting's code, even never run, slows the shader on Apple GPUs (~15% of the solid
/// pass at Retina sizes).
pub struct SolidPipeline {
plain: wgpu::RenderPipeline,
hdr: Option<wgpu::RenderPipeline>,
}
impl SolidPipeline {
/// The pipeline for a frame with HDR output on (`hdr`) or off.
pub fn get(&self, hdr: bool) -> &wgpu::RenderPipeline {
match &self.hdr {
Some(lit) if hdr => lit,
_ => &self.plain,
}
}
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -1085,10 +1105,10 @@ pub struct Renderer {
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
pub config: wgpu::SurfaceConfiguration,
solid: wgpu::RenderPipeline,
solid: SolidPipeline,
/// `solid` split in two passes for the world pass (terrain.wgsl's `opaque_quad`).
solid_opaque: wgpu::RenderPipeline,
solid_cutout: wgpu::RenderPipeline,
solid_opaque: SolidPipeline,
solid_cutout: SolidPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// Flat section draws (`flat`), where multi-draw is emulated.
@@ -1189,6 +1209,9 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
entity_outline: entity_outline::EntityOutline,
panorama: panorama::Panorama,
@@ -1380,9 +1403,12 @@ pub struct Renderer {
hdr_output: Option<hdr::Output>,
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
/// swapchain through `readback`.
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
/// with HDR off since reaches the swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
@@ -1858,9 +1884,10 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
}
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
/// `os` is `std::env::consts::OS`; `arch` the machine's (`system::machine_arch`), not the
/// build's; `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends itself.
/// Auto tries DirectX 12 first on Windows ARM64, the x64 build emulated there included,
/// then Vulkan, based on the device losses seen there (#7513). Other
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
/// crash in the driver can't be
@@ -1983,7 +2010,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
features.contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) && format.allowed_usages.contains(
) && draws_frame(format)
}
/// Whether a format with the adapter's `format` features takes every usage and flag the
/// passes take of the frame.
fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
format.allowed_usages.contains(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
@@ -1993,17 +2026,28 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
)
}
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
/// The frame of a renderer made under `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md)
/// with a shader API 0.2 pack picked, or with HDR on and no pack: float, so values over 1
/// survive from the world to the tonemap and the HDR pass.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for HDR on without a pack where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, HDR on without a pack, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `Graphics::wide_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if wide_frame && hdr_offered && device_draws_it {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
@@ -2024,7 +2068,8 @@ async fn open_device(
for (order, allow_cpu) in device_search(
api.with_env(),
std::env::consts::OS,
std::env::consts::ARCH,
// The machine's: the x64 build emulated on a Windows ARM64 PC has its GPU too (#7513).
horizon_telemetry::system::machine_arch(),
wgpu_backend,
) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
@@ -2476,6 +2521,8 @@ impl Renderer {
translucent_batching: bool,
wide_frame: bool,
shader_pack: bool,
linear_pack: bool,
hdr_float: bool,
) -> Result<Self> {
let Opened {
instance,
@@ -2609,7 +2656,23 @@ impl Renderer {
adapter.get_texture_format_features(WIDE_FRAME),
);
crate::graphics::offer_smooth_gradients(wide_ok);
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
let linear = (
linear_pack,
hdr_float,
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
);
if linear.0 && !linear.2 {
log::warn!(
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
);
}
let frame_format = frame_format(
wide_frame,
hdr_output.is_some(),
wide_ok,
config.format,
linear,
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2729,16 +2792,24 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Instance,
attributes: &debug_line_attributes,
})];
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
// The solid terrain's fragment constants without HDR output's lighting, for the
// frames HDR is off (`SolidPipeline`).
let plain_constants = [
sprite_constants[0],
sprite_constants[1],
("HDR_LIGHTING", 0.0),
];
let pipeline_with = |constants: &[(&str, f64)],
layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
@@ -2769,7 +2840,7 @@ impl Renderer {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -2782,6 +2853,57 @@ impl Renderer {
cache: None,
})
};
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
pipeline_with(
&sprite_constants,
layout,
label,
vertex,
fragment,
buffers,
topology,
cull,
depth_write,
depth_test,
blend,
)
};
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
// output's lighting, and with it where the display offers HDR output.
let solid_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
let build = |constants: &[(&str, f64)]| {
pipeline_with(
constants,
layout,
label,
vertex,
fragment,
buffers,
wgpu::PrimitiveTopology::TriangleList,
Some(wgpu::Face::Back),
true,
true,
None,
)
};
SolidPipeline {
plain: build(&plain_constants),
hdr: hdr_output.is_some().then(|| build(&sprite_constants)),
}
};
let pipeline = |label: &str,
vertex: &str,
fragment: &str,
@@ -2805,41 +2927,29 @@ impl Renderer {
)
};
let tri = wgpu::PrimitiveTopology::TriangleList;
let solid = pipeline(
let solid = solid_in(
&layout,
"Terrain solid",
"vs_terrain",
"fs_solid",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
// `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the
// world pass without a shader pack: no cutout first, through a shader that never
// discards, then the rest.
let solid_opaque = pipeline(
let solid_opaque = solid_in(
&layout,
"Terrain solid opaque",
"vs_terrain_opaque",
"fs_opaque",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
let solid_cutout = pipeline(
let solid_cutout = solid_in(
&layout,
"Terrain solid cutout",
"vs_terrain_cutout",
"fs_solid",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
// The water mirror's camera sees the world from the other side of the plane, which
// flips the winding: its solid terrain culls the front.
@@ -3786,12 +3896,8 @@ impl Renderer {
alpha_blend,
false,
);
let world_text_pipeline = world_text::pipeline(
&device,
Some(&entity_layout),
&entity_shader,
config.format.remove_srgb_suffix(),
);
let world_text_pipeline =
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
// ground it is stopped by, #5950). Terrain and entities are separate draw
@@ -4360,6 +4466,7 @@ impl Renderer {
layout, label, vertex, fragment, buffers, tri, cull, true, true, blend,
)
},
|layout, label, vertex, fragment| solid_in(layout, label, vertex, fragment, &[]),
)
});
// With flat draws' page groups the per-section solid draws read their vertices from
@@ -4373,40 +4480,24 @@ impl Renderer {
..Default::default()
});
let origins = [None, terrain_buffers[1].clone()];
let pulled = |label: &str, vertex: &str, fragment: &str, cull| {
let solid = |label: &str, vertex: &str, fragment: &str| {
solid_in(&pulled_layout, label, vertex, fragment, &origins)
};
(
solid("Terrain solid", "vs_section", "fs_solid"),
solid("Terrain solid opaque", "vs_section_opaque", "fs_opaque"),
solid("Terrain solid cutout", "vs_section_cutout", "fs_solid"),
pipeline_in(
&pulled_layout,
label,
vertex,
fragment,
&origins,
tri,
Some(cull),
true,
true,
None,
)
};
let back = wgpu::Face::Back;
(
pulled("Terrain solid", "vs_section", "fs_solid", back),
pulled(
"Terrain solid opaque",
"vs_section_opaque",
"fs_opaque",
back,
),
pulled(
"Terrain solid cutout",
"vs_section_cutout",
"fs_solid",
back,
),
pulled(
"Terrain solid (mirrored)",
"vs_section",
"fs_solid_mirror",
wgpu::Face::Front,
&origins,
tri,
Some(wgpu::Face::Front),
true,
true,
None,
),
)
}
@@ -4508,6 +4599,7 @@ impl Renderer {
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, frame_format);
let custom_sky = custom_sky::CustomSky::new(&device, frame_format);
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
@@ -4630,6 +4722,8 @@ impl Renderer {
celestial_buffer,
end_sky: end_sky::EndSky::default(),
stars,
custom_sky,
custom_sky_draws: Vec::new(),
entity_outline,
panorama,
border_pipeline,
@@ -4743,6 +4837,7 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
pack_loading: None,
@@ -4845,6 +4940,15 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
}
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
@@ -6690,6 +6794,18 @@ impl Renderer {
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
if (linear || self.frame_format == LINEAR_FRAME)
&& linear != (self.frame_format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
{
log::info!(
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader pack picked or dropped since runs on it",
self.frame_format
);
}
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
@@ -8854,6 +8970,12 @@ impl Renderer {
brightness,
);
}
self.custom_sky.prepare(
&mut self.uploads,
&mut encoder,
jittered,
&self.custom_sky_draws,
);
let celestial_vertices = frame.celestial.len().min(2048 * 4);
if frame.end_sky && !frame.title {
self.end_sky.prepare(&self.device, frame.end_sky_sprite);
@@ -9462,6 +9584,8 @@ impl Renderer {
}
}
if let Some(native) = &mut self.native {
let reflection =
reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view));
native
.frame
.set_reflection(&self.device, reflection.as_ref());
@@ -9495,7 +9619,9 @@ impl Renderer {
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid);
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
let hdr = self.hdr.is_some();
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
// Two solid passes only where a discard costs the GPU its hidden surface
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
// would double the solid draw calls and vertex work for nothing.
@@ -9593,6 +9719,12 @@ impl Renderer {
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
}
// OptiFine custom sky layers, over the disc and sunrise and under the
// sun, moon and stars.
if self.custom_sky.is_drawing() {
self.custom_sky.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
}
}
}
@@ -9605,6 +9737,13 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if frame.end_sky {
self.end_sky.draw(&mut pass);
// The End's custom sky layers go over its sky box.
if self.custom_sky.is_drawing() && !pack_draws_sky && self.native.is_none()
{
self.custom_sky.draw(&mut pass);
pass.set_pipeline(&self.celestial_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
}
}
if celestial_vertices > 0 && !pack_draws_celestial {
// The Overworld's bodies start with the sun's quad
@@ -9720,14 +9859,14 @@ impl Renderer {
let passes: &[(&wgpu::RenderPipeline, Calls, Calls)] = match pack_flat {
Some(p) => &[(&p.0, 0..flat.phase1_calls, 0..phase2_calls)],
None if split_solid => &[
(&flat.solid_opaque, 0..opaque, 0..phase2_split),
(flat.solid_opaque.get(hdr), 0..opaque, 0..phase2_split),
(
&flat.solid_cutout,
flat.solid_cutout.get(hdr),
cutout..flat.phase1_calls,
phase2_split..phase2_calls,
),
],
None => &[(&flat.solid, 0..flat.phase1_calls, 0..phase2_calls)],
None => &[(flat.solid.get(hdr), 0..flat.phase1_calls, 0..phase2_calls)],
};
let draw = |pass: &mut wgpu::RenderPass<'_>| {
for (pipeline, calls, _) in passes {
@@ -9780,9 +9919,13 @@ impl Renderer {
type Draws = std::ops::Range<u32>;
let passes: &[(&wgpu::RenderPipeline, Draws, Draws)] = match material {
None if split_solid => &[
(&self.solid_opaque, 0..opaque, phase1_draws..phase2_cutout),
(
&self.solid_cutout,
self.solid_opaque.get(hdr),
0..opaque,
phase1_draws..phase2_cutout,
),
(
self.solid_cutout.get(hdr),
cutout..phase1_draws,
phase2_cutout..solid_draws,
),
@@ -10124,12 +10267,7 @@ impl Renderer {
depth.size(),
);
if let Some((color, _)) = &scaled {
let color = color.texture();
encoder.copy_texture_to_texture(
color.as_image_copy(),
native.frame.opaque_color.as_image_copy(),
color.size(),
);
native.frame.copy_opaque(&self.device, &mut encoder, color);
}
pass = world_pass_after_culling(
&mut encoder,
@@ -10761,12 +10899,18 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
&mut encoder,
&f.texture,
!frame.title,
lifts_world,
self.own_timing.as_mut(),
),
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
@@ -10785,7 +10929,7 @@ impl Renderer {
.filter(|(request, _)| request.size.is_none())
.zip(self.hdr.as_ref())
.and_then(|(_, hdr)| {
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?;
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
@@ -12315,20 +12459,75 @@ mod tests {
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
use super::frame_format;
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
// No float frame asked for (or a device without it) leaves it as before.
for linear in [
(false, false, false),
(false, false, true),
(true, false, false),
(false, true, false),
(true, true, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
Bgra8Unorm
frame_format(true, true, true, Bgra8Unorm, linear),
Rgba16Unorm
);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear),
Bgra8Unorm
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear),
Rgba8Unorm
);
}
}
}
/// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame
/// wherever the device draws it, HDR on or off.
#[test]
fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::Bgra8UnormSrgb;
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
assert_eq!(
frame_format(
wide_frame,
hdr_offered,
draws,
Bgra8UnormSrgb,
(true, false, true)
),
LINEAR_FRAME
);
}
}
/// HDR on without a pack under `HORIZON_HDR_FRAME=float` gets the float frame where the
/// surface offers HDR output, Smooth Gradients or not; where it doesn't, the frame is as
/// without the switch.
#[test]
fn hdr_without_a_pack_gets_the_float_frame_where_hdr_output_is_offered() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for (wide_frame, draws) in [(true, true), (false, false)] {
assert_eq!(
frame_format(wide_frame, true, draws, Bgra8UnormSrgb, (false, true, true)),
LINEAR_FRAME
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
Rgba8Unorm
frame_format(
wide_frame,
false,
draws,
Bgra8UnormSrgb,
(false, true, true)
),
Bgra8Unorm
);
}
}
@@ -12728,6 +12927,77 @@ mod tests {
}
}
#[test]
fn plain_solid_shaders_translate_for_every_backend() {
// `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING`
// off on every backend: they must still translate (the other test translates them
// with the constants' defaults).
let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let stage = naga::ShaderStage::Fragment;
let constants = [("HDR_LIGHTING".to_owned(), 0.0)].into_iter().collect();
for entry in ["fs_solid", "fs_opaque"] {
let (module, info) = naga::back::pipeline_constants::process_overrides(
&module,
&info,
Some((stage, entry)),
&constants,
)
.unwrap();
let msl = naga::back::msl::write_string(
&module,
&info,
&naga::back::msl::Options {
fake_missing_bindings: true,
..Default::default()
},
&naga::back::msl::PipelineOptions {
entry_point: Some((stage, entry.to_owned())),
..Default::default()
},
);
// Metal compiles the lighting out from this constant.
assert!(
msl.unwrap()
.0
.contains("constant bool HDR_LIGHTING = false;"),
"{entry}"
);
let mut hlsl = String::new();
naga::back::hlsl::Writer::new(
&mut hlsl,
&Default::default(),
&naga::back::hlsl::PipelineOptions {
entry_point: Some((stage, entry.to_owned())),
},
)
.write(&module, &info, None)
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let mut glsl = String::new();
naga::back::glsl::Writer::new(
&mut glsl,
&module,
&info,
&naga::back::glsl::Options {
version: naga::back::glsl::Version::Desktop(330),
..Default::default()
},
&naga::back::glsl::PipelineOptions {
shader_stage: stage,
entry_point: entry.to_owned(),
multiview: None,
},
Default::default(),
)
.and_then(|mut w| w.write())
.unwrap_or_else(|e| panic!("{entry}: {e}"));
}
}
#[test]
fn entity_shader_validates_with_its_overlay_constants() {
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
naga::valid::Validator::new(
@@ -1040,7 +1040,14 @@ mod tests {
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let pipeline = &pipeline;
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
super::super::SolidPipeline {
plain: pipeline(layout, label, "", "", None, None, true),
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -0,0 +1,416 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
Blend::Add,
Blend::Subtract,
Blend::Multiply,
Blend::Dodge,
Blend::Burn,
Blend::Screen,
Blend::Replace,
Blend::Overlay,
Blend::Alpha,
];
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
const MAX_DRAWS: usize = 32;
/// Dynamic uniform offsets are 256-byte aligned on every backend.
const STRIDE: u64 = 256;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
clip_from_sky: [[f32; 4]; 4],
brightness: f32,
mode: u32,
pad: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
uv: [f32; 2],
cell: [f32; 4],
}
pub(super) struct CustomSky {
pipelines: Vec<wgpu::RenderPipeline>,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
/// One bind group per layer texture of the pack.
groups: Vec<wgpu::BindGroup>,
/// This frame's layers: bind group and pipeline indices.
frame: Vec<(u16, u8)>,
}
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
fn blend(mode: Blend) -> wgpu::BlendComponent {
use wgpu::BlendFactor as F;
let (src_factor, dst_factor, operation) = match mode {
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
// dst + dst * src: lightens.
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
// dst * (1 - src): darkens.
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
// dst * src + src * dst.
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
};
wgpu::BlendComponent {
src_factor,
dst_factor,
operation,
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
for face in Face::ALL {
let cell = face.cell();
let [u0, v0, u1, v1] = cell;
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
let corners = face.corners();
for i in [0, 1, 2, 0, 2, 3] {
out.push(Vertex {
position: corners[i],
uv: uv[i],
cell,
});
}
}
out
}
impl CustomSky {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(
std::mem::size_of::<Uniform>() as u64
),
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Custom sky shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Custom sky layout"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
let pipelines = MODES
.iter()
.map(|&mode| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Custom sky layer"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_custom_sky"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_custom_sky"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
// under the layers keeps its own.
blend: Some(wgpu::BlendState {
color: blend(mode),
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Custom sky"),
min_filter: wgpu::FilterMode::Linear,
mag_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Custom sky layers"),
size: STRIDE * MAX_DRAWS as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Custom sky box"),
contents: bytemuck::cast_slice(&mesh()),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
pipelines,
layout,
sampler,
uniform,
vertices,
groups: Vec::new(),
frame: Vec::new(),
}
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
(image.width, image.height, &image.rgba[..])
} else {
log::warn!(
"custom sky texture of {}x{} too large",
image.width,
image.height
);
(1, 1, &[0u8; 4][..])
};
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Custom sky layer"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
rgba,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Custom sky layer"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.uniform,
offset: 0,
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
})
.collect();
}
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
pub(super) fn prepare(
&mut self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
clip: Mat4,
draws: &[Draw],
) {
self.frame.clear();
for draw in draws.iter().take(MAX_DRAWS) {
if usize::from(draw.image) >= self.groups.len() {
continue;
}
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
let uniform = Uniform {
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
brightness: draw.brightness.clamp(0.0, 1.0),
mode: mode as u32,
pad: [0; 2],
};
let offset = STRIDE * self.frame.len() as u64;
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
self.frame.push((draw.image, mode as u8));
}
}
pub(super) fn is_drawing(&self) -> bool {
!self.frame.is_empty()
}
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
/// changed.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_vertex_buffer(0, self.vertices.slice(..));
for (i, &(image, mode)) in self.frame.iter().enumerate() {
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
&[(STRIDE * i as u64) as u32],
);
pass.draw(0..36, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
let k = a * b;
let src = match mode {
Blend::Multiply => 1.0 + (t - 1.0) * k,
Blend::Overlay => 0.5 + (t - 0.5) * k,
Blend::Replace | Blend::Alpha => t,
_ => t * k,
};
let c = blend(mode);
let f = |factor: wgpu::BlendFactor| match factor {
wgpu::BlendFactor::One => 1.0,
wgpu::BlendFactor::Zero => 0.0,
wgpu::BlendFactor::Src => src,
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
wgpu::BlendFactor::Dst => dst,
wgpu::BlendFactor::SrcAlpha => k,
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
other => panic!("{other:?}"),
};
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
match c.operation {
wgpu::BlendOperation::Add => s + d,
wgpu::BlendOperation::ReverseSubtract => d - s,
other => panic!("{other:?}"),
}
}
#[test]
fn every_mode_gives_its_documented_result() {
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
let (d, t) = (0.4, 0.8);
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
for mode in MODES {
if mode != Blend::Replace {
close(mix(mode, d, t, 1.0, 0.0), d);
}
}
// Half faded: add adds half.
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
}
#[test]
fn the_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_box_covers_every_face_with_its_cell() {
let mesh = mesh();
assert_eq!(mesh.len(), 36);
for v in &mesh {
let [u0, v0, u1, v1] = v.cell;
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
}
}
}
@@ -0,0 +1,61 @@
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
// brightness 0 leaves the sky as it was.
struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
@group(0) @binding(2) var sky_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
@location(1) @interpolate(flat) cell: vec4<f32>,
}
@vertex fn vs_custom_sky(
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) cell: vec4<f32>,
) -> Out {
var out: Out;
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
out.uv = uv;
out.cell = cell;
return out;
}
const ADD: u32 = 0u;
const SUBTRACT: u32 = 1u;
const MULTIPLY: u32 = 2u;
const DODGE: u32 = 3u;
const BURN: u32 = 4u;
const SCREEN: u32 = 5u;
const REPLACE: u32 = 6u;
const OVERLAY: u32 = 7u;
const ALPHA: u32 = 8u;
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
let t = textureSample(sky_texture, sky_sampler, uv);
let k = t.a * layer.brightness;
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(t.rgb, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(t.rgb, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(t.rgb * k, 1.0); }
}
}
+10 -16
View File
@@ -17,6 +17,7 @@
//! copied after the test, which tells which ones it saw: each run of a page
//! reserves room for all of its draws, the copy packs the seen ones at its
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
@@ -86,12 +87,12 @@ pub struct Call {
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
pub solid: SolidPipeline,
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
pub solid_opaque: SolidPipeline,
pub solid_cutout: SolidPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -357,7 +358,8 @@ fn plan(
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -372,6 +374,7 @@ impl Flat {
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
@@ -432,32 +435,23 @@ impl Flat {
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
solid: solid(
&layout,
"Terrain solid (flat)",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
solid_opaque: solid(
&layout,
"Terrain solid opaque (flat)",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
solid_cutout: solid(
&layout,
"Terrain solid cutout (flat)",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
@@ -349,12 +351,13 @@ fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -378,8 +381,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -403,6 +407,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -562,7 +567,7 @@ impl NativeMaterial {
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -609,7 +614,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -622,8 +627,8 @@ mod tests {
&stage.source,
&tags,
&materials,
false,
false
(false, false),
Api::V0_1
))
)
});
@@ -647,6 +652,33 @@ mod tests {
);
}
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
#[test]
fn the_terrain_validates_with_a_0_2_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
return input.position + input.tint.rgb;\n}\n";
let files = [
(MANIFEST, manifest),
("material.wgsl", material),
("vertex.wgsl", vertex),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -661,14 +693,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -681,4 +713,116 @@ mod tests {
);
}
}
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
/// what 0.1's does (vanilla's) within an 8-bit step.
#[test]
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
// The entries' call of `material`, with a 16×16 grid of colours.
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let c = floor(p.xy) / 15.0;
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
";
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
let format = wgpu::TextureFormat::Rgba8Unorm;
let draw = |api: Api| {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
[material]\nfile = \"material.wgsl\"\n"
);
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let size = wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: Default::default(),
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
drop(pass);
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
queue.submit([encoder.finish()]);
screenshot
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
};
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
}
}
@@ -101,7 +101,15 @@ impl NativePack {
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let api = report.manifest.api;
let mut frame = NativeFrame::new(
device,
settings.as_ref(),
textures,
lights,
terrain.pbr,
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
@@ -222,8 +230,13 @@ impl NativePack {
)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
})
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -7,11 +7,209 @@
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
};
use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
pub fn line_helpers(api: Api) -> &'static str {
match api {
Api::V0_1 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
Api::V0_2 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
}
"
}
}
}
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
#[derive(Clone)]
pub struct Decoder(wgpu::RenderPipeline);
impl Decoder {
fn new(device: &wgpu::Device) -> Self {
let source = line_helpers(Api::V0_2).to_owned()
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader API 0.2 decode"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let entries = (None, "horizon_vs", "horizon_fs");
Self(fullscreen_pipeline(
device,
"Shader API 0.2 decode",
&module,
entries,
INTERMEDIATE,
&[],
))
}
/// Draws `from` decoded onto `to`.
pub fn run(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
from: &wgpu::TextureView,
to: &wgpu::TextureView,
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader API 0.2 decode"),
layout: &self.0.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(from),
}],
});
fullscreen_pass(
encoder,
"Shader API 0.2 decode",
to,
&self.0,
(0, &group),
None,
);
}
}
/// A full-screen pipeline `label` of `module`'s entries `(vertex, fragment)`, unblended.
fn fullscreen_pipeline(
device: &wgpu::Device,
label: &str,
module: &wgpu::ShaderModule,
(layout, vertex, fragment): (Option<&wgpu::PipelineLayout>, &str, &str),
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let options = wgpu::PipelineCompilationOptions {
constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout,
vertex: wgpu::VertexState {
module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// One full-screen triangle of `pipeline` onto `view`, cleared first.
fn fullscreen_pass(
encoder: &mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
pipeline: &wgpu::RenderPipeline,
(index, group): (u32, &wgpu::BindGroup),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
timestamp_writes: timing.and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(index, group, &[]);
pass.draw(0..3, 0..1);
}
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
fn tonemap_entries() -> String {
StageKind::Tonemap.entries(Api::V0_2).replace(
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
)
}
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
const DEFAULT_TONEMAP: &str =
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
/// A colour target of `INTERMEDIATE` and `size`, drawn and read.
fn intermediate(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
}
/// A 0.2 pack's display (docs/api/shaders-reference-0.2.md): the world decoded into
/// `scene_color` (the first pass's `previous`), the chain ending in `picture`, and the tonemap
/// stage drawing that onto the screen.
struct Display {
decoder: Decoder,
/// The pack's `[tonemap]` label, `None` for Horizon's.
label: Option<String>,
tonemap: wgpu::RenderPipeline,
/// `scene_color` at the world's size; `picture` at the window's (none without a chain:
/// the tonemap reads `scene_color`).
scene: Option<([u32; 2], wgpu::TextureView)>,
picture: Option<([u32; 2], wgpu::TextureView)>,
}
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
@@ -98,6 +296,8 @@ pub struct NativePost {
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
/// Under 0.2: the decode, the picture and the tonemap.
display: Option<Display>,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
@@ -145,6 +345,11 @@ pub struct NativeFrame {
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
/// decoded (`linear_mirror`).
pub api: Api,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
}
/// A depth texture of `size` the world's depth is copied into.
@@ -179,7 +384,10 @@ fn copy_target(
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
// RENDER_ATTACHMENT: 0.2 draws `opaque_color` decoded rather than copying it.
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
}
@@ -225,6 +433,7 @@ impl NativeFrame {
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
api: Api,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -290,7 +499,7 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow, api);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
@@ -347,6 +556,9 @@ impl NativeFrame {
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
@@ -460,6 +672,7 @@ impl NativeFrame {
) {
let size = [depth.width(), depth.height()];
let (color_size, format) = match color {
Some(c) if self.decoder.is_some() => ([c.width(), c.height()], INTERMEDIATE),
Some(c) => ([c.width(), c.height()], c.format()),
None => ([1, 1], self.opaque_color.format()),
};
@@ -478,6 +691,46 @@ impl NativeFrame {
self.regroup(device);
}
/// Fills `opaque_color` from the world's colour `color`: a copy, under 0.2 decoded.
pub fn copy_opaque(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
) {
match &self.decoder {
Some(d) => {
let to = self.opaque_color.create_view(&Default::default());
d.run(device, encoder, color, &to);
}
None => encoder.copy_texture_to_texture(
color.texture().as_image_copy(),
self.opaque_color.as_image_copy(),
color.texture().size(),
),
}
}
/// The water mirror `view` as the material reads it: under 0.2 decoded into a copy.
pub fn linear_mirror(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
view: wgpu::TextureView,
) -> wgpu::TextureView {
let Some(decoder) = &self.decoder else {
return view;
};
let size = [view.texture().width(), view.texture().height()];
let fits = |m: &wgpu::TextureView| [m.texture().width(), m.texture().height()] == size;
if !self.mirror.as_ref().is_some_and(fits) {
self.mirror = Some(intermediate(device, "Water mirror, linear", size));
}
let mirror = self.mirror.clone().unwrap();
decoder.run(device, encoder, &view, &mirror);
mirror
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
@@ -543,6 +796,10 @@ impl NativeFrame {
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref());
inputs.wetness = self.wetness.0;
if self.api == Api::V0_2 {
inputs.sky_color = decoded(inputs.sky_color);
inputs.fog_color = decoded(inputs.fog_color);
}
if !self.reflects {
inputs.has_reflection = 0;
}
@@ -684,7 +941,8 @@ fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
impl NativePost {
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
/// `constants`, drawing onto a screen of `format`; under 0.2 the last onto `picture`, and
/// `tonemap` (else Horizon's) onto the screen. Errors name the pass wgpu refused.
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
@@ -692,6 +950,7 @@ impl NativePost {
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
tonemap: Option<&CheckedStage>,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
@@ -764,7 +1023,7 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if plan[i].writes == Writes::Screen {
format: if plan[i].writes == Writes::Screen && frame.decoder.is_none() {
format
} else {
INTERMEDIATE
@@ -781,6 +1040,41 @@ impl NativePost {
}
passes.push((stage.label, pipeline));
}
let display = match &frame.decoder {
Some(decoder) => {
let source = tonemap.map_or_else(
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|s| s.source.clone(),
) + &tonemap_entries();
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
let module = naga::front::wgsl::parse_str(&source)
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
let present = present_constants(&module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Tonemap"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let entries = (
Some(&pipeline_layout),
"horizon_vs_tonemap",
"horizon_fs_tonemap",
);
let pipeline =
fullscreen_pipeline(device, "Tonemap", &module, entries, format, &present);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{file}: {e}"));
}
Some(Display {
decoder: decoder.clone(),
label: tonemap.map(|s| s.label.clone()),
tonemap: pipeline,
scene: None,
picture: None,
})
}
None => None,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
@@ -811,12 +1105,18 @@ impl NativePost {
named: None,
plan,
format,
display,
})
}
/// The passes' labels, in order.
/// The passes' labels, in order, then the pack's tonemap's.
pub fn labels(&self) -> Vec<&str> {
self.passes.iter().map(|(l, _)| l.as_str()).collect()
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
self.passes
.iter()
.map(|(l, _)| l.as_str())
.chain(tonemap)
.collect()
}
fn prepare(
@@ -824,8 +1124,26 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
window: [u32; 2],
history_reset: bool,
) {
if let Some(d) = &mut self.display {
if d.scene.as_ref().is_none_or(|s| s.0 != size) {
d.scene = Some((
size,
intermediate(device, "Shader API 0.2 scene_color", size),
));
self.groups = None;
}
let chain = !self.passes.is_empty();
if chain && d.picture.as_ref().is_none_or(|p| p.0 != window) {
d.picture = Some((
window,
intermediate(device, "Shader API 0.2 picture", window),
));
self.groups = None;
}
}
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
@@ -913,85 +1231,98 @@ impl NativePost {
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
// Under 0.2 the passes read the world decoded.
let scene = self
.display
.as_ref()
.and_then(|d| d.scene.as_ref())
.map_or(world.0, |s| &s.1);
let group = |previous: &wgpu::TextureView, writes: Option<Writes>| {
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(scene),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if writes == Some(Writes::Target(j)) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &entries,
})
};
let mut groups: Vec<_> = (0..self.passes.len())
.map(|i| {
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
_ => scene,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &entries,
})
group(previous, Some(self.plan[i].writes))
})
.collect();
// The tonemap's, last: the chain's picture, else the decoded world.
if let Some(d) = &self.display {
groups.push(group(d.picture.as_ref().map_or(scene, |p| &p.1), None));
}
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
@@ -1011,18 +1342,34 @@ impl NativePost {
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size, history_reset);
let window = [screen.texture().width(), screen.texture().height()];
self.prepare(device, world, size, window, history_reset);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, _, groups)) = &self.groups else {
return;
};
if let Some(Display {
decoder,
scene: Some((_, scene)),
..
}) = &self.display
{
decoder.run(device, encoder, world.0, scene);
}
let last = match &self.display {
Some(Display {
picture: Some((_, picture)),
..
}) => picture,
_ => screen,
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
_ => last,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -1043,9 +1390,13 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
if let (Some(d), Some(group)) = (&self.display, groups.last()) {
let label = d.label.as_deref().unwrap_or("Tonemap");
fullscreen_pass(encoder, label, screen, &d.tonemap, (GROUP, group), timing);
}
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
let before_last = &self.plan[..self.plan.len().saturating_sub(1)];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
@@ -1080,6 +1431,11 @@ fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::T
})
}
/// Vanilla's gamma colour `c` in linear light (0.2's decode; negatives and NaN made 0).
fn decoded(c: [f32; 3]) -> [f32; 3] {
c.map(|v| if v > 0.0 { v.powf(2.2) } else { 0.0 })
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
@@ -1342,7 +1698,14 @@ mod tests {
};
let hud = crate::hud::HudBatch::new([4, 4]);
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut native = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
frame.pack.rain = 1.0;
@@ -1418,7 +1781,14 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -1431,6 +1801,7 @@ mod tests {
&[("gain".into(), 1.0)],
format,
&frame,
None,
)
.unwrap();
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
@@ -1538,7 +1909,14 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
@@ -1546,6 +1924,7 @@ file = \"b.wgsl\"
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -1650,7 +2029,14 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
@@ -1658,6 +2044,7 @@ file = \"b.wgsl\"
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -1929,4 +2316,126 @@ file = \"b.wgsl\"
);
}
}
/// Shader API 0.2: the world decoded, through a chain that passes it on or none, and
/// Horizon's tonemap or the pack's identity one, comes back encoded as it went in (within
/// an 8-bit step), at alpha 1.
#[test]
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
let Some((device, queue)) = device() else {
return;
};
let post = "[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n";
let curve = "[tonemap]\nfile = \"t.wgsl\"\n";
// A pack needs a stage: the first has only a sky, and no chain or tonemap.
let sky = "[sky]\nfile = \"s.wgsl\"\n";
for extra in [
sky.into(),
post.into(),
curve.into(),
format!("{post}{curve}"),
] {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n{extra}"
);
let pass = "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
let identity = "fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
let mut files = vec![(MANIFEST, manifest.as_str())];
if extra.contains("[[post]]") {
files.extend([("a.wgsl", pass), ("b.wgsl", pass)]);
}
if extra.contains("[tonemap]") {
files.push(("t.wgsl", identity));
}
if extra.contains("[sky]") {
files.push((
"s.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
));
}
let files = files
.into_iter()
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.collect();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let (passes, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
.partition(|s| s.kind == StageKind::Post);
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let inputs = FrameInputs {
headroom: 1.0,
..Default::default()
};
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let mut post = NativePost::new(
&device,
passes,
&report.manifest,
&[],
format,
&frame,
tonemap,
)
.unwrap();
assert_eq!(post.labels().last() == Some(&"t.wgsl"), tonemap.is_some());
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut encoder = device.create_command_encoder(&Default::default());
let clear = wgpu::Color {
r: 0.8,
g: 0.4,
b: 0.2,
a: 0.5,
};
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(clear),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
let view = screen.create_view(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &screen, [4, 4]);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
let want = [204, 102, 51, 255];
assert!(
pixel
.iter()
.zip(want)
.all(|(&got, want)| got.abs_diff(want) <= 1),
"{extra}: {pixel:?}"
);
}
}
}
@@ -6,7 +6,7 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
fn shadow_color(api: Api) -> wgpu::TextureFormat {
match api {
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
Api::V0_2 => super::native_post::INTERMEDIATE,
}
}
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
@@ -218,7 +223,7 @@ pub struct ShadowMap {
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -250,7 +255,7 @@ impl ShadowMap {
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
shadow_color(api),
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
@@ -474,6 +479,7 @@ fn shader_source(
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
api: Api,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
@@ -504,11 +510,12 @@ fn shader_source(
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
),
super::native_post::line_helpers(api)
)
}
@@ -527,6 +534,7 @@ impl NativeShadow {
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
frame.api,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -613,7 +621,7 @@ impl NativeShadow {
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
format: shadow_color(frame.api),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
@@ -731,8 +739,9 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let lines = [Api::V0_1, Api::V0_2];
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -767,6 +776,7 @@ mod tests {
None,
&materials,
Some(&stage.source),
Api::V0_1,
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
}
";
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
)
}
@@ -99,7 +101,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -195,7 +197,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -252,7 +254,14 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -43,6 +43,9 @@ mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod mixed_size_tests;
#[cfg(test)]
mod sky_disc_blend_tests;
@@ -2376,7 +2379,16 @@ impl PackPost {
} else {
written.next()
};
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
// Sizes of one kind that differ never meet. A size relative to the render size and
// one in pixels can: the pass then draws while they resolve to one size
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
let apart = |s: horizon_shaders::BufferSize| {
first.is_some_and(|f| {
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
})
};
if !in_shadow && group != Group::Final && written.any(apart) {
notes.push(format!(
"{}: writes buffers of different sizes (size.buffer); skipped",
pass.name
@@ -4288,6 +4300,18 @@ impl PackPost {
})
}
/// Whether the buffers `pass` writes have different sizes at the current render size: then
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
/// a pass writing buffers sized relative to the render size and in pixels can.
fn sizes_differ(&self, pass: &Pass) -> bool {
let Some(targets) = &self.targets else {
return false;
};
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
let first = sizes.next();
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
}
/// Whether the pack draws terrain itself, or has nothing draw it.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
@@ -4529,6 +4553,14 @@ impl PackPost {
mips,
});
self.forget_groups();
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
log::info!(
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
pass.name,
size[0],
size[1]
);
}
}
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
@@ -5075,6 +5107,10 @@ impl PackPost {
terrain_drawn = true;
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
// As if the pack didn't have it: no compute stages, flips or stage changes.
if self.sizes_differ(pass) {
continue;
}
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
water_drawn = true;
self.draw_translucent(device, queue, encoder, size, terrain);
@@ -0,0 +1,89 @@
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
//! original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
/// blue.
fn pack(colortex3: &str) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders.properties",
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
);
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
/// Every pixel's colour, with how many pixels differ from it by more than one step.
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
let first = pixels[0];
let off = pixels
.iter()
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
.count();
(first, off)
}
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
/// the whole pack in that case; Horizon skips only the program).
#[test]
fn a_program_writing_buffers_that_come_to_one_size_draws() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack("4 4"));
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
assert_eq!(off, 0, "{colour:?}");
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
assert!(got.abs_diff(want) <= 1, "{colour:?}");
}
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
}
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
#[test]
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
let Some(gpu) = Fixture::new() else { return };
let post = pack("0.5 0.5");
let post = gpu
.try_build(post)
.unwrap_or_else(|_| panic!("the pack runs"));
assert!(
post.notes
.iter()
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
"{:?}",
post.notes
);
}
@@ -323,4 +323,27 @@ mod tests {
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
/// the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
}
+23 -17
View File
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
in.snow != 0u);
let out = weather(input);
return vec4(horizon_line_out(out.rgb), out.a);
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
)
}
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -318,19 +322,21 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
use horizon_shader_api::{Api, stages::StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
+7 -3
View File
@@ -1092,8 +1092,9 @@ impl Graphics {
// (horizon_threads: niced on Linux; on macOS and Windows the server
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
// silicon only the performance cores count (horizon_threads::default_workers).
let auto = horizon_threads::jobs::default_threads();
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -1476,7 +1477,10 @@ mod tests {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
@@ -639,12 +639,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
/// BlueMap download radii, in chunks.
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
/// for a short ring of far terrain past a small render distance (a phone or an
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
/// up to the whole world.
const LOD_STEPS: [u32; 18] = [
const LOD_STEPS: [u32; 24] = [
0,
16,
24,
32,
48,
64,
96,
128,
192,
256,
512,
1024,
@@ -2692,12 +2700,21 @@ mod tests {
item.label(&s).plain(),
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
);
item.set_slider(&mut s, 5.0 / 17.0);
item.set_slider(&mut s, 11.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 1024);
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
// The fine stops: a ring of far terrain just past a small render
// distance, which the doubling steps skipped (#9694).
item.set_slider(&mut s, 1.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 16);
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
item.set_slider(&mut s, 2.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 24);
// A distance between stops (from the file) sits on the next one.
s.horizon.lod_distance = 3000;
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
format!("Full Detail Distance: {near} chunks")
+15 -8
View File
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && w.a >= 0.75 && w.a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
light = min(light, vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
+5
View File
@@ -1354,6 +1354,11 @@ impl HudAtlas {
bordered.insert(image.0.clone());
}
}
if full {
let (width, height, rgba) =
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
@@ -485,11 +485,6 @@ pub struct HealthHud {
/// `Util.getMillis()` of the last health change.
pub last_health_time: i64,
pub health_blink_time: i64,
/// `Hud.leftHeight` and `rightHeight` once the health, armor, food, mount
/// and air layers have run; the selected item name and the action bar sit
/// above them.
pub left_height: i32,
pub right_height: i32,
/// `Hud.lastBubblePopSoundPlayed`.
pub last_bubble_pop_sound: i32,
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
@@ -655,9 +650,6 @@ impl HealthHud {
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
let row_height = (10 - (rows - 2)).max(3);
let mut y_air = y_base - 10;
self.left_height = 39 + (rows - 1) * row_height + 10 + if i.armor > 0 { 10 } else { 0 };
// The mount's heart rows (`extractVehicleHealth`); food adds its row below.
self.right_height = 39 + 10 * ((i.vehicle_hearts + 9) / 10);
let offset_index = if i.regeneration {
i.tick % ceil(max_health + 5.0)
} else {
@@ -752,13 +744,11 @@ impl HealthHud {
}
}
y_air -= 10;
self.right_height += 10;
}
// Hud.extractAirBubbles.
let max_air = i.max_air.max(1);
let air = i.air.clamp(0, max_air);
if i.under_water || air < max_air {
self.right_height += 10;
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
let y = y_air - (rows - 1) * 10;
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
@@ -115,8 +115,7 @@ impl From<&crate::effects::EffectInstance> for EffectIcon {
color: def.map_or(0, |d| d.color as i32),
ambient: effect.ambient,
duration: effect.duration,
// Horizon keeps no showIcon flag: every effect shows its icon.
show_icon: true,
show_icon: effect.show_icon,
}
}
}
@@ -323,13 +322,12 @@ impl Titles {
}
}
}
/// `Hud.extractOverlayMessage`; `bar_height` is
/// `max(leftHeight, rightHeight)`.
/// `Hud.extractOverlayMessage`: the action bar keeps its place however
/// tall the health, food, air and mount rows above the hotbar are.
pub fn overlay_message(
&self,
w: i32,
h: i32,
bar_height: i32,
partial: f32,
font: &Font,
background: u32,
@@ -345,12 +343,11 @@ impl Titles {
let alpha = ((t * 255.0 / 20.0) as i32).min(255);
if alpha > 0 {
let message = message.resolve_legacy();
// The bars' height plus the gap between the item name (59) and the
// overlay (68), at least the default level.
let y_shift = bar_height + (68 - 59);
// `Hud.extractOverlayMessage` translates to `guiHeight - 68`, a
// constant the bars above the hotbar never move.
out.push(GuiDraw::Push(vec![PoseOp::Translate(
(w / 2) as f32,
(h - y_shift.max(68)) as f32,
(h - 68) as f32,
)]));
let color = if self.overlay_animated {
hsv_to_argb(t / 50.0, 0.7, 0.6, alpha as u32)
@@ -468,13 +465,13 @@ impl ToolHighlight {
self.last = selected;
}
/// `Hud.extractSelectedItemName`; `can_hurt` is
/// `gameMode.canHurtPlayer()` (survival and adventure) and `bar_height`
/// is `max(leftHeight, rightHeight)`.
/// `gameMode.canHurtPlayer()` (survival and adventure). The name sits 59
/// pixels above the bottom edge whatever the bars above the hotbar do,
/// and 14 lower when the mode cannot hurt the player.
pub fn draw(
&self,
w: i32,
h: i32,
bar_height: i32,
can_hurt: bool,
font: &Font,
background: u32,
@@ -495,7 +492,7 @@ impl ToolHighlight {
}
let str_width = width(font, &name);
let x = (w - str_width) / 2;
let mut y = h - bar_height.max(59);
let mut y = h - 59;
if !can_hurt {
y += 14;
}
@@ -1295,3 +1292,92 @@ mod subtitle_tests {
assert!(!o.listening());
}
}
#[cfg(test)]
mod text_position_tests {
use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The missing-glyph metrics let these tests run without game assets.
fn font() -> Font {
Font::load(
Path::new("/nonexistent-horizon-hud-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
)
}
fn held() -> HeldName {
HeldName {
item: "minecraft:stone".into(),
hover_name: Text::literal("Stone"),
rarity_color: 0xFFFF_FFFF,
custom_name: false,
}
}
fn text_top(draws: &[GuiDraw]) -> Option<i32> {
draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
})
}
/// `Hud.extractSelectedItemName`: `guiHeight - 59`, then `+ 14` when the
/// game mode cannot hurt the player. The health, food, air and mount rows
/// above the hotbar never move it: with armor and a second row of hearts
/// they reach 69 pixels, and the name stays where vanilla draws it.
#[test]
fn the_selected_item_name_sits_fifty_nine_above_the_bottom_edge() {
let mut highlight = ToolHighlight {
last: Some(held()),
timer: 40,
};
let f = font();
let draws = highlight.draw(320, 240, true, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59));
// Creative and spectator add the fourteen pixels the experience bar
// and the food row would have taken.
let draws = highlight.draw(320, 240, false, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59 + 14));
// No timer, no name.
highlight.timer = 0;
assert!(highlight.draw(320, 240, true, &f, 0).is_empty());
}
/// `Hud.extractOverlayMessage`: the action bar and its text translate to
/// `guiHeight - 68` and `-4`, again wherever the bars above the hotbar
/// reach.
#[test]
fn the_action_bar_sits_sixty_eight_above_the_bottom_edge() {
let mut titles = Titles::default();
titles.set_overlay(Text::literal("Action bar"), false);
let f = font();
let draws = titles.overlay_message(320, 240, 0.0, &f, 0);
let translate = draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(_, y) => Some(*y),
_ => None,
}),
_ => None,
});
assert_eq!(translate, Some((240 - 68) as f32));
// The text is drawn four pixels below that origin, as vanilla's is.
let relative = draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
});
assert_eq!(relative, Some(-4));
}
}
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2015 mezz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

+1
View File
@@ -36,6 +36,7 @@ pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
pub mod dynamic_light;
pub mod effects;
+37 -1
View File
@@ -274,6 +274,8 @@ enum Out {
date: NaiveDate,
lines: Vec<String>,
stderr: Option<String>,
/// The message's level, thread and text, for the [`set_line_hook`] callback.
record: (log::Level, String, String),
},
/// Answer once everything before is written.
Flush(mpsc::SyncSender<()>),
@@ -293,12 +295,14 @@ struct Writer {
impl Writer {
fn run(mut self, out: mpsc::Receiver<Out>) {
let stderr = io::stderr();
let mut hook_alive = true;
for msg in out {
match msg {
Out::Write {
date,
lines,
stderr: message,
record: (level, thread, text),
} => {
if let Some(message) = message {
let _ = writeln!(stderr.lock(), "{message}");
@@ -319,6 +323,12 @@ impl Writer {
text.push('\n');
let _ = latest.file.write_all(text.as_bytes());
}
// After the file has it. A callback that panics is not called again: the
// panic's own log line would reach it next.
if let Some(hook) = LINE_HOOK.get().filter(|_| hook_alive) {
let call = || hook(level.as_str(), &thread, &text, &lines.join("\n"));
hook_alive = std::panic::catch_unwind(call).is_ok();
}
}
Out::Flush(done) => {
if let Some(latest) = &mut self.file {
@@ -395,6 +405,18 @@ impl Floods {
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
/// What [`set_line_hook`] installed: level (`INFO`, `WARN`, `ERROR`), thread, message, and the
/// lines as `latest.log` got them.
type LineHook = fn(&str, &str, &str, &str);
static LINE_HOOK: std::sync::OnceLock<LineHook> = std::sync::OnceLock::new();
/// Calls `hook` on the log's writer thread for each message written to the log, once the file
/// has it, so the thread that logged never waits on it. Once per process; a second call keeps
/// the first.
pub fn set_line_hook(hook: LineHook) {
let _ = LINE_HOOK.set(hook);
}
fn ours(target: &str) -> bool {
target.starts_with("horizon")
}
@@ -490,6 +512,7 @@ impl Logger {
date: now.date_naive(),
lines,
stderr,
record: (level, thread.to_owned(), message.to_owned()),
};
if let Err(mpsc::SendError(Out::Write {
stderr: Some(message),
@@ -502,12 +525,25 @@ impl Logger {
}
}
/// The `latest.log` this run writes, once `init` opened it.
static LATEST_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// The `latest.log` this run writes, absolute; None before `init` and when it logs to the
/// terminal only.
pub fn latest_path() -> Option<PathBuf> {
LATEST_PATH.get().cloned()
}
/// Starts the log in `home/logs`. The main thread shows as `main_thread` in it. Messages are
/// still printed when the file cannot be opened.
pub fn init(home: &Path, main_thread: &'static str) {
let dir = logs_dir(home);
let file = match open_latest(&dir) {
Ok(Some(latest)) => Some(latest),
Ok(Some(latest)) => {
let path = dir.join("latest.log");
let _ = LATEST_PATH.set(std::path::absolute(&path).unwrap_or(path));
Some(latest)
}
Ok(None) => {
eprintln!(
"Horizon: another game is writing {}; this one logs to the terminal only",
@@ -63,6 +63,11 @@ struct Globals {
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
override SPRITE_SPLIT: u32 = 2048u;
// Whether the solid shaders carry HDR output's lighting (a pipeline constant): the
// renderer builds its solid terrain without it for the frames HDR is off, where
// `hdr_light.z` is 0, since even a branch never taken costs Apple GPUs ~15% of a
// solid pixel's time (gfx.rs's `SolidPipeline`).
override HDR_LIGHTING: bool = true;
// A sprite sampled with the given gradients, from whichever array holds its layer.
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
@@ -620,7 +625,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -637,7 +642,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -665,7 +670,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// fully fogged mountain would stand out from the fog behind it.
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
var emissive = 1.0;
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
@@ -892,5 +892,9 @@
"Editing: %s": "Editing: %s",
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"horizon.pad.notice.connected.press": "Press to use it"
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe"
}
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
&m,
&c,
case["initial"].as_str().unwrap_or(""),
false,
history.len(),
w,
h,
@@ -8,7 +8,8 @@ use horizon_client::{
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES,
species::can_be_a_baby, sulfur_cube,
},
random_sequences::RandomSequences,
};
@@ -111,7 +112,7 @@ pub fn main() -> horizon_client::Result<()> {
for slot in drops::SLOTS {
drops::clear_slot(&mut m, slot);
}
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
if get("baby", "0") == "1" && can_be_a_baby(m.species()) {
m.extra.age = -24000;
}
if get("fire", "0") == "1" {
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
+32
View File
@@ -146,6 +146,16 @@ fn frame(v: &Value) -> Frame {
}
}
impl Tree {
/// `PlayerAdvancements.checkForAutomaticTriggers`: the advancements that
/// have no criteria at all, so nothing can ever grant them a criterion and
/// vanilla hands them over (and grants their rewards) as the player's
/// progress loads.
pub fn automatic(&self) -> Vec<usize> {
(0..self.list.len())
.filter(|&i| self.list[i].criteria.is_empty())
.collect()
}
/// [`Tree::load`], shared while anyone holds it: a server's advancement
/// packets (`ServerAdvancements`) and its game's criteria read the same
/// files, and each kept its own copy (7 MB). A world whose data packs bring
@@ -2677,6 +2687,28 @@ mod tests {
p.grant("minecraft:t", "b", "now");
assert!(p.done(&a));
}
/// `AdvancementRequirements.test` answers false with no requirement groups, so an advancement
/// without criteria is never done by `isDone`; `PlayerAdvancements.checkForAutomaticTriggers`
/// still grants its rewards as progress loads, and `Tree::automatic` is the list it walks.
#[test]
fn an_advancement_without_criteria_is_not_done_but_granted_automatically() {
let empty = parse("minecraft:free", &serde_json::json!({"criteria": {}})).unwrap();
assert!(!Progress::default().done(&empty), "no groups: not done");
let waiting = parse(
"minecraft:waits",
&serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
)
.unwrap();
assert!(!Progress::default().done(&waiting));
let tree = tree_of(&[
(
"minecraft:waits",
serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
),
("minecraft:free", serde_json::json!({"criteria": {}})),
]);
assert_eq!(tree.automatic(), vec![1]);
}
#[test]
fn layout_spreads_children_like_tree_node_position() {
let mut tree = Tree::default();
@@ -57,6 +57,21 @@ pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// The game's native trample hook (`native_trample`), set once by the game: dimension, entity
/// id, entity type, the farmland's position, the fall distance and cancel.
pub static TRAMPLE: OnceLock<fn(&str, i32, &str, [i32; 3], f64, &mut bool)> = OnceLock::new();
/// Whether the mods let `entity` (of type `kind`) trample the farmland at `position`.
pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f64) -> bool {
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
!cancel
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
+20 -8
View File
@@ -42,6 +42,8 @@ pub struct Interaction {
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
/// `lowerFillLevel` posts BLOCK_CHANGE with the new state.
pub lower_fill_level: bool,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -60,6 +62,7 @@ fn pass() -> Interaction {
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
}
@@ -126,9 +129,20 @@ fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -
block: Some(block),
sound,
stat: Some(stat),
lower_fill_level: false,
}
}
fn changed_lowered(
cauldron: &BlockState,
sound: Option<&'static str>,
stat: &'static str,
) -> Interaction {
let mut result = changed(lowered(cauldron), sound, stat);
result.lower_fill_level = true;
result
}
const COLORS: [&str; 16] = [
"white",
"orange",
@@ -183,7 +197,7 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None, "clean_armor");
return changed_lowered(cauldron, None, "clean_armor");
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
@@ -208,6 +222,7 @@ pub fn interact(
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
} else {
empty_bucket(
@@ -231,6 +246,7 @@ pub fn interact(
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
} else {
empty_bucket(
@@ -266,11 +282,7 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
changed_lowered(cauldron, Some("minecraft:item.bottle.fill"), "use_cauldron")
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -298,7 +310,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None, "clean_banner")
changed_lowered(cauldron, None, "clean_banner")
}
("water", name)
if name
@@ -307,7 +319,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None, "clean_shulker_box")
changed_lowered(cauldron, None, "clean_shulker_box")
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+2 -2
View File
@@ -13,8 +13,8 @@ pub use horizon_client_render::{
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx,
graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
crash, custom_sky, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
+88
View File
@@ -289,6 +289,8 @@ impl ServerTickRateManager {
/// Ticks one sprint frame may run (rendering carries on between frames).
const SPRINT_PER_FRAME: i64 = 40;
const SAMPLES: usize = 100;
/// The windows of [`FixedStep::tps`], in seconds: one, five and fifteen minutes.
const TPS_WINDOWS: [f64; 3] = [60.0, 300.0, 900.0];
/// A step command's broadcast value and the ticks still owed to a new client.
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
@@ -316,6 +318,10 @@ pub struct FixedStep {
tick_times: [i64; SAMPLES],
aggregated: i64,
tick_count: i64,
/// The ticks a second over the last [`TPS_WINDOWS`], decayed once a second ([`Self::tps`]).
tps: [f64; 3],
/// When the second being counted started (at a recorded tick's end) and the ticks since.
tps_second: Option<(std::time::Instant, u32)>,
/// A server's client: its level's tick rate only slows the timer down
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
client: bool,
@@ -349,6 +355,8 @@ impl FixedStep {
tick_times: [0; SAMPLES],
aggregated: 0,
tick_count: 0,
tps: [20.0; 3],
tps_second: None,
client: false,
step_changes: 0,
step_requested: 0,
@@ -530,6 +538,41 @@ impl FixedStep {
let end = self.now_ns() + took;
self.manager.end_tick_work(end);
}
self.count_tick(std::time::Instant::now());
}
/// A recorded tick ended at `now`: once a second has passed since the second being
/// counted started, its ticks a second join each average of [`Self::tps`], weighted as a
/// load average is (`exp(-elapsed / window)` kept of the old value).
fn count_tick(&mut self, now: std::time::Instant) {
let Some((start, ticks)) = &mut self.tps_second else {
self.tps_second = Some((now, 0));
return;
};
*ticks += 1;
let elapsed = now.saturating_duration_since(*start).as_secs_f64();
if elapsed < 1.0 {
return;
}
let rate = f64::from(*ticks) / elapsed;
for (average, window) in self.tps.iter_mut().zip(TPS_WINDOWS) {
let kept = (-elapsed / window).exp();
*average = *average * kept + rate * (1.0 - kept);
}
self.tps_second = Some((now, 0));
}
/// The ticks a second the server kept over the last one, five and fifteen minutes (what
/// Bukkit's `Server.getTPS` reports; vanilla keeps no such average): measured between
/// recorded tick ends, so paused passes count where they are recorded. Starts at 20.
pub fn tps(&self) -> [f64; 3] {
self.tps
}
/// `getTickTimesNanos` as vanilla lays it out: the tick numbered `n` (from 1, as
/// `MinecraftServer.tickCount` after its increment) at index `n % 100`, so the
/// newest is at the recorded ticks' count `% 100`; 0 where no tick was recorded yet.
pub fn tick_times_nanos(&self) -> Vec<u64> {
(0..SAMPLES)
.map(|i| self.tick_times[(i + SAMPLES - 1) % SAMPLES].max(0) as u64)
.collect()
}
/// `getAverageTickTimeNanos`.
pub fn average_nanos(&self) -> u64 {
@@ -547,6 +590,51 @@ impl FixedStep {
mod tests {
use super::*;
/// Twenty ticks a second keep 20; ten a second pull the one-minute average down
/// fastest; a second not yet over changes nothing.
#[test]
fn tps_averages_follow_the_ticks_a_second() {
let mut clock = FixedStep::new(false);
let t0 = std::time::Instant::now();
let ms = std::time::Duration::from_millis;
for i in 0..=40 {
clock.count_tick(t0 + ms(50 * i));
}
assert_eq!(
clock.tps().map(|v| (v * 1000.0).round() / 1000.0),
[20.0; 3]
);
let slow = t0 + ms(2000);
for i in 1..=10 {
clock.count_tick(slow + ms(100 * i));
}
let [one, five, fifteen] = clock.tps();
assert!(
one < five && five < fifteen && fifteen < 20.0,
"{:?}",
clock.tps()
);
assert!((one - (20.0 - 10.0 * (1.0 - (-1.0f64 / 60.0).exp()))).abs() < 1e-9);
clock.count_tick(slow + ms(1050));
assert_eq!(clock.tps(), [one, five, fifteen]);
}
/// The tick times sit where vanilla's array has them: tick n at n % 100.
#[test]
fn tick_times_are_laid_out_as_vanillas() {
let mut clock = FixedStep::new(false);
assert_eq!(clock.tick_times_nanos(), vec![0; SAMPLES]);
for n in 1..=150u64 {
clock.record(std::time::Duration::from_nanos(n));
}
let times = clock.tick_times_nanos();
assert_eq!(times.len(), SAMPLES);
assert_eq!(times[50], 150, "the newest, tick 150");
assert_eq!(times[51], 51, "tick 51, overwritten by none yet");
assert_eq!(times[0], 100);
assert_eq!(times[1], 101);
}
#[test]
fn timer_carries_fractions_and_caps_nothing_itself() {
let mut t = Timer::new(20.0, 0);
+33 -3
View File
@@ -666,7 +666,7 @@ impl ItemEntity {
wanted
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still), and
// too (a boat, a shulker, a happy ghast held still; not a minecart), and
// so does the world border (`Entity.collectColliders`).
crate::collisions::EntityCollisions {
blocks: scene,
@@ -1313,6 +1313,35 @@ impl ExperienceOrb {
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
self.tick_among(
world,
facts,
collision,
player,
server,
next_float,
border,
fluids,
on_sound,
&[],
)
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -1374,10 +1403,11 @@ impl ExperienceOrb {
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
// Entity.collide: the world border's walls stop the orb too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop the orb too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
+114 -14
View File
@@ -105,6 +105,16 @@ impl Base<'_> {
}
tag
}
/// Preserve Entity data without flags owned by Mob's save writer.
/// Only the dedicated boat, item and armor-stand writers use this path.
fn non_mob_tag(&self, raw: Option<&Tag>) -> Tag {
let mut tag = self.tag(raw);
if let Tag::Compound(map) = &mut tag {
map.remove(&"NoAI".into());
map.remove(&"PersistenceRequired".into());
}
tag
}
}
/// `Entity.saveWithoutId`'s common fields for entities Horizon keeps as raw
@@ -128,8 +138,8 @@ pub fn fill_common_defaults(tag: &mut Tag) {
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
use crate::mobs::{Species, finalize::ageable, species::can_be_a_baby};
s == Species::Tadpole || (ageable(s) && can_be_a_baby(s))
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
@@ -633,15 +643,11 @@ pub fn mob_to_nbt(
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
} else if matches!(
species,
crate::mobs::Species::Parrot
| crate::mobs::Species::Slime
| crate::mobs::Species::MagmaCube
| crate::mobs::Species::ZombieNautilus
) {
} else if crate::mobs::finalize::ageable(species) {
// AgeableMob.addAdditionalSaveData writes nothing when canBeABaby is
// false: drop the ignored values a raw input compound carried.
// false — the frog, the parrot, the zombie nautilus, the camel husk,
// the slime and the magma cube: drop the ignored values a raw input
// compound carried rather than writing them back out.
for key in ["Age", "ForcedAge", "AgeLocked"] {
map.remove(&key.into());
}
@@ -2356,7 +2362,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
on_ground: e.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
let mut tag = base.non_mob_tag(raw);
for (key, value) in [
("Item", item_to_nbt(&e.stack)),
("Health", Tag::Short(e.health as i16)),
@@ -3428,7 +3434,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
on_ground: a.on_ground,
fall_distance: a.enchantment_fall_distance,
};
let mut tag = base.tag(raw);
let mut tag = base.non_mob_tag(raw);
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
.iter()
.zip(&a.equipment)
@@ -3582,7 +3588,7 @@ pub fn boat_to_nbt(
on_ground: false,
fall_distance: 0.0,
}
.tag(raw);
.non_mob_tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
}
@@ -4038,11 +4044,16 @@ mod tests {
}
#[test]
fn cubes_omit_supplied_age_and_pigs_keep_modeled_age() {
fn an_ageable_mob_that_cannot_be_a_baby_saves_no_age() {
use crate::mobs::Species;
// AgeableMob.addAdditionalSaveData writes Age only when canBeABaby is
// true, so every ageable class that overrides it to false writes none
// of it: a frog and a camel husk keep no raw `Age` either.
for (species, id) in [
(Species::Slime, "minecraft:slime"),
(Species::MagmaCube, "minecraft:magma_cube"),
(Species::Frog, "minecraft:frog"),
(Species::CamelHusk, "minecraft:camel_husk"),
(Species::Pig, "minecraft:pig"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
@@ -4934,6 +4945,95 @@ mod tests {
}
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:pig".into();
def.species = crate::mobs::Species::Pig;
let types = [("minecraft:pig".into(), kind)].into();
for input in [None, Some(0), Some(1)] {
for id in [
"minecraft:oak_boat",
"minecraft:oak_chest_boat",
"minecraft:armor_stand",
"minecraft:item",
"minecraft:pig",
] {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[4.5, 200.0, 4.5])),
("Motion", doubles(&[0.0, 0.0, 0.0])),
("Silent", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Invulnerable", Tag::Byte(1)),
("PortalCooldown", Tag::Int(17)),
("Tags", Tag::List(vec![string("audit")])),
("Unrelated", string("keep")),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:stone", 1)),
),
]);
if let Some(value) = input {
put(&mut raw, "NoAI", Tag::Byte(value));
put(&mut raw, "PersistenceRequired", Tag::Byte(value));
}
let stand = armor_stand_from_nbt(&raw, 1).unwrap();
let item = item_entity_from_nbt(&raw, 1, 0.0).unwrap();
let boat = boat_from_nbt(&raw).unwrap();
for source in [None, Some(&raw)] {
let saved = match id {
"minecraft:armor_stand" => armor_stand_to_nbt(&stand, uuid, source),
"minecraft:item" => item_entity_to_nbt(&item, uuid, source),
"minecraft:pig" => {
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
// The writer must use live state, even opposite stale raw values.
mob.no_ai = input != Some(1);
mob.persistent = input != Some(1);
mob_to_nbt(&mob, uuid, source, None, |_| None)
}
_ => boat_to_nbt(
id,
boat.0,
boat.1,
[boat.2, 0.0],
uuid,
source,
(id == "minecraft:oak_chest_boat").then_some(&[]),
),
};
if id == "minecraft:pig" {
let live = input != Some(1);
assert_eq!(saved.get("NoAI"), live.then_some(&Tag::Byte(1)));
assert_eq!(
saved.get("PersistenceRequired"),
Some(&Tag::Byte(i8::from(live)))
);
} else {
assert!(saved.get("NoAI").is_none(), "{id} {input:?}");
assert!(saved.get("PersistenceRequired").is_none(), "{id} {input:?}");
}
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(saved.get("Pos"), raw.get("Pos"));
if source.is_some() {
for key in [
"Silent",
"NoGravity",
"Invulnerable",
"PortalCooldown",
"Tags",
"Unrelated",
] {
assert_eq!(saved.get(key), raw.get(key), "{id} {key}");
}
}
}
}
}
}
#[test]
fn animal_integer_loads_save_modeled_values_without_raw_source() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
+61
View File
@@ -46,6 +46,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
(logging::game_home(), "client", "Render thread")
};
// Before the log opens, so a mod's injection hears its first lines.
logging::set_line_hook(crate::native_client_hooks::native_log_line);
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
@@ -98,6 +100,35 @@ pub fn main_with(mods: crate::mod_api::Mods) {
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if horizon_world::worldgen::native_generate::install(
horizon_world::worldgen::native_generate::NativeGenerateHooks {
chunk_generate: |dimension, chunk, seed, flat, chunk_out, generate| {
crate::mod_api::dispatch_chunk_generate(
crate::mod_api::mods(),
dimension,
chunk,
seed,
flat,
chunk_out,
generate,
);
},
lod_column: |dimension, column, seed, flat, column_out| {
crate::mod_api::dispatch_lod_column(
crate::mod_api::mods(),
dimension,
column,
seed,
flat,
column_out,
);
},
},
)
.is_err()
{
log::warn!("Native generation hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
@@ -168,6 +199,23 @@ pub fn main_with(mods: crate::mod_api::Mods) {
register_goals: |mob| {
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
},
player_pre_login: |player, name, address, defer| {
crate::mod_api::dispatch_player_pre_login(
crate::mod_api::mods(),
player,
name,
address,
defer,
);
},
player_profile: |player, name, properties| {
crate::mod_api::dispatch_player_profile(
crate::mod_api::mods(),
player,
name,
properties,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
@@ -180,6 +228,19 @@ pub fn main_with(mods: crate::mod_api::Mods) {
accept,
);
},
log_line: |level, thread, message, line| {
crate::mod_api::dispatch_log_line(crate::mod_api::mods(), level, thread, message, line);
},
handshake: |address, host, intent, cancel, reason| {
crate::mod_api::dispatch_handshake(
crate::mod_api::mods(),
address,
host,
intent,
reason,
cancel,
);
},
})
.is_err()
{
+63 -3
View File
@@ -78,9 +78,6 @@ impl FurnaceCooking for Furnace {
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
@@ -250,6 +247,69 @@ mod tests {
(items, recipes, providers)
}
#[test]
fn loaded_totals_survive_until_actual_completion() {
let (items, recipes, providers) = data("loaded-total");
assert_eq!(
recipes
.cook(
FurnaceKind::Furnace.recipe_type(),
&ItemStack::new("test:ore", 3)
)
.unwrap()
.time,
200
);
// Fresh Java's five-case Game fixture independently admits the loaded
// zero (including a missing saved field), five and normal totals.
for (total, timer, completion) in [(0, 0, 1), (5, 0, 5), (200, 0, 200), (5, 1, 4)] {
let mut f = Furnace {
kind: FurnaceKind::Furnace,
items: [Some(ItemStack::new("test:ore", 3)), None, None],
lit_remaining: 20,
lit_total: 20,
cooking_timer: timer,
cooking_total: total,
speed: 1.0,
recipes_used: HashMap::new(),
};
for elapsed in 1..=6 {
let mut drops = Vec::new();
assert!(!f.idle());
assert_eq!(f.tick(&recipes, &items, &providers, &mut drops), None);
assert!(drops.is_empty());
assert_eq!((f.lit_remaining, f.lit_total), (20 - elapsed, 20));
assert!(f.items[1].is_none());
let complete = elapsed >= completion;
assert_eq!(
f.items[0].as_ref().unwrap().count,
if complete { 2 } else { 3 },
"loaded {total}/{timer}, tick {elapsed}"
);
assert_eq!(
f.items[2].as_ref().map(|s| (&*s.item, s.count)),
if complete {
Some(("test:ingot", 1))
} else {
None
}
);
assert_eq!(f.cooking_total, if complete { 200 } else { total });
assert_eq!(
f.cooking_timer,
if complete {
elapsed - completion
} else {
timer + elapsed
}
);
}
// A changed input still goes through setItem's independent reset.
f.set_input(&recipes, Some(ItemStack::new("test:ingot", 1)));
assert_eq!((f.cooking_timer, f.cooking_total), (0, 200));
}
}
#[test]
fn cooking_requires_output_components_and_capacity() {
let (items, recipes, providers) = data("output-components");
+281 -60
View File
@@ -104,6 +104,7 @@ mod creative_hotbar;
mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod custom_sky;
mod debug_keys;
mod debug_lines;
mod debug_options;
@@ -223,8 +224,10 @@ mod native_biome_colors;
pub mod native_block_entity_draw;
mod native_block_sprites;
mod native_bots;
mod native_chunk_snapshot;
mod native_chunk_tickets;
mod native_claimed_items;
mod native_client_entities;
mod native_client_particles;
mod native_client_particles_fixture;
pub mod native_client_services;
@@ -232,7 +235,9 @@ mod native_client_world;
mod native_components;
mod native_configs;
mod native_criteria;
mod native_damage;
mod native_data_maps;
mod native_diagnostics;
mod native_entities;
mod native_entity_data;
mod native_entity_motion;
@@ -256,8 +261,15 @@ mod native_mobs;
mod native_motion;
mod native_mounts;
mod native_net;
mod native_npcs;
mod native_packets;
mod native_panel;
mod native_panel_recipes;
mod native_permissions;
mod native_player_fx;
mod native_player_reads;
mod native_player_state;
mod native_profiles;
mod native_projectiles;
mod native_quick_move;
mod native_recipes;
@@ -266,12 +278,17 @@ mod native_scene;
mod native_server;
mod native_shaders;
mod native_structures;
pub mod native_text_field;
mod native_ticks;
mod native_travel;
mod native_view_distance;
mod native_visuals;
mod native_world_border;
mod native_world_columns;
pub mod native_world_draw;
mod native_world_edit;
mod native_world_read;
mod native_world_seed;
mod natural_spawning;
mod nautili;
mod net_harness;
@@ -459,6 +476,7 @@ use horizon_world::{
pub use machines::native_fluids::NativeFluidTank;
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_chunk_snapshot::NativeChunkSnapshot;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
pub use native_components::NativeComponentError;
@@ -467,8 +485,10 @@ pub use native_criteria::NativeCriterion;
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
pub use native_inventories::NativeInventoryKind;
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_player_state::NativeEffect;
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_structures::{NativeStructureBlock, NativeStructureTemplate};
@@ -1112,6 +1132,24 @@ impl EntityObstructionWorld for BuildingObstruction {
.collect())
}
}
/// Whether a block's successful use is `InteractionResult.SUCCESS_SERVER`,
/// which a client does not predict: `useWithoutItem` of a bed, a light
/// block, a charged respawn anchor or a sign. Every other block use the
/// level answers with SUCCESS is `SUCCESS` (`SwingSource.PREDICTED`).
fn server_only_block_use(
block: &horizon_items::blocks::StateFacts,
branch: horizon_world::level::UseBranch,
) -> bool {
branch == horizon_world::level::UseBranch::WithoutItem
&& [
"AbstractBedBlock",
"LightBlock",
"RespawnAnchorBlock",
"SignBlock",
]
.iter()
.any(|class| block.has_class(class))
}
/// An entity's box (`EntityDimensions.makeBoundingBox`): `width` wide and
/// `height` tall, standing on `feet`.
fn point_box(feet: [f64; 3], width: f64, height: f64) -> Aabb {
@@ -1252,6 +1290,11 @@ impl LevelAccess for GameLevel<'_> {
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
}
}
fn redstone_change(&self, pos: BlockPos, old: i32, new: &mut i32) {
if self.native_authoritative {
self.native_block_redstone(self.dimension, pos, old, new);
}
}
fn allow_spread(
&self,
kind: horizon_world::level::Spread,
@@ -1574,6 +1617,9 @@ impl LevelAccess for GameLevel<'_> {
/// the count a recount of the players with that container open finds, and
/// the count it replaces. Only the block entities that keep a counter and
/// a scheduled recheck answer; anything else is `None`.
fn container_open(&self, pos: BlockPos) -> bool {
self.open_containers.contains(&(pos.x, pos.y, pos.z))
}
fn recheck_container_openers(&mut self, pos: BlockPos) -> Option<(i32, i32)> {
use horizon_world::level::classes::Class;
let data = self.behaviours.data(self.world.block(pos.x, pos.y, pos.z));
@@ -2240,6 +2286,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
}
impl PackResources {
/// Everything a pack overlay at `root` provides, over `assets` and `render`
@@ -2315,6 +2363,7 @@ impl PackResources {
names: Arc::new(Names::load(root)),
clouds: Arc::new(load_clouds(root)),
forcefield: Arc::new(load_forcefield(root)),
custom_sky: Arc::new(horizon_client::custom_sky::CustomSky::load(root)),
}
}
}
@@ -2874,6 +2923,9 @@ struct Game<'a> {
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
/// Swings of uses whose client predicts them (`SwingSource.PREDICTED`):
/// a server does not send these back to the player who swung.
predicted_swings: u32,
/// The first-person equip heights and the items the hands show (with
/// the slot each came from).
heights: horizon_client::first_person::HandHeights,
@@ -2904,6 +2956,8 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// OptiFine custom sky layers' eased brightness (`custom_sky.rs`).
custom_sky: custom_sky::CustomSkyView,
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
rain_sound_time: i32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
@@ -2966,6 +3020,8 @@ struct Game<'a> {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stands: horizon_client::join_seq::JoinList<horizon_client::entities::ArmorStand>,
/// Worn armor as of the last `collectEquipmentChanges` look (frost walker).
location_armor: [Option<ItemStack>; 4],
/// `Cushion` decoration entities (game/cushions.rs).
cushions: Vec<horizon_client::entities::Cushion>,
/// Entities of types Horizon does not simulate.
@@ -3001,6 +3057,9 @@ struct Game<'a> {
carts: horizon_client::join_seq::JoinList<minecarts::Minecart>,
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
/// What the mods asked for server players out of reach (`native_teleport_player`...),
/// done after the players' packets of the tick (native_travel.rs).
native_moves: Vec<(u128, native_travel::NativeMove)>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
@@ -3135,6 +3194,10 @@ struct Game<'a> {
native_configs: native_configs::NativeConfigs,
/// Native mods' messages waiting (`game/native_net.rs`).
native_net: native_net::NativeNet,
/// What native mods send one player, waiting for the hub (`game/native_player_fx.rs`).
native_player_fx: native_player_fx::NativePlayerFx,
/// Native mods' kicks waiting for the tick's end (`game/native_packets.rs`).
native_packets: native_packets::NativePackets,
/// The mod whose own screen is open (`Screen::Mod`).
native_mod_screen: Option<String>,
/// Meshed sections waiting for an upload budget (Horizon settings).
@@ -3281,6 +3344,9 @@ struct Game<'a> {
/// The last `use_hand` that ended the click did so with `FAIL`, which
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
use_failed: bool,
/// A mod canceled a use the acting guest's client predicted (a throw, a
/// placement): their inventory goes out whole when the turn ends.
guest_inventory_resend: bool,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
@@ -3333,6 +3399,9 @@ struct Game<'a> {
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
lan_acting_uuid: Option<u128>,
/// The player and the stack on their cursor as they clicked a native
/// menu, while `native_menu_clicked` hears it (`native_menu_cursor`).
native_click_cursor: Option<(u128, Option<horizon_client::items::ItemStack>)>,
/// Where the mods refused to prime TNT: the sound and game event the level
/// queued right after the prime are dropped (`prime_tnt_event`).
tnt_refused: Option<BlockPos>,
@@ -3341,6 +3410,9 @@ struct Game<'a> {
/// Their entity id, which the hub (often taken out of `lan` while they
/// act) would otherwise have to be asked for.
lan_acting_entity: Option<u32>,
/// The player `native_player_login` hears and their permission level, while it runs
/// (`native_player_permission`): the hub is out of reach there.
native_login_permission: Option<(u128, u8)>,
/// Command feedback to forward to a guest.
chat_capture: Option<Vec<horizon_client::text::Text>>,
/// Packets for the guest acting now, sent once its action returns.
@@ -3348,6 +3420,9 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// Players whose command tree the mods asked to send again
/// (`native_resend_commands`), sent in the next server tick.
native_resend_trees: Vec<u128>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
@@ -4374,6 +4449,15 @@ impl Game<'_> {
self.award_stat("dropped", &thrown.item, thrown.count as i32);
self.award_custom_stat("drop", 1);
let player = self.native_acting_id();
let mut cancel = false;
self.native_item_dropped_v2(player, &thrown, &mut cancel);
if cancel {
// The stack stays the player's: what doesn't fit is thrown after all.
if let Some(rest) = self.give(thrown) {
self.throw_from_eyes(rest, eye, true);
}
return;
}
self.native_item_dropped(player, &thrown.item, thrown.count);
self.throw_from_eyes(thrown, eye, true);
}
@@ -4650,7 +4734,7 @@ impl Game<'_> {
&& b.max_position().z > min[2];
// ItemEntity.playerTouch: no pickup delay, and the target (if any) is this player.
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
if self.native_pickup_refused(&item.stack) {
if self.native_pickup_refused(&item.stack, item.thrower, item.position) {
item.pickup_delay = 10;
kept.push(item);
continue;
@@ -5603,9 +5687,10 @@ impl Game<'_> {
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
// LevelEventHandler 2011 and 2012: a bee growing a crop, a
// turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
id: 2011 | 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
@@ -6162,11 +6247,12 @@ impl Game<'_> {
/// One falling block's `tick`: places or drops its block in its own tick,
/// so a block falling onto one that just landed lands on top of it. `i` is
/// its place in the list; the caller has checked it is ticking.
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
let entity_drops = self.ui.rules.bool("entity_drops");
let mut f = self.falling.remove(i);
let original = f.clone();
match self.fall_step(&mut f, entity_drops) {
match self.fall_step(&mut f, entity_drops, entities) {
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
Ok(FallStep::Gone) => {}
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
@@ -6179,7 +6265,12 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(
&mut self,
f: &mut FallingBlock,
entity_drops: bool,
entities: &[Aabb],
) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
@@ -6213,10 +6304,11 @@ impl Game<'_> {
false,
);
// On an error the caller keeps the block as it was before this tick.
// Entity.collide: the world border's walls stop it too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop it too.
let movement = collisions::EntityCollisions {
blocks: scene,
entities: &[],
entities,
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
}
.resolve_with_context(
@@ -6729,6 +6821,7 @@ impl Game<'_> {
}
if !claimed {
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
}
if claimed {
self.swing_arm();
@@ -6827,6 +6920,8 @@ impl Game<'_> {
let mut canceled = false;
horizon_client::spans::time("use_mods", || {
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
let hand = self.hand;
self.native_right_click_block_v2(hit.pos, hit.face, hand, &mut canceled);
});
if canceled {
// Nothing was used, so nothing consumed the action.
@@ -7012,7 +7107,13 @@ impl Game<'_> {
}
}
if result.should_swing() {
self.swing_arm();
// SUCCESS swings on the client too; the beds, light,
// respawn anchor and sign's own uses are SUCCESS_SERVER.
if server_only_block_use(self.res.facts().get(cake), branch) {
self.swing_arm();
} else {
self.swing_arm_predicted();
}
}
return Ok(true);
}
@@ -7252,6 +7353,13 @@ impl Game<'_> {
item: stack.item.to_string(),
can_use_game_master_blocks: self.can_use_game_master_blocks(),
};
// The mods, once the position and state are known: a canceled
// placement is a failed one, and the client's prediction is undone.
if self.block_place_canceled(&stack, &ctx) {
self.use_failed = true;
self.guest_inventory_resend = true;
return Ok(true);
}
let outcome = self.with_level(|b, level, ticks| b.place_block(level, ticks, &ctx));
let PlaceOutcome::Placed { pos, state } = outcome else {
self.process_level_events()?;
@@ -7290,7 +7398,8 @@ impl Game<'_> {
_ => {}
}
}
self.swing_arm();
// BlockItem.place's SUCCESS: the client swings with its own prediction.
self.swing_arm_predicted();
// BlockItem.place's sound is among the level events;
// SolidBucketItem.getPlaceSound replaces it with the bucket's own.
let solid_bucket = &*stack.item == "minecraft:powder_snow_bucket";
@@ -7317,6 +7426,30 @@ impl Game<'_> {
}
Ok(true)
}
/// Asks the mods whether the acting guest may place `stack` where and as
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
/// one canceled it. A game's own player is not asked, nor a placement
/// vanilla refuses.
fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let Some((pos, state)) =
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
else {
return false;
};
let mut cancel = false;
self.native_block_place(
uuid,
&name,
stack,
[pos.x, pos.y, pos.z],
state,
&mut cancel,
);
cancel
}
/// Asks the mods whether the acting guest may empty `stack` at `pos`
/// (`native_bucket_empty`): true when one cancelled it. A game's own
/// player is not asked.
@@ -7423,6 +7556,7 @@ impl Game<'_> {
);
instance.ambient = eff.ambient;
instance.visible = eff.show_particles;
instance.show_icon = eff.show_icon;
self.add_player_effect(instance);
}
}
@@ -7550,12 +7684,18 @@ impl Game<'_> {
.instrument(&stack)
.map(|i| i.use_ticks().max(0) as u32)
{
// InstrumentItem.getUseDuration: held up that long, nothing more.
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
// InstrumentItem.getUseDuration: held up that long. When it runs
// out, LivingEntity.stopUsingItem ends the use and causes the
// interaction vibration; letting go of the button already does.
if ticks + 1 < duration {
self.using = Some((slot, ticks + 1));
} else {
self.stop_using_item(&stack);
}
return;
}
if &*stack.item == "minecraft:brush" {
self.tick_brush(slot, ticks);
self.tick_brush(&stack, slot, ticks);
return;
}
if info.bundle {
@@ -9059,9 +9199,6 @@ impl Game<'_> {
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let show_hud = !self.ui.hide_gui;
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
// the action bar sit above it.
let mut bar_height = 39;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
@@ -9160,8 +9297,6 @@ impl Game<'_> {
} else {
self.draw_mount_hud(&mut batch, wi, hi)
};
// Without the survival bars only the mount's hearts raise it.
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
// The one bar above the hotbar: the mount's jump bar, the locator
// bar (with its background) or the experience bar, with the level
// number that a survival player always has.
@@ -9254,10 +9389,6 @@ impl Game<'_> {
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
hud_layout::paint(batch, &g.gui.atlas, &draws);
});
bar_height = self
.health_hud
.left_height
.max(self.health_hud.right_height);
// Hud.playAirBubblePoppedSound: Player.playSound (PLAYERS), heard
// by this client only.
if let Some((volume, pitch)) = self.health_hud.bubble_pop_sound.take() {
@@ -9273,7 +9404,7 @@ impl Game<'_> {
}
if !spectator {
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
g.draw_selected_name(batch, bar_height, !g.creative);
g.draw_selected_name(batch, !g.creative);
});
}
}
@@ -9284,7 +9415,7 @@ impl Game<'_> {
});
if show_hud {
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
g.draw_overlay_message(batch, bar_height);
g.draw_overlay_message(batch);
});
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
g.draw_title(batch);
@@ -10438,18 +10569,27 @@ impl Game<'_> {
if let Some((s, mob)) = session.as_mut() {
self.merchant_sync_in(s, *mob);
}
// The cartography table reads and makes the level's maps; a
// Crafting and cartography read and derive the level's maps; a
// server's (the client menu's ContainerLevelAccess.NULL) doesn't.
let level_maps = (kind == Kind::Cartography && self.remote.is_none()).then(|| {
let held: Vec<ItemStack> = before
let level_maps = (matches!(kind, Kind::Cartography | Kind::Crafting | Kind::Inventory)
&& self.remote.is_none())
.then(|| {
before
.iter()
.flatten()
.chain(self.inventory.iter().flatten())
.chain(self.carried.iter())
.cloned()
.collect();
self.level_maps(&held)
});
.collect::<Vec<_>>()
})
.filter(|held| {
kind == Kind::Cartography
|| held
.iter()
.filter_map(maps::map_id)
.any(|id| self.map(id).is_some())
})
.map(|held| self.level_maps(&held));
let next_map = if merchant_open { self.peek_map_id() } else { 0 };
let tag = |t: &str| self.structure_tag(t);
let maps = trading::GameMaps {
@@ -10559,6 +10699,9 @@ impl Game<'_> {
let predicting = self.remote.is_some();
if !predicting {
self.register_exploration_maps(made);
for (item, count) in std::mem::take(&mut menu.crafted_counts) {
self.award_stat("crafted", &item, count);
}
}
if let Some(s) = menu.merchant.take() {
self.merchant_sync_out(s);
@@ -12287,8 +12430,12 @@ impl Game<'_> {
if let Some(hit) = self.tick_mob {
return hit;
}
self.mob_hit_within(self.entity_reach)
}
/// [`Game::mob_hit`] out to `reach` instead of the entity interaction range
/// (`ProjectileUtil.getHitResultOnViewVector` with another range).
pub(super) fn mob_hit_within(&self, reach: f64) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
@@ -13804,6 +13951,9 @@ impl Game<'_> {
&& let Ok(new) = registry.resolve(&block)
{
self.level_set(pos, new)?;
if result.lower_fill_level {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
if let Some(sound) = result.sound {
self.audio.play(
@@ -14360,12 +14510,19 @@ impl Game<'_> {
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
/// `tryMergeToExisting` compares from the level random, then moves with
/// its own random.
fn spawn_orbs(&mut self, at: [f64; 3], mut value: u32) {
fn spawn_orbs(&mut self, at: [f64; 3], value: u32) {
self.spawn_orbs_toward(at, [0.0; 3], value);
}
/// `ExperienceOrb.awardWithDirection`: `award` whose fresh orbs start
/// `roughly` that way (`new ExperienceOrb(level, pos, roughly, value)`).
fn spawn_orbs_toward(&mut self, at: [f64; 3], roughly: [f64; 3], mut value: u32) {
while value > 0 {
let v = ExperienceOrb::split(value);
value -= v;
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
self.award_orb(at, v, merge_id);
if !self.merge_new_orb(at, v, merge_id) {
self.add_orb_toward(at, roughly, v);
}
}
}
/// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a
@@ -14373,6 +14530,12 @@ impl Game<'_> {
/// the new one as one more in its count (and restarts its age); otherwise
/// a fresh orb spawns.
fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) {
if !self.merge_new_orb(at, value, merge_id) {
self.add_orb(at, value);
}
}
/// `tryMergeToExisting`: true when an orb took the new one.
fn merge_new_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) -> bool {
let centre = Vec3::new(at[0], at[1], at[2]);
let half = Vec3::new(0.5, 0.5, 0.5);
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
@@ -14394,8 +14557,9 @@ impl Game<'_> {
if let Some(i) = target {
self.orbs[i].count += 1;
self.orbs[i].age = 0;
true
} else {
self.add_orb(at, value);
false
}
}
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
@@ -14469,10 +14633,33 @@ impl Game<'_> {
/// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from
/// the orb's own random.
fn add_orb(&mut self, at: [f64; 3], value: u32) {
let at = self.unstuck_orb(at);
self.add_orb_toward(at, [0.0; 3], value);
}
/// `new ExperienceOrb(level, pos, roughly, value)`: a random motion that
/// points against `roughly` is turned round, and the orb starts half its
/// size (0.25) along `roughly`, unstuck if it collides there.
fn add_orb_toward(&mut self, at: [f64; 3], roughly: [f64; 3], value: u32) {
let len_sqr = roughly.iter().map(|v| v * v).sum::<f64>();
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let len = len_sqr.sqrt();
let unit = if len < 1.0e-5 {
[0.0; 3]
} else {
roughly.map(|v| v / len)
};
let at = self.unstuck_orb([0, 1, 2].map(|a| at[a] + unit[a] * 0.25));
let mut r = dispenser::entity_random();
let _yaw = r.next_float();
let p = Vec3::new(at[0], at[1], at[2]);
let mut velocity = Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
);
let dot = roughly[0] * velocity.x + roughly[1] * velocity.y + roughly[2] * velocity.z;
if len_sqr > 0.0 && dot < 0.0 {
velocity = Vec3::new(-velocity.x, -velocity.y, -velocity.z);
}
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
@@ -14481,11 +14668,7 @@ impl Game<'_> {
id: self.next_entity,
position: p,
previous: p,
velocity: Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
),
velocity,
value,
age: 0,
on_ground: false,
@@ -15157,7 +15340,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -15198,7 +15381,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -16327,6 +16510,17 @@ impl Game<'_> {
// (`take_parent_vehicle`); here it stays in the chunk saves.
let mut host = host;
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
// Read-only fixture input, correlated with the real save receipt.
// Keep this identical on the untouched/fixed selector builds.
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
eprintln!(
"advancement selector model raw: {}",
serde_json::json!({"conn":g.conn,"name":g.name,
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
"game_time":self.world.game_time,
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
);
}
self.guest_attachments_into(g);
self.player_autosaves.enqueue(g, &self.world_root);
}
@@ -16892,6 +17086,7 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
self.custom_sky_frame([camera.x, camera.y, camera.z], &sky, (rain, thunder), title);
pr.lap("pr_celestial");
let (mut hand, hand_items) = if title || self.scoping() {
Default::default()
@@ -17567,6 +17762,7 @@ impl Game<'_> {
_ if self.screen == Screen::Dead => {
self.death_key(key)?;
}
_ if self.screen == Screen::Stats => self.stats_key(raw),
_ => {}
}
Ok(())
@@ -18209,13 +18405,7 @@ impl ApplicationHandler for Game<'_> {
return;
}
};
if fullscreen && window.is_maximized() {
window.set_maximized(false);
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor());
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
}
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
@@ -18627,6 +18817,19 @@ impl Game<'_> {
}
}
}
// Title boxes keep their text on the key path. A field hook takes
// it through the same per-character editor as IME and control input.
if !canceled
&& control::title_owns_text(&self.screen, self.title.is_some())
&& self.types_text()
&& let Some(text) = key_event.text.as_deref()
&& let Some(result) = self.type_text_field_input(text)
{
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
key_event.text = None;
}
if canceled {
// charTyped is a separate callback: the characters still
// reach the text box, without the key's own effects. Options'
@@ -20221,6 +20424,7 @@ impl<'a> Game<'a> {
level_up_sound: Default::default(),
swing: Default::default(),
swings_started: 0,
predicted_swings: 0,
heights: Default::default(),
sway: Default::default(),
visible_hands: [(0, None), (0, None)],
@@ -20238,6 +20442,7 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
custom_sky: Default::default(),
rain_sound_time: 0,
lightning_seen: [0; 2],
sleeping: None,
@@ -20273,6 +20478,7 @@ impl<'a> Game<'a> {
map_frame_model: prepared.map_frame_model,
glow_map_frame_model: prepared.glow_map_frame_model,
armor_stands: Default::default(),
location_armor: Default::default(),
cushions: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
@@ -20292,6 +20498,7 @@ impl<'a> Game<'a> {
entity_models: prepared.entity_models,
carts: Default::default(),
watched_vehicles: Vec::new(),
native_moves: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
maps: maps::Maps::default(),
@@ -20390,6 +20597,8 @@ impl<'a> Game<'a> {
native_lifecycle: Default::default(),
native_configs: Default::default(),
native_net: Default::default(),
native_player_fx: Default::default(),
native_packets: Default::default(),
native_mod_screen: None,
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
@@ -20447,6 +20656,7 @@ impl<'a> Game<'a> {
block_reach: 4.5,
guest_use: None,
use_failed: false,
guest_inventory_resend: false,
lan_events: Vec::new(),
lan_songs: Vec::new(),
lan_cushion_breaks: Vec::new(),
@@ -20468,14 +20678,17 @@ impl<'a> Game<'a> {
lan_break_pos: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
native_resend_trees: Vec::new(),
guest_entity_events: Vec::new(),
guest_equipment_breaks: Vec::new(),
guest_player_sounds: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
native_click_cursor: None,
tnt_refused: None,
lan_acting_permission: None,
lan_acting_entity: None,
native_login_permission: None,
chat_capture: None,
admin_feedback: Vec::new(),
console_broadcast: None,
@@ -21893,7 +22106,7 @@ fn build_renderer(
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
shader_pack: shader_packs::PickedPack,
filtering: (i32, i32),
) -> Result<Renderer> {
let assets = &pack.render;
@@ -21932,7 +22145,9 @@ fn build_renderer(
api,
translucent_batching,
wide_frame,
shader_pack,
shader_pack.draws_flat,
shader_pack.linear,
shader_pack.hdr_float,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -21945,6 +22160,7 @@ fn build_renderer(
pack.item_renderer
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
.hud
@@ -22004,6 +22220,8 @@ fn loading_renderer(
// An 8-bit frame, which HDR output takes as well.
false,
false,
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -22424,13 +22642,7 @@ impl Loading {
return;
}
};
if fullscreen && window.is_maximized() {
window.set_maximized(false);
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor());
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
}
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
@@ -22621,7 +22833,7 @@ impl Loading {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -22905,11 +23117,17 @@ pub fn run_with() -> Result<()> {
{
return Ok(());
}
// The packets the server sends its players reach the mods that ask (native_packets.rs).
let _ = horizon_client::lan::conn::SERVER_PACKET.set(Game::native_server_packet);
let _ = horizon_client::lan::conn::ENTITY_DATA_TO.set(Game::native_entity_data_to);
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
let _ = horizon_client::lan::hub::PLAYER_MOVE.set(Game::native_player_move);
let _ = horizon_client::lan::hub::PLAYER_TELEPORT.set(Game::native_player_teleport);
let _ = horizon_client::block_contact::TRAMPLE.set(Game::native_trample);
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
@@ -22937,7 +23155,7 @@ pub fn run_with() -> Result<()> {
{
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
horizon_client::spans::enable(options.spikes.is_some());
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
if options.execution_role.is_client()
@@ -24211,3 +24429,6 @@ fn write_level_for_role(
level.write_without_player(dir)
}
}
#[cfg(test)]
mod vibration_tests;
+60 -41
View File
@@ -392,6 +392,15 @@ impl Game<'_> {
let tree = self.adv.tree.clone();
self.adv.progress = progress;
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
// `PlayerAdvancements.checkForAutomaticTriggers`: an advancement with no
// criteria is never done (`AdvancementRequirements.test` is false), but
// its rewards are granted as the progress loads. Vanilla's `award(holder, "")` finds no `""`
// criterion to grant and so returns without a toast or an announcement;
// the reward grant is the whole effect, and it happens again on the next
// load because there is no criterion to record it.
for index in tree.automatic() {
self.grant_rewards(index);
}
}
pub(super) fn save_advancements(&self) {
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
@@ -920,47 +929,7 @@ impl Game<'_> {
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_done(player, &name, &a.id);
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
if let Some(function) = &a.rewards.function {
self.run_advancement_reward_function(function);
}
let unlocked = self.grant_rewards(index);
if let Some(d) = &a.display {
// A network player's client shows its own toast.
if d.show_toast
@@ -1012,6 +981,56 @@ impl Game<'_> {
}
self.recipe_unlocked(&unlocked);
}
/// `AdvancementRewards.grant`: the experience, recipes, loot and function of
/// a finished advancement, and the recipes it unlocked for the
/// `recipe_unlocked` trigger, which the caller runs after its toast and
/// announcement. Returns those recipes.
fn grant_rewards(&mut self, index: usize) -> Vec<String> {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
if let Some(function) = &a.rewards.function {
self.run_advancement_reward_function(function);
}
unlocked
}
/// `InventoryChangeTrigger`, checked when the inventory changes.
pub(super) fn check_inventory_trigger(&mut self) {
use std::hash::{Hash, Hasher};
+3 -1
View File
@@ -1647,7 +1647,9 @@ impl Game<'_> {
}
if text == "\\b" {
title.backspace();
} else {
} else if let Some(result) = self.type_text_field_input(text) {
result?;
} else if let Some(title) = self.title.as_mut() {
title.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
+58 -1
View File
@@ -151,6 +151,13 @@ impl Game<'_> {
.block_entities
.put(key, BlockEntity::Beehive(hive));
if stored {
let state = self.world.block(at[0], at[1], at[2]);
self.post_block_game_event(
"minecraft:block_change",
at,
Some(Who::Mob(mob)),
Some(state),
);
let kind = self.mobs[i].kind.id.clone();
let block = self
.res
@@ -268,7 +275,9 @@ impl Game<'_> {
} else if let Some(rest) = self.give(bottle)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
// `player.drop(stack, false, PREDICTED)`: thrown from below
// the eyes along the view, with no thrower.
self.throw_from_eyes(rest, eye, false);
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
@@ -368,3 +377,51 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod honey_overflow_tests {
use super::*;
/// `BeehiveBlock.useItemOn`: a honey bottle that no longer fits is
/// `player.drop(stack, false, PREDICTED)`: from 0.3 below the eyes,
/// thrown along the view, with no thrower (#11377).
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_honey_bottle_that_does_not_fit_is_thrown_ahead() {
let mut game = super::super::native_machines::tests::server_game();
let registry = &game.pack.assets.registry;
let hive = registry.default_state("minecraft:beehive").unwrap();
let mut full = registry.state(hive).unwrap().clone();
full.properties.insert("honey_level".into(), "5".into());
let full = registry.id(&full).unwrap();
game.world.set(8, 64, 10, full).unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 64.0, 8.5))
.unwrap(),
);
// Facing south (+z), towards the hive.
game.yaw = 0.0;
game.pitch = 0.0;
for slot in game.inventory.iter_mut() {
*slot = Some(ItemStack::new("minecraft:stone", 64));
}
game.selected = 0;
let bottles = ItemStack::new("minecraft:glass_bottle", 2);
game.inventory[0] = Some(bottles.clone());
game.dropped.clear();
assert!(game.use_beehive([8, 64, 10], Some(&bottles)).unwrap());
let eye = game.eye_position().unwrap();
let honey: Vec<_> = game
.dropped
.iter()
.filter(|e| &*e.stack.item == "minecraft:honey_bottle")
.collect();
assert_eq!(honey.len(), 1);
let e = honey[0];
assert!((e.position.y - (eye[1] - f64::from(0.3f32))).abs() < 1e-9);
assert!(e.velocity.z > 0.2, "thrown ahead: {:?}", e.velocity);
assert_eq!(e.thrower, None);
assert_eq!(e.pickup_delay, 40);
}
}
@@ -506,6 +506,69 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn command_view_uses_live_and_pending_boat_leashes_not_raw_tags() {
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
let (id, uuid, actual_holder, unknown) =
(1, [326, 1, 0, 1], [326, 2, 0, 1], [111, 222, 333, 444]);
game.boats
.push(Boat::new(id, "oak".into(), Vec3::new(6.5, 100.0, 4.5), 0.0));
game.persist.uuids.insert(id, uuid);
game.level.player_uuid = Some([326, 3, 0, 1]);
let read = |game: &mut Game<'_>| game.entity_nbt(Target::Boat(id)).unwrap();
assert!(!game.persist.raw.contains_key(&id));
assert_eq!(
read(&mut game).get("UUID"),
Some(&Tag::IntArray(uuid.to_vec()))
);
assert!(read(&mut game).get("leash").is_none());
game.boats[0].leash = Some(Holder::Player);
game.boats[0].leash_player = Some(actual_holder);
let expected = Tag::compound([("UUID", Tag::IntArray(actual_holder.to_vec()))]);
assert_eq!(read(&mut game).get("leash"), Some(&expected));
assert_eq!(read(&mut game).get("leash"), Some(&expected));
let delayed = Tag::compound([("UUID", Tag::IntArray(unknown.to_vec()))]);
game.persist
.raw
.insert(id, Tag::compound([("leash", delayed.clone())]));
assert_eq!(read(&mut game).get("leash"), Some(&expected));
game.boats[0].leash = None;
game.boats[0].leash_player = None;
game.boats[0].leash_pending = Some(Pending::Uuid(unknown));
assert_eq!(read(&mut game).get("leash"), Some(&delayed));
game.boats[0].leash_pending = None;
assert!(read(&mut game).get("leash").is_none());
}
#[test]
fn a_boat_is_centred_half_its_height_up() {
let b = Boat::new(1, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 0.0);
@@ -26,6 +26,9 @@ pub(super) struct BossState {
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
/// The bars server mods show single players (`native_boss_bar`): by
/// player UUID, each bar's id and bar, in the order they were added.
pub(super) native: HashMap<u128, Vec<(u128, horizon_client::mod_api::NativeBossBar)>>,
}
impl BossState {
@@ -551,6 +554,20 @@ impl Game<'_> {
scope: Scope::Global,
});
}
// Server mods' bars for this player alone (`native_boss_bar`).
for (id, bar) in self.bosses.native.get(&viewer.uuid).into_iter().flatten() {
out.push(Offer {
id: *id,
name: horizon_client::lan::data::component_nbt(&bar.name.component()),
progress: bar.progress,
color: bar.color,
overlay: bar.overlay,
darken: bar.darken,
music: bar.music,
fog: bar.fog,
scope: Scope::Global,
});
}
if viewer.here
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
{
+35 -5
View File
@@ -40,19 +40,49 @@ pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
pub(super) fn tick_brush(
&mut self,
stack: &horizon_client::items::ItemStack,
slot: usize,
ticks: u32,
) {
// No block under the crosshair: vanilla calls releaseUsingItem, which
// stops the use and causes the interaction vibration.
let Some(hit) = self.pick() else {
self.using = None;
self.stop_using_item(stack);
return;
};
// `BrushItem.onUseTick` aims with `getHitResultOnViewVector` out to the
// block interaction range, for mobs too: a mob between the player and
// the block is nearer, so the hit is not a block and the use releases
// the same way. A guest acts on its client's targeted block, whose view
// we cannot trace here, so only the local aim runs the entity test.
if self.forced_hit.is_none()
&& let Some((eye, _)) = self.aim()
&& let Some((_, mob, _)) = self.mob_hit_within(self.block_reach)
{
let block = (
hit.point[0] - eye[0],
hit.point[1] - eye[1],
hit.point[2] - eye[2],
);
if mob <= (block.0 * block.0 + block.1 * block.1 + block.2 * block.2).sqrt() {
self.stop_using_item(stack);
return;
}
}
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
// The use ends after USE_DURATION; stopUsingItem causes the vibration
// (vanilla's tickUsingItem), the held button starts it again.
if elapsed < USE_DURATION {
self.using = Some((slot, elapsed));
} else {
self.stop_using_item(stack);
}
}
/// One stroke on the block aimed at: dust (this window only), the brush
@@ -252,6 +252,7 @@ mod tests {
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[20.5, 64.0, 0.5],
@@ -257,6 +257,76 @@ impl MapData {
#[cfg(test)]
mod tests {
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn crafting_map_takes_use_real_saved_maps() {
use crate::game::{Audio, Options, Settings, load_resources};
use horizon_client::menu::{Env, Input, Kind, Menu};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&crate::game::Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
for mode in [Input::Swap, Input::Pickup, Input::QuickMove] {
let source = game.new_map_id(MapData::fresh(4.5, 4.5, 0, "minecraft:overworld"));
let mut input = ItemStack::new("minecraft:filled_map", 1);
input.data_mut().map_id = Some(source);
let maps = game.level_maps([&input]);
let env = Env {
maps: Some(&maps),
..game.menu_env()
};
let mut menu = Menu::new(Kind::Crafting);
for i in [1, 2, 3, 4, 6, 7, 8, 9] {
menu.set_slot(&env, i, Some(ItemStack::new("minecraft:paper", 1)));
}
menu.set_slot(&env, 5, Some(input));
let preview = menu.slot(0).expect("real scaling recipe admitted");
assert_eq!(preview.data().and_then(|d| d.map_id), Some(source));
assert_eq!(preview.data().and_then(|d| d.map_post_processing), Some(1));
menu.click(&env, 0, if mode == Input::Swap { 2 } else { 0 }, mode)
.unwrap();
let output = match mode {
Input::Swap => menu.player_item(2),
Input::Pickup => menu.carried.as_ref(),
_ => menu.player_item(8),
}
.expect("crafted map remains present");
assert_eq!(output.count, 1);
assert_eq!(output.data().and_then(|d| d.map_id), Some(source + 1));
assert_eq!(output.data().and_then(|d| d.map_post_processing), None);
assert!((1..10).all(|i| menu.slot(i).is_none()));
game.register_made_maps(maps);
let original = game.map(source).unwrap();
assert_eq!((original.scale, original.center), (0, [0, 0]));
let derived = game.map(source + 1).unwrap();
assert_eq!((derived.scale, derived.center), (1, [64, 64]));
}
}
use super::*;
use crate::game::maps::{Decoration, Key};
+67 -11
View File
@@ -720,10 +720,13 @@ impl Game<'_> {
.as_ref()
.filter(|d| !command || d.command)
.map(|d| d.text.clone());
self.open_chat(draft.as_deref().unwrap_or(prefix));
self.open_chat(draft.as_deref().unwrap_or(prefix), draft.is_some());
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
}
pub(super) fn open_chat(&mut self, initial: &str) {
/// Open the chat box on `initial`; `is_draft` when that text is the saved
/// draft (`ChatComponent.createScreen`), which draws grey italic until it
/// is edited.
pub(super) fn open_chat(&mut self, initial: &str, is_draft: bool) {
if self.player.is_none() {
return;
}
@@ -743,6 +746,7 @@ impl Game<'_> {
&m,
&c,
initial,
is_draft,
self.ui.chat.recent.len(),
b.width as i32,
b.height as i32,
@@ -757,6 +761,36 @@ impl Game<'_> {
self.right = false;
self.capture(false);
}
/// The open chat box's text and its cursor, counted in characters; `None`
/// while the chat screen isn't open. API @API:chat_input@.
#[allow(dead_code)]
pub(super) fn native_chat_input(&self) -> Option<(String, usize)> {
let screen = self.ui.chat_screen.as_ref()?;
Some((screen.input.value.clone(), screen.input.cursor))
}
/// Replaces the open chat box's text and puts the cursor at `cursor`
/// (characters, clamped to the text), as an edit: completions refresh as
/// they do for typing. What typing can't put in the box isn't put there
/// either (`chat_box_text`), and the 256-character limit holds. An error
/// while the chat screen isn't open. API @API:chat_input@.
#[allow(dead_code)]
pub(super) fn native_set_chat_input(
&mut self,
text: &str,
cursor: usize,
) -> Result<(), String> {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
let Some(screen) = self.ui.chat_screen.as_mut() else {
return Err("the chat isn't open".into());
};
screen.suggest_command(&m, &c, &chat_box_text(text));
screen.input.move_cursor_to(&m, cursor, false);
Ok(())
}
pub(super) fn close_chat(&mut self, reason: ChatExit) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
@@ -836,11 +870,13 @@ impl Game<'_> {
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat(ChatExit::Interrupted);
if !text.is_empty() {
self.open_chat(&text);
// InBedChatScreen.onPlayerWokeUp: a new ChatScreen with
// the typed text, not a draft.
self.open_chat(&text, false);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.open_chat("", false);
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
@@ -1627,23 +1663,23 @@ impl Game<'_> {
pub(super) fn toggle_hide_gui(&mut self) {
self.ui.hide_gui = !self.ui.hide_gui;
}
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
pub(super) fn take_screenshot(&mut self) {
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
/// file it will write, `None` without a window to draw.
pub(super) fn take_screenshot(&mut self) -> Option<std::path::PathBuf> {
let dir = horizon_client::logging::player_dir().join("screenshots");
if std::fs::create_dir_all(&dir).is_err() {
return;
return None;
}
let name = screenshot_name(&dir);
let Some(renderer) = self.renderer.as_mut() else {
return;
};
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
self.ui.screenshot_message = Some(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
/// `key.screenshot` mapping (F2 by default) takes the picture before a
@@ -1699,6 +1735,14 @@ impl Game<'_> {
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
/// What a mod may write into the chat box: the characters `ChatScreen`'s
/// typing lets in (`StringUtil.isAllowedChatCharacter`: no control
/// characters, no DEL, no section sign).
fn chat_box_text(text: &str) -> String {
text.chars()
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
@@ -1730,6 +1774,17 @@ const LINK_COPY: usize = 1;
mod tests {
use super::*;
/// A mod's text reaches the chat box as typing would leave it: emoji and
/// accents stay, control characters, DEL and the section sign go.
#[test]
fn a_mods_chat_text_keeps_what_typing_allows() {
assert_eq!(
super::chat_box_text("hi :smile: → 😀 é"),
"hi :smile: → 😀 é"
);
assert_eq!(super::chat_box_text("a\nb\tc\u{7f}d§6e"), "abcd6e");
}
/// `ChatScreen.shouldDiscardDraft`.
#[test]
fn draft_discard_follows_the_exit_reason() {
@@ -2057,6 +2112,7 @@ mod tests {
&m,
&c,
"",
false,
0,
width as i32,
height as i32,
@@ -599,6 +599,47 @@ impl Game<'_> {
}
// Java produced the fixture file before this world was opened; map_state loads it.
"crafter_max_map_fixture" => {}
"boat_interact" => {
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("boat click: missing UUID")?
.replace('-', ""),
16,
)?;
self.in_owner_level(|g| -> Result<()> {
let id = g
.boat_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
.ok_or("boat click: missing live boat")?;
let owner = g.serving.as_ref().ok_or("boat click: no server")?.owner;
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.map(|p| p.conn)
})
.ok_or("boat click: owner not connected")?;
let mut host = g.lan.take().ok_or("boat click: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::Interact(
horizon_protocol::packets::play::Interact {
id: id as i32,
hand: 0,
location: [0.0; 3],
secondary: false,
},
),
);
g.lan = Some(host);
Ok(())
})?;
}
"use_item" => {
let owner = self
.serving
@@ -685,6 +726,100 @@ impl Game<'_> {
);
out.push('\n');
}
"craft_menu_click" | "craft_menu_state" | "craft_menu_close" => {
let owner = self.serving.as_ref().ok_or("no server")?.owner;
let state = self.in_owner_level(|g| -> Result<Value> {
let conn = g.lan.as_ref().and_then(|h| h.hub.guests.iter()
.find(|p| p.uuid == owner && p.placed).map(|p| p.conn))
.ok_or("craft fixture: no connected owner")?;
let mut host = g.lan.take().ok_or("no hub")?;
let result = (|| -> Result<Value> {
let (window, revision, _, _) = host.play.get(&conn)
.and_then(|p| p.crafting.table.as_ref())
.ok_or("craft fixture: crafting menu not open")?;
let (window, revision) = (*window, *revision);
if op["op"] == "craft_menu_click" {
// Revision last sent by the real table-content sync, never a fixed 0.
eprintln!("craft fixture click: window={window}, revision={revision}");
g.lan_guest_packet(&mut host, conn,
horizon_protocol::packets::play::Serverbound::ContainerClick(
horizon_protocol::packets::play::ContainerClick {
container: window, state: revision,
slot: op["slot"].as_i64().ok_or("click slot")? as i16,
button: op["button"].as_i64().unwrap_or(0) as i8,
input: op["input"].as_i64().unwrap_or(0) as i32,
changed: Vec::new(), carried: None,
}));
return Ok(Value::Null);
}
if op["op"] == "craft_menu_close" {
g.lan_guest_packet(&mut host, conn,
horizon_protocol::packets::play::Serverbound::ContainerClose(
horizon_protocol::packets::play::ContainerClose { container: window }));
return Ok(Value::Null);
}
let (_, _, menu, _) = host.play.get(&conn)
.and_then(|p| p.crafting.table.as_ref()).unwrap();
if menu.kind() != horizon_client::menu::Kind::Crafting {
return Err("craft fixture: wrong menu kind".into());
}
let guest = host.hub.guest(conn).ok_or("no guest")?;
let stack = |s: Option<&horizon_client::items::ItemStack>| {
s.map(|s| json!([&*s.item, s.count,
s.data().and_then(|d| d.map_id),
s.data().and_then(|d| d.map_post_processing)]))
};
// Table's ten slots plus the owner's live inventory in menu order.
let slots: Vec<_> = (0..10).map(|i| stack(menu.slot(i)))
.chain((9..36).chain(0..9).map(|i| stack(guest.inventory[i].as_ref())))
.collect();
Ok(json!({"window": window, "kind": "minecraft:crafting",
"slots": slots, "carried": stack(guest.carried.as_ref()).unwrap_or_else(|| json!([]))}))
})();
g.lan = Some(host);
result
})?;
if op["op"] == "craft_menu_state" {
let record = json!({"cmd": format!("#craft_menu {}", op["label"].as_str().unwrap_or("")),
"state": state, "messages": [], "results": []});
out.push_str(&record.to_string());
out.push('\n');
}
}
"map_files" => {
let state = self.harness_owner(|g| {
use horizon_world::nbt::Tag;
g.save_maps();
let directory = g.world_root.join("data/minecraft/maps");
let maps: Vec<_> = op["ids"].as_array().ok_or("map IDs")?.iter().map(|id| {
let path = directory.join(format!("{}.dat", id.as_i64().unwrap()));
horizon_client::save::read_dat(&path).map(|root| {
let data = root.get("data").unwrap_or(&root);
let number = |key| match data.get(key) {
Some(Tag::Int(v)) => *v, Some(Tag::Byte(v)) => i32::from(*v), _ => 0,
};
let colors = match data.get("colors") {
Some(Tag::ByteArray(bytes)) => Some(bytes.len()), _ => None,
};
json!({"scale": number("scale"), "locked": number("locked") != 0,
"center": [number("xCenter"), number("zCenter")],
"dimension": horizon_client::save::text(data.get("dimension")),
"byte_array_length": colors})
})
}).collect();
let last = horizon_client::save::read_dat(&directory.join("last_id.dat"))
.and_then(|tag| match tag.get("data").and_then(|d| d.get("map")) {
Some(Tag::Int(v)) => Some(*v), _ => None,
});
Ok(json!({"maps": maps, "last_id": last}))
})?;
out.push_str(
&json!({"cmd": "#map_files", "state": state,
"messages": [], "results": []})
.to_string(),
);
out.push('\n');
}
"map_state" => {
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
let value = self.harness_owner(|g| {
@@ -718,6 +853,118 @@ impl Game<'_> {
out.push_str(&record.to_string());
out.push('\n');
}
"merchant_saved_stats" => {
let (state, raw) = self.harness_owner(|g| {
g.load_stats();
if !g.stats.loaded { return Err("merchant stats not loaded".into()); }
g.save_stats();
let uuid = g.level.player_uuid.ok_or("merchant stats UUID")?;
let path = super::stats::player_json(&g.world_root, "stats", uuid);
let raw = std::fs::read_to_string(path)?;
let saved: Value = serde_json::from_str(&raw)?;
let value = |ty: &str, key: &str| saved["stats"][ty][key].as_i64().unwrap_or(0);
Ok((json!({"uuid":uuid,"books":value("minecraft:crafted","minecraft:bookshelf"),
"glass":value("minecraft:crafted","minecraft:glass"),
"trades":value("minecraft:custom","minecraft:traded_with_villager")}), raw))
})?;
let label = op["label"].as_str().unwrap_or("");
std::fs::write(
output.with_file_name(format!("native-merchant-stats-{label}.json")),
raw,
)?;
out.push_str(
&json!({"cmd":format!("#merchant_saved_stats {label}"),
"messages":[],"results":[],"state":state})
.to_string(),
);
out.push('\n');
}
"merchant_action" | "merchant_state" => {
let observe = op["op"] == "merchant_state";
let state = self.in_owner_level(|g| -> Result<Value> {
let mut host = g.lan.take().ok_or("merchant oracle: no hub")?;
let result = (|| -> Result<Value> {
let owner = g
.serving
.as_ref()
.ok_or("merchant oracle: no server")?
.owner;
let conn = host
.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.ok_or("merchant oracle: owner not connected")?
.conn;
if observe {
return Ok(g
.harness_merchant(&host, conn)
.ok_or("merchant oracle: no live menu")?
.2);
}
use horizon_protocol::packets::play::{self, Serverbound as S};
let packet = match op["action"].as_str().unwrap_or("") {
"open" => {
let uuid = u128::from_str_radix(
op["uuid"]
.as_str()
.ok_or("merchant oracle: UUID")?
.replace('-', "")
.as_str(),
16,
)?;
let Some(super::commands::Target::Mob(id)) =
g.target_by_uuid(uuid)
else {
return Err("merchant oracle: missing live mob".into());
};
S::Interact(play::Interact {
id: id as i32,
hand: 0,
location: [0.0; 3],
secondary: false,
})
}
"select" => S::SelectTrade(play::SelectTrade {
item: op["index"].as_i64().unwrap_or(0) as i32,
}),
"click" => {
let (container, state, _) = g
.harness_merchant(&host, conn)
.ok_or("merchant oracle: no menu")?;
S::ContainerClick(play::ContainerClick {
container,
state,
slot: op["slot"].as_i64().ok_or("merchant oracle: slot")?
as i16,
button: op["button"].as_i64().unwrap_or(0) as i8,
input: op["input"].as_i64().unwrap_or(0) as i32,
changed: Vec::new(),
carried: None,
})
}
"close" => S::ContainerClose(play::ContainerClose {
container: g
.harness_merchant(&host, conn)
.ok_or("merchant oracle: no menu")?
.0,
}),
_ => return Err("merchant oracle: unknown action".into()),
};
eprintln!("merchant action receipt: action={} owner={owner:032x} conn={conn} menu={:?} clock={}",
op["action"], g.harness_merchant(&host, conn).map(|(w, r, _)| (w, r)), g.world.game_time);
g.lan_guest_packet(&mut host, conn, packet);
Ok(Value::Null)
})();
g.lan = Some(host);
result
})?;
if observe {
out.push_str(&json!({"cmd":format!("#merchant_state {}",op["label"].as_str().unwrap_or("")),
"messages":[],"results":[],"state":state}).to_string());
out.push('\n');
}
}
"menu_state" => {
let (_, own_slots, _) = self.harness_owner_menu()?;
let record = json!({"cmd": format!("#menu_state {}", op["label"].as_str().unwrap_or("")),
+63 -5
View File
@@ -906,7 +906,7 @@ impl Game<'_> {
/// the entity lists (a kind added there joins `@e` once it names its
/// `Target`), then the kinds that are not on the walk. An
/// unsorted selector lists them in that order.
fn level_candidates(&self) -> Vec<Target> {
pub(in crate::game) fn level_candidates(&self) -> Vec<Target> {
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
let mut candidates = Vec::new();
for_each_entity!(entity_lists_of!(self), entity => {
@@ -1559,6 +1559,7 @@ impl Game<'_> {
// there at once: the arrival they were waiting for elsewhere
// in it (whose chunks lose their tickets) is abandoned.
if let Some(conn) = self.lan_acting {
horizon_client::lan::hub::teleport_cause(Some("command"));
self.server_trips.retain(|trip| !trip.is(conn));
}
let from = self
@@ -1816,6 +1817,7 @@ impl Game<'_> {
at: pos,
rotation,
vehicle: None,
cause: Some("command"),
});
Ok(true)
}
@@ -4009,6 +4011,7 @@ impl Game<'_> {
));
Ok(count as i32)
}
/// `SayCommand.register`: resolve the source name and broadcast unsigned announcements.
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
Some(Arg::Str(s)) => s.clone(),
@@ -4016,13 +4019,47 @@ impl Game<'_> {
};
let message = self.expand_selectors(&raw);
// CommandSourceStack.getDisplayName: the executing entity's name.
let name = match self.command_source.map(|s| s.entity) {
Some(Some(t)) => self.target_name(t),
Some(None) => Text::literal("Server"),
let name = match self.command_source {
Some(source) => match source.entity {
Some(target) => self.target_name(target),
None => source
.block
.and_then(|pos| match self.world.block_entities.get(&pos) {
Some(horizon_client::containers::BlockEntity::CommandBlock(block)) => {
Some(block.name())
}
_ => None,
})
.or_else(|| {
source.cart.and_then(|id| {
self.carts
.iter()
.find(|cart| cart.id == id)
.map(|cart| cart.command.name())
})
})
.unwrap_or_else(|| Text::literal("Server")),
},
None => self.player_display_name(),
};
// A player's command whose output is captured (a function, `execute …
// run say`, the command harness) gets the announcement with that
// output, in order; everyone else gets the broadcast.
let captured_for = self.chat_capture.as_ref().and(self.lan_acting);
let line = match id {
"say" => Text::translate("chat.type.announcement", &[name, message]),
"say" => {
// SayCommand broadcasts independently of the command source's feedback.
if let Some(host) = &self.lan {
use horizon_client::lan::data::component_nbt;
host.hub.broadcast_source_chat(
"say_command",
component_nbt(&name.component()),
component_nbt(&message.component()),
captured_for,
);
}
Text::translate("chat.type.announcement", &[name, message])
}
"me" => Text::translate("chat.type.emote", &[name, message]),
_ => {
let _ = self.entities(ctx, "targets", true)?;
@@ -4042,6 +4079,27 @@ impl Game<'_> {
.italic()
}
};
if id == "say" {
if self.lan.is_some() && self.options.dedicated.is_some() {
self.lan_log_event(horizon_client::lan::hub::HostEvent::Chat {
line: line.plain(),
secure: false,
});
}
if self.lan.is_some() && self.chat_capture.is_some() {
// The broadcast skipped the acting player: their copy goes with
// the captured output. Without one (the console), the broadcast
// reached everyone and the console logged it above.
if captured_for.is_some()
&& let Some(capture) = self.chat_capture.as_mut()
{
capture.push(line);
}
return Ok(1);
}
self.player_message(line);
return Ok(1);
}
if let Some(capture) = self.chat_capture.as_mut() {
capture.push(line.clone());
}
@@ -136,8 +136,9 @@ impl Game<'_> {
}
Target::Boat(id) => {
let uuid = self.uuid_for(id);
let b = self.boats.iter().find(|b| b.id == id)?;
entity_nbt::boat_to_nbt(
let i = self.boats.iter().position(|b| b.id == id)?;
let b = &self.boats[i];
let mut tag = entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
@@ -145,7 +146,10 @@ impl Game<'_> {
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
)
);
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
self.write_boat_leash(&mut tag, i);
tag
}
Target::Cart(id) => {
let uuid = self.uuid_for(id);
@@ -65,8 +65,9 @@ impl Game<'_> {
for t in self.living_targets(ctx)? {
let changed = match t {
Target::Player | Target::Guest(_) => {
let mut instance = EffectInstance::new(effect.id, duration, amplifier);
instance.visible = particles;
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
let instance = EffectInstance::new(effect.id, duration, amplifier)
.with_flags(false, particles);
self.for_player(t, |g| g.add_player_effect(instance))
.unwrap_or(false)
}
@@ -77,6 +78,14 @@ impl Game<'_> {
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
let changed =
m.add_effect_with(name, duration, amplifier, false, particles);
// MobEffect.onEffectAdded: an omen's sound, at the mob's feet.
if new
&& changed
&& let Some(sound) = super::super::potions::sound_on_added(name)
{
let at = [m.position.x, m.position.y, m.position.z];
self.audio.play(sound, Some(at), 1.0, 1.0);
}
self.mod_effect_added(name, Some(id as i32), amplifier, new);
changed
}
@@ -1156,7 +1156,7 @@ impl Game<'_> {
Ok((true, count))
}
/// The biome name at a block position.
fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
pub(in crate::game) fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
let chunk = self.world.chunks.get(&(p[0] >> 4, p[2] >> 4))?;
// The world's table (`world_biomes!`): a packed biome's id is past
// the built-in list, and `Biomes::get` would clamp it to the last
@@ -1,6 +1,10 @@
//! `RaidCommand`: `/raid start|stop|check|spawnleader|setomen|glow|sound`.
use super::{Game, R, Text, err};
use horizon_client::{command::Context, mobs::raider};
use horizon_client::{
command::{Arg, Context},
mobs::raider,
};
use horizon_core::random::RandomSource;
impl Game<'_> {
pub(in crate::game) fn cmd_raid(&mut self, id: &str, ctx: &Context) -> R<i32> {
@@ -39,7 +43,9 @@ impl Game<'_> {
Ok(-1)
}
},
"raid.check" => match self.raiding_nearby(at).filter(|id| self.raid_started(*id)) {
// RaidCommand.getRaid is getRaidAt: a raid still counting down
// to its first wave is found too (#11372).
"raid.check" => match self.raiding_nearby(at) {
Some(raid) => {
self.feedback(Text::literal("Found a started raid!"));
let status = self.raid_status_text(raid);
@@ -96,13 +102,90 @@ impl Game<'_> {
}
Ok(1)
}
// RaidCommand.playSound: only for the text "local", at volume 2,
// with a seed from the level's random (playSeededSound).
"raid.sound" => {
let at = [player.x + 5.0, player.y, player.z];
self.audio
.play("minecraft:event.raid.horn", Some(at), 1.0, 1.0);
// ComponentArgument: `local`, "local" and 'local' are all the
// text "local".
let kind = match ctx.get("type") {
Some(Arg::Str(s)) => s.trim(),
_ => "",
};
if matches!(kind, "local" | "\"local\"" | "'local'") {
let seed = self.world.random.next_long();
let at = [player.x + 5.0, player.y, player.z];
self.audio
.play_seeded("minecraft:event.raid.horn", Some(at), 2.0, 1.0, seed);
}
Ok(1)
}
_ => Err(err("commands.help.failed", &[])),
}
}
}
#[cfg(test)]
mod tests {
use super::Game;
use crate::game::native_machines::tests::server_game;
use horizon_client::{command::vanilla, motion::PlayerMotion};
use horizon_core::geometry::Vec3;
/// Runs a line of the dev tree (`/raid` is registered only there).
fn run_dev(game: &mut Game<'_>, line: &str) -> i32 {
let d = vanilla::build_with(true);
let parse = d.parse(line, &game.ui.registries);
let (ctx, _) = parse.executable().expect(line);
let (ctx, root) = (ctx.clone(), parse.context.clone());
game.run_chain(&root, &ctx).expect(line)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn raid_check_finds_a_raid_counting_down_and_the_horn_is_local_only() {
let mut game = server_game();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
// RaidCommand needs the owner's permission level (3).
game.ui.registries.permission = 4;
game.chat_capture = Some(Vec::new());
game.audio.capture = Some(Vec::new());
let (id, _) = game.raiding.raids.create_or_extend([0, 64, 0], 2, None, 3);
assert!(!game.raiding.raids.get(id).unwrap().started);
assert_eq!(run_dev(&mut game, "raid check"), 1);
let lines: Vec<String> = game
.chat_capture
.take()
.unwrap()
.iter()
.map(|t| t.plain())
.collect();
assert_eq!(
lines,
[
"Found a started raid!",
"Num groups spawned: 0 Raid omen level: 0 Num mobs: 0 Raid health: 0.0 / 0.0",
]
);
run_dev(&mut game, "raid sound global");
assert!(game.audio.capture.as_ref().unwrap().is_empty());
run_dev(&mut game, "raid sound local");
let played: Vec<_> = game
.audio
.capture
.take()
.unwrap()
.into_iter()
.map(|s| (s.event, s.position, s.volume))
.collect();
assert_eq!(
played,
[(
"minecraft:event.raid.horn".to_owned(),
[5.5, 64.0, 0.5],
2.0
)]
);
}
}
@@ -195,9 +195,37 @@ impl Game<'_> {
_ => by.and_then(|t| self.target_position(t)),
};
// Destruction can remove the target before its success message is built.
let discarded_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
let item_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
.then(|| self.target_name(target));
let hurt = match target {
let hurt = self.damage_target(target, amount, &ty, cause, by, from);
if !hurt {
return Err(err("commands.damage.invulnerable", &[]));
}
let name = item_name.unwrap_or_else(|| self.target_name(target));
self.feedback(Text::translate(
"commands.damage.success",
&[
Text::literal(horizon_client::snbt::java_float(amount)),
name,
],
));
Ok(1)
}
/// `DamageCommand.damage`'s hurt: `target` takes `amount` of `ty` (`minecraft:mob_attack`)
/// from the causing entity `cause` and the direct one `by` (the causing one is either when
/// the other is none), from `from` (the knockback's direction): whether it was hurt. Also
/// `native_hurt_entity`'s.
pub(in crate::game) fn damage_target(
&mut self,
target: Target,
amount: f32,
ty: &str,
cause: Option<Target>,
by: Option<Target>,
from: Option<[f64; 3]>,
) -> bool {
match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player | Target::Guest(_) => {
@@ -217,9 +245,9 @@ impl Game<'_> {
return false;
}
if let Some(name) = named {
g.note_direct_entity(&ty, name);
g.note_direct_entity(ty, name);
}
g.hurt_player_from(amount, from, &ty, attacker)
g.hurt_player_from(amount, from, ty, attacker)
})
.unwrap_or(false)
}
@@ -234,7 +262,7 @@ impl Game<'_> {
_ => None,
};
let damage = Damage {
kind: Self::damage_kind(&ty),
kind: Self::damage_kind(ty),
attacker,
from,
attacker_species,
@@ -264,15 +292,15 @@ impl Game<'_> {
self.command_damage_mob(id, amount, damage)
}
}
Target::Item(id) => self.command_damage_item(id, amount, &ty, cause.or(by)),
Target::Item(id) => self.command_damage_item(id, amount, ty, cause.or(by)),
Target::Boat(_) | Target::Cart(_) => {
self.command_damage_vehicle(target, amount, &ty, cause.or(by))
self.command_damage_vehicle(target, amount, ty, cause.or(by))
}
Target::Dragon(id) => self.command_damage_dragon(
id,
amount,
&ty,
Self::damage_kind(&ty),
ty,
Self::damage_kind(ty),
(
by,
cause.or(by),
@@ -280,22 +308,10 @@ impl Game<'_> {
),
),
Target::Crystal(id) => {
self.command_damage_crystal(id, &ty, Self::damage_kind(&ty), cause.or(by))
self.command_damage_crystal(id, ty, Self::damage_kind(ty), cause.or(by))
}
_ => false,
};
if !hurt {
return Err(err("commands.damage.invulnerable", &[]));
}
let name = discarded_name.unwrap_or_else(|| self.target_name(target));
self.feedback(Text::translate(
"commands.damage.success",
&[
Text::literal(horizon_client::snbt::java_float(amount)),
name,
],
));
Ok(1)
}
/// `VehicleEntity.hurtServer`: report acceptance even when the vehicle survives.
@@ -1509,6 +1525,20 @@ impl Game<'_> {
return Ok(0);
};
let vehicle_name = self.target_name(vehicle);
self.ride_mount(target, vehicle)?;
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
Ok(1)
}
/// `RideCommand.mount` once the targets are known: `target` (the player
/// in place or a mob) rides `vehicle`, as `startRiding(vehicle, true,
/// true)` forces it (also for `native_mount`).
pub(in crate::game) fn ride_mount(&mut self, target: Target, vehicle: Target) -> R<()> {
let name = self.target_name(target);
let vehicle_name = self.target_name(vehicle);
if let Some(current) = self.ride_vehicle(target) {
let current = self.target_name(current);
return Err(err(
@@ -1574,11 +1604,7 @@ impl Game<'_> {
// sendEventAndTriggers: START_RIDING_TRIGGER.
self.start_riding(id);
}
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
return Ok(());
}
Target::Cart(id) => {
if target != Target::Player
@@ -1588,11 +1614,7 @@ impl Game<'_> {
return Err(generic());
}
self.start_riding(id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
return Ok(());
}
_ => {}
}
@@ -1608,11 +1630,7 @@ impl Game<'_> {
if !self.try_mount_mob(rider, mob_id) {
return Err(generic());
}
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
return Ok(());
}
if !self.native_may_board(mob_id) {
return Err(generic());
@@ -1625,11 +1643,7 @@ impl Game<'_> {
self.mount.jump_ticks = 0;
self.mount.jump_scale = 0.0;
self.start_riding(mob_id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
Ok(1)
Ok(())
}
/// Entity.getVehicle: the player's mount, or a mob's.
@@ -386,6 +386,9 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
*target = Vec3::new(to[0], y, to[2]);
}
if !cancel {
horizon_client::lan::hub::teleport_cause(Some("chorus_fruit"));
}
Ok(!cancel)
}
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
+21
View File
@@ -260,6 +260,21 @@ impl Game<'_> {
/// Nothing here is a key — the caller drops control characters. `Err` when
/// no text box of the screen takes them.
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
// Overlays own the input even when a text box remains focused behind them.
if self.dialog_open() {
self.dialog_type(text);
return Ok(());
}
if self.prompt_open() {
return Ok(());
}
if let Some(result) = self.type_text_field_input(text) {
return result;
}
self.type_chars_unhooked(text)
}
pub(super) fn type_chars_unhooked(&mut self, text: &str) -> Result<()> {
if self.book.is_some() {
self.book_type(text);
return Ok(());
@@ -1015,6 +1030,12 @@ impl Game<'_> {
// key event fires it. A cancelled character is dropped, so
// `type` types the survivors only.
// `\b` is the socket's backspace sentinel, not two characters.
if text != "\\b"
&& let Some(result) = self.type_text_field_input(text)
{
result?;
return ok();
}
let typed = if text == "\\b" {
text.to_owned()
} else {
+291 -8
View File
@@ -17,7 +17,7 @@ use horizon_client::{
collisions::SceneCollisions,
crafting::Recipes,
graphics::EntityCullingMode,
health::{HurtSource, Hurting, Knock, knockback_canceled},
health::{HealthEvent, HurtSource, Hurting, Knock, knockback_canceled},
items::ItemStack,
mesher::Vertex,
mobs::{
@@ -108,6 +108,9 @@ pub(super) struct Creatures {
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
/// thousands of mobs would allocate and free megabytes every tick.
seen_buf: Vec<Seen>,
/// The targets the mobs had before their ticks, kept between ticks
/// (empty) as `seen_buf` is: `native_living_change_target` hears a change.
target_buf: Vec<Option<Who>>,
/// The mobs whose own tick ran in the last `tick_creature_entities`:
/// `ServerLevel.tick` ticks only the entities in entity-ticking range, so
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
@@ -124,6 +127,17 @@ pub(super) struct Creatures {
/// Particles from a guest's turn, waiting for it to end.
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
}
/// Whether `p`'s next step goes from inside the world border (`bounds`: [minX, minZ, maxX,
/// maxZ]) to outside it, as `clip_including_border` would stop it.
fn border_crossing(p: &Projectile, bounds: Option<[f64; 4]>) -> bool {
let Some([min_x, min_z, max_x, max_z]) = bounds else {
return false;
};
let within = |x: f64, z: f64| x >= min_x && x < max_x && z >= min_z && z < max_z;
let (at, v) = (p.position, p.velocity);
within(at.x, at.z) && !within(at.x + v.x, at.z + v.z)
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
pub targets: Vec<(Who, Aabb)>,
@@ -296,6 +310,53 @@ fn flame_geometry(
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
m.seen(player)
}
/// A mob's target as `native_living_change_target` names it: a player's UUID
/// or a mob's id, both None for no target; None for one it cannot name.
/// `Who::Player` is the guest the mob sees (`seen`, a connection) or, with
/// none, the host's own player (`host`); a guest's entity (`guest_ids`, by
/// connection) is that guest.
fn target_named(
target: Option<Who>,
seen: Option<u32>,
guests: &super::lan_mobs::GuestViews,
guest_ids: &[(u32, u32)],
host: Option<&PlayerView>,
) -> Option<(Option<u128>, Option<u32>)> {
let uuid = |v: &PlayerView| horizon_client::lan::playerdata::ints_uuid(v.uuid);
let guest = |conn: u32| guests.iter().find(|g| g.0 == conn).map(|g| uuid(&g.1));
match target {
None => Some((None, None)),
Some(Who::Player) => {
let player = match seen {
Some(conn) => guest(conn),
None => host.map(uuid),
};
Some((Some(player?), None))
}
Some(Who::Mob(id)) => match guest_ids.iter().find(|g| g.1 == id) {
Some(&(conn, _)) => Some((Some(guest(conn)?), None)),
None => Some((None, Some(id))),
},
}
}
/// Whether `e`, from a mob's tick, is a hit it deals `target`: a melee hit
/// (with its effect), a sting, a sonic boom.
fn hits_target(e: &MobEvent, want: Who) -> bool {
match (e, want) {
(
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::StingPlayer { .. },
Who::Player,
) => true,
(
MobEvent::AttackMob { target, .. } | MobEvent::AttackMobWithEffect { target, .. },
Who::Mob(id),
) => *target == id,
(MobEvent::SonicBoom { target, .. }, _) => *target == want,
_ => false,
}
}
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
/// its death animation, the one the player rides (it shares the player's root
/// vehicle) and the camera entity the pick starts from, so a packet that names
@@ -1020,6 +1081,11 @@ impl Game<'_> {
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
let mut events = Vec::new();
let mut mobs = std::mem::take(&mut self.mobs);
// Each mob's target as the mods last heard it, by its place in `mobs`
// (`native_living_change_target`).
let mut targets = std::mem::take(&mut self.creatures.target_buf);
targets.clear();
targets.extend(mobs.iter().map(|m| m.ai.target));
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
@@ -1209,6 +1275,46 @@ impl Game<'_> {
} else if let Some(k) = lent {
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
}
// Its tick changed its target: the mods may keep the one it had
// (`native_living_change_target`), and then what the tick did to
// the new one is not dealt.
if let Some(&before) = targets.get(i)
&& m.ai.target != before
{
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&guest_ids,
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
Self::native_living_change_target(
level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
if let Some(new) = m.ai.target {
let mut k = from;
while k < events.len() {
if hits_target(&events[k], new) {
events.remove(k);
} else {
k += 1;
}
}
}
m.ai.target = before;
}
}
targets[i] = m.ai.target;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
@@ -1272,6 +1378,43 @@ impl Game<'_> {
grid.update(i, snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
}
// Targets changed after the mob's own tick (another mob's alert):
// heard now, and a cancel puts the old one back before its next tick.
for (i, m) in mobs.iter_mut().enumerate() {
let Some(&before) = targets.get(i) else {
break;
};
if m.ai.target == before {
continue;
}
let seen_guest = if guests.is_empty() {
None
} else {
bindings.get(&m.id).copied()
};
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&guest_ids,
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
Self::native_living_change_target(
level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
m.ai.target = before;
}
}
}
laps.lap("creature_loop");
let mut flags = keep.into_iter();
mobs.retain(|_| flags.next().unwrap_or(true));
@@ -1303,6 +1446,8 @@ impl Game<'_> {
snapshot.clear();
self.creatures.seen_buf = snapshot;
self.creatures.ticked = ticked;
targets.clear();
self.creatures.target_buf = targets;
self.native_tick_post_batch(round);
// ... and entity event 60 goes out as it is: the poof.
// 🤖 Agent: the split follows the poof, as `LivingEntity.tickDeath`
@@ -2215,6 +2360,15 @@ impl Game<'_> {
let ctx = self.who_context(Some(Who::Mob(source)), None);
self.post_game_event(event, at, ctx);
}
MobEvent::GameEvent {
event: "minecraft:block_place",
at,
source,
state,
} => {
let ctx = self.who_context(Some(Who::Mob(source)), Some(state));
self.post_game_event("minecraft:block_place", at, ctx);
}
// The sculk layer tracks steps and bee/bat flaps itself
// (`entity_motion_events`, `tick_mob_sculk_events`). Birds
// above use their movement's flyDist/nextFlap threshold.
@@ -3316,10 +3470,11 @@ impl Game<'_> {
}
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
/// `mob_drops` game rule is on.
/// `mob_drops` game rule is on, and one `new ExperienceOrb` of the whole
/// value (not split or merged as `ExperienceOrb.award` does).
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
if self.ui.rules.bool("mob_drops") {
self.spawn_orbs(at, amount);
self.add_orb(at, amount);
}
}
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
@@ -3680,13 +3835,14 @@ impl Game<'_> {
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience, from the breeding
/// parent's own random, and only when the `mob_drops` game rule is on.
/// parent's own random, and only when the `mob_drops` game rule is on: one
/// fresh orb of 1 to 7 (#11388).
fn breeding_orb(&mut self, parent: u32, at: [f64; 3]) {
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs(at, xp);
self.add_orb(at, xp);
}
}
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
@@ -3844,6 +4000,7 @@ impl Game<'_> {
// The hit's own push, held back until after the shield's (`hurtServer`
// runs `applyItemBlocking` before `dealDefaultKnockback`).
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
let events_before = self.player.as_ref().map_or(0, |p| p.events.len());
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.health_resources.damage.id(damage_type)
&& let Ok(o) = player.hurt_caused_by(
@@ -3879,15 +4036,36 @@ impl Game<'_> {
default_push = Some(([xd, zd], source));
}
// The thorns hurt the entity that caused the hit, if there is one.
// Thorns' own hit is no attack, so nothing answers it
// (`doPostAttackEffects` runs from the attacks, not `hurtServer`).
let killed = self.player.as_ref().is_some_and(|p| {
super::wither::killed(true, p.health.dead_or_dying(), p.health.totem_used)
});
if let Some(a) = attacker.or(owner)
&& !(post_attack_if_alive && killed)
&& damage_type != "minecraft:thorns"
{
self.thorns(a);
}
}
// LivingEntity.playSecondaryHurtSound: a thorns hit that landed whole
// sounds where the player stands, for everyone around.
if let Some(p) = self.player.as_ref()
&& p.events.get(events_before..).is_some_and(|new| {
new.iter().any(|e| {
matches!(
e,
horizon_client::motion::MotionEvent::Health(HealthEvent::Phase(
"thorns-sound"
))
)
})
})
{
let at = [p.position.x, p.position.y, p.position.z];
self.audio
.play("minecraft:enchant.thorns.hit", Some(at), 1.0, 1.0);
}
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
// A hit a native mod canceled records no damage (hurtServer never ran).
@@ -4113,6 +4291,7 @@ impl Game<'_> {
Vec::new()
};
let border = Some(self.border.bounds());
let dimension = self.dim.id();
let pw = ProjWorld {
w: &w,
targets,
@@ -4158,6 +4337,23 @@ impl Game<'_> {
keep_flags[i] = false;
continue;
}
// MC-310476: the mods may let a thrown projectile through the world border.
let through = p.arrow.is_none() && !p.stuck && border_crossing(p, border) && {
let (at, v) = (p.position, p.velocity);
let (mut pos, mut velocity) = ([at.x, at.y, at.z], [v.x, v.y, v.z]);
let mut collide = true;
let kind = p.kind.id();
let (vel, c) = (&mut velocity, &mut collide);
Self::native_projectile_world_border(dimension, p.id, kind, &mut pos, vel, c);
if pos.iter().chain(&velocity).all(|v| v.is_finite())
&& (pos != [at.x, at.y, at.z] || velocity != [v.x, v.y, v.z])
{
p.position = Vec3::new(pos[0], pos[1], pos[2]);
p.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
p.needs_sync = true;
}
!collide
};
// The projectile as it was, to fly on if a mod cancels its impact.
let before = (hear && !p.stuck).then(|| p.clone());
let mob_shot = !matches!(p.owner, Some(Who::Player));
@@ -4188,6 +4384,16 @@ impl Game<'_> {
targets.extend(winds);
horizon_client::arrows::tick(p, &scene, &targets)
}
// Through the border: this tick's ray does not stop on it.
(false, with_guests, true) if through => p.tick(&ProjWorld {
w: pw.w,
targets: if with_guests {
pw_guests.targets.clone()
} else {
pw.targets.clone()
},
border: None,
}),
(false, false, true) => p.tick(&pw),
(false, true, true) => p.tick(&pw_guests),
(false, pvp_shot, false) => {
@@ -4200,7 +4406,7 @@ impl Game<'_> {
p.tick(&ProjWorld {
w: if pvp_shot { pw_guests.w } else { pw.w },
targets,
border,
border: border.filter(|_| !through),
})
}
};
@@ -4440,6 +4646,23 @@ impl Game<'_> {
None => self.projectile_hit(p, hit),
}
}
/// `EnchantmentHelper.doPostAttackEffects` after the player's shot `p`
/// (its `direct` entity, a hit of `damage_type`) hurt the mob `who`: the
/// victim's own post-attack enchantments answer the player (Thorns).
pub(super) fn shot_post_attack(
&mut self,
p: &Projectile,
who: Who,
direct: &str,
damage_type: &str,
) {
if p.owner == Some(Who::Player)
&& let Who::Mob(id) = who
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
{
self.victim_post_attack(index, direct, Some(damage_type));
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
@@ -4746,6 +4969,12 @@ impl Game<'_> {
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
let result = self.projectile_hit_inner(p, hit);
// LivingEntity.actuallyHurt credits the shooter (a ServerPlayer) with the absorbed damage.
self.settle_dealt_absorbed(p.owner == Some(Who::Player));
result
}
fn projectile_hit_inner(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
let _attacker = self.knockback_scope_for(p.owner);
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
@@ -4949,6 +5178,7 @@ impl Game<'_> {
}),
};
let outcome = if hurt {
self.shot_post_attack(p, who, "minecraft:trident", "minecraft:trident");
EntityHit::Hurt
} else {
EntityHit::Refused
@@ -5123,6 +5353,16 @@ impl Game<'_> {
}])?;
}
if hurt {
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
let direct = if spectral {
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
};
self.shot_post_attack(p, who, direct, "minecraft:arrow");
self.arrow_post_hurt_effects(p, who);
}
match (hurt, who) {
@@ -5327,6 +5567,9 @@ impl Game<'_> {
pl.fall_distance = 0.0;
pl.teleported = true;
}
if self.lan_acting.is_some() {
horizon_client::lan::hub::teleport_cause(Some("ender_pearl"));
}
self.previous_position = to;
if damage > 0.0 {
self.hurt_player_by("minecraft:ender_pearl", damage);
@@ -5478,7 +5721,7 @@ impl Game<'_> {
_ => None,
};
let owner = self.potion_owner(p.owner);
self.splash(stack, at, mob, block, p.age, &owner);
self.splash(stack, at, mob, block, p.age, &owner, p.velocity);
if stack.item.as_ref() != "minecraft:experience_bottle" {
let item = stack.item.to_string();
self.native_potion_splash(&item, [at.x, at.y, at.z]);
@@ -6711,6 +6954,17 @@ impl Game<'_> {
let Some(eye) = self.eye_position() else {
return Ok(false);
};
// The mods, before the projectile exists: a canceled throw is a
// failed use, and the guest's client gets back the item it took.
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let mut cancel = false;
self.native_player_launch_projectile_v2(player, &name, stack, &mut cancel);
if cancel {
self.use_failed = true;
self.guest_inventory_resend = true;
return Ok(true);
}
}
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
@@ -6770,7 +7024,7 @@ impl Game<'_> {
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
@@ -8850,3 +9104,32 @@ mod pickable_projectile_tests {
}
}
}
#[cfg(test)]
mod breeding_orb_tests {
use super::*;
/// `Animal.finalizeSpawnChildFromBreeding`: one fresh orb of the parent's
/// 1-to-7 roll, never split (4 is not 3 + 1) or merged (#11388).
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn breeding_drops_one_orb_of_the_whole_roll() {
let mut game = super::super::native_machines::tests::server_game();
let kind = game.res.mob_types().get("minecraft:cow").unwrap().clone();
game.mobs
.push(Mob::new(7, kind, Vec3::new(8.5, 64.0, 8.5), 0.0));
let mut values = std::collections::BTreeSet::new();
for _ in 0..40 {
let roll = 1 + game.mobs[0].random.clone().next_int_bound(7).unwrap() as u32;
let orbs = game.orbs.len();
game.breeding_orb(7, [8.5, 64.0, 8.5]);
assert_eq!(game.orbs.len(), orbs + 1, "a fresh orb, not merged");
assert_eq!(
game.orbs.last().map(|o| (o.value, o.count)),
Some((roll, 1))
);
values.insert(roll);
}
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
}
}
@@ -0,0 +1,75 @@
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
use super::*;
use horizon_optifine::sky::{Conditions, Now};
/// The layers' eased brightness between frames, and when the last frame was.
#[derive(Default)]
pub(super) struct CustomSkyView {
state: horizon_client::custom_sky::State,
last: Option<std::time::Instant>,
}
impl Game<'_> {
/// Fills the renderer's custom sky layers for this frame; `camera` is the eye's position.
/// Nothing for the title screen, the Nether (it has no sky) or a sky a shader pack draws.
pub(super) fn custom_sky_frame(
&mut self,
camera: [f64; 3],
sky: &horizon_client::environment::Sky,
(rain, thunder): (f32, f32),
title: bool,
) {
let Some(mut draws) = self
.renderer
.as_mut()
.map(|r| std::mem::take(r.custom_sky_draws()))
else {
return;
};
draws.clear();
let world = match self.dim {
Dim::Overworld => Some(0),
Dim::End => Some(1),
Dim::Nether => None,
};
let now = std::time::Instant::now();
let seconds = self
.custom_sky
.last
.replace(now)
.map_or(0.0, |t| now.duration_since(t).as_secs_f32());
if let Some(world) =
world.filter(|&w| !title && self.settings.horizon.sky && self.pack.custom_sky.has(w))
{
let mut state = std::mem::take(&mut self.custom_sky.state);
// Level.isThundering / isRaining.
let weather = if thunder > 0.9 && rain > 0.2 {
Now::Thunder
} else if rain > 0.2 {
Now::Rain
} else {
Now::Clear
};
let conditions = Conditions {
day_time: self.view_day_time(),
weather,
biome: self.biome_at(camera),
y: camera[1].floor() as i32,
};
self.pack.custom_sky.draws(
world,
&conditions,
sky.sun_angle / 360.0,
seconds,
&mut state,
&mut draws,
);
self.custom_sky.state = state;
}
if let Some(renderer) = self.renderer.as_mut() {
*renderer.custom_sky_draws() = draws;
}
}
}
@@ -34,7 +34,7 @@ const HINT: u32 = 0xAA_AAAA;
/// The open screen: its search box and the list's scroll.
pub(super) struct DebugOptionsUi {
search: EditBox,
pub(super) search: EditBox,
scroll: Scroll,
}
@@ -168,6 +168,16 @@ pub(super) fn player_joined(id: u128, name: &str) {
}
}
/// A player in the user cache by name (`UserCache::find_by_name`), on a dedicated server.
pub(super) fn cached_user_by_name(name: &str) -> Option<NameAndId> {
USER_CACHE.lock().unwrap().as_ref()?.find_by_name(name)
}
/// A player in the user cache by id, on a dedicated server.
pub(super) fn cached_user_by_id(id: u128) -> Option<NameAndId> {
USER_CACHE.lock().unwrap().as_mut()?.get_by_id(id)
}
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
fn load_anticheat() -> horizon_anticheat::Config {
@@ -1616,6 +1626,15 @@ impl Game<'_> {
/// server (permission level 4). The dedicated-only commands are here;
/// everything else goes to the game's dispatcher.
fn console_command(&mut self, state: &mut ServerState, line: &str) {
let (mut cancel, mut replies) = (false, Vec::new());
let source = if self.rcon_source { "rcon" } else { "console" };
self.native_console_line(source, line, &mut cancel, &mut replies);
for reply in replies {
self.console_reply(reply);
}
if cancel {
return;
}
let line = line.strip_prefix('/').unwrap_or(line);
let mut words = line.split_whitespace();
let Some(command) = words.next() else {
@@ -51,7 +51,7 @@ pub(super) struct Recording {
impl Recording {
pub(super) fn start(seconds: u32, wire: Vec<(u32, (u64, u64))>) -> Self {
let spans_were_on = spans::enabled();
spans::enable(true);
tick_profile::want_spans(tick_profile::SPANS_REPORT, true);
job_stats::enable(true);
job_stats::take();
tick_profile::take();
@@ -355,9 +355,8 @@ impl Game<'_> {
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
let span_ns = span_cost_nanos();
let mob_ns = tick_profile::sample_cost_nanos();
if !r.spans_were_on {
spans::enable(false);
}
// Off unless `HORIZON_SPIKES` or a mod (`native_spans`) still wants them.
tick_profile::want_spans(tick_profile::SPANS_REPORT, false);
job_stats::enable(false);
let window_s = r.started.elapsed().as_secs_f64();
let (ticks, spans, worst) = tick_summary(&r, window_s);
@@ -44,6 +44,8 @@ impl Game<'_> {
/// Answers the requests waiting on the control endpoint.
pub(super) fn poll_server_control(&mut self, state: &mut ServerState) {
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
// A `console stop` just before stops this server for the next request too.
self.note_stopping(state.lists.lock().unwrap().stop);
let reply = match request.line.trim().split_once(' ') {
_ if request.line.trim() == "server-status" => self.server_status(state),
// A proxy reading whether it may send players here (transfer.rs).
@@ -8,7 +8,7 @@
//! it sends players here). They run
//! between two ticks, on the server thread, so the player is never caught half-way through a
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
//! `[transfer]` is off.
//! `[transfer]` is off, and those that bring a player in once the server has begun to stop.
use crate::game::{Dim, Game, lan::LanHost};
use horizon_client::lan::arrivals;
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -40,6 +40,16 @@ impl Game<'_> {
reply
}
/// Once a stop has begun (`/stop` from the console, a player or the control endpoint, or a stop
/// signal), the transfers know it before the next request is answered: what would bring a player
/// in is refused from then on, and the ways out stay open (`arrivals::Transfers::stopping`).
pub(super) fn note_stopping(&mut self, stop: bool) {
let stopping = stop || horizon_threads::stop_requests() > 0;
if let Some(transfers) = self.lan.as_mut().and_then(|h| h.hub.transfers.as_mut()) {
transfers.stopping |= stopping;
}
}
/// `transfer-status`: the counts a proxy reads before it sends players here
/// (`arrivals::status_request`). A proxy asks it every few seconds, so it writes nothing to
/// the log.
@@ -655,6 +655,8 @@ impl Game<'_> {
v[2].as_f64().unwrap_or(0.0),
)
};
// A new player: no combat of an earlier case's.
self.combat = Default::default();
let mut player = PlayerMotion::new(self.motion, v3(&op["player"]))?;
player.velocity = v3(&op["motion"]);
player.fall_distance = op["fall"].as_f64().unwrap_or(0.0);
@@ -670,6 +672,7 @@ impl Game<'_> {
let used_before = stack
.as_ref()
.map_or(0, |s| self.stats.get("used", &s.item));
let taken_before = self.stats.get("custom", "minecraft:damage_taken");
let base = op["base"].as_f64().unwrap_or(0.0) as f32;
let source = self.attack_source(feet);
let (bonus, hurt) = match targets.first() {
@@ -707,12 +710,39 @@ impl Game<'_> {
};
self.process_level_events()?;
let mut out = Vec::new();
// What the player's tick makes of its own hurt (the victim's Thorns):
// the combat record with its statistic, the hurt sound where it
// stands and the damage game event of the health it lost.
let events = self
.player
.as_mut()
.map(|p| std::mem::take(&mut p.events))
.unwrap_or_default();
self.record_combat(&events);
for event in &events {
if let horizon_client::motion::MotionEvent::HurtSound(sound) = event {
out.push(format!("S {} {sound}", floor_pos(feet)));
}
}
if self
.player
.as_ref()
.is_some_and(|p| p.health.health() < 20.0)
{
self.post_entity_event("minecraft:entity_damage", horizon_client::mobs::Who::Player);
}
if let Some(s) = &stack {
let used = self.stats.get("used", &s.item) - used_before;
if used != 0 {
out.push(format!("stat {} minecraft:used {used}", s.item));
}
}
let taken = self.stats.get("custom", "minecraft:damage_taken") - taken_before;
if taken != 0 {
out.push(format!(
"stat minecraft:damage_taken minecraft:custom {taken}"
));
}
let source = match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
@@ -743,6 +773,25 @@ impl Game<'_> {
num(v.z)
));
}
// What the first target wears, worn by its Thorns.
if let Some(&Target::Mob(id)) = targets.first()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
use horizon_client::mobs::finalize as f;
for (slot, name) in [
(f::FEET, "feet"),
(f::LEGS, "legs"),
(f::CHEST, "chest"),
(f::HEAD, "head"),
] {
if let Some(s) = f::item_stack(m, slot) {
out.push(format!("W {name} {}", canon_stack(Some(&s))));
}
}
}
if let Some(p) = self.player.as_ref() {
out.push(format!("H {}", num(f64::from(p.health.health()))));
}
if let Some(p) = self.player.take() {
let impact = p.impulse.impact_position.map_or("-".to_owned(), |i| {
format!("{},{},{}", num(i.x), num(i.y), num(i.z))
@@ -1044,12 +1093,20 @@ impl Game<'_> {
if flag("brown") && s == Species::Mooshroom {
mob.extra.variant = 1;
}
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
for (name, snbt) in e["equip"].as_object().into_iter().flatten() {
if let (Some(slot), Some(stack)) = (
Slot::from_name(slot),
Slot::from_name(name),
parse_stack(snbt.as_str().unwrap_or("-"))?,
) {
mob_set_slot(&mut mob, slot, stack);
// `setItemSlot` keeps the whole stack: a piece's enchantments and damage.
let kept = ["mainhand", "offhand", "feet", "legs", "chest", "head"]
.iter()
.position(|s| s == name)
.filter(|_| stack.extra.is_some() || stack.damage > 0);
match kept {
Some(at) => horizon_client::mobs::finalize::set_item_stack(&mut mob, at, stack),
None => mob_set_slot(&mut mob, slot, stack),
}
}
}
self.mobs.push(mob);
@@ -17,6 +17,9 @@ use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
use serde_json::Value;
/// `EquipmentSlot.getName` of each `mobs::finalize` slot (hands, feet to head).
const MOB_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
enum MendSlot {
@@ -178,8 +181,12 @@ impl Game<'_> {
let _ = p.set_sprinting(false);
}
}
/// After a successful melee hit: fire aspect and wind burst.
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource` after a successful
/// melee hit (`Player.itemAttackInteraction`, the sweep's
/// `doPostAttackEffects`): the victim's equipment first (Thorns), then the
/// attacker's weapon on the victim (fire aspect and wind burst).
pub(super) fn post_attack(&mut self, mob: usize) {
self.victim_post_attack(mob, "minecraft:player", None);
let Some(held) = self.held().cloned() else {
return;
};
@@ -519,6 +526,152 @@ impl Game<'_> {
}
hits
}
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
/// direct entity: the player, or its arrow or trident):
/// `runIterationOnEquipment` over what the mob holds and wears, in
/// `EquipmentSlot` order, runs the `minecraft:post_attack` effects with
/// `enchanted: victim` of each enchantment on the player (Thorns).
pub(super) fn victim_post_attack(
&mut self,
victim: usize,
direct: &str,
damage_type: Option<&str>,
) {
use horizon_client::mobs::finalize as f;
let Some(m) = self.mobs.get(victim) else {
return;
};
let (id, kind) = (m.id, m.kind.id.clone());
// EquipmentSlot.VALUES: the hands, then the feet up to the head (the
// body and the saddle keep no enchantments here).
for slot in [f::MAINHAND, f::OFFHAND, f::FEET, f::LEGS, f::CHEST, f::HEAD] {
let Some(stack) = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| f::item_stack(m, slot))
else {
continue;
};
let name = MOB_SLOTS[slot];
let hand = slot == f::MAINHAND || slot == f::OFFHAND;
let mut effects = Vec::new();
for (enchantment, level) in stack.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(enchantment) else {
continue;
};
// Enchantment.matchingSlot
if !def.slots.iter().any(|s| {
s == name || s == "any" || (s == "armor" && !hand) || (s == "hand" && hand)
}) {
continue;
}
for effect in def.effects["minecraft:post_attack"]
.as_array()
.into_iter()
.flatten()
{
if effect["enchanted"] == "victim" {
effects.push((effect.clone(), *level));
}
}
}
for (effect, level) in effects {
// Enchantment.damageContext: random_chance draws from the level's random.
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
direct_attacker: Some(direct),
is_direct: direct == "minecraft:player",
damage_type,
items: Some(world_items!(self)),
..Default::default()
};
let ok = effect.get("requirements").is_none_or(|r| {
world_stations!(self).enchantments.condition(
r,
level,
&ctx,
&mut self.world.random,
)
});
// The player is the attacker; with no attacker nothing applies.
if ok && effect["affected"] == "attacker" && self.player.is_some() {
self.victim_effect_on_player(&effect["effect"], level, id, slot);
}
}
}
}
/// `DamageEntity.apply`, `ChangeItemDamage.apply` (on the piece) and
/// `AllOf` for one `post_attack` effect of the piece the mob `wearer` has
/// in `slot`, on the player that hit it.
fn victim_effect_on_player(&mut self, effect: &Value, level: u32, wearer: u32, slot: usize) {
match effect["type"]
.as_str()
.map(|t| t.trim_start_matches("minecraft:"))
{
Some("all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.victim_effect_on_player(e, level, wearer, slot);
}
}
// Mth.randomBetween from the hurt entity's own random (the
// player's, which no level draw shares), then its hurtServer by a
// source the wearer caused: from where the wearer stands.
Some("damage_entity") => {
let min = level_value(&effect["min_damage"], level);
let max = level_value(&effect["max_damage"], level);
let damage = super::dispenser::entity_random().next_float() * (max - min) + min;
let Some(m) = self.mobs.iter().find(|m| m.id == wearer) else {
return;
};
let from = [m.position.x, m.position.y, m.position.z];
let kind = effect["damage_type"].as_str().unwrap_or("minecraft:thorns");
self.mob_hits_player_caused(damage, from, kind, Some(wearer));
}
Some("change_item_damage") => {
let amount = level_value(&effect["amount"], level) as u32;
self.wear_mob_piece(wearer, slot, amount);
}
_ => {}
}
}
/// `ItemStack.hurtAndBreak` with no player for what the mob `id` has in
/// `slot`: Unbreaking takes its share from the level's random, and a
/// piece worn out leaves the slot with its break event
/// (`LivingEntity.onEquippedItemBroken`).
fn wear_mob_piece(&mut self, id: u32, slot: usize, amount: u32) {
use horizon_client::mobs::finalize as f;
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let Some(stack) = f::item_stack(m, slot).filter(|s| {
amount > 0 && horizon_client::stations::is_damageable(world_items!(self), s)
}) else {
return;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
return;
}
let damage = stack.damage + loss;
let max = world_items!(self)
.get(&stack.item)
.map_or(0, |i| i.max_damage);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if damage < max {
f::set_slot_damage(m, slot, damage);
return;
}
horizon_client::mobs::drops::clear_slot(m, MOB_SLOTS[slot]);
// LivingEntity.entityEventForEquipmentBreak
let event = [47, 48, 52, 51, 50, 49][slot];
self.lan_entity_event(id as i32, event, at);
}
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
pub(super) fn mending(&mut self, amount: u32) -> u32 {
let mut amount = amount;
@@ -840,16 +993,26 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z];
let on_ground = p.ground.on_ground;
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
// LivingEntity.collectEquipmentChanges: newly worn armor runs its
// location_changed effects at once, even when standing still.
let changed = armor_changes(&mut self.location_armor, &self.armor);
let moved = cell(from) != cell(at) || (on_ground && !was_on_ground);
if !moved && !changed.iter().any(|c| *c) {
return;
}
let riding = self.riding.is_some();
let disks = location_disks(
&self.stations().enchantments,
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
on_ground,
riding,
);
let slots = ["head", "chest", "legs", "feet"];
let mut disks = Vec::new();
for i in 0..4 {
if moved || changed[i] {
disks.extend(location_disks(
&self.stations().enchantments,
std::iter::once((&self.armor[i], slots[i])),
on_ground,
riding,
));
}
}
for (effect, level) in disks {
self.replace_disk(&effect, level, at, Who::Player);
}
@@ -1182,9 +1345,43 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
}
}
/// `LivingEntity.collectEquipmentChanges`: which armor slots differ from the
/// `snapshot` taken at the last look, which is then brought up to date.
fn armor_changes(
snapshot: &mut [Option<ItemStack>; 4],
armor: &[Option<ItemStack>; 4],
) -> [bool; 4] {
let changed: [bool; 4] = std::array::from_fn(|i| armor[i] != snapshot[i]);
// Only a changed slot is copied: the check runs every tick for every player.
for ((kept, worn), changed) in snapshot.iter_mut().zip(armor).zip(changed) {
if changed {
kept.clone_from(worn);
}
}
changed
}
#[cfg(test)]
mod tests {
use super::block_break_speed_factor;
use super::{armor_changes, block_break_speed_factor};
use horizon_client::items::ItemStack;
/// The snapshot reports a newly worn piece once, then settles.
#[test]
fn armor_changes_settle_after_one_look() {
let worn = [
None,
None,
None,
Some(ItemStack::new("minecraft:iron_boots", 1)),
];
let mut snapshot: [Option<ItemStack>; 4] = Default::default();
assert_eq!(
armor_changes(&mut snapshot, &worn),
[false, false, false, true]
);
assert_eq!(armor_changes(&mut snapshot, &worn), [false; 4]);
}
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
#[test]
+79 -2
View File
@@ -408,6 +408,8 @@ impl Game<'_> {
};
let mut cancel = false;
self.native_player_fish(uuid, &name, state, &mut cancel);
let hand = self.hand;
self.native_player_fish_v2(uuid, &name, state, hand, &mut cancel);
if cancel {
return true;
}
@@ -766,6 +768,17 @@ impl Game<'_> {
{
let mut cancel = false;
self.native_player_fish(uuid, &name, 1, &mut cancel);
// A bite has no hand of its own: the one holding the rod, the main
// hand first, as `FishingHook.shouldStopFishing` looks.
let hand = if self
.stack_in(self.selected)
.is_some_and(|s| &*s.item == "minecraft:fishing_rod")
{
horizon_client::first_person::Hand::Main
} else {
horizon_client::first_person::Hand::Off
};
self.native_player_fish_v2(uuid, &name, 1, hand, &mut cancel);
if cancel {
h.time_until_lured = 0;
h.time_until_hooked = 0;
@@ -896,6 +909,10 @@ impl Game<'_> {
];
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
let in_water = liquid > 0.0;
// The block a flying bobber meets or rests on this tick (and the face), for
// `native_fishing_hook_landed` once the tick is done.
let rested = h.on_ground;
let mut landed: Option<([i32; 3], [i32; 3])> = None;
match h.state {
State::Flying => {
if in_water {
@@ -908,7 +925,8 @@ impl Game<'_> {
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = self.hook_block_hit(h.position, to);
let block_hit_at = self.hook_block_hit_at(h.position, to);
let block_hit = block_hit_at.map(|hit| hit.location);
let entity_end = block_hit.unwrap_or(to);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
@@ -979,6 +997,8 @@ impl Game<'_> {
}
}
} else if let Some(hit) = block_hit {
landed = block_hit_at
.map(|at| ([at.block.x, at.block.y, at.block.z], at.direction.normal()));
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
@@ -1101,7 +1121,24 @@ impl Game<'_> {
h.velocity.y * 0.92,
h.velocity.z * 0.92,
);
// A bobber lying on a block (on the ground as the tick began or ends) rests on
// the block under its centre, on that block's top.
if landed.is_none() && h.state == State::Flying && (rested || h.on_ground) {
let below = [
h.position.x.floor() as i32,
(h.position.y - 1.0e-4).floor() as i32,
h.position.z.floor() as i32,
];
landed = Some((below, [0, 1, 0]));
}
let id = h.id;
self.hook = Some(h);
// The mods hear a server player's bobber on a block with the bobber in place, so
// `native_remove_entity` can take it.
if let (Some((block, face)), Some(player)) = (landed, self.lan_acting_uuid) {
let dimension = self.dim.id();
self.native_fishing_hook_landed(player, id, dimension, block, face);
}
}
/// The players a hook can catch: everyone but spectators and the dead
/// (`Projectile.canHitEntity`), the host and the guests in the level.
@@ -1601,6 +1638,10 @@ impl Game<'_> {
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
self.hook_block_hit_at(from, to).map(|hit| hit.location)
}
/// [`Self::hook_block_hit`] with the block and the face it meets.
fn hook_block_hit_at(&self, from: Vec3, to: Vec3) -> Option<horizon_core::geometry::BlockHit> {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -1618,7 +1659,6 @@ impl Game<'_> {
})
.ok()
.flatten()
.map(|hit| hit.location)
}
/// `FishingHook.tick`: one tick of movement without the fishing (the
@@ -1726,6 +1766,43 @@ impl Game<'_> {
(position, velocity, state, on_ground)
}
/// Takes the server's fishing bobber `id` out of the level in place, for
/// `native_remove_entity`: the bobber of the player in place, or another of this
/// level's players' (out of reach during a guest's turn, when the hub is taken out).
/// Its tracking removes it from the clients; the rod is left as when the bobber goes
/// by itself. Whether it was there.
pub(super) fn remove_server_hook(&mut self, id: u32) -> bool {
if self.remote.is_some() || id == 0 {
return false;
}
if self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
return true;
}
let Some(host) = self.lan.as_mut() else {
return false;
};
let owner = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.map(|g| g.conn)
.find(|conn| {
host.play
.get(conn)
.and_then(|p| p.hook.as_ref())
.is_some_and(|h| h.id == id)
});
match owner.and_then(|conn| host.play.get_mut(&conn)) {
Some(play) => {
play.hook = None;
true
}
None => false,
}
}
pub(super) fn remove_remote_hook(&mut self, id: u32) {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
+80
View File
@@ -283,6 +283,13 @@ impl Game<'_> {
let Some(held) = self.held().cloned() else {
return false;
};
// A map tracking more than 256 counted markers stays in the hand
// (InteractionResult.FAIL): nothing else is tried, no swing (#11383).
if let Some(id) = super::maps::map_id(&held)
&& self.map(id).is_some_and(|m| m.tracked_over_limit(256))
{
return true;
}
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
action = "put";
self.audio
@@ -704,3 +711,76 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod map_limit_tests {
use super::super::maps::{Decoration, Key, MapData};
use super::super::native_machines::tests::server_game;
use super::*;
fn frame(id: u32) -> ItemFrame {
ItemFrame {
id,
pos: [0, 64, id as i32],
facing: 3,
glow: false,
item: None,
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
check_interval: 0,
custom_name: None,
}
}
/// A map whose `count` frame markers all count toward the limit.
fn map_tracking(count: i32) -> MapData {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
map.decorations = (0..count)
.map(|i| Decoration {
sprite: "map/decorations/frame",
x: 0,
y: 0,
rot: 0,
name: None,
key: Key::Frame(1000 + i),
})
.collect();
map
}
fn filled_map(id: i32) -> ItemStack {
let mut stack = ItemStack::new("minecraft:filled_map", 1);
stack.data_mut().map_id = Some(id);
stack
}
fn at(id: u32) -> Option<InteractTarget> {
Some(InteractTarget {
id: id as i32,
location: [0.0; 3],
})
}
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_map_tracking_more_than_256_markers_stays_in_the_hand() {
let mut game = server_game();
game.item_frames = vec![frame(1), frame(2)];
game.maps.data.insert(7, map_tracking(257));
game.maps.data.insert(8, map_tracking(256));
// ItemFrame.interact: FAIL, the frame stays empty and the map is kept.
*game.held_mut() = Some(filled_map(7));
assert!(
game.interact_frame(at(1)),
"the refusal ends the interaction"
);
assert!(game.item_frames[0].item.is_none());
assert_eq!(game.held().and_then(|s| s.data()?.map_id), Some(7));
// At the limit (not over it) the map goes in.
*game.held_mut() = Some(filled_map(8));
assert!(game.interact_frame(at(2)));
let framed = game.item_frames[1].item.as_ref();
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
}
}
+89 -4
View File
@@ -37,9 +37,10 @@ impl Game<'_> {
.collect()
}
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
/// back keeps it still, so an item dropped there stays on top.
/// `canBeCollidedWith(item)` is true. A boat always, a living shulker, and a happy
/// ghast only while it is held still (`isOnStillTimeout`): a player on its back keeps
/// it still, so an item dropped there stays on top. A minecart keeps `Entity`'s false
/// (`AbstractMinecart` overrides only `canCollideWith`), so an item falls through it.
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
self.mobs
.iter()
@@ -52,7 +53,6 @@ impl Game<'_> {
})
.map(|m| m.bounds())
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(self.carts.iter().map(|c| c.bounds()))
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
@@ -179,3 +179,88 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod item_collision_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
/// An item dropped onto a minecart falls through it to the floor, and one
/// dropped onto a boat rests on the boat (`canBeCollidedWith`).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dropped_item_rests_on_a_boat_but_falls_through_a_minecart() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-item-cart-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecart 4.5 200 4.5"));
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
for (x, z) in [(4.5, 4.5), (10.5, 10.5)] {
assert!(game.run_command(&format!(
"summon item {x} 201.5 {z} {{Item:{{id:\"minecraft:stick\",count:1}},Motion:[0.0d,0.0d,0.0d]}}"
)));
}
for _ in 0..60 {
game.tick_world(true).unwrap();
}
let rest = |x: f64| {
game.dropped
.iter()
.find(|d| (d.position.x - x).abs() < 0.5)
.map(|d| d.position.y)
.unwrap()
};
assert_eq!(rest(4.5), 200.0, "through the minecart to the stone");
let boat_top = game.boats[0].bounds().max_position().y;
assert_eq!(rest(10.5), boat_top, "on the boat");
}
}
+9
View File
@@ -200,6 +200,15 @@ impl Game<'_> {
pub(super) fn swing_arm(&mut self) {
self.swing_hand(self.hand, SwingAnimation::DEFAULT);
}
/// `swing_arm` for a use whose result is `SwingSource.PREDICTED`: the
/// player's client swings by itself, so a server keeps it to the others.
pub(super) fn swing_arm_predicted(&mut self) {
let before = self.swings_started;
self.swing_arm();
if self.swings_started != before {
self.predicted_swings += 1;
}
}
/// `Player.resetAttackStrengthTicker`.
pub(super) fn reset_attack_strength(&mut self) {
self.attack_ticks = 0;
+10
View File
@@ -100,7 +100,17 @@ impl Game<'_> {
/// Text the input method committed (`WindowEvent::Ime(Commit)`).
pub(super) fn ime_commit(&mut self, text: &str) {
preedit::set_preedit(None);
// Dialogs have their own fields; the other topmost prompts take no text.
if self.prompt_open() && !self.dialog_open() {
return;
}
let text: String = text.chars().filter(|c| !c.is_control()).collect();
if let Some(result) = self.type_text_field_input(&text) {
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
return;
}
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
// reaches a text box by characters, as a key's own text does, so the
// open screen hears of each of them first and a cancelled one never
+3 -1
View File
@@ -561,6 +561,7 @@ impl Game<'_> {
self.server_packs_world_pack(&folder);
self.connect_local(pipe, &player)?;
if let Some(r) = self.remote.as_mut() {
r.save_folder = Some(folder.clone());
// `new LevelLoadTracker(newWorld ? 500L : 0L)`.
r.level_load = horizon_client::level_loading::LevelLoadTracker::new(if new_world {
horizon_client::level_loading::LEVEL_LOAD_CLOSE_DELAY_MS
@@ -971,7 +972,8 @@ impl Game<'_> {
g.chat_capture = Some(Vec::new());
// A command a native mod registered goes to the mods
// first, as the owner's own chat does (the console is
// level 4, so every registered usage is allowed).
// level 4, so every registered usage is allowed; one with
// a permission node asks the mods for the owner).
if !g.perform_native_command(conn, 4, command.trim()) {
g.run_command(&command);
}
+54 -11
View File
@@ -262,6 +262,11 @@ pub(super) struct LanHost {
/// Native mods' commands and the level each needs, once asked
/// (game/native_server.rs).
pub native_commands: Option<HashMap<String, u8>>,
/// The permission node of each native mod's command that has one
/// (`NativeCommand::permission`), in registration order.
pub native_command_nodes: Vec<(String, String)>,
/// The usages behind them, as the mods registered them.
pub native_usages: Vec<horizon_client::mod_api::NativeCommand>,
/// The players a native mod leaves out of the sleep count when they
/// aren't in bed (`native_sleep_ignored`).
pub sleep_ignored: std::collections::HashSet<u128>,
@@ -1450,6 +1455,8 @@ impl Game<'_> {
quiet: std::collections::VecDeque::new(),
far,
native_commands: None,
native_command_nodes: Vec::new(),
native_usages: Vec::new(),
sleep_ignored: Default::default(),
states: Default::default(),
states_next_entity: None,
@@ -2082,12 +2089,16 @@ impl Game<'_> {
}
HostEvent::Joined(conn) => {
// The mods may refuse the login before the player is placed.
if let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
if let Some((uuid, name, permission)) = host
.hub
.guest(conn)
.map(|g| (g.uuid, g.name.clone(), g.permission))
{
let mut cancel = false;
let mut reason = None;
self.native_login_permission = Some((uuid, permission));
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
self.native_login_permission = None;
if cancel {
let reason = match reason {
Some(text) => {
@@ -2158,6 +2169,8 @@ impl Game<'_> {
{
self.lan = Some(host);
self.native_player_logged_in(uuid, &name);
// What the client said in its configuration (native_packets.rs).
self.configuration_heard(uuid, &name);
// A hook's command can close the hub.
host = self.lan.take()?;
}
@@ -2172,6 +2185,11 @@ impl Game<'_> {
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
// Bukkit's PlayerQuitEvent: the mods hear them leave while
// they are still here.
if !host.players_told_left {
host = self.tell_mods_logging_out(host, conn)?;
}
self.guest_disconnected(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
@@ -2404,7 +2422,7 @@ impl Game<'_> {
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
if !host.hub.native_menu_events.is_empty() || !host.hub.native_menu_returns.is_empty() {
self.lan = Some(host);
horizon_client::spans::time("lan_menu_events", || self.run_native_menu_events());
host = self
@@ -2412,6 +2430,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the menu events");
}
if !self.native_moves.is_empty() {
self.lan = Some(host);
horizon_client::spans::time("lan_native_moves", || self.run_native_moves());
host = self
.lan
.take()
.expect("the hub is back after the mods' moves");
}
host.states = entities;
self.level_pass.packets_drained = true;
drop(holding);
@@ -2549,6 +2575,7 @@ impl Game<'_> {
// What the tick sends a guest goes to its connection at the end.
let holding = lan::Holding::start();
self.register_native_commands(&mut host);
self.resend_native_trees(&mut host);
// The host's own commands' (and command blocks') players moving
// to another level, and their broadcastToAdmins lines.
if !self.level_teleports.is_empty() {
@@ -2591,13 +2618,7 @@ impl Game<'_> {
let mut states = std::mem::take(&mut host.states);
if has_guests {
self.lan_assign_ids();
let guests: Vec<(u32, i32)> = host
.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(&guests, &mut host.hub.riding);
self.lan_riding(&host.hub.guests, &mut host.hub.riding);
// Idle states are kept only when the players' moves are in:
// the clients that could pair are known. The range scale is
// the one the tracker gets below.
@@ -2765,7 +2786,7 @@ impl Game<'_> {
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
if !host.hub.native_menu_events.is_empty() || !host.hub.native_menu_returns.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
host = self
@@ -2803,6 +2824,7 @@ impl Game<'_> {
drop(holding);
self.flush_attachment_sync(&mut host);
self.flush_native_net(&mut host);
self.flush_native_player_fx(&mut host);
self.flush_far_terrain(&mut host);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
@@ -2870,6 +2892,27 @@ impl Game<'_> {
g.food = 20;
g.dimension = super::Dim::Overworld.id().to_owned();
}
// Spigot's PlayerSpawnLocationEvent: the mods may place them elsewhere.
let (mut dimension, mut position, mut rotation) =
(g.dimension.clone(), g.position, [g.yaw, g.pitch]);
self.native_player_spawn_location(
g.uuid,
&g.name,
&mut dimension,
&mut position,
&mut rotation,
);
if dimension != g.dimension && super::Dim::from_id(&dimension).id() == dimension {
g.dimension = dimension;
}
if position != g.position && position.iter().all(|v| v.is_finite()) {
g.position = position;
g.previous_position = position;
}
if rotation != [g.yaw, g.pitch] && rotation.iter().all(|v| v.is_finite()) {
(g.yaw, g.pitch) = (rotation[0], rotation[1]);
g.head_yaw = g.yaw;
}
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
let back = self.dim;
let level = super::Dim::from_id(&g.dimension);
+568 -47
View File
@@ -137,6 +137,9 @@ pub(super) struct GuestPlay {
/// What `ServerPlayer` last recorded for the guest's scoreboard criteria
/// (`lastRecordedHealthAndAbsorption` and the others).
score_recorded: super::scoreboard::Recorded,
/// The window slots of the drag the guest is making, for
/// `native_inventory_drag` at its end.
drag_heard: Option<Vec<i32>>,
}
/// A guest's command waiting for the hub (`LanHost::commands`).
@@ -309,6 +312,25 @@ fn direction(face: i32) -> [i32; 3] {
}
}
/// The windows a guest has open besides their inventory: the block or mod
/// container, the crafting table, a station, a lectern, a merchant, a mount.
fn guest_open_windows(host: &LanHost, conn: u32) -> Vec<i32> {
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
windows
}
/// The hub's current bodies, across dimensions. A borrowed ancestor's
/// live body takes precedence over this saved state until its turn returns.
fn building_players(host: &LanHost) -> Vec<BuildingPlayer> {
@@ -497,14 +519,15 @@ impl Game<'_> {
motion.yaw = g.yaw;
motion.pitch = g.pitch;
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
let mode_flies = g.creative() || g.game_mode == SPECTATOR;
motion.may_fly = mode_flies || g.allow_flight;
motion.flying = g.flying;
motion.flying_speed = g.flying_speed;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
// A player frozen for a hand-over takes no damage (`Abilities.invulnerable`, which the
// void and `/kill` still pass), so its record stays true until the release.
motion.invulnerable = motion.may_fly || g.handover.is_some();
motion.invulnerable = mode_flies || g.handover.is_some();
// `ServerPlayer.isInvulnerableTo`'s connection term, as of now.
motion.client_loaded = g.client_loaded();
let _ = motion.set_health(g.health, false);
@@ -519,6 +542,7 @@ impl Game<'_> {
self.pitch = g.pitch;
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.location_armor, &mut g.location_armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
std::mem::swap(&mut self.carried, &mut g.carried);
let selected = std::mem::replace(&mut self.selected, g.selected);
@@ -620,6 +644,8 @@ impl Game<'_> {
self.lan_acting_permission.replace(g.permission),
self.lan_acting_entity.replace(g.entity_id as u32),
);
// The damage hooks name the guest by UUID from here on.
super::native_damage::note_player(g.entity_id, g.uuid);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
let equipment_breaks = std::mem::take(&mut self.guest_equipment_breaks);
@@ -1021,6 +1047,7 @@ impl Game<'_> {
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.location_armor, &mut g.location_armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
std::mem::swap(&mut self.carried, &mut g.carried);
// A teleport with a rotation (commands) turns the guest. `/rotate`
@@ -1074,7 +1101,15 @@ impl Game<'_> {
passengers: Vec::new(),
}));
}
// What moved them in their turn, for `native_player_teleport`.
let cause = horizon_client::lan::hub::teleport_cause(None);
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
let other = if left_vehicle.is_some() {
"dismount"
} else {
"unknown"
};
horizon_client::lan::hub::teleport_cause(Some(cause.unwrap_or(other)));
host.hub.teleport(i, Some(at));
} else if teleported {
// ServerPlayer.startRiding and an asPassenger transition also
@@ -1149,6 +1184,10 @@ impl Game<'_> {
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
// A use a mod canceled: the client's prediction of it is taken back.
if std::mem::take(&mut self.guest_inventory_resend) {
host.hub.send_inventory(i);
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
// An inner turn may move, kill or change a player's mode. Once its
@@ -1654,6 +1693,8 @@ impl Game<'_> {
}
host.hub.container_closed(conn, window);
}
// A mod's menu with input slots gives them back (`clearContainer`).
self.native_menu_returned(host, conn, removal);
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_removed(host, conn, None, removal);
if removal == MenuRemoval::Disconnected {
@@ -1806,19 +1847,7 @@ impl Game<'_> {
if self.guest_menu_still_valid(host, conn) {
return;
}
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
let windows = guest_open_windows(host, conn);
self.guest_close_all(host, conn);
for container in windows {
host.hub
@@ -1826,6 +1855,76 @@ impl Game<'_> {
}
}
/// The mods hear a guest's click in their own inventory or a vanilla
/// menu they have open (`native_inventory_click`, and a drag's end
/// `native_inventory_drag`) before any handler does. A canceled click
/// becomes a drag start of no type, which every menu takes as
/// `resetQuickCraft` and nothing else, so it moves nothing and the
/// client's predicted slots and cursor are answered as for any click the
/// server refused.
fn lan_inventory_click_hooks(
&mut self,
host: &mut LanHost,
conn: u32,
p: &mut play::ContainerClick,
) {
let Some(g) = host.hub.guest(conn).filter(|g| !g.spectator()) else {
return;
};
// A mod's own menu is `native_menu_clicked`'s.
if g.menu
.as_ref()
.is_some_and(|m| m.window == p.container && m.slots == Slots::Native)
|| (p.container != 0 && !guest_open_windows(host, conn).contains(&p.container))
{
return;
}
let (uuid, name) = (g.uuid, g.name.clone());
let play = host.play.entry(conn).or_default();
let mut cancel = false;
if p.input == 5 {
// A drag is heard once, at its end, with the slots it went over.
match p.button & 3 {
0 => play.drag_heard = Some(Vec::new()),
1 => {
let slot = i32::from(p.slot);
if let Some(slots) = play.drag_heard.as_mut()
&& !slots.contains(&slot)
{
slots.push(slot);
}
}
_ => {
if let Some(slots) = play.drag_heard.take() {
let kind = i32::from(p.button >> 2) & 3;
self.native_inventory_drag(
uuid,
&name,
p.container,
kind,
&slots,
&mut cancel,
);
}
}
}
} else {
play.drag_heard = None;
self.native_inventory_click(
uuid,
&name,
p.container,
i32::from(p.slot),
i32::from(p.button),
p.input,
&mut cancel,
);
}
if cancel {
(p.slot, p.button, p.input) = (-999, 3 << 2, 5);
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
@@ -1952,6 +2051,27 @@ impl Game<'_> {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
// Plugin channels a client registers in play (minecraft:register), from a dead player
// too; the packet goes on as before.
if let S::CustomPayload(p) = &packet
&& let Some(register) = horizon_client::lan::channel_registration(&p.channel)
{
self.guest_channels(host, gi, &p.data, register);
}
// A plugin channel's message on a channel that isn't one of Horizon's own (the match below
// takes those) is for the mods, from a dead player too: vanilla's handleCustomPayload doesn't
// look at health.
let packet = match packet {
S::CustomPayload(p)
if p.channel != horizon_protocol::quiet::CHANNEL
&& p.channel != horizon_protocol::mod_net::CHANNEL
&& p.channel != horizon_client::lod_net::CHANNEL =>
{
self.guest_custom_payload(host, conn, p.channel, p.data);
return;
}
packet => packet,
};
// A dead player acts no more, but the death screen still respawns and chats: its
// commands run as anyone's (ServerGamePacketListenerImpl.handleChatCommand doesn't
// look at the player's health).
@@ -1976,6 +2096,13 @@ impl Game<'_> {
) {
self.guest_validate_menu(host, conn);
}
let mut packet = packet;
if let S::ContainerClick(p) = &mut packet {
self.lan_inventory_click_hooks(host, conn, p);
}
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let spectator = host.hub.guests[gi].spectator();
match packet {
S::PlayerAction(a) => match a.action {
@@ -2300,6 +2427,9 @@ impl Game<'_> {
.border
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
{
if spectator && self.spectator_use_portal(host, conn, gi, p.pos) {
return;
}
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
@@ -2307,6 +2437,7 @@ impl Game<'_> {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let predicted = g.predicted_swings;
// InteractionResult.consumesAction: a FAIL (`use_failed`,
// as BlockItem.place's failed placement) does not count,
// even though the click ended on it.
@@ -2324,7 +2455,8 @@ impl Game<'_> {
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
g.forced_hit = None;
g.guest_use = None;
(g.swings_started != swings, consumed, attempt)
let echo = g.swings_started != swings && g.predicted_swings == predicted;
(echo, consumed, attempt)
});
let swung = used.map(|u| u.0);
self.lan_swing_back(host, conn, swung);
@@ -2551,6 +2683,7 @@ impl Game<'_> {
S::ResourcePack(r) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_resource_pack_status_v2(uuid, &name, r.id, r.action);
g.native_resource_pack_status(uuid, &name, r.action);
});
}
@@ -2606,6 +2739,8 @@ impl Game<'_> {
}
}
S::ContainerClick(p) => {
// A mod's menu on an away player's file: read before the click.
let away = self.away_menu_click_start(host, gi, p.container);
let rules = ItemRules(world_items!(self));
let slots = host.hub.guests[gi]
.menu
@@ -2614,6 +2749,10 @@ impl Game<'_> {
.map(|m| m.slots);
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
let drops = match away {
Some(click) => self.away_menu_click_end(host, gi, click, drops),
None => drops,
};
// A click that changed the container's slots: BlockEntity.setChanged,
// so comparators read it again (a double chest's other half follows).
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
@@ -2650,6 +2789,7 @@ impl Game<'_> {
self.close_guest_vehicle_menu(host, conn, key);
}
host.hub.container_closed(conn, p.container);
self.native_menu_returned(host, conn, MenuRemoval::Live);
self.guest_carried_back(host, gi);
}
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
@@ -2662,6 +2802,9 @@ impl Game<'_> {
S::CustomPayload(p) if p.channel == horizon_client::lod_net::CHANNEL => {
self.guest_far_terrain(host, conn, &p.data)
}
S::CustomPayload(p) if p.channel == horizon_client::lan::recipes::TRANSFER_CHANNEL => {
self.guest_recipe_transfer(host, conn, &p.data);
}
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
@@ -2952,7 +3095,10 @@ impl Game<'_> {
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
let mut cancel = false;
let mut hook = |game: &mut Self| {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
game.native_creative_slot_v2(uuid, &name, slot, stack, &mut cancel);
if !cancel {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel);
}
};
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
@@ -3019,6 +3165,7 @@ impl Game<'_> {
at,
rotation: Some([yaw, pitch]),
vehicle: None,
cause: Some("spectate"),
});
return;
}
@@ -3027,6 +3174,7 @@ impl Game<'_> {
// A new body there: no fall distance or motion carried over, but the
// player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
horizon_client::lan::hub::teleport_cause(Some("spectate"));
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
@@ -3440,12 +3588,24 @@ impl Game<'_> {
self.guest_tempban_command(host, conn, &words);
return;
}
// A native mod's command, for every player its level allows,
// whether the world allows commands or not (game/native_server.rs).
// A native mod's command, for every player its level allows (or,
// for one with a permission node, whom the node lets: asked when it
// runs), whether the world allows commands or not
// (game/native_server.rs).
let integrated = host.hub.config.integrated;
if let Some(level) = Self::native_command_level(host, command.trim_start_matches('/'))
&& let Some(permission) = host.hub.guest(conn).map(|g| g.permission)
&& permission >= level
let line = command.trim_start_matches('/');
// The level this one command runs at, which the mods may change.
let Some((uuid, name, mut permission)) = host
.hub
.guest(conn)
.map(|g| (g.uuid, g.name.clone(), g.permission))
else {
return;
};
self.native_player_command_level(uuid, &name, line, &mut permission);
let permission = permission.min(4);
if let Some(level) = Self::native_command_level(host, line)
&& (permission >= level || Self::native_command_node(host, line).is_some())
{
host.commands.push(GuestCommand {
conn,
@@ -3472,10 +3632,8 @@ impl Game<'_> {
return;
}
// The guest's own level (Commands.performCommand with the player's
// source): an operator's on a dedicated server, never the host's.
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
// source): an operator's on a dedicated server, never the host's,
// unless a mod changed it for this command.
host.commands.push(GuestCommand {
conn,
command: command.trim_start_matches('/').to_owned(),
@@ -3503,12 +3661,19 @@ impl Game<'_> {
/// One guest's command as that guest, its feedback sent to them; false
/// when it failed (or the guest is gone).
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
let Some(mut host) = self.lan.take() else {
return false;
};
self.run_guest_command_told(c).is_some_and(|(ok, _)| ok)
}
/// `run_guest_command`, with the lines it sent the guest; None when the
/// guest is gone.
pub(super) fn run_guest_command_told(
&mut self,
c: GuestCommand,
) -> Option<(bool, Vec<horizon_client::text::Text>)> {
let mut host = self.lan.take()?;
let Some(swap) = self.enter_guest(&mut host, c.conn, self.dim) else {
self.lan = Some(host);
return false;
return None;
};
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
@@ -3536,7 +3701,7 @@ impl Game<'_> {
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
for line in &lines {
host.hub.send_to(
c.conn,
&C::SystemChat(play::SystemChat {
@@ -3548,7 +3713,7 @@ impl Game<'_> {
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
ok
Some((ok, lines))
}
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
@@ -3653,8 +3818,31 @@ impl Game<'_> {
let own_banned_ips = std::mem::replace(&mut reg.banned_ips, banned_ips);
let own_whitelisted = std::mem::replace(&mut reg.whitelisted, whitelisted);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), mut suggestions) =
let ((mut start, mut end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
let (label, rest) = command.split_once(' ').unwrap_or((command, ""));
let arguments: Vec<String> = if command.contains(' ') {
rest.split(' ').map(str::to_owned).collect()
} else {
Vec::new()
};
// A mod's command, which vanilla's tree doesn't know: the word being typed.
if let Some(typed) = arguments.last()
&& Self::native_command_level(host, command).is_some_and(|l| l <= permission)
{
(start, end) = (command.len() - typed.len(), command.len());
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
if super::native_server::completes_player_name(
&host.native_usages,
permission,
label,
&words,
) {
let mut names = horizon_client::command::SuggestionsBuilder::new(command, start);
names.suggest_matching(host.hub.guests.iter().map(|g| g.name.as_str()));
suggestions.extend(names.out);
}
}
self.native_tab_complete(
player_uuid,
&player_name,
@@ -3662,6 +3850,14 @@ impl Game<'_> {
[start, end],
&mut suggestions,
);
self.native_tab_complete_v2(
player_uuid,
&player_name,
label,
&arguments,
[start, end],
&mut suggestions,
);
let reg = &mut self.ui.registries;
reg.permission = level;
reg.players = own_players;
@@ -3693,7 +3889,7 @@ impl Game<'_> {
);
}
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
pub(super) fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
@@ -3737,11 +3933,11 @@ impl Game<'_> {
.respawn_elsewhere
.as_ref()
.map(RespawnConfig::from_saved);
let elsewhere = own.filter(|_| server);
let mut elsewhere = own.filter(|_| server);
// In the window's world a guest has no level of its own to wait in:
// its bed is resolved at once when its chunk is there, else it
// comes back on the bed.
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
let mut in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
let spawn = in_frame_bed.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
@@ -3749,14 +3945,34 @@ impl Game<'_> {
f64::from(c.pos[2]) + 0.5,
]
});
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
let spawn = elsewhere.map_or(spawn, |c| {
let mut level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
let mut spawn = elsewhere.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]),
f64::from(c.pos[2]) + 0.5,
]
});
// PlayerRespawnPositionEvent: a point the mods choose is taken as it is.
let (mut dimension, mut at, mut rotation) = (level.id().to_owned(), spawn, [0.0f32; 2]);
let (uuid, name) = (host.hub.guests[i].uuid, host.hub.guests[i].name.clone());
let [yaw, pitch] = &mut rotation;
self.native_player_respawn_position(uuid, &name, &mut dimension, &mut at, yaw, pitch);
let known = super::Dim::from_id(&dimension).id() == dimension;
let moved = at != spawn && at.iter().all(|v| v.is_finite());
let chosen = (known && dimension != level.id()) || moved;
if chosen {
(elsewhere, in_frame_bed) = (None, None);
if known && server {
level = super::Dim::from_id(&dimension);
}
if moved {
spawn = at;
}
}
if !rotation.iter().all(|v| v.is_finite()) {
rotation = [0.0; 2];
}
let back = self.dim;
let left = super::Dim::from_id(&host.hub.guests[i].dimension);
let away = server && host.hub.guests[i].dimension != level.id();
@@ -3812,7 +4028,7 @@ impl Game<'_> {
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.respawn(i, spawn, &ctx);
host.hub.respawn(i, spawn, rotation, &ctx);
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if self.level.hardcore {
@@ -3831,7 +4047,7 @@ impl Game<'_> {
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
} else if !chosen && in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
// TeleportTransition.createDefault: no point of its own, so the
// world spawn's free column once its chunks have loaded.
self.world_spawn_trip(conn);
@@ -4047,6 +4263,30 @@ impl Game<'_> {
}
}
/// `native_player_logging_out` for guest `conn`, who has left but is still in the hub
/// and in their level (`HostEvent::Left`): the hub goes back into `self.lan` for the
/// hook, so its helpers and commands reach them, and a death it caused (`kill`) is
/// died at once, as they get no tick to die in. None when a hook's command closed the hub.
pub(super) fn tell_mods_logging_out(&mut self, host: LanHost, conn: u32) -> Option<LanHost> {
let Some((uuid, name, alive)) = host
.hub
.guest(conn)
.map(|g| (g.uuid, g.name.clone(), g.health > 0.0 && !g.dead))
else {
return Some(host);
};
self.lan = Some(host);
self.native_player_logging_out(uuid, &name);
let mut host = self.lan.take()?;
if alive
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& host.hub.guests[gi].health <= 0.0
{
self.guest_die(&mut host, gi, conn);
}
Some(host)
}
/// `ServerPlayer.die` for a guest whose health has run out: the death
/// message, the loot and experience, the death screen.
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
@@ -5346,9 +5586,10 @@ impl Game<'_> {
let Some(item) = self.dropped.iter().find(|d| d.id == item_id) else {
continue;
};
let stack = item.stack.clone();
let refused =
self.as_guest(host, conn, |g| g.native_pickup_refused(&stack)) == Some(true);
let (stack, thrower, at) = (item.stack.clone(), item.thrower, item.position);
let refused = self
.as_guest(host, conn, |g| g.native_pickup_refused(&stack, thrower, at))
== Some(true);
// The hook can remove or change any item entity.
let Some(i) = self.dropped.iter().position(|d| d.id == item_id) else {
continue;
@@ -5711,10 +5952,10 @@ impl Game<'_> {
}
/// A use the server ran swung the guest's hand: their own client is told
/// too (`swing(hand, true)` for a SwingSource.SERVER result). Horizon's
/// client does not run uses itself, so it does not predict the swing; a
/// client that did (a block placed) is still in the first half of its
/// swing and ignores it (`SwingState.startIfAble`).
/// too (`swing(hand, true)` for a result that is not SwingSource.PREDICTED).
/// A predicted swing (a block placed, a block's own SUCCESS) is the
/// client's own: sent back, it arrived after the first half of that swing
/// on a slow connection and swung the arm again (#9887).
fn lan_swing_back(&mut self, host: &mut LanHost, conn: u32, swung: Option<bool>) {
if swung != Some(true) {
return;
@@ -6208,6 +6449,7 @@ mod respawn_world_point_tests {
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[0.5, 100.0, 0.5],
@@ -6260,4 +6502,283 @@ mod respawn_world_point_tests {
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
/// A mod's health 0 for a guest dies as `/kill` does: the death message
/// and the death screen, not only a zero in the record.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mods_zero_health_kills_a_guest_as_kill_does() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-status-death-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.level.world_spawn = Some([0, 100, 0]);
game.spawn = Some(Vec3::new(0.5, 100.0, 0.5));
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "status-death-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, _rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 331,
name: "StatusGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[0.5, 100.0, 0.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
// Inside the player's own turn, as a tick hook sets it.
let set = game.as_guest(&mut host, 2, |g| {
g.native_set_player_status(331, Some(0.0), None, None)
});
assert_eq!(set, Some(true));
assert_eq!(host.hub.guests[0].health, 0.0);
game.guest_die(&mut host, 0, 2);
assert!(host.hub.guests[0].dead, "health 0 must kill");
drop(host);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
}
#[cfg(test)]
mod swing_echo_tests {
use super::ItemStack;
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::{
configuration::ClientInformation,
play::{self, Clientbound, Serverbound},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
/// handleUseItemOn swings the player's arm, but sends the swing to the
/// player's own client only when the client did not predict it: a block
/// placed (SUCCESS) is the client's own swing, a bed's use
/// (SUCCESS_SERVER) is not (#9887).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn only_a_server_only_use_swings_back_to_the_player() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-swing-echo-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("setblock 6 200 4 red_bed[facing=north,part=foot]"));
assert!(game.run_command("setblock 6 200 3 red_bed[facing=north,part=head]"));
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "swing-echo-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 331,
name: "SwingGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[4.5, 200.0, 4.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.inventory[0] = Some(ItemStack::new("minecraft:stone", 16));
guest.selected = 0;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let id = host.hub.guests[0].entity_id;
let swings_back = || {
let mut n = 0;
for out in rx.try_iter() {
let bodies = match out {
Out::Packet(body) => vec![body],
Out::Packets(bodies) => bodies,
Out::Disconnect(_) => panic!("unexpected disconnect"),
};
for body in bodies {
let mut r = horizon_protocol::buf::Reader::new(&body);
let packet_id = r.varint().unwrap();
if let Ok(Clientbound::SwingAnimation(p)) =
Clientbound::decode(packet_id, r.rest())
&& p.id == id
{
n += 1;
}
}
}
n
};
let use_on = |pos: [i32; 3], cursor: [f32; 3], sequence: i32| {
Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos,
face: 1,
cursor,
inside: false,
world_border: false,
sequence,
})
};
// BlockItem.place: SUCCESS, the client's own swing.
game.lan_guest_packet(&mut host, 2, use_on([4, 199, 6], [0.5, 1.0, 0.5], 1));
assert_eq!(game.world.block(4, 200, 6).0, stone, "the stone is placed");
assert_eq!(swings_back(), 0, "a placement is not swung back");
// The swing ends before the next use can start one.
for _ in 0..8 {
game.lan_swings(&mut host);
}
host.hub.guests[0].selected = 1;
// BedBlock.useWithoutItem: SUCCESS_SERVER, the server's swing only.
game.lan_guest_packet(&mut host, 2, use_on([6, 200, 4], [0.5, 0.5625, 0.5], 2));
assert_eq!(swings_back(), 1, "a bed's use is swung back");
}
}
+83 -21
View File
@@ -10,7 +10,10 @@ use horizon_client::{
furnace::FurnaceCooking,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
menu::{
Env, Input, Kind, Menu,
place::{PostPlace, TransferError},
},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -125,16 +128,27 @@ impl Game<'_> {
menu: &mut Menu,
op: impl FnOnce(&mut Menu, &Env) -> R,
) -> R {
// The cartography table reads and makes the host level's maps.
let maps = (menu.kind() == Kind::Cartography).then(|| {
// Crafting and cartography read and derive this level's saved maps.
let maps = matches!(
menu.kind(),
Kind::Cartography | Kind::Crafting | Kind::Inventory
)
.then(|| {
let g = &host.hub.guests[gi];
let held: Vec<ItemStack> = (0..menu.slot_count())
(0..menu.slot_count())
.filter_map(|s| menu.slot(s).cloned())
.chain(g.inventory.iter().flatten().cloned())
.chain(g.carried.clone())
.collect();
self.level_maps(&held)
});
.collect::<Vec<_>>()
})
.filter(|held| {
menu.kind() == Kind::Cartography
|| held
.iter()
.filter_map(super::maps::map_id)
.any(|id| self.map(id).is_some())
})
.map(|held| self.level_maps(&held));
let env = Env {
maps: maps.as_ref().map(|m| m as &dyn horizon_client::menu::Maps),
limited_crafting: host.hub.limited_crafting,
@@ -441,7 +455,6 @@ impl Game<'_> {
conn: u32,
p: &horizon_protocol::packets::recipes::PlaceRecipe,
) {
use horizon_client::menu::place::PostPlace;
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
@@ -470,7 +483,63 @@ impl Game<'_> {
let (player, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
let creative = g.creative();
let placed = if p.container == 0 {
let placed = self.lan_apply_recipe(host, gi, p.container, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
if placed == Some(PostPlace::Ghost) {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
}
pub(super) fn guest_recipe_transfer(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
use horizon_protocol::buf::{Reader, Writer};
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[gi];
if g.brand != "horizon" || g.spectator() {
return;
}
let mut reader = Reader::new(data);
let (Ok(container), Ok(id)) = (reader.varint(), reader.string(512)) else {
return;
};
if reader.remaining() != 0 {
return;
}
let result = if container == 0 && (g.menu.is_some() || g.creative()) {
Err(TransferError::Unavailable)
} else {
self.lan_apply_recipe(host, gi, container, |menu, env| {
menu.transfer_recipe(env, &id)
})
.unwrap_or(Err(TransferError::Unavailable))
};
let mut reply = Writer::new();
reply.varint(container);
reply.u8(match result {
Ok(()) => 0,
Err(TransferError::Missing) => 1,
Err(TransferError::NoRoom) => 2,
Err(TransferError::Unavailable) => 3,
});
host.hub.guests[gi].send(&C::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_client::lan::recipes::TRANSFER_CHANNEL.into(),
data: reply.data,
},
));
}
fn lan_apply_recipe<R>(
&mut self,
host: &mut LanHost,
gi: usize,
container: i32,
apply: impl FnOnce(&mut Menu, &Env) -> R,
) -> Option<R> {
let conn = host.hub.guests[gi].conn;
let placed = if container == 0 {
let crafting = host.play.entry(conn).or_default();
let mut menu = crafting
.crafting
@@ -482,7 +551,7 @@ impl Game<'_> {
for (k, s) in grid.into_iter().enumerate() {
menu.set_slot(env, 1 + k, s);
}
menu.place_recipe(env, &id, p.use_max, creative, None)
apply(menu, env)
});
let g = &mut host.hub.guests[gi];
for k in 0..4 {
@@ -493,23 +562,16 @@ impl Game<'_> {
host.hub.send_inventory(gi);
placed
} else {
let Some((window, state, mut menu, pos)) = host
let (window, state, mut menu, pos) = host
.play
.get_mut(&conn)
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
else {
return;
};
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == container))?;
let placed = self.guest_menu_op(host, gi, &mut menu, apply);
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
placed
};
if placed == PostPlace::Ghost {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
Some(placed)
}
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
+12 -4
View File
@@ -6,6 +6,7 @@
use super::Game;
use horizon_client::lan::{
entity::{EntityState, Riding, States},
guest::Guest,
hub::Hub,
playerdata::ints_uuid,
};
@@ -157,10 +158,17 @@ impl Game<'_> {
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
/// mobs on mobs.
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
pub(super) fn lan_riding(&self, guests: &[Guest], riding: &mut Riding) {
riding.clear();
// The server's players in their boats and minecarts.
for &(vehicle, guest) in guests {
// The server's players in their boats and minecarts, in the order they
// got in: the first one drives (`getControllingPassenger`), and its
// client is the one that moves the vehicle.
let mut seated: Vec<(u64, u32, i32)> = guests
.iter()
.filter_map(|g| Some((g.vehicle_order, g.vehicle?, g.entity_id)))
.collect();
seated.sort_by_key(|&(order, ..)| order);
for (_, vehicle, guest) in seated {
riding.add(vehicle as i32, guest);
}
for m in self.mobs.iter().filter(|m| !m.removed) {
@@ -462,7 +470,7 @@ impl Game<'_> {
/// The synched data of a text, block or item display kept unsimulated,
/// from its compound (`Display.readAdditionalSaveData`), with the
/// defaults to send when a value goes back to one.
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
pub(super) fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
use horizon_client::text_display::{display_data, text_display_data};
let empty = Tag::Compound(Default::default());
let (meta, defaults) = match e.kind.as_str() {
@@ -159,6 +159,26 @@ fn guest_view(mut v: PlayerView, uuid: u128, moved: [f64; 3]) -> PlayerView {
}
impl Game<'_> {
/// How mobs see the guest `conn`, as `lan_guest_views` gives each: None
/// for no such guest in play here, or while the hub is taken out.
pub(super) fn lan_guest_view(&mut self, conn: u32) -> Option<PlayerView> {
let mut host = self.lan.take()?;
let found = host
.hub
.guests
.iter()
.find(|g| g.conn == conn && host.hub.here(g) && g.loaded && !g.dead)
.map(|g| {
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
(g.uuid, moved)
});
let view = found.and_then(|(uuid, moved)| {
let v = self.as_guest(&mut host, conn, |g| g.player_view())??;
Some(guest_view(v, uuid, moved))
});
self.lan = Some(host);
view
}
/// How mobs see each guest in play (`player_view` with the guest swapped
/// in).
pub(super) fn lan_guest_views(&mut self) -> GuestViews {
@@ -978,6 +978,7 @@ mod tests {
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: format!("127.0.0.1:{}", 25565 + conn).parse().unwrap(),
expected: false,
configured: Default::default(),
},
1,
at,
+113 -2
View File
@@ -30,6 +30,42 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
)
}
/// The player-versus-player hit being landed, which `native_player_hurt_by_player`
/// hears at the victim's `hurtServer` (`native_hooks::hurt_server`).
#[derive(Clone, Copy)]
pub(super) struct PvpHit {
pub(super) attacker: u128,
pub(super) attacker_entity: i32,
pub(super) victim: u128,
pub(super) victim_entity: i32,
/// `melee`, `sweep` or the projectile's entity type.
pub(super) source: &'static str,
}
thread_local! {
static PVP_HIT: std::cell::Cell<Option<PvpHit>> = const { std::cell::Cell::new(None) };
}
/// The hit being landed on the player `victim_entity`, if one is.
pub(super) fn pvp_hit(victim_entity: i32) -> Option<PvpHit> {
PVP_HIT
.get()
.filter(|hit| hit.victim_entity == victim_entity)
}
/// Names `hit` as the one being landed until it drops.
struct PvpScope(Option<PvpHit>);
impl Drop for PvpScope {
fn drop(&mut self) {
PVP_HIT.set(self.0);
}
}
fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
PvpScope(PVP_HIT.replace(hit))
}
/// Who a player's attack lands on: a player by connection.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Victim {
@@ -259,10 +295,25 @@ impl Game<'_> {
at,
};
let shooter = self.player_attacker(&host, p.guest);
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| a.spectral || &*a.pickup_item.item == "minecraft:spectral_arrow");
let source = if spectral {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let hit = self.pvp_hit_of(&host, p.guest, victim, source);
let zero = matches!(p.kind, ProjectileKind::Snowball | ProjectileKind::Egg);
let result = match victim {
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
g.combat.refuse_player_hit = refused;
g.combat.projectile_attacker = shooter;
let _hit = pvp_scope(hit);
if zero && !refused {
g.pvp_zero_hurt(hit, "minecraft:thrown");
}
let r = g.projectile_hit(p, on_player);
g.combat.refuse_player_hit = false;
g.combat.projectile_attacker = None;
@@ -273,6 +324,58 @@ impl Game<'_> {
result.unwrap_or(Ok(()))
}
/// The hit of `attacker` (a guest; none: this game's player) on `victim` as
/// `native_player_hurt_by_player` names it, none when either has gone.
fn pvp_hit_of(
&mut self,
host: &LanHost,
attacker: Option<u32>,
victim: Victim,
source: &'static str,
) -> Option<PvpHit> {
let Victim::Guest(conn) = victim;
let (victim, victim_entity) = host.hub.guest(conn).map(|g| (g.uuid, g.entity_id))?;
let (attacker, attacker_entity) = match attacker {
Some(conn) => host.hub.guest(conn).map(|g| (g.uuid, g.entity_id))?,
None => (self.native_acting_id(), self.player.as_ref()?.entity_id),
};
Some(PvpHit {
attacker,
attacker_entity,
victim,
victim_entity,
source,
})
}
/// `Player.hurtServer` of a snowball's or egg's zero damage on the player in place: past
/// its guards (not creative, a spectator or dead) the mods hear it, then it stops on zero.
fn pvp_zero_hurt(&mut self, hit: Option<PvpHit>, damage_type: &str) {
let Some(hit) = hit else {
return;
};
if self.creative
|| self.spectator()
|| self
.player
.as_ref()
.is_none_or(|p| p.health.dead_or_dying())
{
return;
}
let (mut amount, mut canceled) = (0.0, false);
Self::native_player_hurt_by_player(
hit.attacker,
hit.attacker_entity,
hit.victim,
hit.victim_entity,
hit.source,
damage_type,
&mut amount,
&mut canceled,
);
}
pub(super) fn player_attacker(&self, host: &LanHost, conn: Option<u32>) -> Option<(u32, Text)> {
match conn {
Some(conn) => {
@@ -314,6 +417,7 @@ impl Game<'_> {
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
});
let attacker_id = self.player.as_ref().map_or(0, |p| p.entity_id);
let hit = self.pvp_hit_of(host, s.attacker, victim, "melee");
let land = |g: &mut Self| {
let _attacker =
g.knockback_scope(attacker_note.as_ref().map_or(attacker_id, |(id, _)| *id));
@@ -321,7 +425,10 @@ impl Game<'_> {
if let Some((id, name)) = attacker_note.clone() {
g.note_player_attacker("minecraft:player_attack", id as u32, name, s.item.clone());
}
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
let hurt = {
let _hit = pvp_scope(hit);
g.mob_hits_player(s.amount, s.from, "minecraft:player_attack")
};
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
@@ -537,8 +644,12 @@ impl Game<'_> {
{
continue;
}
let hit = self.pvp_hit_of(host, sweep.attacker, victim, "sweep");
let Victim::Guest(conn) = victim;
self.guest_struck_by(host, conn, land);
self.guest_struck_by(host, conn, |g| {
let _hit = pvp_scope(hit);
land(g)
});
}
}
}
@@ -36,6 +36,36 @@ fn uuid_words(uuid: u128) -> [i32; 4] {
}
impl Game<'_> {
/// Development oracle: observe the actual open guest merchant, never a synthetic menu.
pub(super) fn harness_merchant(
&self,
host: &LanHost,
conn: u32,
) -> Option<(i32, i32, serde_json::Value)> {
use serde_json::json;
let gm = host.play.get(&conn)?.merchant.as_ref()?;
let guest = host
.hub
.guests
.iter()
.find(|g| g.conn == conn && g.placed)?;
let id = gm.merchant.mob?;
self.mobs
.iter()
.find(|m| m.id == id && m.alive() && !m.removed)?;
let stack = |s: &Option<ItemStack>| s.as_ref().map(|s| json!([s.item.as_ref(), s.count]));
let session = &gm.merchant.session;
Some((
gm.window,
gm.state,
json!({"window":gm.window,"merchant":self.persist.uuids.get(&id)?,
"player":uuid_words(guest.uuid),"slots":gm.slots.iter().map(stack).collect::<Vec<_>>(),
"carried":stack(&guest.carried),"xp":session.xp,
"offers":session.offers.iter().map(|o|json!({"uses":o.uses,
"special":o.special_price_diff,"cost":o.cost_a_count(self.items())})).collect::<Vec<_>>()}),
))
}
/// A trading screen the guest's interaction opened (it opened as a
/// station while the guest acted): handed to the guest.
pub(super) fn lan_open_merchant(
@@ -271,6 +301,7 @@ impl Game<'_> {
let made = maps.made.take();
let session = menu.merchant.take().expect("attached");
let dropped = std::mem::take(&mut menu.dropped);
let crafted_counts = std::mem::take(&mut menu.crafted_counts);
gm.slots = [
menu.slot(0).cloned(),
menu.slot(1).cloned(),
@@ -285,6 +316,14 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
// MerchantResultSlot checks achievements before notifying the merchant.
if !crafted_counts.is_empty() {
self.as_guest(host, conn, |g| {
for (item, count) in crafted_counts {
g.award_stat("crafted", &item, count);
}
});
}
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
gm.merchant.session = session;
if !traded.is_empty() {
+246 -23
View File
@@ -38,11 +38,13 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
/// A boat or minecart a server player rode, followed for
/// `native_vehicle_moved`.
/// A boat, minecart or mount a server player rode, followed for
/// `native_vehicle_moved` (a mount only for its v2).
pub(super) struct WatchedVehicle {
id: u32,
dim: Dim,
/// A mob (a horse, a pig), which v1 does not hear.
mount: bool,
/// The server player it carries now.
rider: Option<u128>,
/// Where the hook last heard of it.
@@ -52,18 +54,25 @@ pub(super) struct WatchedVehicle {
}
impl Game<'_> {
/// Where the boat or minecart `id` of this level is.
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
/// Where the boat, minecart or mount `id` of this level is, and whether
/// it is a mount.
fn watched_vehicle_position(&self, id: u32) -> Option<([f64; 3], bool)> {
let at = |v: Vec3| [v.x, v.y, v.z];
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| at(b.position))
.map(|b| (at(b.position), false))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| at(c.position))
.map(|c| (at(c.position), false))
})
.or_else(|| {
self.mobs
.iter()
.find(|m| m.id == id && !m.removed)
.map(|m| (at(m.position), true))
})
}
@@ -82,7 +91,7 @@ impl Game<'_> {
}
let dim = self.dim;
for &(id, _, uuid) in &riders {
let Some(at) = self.watched_vehicle_position(id) else {
let Some((at, mount)) = self.watched_vehicle_position(id) else {
continue;
};
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
@@ -90,6 +99,7 @@ impl Game<'_> {
None => self.watched_vehicles.push(WatchedVehicle {
id,
dim,
mount,
rider: Some(uuid),
at,
still: 0,
@@ -97,7 +107,7 @@ impl Game<'_> {
}
}
let mut kept = Vec::new();
// (rider's connection and UUID, vehicle type, from, to)
// (rider's connection and UUID, vehicle, type, mount, from, to)
let mut moved = Vec::new();
for mut w in std::mem::take(&mut self.watched_vehicles) {
// Another level's vehicle is told of on that level's tick.
@@ -106,7 +116,7 @@ impl Game<'_> {
continue;
}
// Broken, or gone from this level.
let Some(now) = self.watched_vehicle_position(w.id) else {
let Some((now, _)) = self.watched_vehicle_position(w.id) else {
continue;
};
let rider = riders.iter().find(|r| r.0 == w.id);
@@ -118,7 +128,7 @@ impl Game<'_> {
self.level_entity(w.id as i32)
.unwrap_or(super::commands::Target::Boat(w.id)),
);
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
moved.push((rider.map(|r| (r.1, r.2)), w.id, kind, w.mount, from, now));
} else {
w.still += 1;
if w.rider.is_none() && w.still > 200 {
@@ -130,21 +140,66 @@ impl Game<'_> {
self.watched_vehicles = kept;
// The rider's state is swapped in for the call, so the helpers reach
// them; a vehicle nobody rides is told of with nobody swapped in.
for (rider, kind, from, to) in moved {
let Some((conn, uuid)) = rider else {
self.native_vehicle_moved(None, &kind, from, to);
continue;
for (rider, id, kind, mount, from, to) in moved {
let (mut dest, mut cancel) = (to, false);
let mut hook = |game: &mut Self| {
if !mount {
game.native_vehicle_moved(rider.map(|r| r.1), &kind, from, to);
}
let player = rider.map(|r| r.1);
game.native_vehicle_moved_v2(
player,
dim.id(),
id,
&kind,
from,
&mut dest,
&mut cancel,
);
};
let Some(mut host) = self.lan.take() else {
continue;
};
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
match rider {
None => hook(self),
Some((conn, _)) => {
let Some(mut host) = self.lan.take() else {
continue;
};
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
}
self.lan = Some(host);
}
}
self.lan = Some(host);
self.native_vehicle_move_answer(id, from, to, dest, cancel);
}
}
/// `native_vehicle_moved_v2`'s answer: a canceled move puts the vehicle
/// back at `from`, a changed `to` puts it there, with its riders
/// (`native_teleport_vehicle`), and the watch hears it from there.
fn native_vehicle_move_answer(
&mut self,
id: u32,
from: [f64; 3],
to: [f64; 3],
dest: [f64; 3],
cancel: bool,
) {
let at = if cancel {
from
} else if dest != to && dest.iter().all(|v| v.is_finite()) {
dest
} else {
return;
};
if !self.native_teleport_vehicle(self.dim.id(), id, at) {
return;
}
if let Some((now, _)) = self.watched_vehicle_position(id)
&& let Some(w) = self.watched_vehicles.iter_mut().find(|w| w.id == id)
{
w.at = now;
}
}
@@ -387,6 +442,13 @@ impl Game<'_> {
if g.awaiting_teleport.is_some() || !g.client_loaded() {
return;
}
// Only the vehicle's controlling passenger moves it: the move of a
// player in a boat's second seat is ignored (#11541).
if g.vehicle
.is_some_and(|v| !self.guest_controls_vehicle(host, conn, v))
{
return;
}
let owner = host.hub.singleplayer_owner == Some(g.uuid);
let (vehicle, good) = (g.vehicle, g.vehicle_good);
let back = |host: &mut LanHost, position: Vec3, yaw: f32| {
@@ -563,3 +625,164 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod controller_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::{configuration::ClientInformation, play};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn guest(conn: u32, uuid: u128) -> Guest {
let (tx, _rx) = mpsc::channel();
let mut g = Guest::new(
Joined {
conn,
uuid,
name: format!("Rider{conn}"),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
1000 + conn as i32,
[4.5, 200.0, 4.5],
2,
);
g.dimension = "minecraft:overworld".into();
g.placed = true;
g.loaded = true;
// PlayerLoaded came in: its moves count.
g.load_timeout = 0;
g
}
/// Two players in one boat: the one that got in first drives it
/// (`getControllingPassenger`), whichever joined the server first. Its
/// client hears it sits first, and only its moves count; the second
/// seat's client, which still sends where it sees the boat, used to put
/// the boat back every tick (#11541).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_first_rider_in_drives_the_boat() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-boat-driver-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecraft:oak_boat 4.5 200 4.5"));
let boat = game.boats[0].id;
let start = game.boats[0].position;
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "boat-driver-test".into(), 3)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 3,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let mut host = game.lan.take().unwrap();
// The second seat's player joined the server first; the driver got
// into the boat first.
let (mut passenger, mut driver) = (guest(2, 21), guest(3, 22));
(driver.vehicle, driver.vehicle_order) = (Some(boat), 1);
(passenger.vehicle, passenger.vehicle_order) = (Some(boat), 2);
let (passenger_id, driver_id) = (passenger.entity_id, driver.entity_id);
host.hub.guests.push(passenger);
host.hub.guests.push(driver);
game.lan_riding(&host.hub.guests, &mut host.hub.riding);
assert_eq!(
host.hub.riding.passengers(boat as i32),
[driver_id, passenger_id],
"the driver sits first"
);
let move_to = |x: f64| play::MoveVehicleRequest {
position: [x, start.y, start.z],
yaw: 0.0,
pitch: 0.0,
on_ground: true,
};
game.guest_move_vehicle(&mut host, 2, &move_to(start.x + 0.3));
assert_eq!(
game.boats[0].position, start,
"the second seat moves nothing"
);
game.guest_move_vehicle(&mut host, 3, &move_to(start.x + 0.3));
assert!(
(game.boats[0].position.x - (start.x + 0.3)).abs() < 1.0e-9,
"the driver moves the boat: {:?}",
game.boats[0].position
);
}
}
+6
View File
@@ -532,6 +532,12 @@ impl Game<'_> {
attached |= self.take_knot_boats(knot_pos);
}
if attached {
let event_at = if knot_hit {
[knot.x, knot.y - 0.25, knot.z]
} else {
knot_pos.map(|v| f64::from(v) + 0.5)
};
self.lead_game_event("minecraft:block_attach", event_at);
self.lead_sound("tied", at);
self.swing_arm();
}
Loaded 100 of 526 files, more files were not shown because too many files have changed in this diff. Show more