world: port ruined portal generation point and expose pre-carve terrain

generateBaseTerrain now factors the noise+surface stage out of chunk
generation so structure placement can read pre-carve heights exactly
like vanilla's STRUCTURE_STARTS stage. RuinedPortalGenerationPoint
replays the ruined_portals set: the weighted pick across all seven
variants on its own stream, each attempt's setup/template/rotation/
mirror draws on a fresh GenerationContext stream (makeRandom reseeds
per attempt, confirmed from bytecode), findSuitableY with corner-column
settle scan against those heights, and the post-draw 3D-biome filter at
quart-snapped coordinates.

Known gap, documented in STRUCTURE_NOTES.md: no variant is accepted in
the window around the fixture's portal yet while vanilla placed one;
the biome/surface inputs need a vanilla-side probe to bisect. Not wired
into the production replay until that closes.
This commit is contained in:
Daniar Mannanov 2026-08-26 13:56:51 +03:00
parent 6e04bd8bbc
commit 798a3ec7b4
6 changed files with 454 additions and 31 deletions

View file

@ -0,0 +1,341 @@
package world
import (
"fmt"
"sync"
"regionio/internal/worldgen"
)
// ruined_portal.go ports RuinedPortalStructure.findGenerationPoint — the part
// of the structure that runs on the chunk's own Legacy stream and decides
// whether a portal starts here, which template it uses, and where it sits
// vertically. Piece block placement lands in a follow-up; this file already
// consumes exactly vanilla's draws so later structures on the same stream stay
// aligned.
var ruinedPortalTemplates = []string{
"ruined_portal/portal_1", "ruined_portal/portal_2", "ruined_portal/portal_3",
"ruined_portal/portal_4", "ruined_portal/portal_5", "ruined_portal/portal_6",
"ruined_portal/portal_7", "ruined_portal/portal_8", "ruined_portal/portal_9",
"ruined_portal/portal_10",
}
var ruinedPortalGiants = []string{
"ruined_portal/giant_portal_1", "ruined_portal/giant_portal_2",
"ruined_portal/giant_portal_3",
}
// RuinedPortalStub is everything findGenerationPoint decides.
type RuinedPortalStub struct {
X, Y, Z int
Template string
Rotation int // none, cw90, cw180, ccw90
Mirror string
AirPocket bool
Mossiness float32
Overgrown bool
Vines bool
Cold bool // resolved by the caller; needs the biome at the stub
Blackstone bool
}
func sampleProbability(random *worldgen.Legacy, p float32) bool {
if p == 0 {
return false
}
if p == 1 {
return true
}
return random.NextFloat() < p
}
func randomBetweenInclusiveLegacy(random *worldgen.Legacy, lo, hi int) int {
return lo + int(random.NextIntN(int32(hi-lo+1)))
}
func getRandomWithinInterval(random *worldgen.Legacy, a, b int) int {
if a < b {
return randomBetweenInclusiveLegacy(random, a, b)
}
return b
}
var (
preCarveMu sync.Mutex
preCarveCache = map[[2]int32]baseTerrain{}
)
// preCarveTerrainFor returns the noise+surface terrain for one chunk with
// carving deliberately skipped: structure placement runs before carvers in
// vanilla, and its height queries must not see carved holes. Results are
// cached because several placement queries land in the same chunks.
func preCarveTerrainFor(od *worldgen.OverworldDensity, seed int64, cx, cz int32) (baseTerrain, error) {
key := [2]int32{cx, cz}
preCarveMu.Lock()
defer preCarveMu.Unlock()
if base, ok := preCarveCache[key]; ok {
return base, nil
}
_, fluidPicker, veins, _ := vanillaGeneratorInputs(seed)
base := generateBaseTerrain(od, fluidPicker, veins, nil, seed, cx, cz)
preCarveCache[key] = base
return base, nil
}
var (
templateCacheMu sync.Mutex
templateCache = map[string][]worldgen.TemplateBlockInfo{}
templateSizes = map[string][3]int{}
)
func loadTemplateCached(name string) ([]worldgen.TemplateBlockInfo, [3]int, error) {
templateCacheMu.Lock()
defer templateCacheMu.Unlock()
if blocks, ok := templateCache[name]; ok {
return blocks, templateSizes[name], nil
}
raw, err := worldgen.EmbeddedStructureTemplate(name)
if err != nil {
return nil, [3]int{}, err
}
blocks, size, err := worldgen.LoadResolvedTemplateBytes(raw,
func(stateName string, props map[string]string) (uint16, bool) {
return nameToStateID(stateName, props)
})
if err != nil {
return nil, [3]int{}, err
}
templateCache[name] = blocks
templateSizes[name] = size
return blocks, size, nil
}
// boundingBoxOf transforms the eight corners of the template cuboid and takes
// their min/max, matching StructureTemplate.getBoundingBox over all blocks.
func boundingBoxOf(size [3]int, mirror string, rotation int, pivot [3]int, originX, originZ int) (minX, minY, minZ, maxX, maxY, maxZ int) {
minX, minY, minZ = int(^uint(0)>>1), int(^uint(0)>>1), int(^uint(0)>>1)
maxX, maxY, maxZ = -minX, -minY, -minZ
for _, cx := range [3]int{0, size[0] - 1} {
for _, cy := range [3]int{0, size[1] - 1} {
for _, cz := range [3]int{0, size[2] - 1} {
p := worldgen.TransformBlockPos([3]int{cx, cy, cz}, mirror, rotation, pivot)
x, y, z := originX+p[0], p[1], originZ+p[2]
minX, maxX = min(minX, x), max(maxX, x)
minY, maxY = min(minY, y), max(maxY, y)
minZ, maxZ = min(minZ, z), max(maxZ, z)
}
}
}
return
}
// RuinedPortalGenerationPoint replays the ruined_portals set for one source
// chunk: the weighted pick across all seven portal variants, each variant's
// own findGenerationPoint draws, and the post-draw 3D-biome filter. It returns
// nil when vanilla would have ended up with no valid start here.
func RuinedPortalGenerationPoint(od *worldgen.OverworldDensity, sets *worldgen.StructureSets, seed int64, sx, sz int32) (*RuinedPortalStub, error) {
set := sets.Sets["minecraft:ruined_portals"]
if set == nil || !set.IsStartChunk(seed, sx, sz) {
return nil, nil
}
random := worldgen.NewLegacy(0)
random.SetLargeFeatureSeed(seed, int(sx), int(sz))
indices := make([]int, len(set.Structures))
for i := range indices {
indices[i] = i
}
for len(indices) > 0 {
total := 0
for _, i := range indices {
total += set.Structures[i].Weight
}
pick := int(random.NextIntN(int32(total)))
selected := 0
for offset, i := range indices {
pick -= set.Structures[i].Weight
if pick < 0 {
selected = offset
break
}
}
entry := indices[selected]
def := sets.Structures[set.Structures[entry].Structure]
if def == nil || len(def.Setups) == 0 {
return nil, fmt.Errorf("portal variant %s missing setups", set.Structures[entry].Structure)
}
// GenerationContext.random() is lazy: every attempt reseeds a fresh
// Legacy stream from the same formula instead of continuing the
// previous attempt's stream.
variantRandom := worldgen.NewLegacy(0)
variantRandom.SetLargeFeatureSeed(seed, int(sx), int(sz))
stub := ruinedPortalVariantPoint(od, sets, seed, sx, sz, def, variantRandom)
if stub != nil {
return stub, nil
}
// Vanilla removes the failed entry and redraws from the pick stream.
indices = append(indices[:selected], indices[selected+1:]...)
}
return nil, nil
}
func ruinedPortalVariantPoint(od *worldgen.OverworldDensity, sets *worldgen.StructureSets, seed int64, sx, sz int32, def *worldgen.StructureDef, random *worldgen.Legacy) *RuinedPortalStub {
// Setup selection: skipped entirely for single-setup lists.
setup := def.Setups[0]
if len(def.Setups) > 1 {
total := float32(0)
for _, s := range def.Setups {
total += s.Weight
}
f := random.NextFloat()
for _, s := range def.Setups {
f -= s.Weight / total
if f < 0 {
setup = s
break
}
}
}
airPocket := sampleProbability(random, setup.AirPocketProbability)
templateName := ruinedPortalTemplates[int(random.NextIntN(int32(len(ruinedPortalTemplates))))]
if random.NextFloat() < 0.05 {
templateName = ruinedPortalGiants[int(random.NextIntN(int32(len(ruinedPortalGiants))))]
}
_, size, err := loadTemplateCached(templateName)
if err != nil {
return nil
}
rotation := int(random.NextIntN(4))
mirror := "none"
if random.NextFloat() >= 0.5 {
mirror = "front_back"
}
pivot := [3]int{size[0] / 2, 0, size[2] / 2}
originX, originZ := int(sx)*16, int(sz)*16
minX, minY, minZ, maxX, maxY, maxZ := boundingBoxOf(size, mirror, rotation, pivot, originX, originZ)
centerX := minX + (maxX-minX+1)/2
centerZ := minZ + (maxZ-minZ+1)/2
base, err := preCarveTerrainFor(od, seed, int32(centerX>>4), int32(centerZ>>4))
if err != nil {
return nil
}
heightmapY := func(x, z int, oceanFloor bool) int {
lx, lz := x-int(base.c.X)*16, z-int(base.c.Z)*16
for i := WorldHeight - 1; i >= 0; i-- {
s := base.columns[lx][lz][i]
y := MinY + i
if !oceanFloor {
if s != StateAir {
return y
}
continue
}
// OCEAN_FLOOR_WG scans past fluids to the first motion-blocking.
if s != StateAir && !isWaterState(s) && stateFlags(s)&flagBlocksMotion != 0 {
return y
}
}
return MinY - 1
}
oceanFloor := setup.Placement == "on_ocean_floor"
// getHeight returns one past the top block and vanilla subtracts one, so
// surfaceY lands exactly on the topmost non-air block.
surfaceY := heightmapY(centerX, centerZ, oceanFloor)
ySpan := maxY - minY + 1
y := ruinedPortalFindSuitableY(random, setup.Placement, airPocket, surfaceY, ySpan,
minX, minZ, maxX, maxZ, base)
stub := &RuinedPortalStub{
X: originX, Z: originZ,
Template: templateName, Rotation: rotation, Mirror: mirror,
AirPocket: airPocket, Mossiness: setup.Mossiness,
Overgrown: setup.Overgrown, Vines: setup.Vines,
Blackstone: setup.ReplaceWithBlackstone,
Y: y,
}
// findValidGenerationPoint filters by the 3D noise biome at the stub
// position AFTER every draw has happened. The climate sampler reads
// quarter coordinates, so the position snaps down to the 4-block lattice
// exactly like QuartPos.fromBlock.
qx, qy, qz := (stub.X>>2)<<2, (y>>2)<<2, (stub.Z>>2)<<2
s2D := worldgen.SampleColumn2D(od, SeaLevel, qx, qz)
biomeAtStub := biomeNameByID(BiomeAt3D(od, s2D, qx, qy, qz))
allowed := false
for _, name := range sets.BiomesFor(def) {
if name == biomeAtStub {
allowed = true
break
}
}
if !allowed {
return nil
}
if setup.CanBeCold {
stub.Cold = worldgen.ColdEnoughToSnow(biomeAtStub)
}
return stub
}
func ruinedPortalFindSuitableY(random *worldgen.Legacy, placement string, airPocket bool, surfaceY, ySpan, minX, minZ, maxX, maxZ int, base baseTerrain) int {
minCut := MinY + 15
var y int
switch placement {
case "in_nether":
if airPocket {
y = randomBetweenInclusiveLegacy(random, 32, 100)
} else if random.NextFloat() < 0.5 {
y = randomBetweenInclusiveLegacy(random, 27, 29)
} else {
y = randomBetweenInclusiveLegacy(random, 29, 100)
}
case "in_mountain":
y = getRandomWithinInterval(random, 70, surfaceY-ySpan)
case "underground":
y = getRandomWithinInterval(random, minCut, surfaceY-ySpan)
case "partly_buried":
y = surfaceY + randomBetweenInclusiveLegacy(random, 2, 8)
default:
y = surfaceY
}
opaqueAt := func(x, z, yy int) bool {
lx, lz := x-int(base.c.X)*16, z-int(base.c.Z)*16
if lx < 0 || lx > 15 || lz < 0 || lz > 15 || yy < MinY || yy >= MinY+WorldHeight {
return false
}
state := base.columns[lx][lz][yy-MinY]
if placement == "on_ocean_floor" {
return state != StateAir && !isWaterState(state) && stateFlags(state)&flagBlocksMotion != 0
}
return state != StateAir
}
for yy := y; yy > minCut; yy-- {
opaque := 0
done := false
for _, corner := range [4][2]int{{minX, minZ}, {maxX, minZ}, {minX, maxZ}, {maxX, maxZ}} {
if opaqueAt(corner[0], corner[1], yy) {
opaque++
if opaque == 3 {
done = true
break
}
}
}
if done {
return yy
}
}
return y
}