# Structure port notes — ground truth from the 26.1.2 jar Everything below was read out of `versions/26.1.2/server-26.1.2.jar` with javap. It is the working map for porting structure piece generation; treat it as reference, not as finished code. ## Placement grid (implemented, see worldgen/structures.go) RandomSpreadStructurePlacement.getPotentialStructureChunk: regionX = floorDiv(chunkX, spacing) regionZ = floorDiv(chunkZ, spacing) r = WorldgenRandom(LegacyRandomSource(0)) r.setLargeFeatureWithSalt(seed, regionX, regionZ, salt) span = spacing - separation offsetX = spread.evaluate(r, span) offsetZ = spread.evaluate(r, span) chunk = (regionX*spacing + offsetX, regionZ*spacing + offsetZ) RandomSpreadType.evaluate: linear -> nextInt(span); triangular -> (nextInt(span)+nextInt(span))/2. setLargeFeatureWithSalt(seed, x, z, salt): seed' = x*341873128712 + z*132897987541 + seed + salt, setSeed, zero draws. applyAdditionalChunkRestrictions runs only when frequency < 1.0f and calls the configured reducer shouldGenerate(seed, salt, x, z, frequency): - default / probabilityReducer: setLargeFeatureWithSalt(seed, x, z, salt); nextFloat() < frequency. - legacy_type_1 / legacyPillagerOutpostReducer: regionX=x>>4, regionZ=z>>4; setSeed((long)(regionX ^ (regionZ<<4)) ^ seed); nextInt() discarded once; return nextInt((int)(1.0f/frequency)) == 0. For 0.2f the bound computes to 4. - legacy_type_2 / legacyArbitrarySaltProbabilityReducer: setLargeFeatureWithSalt(seed, x, z, **10387320**) — the set's own salt is ignored; nextFloat() < frequency. Buried treasure uses this with 0.01. - legacy_type_3 / legacyProbabilityReducerWithDouble: setLargeFeatureSeed(seed, x, z) (the two-long XOR mix, no salt); nextDouble() < (double)frequency. Mineshafts use this with 0.004. Verified against the fixture on seed 12345: ruined_portals claims exactly chunk (1,0), where the capture carries the portal's obsidian/gold/crying obsidian; mineshaft starts land at (-4,1), (-3,10), (4,-1), consistent with the deep oak-plank cells crossing chunks (0,0)/(1,0). Not wired yet: concentric_rings (strongholds only). ## Ruined portal (decoded, not yet ported) ### Seeding chain (ChunkGenerator.createStructures / lambda$createStructures$0) Per set per chunk, in order: 1. Skip if any structure of the set already has a valid start here. 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, 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 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 1. Setup selection — skipped entirely (zero draws) when setups.size() <= 1; otherwise sum weights, one nextFloat(), walk setups subtracting weight/total, take the first that leaves the accumulator negative. 2. properties.airPocket = sample(nextFloat vs setup.airPocketProbability), where sample returns false at p==0, true at p==1, else nextFloat() < p. 3. Template: nextFloat() < 0.05 picks giant_portal_{1..3}, otherwise portal_{1..10} (both arrays exactly these names, one nextInt(len)). 4. Rotation = Util.getRandom(Rotation.values(), random) -> one nextInt(4) over [none, clockwise_90, clockwise_180, counterclockwise_90]. 5. Mirror: nextFloat() < 0.5 -> none, else front_back. 6. pivot = (size.x/2, 0, size.z/2) (Java truncating division). 7. box = template.getBoundingBox(chunkOriginBlockPos, rotation, pivot, mirror). 8. surfaceY = getBaseHeight(box.center.x, box.center.z, getHeightMapType(setup.placement)) - 1, where the type is OCEAN_FLOOR_WG for on_ocean_floor and WORLD_SURFACE_WG otherwise. 9. y = findSuitableY(...) below. 10. Stub position = (chunkOriginX, y, chunkOriginZ). The piece consumer sets properties.cold = setup.canBeCold && biomeAt(quart pos).coldEnoughToSnow(pos, seaLevel), then adds one RuinedPortalPiece(templateManager, pos, placement, properties, templateId, template, rotation, mirror, pivot). findSuitableY(random, generator, placement, airPocket, surfaceY, ySpan, box, accessor, randomState): minCut = accessor.minY + 15 switch placement: in_nether: airPocket ? y = randomBetweenInclusive(32, 100) : nextFloat() < 0.5 ? randomBetweenInclusive(27, 29) : randomBetweenInclusive(29, 100) in_mountain: y = getRandomWithinInterval(70, surfaceY - ySpan) underground: y = getRandomWithinInterval(minCut, surfaceY - ySpan) partly_buried: y = surfaceY + randomBetweenInclusive(2, 8) default: y = surfaceY (on_land_surface, on_ocean_floor) // settle: sample the four CORNER columns of the box as raw base columns corners = [(minX,minZ),(maxX,minZ),(minX,maxZ),(maxX,maxZ)] as NoiseColumn type = on_ocean_floor ? OCEAN_FLOOR_WG : WORLD_SURFACE_WG (isOpaque test) while y > minCut: opaque = 0 for column in corners: if type.isOpaque(column.getBlock(y)): opaque++ if opaque == 3: return y // stops inside the scan y-- return y getRandomWithinInterval(r, a, b): a >= b ? b : randomBetweenInclusive(a, b). randomBetweenInclusive(r, lo, hi) = lo + nextInt(hi - lo + 1). Remaining to read before coding the piece: ~~RuinedPortalPiece.postProcess~~ (decoded below), StructureTemplate.getBoundingBox is min/max of transformed positions, and placeInWorld writes non-air blocks first, air last. ### RuinedPortalPiece.postProcess, in order 1. box = template bounding box under the piece settings; skip when the chunk box does not contain box.center; encapsulate otherwise. 2. TemplateStructurePiece.postProcess = template.placeInWorld with the settings below (block entities become markers only). 3. spreadNetherrack(random, level). 4. addNetherrackDripColumnsBelowPortal(random, level). 5. If properties.vines || properties.overgrown: for every position of the bounding box (betweenClosedStream order): vines -> maybeAddVines, overgrown -> maybeAddLeavesAbove. makeSettings(mirror, rotation, placement, pos, properties): - ignore processor: airPocket ? STRUCTURE_BLOCK : STRUCTURE_AND_AIR — without an air pocket template AIR cells are skipped entirely. - RuleProcessor rules, first match wins, each test draws from the block's own positional Legacy stream (RandomSource.create(Mth.getSeed(x,y,z)), NOT the shared random): 1. gold_block -> air with probability 0.3 (RandomBlockMatchTest: state matches && nextFloat() < p) 2. lava rule: on_ocean_floor -> lava->magma always; cold -> lava->netherrack always; otherwise lava->magma at 0.2 3. if !cold: netherrack -> magma at 0.07 - BlockAgeProcessor(mossiness): stone_bricks/stone/chiseled_stone_bricks -> maybeReplaceFullStoneBlock (nextFloat >= 0.5 bail; then mossiness roll picks between [cracked_stone_bricks | stone_brick_stairs(random facing+half)] and [mossy_stone_bricks | mossy_stone_brick_stairs]); stairs tag -> bail at nextFloat >= 0.5 else mossy stairs or mossy slab roll; slabs/walls -> mossy variant when nextFloat < mossiness; obsidian -> crying_obsidian at 0.15. - ProtectedBlockProcessor(FEATURES_CANNOT_REPLACE): skip placement when the CURRENT world block is in the tag. - LavaSubmergedBlockProcessor: placed blocks sitting in lava become magma. - BlackstoneReplaceProcessor appended only when replaceWithBlackstone. spreadNetherrack weights (index by manhattan distance + jitter): [1,1,1,1,1,1,1,0.9,0.9,0.8,0.7,0.6,0.4,0.2]; jitter = nextInt(max(1, 8 - radius/2)) with radius = (xSpan+zSpan)/2; iterate the square around the box center +/- 14; for each cell draw nextDouble() < weight[idx], surfaceY = getHeight(type)-1 at that column, y2 = surfaceY when on_land_surface/on_ocean_floor else min(box.minY, surfaceY), require |y2 - box.minY| <= 3, replace only air/obsidian/not-in-tag/not-lava (not in nether), then placeNetherrackOrMagma (cold -> netherrack always; else nextFloat < 0.07 -> magma else netherrack); overgrown adds leaves above; then addNetherrackDripColumn below (up to 8 steps, each continuing while nextFloat < 0.5). ## Fixture markers worth remembering Seed 12345, committed fixture chunks (0,0) (1,0) (0,1) (-1,-1): - (1,0): ruined portal — obsidian (17,13,3), crying obsidian (21,13,3), gold block (19,18,3), chest (17,13,2). - (0,0)/(1,0): mineshaft planks/fences near y=-41..-42. - (-1,-1): unexplained cobblestone+mossy floor at y=35 under gravel/water with two chests nearby — attributed to no replayed feature yet. Candidate leads: a dungeon whose placement stream we mismatch, or an ocean ruin whose pieces reach across the chunk border.