diff --git a/internal/world/biome_3d_test.go b/internal/world/biome_3d_test.go index fac3963..a96c0b2 100644 --- a/internal/world/biome_3d_test.go +++ b/internal/world/biome_3d_test.go @@ -49,8 +49,8 @@ func TestCaveBiomesPresent(t *testing.T) { tbl := loadBiomeTable() for _, c := range []struct { - name string - point worldgen.TargetPoint + name string + point worldgen.TargetPoint }{ {"minecraft:lush_caves", lush}, {"minecraft:dripstone_caves", drip}, diff --git a/internal/world/features.go b/internal/world/features.go index f82a416..9d1e5ca 100644 --- a/internal/world/features.go +++ b/internal/world/features.go @@ -38,8 +38,8 @@ var oreSpecs = []oreSpec{ // are untouched. func placeOres(c *Chunk, r *chunkRand) { for _, spec := range oreSpecs { - ore := nameToStateID(spec.name, nil) - if ore == StateAir { + ore, ok := nameToStateID(spec.name, nil) + if !ok { continue // unknown block name; skip defensively } for a := 0; a < spec.attempts; a++ { @@ -88,12 +88,12 @@ func placeOreBlob(c *Chunk, ore uint16, n int, lx, y, lz int, r *chunkRand) { // biomeFlowers maps a biome name to the flower blocks that can spawn on its // grassy surface. Empty/absent = no flowers. Names resolve to IDs at runtime. var biomeFlowers = map[string][]string{ - "minecraft:plains": {"minecraft:dandelion", "minecraft:poppy", "minecraft:azure_bluet", "minecraft:cornflower", "minecraft:oxeye_daisy"}, + "minecraft:plains": {"minecraft:dandelion", "minecraft:poppy", "minecraft:azure_bluet", "minecraft:cornflower", "minecraft:oxeye_daisy"}, "minecraft:sunflower_plains": {"minecraft:dandelion", "minecraft:poppy", "minecraft:sunflower"}, - "minecraft:forest": {"minecraft:dandelion", "minecraft:poppy", "minecraft:lily_of_the_valley"}, - "minecraft:flower_forest": {"minecraft:dandelion", "minecraft:poppy", "minecraft:allium", "minecraft:azure_bluet", "minecraft:red_tulip", "minecraft:white_tulip", "minecraft:oxeye_daisy", "minecraft:cornflower"}, - "minecraft:birch_forest": {"minecraft:dandelion", "minecraft:poppy"}, - "minecraft:meadow": {"minecraft:dandelion", "minecraft:poppy", "minecraft:cornflower", "minecraft:allium"}, + "minecraft:forest": {"minecraft:dandelion", "minecraft:poppy", "minecraft:lily_of_the_valley"}, + "minecraft:flower_forest": {"minecraft:dandelion", "minecraft:poppy", "minecraft:allium", "minecraft:azure_bluet", "minecraft:red_tulip", "minecraft:white_tulip", "minecraft:oxeye_daisy", "minecraft:cornflower"}, + "minecraft:birch_forest": {"minecraft:dandelion", "minecraft:poppy"}, + "minecraft:meadow": {"minecraft:dandelion", "minecraft:poppy", "minecraft:cornflower", "minecraft:allium"}, } // placeFlora scatters biome-appropriate small plants on grassy surface columns. @@ -117,8 +117,7 @@ func placeFlora(c *Chunk, r *chunkRand, surfTop *[16][16]int, grass *[16][16]boo if c.GetBlock(lx, y, lz) != StateAir { continue } - flower := nameToStateID(flowers[int(r.next())%len(flowers)], nil) - if flower != StateAir { + if flower, ok := nameToStateID(flowers[int(r.next())%len(flowers)], nil); ok { c.SetBlock(lx, y, lz, flower) } } @@ -155,8 +154,8 @@ func placeDesertFeatures(c *Chunk, r *chunkRand, surfTop *[16][16]int, biomeName // placeCactus writes a 1-3 tall cactus column on top of the surface. func placeCactus(c *Chunk, lx, baseY, lz int, r *chunkRand) { - cactus := nameToStateID("minecraft:cactus", nil) - if cactus == StateAir { + cactus, ok := nameToStateID("minecraft:cactus", nil) + if !ok { return } h := 1 + int(r.next()%3) @@ -167,8 +166,8 @@ func placeCactus(c *Chunk, lx, baseY, lz int, r *chunkRand) { // placeDeadBush writes a single dead_bush on the surface. func placeDeadBush(c *Chunk, lx, baseY, lz int) { - db := nameToStateID("minecraft:dead_bush", nil) - if db == StateAir { + db, ok := nameToStateID("minecraft:dead_bush", nil) + if !ok { return } c.SetBlock(lx, baseY, lz, db) @@ -200,8 +199,8 @@ func placeRocks(c *Chunk, r *chunkRand, surfTop *[16][16]int, grass *[16][16]boo // placeBoulder writes a small 2-3 block cluster of stone-family blocks. func placeBoulder(c *Chunk, lx, baseY, lz int, r *chunkRand) { rocks := []string{"minecraft:cobblestone", "minecraft:granite", "minecraft:diorite", "minecraft:andesite"} - block := nameToStateID(rocks[int(r.next())%len(rocks)], nil) - if block == StateAir { + block, ok := nameToStateID(rocks[int(r.next())%len(rocks)], nil) + if !ok { return } n := 2 + int(r.next()%2) diff --git a/internal/world/heightmap_test.go b/internal/world/heightmap_test.go index 4c57aaa..823104c 100644 --- a/internal/world/heightmap_test.go +++ b/internal/world/heightmap_test.go @@ -14,8 +14,8 @@ func TestHeightmapsDiffer(t *testing.T) { c := NewChunk(0, 0, BiomePlains) const floor = 64 - dandelion := nameToStateID("minecraft:dandelion", nil) - if dandelion == StateAir { + dandelion, ok := nameToStateID("minecraft:dandelion", nil) + if !ok { t.Fatal("dandelion is missing from the block table") } for lx := 0; lx < 16; lx++ { @@ -96,8 +96,8 @@ func TestBlockStatePredicates(t *testing.T) { } } // A flower is the case that separates WORLD_SURFACE from MOTION_BLOCKING. - dandelion := nameToStateID("minecraft:dandelion", nil) - if dandelion == StateAir { + dandelion, ok := nameToStateID("minecraft:dandelion", nil) + if !ok { t.Fatal("dandelion is missing from the block table") } if blocksMotionOrFluid(dandelion) { @@ -113,11 +113,9 @@ func TestSectionFluidCount(t *testing.T) { const y = 20 // One section: stone floor, water above it, and one waterlogged block — // which counts as fluid even though it is not a fluid block. - stairs := nameToStateID("minecraft:oak_stairs", map[string]string{ - "facing": "north", "half": "bottom", "shape": "straight", "waterlogged": "true", - }) - if stairs == StateAir { - t.Fatal("waterlogged oak stairs are missing from the block table") + stairs, ok := nameToStateID("minecraft:oak_stairs", map[string]string{"waterlogged": "true"}) + if !ok { + t.Fatal("oak stairs are missing from the block table") } if stateFlags(stairs)&flagFluid == 0 { t.Fatal("waterlogged stairs do not carry the fluid flag; the dump is wrong") diff --git a/internal/world/light_test.go b/internal/world/light_test.go index 31f268a..a9f15f9 100644 --- a/internal/world/light_test.go +++ b/internal/world/light_test.go @@ -26,7 +26,7 @@ func TestIncrementalBlockLightAgainstVanillaFixture(t *testing.T) { cache.chunkAt(cx, cz) } } - glowstone := nameToStateID("minecraft:glowstone", nil) + glowstone, _ := nameToStateID("minecraft:glowstone", nil) if valid, _ := cache.SetBlockWithLight(15, 100, 8, glowstone); !valid { t.Fatal("glowstone edit rejected") } @@ -67,7 +67,7 @@ func TestIncrementalBlockLightCrossesChunkBoundaryAndClears(t *testing.T) { t.Fatal(err) } - glowstone := nameToStateID("minecraft:glowstone", nil) + glowstone, _ := nameToStateID("minecraft:glowstone", nil) if emission := lightEmission(glowstone); emission != 15 { t.Fatalf("glowstone emission = %d, want 15", emission) } diff --git a/internal/world/spawn_surface_test.go b/internal/world/spawn_surface_test.go index 7bfd6a3..faba37a 100644 --- a/internal/world/spawn_surface_test.go +++ b/internal/world/spawn_surface_test.go @@ -25,7 +25,7 @@ func TestSafeSpawnYRejectsUnderwaterColumn(t *testing.T) { } func TestSafeSpawnYAcceptsNonOpaqueSolidFloor(t *testing.T) { - stairs := nameToStateID("minecraft:oak_stairs", nil) + stairs, _ := nameToStateID("minecraft:oak_stairs", nil) if stairs == StateAir { t.Fatal("oak stairs state is unavailable") } diff --git a/internal/world/state_names.go b/internal/world/state_names.go index 7fd9cae..867b668 100644 --- a/internal/world/state_names.go +++ b/internal/world/state_names.go @@ -3,6 +3,7 @@ package world import ( _ "embed" "encoding/json" + "sort" "strings" "sync" @@ -27,6 +28,7 @@ var ( stateByIDOnce sync.Once stateByIDImpl map[uint16]stateName idsByName map[string][]uint16 + defaultByName map[string]uint16 ) // stateByID returns the named form of a block-state ID, building the lookup @@ -79,6 +81,7 @@ func buildStateTable() { var blocks map[string]struct { States []struct { ID int `json:"id"` + Default bool `json:"default"` Properties map[string]string `json:"properties"` } `json:"states"` } @@ -87,6 +90,7 @@ func buildStateTable() { } stateByIDImpl = make(map[uint16]stateName, 30000) idsByName = make(map[string][]uint16, len(blocks)) + defaultByName = make(map[string]uint16, len(blocks)) for name, b := range blocks { for _, s := range b.States { if s.ID < 0 || s.ID > 65535 { @@ -95,12 +99,20 @@ func buildStateTable() { id := uint16(s.ID) stateByIDImpl[id] = stateName{Name: name, Properties: s.Properties} idsByName[name] = append(idsByName[name], id) + if s.Default { + defaultByName[name] = id + } } } } // blockPaletteEntry builds the NBT compound for a block-state ID: {Name, // Properties} (Properties omitted when empty). Unknown IDs map to air. +// +// Property keys are sorted. nbt.Compound preserves insertion order so that +// encoding is deterministic, but ranging a Go map is not: the same chunk saved +// twice produced different region-file bytes for every block with more than one +// property, which makes a byte-level diff of two saves useless. func blockPaletteEntry(id uint16) *nbt.Compound { s, ok := stateByID(id) if !ok { @@ -108,9 +120,14 @@ func blockPaletteEntry(id uint16) *nbt.Compound { } c := nbt.NewCompound().Set("Name", nbt.String(s.Name)) if len(s.Properties) > 0 { + keys := make([]string, 0, len(s.Properties)) + for k := range s.Properties { + keys = append(keys, k) + } + sort.Strings(keys) props := nbt.NewCompound() - for k, v := range s.Properties { - props.Set(k, nbt.String(v)) + for _, k := range keys { + props.Set(k, nbt.String(s.Properties[k])) } c.Set("Properties", props) } @@ -124,21 +141,47 @@ type paletteEntryKey struct { sig string } -// nameToStateID returns the block-state ID for a (name, properties) pair from -// the loaded table. It is used when decoding on-disk chunk NBT back into a -// Chunk. Unknown names/properties map to air (0). -func nameToStateID(name string, props map[string]string) uint16 { +// nameToStateID resolves a block name plus any properties to a state ID, +// mirroring how vanilla reads a palette entry: start from the block's default +// state and apply the properties it recognises, keeping the default's value for +// anything it does not. +// +// It used to return the block's *first* state — blocks.json lists states in +// StateDefinition.getPossibleStates() order, the property cartesian product, +// which has nothing to do with the default. For 642 of 1168 blocks those +// differ, so every caller passing nil got a corner state: redstone ore came out +// permanently lit, a sunflower came out as its own top half, oak stairs came out +// upside down and waterlogged. blocks.json marks the default state and the +// parser was dropping the flag. +// +// ok is false only for a name that is not a block at all. +func nameToStateID(name string, props map[string]string) (uint16, bool) { stateByIDOnce.Do(buildStateTable) - ids := idsByName[name] - for _, id := range ids { - if propsMatch(stateByIDImpl[id].Properties, props) { - return id + defaultID, ok := defaultByName[name] + if !ok { + return StateAir, false + } + if len(props) == 0 { + return defaultID, true + } + // Overlay only keys the block actually has; an unknown key or an illegal + // value leaves the default's value in place, which is what + // StateHolder.setValue's helper does after logging. + base := stateByIDImpl[defaultID].Properties + merged := make(map[string]string, len(base)) + for k, v := range base { + if override, present := props[k]; present { + merged[k] = override + continue + } + merged[k] = v + } + for _, id := range idsByName[name] { + if propsMatch(stateByIDImpl[id].Properties, merged) { + return id, true } } - if len(ids) > 0 { - return ids[0] - } - return StateAir + return defaultID, true } func propsMatch(a, b map[string]string) bool { diff --git a/internal/world/state_names_test.go b/internal/world/state_names_test.go new file mode 100644 index 0000000..d8f9a25 --- /dev/null +++ b/internal/world/state_names_test.go @@ -0,0 +1,91 @@ +package world + +import ( + "bytes" + "testing" + + "regionio/internal/nbt" +) + +// TestNameToStateIDDefaults pins the one thing this function has to get right: +// a name with no properties resolves to the block's *default* state. It used to +// return blocks.json's first state, which is the property cartesian product's +// first entry and differs from the default for 642 of 1168 blocks. +func TestNameToStateIDDefaults(t *testing.T) { + cases := []struct { + name string + want uint16 + why string + }{ + {"minecraft:redstone_ore", 6882, "lit=false; the first state is lit=true, so every vein glowed"}, + {"minecraft:sunflower", 12916, "half=lower; the first state is the top half of the plant"}, + {"minecraft:oak_stairs", 3918, "north/bottom/straight/dry; the first state is top-half and waterlogged"}, + {"minecraft:grass_block", StateGrass, "snowy=false, and it must agree with the StateGrass constant"}, + {"minecraft:oak_log", StateOakLog, "axis=y, and it must agree with the StateOakLog constant"}, + {"minecraft:oak_leaves", StateOakLeaf, "distance=7/persistent=false/dry, agreeing with StateOakLeaf"}, + {"minecraft:stone", StateStone, "single state"}, + {"minecraft:water", StateWater, "level=0"}, + } + for _, c := range cases { + got, ok := nameToStateID(c.name, nil) + if !ok { + t.Errorf("%s: not found", c.name) + continue + } + if got != c.want { + t.Errorf("%s = %d, want %d (%s)", c.name, got, c.want, c.why) + } + } + if _, ok := nameToStateID("minecraft:not_a_block", nil); ok { + t.Error("an unknown block name resolved to a state") + } +} + +// TestNameToStateIDOverrides checks the "default plus recognised overrides" +// behaviour on the path that matters — decoding a palette entry off disk. +func TestNameToStateIDOverrides(t *testing.T) { + full, ok := nameToStateID("minecraft:oak_stairs", map[string]string{ + "facing": "east", "half": "top", "shape": "straight", "waterlogged": "true", + }) + if !ok { + t.Fatal("oak stairs not found") + } + partial, ok := nameToStateID("minecraft:oak_stairs", map[string]string{ + "facing": "east", "half": "top", "waterlogged": "true", + }) + if !ok { + t.Fatal("oak stairs not found") + } + if full != partial { + t.Errorf("a partial property set gave %d, a complete one %d; the unnamed property should keep its default", partial, full) + } + + // An unknown key and an illegal value both fall back to the default rather + // than to some unrelated corner state. + def, _ := nameToStateID("minecraft:oak_stairs", nil) + if got, _ := nameToStateID("minecraft:oak_stairs", map[string]string{"nonsense": "1"}); got != def { + t.Errorf("unknown property gave %d, want the default %d", got, def) + } + if got, _ := nameToStateID("minecraft:oak_stairs", map[string]string{"facing": "sideways"}); got != def { + t.Errorf("illegal property value gave %d, want the default %d", got, def) + } +} + +// TestBlockPaletteEntryDeterministic guards the one genuinely non-deterministic +// thing in this file: the NBT Properties compound was filled by ranging a Go +// map, so saving the same chunk twice produced different region-file bytes. +func TestBlockPaletteEntryDeterministic(t *testing.T) { + stairs, ok := nameToStateID("minecraft:oak_stairs", nil) + if !ok { + t.Fatal("oak stairs not found") + } + encode := func() []byte { + return nbt.Marshal(nbt.NewCompound().Set("e", blockPaletteEntry(stairs))) + } + first := encode() + for i := 0; i < 64; i++ { + if got := encode(); !bytes.Equal(got, first) { + t.Fatalf("palette entry bytes differ between encodes on attempt %d:\n%x\n%x", i, first, got) + } + } +} diff --git a/internal/world/store.go b/internal/world/store.go index 68a771a..bebdf22 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 = 7 +const generatorVersion = 8 // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // because it is ours, not part of the vanilla chunk format. @@ -565,7 +565,9 @@ func readBlockStates(c *Chunk, si int, sc *nbt.Compound) { } name := string(nbtAsString(ec, "Name")) props := readProps(ec) - ids[i] = nameToStateID(name, props) + // An unknown block name decodes to air rather than to a neighbour's + // state; that loses the block but does not corrupt the column. + ids[i], _ = nameToStateID(name, props) } c.section(si) // ensure allocated s := c.sections[si] diff --git a/internal/world/store_test.go b/internal/world/store_test.go index 1731d03..8fcb96a 100644 --- a/internal/world/store_test.go +++ b/internal/world/store_test.go @@ -143,7 +143,7 @@ func TestStoreLightRoundTrip(t *testing.T) { if _, err := cache.FrameErr(1, 0); err != nil { t.Fatal(err) } - glowstone := nameToStateID("minecraft:glowstone", nil) + glowstone, _ := nameToStateID("minecraft:glowstone", nil) if valid, _ := cache.SetBlockWithLight(15, 0, 8, glowstone); !valid { t.Fatal("glowstone edit rejected") } @@ -191,7 +191,7 @@ func TestCacheReconcilesPersistedLightWithLoadedNeighbor(t *testing.T) { if err != nil { t.Fatal(err) } - glowstone := nameToStateID("minecraft:glowstone", nil) + glowstone, _ := nameToStateID("minecraft:glowstone", nil) left := NewChunk(0, 0, BiomePlains) left.SetBlock(15, 100, 8, glowstone) if err := store.SaveChunk(left); err != nil { diff --git a/internal/world/surface_verify_test.go b/internal/world/surface_verify_test.go index 68c3f33..b5a9da4 100644 --- a/internal/world/surface_verify_test.go +++ b/internal/world/surface_verify_test.go @@ -48,7 +48,7 @@ func centreSurfaceBlock(ch *Chunk) (uint16, bool) { st := s[blockIndex(8, MinY+i*16+ly, 8)] if st != StateAir && st != StateWater { // Dry only if this top block is at/above sea level. - return st, (MinY+i*16+ly) >= SeaLevel + return st, (MinY + i*16 + ly) >= SeaLevel } } } diff --git a/internal/world/terrain_bench_test.go b/internal/world/terrain_bench_test.go index 18e29f0..96e060b 100644 --- a/internal/world/terrain_bench_test.go +++ b/internal/world/terrain_bench_test.go @@ -4,6 +4,9 @@ import "testing" func BenchmarkGenerateTerrain(b *testing.B) { gen := NewTerrainGenerator(0) - b.ResetTimer(); b.ReportAllocs() - for i := 0; i < b.N; i++ { _ = gen(int32(i), 0) } + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = gen(int32(i), 0) + } } diff --git a/internal/world/terrain_debug_test.go b/internal/world/terrain_debug_test.go index d997d09..c6d2d08 100644 --- a/internal/world/terrain_debug_test.go +++ b/internal/world/terrain_debug_test.go @@ -2,8 +2,8 @@ package world import ( "fmt" - "testing" "regionio/internal/worldgen" + "testing" ) func TestTerrainHeightProfile(t *testing.T) { @@ -19,8 +19,12 @@ func TestTerrainHeightProfile(t *testing.T) { } } line += fmt.Sprintf("%d ", top) - if top < minH { minH = top } - if top > maxH { maxH = top } + if top < minH { + minH = top + } + if top > maxH { + maxH = top + } } t.Logf("surface heights (z=8): %s", line) t.Logf("min=%d max=%d range=%d", minH, maxH, maxH-minH) diff --git a/internal/world/vanilla_bench_test.go b/internal/world/vanilla_bench_test.go index eb0475d..79916e1 100644 --- a/internal/world/vanilla_bench_test.go +++ b/internal/world/vanilla_bench_test.go @@ -1,7 +1,12 @@ package world + import "testing" -func BenchmarkGenerateVanilla(b *testing.B){ - g:=NewVanillaGenerator(12345) - b.ResetTimer(); b.ReportAllocs() - for i:=0;i maxDiff { maxDiff = d } + if d == 0 { + exact++ + } + if d <= 1 { + within1++ + } + if d <= 3 { + within3++ + } + if d > maxDiff { + maxDiff = d + } } } pct := func(n int) float64 { return 100 * float64(n) / float64(total) }