From 798a3ec7b427e8132c34458f516baf3535a54777 Mon Sep 17 00:00:00 2001 From: Daniar Mannanov Date: Wed, 26 Aug 2026 13:56:51 +0300 Subject: [PATCH] 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. --- internal/world/ruined_portal.go | 341 ++++++++++++++++++++++++ internal/world/vanilla.go | 29 +- internal/worldgen/STRUCTURE_NOTES.md | 32 ++- internal/worldgen/biome_temperature.go | 4 + internal/worldgen/structure_template.go | 31 ++- internal/worldgen/structures.go | 48 +++- 6 files changed, 454 insertions(+), 31 deletions(-) create mode 100644 internal/world/ruined_portal.go diff --git a/internal/world/ruined_portal.go b/internal/world/ruined_portal.go new file mode 100644 index 0000000..68c7470 --- /dev/null +++ b/internal/world/ruined_portal.go @@ -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 +} + + + + + diff --git a/internal/world/vanilla.go b/internal/world/vanilla.go index be94875..00cb4e6 100644 --- a/internal/world/vanilla.go +++ b/internal/world/vanilla.go @@ -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. diff --git a/internal/worldgen/STRUCTURE_NOTES.md b/internal/worldgen/STRUCTURE_NOTES.md index aae47ec..e24f51a 100644 --- a/internal/worldgen/STRUCTURE_NOTES.md +++ b/internal/worldgen/STRUCTURE_NOTES.md @@ -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 diff --git a/internal/worldgen/biome_temperature.go b/internal/worldgen/biome_temperature.go index b110e58..48d708b 100644 --- a/internal/worldgen/biome_temperature.go +++ b/internal/worldgen/biome_temperature.go @@ -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) } + diff --git a/internal/worldgen/structure_template.go b/internal/worldgen/structure_template.go index 99c7da4..45ba13a 100644 --- a/internal/worldgen/structure_template.go +++ b/internal/worldgen/structure_template.go @@ -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 { } + diff --git a/internal/worldgen/structures.go b/internal/worldgen/structures.go index 6c2429c..568eb65 100644 --- a/internal/worldgen/structures.go +++ b/internal/worldgen/structures.go @@ -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")