diff --git a/crates/horizon-client/src/bin/horizon-ctl/commands.rs b/crates/horizon-client/src/bin/horizon-ctl/commands.rs index c5e716a034..3e47a05459 100644 --- a/crates/horizon-client/src/bin/horizon-ctl/commands.rs +++ b/crates/horizon-client/src/bin/horizon-ctl/commands.rs @@ -187,6 +187,10 @@ pub const COMMANDS: &[Cmd] = &[ &[opt("radius", Ty::Num, "search radius in blocks (default 32)"), opt("type", Ty::Str, "entity type filter")], mcp: true, local: false), cmd!("block", Observe, "block X Y Z", "the block state at a position", "", ["horizon-ctl block 10 64 -3"], &BLOCK_XYZ, mcp: true, local: false), + cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box", + "At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.", + ["horizon-ctl light 0 64 0 15 70 15"], + &[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false), cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block", "At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"], &[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false), diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 54d032d206..9bf2096aa5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6634,6 +6634,7 @@ impl Game<'_> { world.weather = self.world.weather; // An open hub keeps hearing the new level's block changes. world.block_log = self.world.block_log.as_ref().map(|_| Vec::new()); + world.light_log = self.world.light_log.as_ref().map(|_| Default::default()); // The sculk tables' watched blocks are built once per game. world.watched = std::mem::take(&mut self.world.watched); self.streaming = streaming; diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 8a6b546506..b11fdd5526 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -4,7 +4,13 @@ //! take time run as a task advanced every frame and reply when they finish or //! time out, so `horizon-ctl break 10 64 3` returns what the block dropped. use super::{Game, Screen}; -use horizon_client::{Result, block_entity_anim::Ticker, items::ItemStack, widgets}; +use horizon_client::{ + Result, + block_entity_anim::Ticker, + chunk::{MIN_Y, SECTIONS}, + items::ItemStack, + widgets, +}; use horizon_core::geometry::Vec3; use horizon_world::registry::BlockStateId; use serde_json::{Value, json}; @@ -27,6 +33,7 @@ pub(super) const HELP: &[&str] = &[ "entities [RADIUS] [TYPE] mobs, dropped items and orbs near the player, nearest first", "block X Y Z the block state at a position", "blocks X1 Y1 Z1 X2 Y2 Z2 the non-air blocks of a box (at most 32768 blocks)", + "light X1 Y1 Z1 X2 Y2 Z2 the stored sky and block light of a box (at most 32768 blocks)", "find-block BLOCK [RADIUS] the nearest loaded blocks of a kind (default radius 32)", "inventory hotbar, main inventory, armor, offhand and carried stack", "screen the open screen or menu, its slots and its buttons", @@ -962,6 +969,35 @@ impl Game<'_> { } ok(json!({ "from": lo, "to": hi, "counts": census, "blocks": list })) } + "light" => { + self.need_world()?; + let (p, q) = (a.block_pos(0)?, a.block_pos(3)?); + let lo = [0, 1, 2].map(|i| p[i].min(q[i])); + let hi = [0, 1, 2].map(|i| p[i].max(q[i])); + let volume: i64 = (0..3).map(|i| i64::from(hi[i] - lo[i] + 1)).product(); + if volume > 32768 { + return Err(format!("{volume} blocks; at most 32768 per request").into()); + } + // One hex digit per block, x fastest, then z, then y; `.` + // outside the loaded chunks. + let (mut sky, mut block) = (String::new(), String::new()); + for y in lo[1]..=hi[1] { + for z in lo[2]..=hi[2] { + for x in lo[0]..=hi[0] { + let packed = self + .world + .chunks + .get(&(x >> 4, z >> 4)) + .filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y)) + .map(|c| c.light(x & 15, y, z & 15)); + let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.'); + sky.push(packed.map_or('.', |v| digit(v >> 4))); + block.push(packed.map_or('.', |v| digit(v & 15))); + } + } + } + ok(json!({ "from": lo, "to": hi, "sky": sky, "block": block })) + } "find-block" => { self.need_world()?; let want = namespaced(a.get(0, "BLOCK")?); diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 114330e51a..d35bf787a5 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -504,6 +504,7 @@ impl Game<'_> { let hub = Hub::new(lan, sync, encoder, config); let port = hub.lan.port; self.world.block_log = Some(Vec::new()); + self.world.light_log = Some(Default::default()); self.lan = Some(LanHost { hub, settings: LanSettings { port, ..settings }, @@ -553,6 +554,7 @@ impl Game<'_> { // Give the writer threads a moment to flush the disconnect packets. std::thread::sleep(std::time::Duration::from_millis(100)); self.world.block_log = None; + self.world.light_log = None; self.audio.capture = None; self.lan_events.clear(); self.streaming.extra.clear(); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 474e943c0c..680360c93e 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -49,6 +49,9 @@ fn remote_text(tag: &Tag) -> horizon_client::text::Text { comp_text(&tag_to_json(tag)) } +/// A section's sky and block light layers from the wire. +type LightLayers = (Option>, Option>); + /// Streaming needs a generator; a server world never generates. struct NoTerrain; impl ChunkGenerator for NoTerrain { @@ -771,8 +774,64 @@ impl Game<'_> { PalettedContainer::from_values(BIOME_STRATEGY, &cells); } } + // ClientPacketListener.applyLightData: the column keeps the light + // the server sent; nothing is relit. + let (layers, above) = self.wire_light(&p.light); + let sky = r.dimension.has_skylight; + horizon_client::light::light_from_layers(&mut chunk, &layers, above, sky); Ok(chunk) } + /// `ClientPacketListener.readSectionList`: the sky and block layers of a + /// light packet, by section of the client's column, and the sky light + /// above the column's top section (from the light section just above + /// it, else open sky). + fn wire_light( + &self, + light: &horizon_protocol::chunk::LightData, + ) -> (Vec, [u8; 256]) { + let mut layers: Vec = vec![(None, None); SECTIONS]; + let Some(r) = self.remote.as_ref() else { + return (layers, [0; 256]); + }; + let sky = r.dimension.has_skylight; + let mut above = [if sky { 15 } else { 0 }; 256]; + // Light section b is the dimension's section b - 1. + let first = (r.dimension.min_y - MIN_Y).div_euclid(16) - 1; + for (b, layer) in light + .layers(r.dimension.sections() + 2) + .into_iter() + .enumerate() + { + let si = first + b as i32; + if (0..SECTIONS as i32).contains(&si) { + layers[si as usize] = layer; + } else if si == SECTIONS as i32 + && sky + && let Some(l) = &layer.0 + { + for (i, a) in above.iter_mut().enumerate() { + *a = (l[i >> 1] >> ((i & 1) * 4)) & 15; + } + } + } + (layers, above) + } + /// `handleLightUpdatePacket`: the sections it names take its light. + fn remote_light_update(&mut self, p: &play::LightUpdate) { + let Some(sky) = self.remote.as_ref().map(|r| r.dimension.has_skylight) else { + return; + }; + if !self.world.chunks.contains_key(&(p.x, p.z)) { + return; + } + let (layers, _) = self.wire_light(&p.light); + let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else { + return; + }; + let changed = + horizon_client::light::update_light_layers(Arc::make_mut(chunk), &layers, sky); + Streaming::light_updated(&mut self.world, p.x, p.z, &changed); + } /// `ClientChunkCache.Storage.inRange`: within the storage around the /// view centre, `calculateStorageRange` (the server's radius, at least /// two, plus three) chunks each way. @@ -1219,6 +1278,7 @@ impl Game<'_> { } Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z), }, + C::LightUpdate(p) => self.remote_light_update(&p), C::ForgetLevelChunk(p) => { let [x, z] = p.pos; self.streaming.forget(&mut self.world, x, z); diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index d6a7dc3a10..a9cf57a7d3 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -480,6 +480,33 @@ impl ChunkEncoder { w.data } + /// ClientboundLightUpdatePacket for the light sections over `changed` (a + /// bit per section of the column, `sectionLightChanged`), as vanilla's + /// `ClientboundLightUpdatePacketData` writes them, with both kinds of + /// each section (Horizon's light engine doesn't tell them apart; the + /// client keeps the same light either way). A section changed as its + /// last block went (light dimming around a removed block) is sent too: + /// a client not tracking the block would otherwise keep its old light. + pub fn light_update(&self, chunk: &Chunk, changed: u32) -> Vec { + let first = self.sections.start; + let lights: Vec<(usize, SectionLight)> = self + .light_sections(chunk, u64::MAX) + .into_iter() + .filter(|&(b, _)| { + // Light section b holds the column's section first + b - 1. + (1..=self.sections.len()).contains(&b) && changed >> (first + b - 1) & 1 != 0 + }) + .collect(); + horizon_protocol::packets::play::Clientbound::LightUpdate( + horizon_protocol::packets::play::LightUpdate { + x: chunk.x, + z: chunk.z, + light: light_data_of(&lights, self.sky), + }, + ) + .encode() + } + pub fn encode( &self, chunk: &Chunk, diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index faff066ab0..c882927875 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -988,18 +988,23 @@ impl Hub { .any(|g| g.dimension == self.config.dimension) { let _ = ctx.world.block_log.take(); + let _ = ctx.world.light_log.take(); } return events; } if ctx.world.block_log.is_none() { ctx.world.block_log = Some(Vec::new()); } + if ctx.world.light_log.is_none() { + ctx.world.light_log = Some(Default::default()); + } self.sync_world(ctx); for i in 0..self.guests.len() { if self.here(&self.guests[i]) { self.update_chunks(i, ctx); } } + self.broadcast_light_changes(ctx.world); self.broadcast_block_changes(ctx.world); // ServerGamePacketListenerImpl.tick: the actions handled since the last tick are // acknowledged after the block changes they made were sent, so a client's predicted @@ -1234,6 +1239,42 @@ impl Hub { } } + /// `ChunkHolder.broadcastChanges`' light part: the light sections that + /// changed in a column go to the players for whom it is on the border of + /// what they track (`ChunkMap.isChunkOnTrackedBorder`); the others light + /// the blocks they are sent themselves. + fn broadcast_light_changes(&mut self, world: &mut World) { + let Some(log) = world.light_log.as_mut() else { + return; + }; + if log.is_empty() { + return; + } + let mut changes: Vec<((i32, i32), u32)> = log.drain().collect(); + changes.sort_unstable(); + for ((x, z), sections) in changes { + let tracked = |g: &Guest, x: i32, z: i32| g.sent_chunks.contains(&(x, z)); + let viewers: Vec = (0..self.guests.len()) + .filter(|&i| { + let g = &self.guests[i]; + self.here(g) + && tracked(g, x, z) + && (-1..=1).any(|dz| (-1..=1).any(|dx| !tracked(g, x + dx, z + dz))) + }) + .collect(); + if viewers.is_empty() { + continue; + } + let Some(chunk) = world.chunks.get(&(x, z)) else { + continue; + }; + let body = self.encoder.light_update(chunk, sections); + for &i in &viewers { + self.guests[i].sender.send(body.clone()); + } + } + } + fn broadcast_block_changes(&mut self, world: &mut World) { let Some(log) = world.block_log.as_mut() else { return; diff --git a/crates/horizon-client/src/light.rs b/crates/horizon-client/src/light.rs index dcf4633a97..7a939b3086 100644 --- a/crates/horizon-client/src/light.rs +++ b/crates/horizon-client/src/light.rs @@ -381,6 +381,81 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec) -> Vec<(i32, i32, changed } +/// `DataLayer.get`: the nibble at index `i` (`y << 8 | z << 4 | x`), low first. +#[inline] +fn nibble(layer: &[u8], i: usize) -> u8 { + (layer[i >> 1] >> ((i & 1) * 4)) & 15 +} + +/// A column's light as vanilla stores it, two DataLayers per section (sky, +/// block; `layers[i]` for section `i`, `None` where no layer is stored), into +/// the sections' packed light, which then needs no light job. A missing sky +/// layer repeats the bottom layer of the nearest stored one above it, and +/// `above` (per `z << 4 | x`) above the highest +/// (`SkyLightSectionStorage.getLightValue`); a missing block layer is dark. +pub fn light_from_layers>( + chunk: &mut Chunk, + layers: &[(Option, Option)], + mut above: [u8; 256], + sky_light: bool, +) { + for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS).rev() { + let sky = sky.as_ref().filter(|_| sky_light); + let section = Arc::make_mut(&mut chunk.sections[si]); + if sky.is_none() && block.is_none() && above.iter().all(|&v| v == above[0]) { + section.light = Light::Uniform(above[0] << 4); + section.compact(); + continue; + } + let mut data = Box::new([0u8; 4096]); + for (i, packed) in data.iter_mut().enumerate() { + let s = sky.map_or(above[i & 255], |a| nibble(a.as_ref(), i)); + let b = block.as_ref().map_or(0, |a| nibble(a.as_ref(), i)); + *packed = s << 4 | b; + } + for (i, a) in above.iter_mut().enumerate() { + *a = data[i] >> 4; + } + section.light = Light::Full(data); + section.compact(); + } + chunk.lit = true; + chunk.light_loaded = true; +} + +/// A light update's layers (`LevelLightEngine.queueSectionData`): each one +/// given replaces that kind of light in its section, the other kind stays. +/// Returns the sections whose light changed. +pub fn update_light_layers>( + chunk: &mut Chunk, + layers: &[(Option, Option)], + sky_light: bool, +) -> Vec { + let mut changed = Vec::new(); + for (si, (sky, block)) in layers.iter().enumerate().take(SECTIONS) { + let sky = sky.as_ref().filter(|_| sky_light); + if sky.is_none() && block.is_none() { + continue; + } + let old = &chunk.sections[si].light; + let mut data = Box::new([0u8; 4096]); + for (i, packed) in data.iter_mut().enumerate() { + let was = old.get(i); + let s = sky.map_or(was >> 4, |a| nibble(a.as_ref(), i)); + let b = block.as_ref().map_or(was & 15, |a| nibble(a.as_ref(), i)); + *packed = s << 4 | b; + } + if (0..4096).all(|i| old.get(i) == data[i]) { + continue; + } + let section = Arc::make_mut(&mut chunk.sections[si]); + section.light = Light::Full(data); + section.compact(); + changed.push(si); + } + changed +} + /// Resident chunks the incremental relighter reads and writes. pub trait LightWorld { fn state(&self, x: i32, y: i32, z: i32) -> Option; @@ -809,6 +884,44 @@ mod tests { assert!(copied <= 3, "{copied} sections copied"); } + #[test] + fn stored_layers_fill_the_column_as_vanilla_reads_them() { + // Section 10's sky layer is 7 on its bottom row and 3 above; no + // layers anywhere else; a block layer of 5 in section 2. + let mut sky = vec![0x33u8; 2048]; + sky[..128].fill(0x77); + let mut layers: Vec<(Option>, Option>)> = vec![(None, None); SECTIONS]; + layers[10].0 = Some(sky); + layers[2].1 = Some(vec![0x55; 2048]); + let mut chunk = Chunk::empty(0, 0); + light_from_layers(&mut chunk, &layers, [15; 256], true); + let at = |c: &Chunk, si: usize, i: usize| c.sections[si].light(i); + assert!(chunk.lit && chunk.light_loaded); + assert!( + matches!(chunk.sections[11].light, Light::Uniform(0xF0)), + "above: open sky" + ); + assert_eq!((at(&chunk, 10, 0), at(&chunk, 10, 4095)), (0x70, 0x30)); + // Below, each section repeats the bottom row of the stored one above. + assert!(matches!(chunk.sections[9].light, Light::Uniform(0x70))); + assert_eq!(at(&chunk, 2, 100), 0x75, "block light only where stored"); + assert!(matches!(chunk.sections[0].light, Light::Uniform(0x70))); + + // A light update replaces one kind of light in the sections it names. + let mut update: Vec<(Option>, Option>)> = vec![(None, None); SECTIONS]; + update[2].1 = Some(vec![0; 2048]); + update[3].0 = Some(vec![0x77; 2048]); + let changed = update_light_layers(&mut chunk, &update, true); + assert_eq!(changed, vec![2]); + assert!(matches!(chunk.sections[2].light, Light::Uniform(0x70))); + update[3].0 = Some(vec![0xFF; 2048]); + assert_eq!( + update_light_layers(&mut chunk, &update, false), + Vec::::new(), + "no sky storage" + ); + } + #[test] fn empty_chunk_missing_neighbours_and_no_sky() { let mut chunk = Chunk::empty(0, 0); diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index c60da2caa6..ccef3e5677 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -1278,31 +1278,8 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) { }; layers[si as usize] = (layer("SkyLight"), layer("BlockLight")); } - let sky_default = if sky_light { 15u8 } else { 0 }; - // Sky nibbles of the section above's bottom layer, going down. - let mut above: [u8; 256] = [sky_default; 256]; - for si in (0..SECTIONS).rev() { - let (sky, block) = layers[si]; - let mut data = Box::new([0u8; 4096]); - let nibble = |a: &[u8], i: usize| (a[i >> 1] >> ((i & 1) * 4)) & 15; - for (i, packed) in data.iter_mut().enumerate() { - let s = match sky { - Some(a) if sky_light => nibble(a, i), - _ => above[i & 255], - }; - let b = block.map_or(0, |a| nibble(a, i)); - *packed = s << 4 | b; - } - for (i, a) in above.iter_mut().enumerate() { - *a = data[i] >> 4; - } - let mut section = (*chunk.sections[si]).clone(); - section.light = Light::Full(data); - section.compact(); - chunk.sections[si] = Arc::new(section); - } - chunk.lit = true; - chunk.light_loaded = true; + let above = [if sky_light { 15 } else { 0 }; 256]; + crate::light::light_from_layers(chunk, &layers, above, sky_light); } type RegionCache = Mutex>>>>; diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index bff0a8fd65..6acdcafd26 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -502,16 +502,38 @@ impl Streaming { } } } - /// A column received from a server: store it and relight it and its neighbours. + /// A column received from a server, with the light it sent + /// (`ClientPacketListener.handleLevelChunkWithLight`): nothing is + /// relit, and it and its neighbours remesh (`enableChunkLight`). pub fn insert_remote(&mut self, world: &mut World, chunk: Chunk) { let key = (chunk.x, chunk.z); - self.lit.remove(&key); self.lighting.remove(&key); + self.light_snapshots.remove(&key); world.chunks.insert(key, Arc::new(chunk)); + self.lit.insert(key); self.generated_total += 1; for dz in -1..=1 { for dx in -1..=1 { - world.dirty_light.insert((key.0 + dx, key.1 + dz)); + for sy in 0..SECTIONS as i32 { + world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz)); + } + } + } + } + /// A server's light update for a resident column: the sections whose + /// light changed and their neighbours remesh + /// (`setSectionDirtyWithNeighbors`). + pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) { + for &sy in sections { + for dz in -1..=1 { + for dy in -1..=1 { + for dx in -1..=1 { + let s = sy as i32 + dy; + if (0..SECTIONS as i32).contains(&s) { + world.dirty_sections.insert((x + dx, s, z + dz)); + } + } + } } } } @@ -1148,6 +1170,11 @@ impl Streaming { /// Sections whose meshes sample a changed light value (faces and corners /// read light from neighbouring blocks). fn mark_light_changes(world: &mut World, changed: &[(i32, i32, i32)]) { + if let Some(log) = &mut world.light_log { + for &(x, y, z) in changed { + *log.entry((x >> 4, z >> 4)).or_default() |= 1 << ((y - crate::chunk::MIN_Y) >> 4); + } + } let mut last = None; for &(x, y, z) in changed { for dz in -1..=1 { diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs index f6fbcd04d7..efcbf9591a 100644 --- a/crates/horizon-client/src/world.rs +++ b/crates/horizon-client/src/world.rs @@ -47,6 +47,10 @@ pub struct World { /// Every block position written since the log was last drained, kept /// only while the world is open to the network (see `lan`). pub block_log: Option>, + /// The sections whose light changed since the log was last drained, as + /// a bit per section of each column, kept while `block_log` is + /// (`ChunkHolder.sectionLightChanged`). + pub light_log: Option>, /// The villager POIs of each section (`PoiManager`'s sections). pub pois: crate::poi_sections::PoiSections, } @@ -133,6 +137,7 @@ impl World { day_time: 1000, weather: Weather::default(), block_log: None, + light_log: None, pois: Default::default(), } } diff --git a/docs/native-client.md b/docs/native-client.md index 4911c81970..fcf28246a9 100644 --- a/docs/native-client.md +++ b/docs/native-client.md @@ -109,7 +109,7 @@ game accepts, test hooks included. - **Observe:** `state` (position, rotation, velocity, health, food, saturation, XP, effects, game mode, dimension, time, weather, the screen and what the crosshair points at), `looking-at`, `entities [RADIUS] [TYPE]` (mobs with - ids and health, dropped items, orbs), `block X Y Z`, `blocks X1 Y1 Z1 X2 Y2 Z2`, + ids and health, dropped items, orbs), `block X Y Z`, `blocks X1 Y1 Z1 X2 Y2 Z2`, `light X1 Y1 Z1 X2 Y2 Z2`, `find-block BLOCK [RADIUS]`, `inventory` (stacks with their components as SNBT), `screen` (the open screen or title menu, its buttons and slots) and `messages` (chat, title, subtitle and action bar). diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index c306504ab4..4d13b1c221 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -49,6 +49,9 @@ address. The script then checks, from both sides, what a guest's game used to ge guest as the host has them (kind, place, rotation, what they hold or wear, the variant). - projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where the host has them; a falling block lands on both sides. +- light: the guest's light matches the host's: around a torch placed near it, under a roof and + once a block of the roof is removed under the sky, and in the last chunk it tracks next to + glowstone in a chunk it doesn't (only the server's light updates bring that one). Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of both sides go to --out (host-*.png, guest-*.png). @@ -81,7 +84,7 @@ ROOT = Path(__file__).resolve().parents[1] GUEST_NAME = 'Guest' # The host plays single player on its server thread (--server-thread). SERVER_THREAD = False -CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] class Game: @@ -1201,6 +1204,71 @@ def check_projectiles(host, guest, out): return f'{len(theirs)} identical, fuses (host, guest) {fuses}' +def light_differences(host, guest, lo, hi): + """Positions of the box loaded on both sides, and those whose sky or block light differ.""" + h, g = host.raw('light', *lo, *hi), guest.raw('light', *lo, *hi) + both = [i for i in range(len(h['sky'])) if h['sky'][i] != '.' and g['sky'][i] != '.'] + differ = [i for i in both if (h['sky'][i], h['block'][i]) != (g['sky'][i], g['block'][i])] + return h, g, both, differ + + +def same_light(host, guest, lo, hi, what, ticks=100): + """Wait until the guest's light over the box is the host's; the number of positions compared.""" + for _ in range(ticks // 10): + h, g, both, differ = light_differences(host, guest, lo, hi) + if not differ: + assert both, f'{what}: no position of the box is loaded on both sides' + return len(both) + host.wait(10) + i = differ[0] + nx, nz = hi[0] - lo[0] + 1, hi[2] - lo[2] + 1 + pos = (lo[0] + i % nx, lo[1] + i // (nx * nz), lo[2] + i // nx % nz) + raise AssertionError(f'{what}: {len(differ)} of {len(both)} positions differ, first {pos}: host sky ' + f'{h["sky"][i]} block {h["block"][i]}, guest sky {g["sky"][i]} block {g["block"][i]}') + + +def check_light(host, guest, out): + gx, gy, gz = (int(v // 1) for v in pos_of(guest)) + results = {} + # A torch near the guest: the guest lights it itself, as vanilla's client does. + torch = (gx + 3, gy, gz + 3) + host('command', command=f'setblock {torch[0]} {torch[1]} {torch[2]} minecraft:torch') + try: + results['torch'] = same_light(host, guest, (gx - 12, gy - 1, gz - 12), (gx + 16, gy + 3, gz + 16), 'torch') + finally: + host('command', command=f'setblock {torch[0]} {torch[1]} {torch[2]} minecraft:air') + # A roof over the guest, then a hole in it: sky light falls through. + roof = f'{gx - 3} {gy + 4} {gz - 3} {gx + 3} {gy + 4} {gz + 3}' + box = (gx - 6, gy - 1, gz - 6), (gx + 6, gy + 5, gz + 6) + host('command', command=f'fill {roof} minecraft:stone') + try: + results['roof'] = same_light(host, guest, *box, 'roof') + host('command', command=f'setblock {gx} {gy + 4} {gz} minecraft:air') + results['hole'] = same_light(host, guest, *box, 'hole in the roof') + finally: + host('command', command=f'fill {roof} minecraft:air') + # The last chunk the guest tracks eastwards, and glowstone high up just past it in a chunk it + # doesn't track: the guest never sees that block, only the light updates of its border chunk. + y = 200 + row = guest.raw('light', gx, y, gz, gx + 16 * 24, y, gz)['sky'] + assert '.' in row, 'the guest has every chunk 24 chunks east' + untracked = (gx + row.index('.')) >> 4 + near = untracked * 16 + 1 + # On its server thread the host's own game is a client too, sent the same light updates. + if not SERVER_THREAD: + assert host.raw('light', near, y, gz, near, y, gz)['sky'] != '.', 'the host lacks the chunk past the guest' + host('command', command=f'setblock {near} {y} {gz} minecraft:glowstone') + try: + lo, hi = (untracked * 16 - 16, y - 3, gz - 8), (untracked * 16 - 1, y + 3, gz + 8) + results['border'] = same_light(host, guest, lo, hi, 'border chunk') + lit = guest.raw('light', near - 2, y, gz, near - 2, y, gz)['block'] + assert lit == 'd', f'the border chunk by the glowstone has block light {lit}, not 13' + finally: + host('command', command=f'setblock {near} {y} {gz} minecraft:air') + results['border_cleared'] = same_light(host, guest, lo, hi, 'border chunk without the glowstone') + return results + + CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting, 'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, @@ -1209,7 +1277,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name 'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls, 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push, 'eat': check_eat, - 'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles} + 'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, + 'light': check_light} def run(ctl, out, resources, checks):