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
}

View file

@ -112,6 +112,28 @@ func generateVanillaWithoutDecoration(od *worldgen.OverworldDensity, fluidPicker
}
func generateVanillaDecorated(od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPicker, veins *worldgen.OreVeinifier, carver *worldgen.Carver, seed int64, cx, cz int32, withDecoration bool) *Chunk {
data := generateBaseTerrain(od, fluidPicker, veins, carver, seed, cx, cz)
c := data.c
surfTop, grass := data.surfTop, data.grass
if withDecoration {
decorate(c, od, cx, cz, seed, surfTop, grass, data.biomeName)
}
return c
}
// baseTerrain is everything the noise/surface/carve stages produce, kept
// together so structure placement can read pre-carve heights exactly the way
// vanilla's STRUCTURE_STARTS stage does.
type baseTerrain struct {
c *Chunk
columns *[16][16][WorldHeight]uint16
worldSurface *[16][16]int // topmost non-air Y before carving
surfTop *[16][16]int
grass *[16][16]bool
biomeName *[16][16]string
}
func generateBaseTerrain(od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPicker, veins *worldgen.OreVeinifier, carver *worldgen.Carver, seed int64, cx, cz int32) baseTerrain {
c := NewChunk(cx, cz, BiomePlains) // per-cell biomes override below
baseX, baseZ := int(cx)*16, int(cz)*16
@ -240,10 +262,11 @@ func generateVanillaDecorated(od *worldgen.OverworldDensity, fluidPicker worldge
}
}
fillBiomes3D(c, od, s2D, baseX, baseZ)
if withDecoration {
decorate(c, od, cx, cz, seed, &surfTop, &grass, &biomeName)
return baseTerrain{
c: c, columns: &columns,
worldSurface: &worldSurface, surfTop: &surfTop,
grass: &grass, biomeName: &biomeName,
}
return c
}
// fillBiomes3D assigns a per-cell 4×4×4 biome to every section of the chunk.

View file

