LevelChunkSection puts two shorts in front of every section: nonEmptyBlockCount
and fluidCount. We wrote the first and then a literal 0 for the second, under a
comment claiming it was a reserved field that vanilla always leaves at zero.
It is not reserved and vanilla does not.
So every client was told every section is fluid-free, in a world where the
aquifer now fills oceans, lakes and flooded caves. The golden test did not catch
it because its fixture is a superflat chunk whose real fluid count is zero.
The count is per block state, not per block: a waterlogged stair holds a fluid
while a dry one does not, and the flag for that comes from the block-state dump
added with the heightmaps.
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.
Every air block below y=63 was turned into water. That is one line of code and
it cost the entire underground: no dry caves, no lava lakes, no air pockets, a
solid block of water from the sea floor to bedrock.
Vanilla decides fluid per position instead. Aquifer centres sit on a jittered
16x12x16 grid; each gets a fluid level and type from the floodedness and spread
noises, with centres near open sky inheriting the sea and buried ones getting a
much lower randomised level or nothing at all. A position takes its nearest
centre's fluid unless the barrier noise raises enough pressure between the two
or three nearest centres to seal it back to stone. Deep centres turn to lava.
Porting it means fixing the order of generation, not just adding a file. Vanilla
resolves stone/water/lava/air during the density pass and only then runs the
surface rules over a finished column; we did it the other way round, which is
what forced the unconditional flood in the first place. fillVanillaColumn now
asks the aquifer per position, and applySurfaceRule walks the finished column
carrying the bookkeeping SurfaceSystem carries: air resets the counters, a fluid
records its water height, and stone gets a depth from the top of its run plus
one from the bottom, found by looking ahead to the next non-stone block below.
That last one fixes stone_depth's ceiling form, which had no bottom-up depth to
work with and was testing the top-down one instead -- fourteen rules in the
overworld tree use it to dress cave roofs. The floor form is unchanged: vanilla
counts from 1 and compares against 1 + offset, we counted from 0 and compared
against offset.
The aquifer grid is built eagerly per chunk rather than lazily, because our
columns fill concurrently; every cell is a pure function of its grid coordinate
and every cell in the computed range gets consulted anyway. Cost is ~0.5% of
chunk generation, most of it absorbed by the shared preliminary-surface cache.
Inland caves go from 100% water to 3.8%, and lava exists for the first time.
cmd/gendump grows a census that would have failed loudly before, and
TestCavesAreDry guards it in the suite.
Vanilla's SECTION_BIOMES palette strategy switches on the bit count with
`tableswitch {0..3}`: 0 single-valued, 1-3 linear, and everything else falls
through to the global palette. There is no hashmap tier for biomes — that exists
only for block states, whose 0..8 switch we already implement correctly.
We were writing a linear palette all the way up to 7 bits. A section holding 9 or
more distinct biomes therefore went out as a 4-bit indirect container while the
client read it as global: no palette prefix consumed, long array re-read at 7
bits, and every field after it in the chunk payload misaligned. Sections that
straddle the surface and the cave biomes really do carry that many, so this is
reachable in ordinary terrain rather than a corner case.
Checked against the jar rather than recalled: javap -c on Strategy$2 shows the
{0..3} switch with Configuration$Global in the default arm.
The test decodes each container the way the client would and requires it to
consume exactly the bytes we produced, so a misframed container shows up as a
byte count instead of needing a client to notice. Restoring the old threshold
fails the 9-, 20- and 65-biome cases.
The world now survives restarts: chunks load from disk (read-through
cache) and player edits persist via async autosave + a final SaveAll on
shutdown. RegionIO finally does region I/O.
- world/regionfile.go: Anvil .mca container — 8192-byte header
(offset + timestamp tables), 4096-byte sectors, zlib chunk records.
- world/compress.go: zlib deflate/inflate for chunk payloads.
- world/store.go: chunk <-> Level-nested NBT (per-section
block_states/biomes palettes, WORLD_SURFACE heightmap, DataVersion
4790, yPos -4) via the existing nbt package; Store opens one
RegionFile per region with proper floor-division coords.
- world/state_names.go: id->name bridge from the embedded blocks.json
report so network int-IDs round-trip through the disk named palette.
- world/encode.go: GetBiome read accessor for serialization.
- world/cache.go: read-through (disk then generation), dirty tracking,
StartAutosave (returns a done channel so the saver exits before
Close), SaveAll, NewCacheWithStore.
- server.go + main.go: Config.WorldDir (default "world"), -world flag,
autosave loop every 30s, SaveAll + store Close on signal.
- Tests: region round-trip/absent/overwrite, chunk NBT round-trip,
end-to-end save-reload, negative chunk coords, autosave persistence.
Replaces the biome-blind fillVanillaColumn heuristics with a full
interpreter for the overworld surface_rule tree (already embedded in
overworld.json): block/sequence/condition/bandlands rules plus all 11
condition tests (biome, steep, hole, water, temperature, y_above,
stone_depth, noise_threshold, not, vertical_gradient,
above_preliminary_surface).
- worldgen/blockids.go: name(+Properties)→network-ID table for surface
blocks (grass/sand/terracotta/mycelium/podzol/coarse_dirt/sandstone/
calcite/snow/ice/...), with snowy property variants.
- worldgen/surface.go: rule-tree parser + interpreter + SurfaceContext;
LoadOverworldSurfaceRule caches the seed-independent tree.
- loader.go: OverworldDensity.SurfaceRule() exposes the parsed tree.
- biome_lookup.go: BiomeNameAt returns the biome name for biome tests.
- vanilla.go: samples the 2D climate + biome before column fill, threads
the rule tree and biome name into fillVanillaColumn, and applies it
top-down with stone as the default for non-matching (deeper) blocks.
The above_preliminary_surface gate uses an inclusive bound so the top
solid block reaches the biome dispatch.
- Performance: one per-column RNG and a reused SurfaceContext keep the
overhead to ~+13ms/chunk (71ms vs 58ms baseline), within the gate.
- Chunk stores per-section biome arrays (64 cells/section); flat generators
keep the uniform single-valued fallback.
- New writeBiomePalette uses min 1 bpe and direct at registry width (65 biomes).
- Climate sampler splits 2D axes (sampled once per column) from 3D depth
(per cell), keeping per-cell cost to a single density-function compute.
- Full biome parameter table (surface + underground twins + lush/dripstone/
deep_dark caves) with depth as a true range, not a binary layer.
- fillBiomes3D fills the 1536 cells/chunk in parallel; <0.3ms overhead vs
baseline chunk gen (benchmark-verified).
- Tests: cave-biome resolution, per-cell variation, flat-world regression,
registry-range validity, plus chunk-gen and per-cell benchmarks.