From 2f883b5cdbc44dd573bbe4f16dca2eec920ae8d3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:54:13 +0200 Subject: [PATCH] Keep the command harness in the frame; compare finished border chunks - The command harness (HORIZON_COMMAND_SCRIPT) drives the world itself, without the event loop, so it does not start the server thread. - `status` lists the minecarts a client of a server was sent. - save_roundtrip: single player's server thread finishes the chunks its player tracks, as vanilla's integrated server does, including some that vanilla's server (view distance 4) left before their terrain or light. Those had no blocks, light or heightmaps to compare yet. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 2818c89f242fd0856c098ceeb7c809c776120240) --- crates/horizon-client/src/game.rs | 5 +++- crates/horizon-client/src/game/control.rs | 4 ++- crates/horizon-client/src/game/remote.rs | 22 ++++++++++++++++ tools/test_save_roundtrip_parity.py | 32 ++++++++++++++++++++--- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 35814dd6c..affc120e7 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -324,7 +324,10 @@ impl Options { dedicated: None, benchmark: None, dev_mods: Vec::new(), - server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0"), + // The command harness (HORIZON_COMMAND_SCRIPT) drives the world + // itself, without the event loop: it stays in the frame. + server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0") + && std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(), integrated: false, }; let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok(); diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 6c69904ac..28e6c56c0 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -879,7 +879,9 @@ impl Game<'_> { "tridents": self.tridents.len(), "rockets": self.rockets.len(), "fight": self.dragon_status(), - "carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::>(), + // A client of a server (single player's server thread) knows the + // carts it was sent. + "carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::>()), "riding": self.riding, "using": self.using, "render_distance": self.streaming.radius, diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index f7346199a..b770e71a9 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -529,6 +529,28 @@ impl Game<'_> { } } + /// The minecarts the server sent, for the control socket's `status` + /// (their velocity is not tracked here). + pub(super) fn remote_carts(&self) -> Option> { + let r = self.remote.as_ref()?; + let mut carts: Vec<&Tracked> = r + .entities + .values() + .filter(|t| t.kind.ends_with("minecart")) + .collect(); + carts.sort_by(|a, b| { + a.position + .partial_cmp(&b.position) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Some( + carts + .into_iter() + .map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]})) + .collect(), + ) + } + /// The names in the server's player list, sorted. pub(super) fn remote_online_names(&self) -> Option> { let r = self.remote.as_ref()?; diff --git a/tools/test_save_roundtrip_parity.py b/tools/test_save_roundtrip_parity.py index e2531a657..72bea8845 100644 --- a/tools/test_save_roundtrip_parity.py +++ b/tools/test_save_roundtrip_parity.py @@ -68,6 +68,14 @@ IGNORED = { # differently (no worldgen heightmaps, entities in entities/) is accepted. r'^chunk\.promoted\.(Status|Heightmaps\.(OCEAN_FLOOR_WG|WORLD_SURFACE_WG)|entities)$': 'a proto chunk vanilla left at its view distance, finished by Horizon', + # Single player's server thread sends the chunks its player tracks (view distance 6 + # and its outer ring), as vanilla's integrated server does, so it finishes some that + # vanilla's server (view distance 4) left before their terrain: those had no blocks, + # light or heightmaps to compare yet. + r'^chunk\.blank\.': + 'a chunk vanilla left before its terrain (no blocks yet), finished by Horizon', + r'^chunk\.unlit\.': + 'a chunk vanilla left before its light, finished (and lit) by Horizon', # Horizon keeps simulating between loading and saving (it needs a few seconds of # chunk generation first), so counters and positions of live things move on. r'^chunk\.block_entities\.\(2, -60, 5\)\.(cooking_time_spent|lit_time_remaining|Items\[\]\.count)$': @@ -464,6 +472,13 @@ def normalize_chunk(root): return out, sections +# ChunkStatus before NOISE: nothing has filled the chunk's blocks yet. +BLANK_STATUSES = {'minecraft:empty', 'minecraft:structure_starts', 'minecraft:structure_references', + 'minecraft:biomes'} +# ChunkStatus LIGHT and later: the chunk's light is saved. +LIT_STATUSES = {'minecraft:light', 'minecraft:spawn', 'minecraft:full'} + + def compare_chunks(vanilla, horizon, report): for key in sorted(set(vanilla) | set(horizon)): if key not in horizon: @@ -476,9 +491,10 @@ def compare_chunks(vanilla, horizon, report): ha, hs = normalize_chunk(horizon[key]) out = [] diff(va, ha, 'chunk', out) - if va.get('Status') != 'minecraft:full' and ha.get('Status') == 'minecraft:full': - promoted = re.compile(r'^chunk\.(Status|Heightmaps\.\w+_WG|entities)$') - out = [(path.replace('chunk.', 'chunk.promoted.', 1) if promoted.match(path) else path, p, q) + promoted = va.get('Status') != 'minecraft:full' and ha.get('Status') == 'minecraft:full' + if promoted: + rule = re.compile(r'^chunk\.(Status|Heightmaps\.\w+_WG|entities)$') + out = [(path.replace('chunk.', 'chunk.promoted.', 1) if rule.match(path) else path, p, q) for path, p, q in out] for y in sorted(set(vs) | set(hs)): empty = {'blocks': ['minecraft:air'] * 4096, 'biomes': None, 'BlockLight': None, 'SkyLight': None} @@ -493,6 +509,16 @@ def compare_chunks(vanilla, horizon, report): if a[light] != b[light]: out.append((f'chunk.sections.{light}[{y}]', None if a[light] is None else 'present', None if b[light] is None else 'present')) + # Before its terrain a chunk has no blocks, light or heightmaps yet. + if promoted and va.get('Status') in BLANK_STATUSES: + blank = re.compile(r'^chunk\.(Heightmaps|sections)\.') + out = [(path.replace('chunk.', 'chunk.blank.', 1) if blank.match(path) else path, p, q) + for path, p, q in out] + # Before its light step a chunk saves no light; Horizon lights what it finishes. + elif promoted and va.get('Status') not in LIT_STATUSES: + unlit = re.compile(r'^chunk\.sections\.(SkyLight|BlockLight)\[') + out = [(path.replace('chunk.', 'chunk.unlit.', 1) if unlit.match(path) else path, p, q) + for path, p, q in out] for path, p, q in out: report.append((f'{path} @{key}', p, q))