From 5d1038557f26b452f1c07edbcbed2a57427ca22f Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 15:47:43 +0100 Subject: [PATCH] Chunks beside old ones are decorated, their features spilling into the old chunks A chunk with a final neighbour (a chunk of an older world, loaded from the save) used to be left undecorated: no trees, ores or structure pieces in the strip along the old chunks. The streaming now decorates it with the final neighbour's blocks in the region and writes what the features changed into the final chunk, as vanilla's features write into the chunks around them. The structure starts of an old chunk are not made again from the seed. test_blending_parity.py --phase full compares the decorated chunks around old ones with vanilla. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client-render/src/streaming.rs | 108 +++++++++++++++--- crates/horizon-client/src/save.rs | 17 +++ crates/horizon-client/src/vanillagen.rs | 41 +++++++ .../examples/probe/chunk_dump.rs | 6 + .../horizon-world/src/worldgen/generator.rs | 6 + .../src/worldgen/generator_structures.rs | 3 + .../src/worldgen/structure/start.rs | 6 + .../parity/VanillaChunkGenerationOracle.java | 1 + tools/old_world_e2e.py | 8 ++ tools/port_map/levelgen-1.tsv | 4 +- tools/test_blending_parity.py | 6 +- 11 files changed, 185 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs index e754355ef1..c811f91187 100644 --- a/crates/horizon-client-render/src/streaming.rs +++ b/crates/horizon-client-render/src/streaming.rs @@ -70,6 +70,23 @@ pub trait ChunkGenerator: Send + Sync + 'static { } /// Decorate `region[4]`; the region is row-major (dz, dx) around it. fn decorate(&self, _worker: &mut Self::Worker, _region: &mut [Proto; 9]) {} + /// Whether a chunk next to final ones (loaded from the save, as the + /// chunks of a world an older version made) is decorated, its features + /// spilling into them (`proto_beside_final`, `final_writes`), as vanilla's + /// are; otherwise it is left undecorated. + fn decorates_beside_final(&self) -> bool { + false + } + /// The proto chunk of a final chunk, for a decoration region to hold: its + /// blocks are `snapshot`'s. + fn proto_beside_final(&self, _worker: &mut Self::Worker, _snapshot: &Chunk) -> Option { + None + } + /// What decoration wrote into the proto chunk of a final chunk: the blocks + /// that differ from `snapshot`, as `([x, y, z], state)`. + fn final_writes(&self, _snapshot: &Chunk, _proto: &Proto) -> Vec<([i32; 3], u32)> { + Vec::new() + } fn finish(&self, proto: Proto) -> Chunk; /// Whether `proto` has been decorated (a saved one can come back decorated). fn is_decorated(&self, _proto: &Proto) -> bool { @@ -170,7 +187,10 @@ struct MeshSettings { } enum Job { Generate(i32, i32), - Decorate(i32, i32, Box<[Proto; 9]>), + /// A region to decorate, with the final chunks in it (region index and + /// the chunk as it is): their slots hold nothing until the worker makes + /// their proto chunks. + Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>), Finish(Proto), Light(i32, i32, Box, u64, bool), Mesh(i32, i32, i32, Box, u64, MeshSettings), @@ -185,7 +205,9 @@ pub enum Done { Generated(Chunk), /// A proto chunk, and whether it was decorated (loaded so from the save). Proto(i32, i32, Proto, bool), - Decorated(i32, i32, Box<[Proto; 9]>), + /// A decorated region, the region indices of its final chunks and what + /// decoration wrote into them. + Decorated(i32, i32, Box<[Proto; 9]>, Vec, Vec<([i32; 3], u32)>), Lit(i32, i32, Vec, u64), Meshed(i32, i32, i32, SectionMesh, u64), Saved(Vec<((i32, i32), Proto)>), @@ -303,6 +325,8 @@ pub struct Streaming { queue_basis: (((i32, i32), bool, Option), Vec<((i32, i32), i32)>), finishing: FxHashSet<(i32, i32)>, decorates: bool, + /// `ChunkGenerator::decorates_beside_final`. + saves_beside_final: bool, lighting: FxHashMap<(i32, i32), u64>, meshing: FxHashMap<(i32, i32, i32), u64>, /// Chunks whose light is current. @@ -598,9 +622,25 @@ impl Streaming { Done::Proto(x, z, proto, decorated) } }, - Job::Decorate(x, z, mut region) => { + Job::Decorate(x, z, mut region, finals) => { + // The final chunks' proto chunks, made here: a + // generated proto chunk holding their blocks. + for (i, snapshot) in &finals { + if let Some(proto) = + generator.proto_beside_final(&mut worker, snapshot) + { + region[*i] = proto; + } + } generator.decorate(&mut worker, &mut region); - Done::Decorated(x, z, region) + let mut writes = Vec::new(); + for (i, snapshot) in &finals { + writes + .extend(generator.final_writes(snapshot, ®ion[*i])); + region[*i] = Box::new(()); + } + let finals = finals.into_iter().map(|(i, _)| i).collect(); + Done::Decorated(x, z, region, finals, writes) } Job::Finish(proto) => Done::Generated(generator.finish(proto)), Job::Save(protos) => { @@ -672,6 +712,7 @@ impl Streaming { queue_basis: Default::default(), finishing: Default::default(), decorates: generator.decorates(), + saves_beside_final: generator.decorates_beside_final(), lighting: Default::default(), meshing: Default::default(), lit: Default::default(), @@ -818,16 +859,32 @@ impl Streaming { let mut queue = self.shared.queue.lock().unwrap(); rank_again(&mut queue, |x, z| self.distance(x, z)); } - fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) { + fn receive_decorated( + &mut self, + x: i32, + z: i32, + region: [Proto; 9], + finals: Vec, + writes: Vec<([i32; 3], u32)>, + world: &mut World, + ) { self.generation_scan.exhausted = false; self.decorating -= 1; self.decorated.insert((x, z)); for (i, proto) in region.into_iter().enumerate() { + // A final chunk keeps its own chunk: only what decoration wrote + // into it goes there. + if finals.contains(&i) { + continue; + } let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1); self.light_views.remove(&key); self.protos.insert(key, Some(proto)); self.written.insert(key); } + for ([bx, by, bz], state) in writes { + let _ = world.set(bx, by, bz, horizon_world::registry::BlockStateId(state)); + } } /// Saved proto chunks come back; those now out of range are dropped by /// the next `update`. @@ -1048,7 +1105,9 @@ impl Streaming { } } } - Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region), + Done::Decorated(x, z, region, finals, writes) => { + self.receive_decorated(x, z, *region, finals, writes, world) + } Done::Generated(chunk) => self.receive_generated(chunk, world), Done::Saved(protos) => self.receive_saved(protos), Done::Lit(x, z, sections, seq) => { @@ -1283,22 +1342,33 @@ impl Streaming { } let keys: Vec<(i32, i32)> = (0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect(); - // A final neighbour cannot receive writes: skip decoration. Only - // worlds saved before proto chunks were saved get here (their + // A final neighbour cannot be generated again: a generator that + // can decorates beside it (a chunk of an older world), writing + // into it as vanilla's features do; the others skip decoration + // (worlds saved before proto chunks were saved get here: their // final chunks' neighbours are generated again). - if keys.iter().any(|k| world.chunks.contains_key(k)) { + let finals: Vec<(usize, Chunk)> = keys + .iter() + .enumerate() + .filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone()))) + .collect(); + if !finals.is_empty() && !self.saves_beside_final { self.decorated.insert((x, z)); continue; } - if !keys - .iter() - .all(|k| matches!(self.protos.get(k), Some(Some(_)))) - { + if !keys.iter().enumerate().all(|(i, k)| { + finals.iter().any(|(f, _)| *f == i) + || matches!(self.protos.get(k), Some(Some(_))) + }) { continue; } let region: Vec = keys .iter() - .map(|k| { + .enumerate() + .map(|(i, k)| { + if finals.iter().any(|(f, _)| *f == i) { + return Box::new(()) as Proto; + } self.light_views.remove(k); self.protos .insert(*k, None) @@ -1311,7 +1381,7 @@ impl Streaming { Err(_) => continue, }; self.decorating += 1; - self.submit(Some((x, z)), 1, Job::Decorate(x, z, region)); + self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals)); } } self.schedule_generation(world, workers); @@ -1553,7 +1623,9 @@ impl Streaming { while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 { let left = deadline.saturating_duration_since(std::time::Instant::now()); match self.shared.done.recv_timeout(left) { - Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region), + Some(Done::Decorated(x, z, region, finals, writes)) => { + self.receive_decorated(x, z, *region, finals, writes, world) + } Some(Done::Generated(chunk)) => self.receive_generated(chunk, world), Some(Done::Saved(protos)) => self.receive_saved(protos), Some(other) => self.deferred.push(other), @@ -1581,7 +1653,9 @@ impl Streaming { let started = std::time::Instant::now(); while let Some(done) = self.shared.done.try_recv() { match done { - Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region), + Done::Decorated(x, z, region, finals, writes) => { + self.receive_decorated(x, z, *region, finals, writes, world) + } Done::Generated(chunk) => self.receive_generated(chunk, world), Done::Saved(protos) => self.receive_saved(protos), other => self.deferred.push(other), diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index deefec6d69..0919e2fb51 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -4165,6 +4165,23 @@ impl ChunkGenerator for Persistent { fn decorates(&self) -> bool { self.inner.decorates() } + fn decorates_beside_final(&self) -> bool { + self.inner.decorates_beside_final() + } + fn proto_beside_final( + &self, + worker: &mut G::Worker, + snapshot: &Chunk, + ) -> Option { + self.inner.proto_beside_final(worker, snapshot) + } + fn final_writes( + &self, + snapshot: &Chunk, + proto: &crate::streaming::Proto, + ) -> Vec<([i32; 3], u32)> { + self.inner.final_writes(snapshot, proto) + } fn decorate(&self, worker: &mut G::Worker, region: &mut [crate::streaming::Proto; 9]) { self.inner.decorate(worker, region); } diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index 7aa5a19937..6ce6577c1f 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -572,6 +572,47 @@ impl ChunkGenerator for VanillaTerrain { fn decorates(&self) -> bool { true } + fn decorates_beside_final(&self) -> bool { + true + } + /// A final chunk's proto chunk: terrain generated again with the final + /// chunk's blocks over it, so that features read and write it as they do + /// the chunks around it (`ChunkAccess` of a full chunk in the region). + fn proto_beside_final(&self, worker: &mut GeneratorWorker, snapshot: &Chunk) -> Option { + let mut proto = self.proto(worker, snapshot.x, snapshot.z); + self.restore_proto( + &mut proto, + SavedProto { + column: snapshot.clone(), + decorated: true, + block_entities: Vec::new(), + entities: Vec::new(), + }, + ); + Some(proto) + } + fn final_writes(&self, snapshot: &Chunk, proto: &Proto) -> Vec<([i32; 3], u32)> { + let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::() else { + return Vec::new(); + }; + let (bx, bz) = (snapshot.x * 16, snapshot.z * 16); + let (lo, hi) = ( + chunk.terrain.min_y().max(snapshot.min_y()), + (chunk.terrain.min_y() + chunk.terrain.height()).min(snapshot.max_y()), + ); + let mut writes = Vec::new(); + for y in lo..hi { + for z in 0..16 { + for x in 0..16 { + let state = chunk.block(bx + x, y, bz + z).0; + if state != snapshot.get(x, y, z) { + writes.push(([bx + x, y, bz + z], state)); + } + } + } + } + writes + } fn decorate(&self, worker: &mut GeneratorWorker, region: &mut [Proto; 9]) { let taken: Vec> = region .iter_mut() diff --git a/crates/horizon-world/examples/probe/chunk_dump.rs b/crates/horizon-world/examples/probe/chunk_dump.rs index 8b6b9ab6eb..11725bc709 100644 --- a/crates/horizon-world/examples/probe/chunk_dump.rs +++ b/crates/horizon-world/examples/probe/chunk_dump.rs @@ -389,6 +389,12 @@ pub fn main() -> Result<(), Error> { .collect(), }; for (x, z) in order { + // An old chunk is not decorated, only written into. + if old_rect.is_some_and(|[ox0, oz0, ox1, oz1]| { + (ox0..=ox1).contains(&x) && (oz0..=oz1).contains(&z) + }) { + continue; + } let mut region = Vec::with_capacity(9); for dz in -1..=1 { for dx in -1..=1 { diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index b3d011071e..629782ec39 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -1793,6 +1793,12 @@ impl super::structure::start::StartContext for StructureQueries where R::Positional: Clone + Send + Sync, { + fn is_old_chunk(&self, chunk: ChunkPos) -> bool { + self.pipeline + .old_world + .get() + .is_some_and(|world| world.is_old_chunk_around(chunk, 0)) + } fn seed(&self) -> i64 { self.pipeline.seed } diff --git a/crates/horizon-world/src/worldgen/generator_structures.rs b/crates/horizon-world/src/worldgen/generator_structures.rs index 9787948f64..77dacb284a 100644 --- a/crates/horizon-world/src/worldgen/generator_structures.rs +++ b/crates/horizon-world/src/worldgen/generator_structures.rs @@ -291,6 +291,9 @@ impl StructureState { if let Some(found) = self.starts.get((chunk.x, chunk.z)) { return found; } + if context.is_old_chunk(chunk) { + return Starts::default(); + } // Ring searches enlist other workers and wait for them: never hold a // chunk-work lock while helping, even when this chunk has no starts. let rings = self.rings_for(context, chunk); diff --git a/crates/horizon-world/src/worldgen/structure/start.rs b/crates/horizon-world/src/worldgen/structure/start.rs index 502d57799e..58cedf16b9 100644 --- a/crates/horizon-world/src/worldgen/structure/start.rs +++ b/crates/horizon-world/src/worldgen/structure/start.rs @@ -813,6 +813,12 @@ impl NoiseColumn { /// Biome and height queries for one structure generation attempt. pub trait StartContext: JigsawContext { + /// A chunk an older version generated: its structure starts are the ones + /// it saved, which are in its blocks, not the ones this generator would + /// make there. + fn is_old_chunk(&self, _chunk: ChunkPos) -> bool { + false + } fn seed(&self) -> i64; fn sea_level(&self) -> i32; /// ChunkGenerator.getBaseColumn as heightmap opacity, indexed from min_y. diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java index f8f9f0c3bc..513d3e6584 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java @@ -466,6 +466,7 @@ public final class VanillaChunkGenerationOracle { for (int[] at : order) { if (phase.equals("terrain")) break; int x = at[0], z = at[1]; + if (oldChunks.contains(ChunkPos.pack(x, z))) continue; // an old chunk is not decorated, only written into var chunk = chunkAt.apply(x, z); var region = regionFor.apply(ChunkStatus.FEATURES, chunk); generator.applyBiomeDecoration(region, chunk, OracleLevel.structures.forWorldGenRegion(region)); diff --git a/tools/old_world_e2e.py b/tools/old_world_e2e.py index ad649e1d9c..39e569b1e2 100644 --- a/tools/old_world_e2e.py +++ b/tools/old_world_e2e.py @@ -89,6 +89,14 @@ def main(): g.raw('open', 'Dst') g('wait_ready', seconds=400) g('command', command='gamemode spectator') + # The chunk strip next to the old chunks is decorated like any other: it has trees. + logs = 0 + for z0 in range(-32, 32, 16): # a request holds at most 32768 blocks + counts = g.raw('blocks', 0, 60, z0, 15, 187, z0 + 15).get('counts', {}) + logs += sum(n for block, n in counts.items() if block.endswith('_log')) + print(label, 'logs in the new chunk strip at the border:', logs) + if label == 'blended': + assert logs > 0, 'no tree grew in the chunk strip beside the old chunks' for shot, x, y, z, yaw, pitch in SHOTS: g('command', command=f'tp @s {x} {y} {z} {yaw} {pitch}') g.wait(500) diff --git a/tools/port_map/levelgen-1.tsv b/tools/port_map/levelgen-1.tsv index a22986f051..6fd0d5fdeb 100644 --- a/tools/port_map/levelgen-1.tsv +++ b/tools/port_map/levelgen-1.tsv @@ -93,8 +93,8 @@ Blender.blendDensity port crates/horizon-world/src/worldgen/blending.rs::Blender Blender.getBlendingDataValue inline crates/horizon-world/src/worldgen/blending.rs::Blender::blending_value Blender.getBiomeResolver inline crates/horizon-world/src/worldgen/blending.rs::Blender::blend_biome the generator asks `blend_biome` before its own biome source Blender.blendBiome port crates/horizon-world/src/worldgen/blending.rs::Blender::blend_biome -Blender.generateBorderTicks missing the leaves and fluids of the old/new chunk border are not marked for post-processing yet -Blender.generateBorderTick missing part of generateBorderTicks +Blender.generateBorderTicks skip:other does something only for a chunk that has blending data and is being generated again (retrogen); Horizon never regenerates an old chunk +Blender.generateBorderTick skip:other does something only for a chunk that has blending data and is being generated again (retrogen); Horizon never regenerates an old chunk Blender.createAroundOldChunksCarvingMaskFilter inline crates/horizon-world/src/worldgen/blending.rs::OldChunkDistance::around the carving filter is `Blender::carving_filter` Blender.makeOldChunkDistanceGetter inline crates/horizon-world/src/worldgen/blending.rs::OldChunkDistance::distance Blender.makeOffsetOldChunkDistanceGetter inline crates/horizon-world/src/worldgen/blending.rs::OldChunkDistance::around diff --git a/tools/test_blending_parity.py b/tools/test_blending_parity.py index 54f669f9f7..fcb8aa9d5e 100644 --- a/tools/test_blending_parity.py +++ b/tools/test_blending_parity.py @@ -35,9 +35,11 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--filter', help='regex over the case labels') parser.add_argument('--limit', type=int, default=6, help='block differences shown per chunk') + parser.add_argument('--phase', choices=['terrain', 'full'], default='terrain', + help='full: with the features, trees and the rest spilling into the old chunks') args = parser.parse_args() import re - os.environ['HORIZON_PHASE'] = 'terrain' + os.environ['HORIZON_PHASE'] = args.phase oracle = base.Oracle() binary = base.rust_binary() names = base.state_names() @@ -48,7 +50,7 @@ def main(): if args.filter and not re.search(args.filter, label): continue os.environ['HORIZON_OLD_CHUNKS'] = f'{old_seed}:{old}' - tag = f'blending-{seed}-{old_seed}-{old.replace(",", "_")}' + tag = f'blending-{args.phase}-{seed}-{old_seed}-{old.replace(",", "_")}' vanilla_path, rust_path = base.build_dir() / f'vanilla-{tag}.txt', base.build_dir() / f'rust-{tag}.txt' with ThreadPoolExecutor(2) as pool: rust_run = pool.submit(base.run_rust, binary, rust_path, seed, 'overworld', [rect])