diff --git a/CLAUDE.md b/CLAUDE.md index efadf03..b7d8a38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,11 +60,22 @@ threshold was pinned down: `Strategy$2` switches `{0..3}` and everything above f global palette, which `.refjava/` alone could not show. When a constant has to come from vanilla's *runtime* rather than its source or reports, dump it with -a throwaway Java program run against the jar. `tools/VanillaLightDump.java` is the precedent — it -walks the block-state registry and emits opacity, emission and voxel face shapes into -`internal/world/light_properties.bin`. Substring-matching block names is how the light table was -wrong before (`grass_block` matched "grass", `bedrock` matched "bed"); don't reintroduce that shape -of guess anywhere. +a Java program run against the jar. `tools/VanillaBlockStateDump.java` is the one that exists — it +walks the block-state registry and emits, per state, light opacity and emission, voxel face-occlusion +masks, and the `blocksMotion` / fluid / leaves flags the heightmaps need, into +`internal/world/block_properties.bin`. Rebuild it with: + +``` +CP="versions/26.1.2/server-26.1.2.jar;$(find libraries -name '*.jar' | tr '\n' ';')" +javac -nowarn -cp "$CP" -d tools/VanillaBlockStateDump.java +java -cp ";$CP" VanillaBlockStateDump > internal/world/block_properties.bin +``` + +The 39 jars under `libraries/` are required; the server jar alone will not boot the registry. Bump +the format version in both the Java and `internal/world/block_properties.go` whenever the layout or +a flag's meaning changes. Substring-matching block names is how the light table was wrong before +(`grass_block` matched "grass", `bedrock` matched "bed"); don't reintroduce that shape of guess +anywhere. ## Layout @@ -74,7 +85,7 @@ cmd/gendump/ client-free generator diagnostics — biome spread, surface bl subsurface banding, deep-layer composition, bedrock band, fluid census, cross-section cmd/genblocks/ generates internal/worldgen/generated_blocks.go from the block report -cmd/genlight/ legacy light-table generator, superseded by tools/VanillaLightDump.java +cmd/genlight/ legacy light-table generator, superseded by tools/VanillaBlockStateDump.java tools/ Java dumpers run against the jar, plus their Go-side fixtures internal/protocol/ VarInt, framing, compression, packet IDs internal/nbt/ NBT codec (modified UTF-8) diff --git a/internal/world/block_properties.bin b/internal/world/block_properties.bin new file mode 100644 index 0000000..a37bb1c Binary files /dev/null and b/internal/world/block_properties.bin differ diff --git a/internal/world/light_properties.go b/internal/world/block_properties.go similarity index 50% rename from internal/world/light_properties.go rename to internal/world/block_properties.go index c864455..ef8719e 100644 --- a/internal/world/light_properties.go +++ b/internal/world/block_properties.go @@ -6,40 +6,57 @@ import ( "fmt" ) -// lightPropertiesBinary is generated by tools/VanillaLightDump.java directly -// from the 26.1.2 runtime block-state registry. +// blockPropertiesBinary is generated by tools/VanillaBlockStateDump.java +// directly from the 26.1.2 runtime block-state registry. None of what it holds +// appears in generated/reports/blocks.json or in any tag file: light dampening, +// emission, voxel face-occlusion masks, whether a state blocks motion, whether +// it holds a fluid, and whether its block is a LeavesBlock all exist only once +// vanilla has built the registry. // -//go:embed light_properties.bin -var lightPropertiesBinary []byte +//go:embed block_properties.bin +var blockPropertiesBinary []byte var ( blockOpacity [totalBlockStates]byte blockEmission [totalBlockStates]byte - blockLightFlags [totalBlockStates]byte + blockStateFlags [totalBlockStates]byte blockLightShape [totalBlockStates]uint16 lightFaceShapes [][lightShapeBytes]byte ) const ( - lightPropertiesMagic = 0x52494f4c // RIOL - lightPropertiesVersion = 1 + blockPropertiesMagic = 0x52494f4c // RIOL + blockPropertiesVersion = 2 lightShapeBytes = 6 * 32 // six 16x16 face masks ) +// Per-state flag bits, as written by tools/VanillaBlockStateDump.java. The +// first three drive lighting; the rest drive the heightmaps, and none of them +// can be derived from blocks.json or from a tag file — they only exist once +// vanilla's block-state registry is built. +const ( + flagPropagatesSkylight = 1 << iota + flagCanOcclude + flagShapeForOcclusion + flagBlocksMotion + flagFluid + flagLeaves +) + func init() { - if err := decodeLightProperties(lightPropertiesBinary); err != nil { - panic(fmt.Sprintf("world: decode vanilla light properties: %v", err)) + if err := decodeBlockProperties(blockPropertiesBinary); err != nil { + panic(fmt.Sprintf("world: decode vanilla block properties: %v", err)) } } -func decodeLightProperties(data []byte) error { +func decodeBlockProperties(data []byte) error { if len(data) < 16 { return fmt.Errorf("header is truncated") } - if binary.BigEndian.Uint32(data[0:4]) != lightPropertiesMagic { + if binary.BigEndian.Uint32(data[0:4]) != blockPropertiesMagic { return fmt.Errorf("invalid magic") } - if version := binary.BigEndian.Uint32(data[4:8]); version != lightPropertiesVersion { + if version := binary.BigEndian.Uint32(data[4:8]); version != blockPropertiesVersion { return fmt.Errorf("unsupported version %d", version) } states := int(binary.BigEndian.Uint32(data[8:12])) @@ -56,7 +73,7 @@ func decodeLightProperties(data []byte) error { for id := 0; id < states; id++ { blockOpacity[id] = data[offset] blockEmission[id] = data[offset+1] - blockLightFlags[id] = data[offset+2] + blockStateFlags[id] = data[offset+2] blockLightShape[id] = binary.BigEndian.Uint16(data[offset+3 : offset+5]) if int(blockLightShape[id]) >= shapes { return fmt.Errorf("state %d references shape %d of %d", id, blockLightShape[id], shapes) @@ -85,6 +102,29 @@ func lightEmission(state uint16) byte { return blockEmission[state] } +func stateFlags(state uint16) byte { + if int(state) >= len(blockStateFlags) { + return 0 + } + return blockStateFlags[state] +} + +// blocksMotionOrFluid is Heightmap.Types.MOTION_BLOCKING's predicate: +// blocksMotion() || !getFluidState().isEmpty(). Note that a waterlogged block +// satisfies the fluid half, which is why the flag is per state and not per +// block. +func blocksMotionOrFluid(state uint16) bool { + return stateFlags(state)&(flagBlocksMotion|flagFluid) != 0 +} + +// blocksMotionNoLeaves is MOTION_BLOCKING_NO_LEAVES's predicate: the same, less +// anything that is a LeavesBlock. Vanilla tests instanceof, not the +// minecraft:leaves tag, and the two sets are not the same. +func blocksMotionNoLeaves(state uint16) bool { + f := stateFlags(state) + return f&(flagBlocksMotion|flagFluid) != 0 && f&flagLeaves == 0 +} + // lightShapeOccludes mirrors Shapes.faceShapeOccludes for the 1/16-resolution // face masks emitted from vanilla's VoxelShape data. func lightShapeOccludes(from, into uint16, direction int) bool { @@ -95,10 +135,11 @@ func lightShapeOccludes(from, into uint16, direction int) bool { // Vanilla substitutes an empty shape unless both flags are true. Ordinary // full cubes are handled by dampening; only shape-aware blocks (slabs, // stairs, etc.) participate in face occlusion. - if int(from) < len(blockLightFlags) && blockLightFlags[from]&6 == 6 { + const shapeAware = flagCanOcclude | flagShapeForOcclusion + if int(from) < len(blockStateFlags) && blockStateFlags[from]&shapeAware == shapeAware { fromShape = lightFaceShapes[blockLightShape[from]] } - if int(into) < len(blockLightFlags) && blockLightFlags[into]&6 == 6 { + if int(into) < len(blockStateFlags) && blockStateFlags[into]&shapeAware == shapeAware { intoShape = lightFaceShapes[blockLightShape[into]] } opposite := [...]int{1, 0, 3, 2, 5, 4} diff --git a/internal/world/encode.go b/internal/world/encode.go index 6755485..2da7c38 100644 --- a/internal/world/encode.go +++ b/internal/world/encode.go @@ -286,22 +286,36 @@ func (c *Chunk) encode() []byte { return w.Bytes() } -// Heightmap.Types ordinals sent to the client. +// Heightmap.Types ids whose Usage is CLIENT. Vanilla sends exactly these three +// and no others. const ( hmWorldSurface = 1 hmMotionBlocking = 4 hmMotionBlockingNoLeaves = 5 ) -// writeHeightmaps emits the three client-relevant heightmaps. For our blocky -// terrain (no leaves/transparency) they share the same column heights. +// writeHeightmaps emits the three heightmaps the client is sent. +// +// They are not the same map, which is what this code used to assume. Each stops +// at a different block: WORLD_SURFACE at the first thing that is not air, +// MOTION_BLOCKING at the first thing that blocks movement or holds fluid, and +// MOTION_BLOCKING_NO_LEAVES at the first such thing that is not leaves. A +// flower, a torch, a sapling or a tree canopy separates them — the client uses +// MOTION_BLOCKING to place rain and snow particles and to decide where a +// fishing bobber lands, so a canopy reported as solid ground rains indoors. func (c *Chunk) writeHeightmaps(w *protocol.Writer) { - heights := c.columnHeights() - packed := packHeightmap(heights) - + surface, motion, motionNoLeaves := c.heightmaps() w.VarInt(3) - for _, t := range []int32{hmMotionBlockingNoLeaves, hmMotionBlocking, hmWorldSurface} { - w.VarInt(t) + for _, hm := range [...]struct { + id int32 + values [256]uint16 + }{ + {hmWorldSurface, surface}, + {hmMotionBlocking, motion}, + {hmMotionBlockingNoLeaves, motionNoLeaves}, + } { + packed := packHeightmap(hm.values) + w.VarInt(hm.id) w.VarInt(int32(len(packed))) for _, v := range packed { w.Int64(int64(v)) @@ -309,24 +323,54 @@ func (c *Chunk) writeHeightmaps(w *protocol.Writer) { } } -// columnHeights returns, per column, (highestNonAirY + 1) - MinY, clamped to 0. -func (c *Chunk) columnHeights() [256]uint16 { - var h [256]uint16 +// heightmaps walks every column once from the top down, recording the first +// block that satisfies each predicate. The stored value is one above the +// matching block, relative to the world floor — what Heightmap.setHeight +// writes — so 0 means the column has no matching block at all. +func (c *Chunk) heightmaps() (surface, motion, motionNoLeaves [256]uint16) { for lx := 0; lx < 16; lx++ { for lz := 0; lz < 16; lz++ { - height := 0 - for y := MinY + WorldHeight - 1; y >= MinY; y-- { - si := (y - MinY) >> 4 + i := lz*16 + lx + var haveSurface, haveMotion, haveNoLeaves bool + for si := SectionCount - 1; si >= 0; si-- { s := c.sections[si] - if s != nil && s[blockIndex(lx, y, lz)] != StateAir { - height = y + 1 - MinY + if s == nil { + continue + } + for ly := 15; ly >= 0; ly-- { + y := MinY + si*16 + ly + state := s[blockIndex(lx, y, lz)] + if state == StateAir { + continue + } + h := uint16(y + 1 - MinY) + if !haveSurface { + surface[i], haveSurface = h, true + } + if !haveMotion && blocksMotionOrFluid(state) { + motion[i], haveMotion = h, true + } + if !haveNoLeaves && blocksMotionNoLeaves(state) { + motionNoLeaves[i], haveNoLeaves = h, true + } + if haveSurface && haveMotion && haveNoLeaves { + break + } + } + if haveSurface && haveMotion && haveNoLeaves { break } } - h[lz*16+lx] = uint16(height) } } - return h + return surface, motion, motionNoLeaves +} + +// columnHeights returns the WORLD_SURFACE heightmap on its own, for the +// on-disk Heightmaps tag and the parity test. +func (c *Chunk) columnHeights() [256]uint16 { + surface, _, _ := c.heightmaps() + return surface } // packHeightmap packs 256 column heights at 9 bits each, 7 values per long, diff --git a/internal/world/heightmap_test.go b/internal/world/heightmap_test.go new file mode 100644 index 0000000..3e97ef2 --- /dev/null +++ b/internal/world/heightmap_test.go @@ -0,0 +1,106 @@ +package world + +import "testing" + +// TestHeightmapsDiffer is the check the old code could not pass: the three +// heightmaps sent to the client are different maps. They were all written from +// one "highest non-air" array, on the stated assumption that our terrain has no +// leaves or transparency — untrue the moment the generator grew trees and +// flowers. +// +// The client reads MOTION_BLOCKING to place rain and snow and to land a fishing +// bobber, and MOTION_BLOCKING_NO_LEAVES to decide what counts as sky cover. +func TestHeightmapsDiffer(t *testing.T) { + c := NewChunk(0, 0, BiomePlains) + const floor = 64 + + dandelion := nameToStateID("minecraft:dandelion", nil) + if dandelion == StateAir { + t.Fatal("dandelion is missing from the block table") + } + for lx := 0; lx < 16; lx++ { + for lz := 0; lz < 16; lz++ { + c.SetBlock(lx, floor, lz, StateStone) + } + } + // A flower: non-air, but it neither blocks motion nor holds fluid. + c.SetBlock(1, floor+1, 1, dandelion) + // A canopy: leaves block motion, so MOTION_BLOCKING counts them and + // MOTION_BLOCKING_NO_LEAVES does not. + c.SetBlock(2, floor+3, 2, StateOakLeaf) + // Water blocks neither entities nor light but does hold fluid, so it + // counts for both motion maps. + c.SetBlock(3, floor+1, 3, StateWater) + + surface, motion, noLeaves := c.heightmaps() + at := func(h [256]uint16, lx, lz int) int { return int(h[lz*16+lx]) + MinY } + + cases := []struct { + name string + lx, lz int + wantSurface, wantMotion, wantNo int + }{ + {"plain stone: all three agree", 0, 0, floor + 1, floor + 1, floor + 1}, + {"flower: only the surface map sees it", 1, 1, floor + 2, floor + 1, floor + 1}, + {"leaves: no-leaves map falls through to the stone", 2, 2, floor + 4, floor + 4, floor + 1}, + {"water: counts as fluid for both motion maps", 3, 3, floor + 2, floor + 2, floor + 2}, + } + for _, c := range cases { + gotS := at(surface, c.lx, c.lz) + gotM := at(motion, c.lx, c.lz) + gotN := at(noLeaves, c.lx, c.lz) + if gotS != c.wantSurface || gotM != c.wantMotion || gotN != c.wantNo { + t.Errorf("%s: surface=%d motion=%d noLeaves=%d, want %d/%d/%d", + c.name, gotS, gotM, gotN, c.wantSurface, c.wantMotion, c.wantNo) + } + } + + // A column with no matching block at all stores zero, not the world floor. + // A chunk of pure air is the clearest case, and it also exercises the + // nil-section fast path. + emptySurface, emptyMotion, emptyNoLeaves := NewChunk(0, 0, BiomePlains).heightmaps() + for i := range emptySurface { + if emptySurface[i] != 0 || emptyMotion[i] != 0 || emptyNoLeaves[i] != 0 { + t.Fatalf("empty chunk column %d reported %d/%d/%d, want all zero", + i, emptySurface[i], emptyMotion[i], emptyNoLeaves[i]) + } + } +} + +// TestBlockStatePredicates spot-checks the flags the dump carries against +// blocks whose behaviour is not in doubt. A silently wrong dump would make the +// heightmaps wrong everywhere at once. +func TestBlockStatePredicates(t *testing.T) { + cases := []struct { + name string + state uint16 + motion, noLeaves, isLeaves bool + }{ + {"stone", StateStone, true, true, false}, + {"grass block", StateGrass, true, true, false}, + {"oak log", StateOakLog, true, true, false}, + {"oak leaves", StateOakLeaf, true, false, true}, + {"water", StateWater, true, true, false}, + {"lava", StateLava, true, true, false}, + {"air", StateAir, false, false, false}, + } + for _, c := range cases { + if got := blocksMotionOrFluid(c.state); got != c.motion { + t.Errorf("%s: blocksMotionOrFluid = %v, want %v", c.name, got, c.motion) + } + if got := blocksMotionNoLeaves(c.state); got != c.noLeaves { + t.Errorf("%s: blocksMotionNoLeaves = %v, want %v", c.name, got, c.noLeaves) + } + if got := stateFlags(c.state)&flagLeaves != 0; got != c.isLeaves { + t.Errorf("%s: leaves flag = %v, want %v", c.name, got, c.isLeaves) + } + } + // A flower is the case that separates WORLD_SURFACE from MOTION_BLOCKING. + dandelion := nameToStateID("minecraft:dandelion", nil) + if dandelion == StateAir { + t.Fatal("dandelion is missing from the block table") + } + if blocksMotionOrFluid(dandelion) { + t.Error("dandelion blocks motion; it should not") + } +} diff --git a/internal/world/light_properties.bin b/internal/world/light_properties.bin deleted file mode 100644 index b121079..0000000 Binary files a/internal/world/light_properties.bin and /dev/null differ diff --git a/internal/world/store.go b/internal/world/store.go index c5de4d4..68a771a 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -32,7 +32,7 @@ const dataVersion26 = 4790 // first time it ran: chunkAt prefers the store over the generator, so the // already-explored area around spawn keeps its old terrain and every later fix // looks like it did nothing in exactly the place you are standing. -const generatorVersion = 6 +const generatorVersion = 7 // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // because it is ours, not part of the vanilla chunk format. diff --git a/tools/VanillaLightDump.java b/tools/VanillaBlockStateDump.java similarity index 65% rename from tools/VanillaLightDump.java rename to tools/VanillaBlockStateDump.java index 91f4b6e..a2a7705 100644 --- a/tools/VanillaLightDump.java +++ b/tools/VanillaBlockStateDump.java @@ -10,14 +10,26 @@ import net.minecraft.SharedConstants; import net.minecraft.core.Direction; import net.minecraft.server.Bootstrap; import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.LeavesBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.shapes.VoxelShape; -// Dumps protocol-775 lighting properties directly from vanilla runtime state. -// Compile/run against the unpacked 26.1.2 server classpath and redirect stdout -// to internal/world/light_properties.bin. -public final class VanillaLightDump { +// Dumps the per-block-state properties the chunk pipeline needs, straight from +// vanilla runtime state. generated/reports/blocks.json carries none of them: +// they only exist once the block-state registry is built. +// +// Compile and run against the unpacked 26.1.2 server classpath and redirect +// stdout to internal/world/block_properties.bin: +// +// CP="versions/26.1.2/server-26.1.2.jar;$(find libraries -name '*.jar' | tr '\n' ';')" +// javac -nowarn -cp "$CP" -d tools/VanillaBlockStateDump.java +// java -cp ";$CP" VanillaBlockStateDump > internal/world/block_properties.bin +// +// Was VanillaLightDump: it dumped only lighting until the heightmaps needed +// blocksMotion and the leaves test, neither of which is derivable from any +// report or tag file. +public final class VanillaBlockStateDump { private record MaskKey(byte[] data) { @Override public boolean equals(Object other) { return other instanceof MaskKey key && Arrays.equals(data, key.data); @@ -25,6 +37,18 @@ public final class VanillaLightDump { @Override public int hashCode() { return Arrays.hashCode(data); } } + // Format version. Bump whenever the byte layout or the meaning of a flag + // bit changes; the Go decoder rejects anything it does not know. + private static final int FORMAT_VERSION = 2; + + // Flag bits. 1..4 drive lighting, 8..32 drive the heightmaps. + private static final int FLAG_PROPAGATES_SKYLIGHT = 1; + private static final int FLAG_CAN_OCCLUDE = 2; + private static final int FLAG_SHAPE_FOR_OCCLUSION = 4; + private static final int FLAG_BLOCKS_MOTION = 8; + private static final int FLAG_FLUID = 16; + private static final int FLAG_LEAVES = 32; + public static void main(String[] args) throws Exception { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); @@ -42,9 +66,15 @@ public final class VanillaLightDump { dampening[id] = (byte) state.getLightDampening(); emission[id] = (byte) state.getLightEmission(); int stateFlags = 0; - if (state.propagatesSkylightDown()) stateFlags |= 1; - if (state.canOcclude()) stateFlags |= 2; - if (state.useShapeForLightOcclusion()) stateFlags |= 4; + if (state.propagatesSkylightDown()) stateFlags |= FLAG_PROPAGATES_SKYLIGHT; + if (state.canOcclude()) stateFlags |= FLAG_CAN_OCCLUDE; + if (state.useShapeForLightOcclusion()) stateFlags |= FLAG_SHAPE_FOR_OCCLUSION; + // Heightmap.Types: MOTION_BLOCKING is blocksMotion || fluid, and + // MOTION_BLOCKING_NO_LEAVES additionally excludes LeavesBlock -- + // an instanceof, not the minecraft:leaves tag, so the two differ. + if (state.blocksMotion()) stateFlags |= FLAG_BLOCKS_MOTION; + if (!state.getFluidState().isEmpty()) stateFlags |= FLAG_FLUID; + if (state.getBlock() instanceof LeavesBlock) stateFlags |= FLAG_LEAVES; flags[id] = (byte) stateFlags; byte[] masks = faceMasks(state); @@ -60,7 +90,7 @@ public final class VanillaLightDump { DataOutputStream out = new DataOutputStream(new BufferedOutputStream(System.out)); out.writeInt(0x52494f4c); // RIOL - out.writeInt(1); + out.writeInt(FORMAT_VERSION); out.writeInt(count); out.writeInt(shapes.size()); for (int id = 0; id < count; id++) {