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 <[email protected]>
This commit is contained in:
Federico TaglianiandClaude Sonnet 5.5 committed 2026-10-30 15:47:43 +01:00
1 parent 5b5503fce0
commit 5d1038557f
11 files changed
+185 -21

No files matched your search

+91 -17
View File
@@ -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<Proto> {
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<Region>, u64, bool),
Mesh(i32, i32, i32, Box<Region>, 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<usize>, Vec<([i32; 3], u32)>),
Lit(i32, i32, Vec<crate::chunk::Light>, 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<Band>), 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, &region[*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<usize>,
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<Proto> = 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),
+17
View File
@@ -4165,6 +4165,23 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
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<crate::streaming::Proto> {
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);
}
+41
View File
@@ -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<Proto> {
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::<VanillaProto>() 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<Box<VanillaProto>> = region
.iter_mut()
@@ -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 {
@@ -1793,6 +1793,12 @@ impl<R: RandomSource> 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
}
@@ -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);
@@ -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.
@@ -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));
+8
View File
@@ -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)
+2 -2
View File
@@ -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
1 Aquifer.NoiseBasedAquifer.<init> port crates/horizon-world/src/worldgen/aquifer/noise_based.rs::NoiseBasedAquifer::new grid bounds, max surface level, skip-above-Y
93 Blender.getBlendingDataValue inline crates/horizon-world/src/worldgen/blending.rs::Blender::blending_value
94 Blender.getBiomeResolver inline crates/horizon-world/src/worldgen/blending.rs::Blender::blend_biome the generator asks `blend_biome` before its own biome source
95 Blender.blendBiome port crates/horizon-world/src/worldgen/blending.rs::Blender::blend_biome
96 Blender.generateBorderTicks missing skip:other the leaves and fluids of the old/new chunk border are not marked for post-processing yet does something only for a chunk that has blending data and is being generated again (retrogen); Horizon never regenerates an old chunk
97 Blender.generateBorderTick missing skip:other part of generateBorderTicks does something only for a chunk that has blending data and is being generated again (retrogen); Horizon never regenerates an old chunk
98 Blender.createAroundOldChunksCarvingMaskFilter inline crates/horizon-world/src/worldgen/blending.rs::OldChunkDistance::around the carving filter is `Blender::carving_filter`
99 Blender.makeOldChunkDistanceGetter inline crates/horizon-world/src/worldgen/blending.rs::OldChunkDistance::distance
100 Blender.makeOffsetOldChunkDistanceGetter inline crates/horizon-world/src/worldgen/blending.rs::OldChunkDistance::around
+4 -2
View File
@@ -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])