Resolve block names to the default state, not the first one

blocks.json lists a block's states in getPossibleStates() order -- the property
cartesian product -- and separately marks which one is the default. The parser
declared only id and properties, so the default flag was dropped on the floor
and nameToStateID returned states[0]. Those differ for 642 of the 1168 blocks.

What that produced, measured rather than guessed: every redstone vein in the
world was lit=true and glowing, every sunflower was placed as its own top half
with nothing under it, and oak stairs came out upside down and waterlogged. It
also quietly disagreed with the StateGrass, StateOakLog and StateOakLeaf
constants next door in encode.go, which are the real defaults.

Now it starts from the default state and applies the properties it recognises,
keeping the default's value for an unknown key or an illegal value -- which is
what vanilla does when it reads a palette entry. That matters on the disk path:
a chunk written with a property we no longer know used to decode to a random
corner state instead of something sane.

The signature grows an ok result, because air was doing double duty as both a
real block and "no such name".

Separately, blockPaletteEntry filled the Properties compound by ranging a Go
map. nbt.Compound preserves insertion order precisely so encoding is
deterministic, so saving one chunk twice produced different region-file bytes
for every multi-property block. Keys are sorted now.
This commit is contained in:
Master290 2026-07-27 03:35:39 +03:00
parent 9e91425c5d
commit 113a59e365
14 changed files with 215 additions and 62 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 = 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]

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,12 @@
package world
import "testing"
func BenchmarkGenerateVanilla(b *testing.B){
g:=NewVanillaGenerator(12345)
b.ResetTimer(); b.ReportAllocs()
for i:=0;i<b.N;i++{ _=g(int32(i),0) }
func BenchmarkGenerateVanilla(b *testing.B) {
g := NewVanillaGenerator(12345)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = g(int32(i), 0)
}
}

View file

@ -4,8 +4,8 @@ import (
"encoding/json"
"math"
"os"
"strings"
"strconv"
"strings"
"testing"
)
@ -33,10 +33,18 @@ func TestVanillaParity(t *testing.T) {
ourY := int(oh[idx]) - 65
d := int(math.Abs(float64(ourY - vh[idx])))
total++
if d == 0 { exact++ }
if d <= 1 { within1++ }
if d <= 3 { within3++ }
if d > 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) }