Send three real heightmaps instead of one repeated three times

writeHeightmaps computed "highest non-air" once and wrote the same 37 longs
under all three ids, on the stated assumption that our terrain has no leaves or
transparency. That stopped being true the moment the generator grew trees and
flowers.

Vanilla's three client heightmaps stop at different blocks: WORLD_SURFACE at the
first thing that is not air, MOTION_BLOCKING at the first that blocks motion or
holds fluid, MOTION_BLOCKING_NO_LEAVES at the first such thing that is not a
LeavesBlock -- an instanceof, not the minecraft:leaves tag. The client places
rain and snow particles off MOTION_BLOCKING and lands a fishing bobber on it, so
a tree canopy reported as solid ground rains under itself.

Neither blocksMotion() nor the leaves test is derivable from blocks.json: the
first reads cached VoxelShape collision geometry and the forceSolidOn/Off
properties, the second is a Java class check. So the Java dumper grows three
flag bits and the whole thing is renamed for what it now is -- block state
properties, not just lighting. tools/VanillaBlockStateDump.java writes
internal/world/block_properties.bin at format 2; the light bytes are unchanged
byte for byte and only the previously unused high flag bits moved.

Verified the dumper round trip while doing it: recompiling the old
VanillaLightDump against the jar reproduces the committed binary exactly, so the
data really does come from the runtime registry and not from a stale checkout.
CLAUDE.md now carries the command to rebuild it.
This commit is contained in:
Master290 2026-07-27 03:27:09 +03:00
parent 57214fbd76
commit 7880531bdb
8 changed files with 280 additions and 48 deletions

View file

@ -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 <out> tools/VanillaBlockStateDump.java
java -cp "<out>;$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)

Binary file not shown.

View file

@ -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}

View file

@ -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,

View file

@ -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")
}
}

Binary file not shown.

View file

@ -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.

View file

@ -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 <out> tools/VanillaBlockStateDump.java
// java -cp "<out>;$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++) {