Light with vanilla shape occlusion and incremental relighting
Port LightEngine.shapeOccludes / getFaceOcclusionShape (occlusion.nbt) so slabs, stairs, snow layers, paths etc. block light directionally, and use ChunkSkyLightSources.isEdgeOccluded for sky sources. Block light now rises into the section above the highest occupied one. Block edits on lit chunks are relit in place with vanilla's decrease and increase passes instead of recomputing the 3x3 chunks (and remeshing every section of them); edits that race a chunk light job are replayed onto it. examples/light_parity compares Horizon light with the SkyLight/BlockLight arrays of worlds saved by the vanilla 26.3 server (0 differences on villages, outposts and a slab/stair/snow scene; 181 of 113M sky values differ where vanilla's worldgen light is stale) and checks incremental relighting against full relights. tools/vanilla_world_check.py runs the vanilla server headless; tools/anvil_dump.py prints NBT and region chunks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
//! Differential light test: relight chunks of a world saved by the vanilla
|
||||
//! server with Horizon's light engine and compare every SkyLight/BlockLight
|
||||
//! value with the arrays vanilla stored.
|
||||
//!
|
||||
//! cargo run --profile fast -p horizon-client --example light_parity -- \
|
||||
//! RESOURCES DIMENSION_DIR [--nether] [--chunks X0 Z0 X1 Z1]
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, index},
|
||||
light::{LightTables, light_chunk},
|
||||
save,
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
region::RegionFile,
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
type Arrays = HashMap<i32, (Option<Vec<u8>>, Option<Vec<u8>>)>;
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let root = PathBuf::from(
|
||||
args.first()
|
||||
.ok_or("usage: light_parity RESOURCES DIMENSION_DIR")?,
|
||||
);
|
||||
let dim = PathBuf::from(args.get(1).ok_or("missing dimension directory")?);
|
||||
let sky_light = !args.iter().any(|a| a == "--nether");
|
||||
let assets = Assets::load(&root)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Biomes::load(&root)?;
|
||||
let tables = if args.iter().any(|a| a == "--no-shapes") {
|
||||
LightTables::new(&facts)
|
||||
} else {
|
||||
LightTables::load(&root, assets.registry, &facts)?
|
||||
};
|
||||
let mut chunks: HashMap<(i32, i32), Chunk> = HashMap::new();
|
||||
let mut arrays: HashMap<(i32, i32), Arrays> = HashMap::new();
|
||||
for entry in std::fs::read_dir(dim.join("region"))? {
|
||||
let path = entry?.path();
|
||||
let mut region = RegionFile::open(&path)?;
|
||||
let positions: Vec<ChunkPos> = region.chunks().map(|(p, _)| p).collect();
|
||||
for p in positions {
|
||||
let Some(record) = region.read(p)? else {
|
||||
continue;
|
||||
};
|
||||
let root_tag = record.nbt(Limits::default())?;
|
||||
let tag = &root_tag.tag;
|
||||
if !matches!(tag.get("Status"), Some(Tag::String(s)) if s.to_string_lossy() == "minecraft:full")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
|
||||
continue;
|
||||
}
|
||||
let Some(chunk) = save::decode(&root_tag, assets.registry, &biomes) else {
|
||||
continue;
|
||||
};
|
||||
let mut light = Arrays::new();
|
||||
if let Some(Tag::List(sections)) = tag.get("sections") {
|
||||
for s in sections {
|
||||
let Some(Tag::Byte(y)) = s.get("Y") else {
|
||||
continue;
|
||||
};
|
||||
let get = |k: &str| match s.get(k) {
|
||||
Some(Tag::ByteArray(a)) => Some(a.clone()),
|
||||
_ => None,
|
||||
};
|
||||
light.insert(i32::from(*y), (get("SkyLight"), get("BlockLight")));
|
||||
}
|
||||
}
|
||||
arrays.insert((chunk.x, chunk.z), light);
|
||||
chunks.insert((chunk.x, chunk.z), chunk);
|
||||
}
|
||||
}
|
||||
let bounds: Option<[i32; 4]> = args.iter().position(|a| a == "--chunks").map(|i| {
|
||||
std::array::from_fn(|k| {
|
||||
args.get(i + 1 + k)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0)
|
||||
})
|
||||
});
|
||||
if let Some(i) = args.iter().position(|a| a == "--column") {
|
||||
let wx: i32 = args[i + 1].parse()?;
|
||||
let wz: i32 = args[i + 2].parse()?;
|
||||
let (y0, y1): (i32, i32) = (args[i + 3].parse()?, args[i + 4].parse()?);
|
||||
let (x, z) = (wx >> 4, wz >> 4);
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
|
||||
});
|
||||
let lit = light_chunk(®ion, &tables, sky_light);
|
||||
for wy in (y0..=y1).rev() {
|
||||
let si = ((wy - MIN_Y) >> 4) as usize;
|
||||
let i = index(wx, wy, wz);
|
||||
let (sky, block) = arrays[&(x, z)]
|
||||
.get(&((wy) >> 4))
|
||||
.cloned()
|
||||
.unwrap_or((None, None));
|
||||
let nib = |a: &Option<Vec<u8>>| a.as_ref().map(|a| (a[i >> 1] >> ((i & 1) * 4)) & 15);
|
||||
let id = chunks[&(x, z)].get(wx & 15, wy, wz & 15);
|
||||
let packed = lit[si].light(i);
|
||||
println!(
|
||||
"{wy}: {} horizon sky {} block {} vanilla sky {:?} block {:?}",
|
||||
facts.states[id as usize].name,
|
||||
packed >> 4,
|
||||
packed & 15,
|
||||
nib(&sky),
|
||||
nib(&block)
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(i) = args.iter().position(|a| a == "--incremental") {
|
||||
let edits: usize = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(200);
|
||||
incremental(&chunks, &tables, &facts, assets.registry, sky_light, edits);
|
||||
return Ok(());
|
||||
}
|
||||
let mut keys: Vec<(i32, i32)> = chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| {
|
||||
(-1..=1).all(|dz| (-1..=1).all(|dx| chunks.contains_key(&(x + dx, z + dz))))
|
||||
})
|
||||
.filter(|&(x, z)| bounds.is_none_or(|b| x >= b[0] && z >= b[1] && x <= b[2] && z <= b[3]))
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
let (mut total, mut sky_wrong, mut block_wrong) = (0u64, 0u64, 0u64);
|
||||
let mut shown = 0;
|
||||
for (x, z) in keys {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
|
||||
});
|
||||
let lit = light_chunk(®ion, &tables, sky_light);
|
||||
let vanilla = &arrays[&(x, z)];
|
||||
let center = &chunks[&(x, z)];
|
||||
let (mut s_bad, mut b_bad) = (0, 0);
|
||||
for (si, section) in lit.iter().enumerate().take(SECTIONS) {
|
||||
let sy = (MIN_Y >> 4) + si as i32;
|
||||
let (sky, block) = vanilla.get(&sy).cloned().unwrap_or((None, None));
|
||||
for i in 0..4096 {
|
||||
let nibble = |a: &Option<Vec<u8>>, default: u8| {
|
||||
a.as_ref()
|
||||
.map_or(default, |a| (a[i >> 1] >> ((i & 1) * 4)) & 15)
|
||||
};
|
||||
// Vanilla omits never-written (all-zero) layers. Air sections
|
||||
// without a layer read the bottom row of the next stored layer
|
||||
// above (SkyLightSectionStorage.getLightValue), or full
|
||||
// skylight above every stored layer.
|
||||
let vs = match &sky {
|
||||
Some(_) => nibble(&sky, 0),
|
||||
None if !sky_light || !center.sections[si].is_uniform(0) => 0,
|
||||
None => ((sy + 1)..=(MIN_Y >> 4) + SECTIONS as i32)
|
||||
.find_map(|y| vanilla.get(&y).and_then(|(s, _)| s.as_ref()))
|
||||
.map_or(15, |a| (a[(i & 255) >> 1] >> ((i & 1) * 4)) & 15),
|
||||
};
|
||||
let vb = nibble(&block, 0);
|
||||
let packed = section.light(i);
|
||||
let (hs, hb) = (packed >> 4, packed & 15);
|
||||
total += 1;
|
||||
if hs != vs {
|
||||
s_bad += 1;
|
||||
}
|
||||
if hb != vb {
|
||||
b_bad += 1;
|
||||
}
|
||||
if (hs != vs || hb != vb) && shown < 30 {
|
||||
shown += 1;
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let wy = sy * 16 + ly as i32;
|
||||
let wx = x * 16 + lx as i32;
|
||||
let wz = z * 16 + lz as i32;
|
||||
let id = center.get(lx as i32, wy, lz as i32);
|
||||
eprintln!(
|
||||
" {wx} {wy} {wz} {}: sky {hs} vs {vs}, block {hb} vs {vb}",
|
||||
facts.states[id as usize].name
|
||||
);
|
||||
}
|
||||
let _ = index;
|
||||
}
|
||||
}
|
||||
if s_bad + b_bad > 0 {
|
||||
println!("chunk {x} {z}: {s_bad} sky and {b_bad} block values differ");
|
||||
}
|
||||
sky_wrong += s_bad;
|
||||
block_wrong += b_bad;
|
||||
}
|
||||
println!(
|
||||
"{total} values compared: {sky_wrong} sky and {block_wrong} block light values differ"
|
||||
);
|
||||
if sky_wrong + block_wrong > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Edit lit chunks block by block with `light::relight` and check the result
|
||||
/// against a full relight of the edited neighbourhood after every edit.
|
||||
fn incremental(
|
||||
chunks: &HashMap<(i32, i32), Chunk>,
|
||||
tables: &LightTables,
|
||||
facts: &BlockFacts,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
sky_light: bool,
|
||||
edits: usize,
|
||||
) {
|
||||
use horizon_client::light::relight;
|
||||
use std::sync::Arc;
|
||||
let mut world: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
|
||||
let inner: Vec<(i32, i32)> = {
|
||||
let mut k: Vec<_> = chunks
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&(x, z)| {
|
||||
(-3..=3).all(|dz| (-3..=3).all(|dx| chunks.contains_key(&(x + dx, z + dz))))
|
||||
})
|
||||
.collect();
|
||||
k.sort_unstable();
|
||||
k
|
||||
};
|
||||
for &(x, z) in chunks.keys() {
|
||||
if !(-1..=1).all(|dz| (-1..=1).all(|dx| chunks.contains_key(&(x + dx, z + dz)))) {
|
||||
continue;
|
||||
}
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1)))
|
||||
});
|
||||
let mut c = chunks[&(x, z)].clone();
|
||||
c.sections = light_chunk(®ion, tables, sky_light);
|
||||
c.lit = true;
|
||||
world.insert((x, z), Arc::new(c));
|
||||
}
|
||||
let palette: Vec<u16> = [
|
||||
"minecraft:air",
|
||||
"minecraft:stone",
|
||||
"minecraft:torch",
|
||||
"minecraft:glowstone",
|
||||
"minecraft:oak_leaves",
|
||||
"minecraft:water",
|
||||
"minecraft:glass",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|n| registry.default_state(n).map(|s| s.0 as u16))
|
||||
.chain(
|
||||
facts
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| {
|
||||
[
|
||||
"minecraft:stone_slab",
|
||||
"minecraft:oak_stairs",
|
||||
"minecraft:snow",
|
||||
"minecraft:dirt_path",
|
||||
]
|
||||
.contains(&&*f.name)
|
||||
})
|
||||
.map(|(i, _)| i as u16)
|
||||
.step_by(3),
|
||||
)
|
||||
.collect();
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = |n: u64| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed % n
|
||||
};
|
||||
let mut bad = 0;
|
||||
for e in 0..edits {
|
||||
let (cx, cz) = inner[next(inner.len() as u64) as usize];
|
||||
let (x, z) = (cx * 16 + next(16) as i32, cz * 16 + next(16) as i32);
|
||||
// Near the surface, where light varies most.
|
||||
let top = (0..384)
|
||||
.rev()
|
||||
.find(|y| world[&(cx, cz)].get(x & 15, MIN_Y + y, z & 15) != 0)
|
||||
.unwrap_or(100) as i32
|
||||
+ MIN_Y;
|
||||
let y = top - 3 + next(6) as i32;
|
||||
let state = palette[next(palette.len() as u64) as usize];
|
||||
let chunk = world.get_mut(&(cx, cz)).unwrap();
|
||||
let old = Arc::make_mut(chunk).set(x & 15, y, z & 15, state);
|
||||
if old == state || !tables.differs(old, state) {
|
||||
continue;
|
||||
}
|
||||
relight(&mut world, tables, sky_light, &[(x, y, z)], false);
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (kx, kz) = (cx + dx, cz + dz);
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|rz| {
|
||||
std::array::from_fn(|rx| {
|
||||
world
|
||||
.get(&(kx + rx as i32 - 1, kz + rz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let full = light_chunk(®ion, tables, sky_light);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
let (a, b) = (s.light(i), world[&(kx, kz)].sections[si].light(i));
|
||||
if a != b {
|
||||
bad += 1;
|
||||
if bad < 20 {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
eprintln!(
|
||||
"edit {e} at {x} {y} {z} ({} -> {}): {} {} {} full {a:02x} incremental {b:02x}",
|
||||
facts.states[old as usize].name,
|
||||
facts.states[state as usize].name,
|
||||
kx * 16 + lx as i32,
|
||||
MIN_Y + si as i32 * 16 + ly as i32,
|
||||
kz * 16 + lz as i32
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{edits} edits: {bad} light values differ from a full relight");
|
||||
if bad > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -4640,7 +4640,7 @@ pub fn run() -> Result<()> {
|
||||
let visibility = models::load_visibility(&root, &assets.registry)?;
|
||||
eprintln!("load visibility: {:.2}s", started.elapsed().as_secs_f32());
|
||||
let tables = Arc::new(MeshTables::new(&assets, &facts, visibility)?);
|
||||
let light = Arc::new(LightTables::new(&facts));
|
||||
let light = Arc::new(LightTables::load(&root, &assets.registry, &facts)?);
|
||||
eprintln!("load tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
let atlas = HudAtlas::build(&root)?;
|
||||
eprintln!("load atlas: {:.2}s", started.elapsed().as_secs_f32());
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
//! Sky and block light for one chunk, flood-filled over its 3x3 neighbourhood
|
||||
//! with vanilla's rules: sky sources run down each column until an edge is
|
||||
//! occluded (`ChunkSkyLightSources`), and light then spreads losing
|
||||
//! `max(1, dampening)` per step (`LightEngine.getOpacity`). Light can travel at
|
||||
//! most 15 blocks, so the 48-wide region determines the centre exactly, apart
|
||||
//! from face-shape occlusion of partial blocks (slabs, stairs), which is not
|
||||
//! modelled yet.
|
||||
//! with vanilla's `LightEngine` rules:
|
||||
//! - light entering a block loses `max(1, getLightDampening)` (`getOpacity`);
|
||||
//! - it cannot cross a face where the two blocks' occlusion faces together
|
||||
//! cover the whole square (`shapeOccludes`, using `getFaceOcclusionShape`
|
||||
//! only for states with `canOcclude && useShapeForLightOcclusion`), which is
|
||||
//! what makes slabs, stairs, snow layers etc. block light directionally;
|
||||
//! - sky sources run down each column to the first occluded edge
|
||||
//! (`ChunkSkyLightSources.isEdgeOccluded`: dampening != 0 or the faces
|
||||
//! between the two blocks occlude).
|
||||
//!
|
||||
//! The final values of vanilla's queue-based engine are the maximum over all
|
||||
//! paths, so a flood fill to the fixed point reproduces them. Light travels at
|
||||
//! most 15 blocks, so the 48-wide region determines the centre exactly.
|
||||
//! `relight_block` applies vanilla's incremental decrease/increase passes
|
||||
//! directly to resident chunks after a block change.
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, SECTIONS, Section, index},
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
|
||||
const W: usize = 48;
|
||||
/// Face ID 0: empty occlusion face; 1: the full square.
|
||||
const EMPTY: u16 = 0;
|
||||
const FULL: u16 = 1;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Cell {
|
||||
dampening: u8,
|
||||
emission: u8,
|
||||
/// Any face is non-empty (`!isEmptyShape`), so edges need the face table.
|
||||
shaped: bool,
|
||||
faces: [u16; 6],
|
||||
}
|
||||
pub struct LightTables {
|
||||
cells: Vec<Cell>,
|
||||
/// `faceShapeOccludes(a, b)` for every pair of distinct face shapes.
|
||||
occludes: Vec<bool>,
|
||||
faces: usize,
|
||||
}
|
||||
impl LightTables {
|
||||
/// Dampening and emission only: every occlusion face is empty.
|
||||
pub fn new(facts: &BlockFacts) -> Self {
|
||||
Self {
|
||||
cells: facts
|
||||
@@ -29,11 +55,120 @@ impl LightTables {
|
||||
.map(|s| Cell {
|
||||
dampening: s.dampening.min(15),
|
||||
emission: s.emission.min(15),
|
||||
shaped: false,
|
||||
faces: [EMPTY; 6],
|
||||
})
|
||||
.collect(),
|
||||
occludes: vec![false, true, true, true],
|
||||
faces: 2,
|
||||
}
|
||||
}
|
||||
/// Full tables with the vanilla occlusion shapes from `occlusion.nbt`.
|
||||
pub fn load(root: &Path, registry: &BlockRegistry, facts: &BlockFacts) -> crate::Result<Self> {
|
||||
use horizon_world::{
|
||||
collision::CachedCollisionRegistry,
|
||||
nbt::{self, Limits},
|
||||
};
|
||||
let data = nbt::read_named(&std::fs::read(root.join("occlusion.nbt"))?, Limits::default())?.0.tag;
|
||||
let occlusion = CachedCollisionRegistry::from_nbt(&data, registry)?;
|
||||
let mut tables = Self::new(facts);
|
||||
let block = VoxelShape::block();
|
||||
let mut shapes: Vec<VoxelShape> = vec![VoxelShape::empty(), block.clone()];
|
||||
let mut by_geometry: HashMap<Vec<u64>, u16> = HashMap::new();
|
||||
let mut by_shape: HashMap<*const VoxelShape, [u16; 6]> = HashMap::new();
|
||||
for (id, cell) in tables.cells.iter_mut().enumerate() {
|
||||
let state = &facts.states[id];
|
||||
// LightEngine.isEmptyShape: !canOcclude || !useShapeForLightOcclusion.
|
||||
if !state.can_occlude || !state.shape_occlusion {
|
||||
continue;
|
||||
}
|
||||
let Some(shape) = occlusion.cached_shape(BlockStateId(id as u32))? else { continue };
|
||||
if shape.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let faces = if let Some(faces) = by_shape.get(&std::ptr::from_ref(shape)) {
|
||||
*faces
|
||||
} else {
|
||||
let mut faces = [FULL; 6];
|
||||
// BlockState.initCache: solidRender shapes use the full faces.
|
||||
if shape.join_is_not_empty(&block, ShapeOp::XOR)? {
|
||||
for direction in Direction::ALL {
|
||||
let face = shape.face_shape(direction)?;
|
||||
faces[direction as usize] = if face.is_empty() {
|
||||
EMPTY
|
||||
} else if face.same_identity(&block) {
|
||||
FULL
|
||||
} else {
|
||||
let key: Vec<u64> = face
|
||||
.boxes()
|
||||
.iter()
|
||||
.flat_map(|b| b.min_position().components().into_iter().chain(b.max_position().components()))
|
||||
.map(f64::to_bits)
|
||||
.collect();
|
||||
*by_geometry.entry(key).or_insert_with(|| {
|
||||
shapes.push(face.clone());
|
||||
(shapes.len() - 1) as u16
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
by_shape.insert(std::ptr::from_ref(shape), faces);
|
||||
faces
|
||||
};
|
||||
cell.faces = faces;
|
||||
cell.shaped = faces.iter().any(|f| *f != EMPTY);
|
||||
}
|
||||
let n = shapes.len();
|
||||
let mut occludes = vec![false; n * n];
|
||||
for a in 0..n {
|
||||
for b in 0..n {
|
||||
occludes[a * n + b] = if a == FULL as usize || b == FULL as usize {
|
||||
true
|
||||
} else if a == EMPTY as usize && b == EMPTY as usize {
|
||||
false
|
||||
} else {
|
||||
// Shapes.faceShapeOccludes: the union covers the whole face.
|
||||
let union = shapes[a].join_unoptimized(&shapes[b], ShapeOp::OR)?;
|
||||
!block.join_is_not_empty(&union, ShapeOp::ONLY_FIRST)?
|
||||
};
|
||||
}
|
||||
}
|
||||
tables.occludes = occludes;
|
||||
tables.faces = n;
|
||||
Ok(tables)
|
||||
}
|
||||
/// `LightEngine.shapeOccludes(from, to, direction)`.
|
||||
#[inline]
|
||||
fn blocked(&self, from: usize, to: usize, direction: usize) -> bool {
|
||||
let (a, b) = (&self.cells[from], &self.cells[to]);
|
||||
if !a.shaped && !b.shaped {
|
||||
return false;
|
||||
}
|
||||
let fa = a.faces[direction] as usize;
|
||||
let fb = b.faces[direction ^ 1] as usize;
|
||||
self.occludes[fa * self.faces + fb]
|
||||
}
|
||||
/// `ChunkSkyLightSources.isEdgeOccluded(top, bottom)`.
|
||||
#[inline]
|
||||
fn edge_occluded(&self, top: usize, bottom: usize) -> bool {
|
||||
self.cells[bottom].dampening != 0 || self.blocked(top, bottom, Direction::Down as usize)
|
||||
}
|
||||
pub fn emission(&self, state: u16) -> u8 {
|
||||
self.cells[state as usize].emission
|
||||
}
|
||||
pub fn dampening(&self, state: u16) -> u8 {
|
||||
self.cells[state as usize].dampening
|
||||
}
|
||||
/// Whether a state change can change light (`hasDifferentLightProperties`).
|
||||
pub fn differs(&self, old: u16, new: u16) -> bool {
|
||||
let (a, b) = (&self.cells[old as usize], &self.cells[new as usize]);
|
||||
old != new && (a.dampening != b.dampening || a.emission != b.emission || a.shaped || b.shaped)
|
||||
}
|
||||
}
|
||||
// Direction index order matches `Direction` (down, up, north, south, west, east);
|
||||
// `d ^ 1` is the opposite direction.
|
||||
const OFFSETS: [(i32, i32, i32); 6] = [(0, -1, 0), (0, 1, 0), (0, 0, -1), (0, 0, 1), (-1, 0, 0), (1, 0, 0)];
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_light: bool) -> Vec<Arc<Section>> {
|
||||
@@ -48,9 +183,11 @@ pub fn light_chunk(region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_
|
||||
}
|
||||
}
|
||||
}
|
||||
let h = (top_section * 16 + 1).min(HEIGHT as usize);
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(HEIGHT as usize);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let mut damp = vec![0u8; W * W * h];
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
@@ -63,20 +200,20 @@ pub fn light_chunk(region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_
|
||||
continue;
|
||||
}
|
||||
for i in 0..4096 {
|
||||
let id = section.get(i) as usize;
|
||||
let cell = tables.cells[id];
|
||||
let id = section.get(i);
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
damp[p] = cell.dampening;
|
||||
if cell.emission > 0 {
|
||||
block[p] = cell.emission;
|
||||
states[p] = id;
|
||||
let emission = tables.cells[id as usize].emission;
|
||||
if emission > 0 {
|
||||
block[p] = emission;
|
||||
queue.push_back(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Columns of missing neighbour chunks contribute no light.
|
||||
// Columns of missing neighbour chunks neither hold nor pass light.
|
||||
let present = |x: usize, z: usize| region[z / 16][x / 16].is_some();
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
for z in 0..W {
|
||||
@@ -84,13 +221,14 @@ pub fn light_chunk(region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_
|
||||
if !present(x, z) || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let mut y = h as i32 - 1;
|
||||
// The top layer (above the occupied range) is always open sky.
|
||||
while y >= 0 && damp[at(x, y as usize, z)] == 0 {
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0 && !tables.edge_occluded(states[at(x, y as usize, z)] as usize, states[at(x, y as usize - 1, z)] as usize) {
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y + 1;
|
||||
lowest[z * W + x] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
@@ -125,30 +263,23 @@ pub fn light_chunk(region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let y = p / (W * W);
|
||||
let mut visit = |q: usize| {
|
||||
let next = level.saturating_sub(damp[q].max(1));
|
||||
if next > light[q] {
|
||||
let from = states[p] as usize;
|
||||
for (d, (ox, oy, oz)) in OFFSETS.iter().enumerate() {
|
||||
let (nx, ny, nz) = (x as i32 + ox, y as i32 + oy, z as i32 + oz);
|
||||
if nx < 0 || nz < 0 || ny < 0 || nx >= W as i32 || nz >= W as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let (nx, ny, nz) = (nx as usize, ny as usize, nz as usize);
|
||||
if !present(nx, nz) {
|
||||
continue;
|
||||
}
|
||||
let q = at(nx, ny, nz);
|
||||
let to = states[q] as usize;
|
||||
let next = level.saturating_sub(tables.cells[to].dampening.max(1));
|
||||
if next > light[q] && !tables.blocked(from, to, d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
visit(p - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
visit(p + 1);
|
||||
}
|
||||
if z > 0 {
|
||||
visit(p - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
visit(p + W);
|
||||
}
|
||||
if y > 0 {
|
||||
visit(p - W * W);
|
||||
}
|
||||
if y + 1 < h {
|
||||
visit(p + W * W);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -184,3 +315,282 @@ pub fn light_chunk(region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
|
||||
/// Resident chunks the incremental relighter reads and writes.
|
||||
pub trait LightWorld {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u16>;
|
||||
/// Packed light (sky << 4 | block), None outside loaded chunks.
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8);
|
||||
}
|
||||
impl LightWorld for HashMap<(i32, i32), Arc<Chunk>> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u16> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
self.get(&(x >> 4, z >> 4)).filter(|c| c.lit).map(|c| c.get(x & 15, y, z & 15))
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
self.get(&(x >> 4, z >> 4)).filter(|c| c.lit).map(|c| c.light(x & 15, y, z & 15))
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
let Some(chunk) = self.get_mut(&(x >> 4, z >> 4)) else { return };
|
||||
let s = ((y - MIN_Y) >> 4) as usize;
|
||||
let i = index(x, y, z);
|
||||
if chunk.sections[s].light(i) == packed {
|
||||
return;
|
||||
}
|
||||
let section = Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]);
|
||||
if let Light::Uniform(v) = section.light {
|
||||
section.light = Light::Full(Box::new([v; 4096]));
|
||||
}
|
||||
if let Light::Full(a) = &mut section.light {
|
||||
a[i] = packed;
|
||||
}
|
||||
}
|
||||
}
|
||||
type Pos = (i32, i32, i32);
|
||||
fn level(world: &impl LightWorld, sky: bool, p: Pos) -> Option<u8> {
|
||||
world.light(p.0, p.1, p.2).map(|v| if sky { v >> 4 } else { v & 15 })
|
||||
}
|
||||
fn set_level(world: &mut impl LightWorld, sky: bool, p: Pos, value: u8) {
|
||||
let Some(old) = world.light(p.0, p.1, p.2) else { return };
|
||||
let packed = if sky { (old & 0x0F) | value << 4 } else { (old & 0xF0) | value };
|
||||
world.set_light(p.0, p.1, p.2, packed);
|
||||
}
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
pub fn lowest_sky_source(world: &impl LightWorld, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else { return MIN_Y };
|
||||
while y > MIN_Y {
|
||||
let Some(bottom) = world.state(x, y - 1, z) else { break };
|
||||
if tables.edge_occluded(top as usize, bottom as usize) {
|
||||
return y;
|
||||
}
|
||||
top = bottom;
|
||||
y -= 1;
|
||||
}
|
||||
MIN_Y
|
||||
}
|
||||
/// Vanilla's `checkBlock` + `runLightUpdates` for changed positions on
|
||||
/// resident, lit chunks: clear the light that may have come through or from
|
||||
/// the old states (decrease pass), then re-seed sources and propagate back in
|
||||
/// from the brighter boundary (increase pass). Returns every position whose
|
||||
/// stored light changed, for remeshing. `force` clears around each position
|
||||
/// as if it had been fully lit, for light computed from outdated blocks.
|
||||
pub fn relight(world: &mut impl LightWorld, tables: &LightTables, sky_light: bool, positions: &[Pos], force: bool) -> Vec<Pos> {
|
||||
let mut changed = Vec::new();
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, &mut changed);
|
||||
if sky_light {
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: HashMap<(i32, i32), i32> = HashMap::new();
|
||||
for &(x, _, z) in positions {
|
||||
if columns.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
let source = lowest_sky_source(world, tables, x, z);
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
relight_layer(world, tables, true, seeds, force, &mut changed);
|
||||
}
|
||||
changed.sort_unstable();
|
||||
changed.dedup();
|
||||
changed
|
||||
}
|
||||
fn relight_layer(world: &mut impl LightWorld, tables: &LightTables, sky: bool, seeds: Vec<Pos>, force: bool, changed: &mut Vec<Pos>) {
|
||||
let mut decrease: VecDeque<(Pos, u8)> = VecDeque::new();
|
||||
let mut cleared: Vec<Pos> = Vec::new();
|
||||
let mut increase: VecDeque<Pos> = VecDeque::new();
|
||||
for p in seeds {
|
||||
let Some(old) = level(world, sky, p) else { continue };
|
||||
if old > 0 {
|
||||
set_level(world, sky, p, 0);
|
||||
changed.push(p);
|
||||
}
|
||||
cleared.push(p);
|
||||
decrease.push_back((p, if force { 15 } else { old }));
|
||||
}
|
||||
while let Some((p, from)) = decrease.pop_front() {
|
||||
for (ox, oy, oz) in OFFSETS {
|
||||
let q = (p.0 + ox, p.1 + oy, p.2 + oz);
|
||||
let Some(l) = level(world, sky, q) else { continue };
|
||||
if l == 0 {
|
||||
continue;
|
||||
}
|
||||
// Sky light runs straight down without loss, so a 15 below a
|
||||
// cleared 15 may have come from it too.
|
||||
if l < from || (sky && oy == -1 && l == 15 && from == 15) {
|
||||
set_level(world, sky, q, 0);
|
||||
changed.push(q);
|
||||
cleared.push(q);
|
||||
decrease.push_back((q, l));
|
||||
} else {
|
||||
increase.push_back(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sources inside the cleared area light up again.
|
||||
let mut sources: HashMap<(i32, i32), i32> = HashMap::new();
|
||||
for p in cleared {
|
||||
let seed = if sky {
|
||||
let lowest = *sources.entry((p.0, p.2)).or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world.state(p.0, p.1, p.2).map_or(0, |s| tables.cells[s as usize].emission)
|
||||
};
|
||||
if seed > level(world, sky, p).unwrap_or(15) {
|
||||
set_level(world, sky, p, seed);
|
||||
changed.push(p);
|
||||
increase.push_back(p);
|
||||
}
|
||||
}
|
||||
while let Some(p) = increase.pop_front() {
|
||||
let Some(l) = level(world, sky, p) else { continue };
|
||||
if l <= 1 {
|
||||
continue;
|
||||
}
|
||||
let Some(from) = world.state(p.0, p.1, p.2) else { continue };
|
||||
for (d, (ox, oy, oz)) in OFFSETS.iter().enumerate() {
|
||||
let q = (p.0 + ox, p.1 + oy, p.2 + oz);
|
||||
let Some(to) = world.state(q.0, q.1, q.2) else { continue };
|
||||
let Some(ql) = level(world, sky, q) else { continue };
|
||||
let next = l.saturating_sub(tables.cells[to as usize].dampening.max(1));
|
||||
if next > ql && !tables.blocked(from as usize, to as usize, d) {
|
||||
set_level(world, sky, q, next);
|
||||
changed.push(q);
|
||||
increase.push_back(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AIR: u16 = 0;
|
||||
const STONE: u16 = 1;
|
||||
const TORCH: u16 = 2;
|
||||
const SLAB: u16 = 3;
|
||||
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
|
||||
/// empty top), with a third face shape for the half sides.
|
||||
fn tables() -> LightTables {
|
||||
let cell = |dampening, emission, faces: [u16; 6]| Cell {
|
||||
dampening,
|
||||
emission,
|
||||
shaped: faces.iter().any(|f| *f != EMPTY),
|
||||
faces,
|
||||
};
|
||||
LightTables {
|
||||
cells: vec![
|
||||
cell(0, 0, [EMPTY; 6]),
|
||||
cell(15, 0, [FULL; 6]),
|
||||
cell(0, 14, [EMPTY; 6]),
|
||||
cell(0, 0, [FULL, EMPTY, 2, 2, 2, 2]),
|
||||
],
|
||||
// empty, full, half: only pairs with a full face cover the square.
|
||||
occludes: vec![false, true, false, true, true, true, false, true, false],
|
||||
faces: 3,
|
||||
}
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
let mut chunks = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
if (x, z) == (0, 0) {
|
||||
edit(&mut c);
|
||||
}
|
||||
chunks.insert((x, z), c);
|
||||
}
|
||||
}
|
||||
let lit: Vec<_> = chunks
|
||||
.keys()
|
||||
.map(|&(x, z)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|dz| std::array::from_fn(|dx| chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1))));
|
||||
let mut c = chunks[&(x, z)].clone();
|
||||
c.sections = light_chunk(®ion, &tables(), true);
|
||||
c.lit = true;
|
||||
((x, z), Arc::new(c))
|
||||
})
|
||||
.collect();
|
||||
lit.into_iter().collect()
|
||||
}
|
||||
fn sky(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world.light(x, y, z).unwrap() >> 4
|
||||
}
|
||||
fn block(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world.light(x, y, z).unwrap() & 15
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torch_light_falls_off_by_one_per_block() {
|
||||
let w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
assert_eq!(block(&w, 8, 0, 8), 14);
|
||||
assert_eq!(block(&w, 9, 0, 8), 13);
|
||||
assert_eq!(block(&w, 8, 3, 8), 11);
|
||||
assert_eq!(block(&w, 8, -1, 8), 0);
|
||||
}
|
||||
#[test]
|
||||
fn bottom_slab_takes_sky_from_above_but_not_through_its_bottom() {
|
||||
// A slab roof over a closed stone room at y 0..2.
|
||||
let w = world(|c| {
|
||||
for x in 4..13 {
|
||||
for z in 4..13 {
|
||||
c.set(x, 3, z, SLAB);
|
||||
for y in 0..3 {
|
||||
let wall = x == 4 || x == 12 || z == 4 || z == 12;
|
||||
c.set(x, y, z, if wall { STONE } else { AIR });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(sky(&w, 8, 3, 8), 15, "slab cell is lit through its open top");
|
||||
// Without the shape test the room would be lit (slabs have no dampening).
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
let edits = [((8, 0, 8), AIR), ((9, 1, 8), STONE), ((3, 2, 3), TORCH), ((8, 5, 8), SLAB), ((8, -1, 8), AIR)];
|
||||
for ((x, y, z), state) in edits {
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, state);
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false);
|
||||
}
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|dz| std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c)));
|
||||
let full = light_chunk(®ion, &tables(), true);
|
||||
for (si, s) in full.iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(s.light(i), w[&(0, 0)].sections[si].light(i), "section {si} index {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ pub struct Streaming {
|
||||
/// Dimension flags: sky light, Nether face shading.
|
||||
pub sky_light: bool,
|
||||
pub nether_shading: bool,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
light_log: Vec<(u64, (i32, i32, i32))>,
|
||||
light_edit_sequence: u64,
|
||||
/// Edit sequence at each in-flight light job's snapshot.
|
||||
light_snapshots: HashMap<(i32, i32), u64>,
|
||||
pub generated_total: u64,
|
||||
pub meshed_total: u64,
|
||||
}
|
||||
@@ -126,6 +133,7 @@ impl Streaming {
|
||||
stop: AtomicBool::new(false),
|
||||
});
|
||||
let (send, results) = mpsc::channel();
|
||||
let light_tables = context.light.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
@@ -203,6 +211,10 @@ impl Streaming {
|
||||
paused: false,
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
light_snapshots: HashMap::new(),
|
||||
generated_total: 0,
|
||||
meshed_total: 0,
|
||||
}
|
||||
@@ -242,6 +254,7 @@ impl Streaming {
|
||||
if self.paused {
|
||||
return (uploads, Vec::new());
|
||||
}
|
||||
self.relight_edits(world);
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
match done {
|
||||
Done::Proto(x, z, proto) => {
|
||||
@@ -282,6 +295,7 @@ impl Streaming {
|
||||
continue;
|
||||
}
|
||||
self.lighting.remove(&(x, z));
|
||||
let snapshot = self.light_snapshots.remove(&(x, z)).unwrap_or(u64::MAX);
|
||||
if let Some(chunk) = world.chunks.get_mut(&(x, z)) {
|
||||
let chunk = Arc::make_mut(chunk);
|
||||
// Keep blocks edited since the snapshot; take only light.
|
||||
@@ -292,6 +306,7 @@ impl Streaming {
|
||||
}
|
||||
chunk.lit = true;
|
||||
self.lit.insert((x, z));
|
||||
self.replay_light_edits(world, x, z, snapshot);
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..SECTIONS as i32 {
|
||||
@@ -426,6 +441,7 @@ impl Streaming {
|
||||
world.dirty_light.remove(&(x, z));
|
||||
self.sequence += 1;
|
||||
self.lighting.insert((x, z), self.sequence);
|
||||
self.light_snapshots.insert((x, z), self.light_edit_sequence);
|
||||
self.submit(self.distance(x, z) * 4 + 1, Job::Light(x, z, region, self.sequence, self.sky_light));
|
||||
}
|
||||
// Meshing once the column and its neighbours are lit.
|
||||
@@ -455,6 +471,68 @@ impl Streaming {
|
||||
}
|
||||
(uploads, removed)
|
||||
}
|
||||
/// Relight block edits on lit chunks in place (vanilla's incremental light
|
||||
/// engine) instead of recomputing the 3x3 chunks around each edit.
|
||||
fn relight_edits(&mut self, world: &mut World) {
|
||||
if world.light_edits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let edits = std::mem::take(&mut world.light_edits);
|
||||
let positions: Vec<(i32, i32, i32)> = edits
|
||||
.iter()
|
||||
.filter(|(_, old, new)| self.light_tables.differs(*old, *new))
|
||||
.map(|(p, _, _)| *p)
|
||||
.collect();
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let changed = crate::light::relight(&mut world.chunks, &self.light_tables, self.sky_light, &positions, false);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
if !self.light_snapshots.is_empty() {
|
||||
for p in positions {
|
||||
self.light_edit_sequence += 1;
|
||||
self.light_log.push((self.light_edit_sequence, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A light job computed from blocks older than some edits: redo those
|
||||
/// edits on top of its result.
|
||||
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
|
||||
let positions: Vec<(i32, i32, i32)> = self
|
||||
.light_log
|
||||
.iter()
|
||||
.filter(|(seq, p)| *seq > snapshot && ((p.0 >> 4) - x).abs() <= 1 && ((p.2 >> 4) - z).abs() <= 1)
|
||||
.map(|(_, p)| *p)
|
||||
.collect();
|
||||
if !positions.is_empty() {
|
||||
let changed = crate::light::relight(&mut world.chunks, &self.light_tables, self.sky_light, &positions, true);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
}
|
||||
let oldest = self.light_snapshots.values().copied().min();
|
||||
match oldest {
|
||||
Some(oldest) => self.light_log.retain(|(seq, _)| *seq > oldest),
|
||||
None => self.light_log.clear(),
|
||||
}
|
||||
}
|
||||
/// 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)]) {
|
||||
let mut last = None;
|
||||
for &(x, y, z) in changed {
|
||||
for dz in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let s = ((y + dy - crate::chunk::MIN_Y) >> 4, (x + dx) >> 4, (z + dz) >> 4);
|
||||
if last == Some(s) || !(0..SECTIONS as i32).contains(&s.0) {
|
||||
continue;
|
||||
}
|
||||
last = Some(s);
|
||||
world.dirty_sections.insert((s.1, s.0, s.2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
let r = i64::from(self.radius + margin);
|
||||
self.distance(x, z) <= r * r
|
||||
|
||||
@@ -18,6 +18,9 @@ pub struct World {
|
||||
pub dirty_sections: HashSet<(i32, i32, i32)>,
|
||||
/// Chunks whose light must be recomputed.
|
||||
pub dirty_light: HashSet<(i32, i32)>,
|
||||
/// Edits on lit chunks (position, old state, new state), relit
|
||||
/// incrementally by streaming.
|
||||
pub light_edits: Vec<((i32, i32, i32), u16, u16)>,
|
||||
/// Chunks changed since the last save.
|
||||
pub modified: HashSet<(i32, i32)>,
|
||||
pub block_entities: HashMap<(i32, i32, i32), crate::containers::BlockEntity>,
|
||||
@@ -90,6 +93,7 @@ impl World {
|
||||
air,
|
||||
dirty_sections: HashSet::new(),
|
||||
dirty_light: HashSet::new(),
|
||||
light_edits: Vec::new(),
|
||||
modified: HashSet::new(),
|
||||
block_entities: HashMap::new(),
|
||||
game_time: 0,
|
||||
@@ -125,9 +129,13 @@ impl World {
|
||||
}
|
||||
}
|
||||
let _ = sy;
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_light.insert(((x >> 4) + dx, (z >> 4) + dz));
|
||||
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
|
||||
self.light_edits.push(((x, y, z), previous, state.0 as u16));
|
||||
} else {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_light.insert(((x >> 4) + dx, (z >> 4) + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump Minecraft NBT files (level.dat, data/*.dat, players/*.dat) and Anvil
|
||||
region chunks (region/, entities/, poi/) as readable SNBT-like text.
|
||||
|
||||
anvil_dump.py FILE.dat [PATH...]
|
||||
anvil_dump.py r.0.0.mca [CX CZ] [PATH...]
|
||||
|
||||
PATH selects nested keys (for example `sections 3 block_states`). Long
|
||||
arrays are summarised unless --full is given; --typed suffixes numbers
|
||||
with their NBT type (b, s, l, f, d; plain ints are TAG_Int). Used to compare Horizon saves
|
||||
with worlds written by the vanilla server.
|
||||
"""
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
|
||||
def read_tag(f, tag_id):
|
||||
if tag_id == 1:
|
||||
return ('b', struct.unpack('>b', f.read(1))[0])
|
||||
if tag_id == 2:
|
||||
return ('s', struct.unpack('>h', f.read(2))[0])
|
||||
if tag_id == 3:
|
||||
return struct.unpack('>i', f.read(4))[0]
|
||||
if tag_id == 4:
|
||||
return ('l', struct.unpack('>q', f.read(8))[0])
|
||||
if tag_id == 5:
|
||||
return ('f', struct.unpack('>f', f.read(4))[0])
|
||||
if tag_id == 6:
|
||||
return ('d', struct.unpack('>d', f.read(8))[0])
|
||||
if tag_id == 7:
|
||||
n = struct.unpack('>i', f.read(4))[0]
|
||||
return ('B', list(struct.unpack(f'>{n}b', f.read(n))))
|
||||
if tag_id == 8:
|
||||
n = struct.unpack('>H', f.read(2))[0]
|
||||
return f.read(n).decode('utf-8', 'replace')
|
||||
if tag_id == 9:
|
||||
inner, n = struct.unpack('>bi', f.read(5))
|
||||
return [read_tag(f, inner) for _ in range(max(n, 0))]
|
||||
if tag_id == 10:
|
||||
out = {}
|
||||
while True:
|
||||
t = f.read(1)[0]
|
||||
if t == 0:
|
||||
return out
|
||||
n = struct.unpack('>H', f.read(2))[0]
|
||||
name = f.read(n).decode('utf-8', 'replace')
|
||||
out[name] = read_tag(f, t)
|
||||
if tag_id == 11:
|
||||
n = struct.unpack('>i', f.read(4))[0]
|
||||
return ('I', list(struct.unpack(f'>{n}i', f.read(4 * n))))
|
||||
if tag_id == 12:
|
||||
n = struct.unpack('>i', f.read(4))[0]
|
||||
return ('L', list(struct.unpack(f'>{n}q', f.read(8 * n))))
|
||||
raise ValueError(f'bad tag id {tag_id}')
|
||||
|
||||
|
||||
def read_named(data):
|
||||
f = io.BytesIO(data)
|
||||
t = f.read(1)[0]
|
||||
n = struct.unpack('>H', f.read(2))[0]
|
||||
f.read(n)
|
||||
return read_tag(f, t)
|
||||
|
||||
|
||||
def read_dat(path):
|
||||
raw = open(path, 'rb').read()
|
||||
if raw[:2] == b'\x1f\x8b':
|
||||
raw = gzip.decompress(raw)
|
||||
return read_named(raw)
|
||||
|
||||
|
||||
def region_chunks(path):
|
||||
data = open(path, 'rb').read()
|
||||
out = {}
|
||||
for i in range(1024):
|
||||
off = int.from_bytes(data[i * 4:i * 4 + 3], 'big')
|
||||
if off == 0:
|
||||
continue
|
||||
start = off * 4096
|
||||
length = struct.unpack('>i', data[start:start + 4])[0]
|
||||
codec = data[start + 4] & 0x7F
|
||||
payload = data[start + 5:start + 4 + length]
|
||||
if codec == 1:
|
||||
payload = gzip.decompress(payload)
|
||||
elif codec == 2:
|
||||
payload = zlib.decompress(payload)
|
||||
out[(i & 31, i >> 5)] = read_named(payload)
|
||||
return out
|
||||
|
||||
|
||||
def plain(v, full, typed=False):
|
||||
if isinstance(v, tuple):
|
||||
kind, val = v
|
||||
if kind in 'bsldf':
|
||||
return f'{val}{kind}' if typed else val
|
||||
if not full and len(val) > 8:
|
||||
return f'{kind}[{len(val)}: {val[:4]}...]'
|
||||
return val
|
||||
if isinstance(v, dict):
|
||||
return {k: plain(x, full, typed) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [plain(x, full, typed) for x in v]
|
||||
return v
|
||||
|
||||
|
||||
def select(v, path):
|
||||
for p in path:
|
||||
if isinstance(v, list):
|
||||
v = v[int(p)]
|
||||
elif isinstance(v, tuple):
|
||||
v = v[1][int(p)]
|
||||
else:
|
||||
v = v[p]
|
||||
return v
|
||||
|
||||
|
||||
def main(argv):
|
||||
full = '--full' in argv
|
||||
typed = '--typed' in argv
|
||||
argv = [a for a in argv if a not in ('--full', '--typed')]
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
return 1
|
||||
path = argv[0]
|
||||
rest = argv[1:]
|
||||
if path.endswith('.mca'):
|
||||
chunks = region_chunks(path)
|
||||
if len(rest) >= 2 and rest[0].lstrip('-').isdigit():
|
||||
cx, cz = int(rest[0]) & 31, int(rest[1]) & 31
|
||||
value = chunks[(cx, cz)]
|
||||
rest = rest[2:]
|
||||
else:
|
||||
print(json.dumps(sorted(chunks)))
|
||||
return 0
|
||||
else:
|
||||
value = read_dat(path)
|
||||
print(json.dumps(plain(select(value, rest), full, typed), indent=1))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the vanilla 26.3 dedicated server headless on a world folder, optionally
|
||||
send console commands (`sleep SECONDS` pauses between them), then stop it cleanly and report problems from its log.
|
||||
|
||||
vanilla_world_check.py --jar server.jar --dir RUN_DIR [--world NAME]
|
||||
[--seed N] [--port P] [--command CMD ...]
|
||||
[--wait-after SECONDS]
|
||||
|
||||
RUN_DIR receives eula.txt, server.properties and logs/. An existing
|
||||
RUN_DIR/NAME world is opened as is (this is how Horizon saves are checked); a
|
||||
missing one is generated by vanilla (this is how reference worlds are made).
|
||||
Exit status 1 when the log contains errors, upgrade/datafix work or chunk
|
||||
load failures.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
SUSPICIOUS = re.compile(
|
||||
r'(ERROR|Exception|Couldn\'t load|Failed to|Skipping|Invalid|Unknown|corrupt|'
|
||||
r'Recreating|upgrad|DataFix|out of bounds|missing|Mismatch)', re.IGNORECASE)
|
||||
# Benign lines the server prints on every start.
|
||||
BENIGN = re.compile(r'(OFFLINE/INSECURE|online-mode|authenticate usernames|hackers|'
|
||||
r'Failed to load properties|Failed to load eula)')
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--jar', required=True)
|
||||
p.add_argument('--dir', required=True)
|
||||
p.add_argument('--world', default='world')
|
||||
p.add_argument('--seed', default='12345')
|
||||
p.add_argument('--port', default='25671')
|
||||
p.add_argument('--command', action='append', default=[])
|
||||
p.add_argument('--wait-after', type=float, default=1.0)
|
||||
p.add_argument('--timeout', type=float, default=240)
|
||||
args = p.parse_args()
|
||||
os.makedirs(args.dir, exist_ok=True)
|
||||
with open(os.path.join(args.dir, 'eula.txt'), 'w') as f:
|
||||
f.write('eula=true\n')
|
||||
props = {
|
||||
'server-port': args.port, 'online-mode': 'false', 'level-seed': args.seed,
|
||||
'level-name': args.world, 'spawn-protection': '0', 'view-distance': '4',
|
||||
'simulation-distance': '4', 'sync-chunk-writes': 'true', 'enable-query': 'false',
|
||||
'enable-rcon': 'false', 'management-server-enabled': 'false',
|
||||
'pause-when-empty-seconds': '-1',
|
||||
}
|
||||
with open(os.path.join(args.dir, 'server.properties'), 'w') as f:
|
||||
f.writelines(f'{k}={v}\n' for k, v in props.items())
|
||||
proc = subprocess.Popen(
|
||||
['java', '-Xmx2G', '-jar', os.path.abspath(args.jar), '--nogui'], cwd=args.dir,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
lines = []
|
||||
done = threading.Event()
|
||||
|
||||
def pump():
|
||||
for line in proc.stdout:
|
||||
lines.append(line.rstrip('\n'))
|
||||
if 'Done (' in line:
|
||||
done.set()
|
||||
done.set()
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
if not done.wait(args.timeout) or proc.poll() is not None:
|
||||
proc.kill()
|
||||
print('\n'.join(lines))
|
||||
print('server did not start', file=sys.stderr)
|
||||
return 1
|
||||
for command in args.command:
|
||||
if command.startswith('sleep '):
|
||||
time.sleep(float(command.split()[1]))
|
||||
continue
|
||||
proc.stdin.write(command + '\n')
|
||||
proc.stdin.flush()
|
||||
time.sleep(0.5)
|
||||
time.sleep(args.wait_after)
|
||||
proc.stdin.write('stop\n')
|
||||
proc.stdin.flush()
|
||||
try:
|
||||
proc.wait(args.timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
time.sleep(0.2)
|
||||
problems = [l for l in lines if SUSPICIOUS.search(l) and not BENIGN.search(l)]
|
||||
with open(os.path.join(args.dir, 'check.log'), 'w') as f:
|
||||
f.write('\n'.join(lines) + '\n')
|
||||
for l in problems:
|
||||
print(l)
|
||||
print(f'{len(lines)} log lines, {len(problems)} suspicious')
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user