Merge pull request #2070 from RoyalPixelsDE/fix/dimension-height

🤖 Agent: Gate check by Ludovic's AI agent (Claude). Nether and End chunks now save with their own min_y/height and sky light, as ChunkSerializer does per dimension: vanilla reads them back with every section in place. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-26 16:38:29 +01:00
commit 9cbf93a193
1 file changed
+153 -27
+153 -27
View File
@@ -754,18 +754,67 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
Some((pos, entity))
}
/// What a dimension's saved chunks depend on: skylight and the build height
/// (`DimensionType.minY` and `height`). `Chunk` always holds the Overworld's
/// sections; a level saves only the ones inside its own height.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
pub min_y: i32,
pub height: i32,
}
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
min_y: MIN_Y,
height: SECTIONS as i32 * 16,
};
pub const NETHER: Self = Self {
sky_light: false,
min_y: 0,
height: 256,
};
pub const END: Self = Self {
sky_light: true,
min_y: 0,
height: 256,
};
/// The shape of a dimension folder (`dimensions/minecraft/<name>`, or the
/// old `DIM-1` and `DIM1`).
pub fn for_dir(root: &Path) -> Self {
if root.ends_with("the_nether") || root.ends_with("DIM-1") {
Self::NETHER
} else if root.ends_with("the_end") || root.ends_with("DIM1") {
Self::END
} else {
Self::OVERWORLD
}
}
fn min_section(self) -> i32 {
self.min_y >> 4
}
fn section_count(self) -> usize {
(self.height >> 4) as usize
}
fn has_section(self, y: i32) -> bool {
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
}
}
/// A column's sections as `Heightmap.primeHeightmaps` reads them.
struct HeightmapView<'a> {
sections: &'a [Arc<Section>],
tables: &'a SaveTables,
min_y: i32,
height: i32,
}
impl HeightmapChunk for HeightmapView<'_> {
type State = crate::assets::StateFacts;
fn min_y(&self) -> i32 {
MIN_Y
self.min_y
}
fn height(&self) -> i32 {
SECTIONS as i32 * 16
self.height
}
fn highest_section_y(&self) -> i32 {
let top = self
@@ -785,7 +834,7 @@ impl HeightmapChunk for HeightmapView<'_> {
}
}
/// Heightmaps kept after world generation (`ChunkStatus.FULL.heightmapsAfter`).
fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
fn heightmaps(chunk: &Chunk, tables: &SaveTables, shape: LevelShape) -> Tag {
let kinds = [
HeightmapType::WorldSurface,
HeightmapType::OceanFloor,
@@ -794,11 +843,13 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
];
let mut maps: Vec<Heightmap> = kinds
.iter()
.filter_map(|k| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok())
.filter_map(|k| Heightmap::new(*k, shape.min_y, shape.height).ok())
.collect();
let view = HeightmapView {
sections: &chunk.sections,
tables,
min_y: shape.min_y,
height: shape.height,
};
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().collect();
if Heightmap::prime(&view, &mut refs).is_err() {
@@ -828,7 +879,12 @@ pub fn client_heightmaps_in(
.iter()
.filter_map(|(id, k)| Heightmap::new(*k, min_y, height).ok().map(|m| (*id, m)))
.collect();
let view = HeightmapView { sections, tables };
let view = HeightmapView {
sections,
tables,
min_y,
height,
};
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect();
if Heightmap::prime(&view, &mut refs).is_err() {
return Vec::new();
@@ -854,17 +910,18 @@ fn nibbles(light: &Light, sky: bool) -> Vec<u8> {
}
pub fn encode(chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes) -> NamedTag {
encode_with(chunk, registry, biomes, None, true)
encode_with(chunk, registry, biomes, None, LevelShape::OVERWORLD)
}
/// The chunk NBT vanilla's `SerializableChunkData.write` produces for a full
/// chunk. `sky_light` is false in dimensions without skylight.
/// chunk in a level of `shape`.
pub fn encode_with(
chunk: &Chunk,
registry: &BlockRegistry,
biomes: &Biomes,
tables: Option<&SaveTables>,
sky_light: bool,
shape: LevelShape,
) -> NamedTag {
let sky_light = shape.sky_light;
let mut sections = Vec::new();
let air = tables.map_or(0, |t| t.air);
// Sky layers below the highest section with light other than 15 are
@@ -876,6 +933,9 @@ pub fn encode_with(
});
for (si, section) in chunk.sections.iter().enumerate() {
let y = (MIN_Y >> 4) + si as i32;
if !shape.has_section(y) {
continue;
}
// PalettedContainer.pack: distinct states in index order, indices at
// the saved width.
let packed = section.blocks.pack();
@@ -1005,7 +1065,7 @@ pub fn encode_with(
("DataVersion", Tag::Int(DATA_VERSION)),
("xPos", Tag::Int(chunk.x)),
("zPos", Tag::Int(chunk.z)),
("yPos", Tag::Int(MIN_Y >> 4)),
("yPos", Tag::Int(shape.min_section())),
("Status", string("minecraft:full")),
("LastUpdate", Tag::Long(extras.map_or(0, |e| e.last_update))),
("isLightOn", Tag::Byte(i8::from(chunk.lit))),
@@ -1038,10 +1098,10 @@ pub fn encode_with(
s.insert("References".into(), Tag::Compound(refs));
}
map.entry("PostProcessing".into())
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); SECTIONS]));
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()]));
}
if let Some(t) = tables {
put(&mut root, "Heightmaps", heightmaps(chunk, t));
put(&mut root, "Heightmaps", heightmaps(chunk, t, shape));
let names = |k: u32| t.block_names.get(k as usize).cloned();
let (blocks, fluids) = extras.map_or((&[][..], &[][..]), |e| {
(&e.block_ticks[..], &e.fluid_ticks[..])
@@ -1257,18 +1317,19 @@ pub fn encode_proto(
registry: &BlockRegistry,
biomes: &Biomes,
tables: Option<&SaveTables>,
sky_light: bool,
shape: LevelShape,
) -> NamedTag {
let mut root = encode_with(&proto.column, registry, biomes, tables, sky_light);
let mut root = encode_with(&proto.column, registry, biomes, tables, shape);
let status = if proto.decorated {
ChunkStatus::Features
} else {
ChunkStatus::Terrain
};
// ProtoChunk.postProcessing: one list per section, packed section offsets.
let mut marks = vec![Vec::new(); SECTIONS];
let mut marks = vec![Vec::new(); shape.section_count()];
for &[x, y, z] in &proto.column.postprocess {
if let Some(list) = marks.get_mut(((y - MIN_Y) >> 4) as usize) {
let section = usize::try_from((y - shape.min_y) >> 4).ok();
if let Some(list) = section.and_then(|s| marks.get_mut(s)) {
list.push(Tag::Short(
((x & 15) | (y & 15) << 4 | (z & 15) << 8) as i16,
));
@@ -1582,7 +1643,7 @@ struct Encoding {
registry: &'static BlockRegistry,
biomes: Arc<Biomes>,
tables: Option<Arc<SaveTables>>,
sky_light: bool,
shape: LevelShape,
}
impl Record {
fn tag(&self) -> Option<Arc<NamedTag>> {
@@ -1593,7 +1654,7 @@ impl Record {
e.registry,
&e.biomes,
e.tables.as_deref(),
e.sky_light,
e.shape,
))),
Record::Delete => None,
}
@@ -1630,12 +1691,12 @@ pub struct SaveDir {
outstanding: Arc<Outstanding>,
/// Set by `Persistent` before the first load.
pub tables: OnceLock<Arc<SaveTables>>,
/// False in dimensions without skylight (no SkyLight layers are written).
pub sky_light: bool,
/// The dimension's skylight and height, from its folder name.
pub shape: LevelShape,
}
impl SaveDir {
pub fn new(root: PathBuf) -> Self {
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
let shape = LevelShape::for_dir(&root);
Self {
root,
regions: Default::default(),
@@ -1645,7 +1706,7 @@ impl SaveDir {
writer: Mutex::new(None),
outstanding: Default::default(),
tables: OnceLock::new(),
sky_light,
shape,
}
}
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
@@ -1676,7 +1737,7 @@ impl SaveDir {
let root = self.read_record("region", x, z)?;
let tables = self.tables.get().map(|t| &**t);
let mut chunk = decode_with(&root, registry, biomes, tables)?;
load_light(&root.tag, &mut chunk, self.sky_light);
load_light(&root.tag, &mut chunk, self.shape.sky_light);
Some(chunk)
}
/// A proto chunk saved before it became final (`decode_proto`).
@@ -1701,7 +1762,7 @@ impl SaveDir {
let records = protos
.iter()
.map(|p| {
let tag = encode_proto(p, registry, biomes, tables, self.sky_light);
let tag = encode_proto(p, registry, biomes, tables, self.shape);
((p.column.x, p.column.z), Some(tag))
})
.collect();
@@ -1876,7 +1937,7 @@ impl SaveDir {
.map(|c| {
(
(c.x, c.z),
Some(encode_with(c, registry, biomes, tables, self.sky_light)),
Some(encode_with(c, registry, biomes, tables, self.shape)),
)
})
.collect();
@@ -1895,7 +1956,7 @@ impl SaveDir {
registry,
biomes: biomes.clone(),
tables: self.tables.get().cloned(),
sky_light: self.sky_light,
shape: self.shape,
});
let records = chunks
.iter()
@@ -3365,7 +3426,7 @@ impl SaveDir {
{
return None;
}
decode_lod_surfaces(&root, assets, biomes, self.sky_light)
decode_lod_surfaces(&root, assets, biomes, self.shape.sky_light)
}
}
@@ -3456,6 +3517,71 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
#[cfg(test)]
mod tests {
#[test]
fn nether_and_end_chunks_save_with_their_own_height() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Biomes::load(&root).expect("biomes");
let tables = SaveTables::new(&assets);
let rack = assets
.registry
.default_state("minecraft:netherrack")
.unwrap();
let mut chunk = Chunk::empty(0, 0);
for y in 0..128 {
chunk.set(0, y, 0, rack.0);
}
let saved = encode_with(
&chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::NETHER,
);
assert_eq!(number(saved.tag.get("yPos")), Some(0));
let Some(Tag::List(sections)) = saved.tag.get("sections") else {
panic!("no sections");
};
let ys: Vec<i32> = sections
.iter()
.filter_map(|s| number(s.get("Y")).map(|y| y as i32))
.collect();
assert_eq!(ys, (0..16).collect::<Vec<_>>());
let Some(Tag::List(post)) = saved.tag.get("PostProcessing") else {
panic!("no PostProcessing");
};
assert_eq!(post.len(), 16);
// Heightmap values count from the level's minimum: the column's top
// block at y=127 reads 128 (vanilla), not 192 (from the Overworld's -64).
let Some(Tag::LongArray(surface)) = saved
.tag
.get("Heightmaps")
.and_then(|h| h.get("WORLD_SURFACE"))
else {
panic!("no WORLD_SURFACE");
};
assert_eq!(surface[0] & 0x1ff, 128);
let overworld = encode_with(
&chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::OVERWORLD,
);
assert_eq!(number(overworld.tag.get("yPos")), Some(-4));
assert_eq!(
LevelShape::for_dir(Path::new("w/dimensions/minecraft/the_end")),
LevelShape::END
);
assert_eq!(
LevelShape::for_dir(Path::new("w/DIM-1")),
LevelShape::NETHER
);
}
#[test]
fn saved_far_surface_reads_builds_and_water_without_gameplay_chunks() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
@@ -3574,7 +3700,7 @@ mod tests {
"unfinished proto saves must not suppress complete decoration"
);
let mut nether = SaveDir::new(dir.clone());
nether.sky_light = false;
nether.shape = LevelShape::NETHER;
let dark = nether.lod_surfaces(-2, 3, &assets, &biomes).unwrap();
assert!(
dark.iter()