world: replay stage-3 monster rooms
Ports MonsterRoomFeature from bytecode with vanilla draw order and loop structure: shell validation (solid floor and ceiling, one to five open side columns), interior cave_air carving, cobblestone walls with the mossy three-in-four floor roll drawn only where vanilla draws it, chest placement faced away from its single opaque neighbour with the loot-seed long consumed even though block entities are not modelled yet, and the spawner's one-draw mob pick. Writes go through Feature.safeSetBlock semantics against #minecraft:features_cannot_replace, captured verbatim from the jar into the embedded datapack data. The pass slots between geodes and the ores so the rooms' cave_air pockets exist before ore ellipsoids roll their air-exposure discards. The committed fixture's four chunks happen to contain no dungeons, so measured parity holds at 98.028%, but the generator's output can differ elsewhere: generatorVersion bumps to 22.
This commit is contained in:
parent
9454da3ab3
commit
790d7e3144
6 changed files with 469 additions and 1 deletions
|
|
@ -36,6 +36,12 @@ func (r *decorationRegion) replayScheduledOres(seed int64, targetX, targetZ int3
|
|||
if err := r.placeScheduledGeodes(seed); err != nil {
|
||||
return fmt.Errorf("world: replay source geodes (%d,%d): %w", source.X, source.Z, err)
|
||||
}
|
||||
// Stage 3 runs before the ores on purpose: the rooms' cave_air pockets
|
||||
// are what vanilla's ore ellipsoids roll their air-exposure discards
|
||||
// against.
|
||||
if err := r.placeScheduledMonsterRooms(seed); err != nil {
|
||||
return fmt.Errorf("world: replay source monster rooms (%d,%d): %w", source.X, source.Z, err)
|
||||
}
|
||||
if err := r.placeScheduledUndergroundOresStage(seed); err != nil {
|
||||
return fmt.Errorf("world: replay source underground ores (%d,%d): %w", source.X, source.Z, err)
|
||||
}
|
||||
|
|
|
|||
297
internal/world/monster_rooms.go
Normal file
297
internal/world/monster_rooms.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
// monster_rooms.go ports net.minecraft.world.level.levelgen.feature.
|
||||
// MonsterRoomFeature, the stage-3 underground-structures feature behind the
|
||||
// monster_room and monster_room_deep placed features. Both share one configured
|
||||
// feature with an empty config; only their placement modifiers differ, and the
|
||||
// generic placement driver already handles those.
|
||||
//
|
||||
// The rooms matter far beyond their own blocks: they open cave_air pockets
|
||||
// during the stage that runs before the ores. Ore ellipsoids crossing those
|
||||
// pockets roll air-exposure discards, so replaying monster rooms in the right
|
||||
// order is what lets the ore schedule see the world vanilla's ores saw.
|
||||
//
|
||||
// The port follows bytecode fidelity like the carvers do: every random draw,
|
||||
// loop order, and early return is vanilla's. Block entities are not modelled
|
||||
// yet — chests place as plain block states and the spawner's mob pick still
|
||||
// consumes its draw so later placement positions stay on vanilla's stream.
|
||||
|
||||
const undergroundStructuresStage = 3
|
||||
|
||||
var (
|
||||
monsterRoomOnce sync.Once
|
||||
monsterRoomCannotTable []bool
|
||||
monsterRoomStatesOnce sync.Once
|
||||
|
||||
monsterCaveAirID uint16
|
||||
monsterChestIDs map[string]uint16
|
||||
monsterSpawnerID uint16
|
||||
monsterCobbleID uint16
|
||||
monsterMossyID uint16
|
||||
monsterAirIDs map[uint16]bool
|
||||
monsterCannotTable []bool
|
||||
)
|
||||
|
||||
func initMonsterRoomTables() {
|
||||
monsterRoomOnce.Do(func() {
|
||||
names, err := worldgen.FeaturesCannotReplace()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
stateByIDOnce.Do(buildStateTable)
|
||||
monsterCannotTable = make([]bool, totalBlockStates)
|
||||
for _, name := range names {
|
||||
for _, id := range idsByName[name] {
|
||||
if int(id) < len(monsterCannotTable) {
|
||||
monsterCannotTable[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
monsterRoomStatesOnce.Do(func() {
|
||||
stateByIDOnce.Do(buildStateTable)
|
||||
monsterCaveAirID = monsterMustState("minecraft:cave_air", nil)
|
||||
monsterSpawnerID = monsterMustState("minecraft:spawner", nil)
|
||||
monsterCobbleID = monsterMustState("minecraft:cobblestone", nil)
|
||||
monsterMossyID = monsterMustState("minecraft:mossy_cobblestone", nil)
|
||||
monsterChestIDs = map[string]uint16{}
|
||||
for _, facing := range []string{"north", "south", "west", "east"} {
|
||||
monsterChestIDs[facing] = monsterMustState("minecraft:chest", map[string]string{
|
||||
"facing": facing, "type": "single", "waterlogged": "false",
|
||||
})
|
||||
}
|
||||
monsterAirIDs = map[uint16]bool{
|
||||
mustMonsterAir("minecraft:air"): true,
|
||||
mustMonsterAir("minecraft:cave_air"): true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func monsterMustState(name string, props map[string]string) uint16 {
|
||||
id, ok := nameToStateID(name, props)
|
||||
if !ok {
|
||||
panic("world: missing block state for monster rooms: " + name)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustMonsterAir(name string) uint16 {
|
||||
return monsterMustState(name, nil)
|
||||
}
|
||||
|
||||
// monsterSafeSetBlock is Feature.safeSetBlock: replace unless the existing
|
||||
// state is in #minecraft:features_cannot_replace.
|
||||
func (r *decorationRegion) monsterSafeSetBlock(x, y, z int, state uint16) bool {
|
||||
if monsterCannotTable[r.getBlock(x, y, z)] {
|
||||
return false
|
||||
}
|
||||
return r.setBlock(x, y, z, state)
|
||||
}
|
||||
|
||||
// placeScheduledMonsterRooms replays the vanilla stage-3 schedule from one
|
||||
// source center into the mutable decoration region.
|
||||
func (r *decorationRegion) placeScheduledMonsterRooms(seed int64) error {
|
||||
set, err := worldgen.LoadFeatureSet()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
initMonsterRoomTables()
|
||||
if err := r.ensureSourceNeighborhood(); err != nil {
|
||||
return err
|
||||
}
|
||||
schedule, err := set.FeatureSchedule(possibleBiomeOrder(), r.sourceBiomes(), undergroundStructuresStage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
random, decorationSeed := worldgen.DecorationRandom(seed, int(r.sourceX), int(r.sourceZ))
|
||||
origin := worldgen.FeaturePosition{X: int(r.sourceX) << 4, Y: MinY, Z: int(r.sourceZ) << 4}
|
||||
for _, scheduled := range schedule {
|
||||
placed, ok := set.Placed[scheduled.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
configured, ok := set.Configured[placed.Feature]
|
||||
if !ok || configured.Type != "minecraft:monster_room" {
|
||||
continue
|
||||
}
|
||||
random.SetFeatureSeed(decorationSeed, scheduled.Index, undergroundStructuresStage)
|
||||
context := r.placementContext(func(position worldgen.FeaturePosition) bool {
|
||||
return r.biomeAllowsFeature(set, scheduled.Name, undergroundStructuresStage, position)
|
||||
})
|
||||
if err := set.ForEachPlacementPosition(scheduled.Name, random, origin, context, func(position worldgen.FeaturePosition) error {
|
||||
placeMonsterRoom(r, random, position.X, position.Y, position.Z)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeMonsterRoom ports MonsterRoomFeature.place for one origin.
|
||||
func placeMonsterRoom(r *decorationRegion, random worldgen.RandomSource, ox, oy, oz int) bool {
|
||||
initMonsterRoomTables()
|
||||
|
||||
j := int(random.NextIntN(2)) + 2 // x half-extent minus walls: 2..3
|
||||
o := int(random.NextIntN(2)) + 2 // z half-extent minus walls: 2..3
|
||||
k, l := -j-1, j+1 // full x span, walls included
|
||||
p, q := -o-1, o+1 // full z span
|
||||
|
||||
// Pass 1 validates the shell: solid floor at y=-1 and ceiling at y=4 for
|
||||
// every column, and between one and five open side openings at y=0.
|
||||
openings := 0
|
||||
for x := k; x <= l; x++ {
|
||||
for y := -1; y <= 4; y++ {
|
||||
for z := p; z <= q; z++ {
|
||||
solid := monsterIsSolid(r.getBlock(ox+x, oy+y, oz+z))
|
||||
if y == -1 && !solid {
|
||||
return false
|
||||
}
|
||||
if y == 4 && !solid {
|
||||
return false
|
||||
}
|
||||
if (x == k || x == l || z == p || z == q) && y == 0 {
|
||||
if monsterIsAir(r.getBlock(ox+x, oy, oz+z)) &&
|
||||
monsterIsAir(r.getBlock(ox+x, oy+1, oz+z)) {
|
||||
openings++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if openings < 1 || openings > 5 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Pass 2 carves: interior becomes cave air; walls become cobblestone, with
|
||||
// a mossy three-in-four roll on the floor row; a wall block whose own floor
|
||||
// was already carved becomes cave air outright, bypassing the replaceable
|
||||
// guard just as vanilla's unconditional setBlock does. The mossy roll only
|
||||
// draws for a solid non-chest cell that reaches the wall branch — the same
|
||||
// conditions under which vanilla reaches its nextInt(4).
|
||||
for x := k; x <= l; x++ {
|
||||
for y := 3; y >= -1; y-- {
|
||||
for z := p; z <= q; z++ {
|
||||
bx, by, bz := ox+x, oy+y, oz+z
|
||||
current := r.getBlock(bx, by, bz)
|
||||
interior := x != k && y != -1 && z != p && x != l && y != 4 && z != q
|
||||
if interior {
|
||||
if current == monsterChestIDs["north"] ||
|
||||
current == monsterChestIDs["south"] ||
|
||||
current == monsterChestIDs["west"] ||
|
||||
current == monsterChestIDs["east"] ||
|
||||
current == monsterSpawnerID {
|
||||
continue
|
||||
}
|
||||
r.monsterSafeSetBlock(bx, by, bz, monsterCaveAirID)
|
||||
continue
|
||||
}
|
||||
// The mossy roll draws only here, exactly where vanilla's
|
||||
// floor branch sits; every other wall row goes cobblestone
|
||||
// without a draw.
|
||||
placeWall := func() {
|
||||
state := monsterCobbleID
|
||||
if y == -1 && random.NextIntN(4) != 0 {
|
||||
state = monsterMossyID
|
||||
}
|
||||
r.monsterSafeSetBlock(bx, by, bz, state)
|
||||
}
|
||||
if by < MinY {
|
||||
if !monsterIsSolid(current) || isMonsterChest(current) {
|
||||
continue
|
||||
}
|
||||
placeWall()
|
||||
continue
|
||||
}
|
||||
if !monsterIsSolid(r.getBlock(bx, by-1, bz)) {
|
||||
r.setBlock(bx, by, bz, monsterCaveAirID)
|
||||
continue
|
||||
}
|
||||
if !monsterIsSolid(current) || isMonsterChest(current) {
|
||||
continue
|
||||
}
|
||||
placeWall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3 tries two chest spots, three times each: an air cell with exactly
|
||||
// one opaque horizontal neighbour, faced away from it. An adjacent chest
|
||||
// leaves the new one at its default facing instead.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
for try := 0; try < 3; try++ {
|
||||
cx := ox + int(random.NextIntN(int32(j*2+1))) - j
|
||||
cz := oz + int(random.NextIntN(int32(o*2+1))) - o
|
||||
cy := oy
|
||||
if !monsterIsAir(r.getBlock(cx, cy, cz)) {
|
||||
continue
|
||||
}
|
||||
solids := 0
|
||||
var solidDir [2]int
|
||||
adjacentChest := false
|
||||
for _, d := range [4][2]int{{0, -1}, {1, 0}, {0, 1}, {-1, 0}} {
|
||||
neighbor := r.getBlock(cx+d[0], cy, cz+d[1])
|
||||
if isMonsterChest(neighbor) {
|
||||
adjacentChest = true
|
||||
continue
|
||||
}
|
||||
if monsterIsSolidRender(neighbor) {
|
||||
solids++
|
||||
solidDir = d
|
||||
}
|
||||
}
|
||||
if solids != 1 {
|
||||
continue
|
||||
}
|
||||
facing := "north"
|
||||
if !adjacentChest {
|
||||
switch {
|
||||
case solidDir[0] == 1:
|
||||
facing = "west"
|
||||
case solidDir[0] == -1:
|
||||
facing = "east"
|
||||
case solidDir[1] == 1:
|
||||
facing = "north"
|
||||
default:
|
||||
facing = "south"
|
||||
}
|
||||
}
|
||||
if r.monsterSafeSetBlock(cx, cy, cz, monsterChestIDs[facing]) {
|
||||
// Vanilla seeds the chest's loot table from one long here.
|
||||
random.NextLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4 places the spawner at the center; picking its mob is one draw.
|
||||
if r.monsterSafeSetBlock(ox, oy, oz, monsterSpawnerID) {
|
||||
random.NextIntN(4) // skeleton / zombie / zombie / spider
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isMonsterChest(state uint16) bool {
|
||||
return state == monsterChestIDs["north"] || state == monsterChestIDs["south"] ||
|
||||
state == monsterChestIDs["west"] || state == monsterChestIDs["east"]
|
||||
}
|
||||
|
||||
// monsterIsSolid approximates BlockState.isSolid, which in this version reads
|
||||
// the state's blocks-motion property.
|
||||
func monsterIsSolid(state uint16) bool {
|
||||
return stateFlags(state)&flagBlocksMotion != 0
|
||||
}
|
||||
|
||||
// monsterIsSolidRender approximates BlockState.isSolidRender: an occluding,
|
||||
// fully opaque cube.
|
||||
func monsterIsSolidRender(state uint16) bool {
|
||||
f := stateFlags(state)
|
||||
return f&flagCanOcclude != 0 && lightOpacity(state) == 15
|
||||
}
|
||||
|
||||
func monsterIsAir(state uint16) bool { return monsterAirIDs[state] }
|
||||
128
internal/world/monster_rooms_test.go
Normal file
128
internal/world/monster_rooms_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
// buildSolidRegion returns a one-chunk region filled with stone below y=20 so
|
||||
// room validation has a predictable shell to accept or reject.
|
||||
func buildSolidRegion(t *testing.T) (*decorationRegion, []*Chunk) {
|
||||
t.Helper()
|
||||
chunk := NewChunk(0, 0, BiomePlains)
|
||||
for y := MinY; y < 24; y++ {
|
||||
for z := 0; z < 16; z++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
chunk.SetBlock(x, y, z, StateStone)
|
||||
}
|
||||
}
|
||||
}
|
||||
region, err := newDecorationRegion([]*Chunk{chunk})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := region.setSource(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return region, []*Chunk{chunk}
|
||||
}
|
||||
|
||||
func TestMonsterRoomCarvesShell(t *testing.T) {
|
||||
initMonsterRoomTables()
|
||||
region, _ := buildSolidRegion(t)
|
||||
|
||||
// A fully sealed shell has no side openings at y=0, so vanilla rejects the
|
||||
// placement before any write.
|
||||
random := worldgen.NewWorldgenRandom(1)
|
||||
if placeMonsterRoom(region, random, 8, 10, 8) {
|
||||
t.Fatal("room placed in a sealed shell")
|
||||
}
|
||||
|
||||
// Open one column on the +x wall: two adjacent air cells at y=0 and y=1.
|
||||
region.setBlock(8 + 4, 10, 8, StateAir)
|
||||
region.setBlock(8 + 4, 11, 8, StateAir)
|
||||
if !placeMonsterRoom(region, random, 8, 10, 8) {
|
||||
t.Fatal("room rejected despite an open side column")
|
||||
}
|
||||
|
||||
// The spawner lands at the origin; neighbouring interior cells are air.
|
||||
if got := region.getBlock(8, 10, 8); got != monsterSpawnerID {
|
||||
t.Fatalf("origin state = %s, want the spawner", stateLabel(got))
|
||||
}
|
||||
if got := region.getBlock(6, 10, 6); !monsterIsAir(got) {
|
||||
t.Fatalf("interior state = %s, want air", stateLabel(got))
|
||||
}
|
||||
// The floor row is cobblestone or mossy cobblestone.
|
||||
floor := region.getBlock(7, 9, 8)
|
||||
if floor != monsterCobbleID && floor != monsterMossyID {
|
||||
t.Fatalf("floor state = %s, want cobblestone family", stateLabel(floor))
|
||||
}
|
||||
// The untouched shell above stays stone.
|
||||
if got := region.getBlock(8, 15, 8); got != StateStone {
|
||||
t.Fatalf("ceiling state = %s, want stone", stateLabel(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsterRoomPlacementIsDeterministic(t *testing.T) {
|
||||
initMonsterRoomTables()
|
||||
build := func() *Chunk {
|
||||
region, _ := buildSolidRegion(t)
|
||||
region.setBlock(12, 10, 8, StateAir)
|
||||
region.setBlock(12, 11, 8, StateAir)
|
||||
random := worldgen.NewWorldgenRandom(42)
|
||||
placeMonsterRoom(region, random, 8, 10, 8)
|
||||
return region.chunks[[2]int32{0, 0}]
|
||||
}
|
||||
a, b := build(), build()
|
||||
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||
for z := 0; z < 16; z++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
if a.GetBlock(x, y, z) != b.GetBlock(x, y, z) {
|
||||
t.Fatalf("nondeterministic block at (%d,%d,%d)", x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsterRoomReplayMatchesDirectPass(t *testing.T) {
|
||||
od, err := worldgen.LoadOverworldFinalDensity(12345)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fluidPicker := worldgen.OverworldFluidPicker(od.SeaLevel)
|
||||
veins := worldgen.NewOreVeinifier(od)
|
||||
carver, err := worldgen.NewCarver(od, 12345)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initCarverReplaceable(carver.ReplaceableBlocks())
|
||||
generate := func() *Chunk {
|
||||
var chunks []*Chunk
|
||||
for cx := int32(-2); cx <= 2; cx++ {
|
||||
for cz := int32(-2); cz <= 2; cz++ {
|
||||
chunks = append(chunks, generateVanillaWithoutDecoration(od, fluidPicker, veins, carver, 12345, cx, cz))
|
||||
}
|
||||
}
|
||||
region, err := newDecorationRegion(chunks)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := region.replayScheduledOres(12345, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return region.chunks[[2]int32{0, 0}]
|
||||
}
|
||||
a, b := generate(), generate()
|
||||
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||
for z := 0; z < 16; z++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
if a.GetBlock(x, y, z) != b.GetBlock(x, y, z) {
|
||||
t.Fatalf("region replay nondeterministic at (%d,%d,%d)", x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +33,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 = 21
|
||||
const generatorVersion = 22
|
||||
|
||||
// generatorVersionTag is the NBT key holding generatorVersion. It is namespaced
|
||||
// because it is ours, not part of the vanilla chunk format.
|
||||
|
|
|
|||
11
internal/worldgen/data/tag/features_cannot_replace.json
Normal file
11
internal/worldgen/data/tag/features_cannot_replace.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"values": [
|
||||
"minecraft:bedrock",
|
||||
"minecraft:spawner",
|
||||
"minecraft:chest",
|
||||
"minecraft:end_portal_frame",
|
||||
"minecraft:reinforced_deepslate",
|
||||
"minecraft:trial_spawner",
|
||||
"minecraft:vault"
|
||||
]
|
||||
}
|
||||
26
internal/worldgen/features_cannot_replace.go
Normal file
26
internal/worldgen/features_cannot_replace.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package worldgen
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// features_cannot_replace.json is #minecraft:features_cannot_replace, captured
|
||||
// verbatim from the 26.1.2 server jar. Features guard their writes through
|
||||
// Feature.safeSetBlock with this tag: an existing state in the tag stays, an
|
||||
// existing state outside it may be replaced.
|
||||
//
|
||||
//go:embed data/tag/features_cannot_replace.json
|
||||
var featuresCannotReplaceJSON []byte
|
||||
|
||||
// FeaturesCannotReplace returns the block names in
|
||||
// #minecraft:features_cannot_replace.
|
||||
func FeaturesCannotReplace() ([]string, error) {
|
||||
var doc struct {
|
||||
Values []string `json:"values"`
|
||||
}
|
||||
if err := json.Unmarshal(featuresCannotReplaceJSON, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc.Values, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue