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 <noreply@anthropic.com>
(cherry picked from commit 2818c89f242fd0856c098ceeb7c809c776120240)
This commit is contained in:
Ludovic Marie
2026-10-17 20:54:13 +02:00
parent 0468b75c43
commit 2f883b5cdb
4 changed files with 58 additions and 5 deletions
+4 -1
View File
@@ -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();
+3 -1
View File
@@ -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::<Vec<_>>(),
// 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::<Vec<_>>()),
"riding": self.riding,
"using": self.using,
"render_distance": self.streaming.radius,
+22
View File
@@ -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<Vec<serde_json::Value>> {
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<Vec<String>> {
let r = self.remote.as_ref()?;
+29 -3
View File
@@ -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))