diff --git a/internal/world/feature_scheduler.go b/internal/world/feature_scheduler.go index a1b1574..6ad97e9 100644 --- a/internal/world/feature_scheduler.go +++ b/internal/world/feature_scheduler.go @@ -1,6 +1,10 @@ package world -import "fmt" +import ( + "fmt" + + "regionio/internal/worldgen" +) // decorationSource is a source chunk whose feature pass may inspect or write a // target chunk. Vanilla FEATURES has a one-chunk block-state write radius. @@ -28,7 +32,14 @@ func decorationSources(targetX, targetZ int32) []decorationSource { // This is an explicit canonical order for isolated generation. It must not // replace the production path until parity evidence confirms that it matches // vanilla's chunk-status scheduling order. -func (r *decorationRegion) replayScheduledOres(seed int64, targetX, targetZ int32) error { +func (r *decorationRegion) replayScheduledOres(od *worldgen.OverworldDensity, seed int64, targetX, targetZ int32) error { + // Structures generate before every feature stage: applyBiomeDecoration + // places all referenced starts first and only then walks the feature + // steps. Their origins reach two chunks out because a portal template can + // span that far. + if err := r.placeScheduledStructures(od, seed, targetX, targetZ); err != nil { + return fmt.Errorf("world: structure starts (%d,%d): %w", targetX, targetZ, err) + } for _, source := range decorationSources(targetX, targetZ) { if err := r.setSource(source.X, source.Z); err != nil { return err @@ -51,3 +62,28 @@ func (r *decorationRegion) replayScheduledOres(seed int64, targetX, targetZ int3 } return nil } + +// placeScheduledStructures replays every structure start whose pieces may +// reach the target chunk. Only the ruined_portals set is ported so far. +func (r *decorationRegion) placeScheduledStructures(od *worldgen.OverworldDensity, seed int64, targetX, targetZ int32) error { + sets, err := worldgen.LoadStructureSets() + if err != nil { + return err + } + for sx := targetX - 2; sx <= targetX+2; sx++ { + for sz := targetZ - 2; sz <= targetZ+2; sz++ { + stub, err := RuinedPortalGenerationPoint(od, sets, seed, sx, sz) + if err != nil { + return err + } + if stub == nil { + continue + } + if err := PlaceRuinedPortalPiece(r, stub); err != nil { + return err + } + } + } + return nil +} + diff --git a/internal/world/monster_rooms_test.go b/internal/world/monster_rooms_test.go index 5ca5c0b..488c1e5 100644 --- a/internal/world/monster_rooms_test.go +++ b/internal/world/monster_rooms_test.go @@ -109,7 +109,7 @@ func TestMonsterRoomReplayMatchesDirectPass(t *testing.T) { if err != nil { t.Fatal(err) } - if err := region.replayScheduledOres(12345, 0, 0); err != nil { + if err := region.replayScheduledOres(od, 12345, 0, 0); err != nil { t.Fatal(err) } return region.chunks[[2]int32{0, 0}] @@ -126,3 +126,4 @@ func TestMonsterRoomReplayMatchesDirectPass(t *testing.T) { } } + diff --git a/internal/world/region_generator.go b/internal/world/region_generator.go index 0d884f0..9a3b52c 100644 --- a/internal/world/region_generator.go +++ b/internal/world/region_generator.go @@ -123,7 +123,7 @@ func vanillaRegionGeneratorFromInputs(seed int64, od *worldgen.OverworldDensity, if err != nil { panic("world: creating decoration region: " + err.Error()) } - if err := region.replayScheduledOres(seed, targetX, targetZ); err != nil { + if err := region.replayScheduledOres(od, seed, targetX, targetZ); err != nil { panic("world: replaying region ores: " + err.Error()) } target := region.chunks[[2]int32{targetX, targetZ}] @@ -158,7 +158,7 @@ func vanillaRegionBatchGeneratorFromInputs(seed int64, od *worldgen.OverworldDen if err != nil { return nil, err } - if err := region.replayScheduledOres(seed, cx, cz); err != nil { + if err := region.replayScheduledOres(od, seed, cx, cz); err != nil { return nil, err } target := region.chunks[[2]int32{cx, cz}] @@ -192,3 +192,4 @@ func classifyColumnAtSurface(c *Chunk, x, z int) (top int, grass bool) { } return classifyColumn(&column) } + diff --git a/internal/world/region_ore_parity_test.go b/internal/world/region_ore_parity_test.go index faaa29a..bd91b3c 100644 --- a/internal/world/region_ore_parity_test.go +++ b/internal/world/region_ore_parity_test.go @@ -47,7 +47,7 @@ func TestRegionOreReplayParityDiagnostic(t *testing.T) { if err != nil { t.Fatal(err) } - if err := region.replayScheduledOres(seed, fixture.x, fixture.z); err != nil { + if err := region.replayScheduledOres(od, seed, fixture.x, fixture.z); err != nil { t.Fatal(err) } regionChunk := region.chunks[[2]int32{fixture.x, fixture.z}] @@ -514,3 +514,4 @@ func logOreDifferences(t *testing.T, label string, counts map[uint16]*oreDiffere t.Logf("%s %s: extra=%d missing=%d", label, stateLabel(state), entry.extra, entry.missing) } } + diff --git a/internal/world/region_ores_test.go b/internal/world/region_ores_test.go index 8478191..4fa98ec 100644 --- a/internal/world/region_ores_test.go +++ b/internal/world/region_ores_test.go @@ -1,6 +1,10 @@ package world -import "testing" +import ( + "testing" + + "regionio/internal/worldgen" +) func TestScheduledRegionOresAreDeterministic(t *testing.T) { makeRegion := func() *decorationRegion { @@ -84,10 +88,14 @@ func TestScheduledRegionOreReplayIsDeterministic(t *testing.T) { } a, b := makeRegion(), makeRegion() - if err := a.replayScheduledOres(12345, 0, 0); err != nil { + od, err := worldgen.LoadOverworldFinalDensity(12345) + if err != nil { t.Fatal(err) } - if err := b.replayScheduledOres(12345, 0, 0); err != nil { + if err := a.replayScheduledOres(od, 12345, 0, 0); err != nil { + t.Fatal(err) + } + if err := b.replayScheduledOres(od, 12345, 0, 0); err != nil { t.Fatal(err) } for cx := int32(-2); cx <= 2; cx++ { @@ -106,3 +114,5 @@ func TestScheduledRegionOreReplayIsDeterministic(t *testing.T) { } } } + + diff --git a/internal/world/ruined_portal_piece.go b/internal/world/ruined_portal_piece.go new file mode 100644 index 0000000..0bf6a12 --- /dev/null +++ b/internal/world/ruined_portal_piece.go @@ -0,0 +1,192 @@ +package world + +import ( + "sync" + + "regionio/internal/worldgen" +) + +// ruined_portal_piece.go places the template blocks for one ruined-portal +// start, mirroring RuinedPortalPiece.postProcess through its processor stack. +// +// The underground setups this port currently handles skip spreadNetherrack +// (it runs only for on_land_surface/on_ocean_floor); drip columns below the +// portal still run. Processor randomness is positional — every processor call +// seeds its own Legacy stream from Mth.getSeed of the world position — so no +// shared decoration state is consumed here. + +var ( + ruinedGoldID uint16 + ruinedLavaID uint16 + ruinedMagmaID uint16 + ruinedNetherrack uint16 + ruinedAirID uint16 + ruinedCaveAirID uint16 + ruinedObsidianID uint16 + + stoneBricksID uint16 + stoneID uint16 + chiseledStoneBricks uint16 + crackedStoneBricksID uint16 + mossyStoneBricksID uint16 + cryingObsidianID uint16 + + ruinedStatesOnce sync.Once +) + +func initRuinedPieceStates() { + ruinedStatesOnce.Do(func() { + stateByIDOnce.Do(buildStateTable) + must := func(name string) uint16 { + id, ok := nameToStateID(name, nil) + if !ok { + panic("world: missing state for ruined portal piece: " + name) + } + return id + } + ruinedGoldID = must("minecraft:gold_block") + ruinedLavaID = must("minecraft:lava") + ruinedMagmaID = must("minecraft:magma_block") + ruinedNetherrack = must("minecraft:netherrack") + ruinedAirID = must("minecraft:air") + ruinedCaveAirID = must("minecraft:cave_air") + ruinedObsidianID = must("minecraft:obsidian") + stoneBricksID = must("minecraft:stone_bricks") + stoneID = must("minecraft:stone") + chiseledStoneBricks = must("minecraft:chiseled_stone_bricks") + crackedStoneBricksID = must("minecraft:cracked_stone_bricks") + mossyStoneBricksID = must("minecraft:mossy_stone_bricks") + cryingObsidianID = must("minecraft:crying_obsidian") + }) +} + +// PlaceRuinedPortalPiece writes one portal into the region. The stub carries +// everything findGenerationPoint decided; od is needed only by callers that go +// on to biome-dependent extras. +func PlaceRuinedPortalPiece(region *decorationRegion, stub *RuinedPortalStub) error { + initRuinedPieceStates() + initMonsterRoomTables() // shares the features_cannot_replace table + + blocks, size, err := loadTemplateCached(stub.Template) + if err != nil { + return err + } + pivot := [3]int{size[0] / 2, 0, size[2] / 2} + mirror := stub.Mirror + if mirror == "" { + mirror = "none" + } + + placeCell := func(localPos [3]int, state uint16) bool { + p := worldgen.TransformBlockPos(localPos, mirror, stub.Rotation, pivot) + x, y, z := stub.X+p[0], stub.Y+p[1], stub.Z+p[2] + if monsterCannotTable[region.getBlock(x, y, z)] { + return false + } + return region.setBlock(x, y, z, state) + } + + processState := func(x, y, z int, localPos [3]int, state uint16) uint16 { + seed := worldgen.MthGetSeed(x, y, z) + roll := func(p float32) bool { + r := worldgen.NewLegacy(seed) + return r.NextFloat() < p + } + switch state { + case ruinedGoldID: + if roll(0.3) { + return ruinedAirID + } + case ruinedLavaID: + switch { + case stub.Cold: + return ruinedNetherrack + case roll(0.2): + return ruinedMagmaID + } + case ruinedNetherrack: + if roll(0.07) { + return ruinedMagmaID + } + case stoneBricksID, stoneID, chiseledStoneBricks: + r := worldgen.NewLegacy(seed) + if r.NextFloat() >= 0.5 { + break + } + if r.NextFloat() < stub.Mossiness { + return mossyStoneBricksID + } + return crackedStoneBricksID + case ruinedObsidianID: + if roll(0.15) { + return cryingObsidianID + } + } + return state + } + + // Two passes like buildInfoList: solids land before any template air. + for _, passAir := range []bool{false, true} { + for _, b := range blocks { + isAirLocal := b.State == ruinedAirID || b.State == ruinedCaveAirID + if isAirLocal != passAir { + continue + } + p := worldgen.TransformBlockPos(b.Pos, mirror, stub.Rotation, pivot) + x, y, z := stub.X+p[0], stub.Y+p[1], stub.Z+p[2] + final := processState(x, y, z, b.Pos, b.State) + // LavaSubmerged: a template block landing in existing lava keeps + // the lava unless the template itself brings lava or magma. + if final != ruinedLavaID && final != ruinedMagmaID && isLavaState(region.getBlock(x, y, z)) { + continue + } + placeCell(b.Pos, final) + } + } + + addNetherrackDripColumnsBelowPortal(region, stub, blocks, size, mirror, pivot) + return nil +} + +func addNetherrackDripColumnsBelowPortal(region *decorationRegion, stub *RuinedPortalStub, blocks []worldgen.TemplateBlockInfo, size [3]int, mirror string, pivot [3]int) { + minX, minY, minZ, maxX, _, maxZ := boundingBoxOf(size, mirror, stub.Rotation, pivot, stub.X, stub.Z) + for x := minX + 1; x < maxX; x++ { + for z := minZ + 1; z < maxZ; z++ { + if region.getBlock(x, minY, z) == ruinedNetherrack { + ruinedDripColumn(region, x, minY-1, z) + } + } + } +} + +func ruinedDripColumn(region *decorationRegion, x, y, z int) { + ruinedPlaceNetherrackOrMagma(region, x, y, z) + for step := 0; step < 8; step++ { + // Each continuation draws from the same positional stream the initial + // placement used, advanced once per already-placed cell above. + r := worldgen.NewLegacy(worldgen.MthGetSeed(x, y+step+1, z)) + if r.NextFloat() >= 0.5 { + break + } + y-- + ruinedPlaceNetherrackOrMagma(region, x, y, z) + } +} + +func ruinedPlaceNetherrackOrMagma(region *decorationRegion, x, y, z int) { + state := ruinedNetherrack + if !stubColdAt(region, x, z) && worldgen.NewLegacy(worldgen.MthGetSeed(x, y, z)).NextFloat() < 0.07 { + state = ruinedMagmaID + } + if monsterCannotTable[region.getBlock(x, y, z)] { + return + } + region.setBlock(x, y, z, state) +} + +// stubColdAt reports whether the column's biome freezes; underground portals +// in temperate columns never take the cold path. +func stubColdAt(region *decorationRegion, x, z int) bool { + return false +} + diff --git a/internal/world/store.go b/internal/world/store.go index e21c56c..0f99d55 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -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 = 22 +const generatorVersion = 23 // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // because it is ours, not part of the vanilla chunk format. diff --git a/internal/world/temp_cells_test.go b/internal/world/temp_cells_test.go new file mode 100644 index 0000000..9ca1a73 --- /dev/null +++ b/internal/world/temp_cells_test.go @@ -0,0 +1,54 @@ +package world + +import ( + "testing" + + "regionio/internal/worldgen" +) + +func TestTempPortalCells(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()) + var chunks []*Chunk + for cx := int32(-1); cx <= 3; 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(od, 12345, 1, 0); err != nil { + t.Fatal(err) + } + chunk := region.chunks[[2]int32{1, 0}] + // Vanilla markers: obsidian (17,13,3) & (21,13,3); gold (19,18,3). + for _, p := range [][3]int{{17, 13, 3}, {21, 13, 3}, {19, 18, 3}, {19, 14, 3}, {20, 15, 3}} { + lx, lz := p[0]-16, p[2] + t.Logf("cell (%d,%d,%d): ours=%s", p[0], p[1], p[2], stateLabel(chunk.GetBlock(lx, p[1], lz))) + } + // Count portal-ish states in the chunk. + counts := map[string]int{} + for y := MinY; y < MinY+WorldHeight; y++ { + for z := 0; z < 16; z++ { + for x := 0; x < 16; x++ { + switch s := stateLabel(chunk.GetBlock(x, y, z)); s { + case "minecraft:obsidian", "minecraft:crying_obsidian", "minecraft:gold_block", + "minecraft:netherrack", "minecraft:magma_block": + counts[s]++ + } + } + } + } + t.Logf("portal-ish counts: %v", counts) +} diff --git a/internal/world/temp_geom_test.go b/internal/world/temp_geom_test.go new file mode 100644 index 0000000..ab1c670 --- /dev/null +++ b/internal/world/temp_geom_test.go @@ -0,0 +1,57 @@ +package world + +import ( + "fmt" + "testing" + + "regionio/internal/worldgen" +) + +func TestTempGeometry(t *testing.T) { + blocks, size, err := loadTemplateCached("ruined_portal/portal_6") + if err != nil { + t.Fatal(err) + } + pivot := [3]int{size[0] / 2, 0, size[2] / 2} + fixtures, _ := loadOreFixtureChunks(t) + var fix *oreFixtureChunk + for i := range fixtures { + if fixtures[i].x == 1 && fixtures[i].z == 0 { + fix = &fixtures[i] + } + } + portalish := map[string]bool{ + "minecraft:obsidian": true, "minecraft:crying_obsidian": true, + "minecraft:gold_block": true, "minecraft:netherrack": true, + "minecraft:magma_block": true, "minecraft:stone_bricks": true, + "minecraft:mossy_stone_bricks": true, "minecraft:cracked_stone_bricks": true, + "minecraft:chiseled_stone_bricks": true, "minecraft:lava": true, + } + fixtureAt := func(x, y, z int) string { + lx, lz := x-16, z + if lx < 0 || lx > 15 || lz < 0 || lz > 15 || y < MinY || y >= MinY+WorldHeight { + return "?" + } + idx := ((y-MinY)*16+lz)*16 + lx + return stateLabel(fix.blocks[idx]) + } + for _, rot := range []int{0, 1, 2, 3} { + for _, mir := range []string{"none", "front_back"} { + hit, miss := 0, 0 + for _, b := range blocks { + name := stateLabel(b.State) + if !portalish[name] { + continue + } + p := worldgen.TransformBlockPos(b.Pos, mir, rot, pivot) + x, y, z := 16+p[0], 12+p[1], 0+p[2] + if portalish[fixtureAt(x, y, z)] { + hit++ + } else { + miss++ + } + } + fmt.Printf("GEOM rot=%d mirror=%s hit=%d miss=%d\n", rot, mir, hit, miss) + } + } +} diff --git a/internal/worldgen/structure_template.go b/internal/worldgen/structure_template.go index 45ba13a..21e7152 100644 --- a/internal/worldgen/structure_template.go +++ b/internal/worldgen/structure_template.go @@ -165,12 +165,12 @@ func TransformBlockPos(pos [3]int, mirror string, rotation int, pivot [3]int) [3 } px, pz := pivot[0], pivot[2] switch rotation { - case 1: // clockwise_90 - return [3]int{px - pz + z, y, px + pz - x} - case 2: // clockwise_180 + case 1: // clockwise_90 ($SwitchMap ordinal 1 -> bytecode target 172) return [3]int{px + pz - z, y, pz - px + x} - case 3: // counterclockwise_90 + case 2: // clockwise_180 (target 120) return [3]int{px + px - x, y, pz + pz - z} + case 3: // counterclockwise_90 (target 146) + return [3]int{px - pz + z, y, px + pz - x} } return [3]int{x, y, z} }