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:
Ludovic Marie
2026-10-08 15:25:41 +02:00
parent 1b38d82aac
commit 94539db425
7 changed files with 1111 additions and 45 deletions
@@ -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(&region, &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(&region, &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(&region, 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(&region, 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);
}
}
+1 -1
View File
@@ -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());
+451 -41
View File
@@ -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(&region, &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(&region, &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}");
}
}
}
}
+78
View File
@@ -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
+11 -3
View File
@@ -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));
}
}
}
}
+146
View File
@@ -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:]))
+98
View File
@@ -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())