@ -55,19 +55,31 @@ Per set per chunk, in order:
2. placement.isStructureChunk(state, x, z) must hold (the grid above).
3. Single-entry sets go straight to tryGenerateStructure with **zero draws**.
4. Multi-entry sets: `random = WorldgenRandom(Legacy(0));
random.setLargeFeatureSeed(levelSeed, chunkX, chunkZ)` (the two-long XOR
mix), then loop:
random.setLargeFeatureSeed(levelSeed, chunkX, chunkPos.z)` (the two-long
XOR mix) drives ONLY the weighted picks; then loop:
- pick = random.nextInt(totalWeight over remaining entries)
- walk entries subtracting weight; first negative wins;
- tryGenerateStructure it; on success stop, else REMOVE the entry,
total -= its weight, and repeat **without reseeding** (the stream keeps
running).
5. tryGenerateStructure -> Structure.generate builds its own
`WorldgenRandom(LegacyRandomSource(0))` seeded with
setLargeFeatureSeed(seed, chunkX, chunkZ) — that is the `context.random()`
every findGenerationPoint draw reads from. Biome check happens inside
generate via the structure's biomes HolderSet against the biome at the
generation point (position-dependent, after the stub is found).
total -= its weight, and repeat **without reseeding** the pick stream.
5. tryGenerateStructure -> Structure.generate builds a GenerationContext whose
random comes from `GenerationContext.makeRandom(seed, chunkPos)`:
`new WorldgenRandom(new LegacyRandomSource(0))` +
`setLargeFeatureSeed(seed, chunkX, chunkZ)`. Every attempt therefore starts
from an identically seeded FRESH stream — attempts do NOT continue each
other's streams.
6. Structure.findValidGenerationPoint runs findGenerationPoint first and filters
by isValidBiome AFTERWARDS: the 3D noise biome at the stub position
(quart-snapped coordinates) against the structure's biome set.
Port status: the whole chain above plus findSuitableY live in
world/ruined_portal.go, but nothing accepts in the ±3-chunk window around the
fixture's portal yet — every variant lands on ocean or lush-caves biomes at
its stub while vanilla accepted one here. Open leads, in order of suspicion:
(a) our 3D biome sampling off the fixture's 4x4x4 lattice may diverge from
vanilla's Climate sampler at arbitrary quart positions; (b) getBaseHeight
semantics on water columns; (c) the settle-scan corner sampling. A Java probe
dumping vanilla's own stub for seed 12345 chunk (1,0) would settle it
decisively.
### findGenerationPoint, in draw order

View file

@ -97,3 +97,7 @@ func coldEnoughToSnow(biome string) bool {
temperature, ok := biomeTemperature[biome]
return ok && temperature < 0.15
}
// ColdEnoughToSnow exposes the per-biome snow threshold to structure ports.
func ColdEnoughToSnow(biome string) bool { return coldEnoughToSnow(biome) }

View file

@ -24,10 +24,28 @@ type TemplateBlockInfo struct {
HasNBT bool
}
// ResolvedTemplate is a parsed .nbt structure with every palette entry mapped
// to concrete state IDs through resolve.
// LoadResolvedTemplate reads a template file from disk.
func LoadResolvedTemplate(path string, resolve func(name string, props map[string]string) (uint16, bool)) ([]TemplateBlockInfo, [3]int, error) {
raw, err := readMaybeGzip(path)
raw, err := os.ReadFile(path)
if err != nil {
return nil, [3]int{}, err
}
raw, err = readMaybeGzipBytes(raw)
if err != nil {
return nil, [3]int{}, err
}
return LoadResolvedTemplateBytes(raw, resolve)
}
// EmbeddedStructureTemplate reads a template from the embedded datapack data
// by its resource-path name, e.g. "ruined_portal/portal_1".
func EmbeddedStructureTemplate(name string) ([]byte, error) {
return dataFS.ReadFile("data/structure_template/" + name + ".nbt")
}
// LoadResolvedTemplateBytes parses template NBT already in memory.
func LoadResolvedTemplateBytes(raw []byte, resolve func(name string, props map[string]string) (uint16, bool)) ([]TemplateBlockInfo, [3]int, error) {
raw, err := readMaybeGzipBytes(raw)
if err != nil {
return nil, [3]int{}, err
}
@ -121,11 +139,7 @@ func LoadResolvedTemplate(path string, resolve func(name string, props map[strin
return blocks, size, nil
}
func readMaybeGzip(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
func readMaybeGzipBytes(b []byte) ([]byte, error) {
if len(b) >= 2 && b[0] == 0x1f && b[1] == 0x8b {
gr, err := gzip.NewReader(bytes.NewReader(b))
if err != nil {
@ -174,3 +188,4 @@ func MthGetSeed(x, y, z int) int64 {
}

View file

@ -86,12 +86,42 @@ func (p *rawPlacement) decode() (*Placement, error) {
}
}
// RuinedPortalSetup is one weighted entry of a ruined portal structure's
// "setups" list.
type RuinedPortalSetup struct {
Placement string `json:"placement"`
AirPocketProbability float32 `json:"air_pocket_probability"`
Mossiness float32 `json:"mossiness"`
Overgrown bool `json:"overgrown"`
Vines bool `json:"vines"`
ReplaceWithBlackstone bool `json:"replace_with_blackstone"`
CanBeCold bool `json:"can_be_cold"`
Weight float32 `json:"weight"`
}
// StructureDef is one entry of data/minecraft/worldgen/structure.
type StructureDef struct {
Name string `json:"-"`
Type string `json:"type"`
Biomes string `json:"biomes"`
Step string `json:"step"`
Name string `json:"-"`
Type string `json:"type"`
Biomes string `json:"biomes"`
Step string `json:"step"`
Setups []RuinedPortalSetup `json:"-"`
}
func decodeStructureDef(name string, raw []byte) (*StructureDef, error) {
var doc struct {
Type string `json:"type"`
Biomes string `json:"biomes"`
Step string `json:"step"`
Setups []RuinedPortalSetup `json:"setups"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
return &StructureDef{
Name: name, Type: doc.Type, Biomes: strings.TrimPrefix(doc.Biomes, "minecraft:"),
Step: doc.Step, Setups: doc.Setups,
}, nil
}
// StructureSet is one entry of data/minecraft/worldgen/structure_set.
@ -183,14 +213,12 @@ func loadStructureSets() (*StructureSets, error) {
if err != nil {
return nil, err
}
var def StructureDef
if err := json.Unmarshal(raw, &def); err != nil {
name := "minecraft:" + strings.TrimSuffix(baseName(path), ".json")
def, err := decodeStructureDef(name, raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
name := "minecraft:" + strings.TrimSuffix(baseName(path), ".json")
def.Name = name
def.Biomes = strings.TrimPrefix(def.Biomes, "minecraft:")
out.Structures[name] = &def
out.Structures[name] = def
}
setFiles, err := fs.Glob(dataFS, "data/structure_set/*.json")