diff --git a/src/noise/cliffs/cliffFields.ts b/src/noise/cliffs/cliffFields.ts deleted file mode 100644 index b41c1055..00000000 --- a/src/noise/cliffs/cliffFields.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * `cliff_elevation_nauvis`: the elevation field the game samples cliffs against - * (before the lever-derived elevation-0/interval remap and no-cliff clamp, - * which land in a later task). A two-line combination of already-validated - * ports (`nauvis_hills`, `nauvis_hills_cliff_level`) from the shared Nauvis - * noise sub-tree. - * - * See noise-programs.lua's `cliff_elevation_nauvis` and - * `docs/superpowers/sdd/` for the cliffs design spec. - */ - -import { makeNauvisShared } from "../expressions/nauvisShared"; -import { makeElevationNauvis } from "../expressions/elevationNauvis"; -import { basisNoiseExpr } from "../eval/primitives"; -import { basisNoiseTablesFromSeed } from "../basisNoise"; -import { distanceFromNearestPoint } from "../distanceFromNearestPoint"; -import { - LOW_FREQ_CLIFFINESS_SEED1, - getModifiedElevationInterval, - getModifiedRichness, - sliderToLinear, -} from "./cliffCatalog"; -import type { CliffControls, CliffSettingsInput } from "./cliffCatalog"; -import type { Point } from "../distanceFromNearestPoint"; - -/** Inputs `makeCliffElevation` (and later cliff-field builders) need. */ -export interface CliffFieldCtx { - readonly seed0: number; - readonly controls: CliffControls; // { frequency, continuity } - readonly settings: CliffSettingsInput; // { cliffElevation0, cliffElevationInterval, richness } - readonly segmentationMultiplier?: number; // control:water:frequency; default 1 - readonly waterLevel?: number; // default 0 (used by the no_cliff elevation term, Task 6) - readonly startingPositions?: readonly Point[]; // default [{x:0,y:0}] - readonly startingLakePositions?: readonly Point[]; -} - -/** - * `cliff_elevation_nauvis(x,y) = 10 + 30 * (nauvis_hills(x,y) - nauvis_hills_cliff_level(x,y))`, - * from the shared Nauvis noise sub-tree (`nz = makeNauvisShared({ seed0, segmentationMultiplier })`). - */ -export function makeCliffElevation(ctx: CliffFieldCtx): (x: number, y: number) => number { - const nz = makeNauvisShared({ - seed0: ctx.seed0, - segmentationMultiplier: ctx.segmentationMultiplier, - }); - return (x: number, y: number): number => 10 + 30 * (nz.hills(x, y) - nz.cliffLevel(x, y)); -} - -/** - * `cliffiness_nauvis(x,y) = (main_cliffiness >= cliff_cutoff) * 10` - the core cliff - * GATE field, so every output is exactly `0` or `10` (a mismatch against the oracle - * is a real cutoff/term bug, not f32 drift). `main_cliffiness` is the `min` of six - * sub-terms, each a scaled combination of already-validated ports: - * - * base_cliffiness = (nauvis_cliff_ringbreak - 0.01) * 60 - * forest_path_cliffiness = (forest_path_billows - 0.03) * 12 - * bridge_path_cliffiness = (nauvis_bridge_billows - 0.05) * 15 - * elevation_cliffiness = (elevation_nauvis_no_cliff - 4) / 2 - * starting_area_cliffiness = -2 + distance * segmentation_multiplier / 120 - * 4 * low_frequency_cliffiness - * - * with cliff_cutoff = 2 * (0.5 - 0.5*slider_to_linear(cliff_richness,-1,1))^1.5 - * (= 0.7071 at the default richness). `starting_area_cliffiness` uses the PLAIN - * `segmentation_multiplier` (control:water:frequency), NOT `nauvis_segmentation_multiplier`, - * and its `distance` is `distance_from_nearest_point{points = starting_positions}` - * with NO `maximum_distance` - the game leaves it uncapped (oracle-confirmed: the - * uncapped `distance` returns the true Euclidean distance out past 14000 tiles), so - * we pass no cap (default Infinity). See noise-programs.lua's `cliffiness_nauvis`. - */ -export function makeCliffiness(ctx: CliffFieldCtx): (x: number, y: number) => number { - const seed0 = ctx.seed0; - const seg = ctx.segmentationMultiplier ?? 1; - const nz = makeNauvisShared({ seed0, segmentationMultiplier: seg }); - const nauvisSeg = nz.nauvisSeg; - - // Effective levers. - const interval = getModifiedElevationInterval( - ctx.settings.cliffElevationInterval, - ctx.controls.frequency, - ); - const cliffRichness = getModifiedRichness(ctx.settings.richness, ctx.controls.continuity); - const cliffFrequency = 40 / interval; - - // elevation_nauvis_no_cliff: the elevation tree with added_cliff_elevation = 0. - const noCliffElev = makeElevationNauvis({ - seed0, - waterLevel: ctx.waterLevel, - segmentationMultiplier: seg, - startingPositions: ctx.startingPositions ? [...ctx.startingPositions] : undefined, - startingLakePositions: ctx.startingLakePositions ? [...ctx.startingLakePositions] : undefined, - withCliffElevation: false, - }); - - const lowFreqTables = basisNoiseTablesFromSeed(seed0, LOW_FREQ_CLIFFINESS_SEED1); - const spawn: readonly Point[] = ctx.startingPositions ?? [{ x: 0, y: 0 }]; - - // Distance-independent parts of low_frequency_cliffiness and the cutoff. - const lowFreqLever = Math.min( - sliderToLinear(cliffFrequency, -1.7, 1.7), - sliderToLinear(cliffRichness, -1, 1), - ); - const cliffGapSize = 0.5 - 0.5 * sliderToLinear(cliffRichness, -1, 1); - const cliffCutoff = 2 * cliffGapSize ** 1.5; - - return (x: number, y: number): number => { - const base = (nz.cliffRingbreak(x, y) - 0.01) * 60; - const forest = (nz.forestPathBillows(x, y) - 0.03) * 12; - const bridge = (nz.bridgeBillows(x, y) - 0.05) * 15; - const elev = (noCliffElev(x, y) - 4) / 2; - // distance is uncapped (no maximum_distance in the game's `distance` expression). - const startArea = -2 + (distanceFromNearestPoint(x, y, spawn) * seg) / 120; - const lowFreq = - 1.5 + - basisNoiseExpr( - x, - y, - { seed0, seed1: LOW_FREQ_CLIFFINESS_SEED1, inputScale: nauvisSeg / 500, outputScale: 0.51 }, - lowFreqTables, - ) + - lowFreqLever; - const mainCliffiness = Math.min(base, forest, bridge, elev, startArea, 4 * lowFreq); - return mainCliffiness >= cliffCutoff ? 10 : 0; - }; -} - -/** - * Both Nauvis cliff fields the placement pass needs: `cliff_elevation_nauvis` - * (which band a cliff sits on) and `cliffiness_nauvis` (the 0/10 gate for whether - * a cell may carry a cliff at all). - */ -export function makeCliffFields(ctx: CliffFieldCtx): { - cliffElevation: (x: number, y: number) => number; - cliffiness: (x: number, y: number) => number; -} { - return { - cliffElevation: makeCliffElevation(ctx), - cliffiness: makeCliffiness(ctx), - }; -} diff --git a/src/noise/cliffs/cliffPlacement.ts b/src/noise/cliffs/cliffPlacement.ts deleted file mode 100644 index 66b467b4..00000000 --- a/src/noise/cliffs/cliffPlacement.ts +++ /dev/null @@ -1,651 +0,0 @@ -/** - * Cliff placement: turns the two cliff fields (`cliffElevation`, `cliffiness`, - * from `makeCliffFields`) into placed cliff cell centers on the game's 4-tile - * placement grid, via `CliffGenerator::crossesCliff` and - * `CellCliffCrossing::toMaybeCliffOrientation` (see `cliffCatalog.ts` and - * `docs/noise/cliffs-NOTES.md` "Placement rule" / "Cell -> cliff (orientation - * code)" sections for the disasm-confirmed rule this ports). - */ - -import type { CliffFieldCtx } from "./cliffFields"; -import { makeCliffFields } from "./cliffFields"; -import { - CLIFF_CELL_CENTER_X, - CLIFF_CELL_CENTER_Y, - CLIFF_GRID_SIZE, - cliffCollisionTileBox, - getModifiedElevationInterval, - isCliffPlaced, -} from "./cliffCatalog"; - -/** - * `CliffGenerator::crossesCliff(a, b, cliffinessAvg, elevation_0, interval)` - * (`0x101606d08`): does the edge between two corners with elevations `a`/`b` - * cross a cliff band, and if so, which way? Returns `0` (no crossing), `+1` - * (crossing up, low->high as a/b order), or `-1` (crossing down). - * - * Both elevations must be non-negative and their max must reach `elevation_0`; - * the cliffiness gate compares the AVERAGE of the two corners' cliffiness to - * `0.5` (not `> 0`) - see cliffs-NOTES.md for why this makes sense given - * `cliffiness_nauvis in {0,10}`. - */ -export function crossesCliff( - a: number, - b: number, - cliffAvg: number, - e0: number, - interval: number, -): -1 | 0 | 1 { - if (a < 0 || b < 0) return 0; - const boundary = e0 + interval * Math.floor((Math.max(a, b) - e0) / interval); - if (boundary < e0) return 0; - const dA = a - boundary; - const dB = b - boundary; - if (cliffAvg > 0.5) { - if (dA < 0 && dB > 0) return 1; - if (dA > 0 && dB < 0) return -1; - } - return 0; -} - -/** 2-bit edge-crossing encoding used to assemble a cell's `code`: -1 -> 3. */ -function enc(v: -1 | 0 | 1): number { - return v < 0 ? 3 : v; -} - -interface CornerSample { - elev: number; - cliff: number; -} - -/** - * The two fields the placement pass samples at the corner lattice. Nauvis builds - * these from `makeCliffFields`, Vulcanus from `makeVulcanusCliffFields`; the - * geometry below does not care which, because `crossesCliff` and the 4-tile - * lattice are engine behaviour, not planet behaviour. - */ -export interface CliffFields { - readonly cliffElevation: (x: number, y: number) => number; - readonly cliffiness: (x: number, y: number) => number; -} - -/** Band phase and spacing, after the frequency lever has been applied. */ -export interface CliffBands { - /** `cliff_elevation_0`: the elevation of the first cliff band. */ - readonly elevation0: number; - /** `cliff_elevation_interval`, already divided by the frequency lever. */ - readonly interval: number; - /** - * `cliff_smoothing`, 0..1. Defaults to **0** here, which is Nauvis's value - - * NOT the prototype default of 1. See `smoothedElevation` below: this is a - * planet-level constant, and getting it wrong is invisible on Nauvis and - * catastrophic on Vulcanus. - */ - readonly smoothing?: number; - /** - * Run `CellEdgeCliffCrossingArray::fixImpossibleCells`, the game's per-chunk - * repair sweep. Defaults to **true**, because the game always runs it - - * `crossingsForChunk` calls it unconditionally at its tail. Pass `false` only - * to measure what it changes. - */ - readonly fixImpossibleCells?: boolean; - /** When true, `placedCells` returns nothing (continuity or richness is 0). */ - readonly disabled?: boolean; - /** - * The game's tile-collision rejection: return `true` for a tile a cliff cannot - * occupy. Omit it and no rejection runs, which is what every caller did before - * 2026-07-30. - * - * `EntityMapGenerationTask::tryToAddCliff` (`0x101625038`) looks up the cell's - * orientation, takes that orientation's `collision_bounding_box`, and calls - * `wouldCollide` (`0x101625468`) against the tile mask grid; on a hit the - * cliff is simply **not added**. `generateCliffs` ignores the return value - * entirely - there is no retry and no alternative orientation. - * - * That reading of the code is unchanged, but the conclusion drawn from it - - * "and therefore no effect on the neighbouring cells" - **is refuted by the - * game's output**, so see `rejectAtCrossingStage` below before assuming this - * is a post-filter. The observable behaviour is that a rejected cell's - * crossings go with it and its neighbour's orientation changes. - * - * Which tiles collide is planet-specific but the rule is not: a tile collides - * when its `CollisionMask` shares a layer with the cliff's. The cliff mask - * holds `water_tile`, so on Nauvis that is water and on Vulcanus it is - * `lava` / `lava-hot` (whose `tile_collision_masks.lava()` sets `water_tile`). - * - * The predicate is called with **integer tile coordinates**, up to ~30 per - * placed cell, and only for cells that are actually placed. - */ - readonly tileCollides?: (x: number, y: number) => boolean; - /** - * An additional per-cell rejection, called with the cell's crossing `code` and - * its centre, for cells that survive the bounds test and `tileCollides`. - * Return `true` to drop the cell. - * - * **Deliberately opaque.** This module is planet-agnostic - the corner - * lattice, `crossesCliff` and the orientation table are engine behaviour - and - * the one rule that currently uses this hook is planet-specific and only - * partly explained (Vulcanus's ORE -> CLIFF suppression, see - * `vulcanusOreRejection.ts` - the mechanism is - * `ResourceEntityPrototype::cliff_removal_probability`, but the geometry it - * removes with is still an empirical fit). Keeping it a bare predicate is what - * stops such a rule from leaking into the shared core. - * - * It runs at the same site as `tileCollides` rather than as a filter over - * `placedCells`' return value so that the model the specs score is the model - * the renderer ships; every spec here drives `makeCliffPlacementFromFields` - * directly, so a filter applied further out would score a different thing than - * it renders. - * - * Like `tileCollides`, whether this acts as a post-filter or on the crossing - * itself is `rejectAtCrossingStage`'s decision, and the measurement says the - * crossing. Either way it stays chunk-local - the zeroing runs over the whole - * chunk, including cells outside the query box - so worker tiling remains - * byte-identical. - */ - readonly cellRejects?: (code: number, x: number, y: number) => boolean; - /** - * EXPERIMENTAL (#84): apply `tileCollides` / `cellRejects` by zeroing the - * rejected cell's four edge registers after the repair sweep, instead of - * filtering the emitted cell. A neighbour sharing one of those edges therefore - * loses it too, and its orientation changes. - * - * The post-filter reading came from `tryToAddCliff` ignoring `wouldCollide`'s - * return value, and it is REFUTED as a description of the observable output: - * see `test/vulcanusCliffRejectionStage.spec.ts`. Under a post-filter, a - * surviving cell keeps an edge whose neighbour was rejected; the game shows - * that 0 times where the model predicts 1,662. - */ - readonly rejectAtCrossingStage?: boolean; - /** - * With `rejectAtCrossingStage`, re-run the rejection pass until it finds - * nothing, so a cell whose ORIENTATION changed because a neighbour's edges - * were zeroed is re-tested with its new collision box. - * - * This is the "cascade along cliff connections" half of the open question in - * `vulcanusOreRejection.ts` - the other half being a wider box. Off by - * default; `test/cliffOreCascade.spec.ts` is what decides it. - */ - readonly rejectionCascades?: boolean; - /** - * EXPERIMENTAL (#84): permute the order `fixImpossibleCellsSweep` tries edges - * in. **Not the game's rule** - the engine is `L, T, R, B`, which is the - * default - and provided only so a spec can test whether the WEST-edge - * concentration of the residual is caused by that order. See - * `SWEEP_EDGE_ORDER_LTRB`. - */ - readonly sweepEdgeOrder?: readonly number[]; -} - -/** Cells per chunk axis: a 32-tile chunk over the 4-tile placement grid. */ -const CHUNK_CELLS = 32 / CLIFF_GRID_SIZE; - -/** Packs the four edge crossings into the cell code the orientation table keys on. */ -function cellCode(l: number, r: number, t: number, b: number): number { - return ((l & 3) << 6) | ((r & 3) << 4) | ((t & 3) << 2) | (b & 3); -} - -/** - * `CellEdgeCliffCrossingArray::fixImpossibleCells` (`0x10160c550`), the pass - * that runs at the tail of `crossingsForChunk` and is the named cause of the - * ~6% residual Nauvis's port has carried since M4. - * - * It is a **single forward sweep** over one chunk's `8x8` cells (row-major, `cy` - * outer), not a fixpoint over the whole array: clearing an edge changes the two - * cells that share it, and cells already visited are never revisited. Porting it - * as a relax-until-stable loop would be a different algorithm. - * - * Per cell it clears edges until the cell's code is one the orientation table - * accepts, choosing the first **clearable** edge in the order `L, T, R, B`. An - * edge is clearable only if it is not on the chunk's outer boundary, so the - * chunk cannot disturb its neighbours - which is what keeps the pass chunk-local - * and lets this run without a chunk-ordering dependency. - * - * The legality predicate needs no new table. The disassembly splits on - * `code <= 0x50` (a 0x51-byte jump table at `0x102d00115` / `0x102d00166`, one - * per branch, both encoding the same accept/reject split) and `code >= 0xC0` (a - * bitmask `0x0001000000001003`, whose set bits are offsets 0, 1, 12 and 48 -> - * codes `0xC0`, `0xC1`, `0xCC`, `0xF0`). Extracting both and comparing against - * `CLIFF_PLACED_TABLE`: the accepted set is exactly `isCliffPlaced(code)` plus - * code `0`. Codes in `0x51..0xBF` are all rejected. - * - * Note the binary is a **universal** Mach-O; raw byte reads of those tables need - * the arm64 slice offset added, or they silently return x86_64 bytes. - * - * The `bool` parameter gates an extra step that zeroes the outer edges of the - * chunk's four CORNER cells (8 edges). `crossingsForChunk` passes `false` - * (`mov w1, #0x0` at `0x10160d0c8`), so it never runs in this path and is not - * ported. An earlier note in cliffs-NOTES.md described this pass as zeroing the - * whole chunk border; it does not, and it does not run at all here. - */ -/** - * The order the sweep tries edges in, as indices `0 = L (west)`, `1 = T - * (north)`, `2 = R (east)`, `3 = B (south)`. The engine's order is `L, T, R, B` - * and that is the default; **a permutation is not the game's rule** and exists - * only so `test/cliffSweepOrderLever.spec.ts` can ask whether the west-edge - * concentration of #84's residual MOVES with it. A residual that relocates when - * the order is permuted is caused by the order; one that does not is not. - */ -export const SWEEP_EDGE_ORDER_LTRB: readonly number[] = [0, 1, 2, 3]; - -export function fixImpossibleCellsSweep( - v: Int8Array, - h: Int8Array, - w: number, - hh: number, - order: readonly number[] = SWEEP_EDGE_ORDER_LTRB, -): void { - const vIndex = (cx: number, cy: number): number => cy * (w + 1) + cx; - const hIndex = (cx: number, cy: number): number => cy * w + cx; - - /** - * The `bool` parameter, and it is a **retry flag the function sets on - * itself** - not a caller-supplied mode, which is how it was read until - * 2026-07-30. `crossingsForChunk` passes `false`, and an earlier note here - * concluded from that alone that the corner step "never runs in this path". - * It does. When the sweep reaches a cell it cannot fix, the disassembly does - * - * uVar10 = param_2 & 1; param_2 = 1; - * if (uVar10 != 0) { log(...); return; } - * goto ; - * - * i.e. it turns the flag on and **restarts the whole pass**, which this time - * begins by zeroing the eight outer edges of the chunk's four corner cells. - * A second failure logs "Unable to remove excess cliff cell edge crossings" - * and abandons the rest of the chunk outright. - * - * Note the restart re-sweeps the arrays **as already mutated** by the - * abandoned pass - it is not a fresh start from the raw crossings. - */ - for (let retry = 0; ; retry++) { - if (retry > 0) { - // The eight edges: the two outer edges of each corner cell. Zeroing these - // is what can make an otherwise unfixable corner cell legal, since its - // only remaining crossings were the ones the sweep is forbidden to clear. - v[vIndex(0, 0)] = 0; - h[hIndex(0, 0)] = 0; - v[vIndex(w, 0)] = 0; - h[hIndex(w - 1, 0)] = 0; - v[vIndex(0, hh - 1)] = 0; - h[hIndex(0, hh)] = 0; - v[vIndex(w, hh - 1)] = 0; - h[hIndex(w - 1, hh)] = 0; - } - - let stuck = false; - for (let cy = 0; cy < hh && !stuck; cy++) { - for (let cx = 0; cx < w && !stuck; cx++) { - const li = vIndex(cx, cy); - const ri = vIndex(cx + 1, cy); - const ti = hIndex(cx, cy); - const bi = hIndex(cx, cy + 1); - - for (;;) { - const code = cellCode(v[li], v[ri], h[ti], h[bi]); - // The engine first counts non-zero edges and only consults the table - // when the count is below 3. That is pure optimisation: every one of - // the 20 placing codes has one or two crossings, so a count of 3 or 4 - // can never be legal. Checking the table directly is equivalent. - if (code === 0 || isCliffPlaced(code)) break; - // `order` is `L, T, R, B` unless a spec is permuting it - see the - // parameter's own note. The guards are unchanged and stay bound to - // their own edge: an edge is clearable only when it is not on the - // chunk's outer boundary, which is what makes the CHOICE ORDER - // observable at all (a west-edge cell is denied its first choice). - let cleared = false; - for (const e of order) { - if (e === 0 && v[li] !== 0 && cx !== 0) v[li] = 0; - else if (e === 1 && h[ti] !== 0 && cy !== 0) h[ti] = 0; - else if (e === 2 && v[ri] !== 0 && cx < w - 1) v[ri] = 0; - else if (e === 3 && h[bi] !== 0 && cy < hh - 1) h[bi] = 0; - else continue; - cleared = true; - break; - } - if (!cleared) { - stuck = true; - break; - } - } - } - } - - // Not stuck -> the pass completed. Stuck on the retry -> the engine logs and - // abandons the chunk, leaving the arrays as they are. - if (!stuck || retry > 0) return; - } -} - -/** Corners per chunk axis: a 32-tile chunk over the 4-tile grid. */ -const CHUNK_CORNERS = 32 / CLIFF_GRID_SIZE; - -/** - * The knot pair and blend fraction that `cliff_smoothing` interpolates a corner - * between, for one axis. `crossingsForChunk` (`0x10160cdec`) walks each chunk's - * own `9x9` corner block and, per axis, takes - * - * ``` - * lo = i & ~3 // i is the IN-CHUNK corner index, 0..8 - * hi = min(lo + 4, CHUNK_CORNERS - 1) - * t = (i & 3) / (hi - lo) - * ``` - * - * so the knots land at in-chunk indices **0, 4 and 7** - the second span is - * three corners wide, not four, because `hi` is clamped to `CHUNK_CORNERS - 1` - * (7) rather than to the block edge (8). Index 8 falls out with `t = 0` on - * itself, which is the same world point as the next chunk's index 0, also a - * knot - so the two chunks agree there and this reduces cleanly to a function - * of the GLOBAL corner index, with no chunk loop needed. - * - * That asymmetry is not a misreading: it is what makes smoothing "inaccurate" - * in the prototype docs' own words, and it is anchored to the chunk grid, so - * the smoothed field is deliberately discontinuous every 32 tiles. - */ -export function smoothingKnots(index: number): { lo: number; hi: number; t: number } { - const i = ((index % CHUNK_CORNERS) + CHUNK_CORNERS) % CHUNK_CORNERS; - const base = index - i; - const lo = i & ~3; - const hi = Math.min(lo + 4, CHUNK_CORNERS - 1); - return { lo: base + lo, hi: base + hi, t: (i & 3) / (hi - lo) }; -} - -/** - * A placed cliff: the cell centre, plus the 8-bit edge-crossing `code` it was - * placed by. The code is carried out rather than discarded because it is the - * only thing that names the cliff's ORIENTATION, and therefore its collision - * box - `cliffOrientationForCode(code)`. Every consumer that only wants - * positions can ignore it; `test/cliffOrientationOracle.spec.ts` compares it - * against the game's own `LuaEntity.cliff_orientation`, which is what makes - * `CLIFF_CODE_TO_ORIENTATION` checkable against something outside this port. - */ -export interface PlacedCliffCell { - readonly x: number; - readonly y: number; - readonly code: number; -} - -export interface CliffPlacement { - placedCells(x0: number, y0: number, x1: number, y1: number): PlacedCliffCell[]; -} - -/** - * Builds the placed-cliff-cell query for a given cliff config: `placedCells` - * enumerates the 4-tile placement grid over a world box and returns the - * center `{x,y}` of every cell whose crossing code places a cliff. - */ -export function makeCliffPlacement( - ctx: CliffFieldCtx, - opts: Pick = {}, -): CliffPlacement { - return makeCliffPlacementFromFields(makeCliffFields(ctx), { - elevation0: ctx.settings.cliffElevation0, - interval: getModifiedElevationInterval( - ctx.settings.cliffElevationInterval, - ctx.controls.frequency, - ), - disabled: ctx.controls.continuity === 0 || ctx.settings.richness === 0, - tileCollides: opts.tileCollides, - }); -} - -/** - * The planet-agnostic half: the corner lattice, `crossesCliff` on the four cell - * edges, and the `toMaybeCliffOrientation` not-none predicate. Everything - * planet-specific lives in the two fields and the two band numbers. - */ -export function makeCliffPlacementFromFields( - fields: CliffFields, - bands: CliffBands, -): CliffPlacement { - const { cliffElevation, cliffiness } = fields; - const { elevation0: e0, interval } = bands; - const smoothing = bands.smoothing ?? 0; - const tileCollides = bands.tileCollides; - const cellRejects = bands.cellRejects; - - /** - * `tryToAddCliff`'s rejection, as a predicate on an already-placed cell: scan - * the orientation's collision box and drop the cell if any tile in it collides. - * With no `tileCollides` supplied this is a constant `false` and costs nothing. - * - * The box is `cliffCollisionTileBox` and nothing narrows it: `wouldCollide` - * floors the stored rectangle with `(box + position) >> 8` and scans the - * inclusive tile rect, with the box's own `1/8` orientation tag discarded. - * See `rotbbBox` in `cliffCatalog.ts` for the disassembly that establishes it. - */ - const rejected = (code: number, x: number, y: number): boolean => { - if (tileCollides === undefined) return false; - const box = cliffCollisionTileBox(code, x, y); - // `undefined` only for a code that places nothing, which cannot reach here. - if (box === undefined) return false; - for (let tx = box.left; tx <= box.right; tx++) - for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true; - return false; - }; - - return { - placedCells(x0: number, y0: number, x1: number, y1: number): PlacedCliffCell[] { - if (bands.disabled === true) return []; - - const raw = new Map(); - /** - * Sampled at the BARE lattice `(i*4, j*4)`. The prototype's `grid_offset` - * is a CENTRE offset, not a sample offset - see `CLIFF_CELL_CENTER_X` - - * and `crossingsForChunk` never reads it. Adding it here (as this did - * until 2026-07-30) moves no cliff and costs ~7 points of recall. - */ - const rawElevation = (i: number, j: number): number => { - const key = `${i},${j}`; - let value = raw.get(key); - if (value === undefined) { - value = cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE); - raw.set(key, value); - } - return value; - }; - - /** - * `cliff_smoothing` applied to the cliff ELEVATION register only - - * cliffiness is read unsmoothed (`crossingsForChunk` smooths the register - * at `[settings+0x1e0]`, then reads `[+0x1e4]` raw). The blend is - * - * (1 - s) * E(i,j) + s * bilerp(E at the four surrounding knots) - * - * At `s = 1` the `E(i,j)` term vanishes exactly, so the raw elevation - * sample is skipped and only the knot corners are ever evaluated. That - * makes smoothing slightly cheaper than no smoothing rather than dearer, - * but only slightly: measured over `placedCells(0,0,1024,1024)` on - * Vulcanus, 6.95s at `s = 0` vs 6.26s at `s = 1` (~10%, three paired runs, - * 2026-07-28). Cliffiness is still sampled at every corner and dominates, - * so do not expect the knot ratio to show up as a speedup. - */ - const smoothedElevation = (i: number, j: number): number => { - const kx = smoothingKnots(i); - const ky = smoothingKnots(j); - const bilinear = - (1 - kx.t) * (1 - ky.t) * rawElevation(kx.lo, ky.lo) + - kx.t * (1 - ky.t) * rawElevation(kx.hi, ky.lo) + - (1 - kx.t) * ky.t * rawElevation(kx.lo, ky.hi) + - kx.t * ky.t * rawElevation(kx.hi, ky.hi); - if (smoothing === 1) return bilinear; - return (1 - smoothing) * rawElevation(i, j) + smoothing * bilinear; - }; - - const elevationAt = smoothing === 0 ? rawElevation : smoothedElevation; - - const corners = new Map(); - const corner = (i: number, j: number): CornerSample => { - const key = `${i},${j}`; - let sample = corners.get(key); - if (sample === undefined) { - const wx = i * CLIFF_GRID_SIZE; - const wy = j * CLIFF_GRID_SIZE; - sample = { elev: elevationAt(i, j), cliff: cliffiness(wx, wy) }; - corners.set(key, sample); - } - return sample; - }; - - const cross = (p: CornerSample, q: CornerSample): -1 | 0 | 1 => - crossesCliff(p.elev, q.elev, (p.cliff + q.cliff) / 2, e0, interval); - - /** - * The INCLUSIVE cell-index range whose centres land in the query box. - * Cell `cx` sits at `cx * G + CX`, and the emit filter below keeps it when - * that is in `[x0, x1)`, so the exact range is - * - * cx >= (x0 - CX) / G -> ceil((x0 - CX) / G) - * cx < (x1 - CX) / G -> ceil((x1 - CX) / G) - 1 - * - * (`ceil(v) - 1` is right at an integer `v` too: `cx < k` means `k - 1`.) - * - * These used to be `floor` / `ceil`, which overshot by one cell at each - * end. Every extra cell was discarded by the emit filter, so the OUTPUT - * was correct - but the chunk loop below rounds this range out to whole - * chunks, and one extra cell is enough to pull in a whole extra 8-cell - * chunk on each side. That is a FIXED +2 chunks per axis per call, which - * is minor on a whole-image render and severe when tiled: measured at - * 512x512 vs 16 x 128x128, the cliff pass evaluated 21,025 cliffiness - * samples whole against 38,416 tiled - 1.83x the noise for identical - * output. See `test/cliffCellBounds.spec.ts`, which pins the ratio. - */ - const cxMin = Math.ceil((x0 - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE); - const cxMax = Math.ceil((x1 - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE) - 1; - const cyMin = Math.ceil((y0 - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE); - const cyMax = Math.ceil((y1 - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE) - 1; - - if (bands.fixImpossibleCells !== false) { - // Chunk-structured path. Each chunk builds its own edge arrays and runs - // the repair sweep in isolation, exactly as the game does - including - // recomputing the edges it shares with its neighbours, which both - // chunks own a private copy of. That is what makes the result - // independent of the query box, so worker tiling stays byte-identical. - const result: PlacedCliffCell[] = []; - const chunkX0 = Math.floor(cxMin / CHUNK_CELLS); - const chunkX1 = Math.floor(cxMax / CHUNK_CELLS); - const chunkY0 = Math.floor(cyMin / CHUNK_CELLS); - const chunkY1 = Math.floor(cyMax / CHUNK_CELLS); - const n = CHUNK_CELLS; - const v = new Int8Array((n + 1) * n); - const hEdges = new Int8Array(n * (n + 1)); - - for (let chY = chunkY0; chY <= chunkY1; chY++) { - for (let chX = chunkX0; chX <= chunkX1; chX++) { - const baseX = chX * n; - const baseY = chY * n; - - for (let cy = 0; cy < n; cy++) { - for (let cx = 0; cx <= n; cx++) { - v[cy * (n + 1) + cx] = cross( - corner(baseX + cx, baseY + cy), - corner(baseX + cx, baseY + cy + 1), - ); - } - } - for (let cy = 0; cy <= n; cy++) { - for (let cx = 0; cx < n; cx++) { - hEdges[cy * n + cx] = cross( - corner(baseX + cx, baseY + cy), - corner(baseX + cx + 1, baseY + cy), - ); - } - } - - fixImpossibleCellsSweep(v, hEdges, n, n, bands.sweepEdgeOrder); - - if (bands.rejectAtCrossingStage === true) { - // Collect first, then clear: a cell's rejection is decided from - // the code the repair left, not from a code a previous cell's - // clearing has already eaten into. - // - // `rejectionCascades` re-runs that to a fixpoint. Zeroing a cell's - // edges changes its neighbours' codes, and a changed code is a - // changed ORIENTATION, so a neighbour can become rejectable when - // it was not before. Whether the game does that is a measurement, - // not a deduction - see `cliffOreCascade.spec.ts`. - for (let pass = 0; ; pass++) { - const kill: number[] = []; - for (let cy = 0; cy < n; cy++) { - for (let cx = 0; cx < n; cx++) { - const code = cellCode( - v[cy * (n + 1) + cx], - v[cy * (n + 1) + cx + 1], - hEdges[cy * n + cx], - hEdges[(cy + 1) * n + cx], - ); - if (!isCliffPlaced(code)) continue; - const x = (baseX + cx) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; - const y = (baseY + cy) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; - if (rejected(code, x, y) || cellRejects?.(code, x, y) === true) - kill.push(cx, cy); - } - } - for (let i = 0; i < kill.length; i += 2) { - const cx = kill[i]; - const cy = kill[i + 1]; - v[cy * (n + 1) + cx] = 0; - v[cy * (n + 1) + cx + 1] = 0; - hEdges[cy * n + cx] = 0; - hEdges[(cy + 1) * n + cx] = 0; - } - // One pass is the shipping model; the cascade stops when a pass - // finds nothing, and `pass` is bounded by the cell count anyway. - if (bands.rejectionCascades !== true || kill.length === 0 || pass > 64) break; - } - } - - for (let cy = 0; cy < n; cy++) { - for (let cx = 0; cx < n; cx++) { - const code = cellCode( - v[cy * (n + 1) + cx], - v[cy * (n + 1) + cx + 1], - hEdges[cy * n + cx], - hEdges[(cy + 1) * n + cx], - ); - if (!isCliffPlaced(code)) continue; - const x = (baseX + cx) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; - const y = (baseY + cy) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; - // Bounds-test BEFORE the collision test: the rejection is the - // expensive half (it resolves tiles), and a chunk always - // overhangs the query box. - if (x < x0 || x >= x1 || y < y0 || y >= y1) continue; - if (bands.rejectAtCrossingStage !== true) { - if (rejected(code, x, y)) continue; - if (cellRejects?.(code, x, y) === true) continue; - } - result.push({ x, y, code }); - } - } - } - } - return result; - } - - const result: PlacedCliffCell[] = []; - for (let cy = cyMin; cy <= cyMax; cy++) { - for (let cx = cxMin; cx <= cxMax; cx++) { - const cx0y0 = corner(cx, cy); - const cx0y1 = corner(cx, cy + 1); - const cx1y0 = corner(cx + 1, cy); - const cx1y1 = corner(cx + 1, cy + 1); - - const l = cross(cx0y0, cx0y1); - const r = cross(cx1y0, cx1y1); - const t = cross(cx0y0, cx1y0); - const b = cross(cx0y1, cx1y1); - - const code = (enc(l) << 6) | (enc(r) << 4) | (enc(t) << 2) | enc(b); - if (!isCliffPlaced(code)) continue; - - const x = cx * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; - const y = cy * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; - if (x < x0 || x >= x1 || y < y0 || y >= y1) continue; - if (rejected(code, x, y)) continue; - if (cellRejects?.(code, x, y) === true) continue; - result.push({ x, y, code }); - } - } - return result; - }, - }; -} diff --git a/src/noise/cliffs/vulcanusCliffFields.ts b/src/noise/cliffs/vulcanusCliffFields.ts deleted file mode 100644 index ad649ad7..00000000 --- a/src/noise/cliffs/vulcanusCliffFields.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * The two cliff fields for Vulcanus. - * - * Vulcanus does not reuse Nauvis's cliff expressions. `planet-map-gen.lua:13-14` - * overrides both properties: - * - * ```lua - * cliffiness = "cliffiness_basic", - * cliff_elevation = "cliff_elevation_from_elevation", -- = "elevation" - * cliff_settings = { name = "cliff-vulcanus", - * cliff_elevation_interval = 120, - * cliff_elevation_0 = 70 } - * ``` - * - * so the port is much smaller than the Nauvis one: `cliff_elevation` is just the - * planet's own elevation (already ported), and `cliffiness_basic` is a single - * clamp over a 2-octave `quick_multioctave_noise` (also already ported). None of - * the Nauvis hills/ringbreak/billows machinery is involved. - * - * **There are no Vulcanus cliff sliders.** `space-age/prototypes/autoplace-controls.lua` - * defines `gleba_cliff` and `fulgora_cliff` but no Vulcanus equivalent, and - * `planet_map_gen.vulcanus()`'s `autoplace_controls` list contains no cliff - * entry - so frequency and continuity are fixed at 1 and `cliff_richness` - * (`getModifiedRichness(richness, size)`) is fixed at 1. The interval and - * elevation-0 below are planet constants for the same reason: they come from the - * planet definition, not from the user's preset, which describes a Nauvis - * surface. Contrast Nauvis, where all four come off `preset.cliffSettings` and - * the `nauvis_cliff` control. - */ - -import type { EvalCtx } from "../eval/ctx"; -import { makeVulcanusBiomes } from "../expressions/vulcanusBiomes"; -import { makeVulcanusClimate } from "../expressions/vulcanusClimate"; -import { makeVulcanusCracks } from "../expressions/vulcanusCracks"; -import { makeVulcanusElevation } from "../expressions/vulcanusElevation"; -import { makeVulcanusHelpers } from "../expressions/vulcanusHelpers"; -import { makeVulcanusSpawn } from "../expressions/vulcanusSpawn"; -import { quickMultioctaveNoise } from "../quickMultioctaveNoise"; -import type { VulcanusStack } from "../tiles/vulcanusCatalog"; -import type { CliffFields } from "./cliffPlacement"; - -/** `cliff_elevation_0` from `planet_map_gen.vulcanus()`'s `cliff_settings`. */ -export const VULCANUS_CLIFF_ELEVATION_0 = 70; - -/** `cliff_elevation_interval` from the same `cliff_settings`. */ -export const VULCANUS_CLIFF_ELEVATION_INTERVAL = 120; - -/** - * `cliff_smoothing` on Vulcanus - **1, and it is load-bearing.** - * - * Vulcanus's `cliff_settings` block sets only `name`, `cliff_elevation_interval` - * and `cliff_elevation_0`, so smoothing takes the CliffPlacementSettings - * prototype default, which is `1` (full smoothing), not 0. Vulcanus is the odd - * planet out: Nauvis (`base/prototypes/planet/planet-map-gen.lua:18`), Fulgora - * and Gleba all set `cliff_smoothing = 0` explicitly, Fulgora with the comment - * "This is critical for correct cliff placement." - * - * The prototype docs say smoothing "makes cliffs straighter on rough elevation - * but makes placement inaccurate", and that is exactly what it did here: with - * this left at Nauvis's 0, Vulcanus reproduced 57-69% of real cliffs while - * placing 1.1-1.6x too many (issue #18). See `smoothingKnots` in - * `cliffPlacement.ts` for the rule this feeds. - */ -export const VULCANUS_CLIFF_SMOOTHING = 1; - -/** - * `cliff_richness` on Vulcanus. `getModifiedRichness(richness, size)` with no - * cliff autoplace control to move either lever, so it is pinned at 1 and the - * `0.5 * log2(cliff_richness)` term of `cliffiness_basic` vanishes. Kept as a - * named constant rather than folded away so the expression below still reads - * like the Lua it ports. - */ -export const VULCANUS_CLIFF_RICHNESS = 1; - -/** `seed1` of `cliffiness_basic`'s `quick_multioctave_noise` call. */ -export const CLIFFINESS_BASIC_SEED1 = 123; - -/** - * `cliffiness_basic` (`core/prototypes/noise-programs.lua:310`): - * - * ``` - * clamp(0.5 * log2(cliff_richness) + - * quick_multioctave_noise{x = x, y = y, seed0 = map_seed, seed1 = 123, - * input_scale = 1/32, output_scale = 1, octaves = 2, - * octave_output_scale_multiplier = 1, - * octave_input_scale_multiplier = 1/3}, - * 0, 1) + 0.5 - * ``` - * - * Range `[0.5, 1.5]`. That matters for the placement gate: `crossesCliff` - * compares the AVERAGE of two corners' cliffiness against `0.5`, so on Vulcanus - * an edge is cliffy whenever the clamp is above zero at either corner - a - * continuous field, unlike Nauvis's `cliffiness_nauvis` which is a hard 0-or-10 - * gate. Same comparison, different shape of input. - */ -export function makeCliffinessBasic( - seed0: number, - cliffRichness = VULCANUS_CLIFF_RICHNESS, -): (x: number, y: number) => number { - const richnessTerm = 0.5 * Math.log2(cliffRichness); - return (x: number, y: number): number => { - const n = quickMultioctaveNoise(x, y, { - seed0, - seed1: CLIFFINESS_BASIC_SEED1, - octaves: 2, - inputScale: 1 / 32, - outputScale: 1, - octaveOutputScaleMultiplier: 1, - octaveInputScaleMultiplier: 1 / 3, - offsetX: 0, - }); - return Math.min(1, Math.max(0, richnessTerm + n)) + 0.5; - }; -} - -/** - * Both fields the placement pass needs, for one seed/ctx. `cliffElevation` is - * `vulcanus_elevation` itself (`= max(-500, vulcanus_elev)`), which is what - * `cliff_elevation_from_elevation` resolves to once the planet has routed the - * `elevation` property at `vulcanus_elevation`. - */ -export function makeVulcanusCliffFields(ctx: EvalCtx, shared?: VulcanusStack): CliffFields { - // Same seam `makeVulcanusRockFields` uses: reuse the composite's one stack - // when there is one, and build a private DAG only for a standalone call. - const elevation = - shared?.elevation ?? - (() => { - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = makeVulcanusBiomes(ctx, helpers, spawn, cracks); - const climate = makeVulcanusClimate(ctx, helpers, cracks); - return makeVulcanusElevation(ctx, helpers, biomes, cracks, climate); - })(); - - return { - /** - * **`cliffElevation`, not `elevation`** - the cliff generator and the tile - * generator read genuinely different fields. - * - * `multisample`'s offsets are in the consuming noise program's GRID UNITS, - * and the cliff generator walks the 4-tile corner lattice while every - * per-tile consumer walks 1 tile, so `vulcanus_basalt_lakes_multisample`'s - * 2x2 min-filter spans 4 tiles here and 1 there. Using the per-tile field - * made the cliff elevation too rough and was issue #18's root cause - - * measured through the cliff generator itself in - * `test/multisampleGrid.spec.ts`, where `multisample(x, 4, 0)` routed onto - * `cliff_elevation` moves the contour 16 tiles rather than 4. - * - * Both variants hang off the one stack and share every sub-expression below - * the multisample, so this costs a second memo table and nothing else. - */ - cliffElevation: (x, y) => elevation.cliffElevation(x, y), - cliffiness: makeCliffinessBasic(ctx.seed0), - }; -} diff --git a/src/noise/cliffs/vulcanusOreRejection.ts b/src/noise/cliffs/vulcanusOreRejection.ts deleted file mode 100644 index f1ae4900..00000000 --- a/src/noise/cliffs/vulcanusOreRejection.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * The ORE -> CLIFF rejection: a resource entity's collision rectangle overlapping - * a cliff cell's suppresses that cliff. - * - * ## What is established, and what is not - * - * **Established, by a lever rather than an argument** (#99, - * `test/cliffOreDirection.spec.ts`). `autoplace_controls` is settable on the - * surface exactly like `cliff_settings`, so the game can be re-run with the - * resources switched off (`size = 0`) over the same regions. It gives both arms: - * turning the ore off fills all ten cells of the blob the game otherwise leaves - * empty, and forcing 335 cliffs through the tungsten field against the default's - * 283 moves the ore not one tile. The rule is therefore - * - * - **one-way** - removing a resource only ever ADDS cliffs, never removes one, - * - **additive** - 27 calcite + 4 geyser = exactly the 31 of all-off, disjoint, - * - **local**, and shaped like a BOX OVERLAP against the resource ENTITY's - * rectangle rather than "a resource tile lies in the 4x4 cell". That - * distinction is only visible because `sulfuric-acid-geyser`'s collision - * half-extent is 1.398 against the ores' 0.098: a point-at-tile-centre test - * explains the calcite cells and cannot explain the geyser ones. - * - * **ESTABLISHED 2026-08-14: the mechanism is - * `ResourceEntityPrototype::cliff_removal_probability`.** It defaults to - * **1.0**, and no shipped prototype overrides it - grepped across `base/`, - * `core/`, `space-age/`, `quality/` and `elevated-rails/` - so it is invisible - * from the data alone and can only be seen by changing it. - * - * Settled by a lever, and specifically by a PROTOTYPE lever rather than a - * surface one (`test/cliffRemovalProbability.spec.ts`). Switching the resources - * off, which is how #99 fixed the direction, removes everything about the ore - * at once and so can never say how. Zeroing one field instead leaves all 945 - * resource entities exactly where the control has them: - * - * | arm | blob cells | cliff-vulcanus | resources | field | - * | --- | --- | --- | --- | --- | - * | control | 0 / 10 | 335 | 945 | 1 | - * | field = 0 | **10 / 10** | 345 | **945** | 0 | - * | resources OFF | 10 / 10 | 345 | 0 | 1 | - * - * The zeroed arm is indistinguishable from the no-resources arm, and 345 - 335 - * is exactly the ten blob cells, so the field accounts for the effect entirely - * rather than partly. Each arm reads the field back off the running game, so an - * override that failed to apply cannot be mistaken for a term that does not - * matter. - * - * **No code changes, and that is the point of recording it.** At 1.0 the - * removal is unconditional, so the box-overlap rejection below is correct - * exactly as written. What changes is that its SHAPE is explained rather than - * fitted - a placed resource destroys the cliffs it collides with - and that - * the refutation below stops being a dead end and becomes the reason the effect - * had to be a removal at all. - * - * **Still NOT established: the geometry the engine removes with.** The base - * `collision_box` in point 1 below remains an empirical fit rather than a read - * of the code path, and naming the field licenses no tuning of it. - * - * The rival candidate was refuted before any of this, and stays refuted: - * `EntityMapGenerationTask::computeInternal` (`0x101622860`) calls - * `generateCliffs` at `+44` and `generateEntities` at `+148`, and `apply` - * (`0x101623b48`) calls `applyCliffs` at `+124` and `applyEntities` at `+164`, - * so cliffs are both computed and placed BEFORE any resource entity exists. No - * collision test can see an entity that is not there yet. The masks are disjoint - * too (resources carry only the `resource` layer, which the cliff mask does not - * hold). - * - * **That refutation is now VERIFIED rather than asserted, by three independent - * routes** (`test/cliffOreActsAtDestroyStage.spec.ts`, 2.1.12 arm64 slice): - * - * - **Order.** `computeInternal` calls `generateCliffs` before it even builds - * the `NoiseCache` the entity passes use; `apply` calls `applyCliffs`, - * `applyDecoratives`, then `applyEntities`. Read off the binary, not quoted. - * - **Inputs.** `generateCliffs` calls exactly three things - - * `crossingsForChunk`, `MaybeCliffOrientation::value`, `tryToAddCliff`. The - * queue has no resource input at all. - * - **Masks, at the PROTOTYPE level rather than the type default.** `calcite`, - * `tungsten-ore` and `sulfuric-acid-geyser` are all `type = "resource"` and - * none overrides `collision_mask`, so all three take - * `{layers={resource=true}}`; the cliff default is `{item, meltable, object, - * player, water_tile, is_lower_object, is_object, cliff}`. Disjoint. This is - * what also closes the CROSS-CHUNK variant of the idea - chunk N's entities - * are on the surface before chunk N+1's cliffs are applied, and it still - * cannot matter at any box size. - * - * And the one entity-versus-cliff test that does exist runs the other way: - * `applyEntities` calls `Surface::mapGeneratorWouldCollide` (`0x101624a44`) per - * queued entity and, on a hit, **skips that entity** - it never destroys a - * cliff. That is the direction #99 measured as inert. - * - * **Where the rule DOES act is measured**: at the destroy stage. The one - * ore-suppressed cell whose neighbour can tell destruction from non-generation - * (`1546,1550.5`, a geyser cell) says DESTROYED, so the effect enters at - * `applyCliffs`/`Surface::wouldCollide` and not at `crossingsForChunk`. n=1 - - * the oracle is thin here and the spec says so. **That thin result is now - * corroborated by something other than itself:** a field literally named - * `cliff_removal_probability` can only act on a cliff that already exists, so - * "destroyed rather than never queued" is what the mechanism predicts. - * - * **Consequence for anyone about to widen the box:** it would not be modelling a - * known code path, because the engine's entity collision provably is not this - * rule. Point 2 below already says do not tune it; this is why that is not - * merely caution. - * - * ## Two things here are deliberately NOT the shape you might expect - * - * 1. **The cliff rectangle is the prototype's BASE `collision_box`, not the - * per-orientation rotbb box** that the lava rejection uses - * (`CLIFF_ORIENTATION_COLLISION_BOX`). Those are materially different shapes - * - the base box is `+/-0.988 x +/-0.488`, while orientation 4's rotbb is - * `[-3.5,-3,4.5,3]`. The base box is the one the rule was measured with, and - * naming the mechanism does not settle its geometry - a removal test need not - * reuse the collision path's shape, and nothing yet says which shape it does - * use. `test/cliffOreRejection.spec.ts` scores - * BOTH so the choice is a recorded measurement rather than an assumption - - * which is the lesson #88/#90 already paid for, where the best-scoring - * collision model was the wrong one because it absorbed an unrelated defect. - * 2. **It does not explain all 31 cells, and it is not tuned until it does.** - * Box overlap accounts for 21 of the 31 with zero false alarms in the 885 - * cliffs the game kept. The other 10 are run remainders - every one of the - * six connected components of the suppressed set contains a directly - * overlapped cell - and whether that is a cascade along cliff connections or - * a wider box is open. Widening the box until all 31 fall out is exactly how - * #88 shipped a wrong model that scored perfectly. - * - * **Half of that is now settled, and the remainder count is down by two** - * (`test/cliffOreCascade.spec.ts`): - * - * - **The cascade half is REFUTED.** #108 established that a rejection zeroes - * the cell's edge registers, so a neighbour's code - hence its orientation, - * hence its collision box - changes. Re-testing to a fixpoint is exactly - * "a cascade along cliff connections", and `rejectionCascades` measures it: - * a bit-for-bit no-op at the shipping settings, and net harmful on the - * collapsed rule. Rejected cells do not turn neighbours rejectable. - * - * **That refutation is about the CROSSING-stage cascade, and it does not - * cover the one in `applyCliffs`** - `Cliff::onDestroy` taking the facing - * end of every connected neighbour and destroying a neighbour left with no - * end at all. That mechanism was read out of the binary later, in #113, so - * nothing had re-run the remainder question against it. - * `test/cliffOreRemainderCascade.spec.ts` does, entirely on the game's own - * data - its ore-off cliff set, its resource positions, these prototype - * boxes - and it closes **four of the ten** remainders and three of the - * five orientation errors, at zero cost in precision. Recall on the - * lever's 31 goes 21/31 to **25/31**. Six remain. - * - * So read the line above as "rejected cells do not turn neighbours - * REJECTABLE", which is still true, and not as "the remainders are not a - * cascade" - four of them are. - * - **The crossing STAGE explains 2 of the remainders with no tuning at all.** - * The predicate fires on 20 placed cells; the placement loses 22, because - * zeroing a rejected cell's edges leaves two neighbours with codes that no - * longer place. - * - Scored against the lever rather than by totals, the rule is - * **precision 1.000, recall 0.710** (22 of 31): exactly right where it - * fires, simply too narrow. Of the 9 it misses, **4 are geyser** cells the - * `includeGeyser` default deliberately excludes, and 5 are calcite; all 9 - * are adjacent to another suppressed cell. - * - * That leaves the wider-box half open - and it is the half #88 says must not - * be tuned into fitting. - */ -import type { VulcanusResourceControls } from "../eval/ctx"; -import type { VulcanusResources } from "../expressions/vulcanusResources"; -import { makeVulcanusOreFootprint } from "../resources/vulcanusResourceCatalog"; -import type { CliffCollisionBox } from "./cliffCatalog"; -import { CLIFF_ORIENTATION_COLLISION_BOX, cliffOrientationForCode } from "./cliffCatalog"; - -/** - * `cliff-vulcanus`'s prototype `collision_box`, read off a running game - * (`LuaEntityPrototype.collision_box`) and carried in - * `oracle-vulcanus-cliff-ore-direction.seed123456.json` as - * `protos["cliff-vulcanus"].box`, so the fixture holds the number rather than - * this file asserting it. Quantised to 1/256 because `MapPosition` is 8-bit - * fixed point: `0.98828125 = 253/256`, `0.48828125 = 125/256`. - */ -export const VULCANUS_CLIFF_BASE_COLLISION_BOX: CliffCollisionBox = [ - -0.98828125, -0.48828125, 0.98828125, 0.48828125, -]; - -/** - * The three solid ores' collision half-extent, `0.09765625 = 25/256`, identical - * across `tungsten-ore`, `calcite` and `coal` (same fixture). - */ -export const VULCANUS_ORE_COLLISION_HALF = 0.09765625; - -/** - * `sulfuric-acid-geyser`'s collision half-extent, `1.3984375 = 358/256` - the - * 2.8 x 2.8 box from `space-age/prototypes/entity/resources.lua:182`. More than - * fourteen times the ores' in each axis, which is what makes the geometry - * measurable at all. - */ -export const VULCANUS_GEYSER_COLLISION_HALF = 1.3984375; - -/** Which cliff rectangle the rejection tests with. */ -export type CliffRejectionBox = "base" | "orientation"; - -export interface VulcanusOreRejectionOptions { - /** - * Include the sulfuric-acid geyser as a suppressing entity. **Defaults to - * false**, and that default is a measurement, not caution for its own sake. - * - * The three solid ores THRESHOLD off region fields the oracle validates to - * ~1e-3, and the region saturates, so their footprint boundary is sharp and - * essentially deterministic. The geyser ROLLS: its placements are - * salt-dependent, and re-running one region over eight salts gives 46-63 - * entities against the game's 56 (see `makeVulcanusGeyserPlacement`). A geyser - * our model puts in the wrong place, with a box 14x the ores', removes a cliff - * the game KEPT - a false rejection, which costs recall. This rule is - * otherwise pure precision, so recall loss is the one outcome worth gating - * against. `test/cliffOreRejection.spec.ts` measures the arm both ways. - */ - readonly includeGeyser?: boolean; - /** - * Which cliff rectangle to test with - see the module comment. `"base"` is the - * shape the rule was measured with and the shipping default. - */ - readonly box?: CliffRejectionBox; - /** - * The geyser placement predicate, when `includeGeyser` is set. Injected rather - * than built here so the caller can hand over the composite's one - * `VulcanusStack` - see `geyserPlacementFrom`. - */ - readonly geyserAt?: (x: number, y: number) => boolean; -} - -interface Suppressor { - readonly half: number; - readonly occupies: (x: number, y: number) => boolean; -} - -/** - * The cliff rectangle for a cell, relative to its centre. The `"orientation"` - * variant falls back to the base box for a code that places nothing, which - * cannot reach the predicate anyway. - */ -function cliffBoxFor(box: CliffRejectionBox, code: number): CliffCollisionBox { - if (box === "base") return VULCANUS_CLIFF_BASE_COLLISION_BOX; - const id = cliffOrientationForCode(code); - return id === undefined ? VULCANUS_CLIFF_BASE_COLLISION_BOX : CLIFF_ORIENTATION_COLLISION_BOX[id]; -} - -/** - * Build the `CliffBands.cellRejects` predicate for Vulcanus: true when the - * cell's cliff rectangle overlaps a resource entity's. - * - * ## Why this is cheap - * - * It looks like it needs the set of resource entities near the cell, but it does - * not. A resource entity sits at a tile centre, so the tiles whose centre can - * possibly overlap follow in closed form from the two rectangles, and the - * predicate just asks the footprint about each of them. Cell centres sit at - * integer `x` and half-integer `y` (`cx*4+2`, `cy*4+2.5`), so for the base box - * against an ore that window is exactly **two tiles**; the geyser's larger box - * widens it to 4x3. Both are well under the lava rejection's ~30 tile lookups - * per cell, and no entity enumeration or spatial index is needed. - * - * The window is derived rather than hardcoded, and - * `test/cliffOreRejection.spec.ts` asserts that widening it by a tile on every - * side changes no cell - so the derivation is guarded, not trusted. - */ -export function makeVulcanusOreRejection( - resources: VulcanusResources, - controls: VulcanusResourceControls, - opts: VulcanusOreRejectionOptions = {}, -): (code: number, x: number, y: number) => boolean { - const boxKind = opts.box ?? "base"; - const suppressors: Suppressor[] = [ - { half: VULCANUS_ORE_COLLISION_HALF, occupies: makeVulcanusOreFootprint(resources, controls) }, - ]; - if (opts.includeGeyser === true && opts.geyserAt !== undefined) - suppressors.push({ half: VULCANUS_GEYSER_COLLISION_HALF, occupies: opts.geyserAt }); - - return (code, x, y) => { - const [l, t, r, b] = cliffBoxFor(boxKind, code); - for (const s of suppressors) { - // An entity centred at (tx + 0.5, ty + 0.5) overlaps when its box and the - // cliff's do, strictly - the same `<` the measurement used. Solving for tx - // gives the inclusive tile window below. - const txMin = Math.floor(x + l - s.half - 0.5) + 1; - const txMax = Math.ceil(x + r + s.half - 0.5) - 1; - const tyMin = Math.floor(y + t - s.half - 0.5) + 1; - const tyMax = Math.ceil(y + b + s.half - 0.5) - 1; - for (let tx = txMin; tx <= txMax; tx++) - for (let ty = tyMin; ty <= tyMax; ty++) if (s.occupies(tx, ty)) return true; - } - return false; - }; -} diff --git a/src/noise/enemies/enemyBaseField.ts b/src/noise/enemies/enemyBaseField.ts deleted file mode 100644 index 8525e6af..00000000 --- a/src/noise/enemies/enemyBaseField.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * `enemy_base_probability`, the noise expression driving the deterministic - * enemy-base spawner (used for the client-side enemy-base overlay), ported over - * the same solved primitives as `regularPatches.ts` (`selectSpots`, `basisNoise`, - * `distanceFromNearestPoint`). Single field, no per-resource params, no - * `quantityBatch`, no hard-target shrink - the game's cone here has no - * `min(32, ...)` radius cap (that clamp is resource-only) and `coneScale` is - * always 1 (`hardRegionTargetQuantity: false`). - * - * enemy_base_probability = - * spotField + blobTerm - 0.3 + min(0, (20 / starting_area_radius) * distance - 20) - * spotField = max(basement, max over nearby spots of (peak - dist*slope)) - * blobTerm = (basis_noise{1/8} + basis_noise{1/24} + 2*basis_noise{1/64} - 0.5) - * * (spot_radius(distance) / 150) * (0.1 + 0.9*clamp(distance/3000, 0, 1)) - * - * An M4 spike validated this exact math against the live game to abs < 0.001; - * see test/enemyBaseField.spec.ts (validated against the Task 2 oracle fixture, - * test/fixtures/oracle-enemy-base.seed123456.json). - */ -import { basisNoise, basisNoiseTablesFromSeed, type BasisNoiseTables } from "../basisNoise"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { f32 } from "../eval/f32"; -import { selectSpots, type SelectedSpot } from "../spotSelection"; -import type { SpotRegionKey } from "../spotCandidates"; -import { - ENEMY_BASEMENT, - ENEMY_CANDIDATE_SPOT_COUNT, - ENEMY_MAX_SPOT_BASEMENT_RADIUS, - ENEMY_PLACEMENT_CAP, - ENEMY_REGION_SIZE, - ENEMY_SEED1, - ENEMY_SPACING, - STARTING_AREA_RADIUS, - enemyDensity, - enemySpotQuantity, - enemySpotRadius, - type EnemyControls, -} from "./enemyCatalog"; - -export interface EnemyBaseFieldCtx { - readonly seed0: number; - readonly controls: EnemyControls; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; -} - -export interface EnemyBaseField { - /** Raw `enemy_base_probability` (spot field + blob term - 0.3 + starting term). */ - field(x: number, y: number): number; - /** clamp(min(field, ENEMY_PLACEMENT_CAP), 0, 1) - the deterministic spawner placement source. */ - probability(x: number, y: number): number; -} - -const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi); -/** region index for a coordinate (regions centred on multiples of ENEMY_REGION_SIZE). */ -const regionIndex = (c: number): number => - Math.floor((c + ENEMY_REGION_SIZE / 2) / ENEMY_REGION_SIZE); - -export function makeEnemyBaseField(ctx: EnemyBaseFieldCtx): EnemyBaseField { - const controls: EnemyControls = ctx.controls; - const spawn: readonly Point[] = ctx.startingPositions ?? [{ x: 0, y: 0 }]; - const distanceAt = (x: number, y: number): number => - distanceFromNearestPoint(x, y, spawn as Point[]); - const tables: BasisNoiseTables = basisNoiseTablesFromSeed(ctx.seed0, ENEMY_SEED1); - - const regionCache = new Map(); - const regionSpots = (rX: number, rY: number): SelectedSpot[] => { - const key = `${rX},${rY}`; - let spots = regionCache.get(key); - if (spots) return spots; - const regionKey: SpotRegionKey = { - seed0: ctx.seed0, - seed1: ENEMY_SEED1, - regionX: rX, - regionY: rY, - }; - spots = selectSpots(regionKey, { - density: (x, y) => enemyDensity(distanceAt(x, y), controls), - quantity: (x, y) => enemySpotQuantity(distanceAt(x, y), controls), - favorability: () => 1, - regionSize: ENEMY_REGION_SIZE, - candidateSpotCount: ENEMY_CANDIDATE_SPOT_COUNT, - spacing: ENEMY_SPACING, - hardRegionTargetQuantity: false, - }); - regionCache.set(key, spots); - return spots; - }; - - const spotFieldAt = (x: number, y: number): number => { - let best = ENEMY_BASEMENT; - const R = ENEMY_MAX_SPOT_BASEMENT_RADIUS; - const rXlo = regionIndex(x - R); - const rXhi = regionIndex(x + R); - const rYlo = regionIndex(y - R); - const rYhi = regionIndex(y + R); - for (let rX = rXlo; rX <= rXhi; rX++) { - for (let rY = rYlo; rY <= rYhi; rY++) { - for (const s of regionSpots(rX, rY)) { - const dx = x - s.x; - const dy = y - s.y; - const d2 = dx * dx + dy * dy; - if (d2 > R * R) continue; - // radius = spot_radius at the spot (NO min(32,...) cap - resource-only); coneScale === 1. - const radius = f32(enemySpotRadius(distanceAt(s.x, s.y), controls) * s.coneScale); - if (radius <= 0) continue; - const q = s.quantity; - const peak = f32(f32(3 * q) / f32(f32(Math.PI * radius) * radius)); - const cone = f32(peak - f32(f32(Math.sqrt(d2)) * f32(peak / radius))); - if (cone > best) best = cone; - } - } - } - return best; - }; - - const blobTermAt = (x: number, y: number): number => { - const b = - basisNoise(x / 8, y / 8, tables) + - basisNoise(x / 24, y / 24, tables) + - 2 * basisNoise(x / 64, y / 64, tables); // blob(1/64, 2) -> output_scale 2 - const d = distanceAt(x, y); - return (b - 0.5) * (enemySpotRadius(d, controls) / 150) * (0.1 + 0.9 * clamp(d / 3000, 0, 1)); - }; - - const field = (x: number, y: number): number => { - const d = distanceAt(x, y); - return ( - spotFieldAt(x, y) + blobTermAt(x, y) - 0.3 + Math.min(0, (20 / STARTING_AREA_RADIUS) * d - 20) - ); - }; - - return { - field, - probability: (x, y) => clamp(Math.min(field(x, y), ENEMY_PLACEMENT_CAP), 0, 1), - }; -} diff --git a/src/noise/expressions/aux.ts b/src/noise/expressions/aux.ts deleted file mode 100644 index c28f2958..00000000 --- a/src/noise/expressions/aux.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { clamp } from "../eval/math"; -import { makeNauvisShared } from "./nauvisShared"; -import { makeQuickMultioctaveNoise } from "../quickMultioctaveNoise"; - -export interface AuxParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:water:frequency; default 1. Threads into nauvis_plateaus via makeNauvisShared. */ - readonly segmentationMultiplier?: number; - /** control:aux:frequency; default 1. */ - readonly frequency?: number; - /** control:aux:bias; default 0. */ - readonly bias?: number; -} - -/** - * Compile the `aux` (= `aux_nauvis`, "terrain type") climate tree for one seed - * into an `(x, y) => aux` evaluator: - * - * clamp(0.5 + bias + 0.06 * (nauvis_plateaus - 0.4) + quick_multioctave_noise{...}, 0, 1) - * - * `nauvis_plateaus` is the shared Nauvis sub-tree (also used by - * `elevation_nauvis`) - see {@link makeNauvisShared}. The noise term is a - * 4-octave `quick_multioctave_noise` (seed1 = 7), input_scale = frequency/2048, - * output_scale = 0.25, offset_x = 20000/frequency, octave_output_scale_multiplier - * = 0.5, octave_input_scale_multiplier = 3. Mirrors core/prototypes/noise-programs.lua. - */ -export function makeAux(params: AuxParams): (x: number, y: number) => number { - const seed0 = params.seed0; - const segmentationMultiplier = params.segmentationMultiplier ?? 1; - const frequency = params.frequency ?? 1; - const bias = params.bias ?? 0; - - const { plateaus } = makeNauvisShared({ seed0, segmentationMultiplier }); - - const auxNoise = makeQuickMultioctaveNoise({ - seed0, - seed1: 7, - octaves: 4, - inputScale: frequency / 2048, - outputScale: 0.25, - offsetX: 20000 / frequency, - octaveOutputScaleMultiplier: 0.5, - octaveInputScaleMultiplier: 3, - }); - - return (x: number, y: number): number => - clamp(0.5 + bias + 0.06 * (plateaus(x, y) - 0.4) + auxNoise(x, y), 0, 1); -} diff --git a/src/noise/expressions/elevationIsland.ts b/src/noise/expressions/elevationIsland.ts deleted file mode 100644 index dc9d5fb7..00000000 --- a/src/noise/expressions/elevationIsland.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { makeElevationLakes, type ElevationLakesParams } from "./elevationLakes"; -import { withCtxDefaults, type EvalCtxInput } from "../eval/ctx"; - -/** Same free variables as elevation_lakes; `bias` is fixed at -1000 internally. */ -export type ElevationIslandParams = Omit; - -/** - * Compile the `elevation_island` tree for one seed into a `(x, y) => elevation` - * evaluator. Island is `elevation_lakes` with `bias = -1000` and - * `segmentation_multiplier / 4` (the game's `segmentation_mult`), fed to both - * make_0_12like_lakes and finish_elevation. Callers pass the RAW user - * segmentation; the /4 is applied here. See noise-programs.lua `elevation_island`. - */ -export function makeElevationIsland( - params: ElevationIslandParams, -): (x: number, y: number) => number { - const seg = params.segmentationMultiplier ?? 1; - return makeElevationLakes({ - ...params, - bias: -1000, - segmentationMultiplier: seg / 4, - }); -} - -/** - * Evaluate `elevation_island` at a single point. Convenience over - * {@link makeElevationIsland} - for sweeping a grid, call makeElevationIsland once - * and reuse the returned evaluator. - */ -export function elevationIsland(ctx: EvalCtxInput): number { - const c = withCtxDefaults(ctx); - return makeElevationIsland({ - seed0: c.seed0, - waterLevel: c.waterLevel, - segmentationMultiplier: c.segmentationMultiplier, - startingPositions: c.startingPositions, - startingLakePositions: c.startingLakePositions, - })(c.x, c.y); -} diff --git a/src/noise/expressions/elevationLakes.ts b/src/noise/expressions/elevationLakes.ts deleted file mode 100644 index b2b20942..00000000 --- a/src/noise/expressions/elevationLakes.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { basisNoiseTablesFromSeed } from "../basisNoise"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { basisNoiseExpr } from "../eval/primitives"; -import { clamp, max, min } from "../eval/math"; -import { withCtxDefaults, type EvalCtxInput } from "../eval/ctx"; -import { makeQuickMultioctaveNoisePersistence } from "../quickMultioctaveNoise"; -import { startingLakePositions as computeStartingLakes } from "../startingLakes"; -import { - amplitudeCorrectedMultioctaveNoise, - makeVariablePersistenceMultioctaveNoise, -} from "../variablePersistenceMultioctaveNoise"; - -export interface ElevationLakesParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** 10*log2(control:water:size); default 0. */ - readonly waterLevel?: number; - /** control:water:frequency; default 1. */ - readonly segmentationMultiplier?: number; - /** Spawn points for `distance` (uncapped). Default single origin spawn. */ - readonly startingPositions?: Point[]; - /** - * Lake points for `starting_lake_distance` (capped at 1024). When omitted, the - * game's real positions are computed from `(seed0, startingPositions)` via - * `startingLakePositions` (startingLakes.ts). Pass `[]` for the old far-field-only - * behavior. - */ - readonly startingLakePositions?: Point[]; - /** - * make_0_12like_lakes `bias` (branch 1's additive term). Default 20 (elevation_lakes). - * elevation_island passes -1000. Branch 2's literal 20 is independent of this. - */ - readonly bias?: number; -} - -/** - * Compile the `elevation_lakes` tree for one seed into a `(x, y) => elevation` - * evaluator. The two heavy variable-persistence octave stacks (8 and 6 octaves), - * the basis-123 tables, the amplitude-corrected persistence field, and the - * quick-persistence lake noise all derive their basis tables once here. Mirrors - * core/prototypes/noise-programs.lua 1:1 - see the M1 design spec. - */ -export function makeElevationLakes(params: ElevationLakesParams): (x: number, y: number) => number { - const seed0 = params.seed0; - const waterLevel = params.waterLevel ?? 0; - const seg = params.segmentationMultiplier ?? 1; - const startingPositions = params.startingPositions ?? [{ x: 0, y: 0 }]; - const startingLakePositions = - params.startingLakePositions ?? computeStartingLakes(seed0, startingPositions); - - // make_0_12like_lakes locals (bias = 20, terrain_octaves = 8). - const bias = params.bias ?? 20; - const terrainOctaves = 8; - const inputScale = seg / 2; - const offsetX = 10000 / seg; - - // Heavy octave stacks: build closures once (persistence still varies per tile). - const varPers1 = makeVariablePersistenceMultioctaveNoise({ - seed0, - seed1: 1, - octaves: terrainOctaves, - inputScale, - outputScale: 0.125, - offsetX, - }); - const varPers2 = makeVariablePersistenceMultioctaveNoise({ - seed0, - seed1: 2, - octaves: 6, - inputScale, - outputScale: 0.125, - offsetX, - }); - - // finish_elevation's basis term (seed1 = 123): derive tables once. - const basisTables123 = basisNoiseTablesFromSeed(seed0, 123); - - // amplitude_corrected persistence field (seed1 = 1): derive tables once. - const ampTables = basisNoiseTablesFromSeed(seed0, 1); - - // finish_elevation's starting_lake_noise (seed1 = 14): build the closure once. - const quickLakeNoise = makeQuickMultioctaveNoisePersistence({ - seed0, - seed1: 14, - octaves: 5, - inputScale: 1 / 8, - outputScale: 1, - octaveInputScaleMultiplier: 0.5, - persistence: 0.75, - }); - - const make0_12likeLakes = (x: number, y: number): number => { - // persistence field: clamp(amplitude_corrected + 0.3, 0.1, 0.9), fed to BOTH branches. - const p = clamp( - amplitudeCorrectedMultioctaveNoise( - x, - y, - { - seed0, - seed1: 1, - octaves: terrainOctaves - 2, - inputScale, - offsetX, - persistence: 0.7, - amplitude: 0.5, - }, - ampTables, - ) + 0.3, - 0.1, - 0.9, - ); - const distance = distanceFromNearestPoint(x, y, startingPositions); - const branch1 = bias + varPers1(x, y, p); - // NOTE: literal 20, not `bias` (they coincide at elevation_lakes; elevation_island - // sets bias = -1000 so branch 2 keeps 20 while branch 1 collapses). - const branch2 = 20 + waterLevel - 0.1 * seg * distance + varPers2(x, y, p); - return max(branch1, branch2); - }; - - const finishElevation = (elevation: number, x: number, y: number): number => { - const sld = distanceFromNearestPoint(x, y, startingLakePositions, 1024); - const sln = quickLakeNoise(x, y); - const term1 = (elevation - waterLevel) / seg; - const term2 = - basisNoiseExpr( - x, - y, - { seed0, seed1: 123, inputScale: 1 / 8, outputScale: 1.5 }, - basisTables123, - ) + - sld / 4 - - 4; - const term3 = -1 + (sld + sln) / 16; - const term4 = max(2, 2 + sld / 16 + sln / 2); - return min(term1, term2, term3, term4); - }; - - return (x: number, y: number): number => finishElevation(make0_12likeLakes(x, y), x, y); -} - -/** - * Evaluate `elevation_lakes` at a single point. Convenience over - * {@link makeElevationLakes} - for sweeping a grid, call makeElevationLakes once - * and reuse the returned evaluator. - */ -export function elevationLakes(ctx: EvalCtxInput): number { - const c = withCtxDefaults(ctx); - return makeElevationLakes({ - seed0: c.seed0, - waterLevel: c.waterLevel, - segmentationMultiplier: c.segmentationMultiplier, - startingPositions: c.startingPositions, - startingLakePositions: c.startingLakePositions, - })(c.x, c.y); -} diff --git a/src/noise/expressions/elevationNauvis.ts b/src/noise/expressions/elevationNauvis.ts deleted file mode 100644 index 8c4023aa..00000000 --- a/src/noise/expressions/elevationNauvis.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { basisNoiseTablesFromSeed } from "../basisNoise"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { clamp, lerp, max, min } from "../eval/math"; -import { withCtxDefaults, type EvalCtxInput } from "../eval/ctx"; -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import { makeNauvisShared } from "./nauvisShared"; -import { makeQuickMultioctaveNoisePersistence } from "../quickMultioctaveNoise"; -import { startingLakePositions as computeStartingLakes } from "../startingLakes"; -import { - amplitudeCorrectedMultioctaveNoise, - makeVariablePersistenceMultioctaveNoise, -} from "../variablePersistenceMultioctaveNoise"; -import type { BasisNoiseTables } from "../basisNoise"; - -export interface ElevationNauvisParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** 10*log2(control:water:size); default 0. */ - readonly waterLevel?: number; - /** control:water:frequency; default 1. */ - readonly segmentationMultiplier?: number; - /** Spawn points for `distance` (uncapped). Default single origin spawn. */ - readonly startingPositions?: Point[]; - /** - * Lake points for `starting_lake_distance` (capped at 1024). When omitted, the - * game's real positions are computed from `(seed0, startingPositions)` via - * `startingLakePositions`. Pass `[]` for far-field-only behavior. - */ - readonly startingLakePositions?: Point[]; - /** - * Whether `nauvis_hills_plateaus` feeds `added_cliff_elevation` (default `true`, - * matching the game's `elevation_nauvis`). `false` forces `added_cliff_elevation = 0`, - * matching `elevation_nauvis_no_cliff` (= `elevation_nauvis_function(0)`) - the - * cliffiness field's dependency (see `cliff_elevation_nauvis`). - */ - readonly withCliffElevation?: boolean; -} - -/** - * Compile the `elevation_nauvis` tree for one seed into a `(x, y) => elevation` - * evaluator. Mirrors core/prototypes/noise-programs.lua 1:1 - * (`elevation_nauvis_function(nauvis_hills_plateaus)`); every basis table and - * octave closure is derived once here. See the M1 nauvis design spec. - */ -export function makeElevationNauvis( - params: ElevationNauvisParams, -): (x: number, y: number) => number { - const seed0 = params.seed0; - const waterLevel = params.waterLevel ?? 0; - const seg = params.segmentationMultiplier ?? 1; - const startingPositions = params.startingPositions ?? [{ x: 0, y: 0 }]; - const startingLakePositions = - params.startingLakePositions ?? computeStartingLakes(seed0, startingPositions); - - // nauvis_segmentation_multiplier = 1.5 * control:water:frequency. EVERY noise - // sub-node scales/offsets by THIS, not by the plain `seg` (= segmentation_multiplier); - // only starting_island uses plain seg (see below). See noise-programs.lua. - const nz = makeNauvisShared({ seed0, segmentationMultiplier: seg }); - const nauvisSeg = nz.nauvisSeg; - const offsetX = 10000 / nauvisSeg; - - // Hoisted noise closures / tables (persistence field still varies per tile). - const detail = makeVariablePersistenceMultioctaveNoise({ - seed0, - seed1: 600, - octaves: 5, - inputScale: nauvisSeg / 14, - outputScale: 0.03, - offsetX, - }); - const macroA = makeMultioctaveNoise({ - seed0, - seed1: 1000, - octaves: 2, - persistence: 0.6, - inputScale: nauvisSeg / 1600, - outputScale: 1, - }); - const macroB = makeMultioctaveNoise({ - seed0, - seed1: 1100, - octaves: 1, - persistence: 0.6, - inputScale: nauvisSeg / 1600, - outputScale: 1, - }); - const persistanceTables: BasisNoiseTables = basisNoiseTablesFromSeed(seed0, 500); - const startingLakeNoise = makeQuickMultioctaveNoisePersistence({ - seed0, - seed1: 14, - octaves: 4, - inputScale: 1 / 8, - outputScale: 0.8, - octaveInputScaleMultiplier: 0.5, - persistence: 0.68, - }); - - return (x: number, y: number): number => { - // nauvis_persistance -> nauvis_detail (variable persistence field) - const persistence = clamp( - amplitudeCorrectedMultioctaveNoise( - x, - y, - { - seed0, - seed1: 500, - octaves: 5, - inputScale: nauvisSeg / 2, - offsetX, - persistence: 0.7, - amplitude: 0.5, - }, - persistanceTables, - ) + 0.55, - 0.5, - 0.65, - ); - const nauvisDetail = detail(x, y, persistence); - - // nauvis_bridges - const bb = nz.bridgeBillows(x, y); - const nauvisBridges = 1 - 0.1 * bb - 0.9 * max(0, -0.1 + bb); - - // nauvis_macro - const nauvisMacro = macroA(x, y) * max(0, macroB(x, y)); - - // nauvis_hills -> nauvis_plateaus -> nauvis_hills_plateaus (= added_cliff_elevation) - const nauvisHills = nz.hills(x, y); - const nauvisPlateaus = nz.plateaus(x, y); - const addedCliffElevation = - (params.withCliffElevation ?? true) ? 0.1 * nauvisHills + 0.8 * nauvisPlateaus : 0; - - // elevation_nauvis_function body (elevation_magnitude = 20, wlc_amplitude = 2) - const distance = distanceFromNearestPoint(x, y, startingPositions); - const startingMacroMultiplier = clamp((distance * nauvisSeg) / 2000, 0, 1); - const nauvisMain = - 20 * - (lerp( - 0.5 * addedCliffElevation - 0.6, - 1.9 * addedCliffElevation + 1.6, - 0.1 + 0.5 * nauvisBridges, - ) + - 0.25 * nauvisDetail + - 3 * nauvisMacro * startingMacroMultiplier); - const startingIsland = nauvisMain + 20 * (2.5 - (distance * seg) / 200); - const wlcElevation = max(nauvisMain - waterLevel * 2, startingIsland); - - const sld = distanceFromNearestPoint(x, y, startingLakePositions, 1024); - const sln = startingLakeNoise(x, y); - const startingLake = (20 * (-3 + (sld + sln) / 8)) / 8; - - return min(wlcElevation, startingLake); - }; -} - -/** - * Evaluate `elevation_nauvis` at a single point. Convenience over - * {@link makeElevationNauvis} - for sweeping a grid, call makeElevationNauvis once - * and reuse the returned evaluator. - */ -export function elevationNauvis(ctx: EvalCtxInput): number { - const c = withCtxDefaults(ctx); - return makeElevationNauvis({ - seed0: c.seed0, - waterLevel: c.waterLevel, - segmentationMultiplier: c.segmentationMultiplier, - startingPositions: c.startingPositions, - startingLakePositions: c.startingLakePositions, - })(c.x, c.y); -} diff --git a/src/noise/expressions/moisture.ts b/src/noise/expressions/moisture.ts deleted file mode 100644 index 18409e62..00000000 --- a/src/noise/expressions/moisture.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { clamp, lerp, max, min, sliderToLinear } from "../eval/math"; -import { makeNauvisShared } from "./nauvisShared"; -import { makeQuickMultioctaveNoise } from "../quickMultioctaveNoise"; - -export interface MoistureParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:water:frequency; default 1. Threads into nauvis_plateaus/hills/etc via makeNauvisShared. */ - readonly segmentationMultiplier?: number; - /** control:moisture:frequency; default 1. */ - readonly moistureFrequency?: number; - /** control:moisture:bias; default 0. */ - readonly moistureBias?: number; - /** control:starting_area_moisture:size (starting-area bias lever); default 1 (degenerate - see below). */ - readonly startingAreaMoistureSize?: number; - /** control:starting_area_moisture:frequency (starting-area falloff lever); default 1. */ - readonly startingAreaMoistureFrequency?: number; - /** Spawn points for the starting-area bias region distance (uncapped). Default single origin spawn. */ - readonly startingPositions?: Point[]; -} - -/** - * Compile the `moisture` (= `moisture_nauvis`) climate tree for one seed into an - * `(x, y) => moisture` evaluator. Mirrors core/prototypes/noise-programs.lua 1:1 - - * the most complex climate expression: a base 4-octave `quick_multioctave_noise` - * term, a starting-area bias blend keyed on `distance_from_nearest_point` (uncapped, - * unlike the elevation tree's capped `starting_lake_distance`), and a forest-path / - * hills / bridge-billows "cutout" that pulls moisture down near cliffs and forest - * paths so they don't get swallowed by high-moisture biomes. - * - * `nauvis_plateaus`, `nauvis_hills`, `nauvis_bridge_billows`, `forest_path_billows` - * are the shared Nauvis sub-tree (also used by `elevation_nauvis` and `aux_nauvis`) - - * see {@link makeNauvisShared}. - * - * At the default `startingAreaMoistureSize = 1`, `sliderToLinear(1, -0.5, 0.5) = 0`, - * so `startingBiasChange = 0` and the starting-area bias blend collapses to - * `moistureAdjustedBias = baseBias` everywhere (the starting-area levers have no - * effect at their defaults) - a degenerate but real path, still validated end to - * end by the oracle fixture at defaults. - */ -export function makeMoisture(params: MoistureParams): (x: number, y: number) => number { - const seed0 = params.seed0; - const segmentationMultiplier = params.segmentationMultiplier ?? 1; - const moistureFrequency = params.moistureFrequency ?? 1; - const moistureBias = params.moistureBias ?? 0; - const startingAreaMoistureSize = params.startingAreaMoistureSize ?? 1; - const startingAreaMoistureFrequency = params.startingAreaMoistureFrequency ?? 1; - const startingPositions = params.startingPositions ?? [{ x: 0, y: 0 }]; - - const nz = makeNauvisShared({ seed0, segmentationMultiplier }); - - const moistureNoise = makeQuickMultioctaveNoise({ - seed0, - seed1: 6, - octaves: 4, - inputScale: moistureFrequency / 256, - outputScale: 0.125, - offsetX: 30000 / moistureFrequency, - octaveOutputScaleMultiplier: 1.5, - octaveInputScaleMultiplier: 1 / 3, - }); - - const baseBias = moistureBias; - const startingBiasChange = sliderToLinear(startingAreaMoistureSize, -0.5, 0.5); - const startingBias = lerp(baseBias, startingBiasChange, Math.abs(2 * startingBiasChange) * 1.1); - - return (x: number, y: number): number => { - const distance = distanceFromNearestPoint(x, y, startingPositions); - const startingBiasRegion = clamp(2 - (startingAreaMoistureFrequency / 400) * distance, 0, 1); - const moistureAdjustedBias = lerp(baseBias, startingBias, startingBiasRegion); - - const moistureMain = clamp( - 0.4 + moistureAdjustedBias + moistureNoise(x, y) - 0.08 * (nz.plateaus(x, y) - 0.6), - 0, - 1, - ); - - const treesForestPathCutout = min( - (nz.bridgeBillows(x, y) - 0.07) * 5, - (nz.hills(x, y) - 0.1) * 3, - (nz.forestPathBillows(x, y) - 0.07) * 3, - ); - - return max( - min(moistureMain, 0.45), - moistureMain - 0.2 * max(0, 1 - treesForestPathCutout * 1.5), - ); - }; -} diff --git a/src/noise/expressions/nauvisShared.ts b/src/noise/expressions/nauvisShared.ts deleted file mode 100644 index e0598ff5..00000000 --- a/src/noise/expressions/nauvisShared.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { basisNoise, basisNoiseTablesFromSeed } from "../basisNoise"; -import { basisNoiseExpr } from "../eval/primitives"; -import { clamp } from "../eval/math"; -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import type { BasisNoiseTables } from "../basisNoise"; - -/** - * `basis_noise` `seed1` for `nauvis_hills_offset_raw_x` - the game passes the - * STRING `'nauvis_offset_x'`, which Factorio hashes into the numeric seed1 with - * standard CRC32 (identical to `src/codec/crc32.ts`). Resolved once and hardcoded - * (spike-confirmed against the oracle, ~1e-7): - * `crc32(utf8("nauvis_offset_x")) = 593691028` (0x2360A1D4). See docs/noise/cliffs-NOTES.md. - */ -export const NAUVIS_OFFSET_X_SEED1 = 593691028; -/** - * `basis_noise` `seed1` for `nauvis_hills_offset_raw_y` - the string - * `'nauvis_offset_y'` hashed with CRC32: - * `crc32(utf8("nauvis_offset_y")) = 1415852290` (0x5460AAC2). - */ -export const NAUVIS_OFFSET_Y_SEED1 = 1415852290; - -export interface NauvisSharedParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:water:frequency; default 1. */ - readonly segmentationMultiplier?: number; -} - -export interface NauvisShared { - /** `1.5 * segmentationMultiplier` - the scale every nauvis noise sub-node uses. */ - readonly nauvisSeg: number; - /** `nauvis_hills`: abs of a 4-octave multioctave noise (seed1 900). */ - readonly hills: (x: number, y: number) => number; - /** `nauvis_hills_cliff_level`: a basis-noise term clamped to [0.15, 1.15]. */ - readonly cliffLevel: (x: number, y: number) => number; - /** `nauvis_plateaus`: `0.5 + clamp((hills - cliffLevel) * 10, -0.5, 0.5)`. */ - readonly plateaus: (x: number, y: number) => number; - /** `nauvis_bridge_billows`: abs of a 4-octave multioctave noise (seed1 700). */ - readonly bridgeBillows: (x: number, y: number) => number; - /** New: abs of a 4-octave multioctave noise (seed1 1800), no offset_x. */ - readonly forestPathBillows: (x: number, y: number) => number; - /** - * `nauvis_hills_offset`: the `hills` seed1=900 multioctave field re-evaluated at - * a domain-warped coordinate `(x + 12*nx, y + 12*ny)`, then abs. `nx`/`ny` come - * from two `basis_noise` warp fields (seed1 = crc32("nauvis_offset_x"/"_y")). - */ - readonly hillsOffset: (x: number, y: number) => number; - /** - * `nauvis_cliff_ringbreak`: `abs(hills(x,y) - hillsOffset(x,y))` - the - * `base_cliffiness` input. Low along a band perpendicular to the offset - * direction, which breaks small ring features. - */ - readonly cliffRingbreak: (x: number, y: number) => number; -} - -/** - * Shared Nauvis noise internals (`nauvis_hills`, `nauvis_hills_cliff_level`, - * `nauvis_plateaus`, `nauvis_bridge_billows`, plus the new `forest_path_billows`), - * extracted from `makeElevationNauvis` so `elevation_nauvis` and the Nauvis climate - * expressions (`moisture_nauvis`, `aux_nauvis`) can reuse the same closures instead - * of re-deriving them. See noise-programs.lua and the M2 climate/terrain design spec. - * - * None of these builders take or apply `offset_x` - `elevation_nauvis`'s - * `offsetX = 10000/nauvisSeg` is specific to its `detail`/`persistance` terms and - * must not be routed in here. - */ -export function makeNauvisShared(params: NauvisSharedParams): NauvisShared { - const seed0 = params.seed0; - const seg = params.segmentationMultiplier ?? 1; - const nauvisSeg = 1.5 * seg; - - const hillsNoise = makeMultioctaveNoise({ - seed0, - seed1: 900, - octaves: 4, - persistence: 0.5, - inputScale: nauvisSeg / 90, - outputScale: 1, - }); - const cliffLevelTables: BasisNoiseTables = basisNoiseTablesFromSeed(seed0, 99584); - const bridgeBillowsNoise = makeMultioctaveNoise({ - seed0, - seed1: 700, - octaves: 4, - persistence: 0.5, - inputScale: nauvisSeg / 150, - outputScale: 1, - }); - const forestPathBillowsNoise = makeMultioctaveNoise({ - seed0, - seed1: 1800, - octaves: 4, - persistence: 0.5, - inputScale: nauvisSeg / 100, - outputScale: 1, - }); - - // The two `basis_noise` warp fields (`nauvis_hills_offset_raw_x/raw_y`), used to - // domain-warp the seed1=900 hills field into `nauvis_hills_offset`. input_scale - // = nauvisSeg / 500; string seed1s resolve to the crc32 constants above. - const offsetXTables: BasisNoiseTables = basisNoiseTablesFromSeed(seed0, NAUVIS_OFFSET_X_SEED1); - const offsetYTables: BasisNoiseTables = basisNoiseTablesFromSeed(seed0, NAUVIS_OFFSET_Y_SEED1); - const offsetInputScale = nauvisSeg / 500; - - const hills = (x: number, y: number): number => Math.abs(hillsNoise(x, y)); - - const cliffLevel = (x: number, y: number): number => - clamp( - 0.65 + - basisNoiseExpr( - x, - y, - { seed0, seed1: 99584, inputScale: nauvisSeg / 500, outputScale: 0.6 }, - cliffLevelTables, - ), - 0.15, - 1.15, - ); - - const plateaus = (x: number, y: number): number => - 0.5 + clamp((hills(x, y) - cliffLevel(x, y)) * 10, -0.5, 0.5); - - const bridgeBillows = (x: number, y: number): number => Math.abs(bridgeBillowsNoise(x, y)); - - const forestPathBillows = (x: number, y: number): number => - Math.abs(forestPathBillowsNoise(x, y)); - - // `normalize(primary, secondary, bias=0.001)` from noise-programs.lua: - // primary / sqrt(bias + primary^2 + secondary^2). - const normalize = (a: number, b: number): number => a / Math.sqrt(0.001 + a * a + b * b); - - const hillsOffset = (x: number, y: number): number => { - const rawX = basisNoise(x * offsetInputScale, y * offsetInputScale, offsetXTables); - const rawY = basisNoise(x * offsetInputScale, y * offsetInputScale, offsetYTables); - const nx = normalize(rawX, rawY); - const ny = normalize(rawY, rawX); - // Re-evaluate the seed1=900 octave field (same params as `hills`) at the WARPED - // coordinate - NOT the abs-wrapped `hills(x,y)`, which is memoized at (x,y). - return Math.abs(hillsNoise(x + 12 * nx, y + 12 * ny)); - }; - - const cliffRingbreak = (x: number, y: number): number => - Math.abs(hills(x, y) - hillsOffset(x, y)); - - return { - nauvisSeg, - hills, - cliffLevel, - plateaus, - bridgeBillows, - forestPathBillows, - hillsOffset, - cliffRingbreak, - }; -} diff --git a/src/noise/expressions/temperature.ts b/src/noise/expressions/temperature.ts deleted file mode 100644 index 4860a2c3..00000000 --- a/src/noise/expressions/temperature.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { clamp } from "../eval/math"; -import { makeQuickMultioctaveNoise } from "../quickMultioctaveNoise"; - -export interface TemperatureParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:temperature:frequency; default 1. */ - readonly frequency?: number; - /** control:temperature:bias; default 0. */ - readonly bias?: number; -} - -/** - * Compile the `temperature` (= `temperature_basic`) climate tree for one seed into - * a `(x, y) => temperature` evaluator: - * - * clamp(15 + bias + quick_multioctave_noise{...}, -20, 50) - * - * where `15` is the `sea_level_temperature` constant and the noise term is a - * 4-octave `quick_multioctave_noise` (seed1 = 5), input_scale = frequency/32, - * output_scale = 1/20, offset_x = 40000/frequency, octave_output_scale_multiplier - * = 3, octave_input_scale_multiplier = 1/3. Mirrors core/prototypes/noise-programs.lua. - */ -export function makeTemperature(params: TemperatureParams): (x: number, y: number) => number { - const seed0 = params.seed0; - const frequency = params.frequency ?? 1; - const bias = params.bias ?? 0; - - const quick = makeQuickMultioctaveNoise({ - seed0, - seed1: 5, - octaves: 4, - inputScale: frequency / 32, - outputScale: 1 / 20, - offsetX: 40000 / frequency, - octaveOutputScaleMultiplier: 3, - octaveInputScaleMultiplier: 1 / 3, - }); - - return (x: number, y: number): number => clamp(15 + bias + quick(x, y), -20, 50); -} diff --git a/src/noise/preview/elevationRenderRequest.ts b/src/noise/preview/elevationRenderRequest.ts index bba076ea..a2e4bd18 100644 --- a/src/noise/preview/elevationRenderRequest.ts +++ b/src/noise/preview/elevationRenderRequest.ts @@ -5,21 +5,8 @@ import type { VulcanusResourceControls } from "../eval/ctx"; import type { EnemyControls } from "../enemies/enemyCatalog"; import type { Planet } from "../../model/planets"; import { PLACEMENT_MARK_RADIUS_PX } from "../placement/placementRoll"; -import { NAUVIS_MAX_STARTING_POINTS } from "../wasm/request"; import type { ResourceControlLevers } from "../resources/resourceCatalog"; import type { RockControls } from "../rocks/rockCatalog"; -import { renderCliffs } from "./renderCliffs"; -import { renderElevation } from "./renderElevation"; -import { renderEnemies } from "./renderEnemies"; -import { renderResources } from "./renderResources"; -import { renderRocks } from "./renderRocks"; -import { renderTerrain } from "./renderTerrain"; -import { renderTrees } from "./renderTrees"; -import { renderVulcanusCliffs } from "./renderVulcanusCliffs"; -import { makeVulcanusStack } from "../tiles/vulcanusCatalog"; -import { renderVulcanusResources } from "./renderVulcanusResources"; -import { renderVulcanusRocks } from "./renderVulcanusRocks"; -import { renderVulcanusTerrain } from "./renderVulcanusTerrain"; import { renderFulgoraLandMask, renderFulgoraTerrain } from "./renderFulgoraTerrain"; import { renderThroughWasm, type EngineExports } from "../wasm/engine"; import { renderFulgoraResources } from "./renderFulgoraResources"; @@ -144,13 +131,6 @@ export interface ElevationRenderRequest { * renderer reads the levers the game does rather than hardcoding neutral. */ fulgoraScrapControls?: FulgoraScrapControls; - /** - * Escape hatch for `test/vulcanusStackCache.spec.ts`, which has to render the - * same request BOTH ways to prove the shared cached stack is byte-identical - * to per-renderer stacks. Defaults to the shared stack; there is no reason to - * set this outside that test. - */ - unsharedStacks?: boolean; /** * The enemy-base autoplace control's frequency/size (control:enemy-base:*) - * consumed only when `view: "enemies"`. Defaults to `{ frequency: 1, size: 1 }` @@ -501,6 +481,34 @@ function renderNauvisThroughWasm( return { id: req.id, buffer: owned.buffer, width: req.width, height: req.height }; } +/** + * What a render with no engine is refused with. + * + * Every planet but Fulgora goes through the module as of #227, so a missing + * engine stopped being a slower path and became no path at all. The worker + * queues requests until the handshake lands and fails them if it never does, so + * this fires only for a caller that assembled a request by hand without one. + */ +export const ENGINE_REQUIRED = "this render needs the WASM engine, and none was supplied"; + +/** The engine, or `ENGINE_REQUIRED`. */ +function requireEngine(engine: EngineExports | undefined): EngineExports { + if (engine === undefined) throw new Error(ENGINE_REQUIRED); + return engine; +} + +/** + * A `(planet, view)` pair with no renderer, named rather than numbered. + * + * Reachable only for a pair `servedView` does not normalise and the module does + * not serve, which today means a non-Nauvis `"elevation"`. The rest are type + * obligations: TypeScript cannot see that `servedView` has already removed + * them, so the branches need an exit even though nothing can take it. + */ +function unsupportedPair(planet: Planet, view: ElevationRenderRequest["view"]): string { + return `no renderer for planet ${planet}, view ${view ?? "elevation"}`; +} + /** * What a caller-supplied `startingLakePositions` is refused with. * @@ -599,7 +607,6 @@ export function runRenderRequest( } const planet = req.planet ?? "nauvis"; const view = servedView(planet, req.view); - let image: ImageData; if ( view === "terrain" || view === "resources" || @@ -611,104 +618,26 @@ export function runRenderRequest( view === "landmask" ) { if (planet === "vulcanus") { - // Vulcanus has its own resource and cliff overlays. The remaining three - // Nauvis overlays (enemies, trees, rocks) have no Vulcanus port, so a - // terrain-family view that asks for one still gets plain terrain rather - // than a Nauvis field composited onto Vulcanus colors. - const wantsResources = view === "resources" || view === "all"; - // Checked BEFORE the TypeScript stack is built, for the reason the - // Fulgora branch gives: `makeVulcanusStack` derives seed tables for the - // whole biome, crack, climate and elevation chain, and building them only - // to throw them away would be most of the saving. + // Every Vulcanus view the planet has is served by the module (#225). The + // TypeScript arm that used to sit here was the arm tier 3 compared + // against; #227 deletes it, so this is the only path now rather than the + // faster of two. // - // Every Vulcanus view the planet has is served here now (#225). What is - // left below is the TypeScript path, which stays because it is the arm - // tier 3 compares against - the two are byte-identical, so an engine that - // failed to load is slower and never wrong. + // `enemies`, `trees` and `landmask` cannot arrive here - `servedView` + // normalises all three onto `"terrain"` - so the throw below is a type + // obligation rather than a reachable state. It still names the pair, + // because an unexplained failure inside a worker is not something anyone + // diagnoses twice. if ( - engine !== undefined && - (view === "terrain" || - view === "cliffs" || - view === "rocks" || - view === "resources" || - view === "all") + view === "terrain" || + view === "cliffs" || + view === "rocks" || + view === "resources" || + view === "all" ) { - return renderVulcanusThroughWasm(req, engine, view); - } - // ONE stack for the whole composite. Two things make this pay, and both - // are needed: the overlays reuse the field objects terrain built, and - // those objects carry a cross-traversal cache (`memoRegion`), so a pass - // that walks the image in a different order from terrain - the rock - // overlay resolves whole 32x32 chunks - still hits values terrain - // computed. Sharing without the cache buys almost nothing, because - // `memoXY` holds only the last coordinate. - const stack = - req.unsharedStacks === true - ? undefined - : makeVulcanusStack( - { - seed0: req.seed0, - startingPositions: req.startingPositions, - vulcanusResourceControls: req.vulcanusResourceControls, - }, - { cacheShared: true }, - ); - image = renderVulcanusTerrain({ - seed0: req.seed0, - width: req.width, - height: req.height, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { - startingPositions: req.startingPositions, - vulcanusResourceControls: req.vulcanusResourceControls, - }, - stack, - }); - // Resources paint first, then the two obstruction overlays on top, so a - // cliff or a rock crossing an ore patch still reads as the thing that is - // in the way. Cliffs last matches the Nauvis order below, where - // renderCliffs is the final pass. - if (wantsResources) { - renderVulcanusResources(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { - startingPositions: req.startingPositions, - vulcanusResourceControls: req.vulcanusResourceControls, - }, - // The geyser rolls and paints a 3x3 mark; the three solid ores - // threshold and paint 1x1, and ignore this. - sweepBox: placementMarkSweepBox(req), - stack, - }); - } - if (view === "rocks" || view === "all") { - renderVulcanusRocks(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { startingPositions: req.startingPositions }, - sweepBox: placementMarkSweepBox(req), - sharedStack: stack, - }); - } - if (view === "cliffs" || view === "all") { - renderVulcanusCliffs(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { startingPositions: req.startingPositions }, - cellQueryBox: cliffCellQueryBox(req), - sharedStack: stack, - }); + return renderVulcanusThroughWasm(req, requireEngine(engine), view); } - return { id: req.id, buffer: image.data.buffer, width: req.width, height: req.height }; + throw new Error(unsupportedPair(planet, view)); } if (planet === "fulgora") { // The one path the Rust engine serves so far (#223). Checked BEFORE the @@ -727,10 +656,12 @@ export function runRenderRequest( islandsFrequency: req.fulgoraIslandControls?.frequency, islandsSize: req.fulgoraIslandControls?.size, }; - const stack = - req.unsharedStacks === true - ? undefined - : makeFulgoraStack({ seed0: req.seed0, ...fulgoraCtx }); + // Unconditional now. `unsharedStacks` existed only so + // `test/vulcanusStackCache.spec.ts` could compare a shared Vulcanus stack + // against an unshared one on the TypeScript path. That spec and that path + // both go with #227, and nothing ever set the flag on a Fulgora request, + // so this is a no-op for every caller. + const stack = makeFulgoraStack({ seed0: req.seed0, ...fulgoraCtx }); const fulgoraRender = { seed0: req.seed0, width: req.width, @@ -747,7 +678,7 @@ export function runRenderRequest( const mask = renderFulgoraLandMask(fulgoraRender); return { id: req.id, buffer: mask.data.buffer, width: req.width, height: req.height }; } - image = renderFulgoraTerrain(fulgoraRender); + const image = renderFulgoraTerrain(fulgoraRender); if (view === "resources" || view === "all") { renderFulgoraResources(image, { seed0: req.seed0, @@ -761,199 +692,56 @@ export function runRenderRequest( } return { id: req.id, buffer: image.data.buffer, width: req.width, height: req.height }; } - // Every Nauvis view the Rust engine serves. `"elevation"` is no longer the - // exception - it is served at the tail of this function under its own three - // `view` codes as of #227 - so this list is the seven TILE-family views and - // the elevation gate is separate because it paints a different palette. - // Checked BEFORE the TypeScript render rather than after, so the engine's - // work replaces it instead of being thrown away. + // Every Nauvis tile-family view, all of them served by the module. The + // TypeScript renderers this used to fall back to are deleted in #227, so + // the gate stops being a choice between two right answers and becomes the + // only answer. // - // The Nauvis block carries the spawn list as of #227, so a moved spawn no - // longer forces the TypeScript path - the module reads the same points and - // reaches the same distance terms. An over-long list is refused by the - // writer rather than silently shortened, so it cannot arrive here wrong. + // **The spawn-list cap is gone from the condition.** It used to divert an + // over-long list to TypeScript. Now the request writer refuses it by name - + // `startingPositions holds N points, over the ABI cap of 8` - which beats a + // silently different render, and `serve()` turns that throw into a failure + // for the one request rather than for every tile the worker was holding. // - // `startingLakePositions` no longer appears here: it is refused outright at - // the top of this function, so by the time the gate is evaluated there is - // nothing left to test. The spawn-list cap still forces the TypeScript path, - // and it is the last thing that does. + // `landmask` cannot arrive here either; `servedView` normalises it onto + // `"terrain"`, so the throw is a type obligation rather than a reachable + // state. if ( - engine !== undefined && - (view === "terrain" || - view === "trees" || - view === "rocks" || - view === "enemies" || - view === "cliffs" || - view === "resources" || - view === "all") && - req.startingPositions.length <= NAUVIS_MAX_STARTING_POINTS + view === "terrain" || + view === "trees" || + view === "rocks" || + view === "enemies" || + view === "cliffs" || + view === "resources" || + view === "all" ) { - return renderNauvisThroughWasm(req, engine, view); - } - image = renderTerrain({ - seed0: req.seed0, - width: req.width, - height: req.height, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - ctx: { - segmentationMultiplier: req.segmentationMultiplier, - startingPositions: req.startingPositions, - moistureFrequency: req.moistureFrequency, - moistureBias: req.moistureBias, - auxFrequency: req.auxFrequency, - auxBias: req.auxBias, - startingAreaMoistureSize: req.startingAreaMoistureSize, - startingAreaMoistureFrequency: req.startingAreaMoistureFrequency, - }, - }); - if (view === "trees" || view === "all") { - renderTrees(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - treesFrequency: req.treeControls?.frequency ?? 1, - treesSize: req.treeControls?.size ?? 1, - segmentationMultiplier: req.segmentationMultiplier, - moistureFrequency: req.moistureFrequency, - moistureBias: req.moistureBias, - temperatureFrequency: req.temperatureFrequency, - temperatureBias: req.temperatureBias, - startingAreaMoistureSize: req.startingAreaMoistureSize, - startingAreaMoistureFrequency: req.startingAreaMoistureFrequency, - startingPositions: req.startingPositions, - }); - } - if (view === "resources" || view === "all") { - renderResources(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - controls: req.resourceControls ?? {}, - startingPositions: req.startingPositions, - segmentationMultiplier: req.segmentationMultiplier, - waterLevel: req.waterLevel, - startingLakePositions: req.startingLakePositions, - moistureFrequency: req.moistureFrequency, - moistureBias: req.moistureBias, - auxFrequency: req.auxFrequency, - auxBias: req.auxBias, - startingAreaMoistureSize: req.startingAreaMoistureSize, - startingAreaMoistureFrequency: req.startingAreaMoistureFrequency, - sweepBox: placementMarkSweepBox(req), - }); + return renderNauvisThroughWasm(req, requireEngine(engine), view); } - // Rocks paint after resources (and cliffs last of all) so an obstruction - // crossing an ore patch reads as the obstruction - same order as the - // Vulcanus branch above. Trees stay under resources: a forest is cleared, - // not an obstacle you route around. - if (view === "rocks" || view === "all") { - renderRocks(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - controls: req.rockControls ?? { frequency: 1, size: 1 }, - segmentationMultiplier: req.segmentationMultiplier, - moistureFrequency: req.moistureFrequency, - moistureBias: req.moistureBias, - auxFrequency: req.auxFrequency, - auxBias: req.auxBias, - startingAreaMoistureSize: req.startingAreaMoistureSize, - startingAreaMoistureFrequency: req.startingAreaMoistureFrequency, - startingPositions: req.startingPositions, - sweepBox: placementMarkSweepBox(req), - }); - } - if (view === "enemies" || view === "all") { - renderEnemies(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - controls: req.enemyControls ?? { frequency: 1, size: 1 }, - startingPositions: req.startingPositions, - segmentationMultiplier: req.segmentationMultiplier, - moistureFrequency: req.moistureFrequency, - moistureBias: req.moistureBias, - auxFrequency: req.auxFrequency, - auxBias: req.auxBias, - startingAreaMoistureSize: req.startingAreaMoistureSize, - startingAreaMoistureFrequency: req.startingAreaMoistureFrequency, - sweepBox: placementMarkSweepBox(req), - }); - } - if (view === "cliffs" || view === "all") { - renderCliffs(image, { - seed0: req.seed0, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - controls: req.cliffControls ?? { frequency: 1, continuity: 1 }, - settings: req.cliffSettings ?? { - cliffElevation0: 10, - cliffElevationInterval: 40, - richness: 1, - }, - segmentationMultiplier: req.segmentationMultiplier, - waterLevel: req.waterLevel, - startingPositions: req.startingPositions, - startingLakePositions: req.startingLakePositions, - cellQueryBox: cliffCellQueryBox(req), - }); - } - } else { - // The elevation view, ported in #227. It rides the Nauvis param block, - // which already carries every lever the trees read - seed, water level, - // segmentation and the spawn list - so this needed three `view` codes and - // no layout change. `mapType` picks the code because the common prefix has - // no `mapType` field; see `VIEW` in `src/noise/wasm/request.ts`. - // - // **`startingLakePositions` is gone from this gate**, because it is refused - // at the top of the function now. It was a CORRECTNESS carve-out rather - // than a speed one - the module derives the lake list from the seed and the - // spawn, which is the game's own rule - and an ABI one besides, the request - // being a fixed-size struct with no room for a variable-length array. With - // the TypeScript arm going there is no path that could honour it, so the - // honest answer is to refuse rather than to ignore. - // - // **A non-Nauvis `planet` also stays on TypeScript.** `mapType` spans the - // Nauvis family only, and the branch below ignores `planet` outright, so - // routing an odd pairing through the module would change behaviour for no - // gain. `planet` defaults to `"nauvis"`, so the app never sends one. - if ( - engine !== undefined && - planet === "nauvis" && - req.startingPositions.length <= NAUVIS_MAX_STARTING_POINTS - ) { - return renderNauvisThroughWasm( - req, - engine, - req.mapType === "nauvis" - ? "elevationNauvis" - : req.mapType === "island" - ? "elevationIsland" - : "elevationLakes", - ); - } - image = renderElevation({ - seed0: req.seed0, - width: req.width, - height: req.height, - originX: req.originX, - originY: req.originY, - tilesPerPixel: req.tilesPerPixel, - mapType: req.mapType, - ctx: { - waterLevel: req.waterLevel, - segmentationMultiplier: req.segmentationMultiplier, - startingPositions: req.startingPositions, - startingLakePositions: req.startingLakePositions, - }, - }); + throw new Error(unsupportedPair(planet, view)); + } + // The elevation views. They ride the Nauvis param block, which already + // carries every lever the trees read - seed, water level, segmentation and + // the spawn list - so this needed three `view` codes and no layout change. + // `mapType` picks the code because the common prefix has no `mapType` field; + // see `VIEW` in `src/noise/wasm/request.ts`. + // + // **A non-Nauvis planet is refused rather than rendered.** `mapType` spans + // the Nauvis family only. This used to fall through to `renderElevation`, + // which ignored `planet` outright and painted the NAUVIS field under a + // Fulgora or Vulcanus label - a wrong answer that looked exactly like a + // right one. `test/elevationRenderRequest.spec.ts` pinned that as a KNOWN + // HOLE and said it should flip to asserting a refusal once there was one to + // assert; this is that refusal. + if (planet !== "nauvis") { + throw new Error(unsupportedPair(planet, "elevation")); } - return { id: req.id, buffer: image.data.buffer, width: req.width, height: req.height }; + return renderNauvisThroughWasm( + req, + requireEngine(engine), + req.mapType === "nauvis" + ? "elevationNauvis" + : req.mapType === "island" + ? "elevationIsland" + : "elevationLakes", + ); } diff --git a/src/noise/preview/renderCliffs.ts b/src/noise/preview/renderCliffs.ts deleted file mode 100644 index 8954e561..00000000 --- a/src/noise/preview/renderCliffs.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Composite the cliff footprint overlay onto a terrain ImageData: enumerate - * placed cliff cells over the pixel grid's world box (via `makeCliffPlacement`, - * T7), map each cell center to a pixel, and paint a small `CLIFF_MAP_COLOR` - * block (`CLIFF_MARK_SIZE_PX`) so the sparse 4-tile-grid footprint reads at - * preview scale; leave the terrain pixel untouched elsewhere. Mutates `base` in - * place. See M4 cliffs plan T9. - * - * Unlike renderResources/renderEnemies (which sweep every pixel and query a - * field), cliffs are placed on a sparse 4-tile grid - `placedCells` already - * enumerates just the placed cells over the box, so we map cell centers to - * pixels instead of sweeping. - * - * Cliffs never sit on water, so we skip any pixel the terrain drew as - * water/deepwater - the same `WATER_TILE_COLORS` water-skip renderResources - * and renderEnemies use, reused (not re-derived) so the cliff footprint edge - * lines up with the coastline the terrain already drew. - */ -import type { Point } from "../distanceFromNearestPoint"; -import { makeCliffPlacement } from "../cliffs/cliffPlacement"; -import { - CLIFF_MAP_COLOR, - CLIFF_MARK_BACK_PX, - CLIFF_MARK_SIZE_PX, - type CliffControls, - type CliffSettingsInput, -} from "../cliffs/cliffCatalog"; -import { WATER_TILE_COLORS } from "./renderResources"; - -/** - * The Nauvis tiles whose `CollisionMask` shares a layer with the cliff's - the - * planet's answer to `VULCANUS_CLIFF_BLOCKING_TILES`. `tile_collision_masks.water()` - * sets `water_tile`, which the cliff mask holds. - * - * **`renderCliffs` does not use this, on purpose.** It skips water-COLOURED - * pixels as it paints, which is cheaper (no tile resolution at all) and visually - * identical for as long as no Nauvis cliff's collision box actually touches - * water - which is measured, and now guarded, in `test/cliffPlacement.spec.ts`. - * If that guard ever fails, the placement pass here needs the real - * `tileCollides` rejection and this set is what to pass it. - */ -export const NAUVIS_CLIFF_BLOCKING_TILES: ReadonlySet = new Set(["water", "deepwater"]); - -export interface RenderCliffsOptions { - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - readonly controls: CliffControls; - readonly settings: CliffSettingsInput; - readonly segmentationMultiplier?: number; - readonly waterLevel?: number; - readonly startingPositions?: readonly Point[]; - readonly startingLakePositions?: readonly Point[]; - /** - * World box to enumerate placed cliff cells over. Defaults to the pixel grid's - * own world box. The tiled renderer widens this by CLIFF_MARK_BACK_PX tiles - * (clamped to the full image) so a cell centered just outside this tile still - * paints the part of its mark that falls inside - without it, cliff marks are - * clipped at tile seams. The paint loop already clips to the pixel grid, so a - * wider query cannot paint outside the tile. - */ - readonly cellQueryBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; -} - -/** - * Paint one square mark centred on a pixel, clipped to the image. Shared by the - * cliff painter and the placement-roll overlays; `skipPixel` is re-checked per - * painted pixel so a thickened mark still respects an exclusion (e.g. water). - * - * `mark`, when given, gets a 1 at the `width`-strided index of every pixel this - * call actually painted. `renderResources` uses it to protect crude oil's marks - * from the resources oil outranks (#22 item 3). It is an out-parameter rather - * than a second loop in the caller because the answer must agree with this - * function's *clipping and skipping* exactly, and a copy of that geometry is - * precisely the kind of thing that silently drifts. - */ -export function paintMark( - base: ImageData, - px: number, - py: number, - color: readonly [number, number, number], - radius: number, - skipPixel?: (r: number, g: number, b: number) => boolean, - mark?: Uint8Array, -): void { - const { width, height } = base; - for (let dy = -radius; dy <= radius; dy++) { - const y = py + dy; - if (y < 0 || y >= height) continue; - for (let dx = -radius; dx <= radius; dx++) { - const x = px + dx; - if (x < 0 || x >= width) continue; - const o = (y * width + x) * 4; - if (skipPixel?.(base.data[o], base.data[o + 1], base.data[o + 2]) === true) continue; - base.data[o] = color[0]; - base.data[o + 1] = color[1]; - base.data[o + 2] = color[2]; - base.data[o + 3] = 255; - if (mark !== undefined) mark[y * width + x] = 1; - } - } -} - -/** - * Paint the block for one cliff cell, anchored on the cell's own footprint - * rather than centred on its pixel: `px - CLIFF_MARK_BACK_PX` through - * `px + CLIFF_MARK_SIZE_PX - CLIFF_MARK_BACK_PX - 1`, inclusive. - * - * **Why not `paintMark`.** That paints a `(2r+1)x(2r+1)` block, which is always - * odd-sided, and the size that makes cliff cells tile exactly at 1 tile/px is the - * EVEN 4 - cell centres are 4px apart there. The old 5x5 overlapped each - * neighbour by a pixel and read a pixel too thick; 3x3 falls a pixel short and - * dashes the ridgelines. Neither is expressible as a radius, so cliffs get their - * own painter and `paintMark` stays the odd-sided one the placement overlays use. - */ -function paintCellBlock( - base: ImageData, - px: number, - py: number, - color: readonly [number, number, number], - skipPixel?: (r: number, g: number, b: number) => boolean, -): void { - const { width, height } = base; - const lo = CLIFF_MARK_BACK_PX; - const hi = CLIFF_MARK_SIZE_PX - CLIFF_MARK_BACK_PX - 1; - for (let dy = -lo; dy <= hi; dy++) { - const y = py + dy; - if (y < 0 || y >= height) continue; - for (let dx = -lo; dx <= hi; dx++) { - const x = px + dx; - if (x < 0 || x >= width) continue; - const o = (y * width + x) * 4; - if (skipPixel?.(base.data[o], base.data[o + 1], base.data[o + 2]) === true) continue; - base.data[o] = color[0]; - base.data[o + 1] = color[1]; - base.data[o + 2] = color[2]; - base.data[o + 3] = 255; - } - } -} - -/** - * Paint one `CLIFF_MAP_COLOR` mark per placed cell center. Shared with the - * Vulcanus renderer, which passes no `skipPixel` because Vulcanus has no water - * tile to keep the footprint off. - */ -export function paintCliffCells( - base: ImageData, - cells: readonly { x: number; y: number }[], - opts: { - readonly originX: number; - readonly originY: number; - readonly tilesPerPixel: number; - readonly skipPixel?: (r: number, g: number, b: number) => boolean; - }, -): void { - const { originX, originY, tilesPerPixel: tpp, skipPixel } = opts; - for (const { x: wx, y: wy } of cells) { - const cx = Math.floor((wx - originX) / tpp); - const cy = Math.floor((wy - originY) / tpp); - paintCellBlock(base, cx, cy, CLIFF_MAP_COLOR, skipPixel); - } -} - -export function renderCliffs(base: ImageData, opts: RenderCliffsOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const isWater = (r: number, g: number, b: number): boolean => { - for (const [wr, wg, wb] of WATER_TILE_COLORS) { - if (r === wr && g === wg && b === wb) return true; - } - return false; - }; - - const placement = makeCliffPlacement({ - seed0: opts.seed0, - controls: opts.controls, - settings: opts.settings, - segmentationMultiplier: opts.segmentationMultiplier, - waterLevel: opts.waterLevel, - startingPositions: opts.startingPositions, - startingLakePositions: opts.startingLakePositions, - }); - - const box = opts.cellQueryBox ?? { - x0: originX, - y0: originY, - x1: originX + width * tpp, - y1: originY + height * tpp, - }; - const cells = placement.placedCells(box.x0, box.y0, box.x1, box.y1); - - paintCliffCells(base, cells, { - originX, - originY, - tilesPerPixel: tpp, - skipPixel: isWater, - }); -} diff --git a/src/noise/preview/renderElevation.ts b/src/noise/preview/renderElevation.ts deleted file mode 100644 index 9458edf2..00000000 --- a/src/noise/preview/renderElevation.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { makeElevationLakes, type ElevationLakesParams } from "../expressions/elevationLakes"; -import { makeElevationNauvis } from "../expressions/elevationNauvis"; -import { makeElevationIsland } from "../expressions/elevationIsland"; -import { LAND_RGBA, WATER_RGBA } from "./palette"; - -export interface RenderElevationOptions { - /** Map seed (= map_seed / seed0). Callers resolve a null "random" seed first. */ - readonly seed0: number; - /** Output pixel dimensions. */ - readonly width: number; - readonly height: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** Which elevation tree to render. Default "lakes". */ - readonly mapType?: "lakes" | "nauvis" | "island"; - /** Non-seed tree params (waterLevel, segmentationMultiplier, spawn/lake points). */ - readonly ctx?: Omit; -} - -/** - * Sweep a `width x height` pixel grid over world space and return an `ImageData` - * whose pixels are {@link WATER_RGBA} where `elevation_lakes < 0` and - * {@link LAND_RGBA} otherwise. The tree evaluator is compiled once (heavy octave - * closures built up front) and reused across pixels. - * - * Near-spawn fidelity: the render computes the game's real starting lake positions - * (see startingLakes.ts) by default, so the coastline is faithful near spawn as - * well as far out. Callers may still pass an explicit `ctx.startingLakePositions` - * (including `[]`) to override. - */ -export function renderElevation(opts: RenderElevationOptions): ImageData { - const { width, height } = opts; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const make = - opts.mapType === "nauvis" - ? makeElevationNauvis - : opts.mapType === "island" - ? makeElevationIsland - : makeElevationLakes; - const evalAt = make({ seed0: opts.seed0, ...opts.ctx }); - const data = new Uint8ClampedArray(width * height * 4); - - for (let py = 0; py < height; py++) { - const wy = originY + py * tpp; - for (let px = 0; px < width; px++) { - const wx = originX + px * tpp; - const rgba = evalAt(wx, wy) < 0 ? WATER_RGBA : LAND_RGBA; - const o = (py * width + px) * 4; - data[o] = rgba[0]; - data[o + 1] = rgba[1]; - data[o + 2] = rgba[2]; - data[o + 3] = rgba[3]; - } - } - - return new ImageData(data, width, height); -} diff --git a/src/noise/preview/renderEnemies.ts b/src/noise/preview/renderEnemies.ts deleted file mode 100644 index e05860f2..00000000 --- a/src/noise/preview/renderEnemies.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Composite the enemy-base overlay onto a terrain ImageData: sweep the same pixel - * grid as renderTerrain/renderResources, roll the game's per-tile placement draw - * against the spawner probability, and paint a `PLACEMENT_MARK_RADIUS_PX` (3x3) - * mark in `ENEMY_MAP_COLOR` wherever it wins. Mutates `base` in place. - * - * This rolls rather than thresholds. The old render painted every pixel where - * `enemy_base_probability` cleared a 0.05 footprint threshold, which drew the - * *shape of a base's cone* rather than the spawners inside it. The game instead - * rolls `U < probability` per tile (`docs/noise/placement-roll-NOTES.md`) subject - * to two arbitration gates, and `makePlacementSet` reproduces all three. - * - * Unlike rocks, the mark stays 3x3: a spawner is a 7.4 x 6.4-tile entity and this - * overlay places ~1 per 1700-10000 tiles, so a 1px dot would be invisible. - * A 3x3 mark can spill across a worker-tile seam, so - exactly like cliffs and - * like Vulcanus rocks before they went to 1x1 - the tiled renderer hands in a - * halo-widened `sweepBox` (`elevationRenderRequest.ts`'s `placementMarkSweepBox`). - * Without it `test/tiledEquality.spec.ts` fails at the seams. - * - * The water skip is now TWO separate things, and the distinction matters: - * - * - `tileAllowed` inside `makeNauvisEnemyPlacement` is the CORRECTNESS gate. It is - * derived from the ported tile resolver, so it is a pure function of world - * position - required, because the chunk resolver asks about tiles outside the - * render window where no pixel exists to read. - * - the `isWater` pixel check passed to `paintMark` is only a PAINT guard, so the - * mark's outer ring does not spill red onto a lake the terrain already drew. - */ -import type { Point } from "../distanceFromNearestPoint"; -import { makeEnemyBaseField } from "../enemies/enemyBaseField"; -import { - ENEMY_RANDOM_PENALTY_AMPLITUDE, - ENEMY_SPAWNER_MAP_GEN_BOX, - ENEMY_MAP_COLOR, - type EnemyControls, -} from "../enemies/enemyCatalog"; -import { - PLACEMENT_MARK_RADIUS_PX, - PLACEMENT_SALT, - makePlacementRoll, - makePlacementSet, -} from "../placement/placementRoll"; -import { makeTileResolver } from "../tiles/resolve"; -import { paintMark } from "./renderCliffs"; -import { WATER_TILE_COLORS } from "./renderResources"; - -export interface RenderEnemiesOptions { - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - readonly controls: EnemyControls; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; - /** - * Climate/terrain params, threaded only into the water gate's tile resolver so - * the gate agrees with the terrain the same request drew. All default to the - * game's defaults. - */ - readonly segmentationMultiplier?: number; - readonly moistureFrequency?: number; - readonly moistureBias?: number; - readonly auxFrequency?: number; - readonly auxBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; - /** - * World box to sweep for roll hits. Defaults to this render's own pixel box. - * The tiled renderer widens it by `PLACEMENT_MARK_RADIUS_PX` tiles (clamped to - * the full image) so a hit centered just outside this tile still paints the - * part of its mark that falls inside. `paintMark` clips to the pixel grid, so a - * wider sweep can never paint outside this tile's own bounds. - */ - readonly sweepBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; -} - -export interface NauvisEnemyPlacementParams { - readonly seed0: number; - readonly controls: EnemyControls; - readonly startingPositions?: readonly Point[]; - readonly segmentationMultiplier?: number; - readonly moistureFrequency?: number; - readonly moistureBias?: number; - readonly auxFrequency?: number; - readonly auxBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; -} - -/** - * The two tiles no spawner may sit on. - * - * Neither spawner declares a `tile_restriction`, and neither overrides - * `collision_mask`; `type = "unit-spawner"` defaults to `building()` - * (`core/lualib/collision-mask-defaults.lua:67`), which includes `water_tile`. So - * the gate is "the tile is not water" - the same gate, from the same default, as - * the Nauvis rocks overlay - and it is shared by both prototypes, which is the - * precondition `resolveChunk`'s doc comment requires before a single - * probability-then-restriction test may stand in for the game's - * arbitrate-then-roll order. - */ -const WATER_TILE_NAMES = new Set(["water", "deepwater"]); - -/** - * The spawner GROUP's arbitrated probability at a tile: `max` over the two - * spawners of `random_penalty{min(enemy_base_probability, 0.25), amplitude 0.1}`, - * which is `source - 0.1 * min(U_biter, U_spitter)`, floored at 0. - * - * Exported so `test/entityDensity.spec.ts`'s ungated roll-vs-field-integral check - * - the claim that holds independent of the two gates - integrates the same - * field the renderer rolls against, rather than the un-penalised - * `makeEnemyBaseField(...).probability`. - * - * See `makeNauvisEnemyPlacement` for where the penalty comes from and what - * leaving it out measures. - */ -export function makeNauvisEnemyProbability( - params: NauvisEnemyPlacementParams, -): (x: number, y: number) => number { - const field = makeEnemyBaseField({ - seed0: params.seed0, - controls: params.controls, - startingPositions: params.startingPositions, - }); - const biterPenalty = makePlacementRoll(PLACEMENT_SALT.enemyBiterPenalty); - const spitterPenalty = makePlacementRoll(PLACEMENT_SALT.enemySpitterPenalty); - // Floored at 0 because random_penalty can drive a small source negative, and a - // negative probability simply never wins the roll. - return (x, y) => - Math.max( - 0, - field.probability(x, y) - - ENEMY_RANDOM_PENALTY_AMPLITUDE * Math.min(biterPenalty(x, y), spitterPenalty(x, y)), - ); -} - -/** - * The shipped Nauvis enemy-base placement predicate: the roll against the - * spawner group's probability, gated by the water restriction and by collision - * against spawners already placed in the same chunk. Exported so - * `test/entityDensity.spec.ts` measures the exact predicate the renderer paints. - * - * ## What the two spawners actually declare (from source, 2.1.12) - * - * | | biter-spawner | spitter-spawner | - * | --- | --- | --- | - * | autoplace | `enemy_autoplace_base(0, 6)` | `enemy_autoplace_base(0, 7)` | - * | autoplace order | `b[enemy]-a[spawner]` | `b[enemy]-a[spawner]` | - * | collision_box | 4.4 x 4.4 | 4.4 x 4.4 | - * | map_generator_bounding_box | **7.4 x 6.4** | **7.4 x 6.4** | - * | tile_restriction | none | none | - * | collision_mask | `building()` default | `building()` default | - * - * **The argmax box question is fully degenerate here, and for the strongest - * possible reason: the two prototypes declare the SAME box.** No rule - argmax, - * uniform-biter, uniform-spitter - can differ. That is a different degeneracy - * from the Nauvis rock one (three distinct boxes that happen to collapse onto the - * same lattice neighbourhood) and from the Vulcanus one (an ordering theorem); - * this overlay simply has one box. The live question was `collision_box` vs - * `map_generator_bounding_box`, and the prototype API settles it: the latter is - * "used instead of the collision box during map generation". Measurement agrees - - * see the table below. - * - * ## The probability is NOT `enemy_base_probability` - * - * `enemy_autoplace_base(0, seed)` wraps `min(enemy_base_probability, 0.25)` in - * `random_penalty{x = x + seed, amplitude = 0.1}`, so each spawner's probability - * is `source - 0.1*U`, and the group's arbitrated winner is the max of the two, - * i.e. `source - 0.1*min(U_biter, U_spitter)`. - * - * `random_penalty` is a batch op whose stream depends on the batch order - * (`randomPenalty.ts`), so the two `U`s here are deterministic per-tile stand-ins - * drawn from the same taus88 chunk machinery as the placement roll, under their - * own salts. **The distribution is exact; the positional identity is not** - the - * identical compromise `PLACEMENT_SALT` already documents. Positions were never - * claimed to match; density is. - * - * ## Why the spawners are treated as a group of two, and worms are ignored - * - * `enemy_worm_autoplace` puts the four worms at order `b[enemy]-b[worm]` while - * both spawners share `b[enemy]-a[spawner]`, and the notes' arbitration section - * records that `generateEntities` processes autoplacers in groups sorted by a - * name `memcmp`. Per-group arbitration is not merely convenient here, it is - * FORCED by the fixture: `behemoth-worm-turret` is `enemy_autoplace_base(8, 5)`, - * whose cap is `0.25 + 8*0.05 = 0.65` and whose multiplier at region 1's distance - * (~5800 tiles) is `1 + 0.016*(5793 - 2646) = 51`, so a single GLOBAL - * max-probability arbitration would hand essentially every enemy tile out there - * to a behemoth worm and leave ~0 spawners. The game has 142. So the spawner - * group arbitrates among its own two members, and this overlay models exactly - * that. - * - * ## Measured, against `test/fixtures/oracle-entity-counts.seed123456.json` - * - * Factorio 2.1.12, seed 123456, `biter-spawner + spitter-spawner` summed: - * - * | variant | region 0 `[0,0]` (game 19) | region 1 `[4096,4096]` (game 142) | - * | --- | --- | --- | - * | bare roll, no gates, no penalty | 284 (1394.7%) | 7763 (5366.9%) | - * | + water restriction only | 284 (1394.7%) | 1704 (1100.0%) | - * | + collision only, map-gen box | 36 (89.5%) | 730 (414.1%) | - * | + both gates, `collision_box` 4.4 x 4.4 | 61 (221.1%) | 290 (104.2%) | - * | + both gates, map-gen box | 36 (89.5%) | 167 (17.6%) | - * | **+ both gates, map-gen box, + penalty (shipped)** | **28 (47.4%)** | **157 (10.6%)** | - * - * Two things that table settles. The map-gen box beats the collision box in both - * regions by a wide margin, confirming the API doc rather than assuming it. And - * `random_penalty` removes 47% of region 0's overshoot (17 -> 9 spawners above the - * game's 19) and 40% of region 1's (25 -> 15 above 142) - it is not a rounding - * detail. Stated against the overshoot, in the same units as the "points" above, - * because the count reduction (8/36 and 10/167, i.e. 22% and 6%) says nothing - * about agreement. - * - * **The last row is salt-dependent and the spread is worth knowing before anyone - * reads it as precise.** Re-running it over six different penalty salt pairs - * gives 27-28 in region 0 (rel 0.42-0.47) and 149-157 in region 1 (rel - * 0.049-0.106); the shipped pair happens to sit at the top of both ranges. The - * band in `test/entityDensity.spec.ts` is pinned to the shipped constants, which - * are fixed, so the test is deterministic - but every one of those six pairs - * passes that band, so **a salt change is absorbed silently** even though it is a - * real ~5-point move. The band has power over the physics, not over this choice. - * - * ## Region 0 is a STOP-AND-REPORT, not a passing region - * - * 47.4% is past this project's 0.3 report threshold and `test/entityDensity.spec.ts` - * deliberately does NOT pin a `rel` band for it. The cause was measured, not - * guessed: spawners sort at `b[enemy]-a[spawner]`, while trees (`a[tree]-...`) - * and rocks (`a[landscape]-c[rock]-...`) sort BEFORE them, so under the same - * per-group sequential processing that the worm argument above forces, trees and - * rocks take their tiles first and a spawner's large box cannot fit beside them. - * Sweeping this app's own tree density and rock placement over the two regions - * and excluding the tiles they occupy: - * - * | | region 0 | region 1 | - * | --- | --- | --- | - * | area excluded by trees | 34.3% | 10.9% | - * | area excluded by rocks | 3.8% | 1.3% | - * | spawners with those blockers applied | 19 (0.0%) | 155 (9.2%) | - * - * So the residual is the forest, and it is ~3x larger in the near-spawn region - * because that region is ~3x more wooded. Region 0 landing exactly on 19 is not - * evidence of a precise model - the salt spread above is wider than that - but - * the direction and the ~3x asymmetry are the point. - * - * This is NOT modelled here. Doing it means running a tree placement roll that - * has never been validated against anything (the trees overlay renders expected - * coverage and never places), inside the enemy chunk resolver, at roughly 2x the - * current cost. That is a cross-overlay task, not a band. - */ -export function makeNauvisEnemyPlacement( - params: NauvisEnemyPlacementParams, -): (x: number, y: number) => boolean { - const tileAt = makeTileResolver({ - seed0: params.seed0, - segmentationMultiplier: params.segmentationMultiplier, - moistureFrequency: params.moistureFrequency, - moistureBias: params.moistureBias, - auxFrequency: params.auxFrequency, - auxBias: params.auxBias, - startingAreaMoistureSize: params.startingAreaMoistureSize, - startingAreaMoistureFrequency: params.startingAreaMoistureFrequency, - startingPositions: [...(params.startingPositions ?? [{ x: 0, y: 0 }])], - }); - - return makePlacementSet({ - salt: PLACEMENT_SALT.enemyBases, - probability: makeNauvisEnemyProbability(params), - tileAllowed: (x, y) => !WATER_TILE_NAMES.has(tileAt(x, y).name), - collisionBox: () => ENEMY_SPAWNER_MAP_GEN_BOX, - }); -} - -export function renderEnemies(base: ImageData, opts: RenderEnemiesOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const placed = makeNauvisEnemyPlacement({ - seed0: opts.seed0, - controls: opts.controls, - startingPositions: opts.startingPositions, - segmentationMultiplier: opts.segmentationMultiplier, - moistureFrequency: opts.moistureFrequency, - moistureBias: opts.moistureBias, - auxFrequency: opts.auxFrequency, - auxBias: opts.auxBias, - startingAreaMoistureSize: opts.startingAreaMoistureSize, - startingAreaMoistureFrequency: opts.startingAreaMoistureFrequency, - }); - - const isWater = (r: number, g: number, b: number): boolean => { - for (const [wr, wg, wb] of WATER_TILE_COLORS) { - if (r === wr && g === wg && b === wb) return true; - } - return false; - }; - - // Local pixel range to sweep - the image's own bounds by default, widened by - // the halo when `sweepBox` is given. The world->local division is exact: - // `sweepBox` is always originX/originY plus an integer multiple of tpp (see - // `placementMarkSweepBox`), the same guarantee `cliffCellQueryBox` relies on. - const box = opts.sweepBox; - const pxStart = box ? Math.round((box.x0 - originX) / tpp) : 0; - const pxEnd = box ? Math.round((box.x1 - originX) / tpp) : width; - const pyStart = box ? Math.round((box.y0 - originY) / tpp) : 0; - const pyEnd = box ? Math.round((box.y1 - originY) / tpp) : height; - - for (let py = pyStart; py < pyEnd; py++) { - const wy = originY + py * tpp; - for (let px = pxStart; px < pxEnd; px++) { - const wx = originX + px * tpp; - if (!placed(wx, wy)) continue; - paintMark(base, px, py, ENEMY_MAP_COLOR, PLACEMENT_MARK_RADIUS_PX, isWater); - } - } -} diff --git a/src/noise/preview/renderResources.ts b/src/noise/preview/renderResources.ts deleted file mode 100644 index 468f1ae5..00000000 --- a/src/noise/preview/renderResources.ts +++ /dev/null @@ -1,388 +0,0 @@ -/** - * Composite the resource-patch overlay onto a terrain ImageData: sweep the same - * pixel grid as renderTerrain, and where a resource wins, paint its `map_color` - * opaque; leave the terrain pixel untouched elsewhere. Mutates `base` in place. - * See M3a plan T7. - * - * **Two placement modes, because one resource is not a patch.** The catalog's - * `placement` field picks per entry (`resourceCatalog.ts`): - * - * - The four solids and uranium THRESHOLD. Their `random_probability` is 1, so - * the probability is `clamp(all_patches, 0, 1)`, which saturates to 1 inside a - * patch and is 0 outside - `>= 0.5` is the patch boundary. - * - Crude oil ROLLS. It is the only entry whose `random_probability` is not 1 - * (1/48), which multiplies its probability by - * `random_penalty{source = 1, amplitude = 48}` - a factor that is positive on - * only about one tile in 48. Thresholding that paints the whole patch extent - * as solid ore: 1234 tiles in `[0,0]-[512,512]` where the game has **8** oil - * wells. See `makeNauvisOilPlacement`. - * - * **Paint order: oil marks first, then the thresholded resources over the top, - * except where oil outranks the winner.** Oil's autoplace order is "c", so the - * four solids ("b") must win a shared pixel, and painting them last reproduces - * that without a colour test. Uranium is the exception: it is also "c" but sorts - * *after* oil (`patchSetIndex` 5 vs 4), so an unguarded overwrite hides a well the - * game would show. `oilMark` records the pixels pass 1 painted and pass 2 declines - * to overwrite them when {@link comparePriority} puts oil first - the same - * comparison the resolver itself sorts by, rather than a second copy of the rule. - * - * **This guard was added because the "it never happens" premise was measured and - * refuted** (#22 item 3, 2026-08-10). The zero it rested on - over - * `[-2048,-2048]-[2048,2048]` at seed 123456 the oil and uranium footprints cover - * 39869 and 10733 tiles and share **0** - reproduces exactly, and is a property of - * that seed, not of the geometry: - * - * | sweep (default controls) | result | - * | --- | --- | - * | 256 windows of 4096^2 (4.3e9 tiles), 128 seeds near spawn + 128 far field | 5 windows (2.0%) have overlapping footprints, 2 of them in the same `[-2048, 2048)^2` box | - * | 1024 windows of 4096^2 (1.7e10 tiles), 290,335 oil wells | **7 wells overwritten, 5 of them completely** (seeds 2980111949, 847539870, 1748438780) | - * - * So at default controls a hidden well is a ~1-in-41,000 event, and it is *not* - * rare once the map-gen sliders move: at 600% frequency and size - a setting the - * game itself offers - seed 123456 hides two wells inside `[-1024, 1024)^2` alone. - * `test/renderResourcesPaintOrder.spec.ts` pins one case of each. - * - * The cost objection this comment used to record ("a per-pixel guard would cost - * every pixel") does not apply to the guard that landed: `oilMark` is allocated - * lazily on the first oil hit, so a window with no oil pays nothing, and the test - * is one `Uint8Array` read on the pixels where a resource already won. Measured - * rather than argued, on a 512x512-pixel render at 4 tiles/px (median of 9, two - * interleaved rounds): **1928 / 1961 ms with the guard stripped out against - * 1937 / 1862 ms with it in**. The sign flips between rounds, so the cost is - * below this render's run-to-run noise - do not quote a percentage from it. - * - * Resources collide with water, so ore is never placed on a water tile. The - * threshold pass skips any pixel the terrain drew as water/deepwater (which also - * skips the expensive resolver there), reusing renderTerrain's exact water - * decision so the ore edge lines up with the coastline already drawn. Oil's roll - * instead gates on the ported tile resolver, because its chunk collision pass - * asks about tiles outside the render window, where there are no pixels to read. - */ -import type { Point } from "../distanceFromNearestPoint"; -import { - PLACEMENT_MARK_RADIUS_PX, - PLACEMENT_SALT, - makePlacementRoll, - makePlacementSet, -} from "../placement/placementRoll"; -import type { PlacementCollisionBox } from "../placement/placementRoll"; -import { RESOURCE_CATALOG, type ResourceControlLevers } from "../resources/resourceCatalog"; -import { makeResourcePatches } from "../resources/resourcePatches"; -import { comparePriority, makeResourceResolver } from "../resources/resolveResource"; -import { makeTileResolver } from "../tiles/resolve"; -import { paintMark } from "./renderCliffs"; - -/** - * The Nauvis water tiles' `map_color` RGB (deepwater, water) - mirrors catalog.ts. - * Resources are excluded from these tiles. A drift-guard test (renderResources.spec) - * asserts these still match the catalog. - */ -export const WATER_TILE_COLORS: readonly (readonly [number, number, number])[] = [ - [38, 64, 73], // deepwater - [51, 83, 95], // water -]; - -/** - * The Nauvis tiles no resource may sit on, by name. - * - * **Derived from the collision mask.** `type = "resource"` defaults to a - * `{layers = {resource = true}}` collision mask - * (`core/lualib/collision-mask-defaults.lua:187`), and on Nauvis the tile masks - * carrying `resource = true` are `water()` and `shallow_water()` - * (`base/prototypes/tile/tile-collision-masks.lua:22`, `:51`). The default-Nauvis - * tile catalog can only produce `water` and `deepwater` - * (`src/noise/tiles/catalog.ts`), so those two are the whole set here - the same - * set the rock and enemy overlays use, and the same one the threshold pass - * expresses as pixel colours. - */ -const WATER_TILE_NAMES = new Set(["water", "deepwater"]); - -/** - * Crude oil's `collision_box`, 2.8 x 2.8 tiles - * (`base/prototypes/entity/resources.lua:262`: `{{-1.4,-1.4},{1.4,1.4}}`). - * - * **Checked for `map_generator_bounding_box`, which is absent** - a grep over - * `base/`, `core/` and `space-age/` at 2.1.12 returns eight declarations and not - * one is a `resource`. That field overrides the collision box during map - * generation and cost Task 6 87-132 points when it was missed, so it is checked - * per overlay rather than assumed. Oil and the sulfuric-acid geyser turn out to - * declare the *same* box, which is a coincidence of two prototypes rather than a - * rule about resources. - */ -const OIL_COLLISION_BOX: PlacementCollisionBox = { w: 2.8, h: 2.8 }; - -/** `random_penalty` amplitude for oil: `1 / random_probability` = 1 / (1/48). */ -const OIL_PENALTY_AMPLITUDE = 48; - -/** Inputs shared by oil's probability and its gated placement predicate. */ -export interface NauvisOilPlacementParams { - readonly seed0: number; - readonly controls?: Record; - readonly startingPositions?: readonly Point[]; - readonly segmentationMultiplier?: number; - readonly moistureFrequency?: number; - readonly moistureBias?: number; - readonly auxFrequency?: number; - readonly auxBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; -} - -const DEFAULT_LEVERS: ResourceControlLevers = { frequency: 1, size: 1, richness: 1 }; -/** The regular set shares one candidate stream across all 6 resources. */ -const REGULAR_SKIP_SPAN = 6; -/** The starting set shares one candidate stream across the 4 solids. */ -const STARTING_SKIP_SPAN = 4; - -/** - * Crude oil's `entity:crude-oil:probability`, the game's expression from - * `core/lualib/resource-autoplace.lua:103-105` (2.1.12): - * - * ```lua - * probability_expression = "clamp(var(''), 0, 1)" - * if (params.random_probability or 1) < 1 then - * probability_expression = probability_expression - * .. "* random_penalty{x = x, y = y, source = 1, amplitude = 1 /" .. params.random_probability .. "}" - * ``` - * - * so with `random_probability = 1/48`: - * - * ``` - * probability = clamp(all_patches, 0, 1) * random_penalty{source = 1, amplitude = 48} - * = clamp(all_patches, 0, 1) * (1 - 48 * U) - * ``` - * - * **`U` comes from a dedicated placement-roll stream, not from the game's batch.** - * `random_penalty` is a batch op whose draw order depends on the evaluation - * batch's extent and starting position (`docs/noise/random-penalty-NOTES.md`), and - * this port does not reproduce the noise path's batching. It does not have to: - * `source = 1` is constant and strictly positive, so every tile consumes exactly - * one draw and each tile's `U` is marginally uniform on [0, 1) regardless of how - * the batch is cut. Density is a sum of per-tile marginals and is therefore - * batch-invariant; only *which* tile gets which draw changes, and positions are - * explicitly not claimed (`test/entityDensity.spec.ts`). This is the same - * stand-in the two spawner penalties use (`renderEnemies.ts`, Task 6). - * - * The result is floored at 0 because `1 - 48 * U` is negative for `U > 1/48` - - * a negative probability simply never wins the roll, exactly as in the game. - * - * **The factor costs a factor of 96, not 48.** `1 - 48U` is positive only for - * `U < 1/48`, and its mean over that range is 1/2, so the expected probability is - * `clamp / 96`. Confirmed against the field sum: over `[0,0]-[512,512]`, - * `sum(clamp)` is 1234.0 and `sum(penalised)` is 13.1 against the predicted - * 1234/96 = 12.85. - */ -export function makeNauvisOilProbability( - params: NauvisOilPlacementParams, -): (x: number, y: number) => number { - const oil = RESOURCE_CATALOG.find((p) => p.name === "crude-oil"); - if (oil === undefined) throw new Error("crude-oil missing from RESOURCE_CATALOG"); - const levers = params.controls?.[oil.controlName] ?? DEFAULT_LEVERS; - const patches = makeResourcePatches(oil, { - seed0: params.seed0, - controls: levers, - startingPositions: params.startingPositions, - segmentationMultiplier: params.segmentationMultiplier, - regularSkipSpan: REGULAR_SKIP_SPAN, - regularSkipOffset: oil.patchSetIndex, - startingSkipSpan: STARTING_SKIP_SPAN, - startingSkipOffset: oil.patchSetIndex, - }); - const penalty = makePlacementRoll(PLACEMENT_SALT.crudeOilPenalty); - return (x, y) => - Math.max(0, patches.probability(x, y) * (1 - OIL_PENALTY_AMPLITUDE * penalty(x, y))); -} - -/** - * The shipped crude-oil placement predicate: the roll against - * {@link makeNauvisOilProbability}, gated by the water tile restriction and by - * collision against oil already placed in the same chunk. Exported so - * `test/entityDensity.spec.ts` measures the exact predicate the renderer paints. - * - * ## The prototype data, from source (2.1.12) - * - * | | crude-oil | - * | --- | --- | - * | type | `resource` | - * | autoplace order | `c` (the four solids are `b`; uranium is also `c`) | - * | random_probability | **1/48** - the only one in the catalog below 1 | - * | collision_box | 2.8 x 2.8 | - * | map_generator_bounding_box | **not declared** - so the collision box is the map-gen box | - * | tile_restriction | none - the water gate comes from the collision MASK | - * - * The prototype carries the developer comment that the sulfuric-acid geyser's - * `order = "c"` copies almost verbatim: *"Other resources are 'b'; oil won't get - * placed if something else is already there."* This is where that sentence - * originates, and it is the same textual evidence for sequential shared space - * that `docs/noise/placement-roll-NOTES.md` records for the geyser. - * - * ## Measured, against `test/fixtures/oracle-entity-counts.seed123456.json` - * - * Factorio 2.1.12, seed 123456. - * - * | variant | region 0 `[0,0]` (game 8) | region 1 `[4096,4096]` (game 0) | - * | --- | --- | --- | - * | old threshold footprint | 1234 | 248 | - * | roll, no penalty factor | 118 | 0 | - * | **roll + penalty (shipped)** | **7** | **0** | - * - * **Region 0's n = 8 is the weakest denominator in the whole density oracle.** - * Poisson sigma on 8 is 2.83, i.e. 35%, so 7-vs-8 (12.5%) is well inside the - * noise and is emphatically not evidence of 12.5%-grade accuracy. Region 1 - * contributes a zero-vs-zero agreement, which rules out gross over-placement in - * a window with patches but no oil and is worth having, but it cannot - * discriminate a factor-of-two error either. Treat oil as the loosest-validated - * overlay in the set. - */ -export function makeNauvisOilPlacement( - params: NauvisOilPlacementParams, -): (x: number, y: number) => boolean { - // Derived from the ported tile resolver, NOT from rendered pixel colours: the - // chunk resolver asks about tiles outside the render window, and reading the - // ImageData would make the answer window-dependent. - const tileAt = makeTileResolver({ - seed0: params.seed0, - segmentationMultiplier: params.segmentationMultiplier, - moistureFrequency: params.moistureFrequency, - moistureBias: params.moistureBias, - auxFrequency: params.auxFrequency, - auxBias: params.auxBias, - startingAreaMoistureSize: params.startingAreaMoistureSize, - startingAreaMoistureFrequency: params.startingAreaMoistureFrequency, - startingPositions: [...(params.startingPositions ?? [{ x: 0, y: 0 }])], - }); - return makePlacementSet({ - salt: PLACEMENT_SALT.crudeOil, - probability: makeNauvisOilProbability(params), - tileAllowed: (x, y) => !WATER_TILE_NAMES.has(tileAt(x, y).name), - collisionBox: () => OIL_COLLISION_BOX, - }); -} - -export interface RenderResourcesOptions { - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** Per-resource control levers, keyed by controlName; missing => all-default. */ - readonly controls: Record; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; - /** elevation inputs for the starting-patch favorability coupling (solids only). */ - readonly segmentationMultiplier?: number; - readonly waterLevel?: number; - readonly startingLakePositions?: readonly Point[]; - /** Climate inputs for oil's tile gate (the ported tile resolver). */ - readonly moistureFrequency?: number; - readonly moistureBias?: number; - readonly auxFrequency?: number; - readonly auxBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; - /** - * World box to sweep for oil roll hits. Defaults to this render's own pixel - * box. The tiled renderer widens it by `PLACEMENT_MARK_RADIUS_PX` pixels' - * worth of tiles (clamped to the full image) so a hit centred just outside this - * tile still paints the part of its 3x3 mark that falls inside. `paintMark` - * clips to the pixel grid, so a wider sweep can never paint outside this tile's - * own bounds. The thresholded resources paint 1x1 and ignore this. - */ - readonly sweepBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; -} - -export function renderResources(base: ImageData, opts: RenderResourcesOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const resolve = makeResourceResolver({ - seed0: opts.seed0, - controls: opts.controls, - startingPositions: opts.startingPositions, - segmentationMultiplier: opts.segmentationMultiplier, - waterLevel: opts.waterLevel, - startingLakePositions: opts.startingLakePositions, - }); - - const isWater = (r: number, g: number, b: number): boolean => { - for (const [wr, wg, wb] of WATER_TILE_COLORS) { - if (r === wr && g === wg && b === wb) return true; - } - return false; - }; - - // Pass 1: crude oil, the one roll resource, painted as a 3x3 mark. An oil well - // is 2.8 x 2.8 tiles and the game puts down single digits of them per 512x512 - // region, so a lone pixel disappears - the same reasoning the geyser and the - // spawners use for the same mark. See the module comment for the paint order. - // - // `oilMark` flags the pixels this pass painted so pass 2 can leave the ones oil - // outranks alone. Allocated on the first hit, so the common no-oil window pays - // nothing at all. - let oilMark: Uint8Array | null = null; - const oil = RESOURCE_CATALOG.find((p) => p.placement === "roll"); - if (oil !== undefined && (opts.controls[oil.controlName]?.size ?? 1) > 0) { - const placed = makeNauvisOilPlacement({ - seed0: opts.seed0, - controls: opts.controls, - startingPositions: opts.startingPositions, - segmentationMultiplier: opts.segmentationMultiplier, - moistureFrequency: opts.moistureFrequency, - moistureBias: opts.moistureBias, - auxFrequency: opts.auxFrequency, - auxBias: opts.auxBias, - startingAreaMoistureSize: opts.startingAreaMoistureSize, - startingAreaMoistureFrequency: opts.startingAreaMoistureFrequency, - }); - const box = opts.sweepBox; - const pxStart = box ? Math.round((box.x0 - originX) / tpp) : 0; - const pxEnd = box ? Math.round((box.x1 - originX) / tpp) : width; - const pyStart = box ? Math.round((box.y0 - originY) / tpp) : 0; - const pyEnd = box ? Math.round((box.y1 - originY) / tpp) : height; - for (let py = pyStart; py < pyEnd; py++) { - const wy = originY + py * tpp; - for (let px = pxStart; px < pxEnd; px++) { - const wx = originX + px * tpp; - if (!placed(wx, wy)) continue; - oilMark ??= new Uint8Array(width * height); - paintMark(base, px, py, oil.mapColor, PLACEMENT_MARK_RADIUS_PX, undefined, oilMark); - } - } - } - - // The catalog entries a painted oil mark survives: those oil is drawn in - // preference to. Uranium alone today - the four solids outrank oil and must keep - // overwriting it. Six comparisons once per render, not per pixel. - const oilOutranks = new Set( - oil === undefined ? [] : RESOURCE_CATALOG.filter((p) => comparePriority(oil, p) < 0), - ); - - // Pass 2: the thresholded resources, over the top - see the module comment on - // paint order. - for (let py = 0; py < height; py++) { - const wy = originY + py * tpp; - for (let px = 0; px < width; px++) { - const o = (py * width + px) * 4; - // Ore never sits on water - skip water tiles (and the resolver call for them). - if (isWater(base.data[o], base.data[o + 1], base.data[o + 2])) continue; - const wx = originX + px * tpp; - const winner = resolve(wx, wy); - if (!winner) continue; - // The guard, reached only on a pixel a resource already won: an oil mark is - // not overwritten by a resource that sorts after oil. - if (oilMark !== null && oilMark[py * width + px] === 1 && oilOutranks.has(winner)) continue; - base.data[o] = winner.mapColor[0]; - base.data[o + 1] = winner.mapColor[1]; - base.data[o + 2] = winner.mapColor[2]; - base.data[o + 3] = 255; - } - } -} diff --git a/src/noise/preview/renderRocks.ts b/src/noise/preview/renderRocks.ts deleted file mode 100644 index d065f1a0..00000000 --- a/src/noise/preview/renderRocks.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Composite the rocks overlay onto a terrain ImageData: sweep the same pixel grid - * as renderTerrain/renderEnemies, roll the game's per-tile placement draw against - * the rock probability field, and paint a `ROCK_MAP_COLOR` mark wherever it wins. - * Mutates `base` in place. - * - * **The mark is 3x3, not a single pixel**, and this comment used to say - * otherwise ("1 pixel per placed rock, no legibility block"). It was true until - * 2026-07-28, when comparing against the game's own `--generate-map-preview` - * output moved BOTH planets to `NAUVIS_ROCK_MARK_RADIUS_PX = 1` - the game - * paints each rock's real footprint, and a 1x1 dot was 14x too little ink. The - * measurement is in `rockCatalog.ts` beside the constant. - * - * This rolls rather than thresholds: it draws `makePlacementSet`'s per-tile `U` - * and places where `U < density(x, y)` AND the game's two arbitration gates pass - * (`docs/noise/placement-roll-NOTES.md`: the winner is picked by max probability - * "subject to collision-mask and tile-restriction checks"). Positions are not - * tile-exact - there is no cross-overlay arbitration against the other - * autoplacers and no jitter draws within the tile (see `placementRoll.ts`) - but - * density is the property under test, and `test/entityDensity.spec.ts` pins it - * against the real game's per-region entity counts. - * - * Rocks collide with water, so water pixels are skipped, reusing renderTerrain's - * exact water decision via WATER_TILE_COLORS the same way renderResources does. - * That pixel-colour skip is only an optimisation and a paint guard - the gate - * that matters for correctness is `tileAllowed` below, which is derived from the - * ported tile resolver and is therefore a pure function of world position. - * Cliff exclusion is not wired, matching the existing ore-on-cliffs deferred item. - */ -import type { Point } from "../distanceFromNearestPoint"; -import { PLACEMENT_SALT, makePlacementSet } from "../placement/placementRoll"; -import type { PlacementCollisionBox } from "../placement/placementRoll"; -import { makeRockFields, type RockFieldParams } from "../rocks/rockField"; -import { - ROCK_FIELD_LATTICE, - ROCK_MAP_COLOR, - NAUVIS_ROCK_MARK_RADIUS_PX, - latticeSnapped, - type RockControls, -} from "../rocks/rockCatalog"; -import { makeTileResolver } from "../tiles/resolve"; -import { paintMark } from "./renderCliffs"; -import { WATER_TILE_COLORS } from "./renderResources"; - -export interface RenderRocksOptions { - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** control:rocks:frequency/size. Defaults to { frequency: 1, size: 1 }. */ - readonly controls?: RockControls; - readonly segmentationMultiplier?: number; - readonly moistureFrequency?: number; - readonly moistureBias?: number; - readonly auxFrequency?: number; - readonly auxBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; - /** - * World box to sweep for rock placements. Defaults to this render's own pixel - * box. The tiled renderer widens it by `NAUVIS_ROCK_MARK_RADIUS_PX` pixels' worth of - * tiles (clamped to the full image) so a rock centred just outside this tile - * still paints the part of its mark that falls inside. `paintMark` clips to the - * pixel grid, so a wider sweep can never paint outside this tile's own bounds. - * - * Rocks did NOT need this while they painted 1x1 - a single pixel cannot - * straddle a seam. It became load-bearing the moment the mark grew to 3x3, and - * `test/tiledEquality.spec.ts` failed on four cases until it was added. - */ - readonly sweepBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; -} - -/** - * The two tiles no rock may sit on. - * - * Unlike Vulcanus's rocks, none of the three Nauvis rock prototypes declares a - * `tile_restriction` at all - `decoratives.lua` has no such key anywhere. The - * restriction comes from the collision mask instead: all three are - * `type = "simple-entity"`, whose default mask is `building()` in - * `core/lualib/collision-mask-defaults.lua`, and that includes `water_tile`. So - * the gate is "the tile is not water", and it is shared by all three prototypes - * - which is the condition `resolveChunk`'s doc comment requires before a single - * probability-then-restriction test is allowed to stand in for the game's - * arbitrate-then-roll order. - */ -const WATER_TILE_NAMES = new Set(["water", "deepwater"]); - -/** - * The three prototypes' collision boxes, from `decoratives.lua` (2.1.12): - * `huge-rock` {{-1.5,-1.1},{1.5,1.1}}, `big-rock` {{-1.0,-0.9},{1.0,1.0}}, - * `big-sand-rock` {{-0.75,-0.75},{0.75,0.75}}. - */ -const HUGE_ROCK_BOX: PlacementCollisionBox = { w: 3, h: 2.2 }; -const BIG_ROCK_BOX: PlacementCollisionBox = { w: 2, h: 1.9 }; -const BIG_SAND_ROCK_BOX: PlacementCollisionBox = { w: 1.5, h: 1.5 }; - -/** - * Pick the collision box of whichever prototype has the highest probability at - * this tile - the argmax rule, kept here because on Nauvis it is NOT degenerate - * in identity, unlike on Vulcanus (`renderVulcanusRocks.ts`). - * - * **What IS degenerate here.** `huge-rock` can never win the argmax, and that is - * a theorem rather than a seed accident. With `T = moisture_band + rock_density`, - * `big = 0.17*(T - 1.6)` and `huge = 0.07*(T - 1.7)`, so `big > huge` exactly - * when `T > 1.53`; but `big` is only positive when `T > 1.6` and `huge` only when - * `T > 1.7`, so wherever either prototype can place at all, `big` is strictly - * ahead. Same shape as the Vulcanus finding, and the same consequence: the - * max-probability arbitration this port models predicts 0% huge-rock, while the - * game's region 0 holds 42 huge / 149 big / 1 sand - 22% huge. See the - * falsification writeup in `docs/noise/placement-roll-NOTES.md`; the claim this - * overlay makes is density, not identity. - * - * **What is not.** `big-sand-rock` vs `big-rock` is a real contest, and this is - * where Nauvis differs from Vulcanus. The two read DIFFERENT climate bands - - * `big-rock`'s `region_box` wants moisture in [0.35, 1], `big-sand-rock`'s wants - * moisture in [0, 0.3] AND aux in [0.3, 1] - and those moisture ranges are - * disjoint, so whichever band a tile sits in decides the winner. Measured over - * the placed tiles of the two oracle regions the split is total: region 0 is - * 205 big / 0 sand, region 1 is 0 big / 54 sand. The game's own populations - * agree in direction (region 0: 42 + 149 + 1; region 1: 0 + 0 + 64). - * - * **But the argmax makes no numeric difference here, and that is worth knowing - * before reusing this shape.** Placement is resolved on the integer tile lattice - * with tile-centred boxes, so what a box does is set an exclusion neighbourhood. - * `big-rock` (2 x 1.9) excludes |dx| <= 1 and |dy| <= 1; `big-sand-rock` - * (1.5 x 1.5) excludes exactly the same 3x3. Only `huge-rock` (3 x 2.2) differs, - * at 5x5 - and huge can never win. So argmax, uniform-big and uniform-sand all - * place identically (measured: 205 / 54 for all three), and only uniform-huge is - * distinguishable (168 / 46, materially worse). The agreement is pointwise, not - * just in aggregate: a mixed big/sand pair also tests as 3x3, so no tile can - * differ. - * - * The argmax is kept because it is the rule the game describes, and it would - * start to matter the moment a mod or another planet separated those two boxes - - * not because it bought accuracy today. **No lever this app exposes can separate - * them.** `control:rocks:size` is the same outer multiplier on all three - * probabilities, and the huge-vs-big theorem holds for every value of the shared - * term `T` (which is where size's other effect lands), so no size setting lets - * huge win. The moisture and aux levers move `T` and the two region boxes, so they - * can only shift which of big and sand wins - and those two are lattice-identical. - */ -function rockCollisionBoxFor(huge: number, big: number, sand: number): PlacementCollisionBox { - if (sand > big && sand > huge) return BIG_SAND_ROCK_BOX; - if (big >= huge) return BIG_ROCK_BOX; - return HUGE_ROCK_BOX; -} - -/** - * The shipped Nauvis rock placement predicate: the roll against `density`, gated - * by the water restriction and by collision against rocks already placed in the - * same chunk. Exported so `test/entityDensity.spec.ts` measures the exact - * predicate the renderer paints, not a re-derivation of it. - * - * **Measured rather than assumed**, against - * `test/fixtures/oracle-entity-counts.seed123456.json` (Factorio 2.1.12, seed - * 123456), comparing the placed-tile count with the sum of the game's - * huge-rock + big-rock + big-sand-rock counts: - * - * | | region 0 `[0,0]` (game 192) | region 1 `[4096,4096]` (game 64) | - * | --- | --- | --- | - * | bare roll, no gates | 312 (62.5%) | 182 (184.4%) | - * | + water restriction only | 252 (31.3%) | 60 (6.3%) | - * | + collision, uniform huge box | 168 (12.5%) | 46 (28.1%) | - * | **+ collision, argmax box (shipped)** | **205 (6.8%)** | **54 (15.6%)** | - * - * Uniform-big and uniform-sand also give 205 / 54; see `rockCollisionBoxFor` - * for why those three rules cannot differ on the integer lattice. - * - * Two honest caveats on the numbers. Region 1 is 60% water (measured with the - * same tile resolver), so the restriction gate alone does most of the work there - * and happens to land closer to the game (6.3%) than the full model does - * (15.6%); that is a 6-rock difference on a 64-rock region, not evidence that - * the collision gate is wrong, and dropping a gate the game demonstrably applies - * in order to improve one region's number would be fitting. Second, the - * denominators are small - one rock is 0.5% of region 0 and 1.6% of region 1 - - * so these percentages are far noisier per rock than the Vulcanus ones, whose - * regions hold ~1200 rocks each. - */ -export function makeNauvisRockPlacement( - params: RockFieldParams, -): (x: number, y: number) => boolean { - const fields = makeRockFields(params); - // Derived from the ported tile resolver, NOT from rendered pixel colours: the - // chunk resolver asks about tiles outside the render window, and reading the - // ImageData would make the answer window-dependent. - const tileAt = makeTileResolver({ - seed0: params.seed0, - segmentationMultiplier: params.segmentationMultiplier, - moistureFrequency: params.moistureFrequency, - moistureBias: params.moistureBias, - auxFrequency: params.auxFrequency, - auxBias: params.auxBias, - startingAreaMoistureSize: params.startingAreaMoistureSize, - startingAreaMoistureFrequency: params.startingAreaMoistureFrequency, - startingPositions: [...(params.startingPositions ?? [{ x: 0, y: 0 }])], - }); - - return makePlacementSet({ - salt: PLACEMENT_SALT.nauvisRocks, - // Snapped to `ROCK_FIELD_LATTICE`, which ships at 1 (a no-op that returns - // `fields.density` itself) - see `rockCatalog.ts`. - probability: latticeSnapped(fields.density, ROCK_FIELD_LATTICE), - tileAllowed: (x, y) => !WATER_TILE_NAMES.has(tileAt(x, y).name), - collisionBox: (x, y) => { - const p = fields.at(x, y); - return rockCollisionBoxFor(p.huge, p.big, p.sand); - }, - }); -} - -export function renderRocks(base: ImageData, opts: RenderRocksOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const placed = makeNauvisRockPlacement({ - seed0: opts.seed0, - rocksFrequency: opts.controls?.frequency ?? 1, - rocksSize: opts.controls?.size ?? 1, - segmentationMultiplier: opts.segmentationMultiplier, - moistureFrequency: opts.moistureFrequency, - moistureBias: opts.moistureBias, - auxFrequency: opts.auxFrequency, - auxBias: opts.auxBias, - startingAreaMoistureSize: opts.startingAreaMoistureSize, - startingAreaMoistureFrequency: opts.startingAreaMoistureFrequency, - startingPositions: opts.startingPositions, - }); - - const isWater = (r: number, g: number, b: number): boolean => { - for (const [wr, wg, wb] of WATER_TILE_COLORS) { - if (r === wr && g === wg && b === wb) return true; - } - return false; - }; - - const box = opts.sweepBox; - const pxStart = box ? Math.round((box.x0 - originX) / tpp) : 0; - const pxEnd = box ? Math.round((box.x1 - originX) / tpp) : width; - const pyStart = box ? Math.round((box.y0 - originY) / tpp) : 0; - const pyEnd = box ? Math.round((box.y1 - originY) / tpp) : height; - - for (let py = pyStart; py < pyEnd; py++) { - const wy = originY + py * tpp; - for (let px = pxStart; px < pxEnd; px++) { - const o = (py * width + px) * 4; - // Rocks never sit on water - skip water tiles (and the field call for them). - if (isWater(base.data[o], base.data[o + 1], base.data[o + 2])) continue; - const wx = originX + px * tpp; - if (!placed(wx, wy)) continue; - // `isWater` is re-checked per painted pixel, so a thickened mark still - // stops at the coastline rather than spilling onto water. - paintMark(base, px, py, ROCK_MAP_COLOR, NAUVIS_ROCK_MARK_RADIUS_PX, isWater); - } - } -} diff --git a/src/noise/preview/renderTerrain.ts b/src/noise/preview/renderTerrain.ts deleted file mode 100644 index fa60e443..00000000 --- a/src/noise/preview/renderTerrain.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { makeElevationNauvis } from "../expressions/elevationNauvis"; -import { makeTileCatalog } from "../tiles/catalog"; -import { waterBase } from "../tiles/helpers"; -import { makeTileResolver, type TileResolverParams } from "../tiles/resolve"; - -export interface RenderTerrainOptions { - /** Map seed (= map_seed / seed0). Callers resolve a null "random" seed first. */ - readonly seed0: number; - /** Output pixel dimensions. */ - readonly width: number; - readonly height: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** Non-seed resolver params (currently just segmentationMultiplier). */ - readonly ctx?: Omit; -} - -/** - * Water early-out threshold, in `water_base(elevation, 0, 100)` units (Task 11). - * - * Every one of the 19 land tiles is `expressionInRangeBase(...) [<= 1] (+max - * with another such term, still <= 1) + noiseLayerNoise(x, y)`, except sand-1, - * whose extra unbounded coastal term is capped by its own (aux) box regardless - * of elevation. `noiseLayerNoise` is a 4-octave `multioctave_noise` built on - * `basisNoise`; both are provably bounded (not just empirically observed) by - * Cauchy-Schwarz - `basisNoise`'s 4 corner terms are each `<= (1-d)^3 * sqrt(d) * - * GRADIENT_MAGNITUDE`, maximized over d in [0,1) at `d = 1/7` for a single term - * (~1.0), and jointly over all 4 corners (numeric search) at `~1.7719`; summing - * the 4 octaves' amplitudes (`norm * sum((1/persistence)^k)` for persistence - * 0.7, 4 octaves ~= 1.8634) and applying `outputScale = 2/3` gives a hard bound - * `|noiseLayerNoise| <= (2/3) * 1.7719 * 1.8634 ~= 2.2012` - true for ANY (x, y) - * and ANY of the 19 layer seeds, not just the ones sampled. (An empirical sweep - * of 20,000 points per seed across all 19 layer seeds found an observed max of - * ~1.84, comfortably under this bound, which is the expected relationship for a - * valid-but-not-maximally-tight analytic bound.) - * - * sand-1's extra term `expression_in_range(5, inf, [elevation, aux], [-1.5, 0.5], - * [1.5, 1])` is unbounded in `peak_maximum`, but its value is `5 * min(elevation - * + 1.5, 1.5 - elevation, aux - 0.5, 1 - aux)`, and the `aux` pair alone caps the - * min at `0.25` (half-width of `[0.5, 1]`) regardless of `elevation` - so this - * term is <= 1.25 unconditionally, a bit above the general 1.0 cap. - * - * So the true worst-case land probability, over every tile and every point, is - * `<= max(1, 1.25) + 2.2012 = 3.4512`. Choosing `WATER_EARLY_OUT_THRESHOLD = 5` - * leaves a >=30% margin over that proven bound: whenever - * `water_base(elevation, 0, 100) >= 5`, that value (or `deepwater`'s, if - * higher - the early-out compares them directly) exceeds every land tile's - * probability, so picking water/deepwater directly reproduces the full - * resolver's argmax winner exactly. - * - * The design doc's own back-of-envelope estimate ("land tops out at peak_maximum(1) - * + noise_layer_noise(+/-~0.67)" ~= 1.67) undershoots the true bound - the - * empirical sweep alone found points past 1.8 - so this task deliberately uses - * 5, not 1.67, as the threshold. See the task report for the full derivation and - * the equivalence test that validates it over a live grid. - */ -const WATER_EARLY_OUT_THRESHOLD = 5; - -/** - * Sweep a `width x height` pixel grid over world space and return an `ImageData` - * painted with each pixel's winning tile's `map_color` (Task 11 - the terrain - * color render path, `makeTileResolver`'s argmax turned into pixels). - * - * Performance: most open-water pixels skip the 19 land `noise_layer_noise` - * evaluations via a provably-safe early-out (see {@link WATER_EARLY_OUT_THRESHOLD}) - - * only `elevation` and the two `water_base` terms are evaluated there. Pixels - * near the coast or on land still run the full resolver. - */ -export function renderTerrain(opts: RenderTerrainOptions): ImageData { - const { width, height, seed0 } = opts; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const resolve = makeTileResolver({ seed0, ...opts.ctx }); - const elevationAt = makeElevationNauvis({ - seed0, - segmentationMultiplier: opts.ctx?.segmentationMultiplier, - startingPositions: opts.ctx?.startingPositions, - }); - const catalog = makeTileCatalog(seed0); - const deepwaterTile = catalog.find((t) => t.name === "deepwater")!; - const waterTile = catalog.find((t) => t.name === "water")!; - - const data = new Uint8ClampedArray(width * height * 4); - - for (let py = 0; py < height; py++) { - const wy = originY + py * tpp; - for (let px = 0; px < width; px++) { - const wx = originX + px * tpp; - - const elevation = elevationAt(wx, wy); - const waterInfluence = waterBase(elevation, 0, 100); - - let color: readonly [number, number, number, number]; - if (waterInfluence >= WATER_EARLY_OUT_THRESHOLD) { - // Water/deepwater necessarily beat every land tile here (see the - // threshold derivation above); pick between the two directly, matching - // the full resolver's tie-break (deepwater is catalog[0], so a strict - // `>` never lets water displace it on an exact tie). - const deepInfluence = waterBase(elevation, -2, 200); - color = deepInfluence >= waterInfluence ? deepwaterTile.color : waterTile.color; - } else { - color = resolve(wx, wy).color; - } - - const o = (py * width + px) * 4; - data[o] = color[0]; - data[o + 1] = color[1]; - data[o + 2] = color[2]; - data[o + 3] = color[3]; - } - } - - return new ImageData(data, width, height); -} diff --git a/src/noise/preview/renderTrees.ts b/src/noise/preview/renderTrees.ts deleted file mode 100644 index 2d9b5f0e..00000000 --- a/src/noise/preview/renderTrees.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Composite the tree overlay onto a terrain ImageData: sweep the same pixel grid - * as renderTerrain and blend the game's tree chart color over each pixel with an - * alpha proportional to the pixel's tree-footprint coverage. Mutates `base` in - * place. - * - * Why a footprint kernel and not a flat per-pixel blend: the game's own map - * preview places real tree entities and charts each one via - * `ChartingInterface::drawRectangle`, which rasterizes an entity's box - * floor-to-ceil and blends every pixel it touches at full alpha (see - * `0x1001b1fdc` in the disassembly). A tree's charted box is 1.0x1.0 tiles, so - * with a uniformly distributed sub-tile placement it paints its own tile's - * pixel with probability 1, each of the 4 edge-adjacent pixels with probability - * 0.5, and each diagonal with probability 0.25 - the separable kernel - * `[0.5, 1.0, 0.5]`. We cannot place individual trees without the per-chunk - * placement roll (deferred - see docs/noise/placement-roll-NOTES.md), so we - * draw the EXPECTED coverage instead: the density is exactly the probability - * the game rolls against, because arbitration picks the max-probability entity - * per tile and rolls once. Treating neighbouring tiles' placements as - * independent events, the probability at least one paints a given pixel is one - * minus the product of all their misses - which also self-limits at 1 with no - * artificial clamp needed. Modelling one pixel per tree left the overlay 4.2x - * under-inked against a real game render; this closes that gap. See the design - * spec for the full RE and the coverage-prediction validation against seed - * 123456 (9.80% / 11.46% / 13.13% predicted for box sizes 0.8 / 1.0 / 1.2 - * against a measured 11.49%). - * - * Trees do not place on water, so water pixels are skipped, reusing renderTerrain's - * exact water decision via WATER_TILE_COLORS the same way renderResources does. - * Cliff exclusion is not wired, matching the existing deferred ore-on-cliffs item. - * - * The blend itself compounds per drawn tree, not per coverage probability: - * `drawRectangle` blends the tree color into the chart once for every tree - * that touches a pixel, so two overlapping trees reach - * `1 - (1 - TREE_MAX_ALPHA)^2`, not a single capped blend at `TREE_MAX_ALPHA`. - * Blending once against a coverage probability structurally capped alpha at - * `TREE_MAX_ALPHA` (0.398) and left the overlay under-inked (3.85 vs. the - * game's 5.70 total ink units at seed 123456, whose implied alpha reaches a - * measured max of 0.952). Moving the per-tree alpha inside the product lets - * independent blends compound instead. - */ -import { WATER_TILE_COLORS } from "./renderResources"; -import { makeTreeDensity, type TreeFieldParams } from "../trees/treeField"; -import { TREE_MAP_COLOR } from "./palette"; - -/** The alpha component of the same constant: fully-forested tiles blend at 40%. */ -export const TREE_MAX_ALPHA = 0.4; - -/** Separable 1-D footprint kernel for a 1.0-tile box with a uniform sub-tile offset. */ -const KERNEL_WEIGHT: readonly [number, number, number] = [0.5, 1.0, 0.5]; - -export interface RenderTreesOptions extends TreeFieldParams { - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; -} - -export function renderTrees(base: ImageData, opts: RenderTreesOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const density = makeTreeDensity(opts); - - const isWater = (r: number, g: number, b: number): boolean => { - for (const [wr, wg, wb] of WATER_TILE_COLORS) { - if (r === wr && g === wg && b === wb) return true; - } - return false; - }; - - // Precompute a (width + 2) x (height + 2) density buffer spanning the pixel - // grid plus a one-cell border, sampled at the exact world coordinates the - // pixel loop below uses. This reads the density FIELD (a pure function of - // world coordinates), never the image buffer, so no halo is needed for - // tiled rendering to stay byte-identical - and it keeps the cost at one - // density evaluation per pixel (plus the thin border), not nine. - const bufW = width + 2; - const bufH = height + 2; - const densityBuf = new Float64Array(bufW * bufH); - for (let by = 0; by < bufH; by++) { - const wy = originY + (by - 1) * tpp; - for (let bx = 0; bx < bufW; bx++) { - const wx = originX + (bx - 1) * tpp; - densityBuf[by * bufW + bx] = density(wx, wy); - } - } - - for (let py = 0; py < height; py++) { - for (let px = 0; px < width; px++) { - const o = (py * width + px) * 4; - if (isWater(base.data[o], base.data[o + 1], base.data[o + 2])) continue; - - // The game blends once PER DRAWN TREE, not once against a coverage - // probability - so overlapping trees compound rather than cap out at a - // single tree's TREE_MAX_ALPHA. Each neighbouring tile independently - // draws at alpha `TREE_MAX_ALPHA * p * w` (w = the separable - // [0.5, 1, 0.5] kernel weight), and the combined alpha of independent - // blends is 1 minus the product of their misses - which self-limits - // toward 1, not TREE_MAX_ALPHA. Measured against a real - // --generate-map-preview render at seed 123456, the game's implied - // alpha has median 0.381, p90 0.65, max 0.952 - all well above the old - // formula's structural ceiling of TREE_MAX_ALPHA (0.398). - let miss = 1; - for (let dy = -1; dy <= 1; dy++) { - const wy = KERNEL_WEIGHT[dy + 1]; - const by = py + 1 + dy; - for (let dx = -1; dx <= 1; dx++) { - const wx = KERNEL_WEIGHT[dx + 1]; - const bx = px + 1 + dx; - const p = densityBuf[by * bufW + bx] * wx * wy; - miss *= 1 - TREE_MAX_ALPHA * p; - } - } - const alpha = 1 - miss; - if (alpha <= 0) continue; - - // Match the game's integer blend arithmetic exactly rather than - // float-rounding: alpha is an 8-bit byte, then fixed up 255 -> 256. - const A = Math.round(alpha * 255); - const a = A + (A >> 7); - base.data[o] = ((256 - a) * base.data[o] + a * TREE_MAP_COLOR[0]) >> 8; - base.data[o + 1] = ((256 - a) * base.data[o + 1] + a * TREE_MAP_COLOR[1]) >> 8; - base.data[o + 2] = ((256 - a) * base.data[o + 2] + a * TREE_MAP_COLOR[2]) >> 8; - base.data[o + 3] = 255; - } - } -} diff --git a/src/noise/preview/renderVulcanusCliffs.ts b/src/noise/preview/renderVulcanusCliffs.ts deleted file mode 100644 index d2197a67..00000000 --- a/src/noise/preview/renderVulcanusCliffs.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Composite the Vulcanus cliff footprint onto a terrain ImageData. Mirrors - * renderCliffs (Nauvis), reusing the same placement geometry and the same - * `CLIFF_MAP_COLOR` mark - `cliff-vulcanus` declares - * `map_color = {144, 119, 87}` in `space-age/prototypes/entity/entities.lua`, - * byte-identical to Nauvis's `cliff`, so no second colour is needed. - * - * Two differences from the Nauvis renderer: - * - * - **Lava exclusion happens at PLACEMENT, not at paint time.** Nauvis skips - * water-coloured pixels as it paints; here the cells never exist. That is not - * a stylistic choice - `tryToAddCliff` runs a real collision test and drops - * the entity, so a paint-time skip would leave the cell in `placedCells` and - * the specs that score against `find_entities_filtered` would still count it. - * - * **This comment used to say the opposite** ("Lava plays that visual role but - * is not a water tile, and the game does not exclude cliffs from it here"), - * which was wrong in both halves: `tile_collision_masks.lava()` sets - * `water_tile = true`, which IS a layer the cliff's own mask holds, and the - * game excludes cliffs from lava for exactly that reason (issue #18). - * - **No levers.** Vulcanus has no cliff autoplace control, so there is nothing - * to disable the pass and nothing to rescale the interval; the bands are the - * planet constants from `vulcanusCliffFields.ts`. - */ -import type { EvalCtxInput } from "../eval/ctx"; -import { withCtxDefaults } from "../eval/ctx"; -import { makeCliffPlacementFromFields } from "../cliffs/cliffPlacement"; -import { VULCANUS_CLIFF_BLOCKING_TILES } from "../cliffs/cliffCatalog"; -import { - VULCANUS_CLIFF_ELEVATION_0, - VULCANUS_CLIFF_ELEVATION_INTERVAL, - VULCANUS_CLIFF_SMOOTHING, - makeVulcanusCliffFields, -} from "../cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../cliffs/vulcanusOreRejection"; -import { paintCliffCells } from "./renderCliffs"; -import { buildResources } from "./renderVulcanusResources"; -import { - type VulcanusStack, - makeVulcanusTileResolver, - makeVulcanusTileResolverFrom, -} from "../tiles/vulcanusCatalog"; - -export interface RenderVulcanusCliffsOptions { - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** Non-seed resolver params (notably `startingPositions`). */ - readonly ctx?: Omit; - /** - * World box to enumerate placed cliff cells over. Defaults to the pixel grid's - * own world box. The tiled renderer widens this so a cell centered just - * outside a tile still paints the part of its mark that falls inside - see - * renderCliffs' identical option for the full rationale. - */ - readonly cellQueryBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; - /** - * The composite's one `VulcanusStack`, as `renderVulcanusRocks` takes it. The - * lava rejection needs a tile resolver, and building a private one here would - * duplicate the whole field DAG - `memoXY` is single-entry, so separate copies - * share nothing at all. - */ - readonly sharedStack?: VulcanusStack; -} - -export function renderVulcanusCliffs(base: ImageData, opts: RenderVulcanusCliffsOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const ctx = withCtxDefaults({ seed0: opts.seed0, ...opts.ctx }); - const shared = opts.sharedStack; - // As in `makeVulcanusRockPlacement`: derived from the ported tile resolver, - // NOT from rendered pixel colours. The collision box reaches tiles outside the - // render window, so reading back the ImageData would make the answer depend on - // the window and break tiled equality. - const tileAt = - shared === undefined ? makeVulcanusTileResolver(ctx) : makeVulcanusTileResolverFrom(shared); - // The ORE -> CLIFF suppression (#84 item 1). Same sourcing rule as the tile - // resolver above: the composite's own resource stack when there is one, so the - // two overlays agree on where the ore is by construction rather than by - // coincidence, and a private DAG only when running standalone. - const resources = shared?.resources ?? buildResources(ctx); - const placement = makeCliffPlacementFromFields(makeVulcanusCliffFields(ctx, shared), { - elevation0: VULCANUS_CLIFF_ELEVATION_0, - interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, - smoothing: VULCANUS_CLIFF_SMOOTHING, - tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), - cellRejects: makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls), - // Both rejections act on the CROSSING, not on the emitted entity (#84). - // A rejected cell's four edges go with it, so a surviving neighbour loses - // the shared one and changes orientation. See - // `test/vulcanusCliffRejectionStage.spec.ts`: the post-filter reading - // predicts 1,662 cases of a survivor keeping such an edge and the game - // shows 0. Worth 33 -> 21 wrong orientations at no cost in recall. - rejectAtCrossingStage: true, - }); - - const box = opts.cellQueryBox ?? { - x0: originX, - y0: originY, - x1: originX + width * tpp, - y1: originY + height * tpp, - }; - - paintCliffCells(base, placement.placedCells(box.x0, box.y0, box.x1, box.y1), { - originX, - originY, - tilesPerPixel: tpp, - }); -} diff --git a/src/noise/preview/renderVulcanusResources.ts b/src/noise/preview/renderVulcanusResources.ts deleted file mode 100644 index d10f8538..00000000 --- a/src/noise/preview/renderVulcanusResources.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Composite the Vulcanus ore overlay onto a terrain ImageData: sweep the same - * pixel grid as renderVulcanusTerrain and paint each entry's `map_color` - * opaque where it wins. Mutates `base` in place. Mirrors renderResources - * (Nauvis). - * - * **Two placement modes, because Vulcanus has two kinds of resource.** The - * catalog's `placement` field picks per entry (`vulcanusResourceCatalog.ts`): - * - * - The three solid ores THRESHOLD. Their probability is - * - * probability = (control::size > 0) * 1000 * ((1 + region) * rp - 1) - * = (size > 0) * 1000 * region [rp -> 1] - * - * which saturates to ~1 inside a patch and 0 outside, so `probability >= 0.5` - * (i.e. `region >= 0.0005`) is the patch boundary. Same threshold convention - * renderResources uses. Writing it as the game's probability rather than a - * bare `region > 0` keeps the `size = 0` disable case and the - * `random_penalty -> 1` substitution visible at the call site. - * - The sulfuric-acid geyser ROLLS. Its probability peaks below 0.09 anywhere on - * the map, so there is no threshold that yields a footprint - a geyser is an - * individual entity the game rolls for per tile. See - * `makeVulcanusGeyserPlacement`. - * - * **No water exclusion**, unlike the Nauvis renderer: Vulcanus has no water - * tile, and ore exclusion is expressed through the biome favorabilities rather - * than a tile test. The geyser's roll does carry a lava tile gate, which is a - * different mechanism (a collision mask, not a favorability) - see below. - * - * **Paint order: geyser marks first, then the three thresholded ores over the - * top.** The catalog's module comment explains why a solid ore must win a - * shared pixel (the game arbitrates by max probability, and calcite saturates to - * ~1 against the geyser's <0.09). Painting the ores last reproduces that without - * a colour test: any geyser pixel an ore also claims is simply overwritten. It - * also keeps the ore pass a per-pixel pure function of world position, which is - * what `test/tiledEquality.spec.ts` needs. - */ -import type { EvalCtx, EvalCtxInput } from "../eval/ctx"; -import { withCtxDefaults } from "../eval/ctx"; -import { makeVulcanusBiomes } from "../expressions/vulcanusBiomes"; -import { makeVulcanusCracks } from "../expressions/vulcanusCracks"; -import { makeVulcanusHelpers } from "../expressions/vulcanusHelpers"; -import { makeVulcanusResources } from "../expressions/vulcanusResources"; -import type { VulcanusResources } from "../expressions/vulcanusResources"; -import { makeVulcanusSpawn } from "../expressions/vulcanusSpawn"; -import { - PLACEMENT_MARK_RADIUS_PX, - PLACEMENT_SALT, - makePlacementSet, -} from "../placement/placementRoll"; -import type { PlacementCollisionBox } from "../placement/placementRoll"; -import { - RESOURCE_PROBABILITY_THRESHOLD, - VULCANUS_RESOURCE_CATALOG, - sulfuricAcidGeyserProbability, -} from "../resources/vulcanusResourceCatalog"; -import { - type VulcanusStack, - makeVulcanusTileResolver, - makeVulcanusTileResolverFrom, -} from "../tiles/vulcanusCatalog"; -import { paintMark } from "./renderCliffs"; - -/** - * The overlay's placement threshold: probability >= 0.5 (see the module - * comment). Defined in `vulcanusResourceCatalog.ts` because the cliff overlay's - * ore rejection asks the same question - see `RESOURCE_PROBABILITY_THRESHOLD`. - */ -const PROBABILITY_THRESHOLD = RESOURCE_PROBABILITY_THRESHOLD; - -/** - * The two Vulcanus tiles no geyser may sit on. - * - * **Derived from the collision mask, not from a `tile_restriction`.** The geyser - * prototype declares none (`space-age/prototypes/entity/resources.lua:137-190`, - * 2.1.12) - that field only appears there inside the shared - * `resource_autoplace` helper, which this literal prototype does not use. What - * gates it instead is `type = "resource"`, whose default collision mask is - * `{layers = {resource = true}}` (`core/lualib/collision-mask-defaults.lua:187`), - * against the tiles' own masks: on Vulcanus exactly `lava` and `lava-hot` use - * `tile_collision_masks.lava()`, which lists `resource = true` - * (`base/prototypes/tile/tile-collision-masks.lua:65`, - * `space-age/prototypes/tile/tiles-vulcanus.lua:417`, `:459`). Every other - * Vulcanus tile uses `ground()`, which does not. - * - * So the forbidden set coincides with the rock overlay's - * (`renderVulcanusRocks.ts`) while being reached by a completely different - * route, and the geyser is a single prototype, so the "all prototypes sharing - * the overlay must share one `tileAllowed`" precondition in `resolveChunk`'s - * doc comment is trivially met. - * - * **This gate rejects nothing in the one oracle region that has geysers**, which - * is worth stating so nobody reads its 0 as evidence it is inert: over a - * +/-2000-tile sample at seed 123456, 426 of 5627 tiles with a positive geyser - * probability are lava, and the gate rejects 12 of 195 roll hits (~6%). Oracle - * region 4 simply has no lava where its sulfur is. - */ -const GEYSER_FORBIDDEN_TILES = new Set(["lava", "lava-hot"]); - -/** - * The geyser's `collision_box`, 2.8 x 2.8 tiles - * (`space-age/prototypes/entity/resources.lua:182`: `{{-1.4,-1.4},{1.4,1.4}}`). - * - * **Checked for `map_generator_bounding_box` rather than assumed.** That field - * overrides the collision box during map generation and cost Task 6 87-132 - * points when it was missed; a grep across `base/`, `core/` and `space-age/` at - * 2.1.12 returns 8 declarations - the two spawners, the four worms, the base - * Nauvis tree family and `gleba-spawner-small` - and **no resource**. So the - * collision box really is the map-gen box here. - * - * **There is no argmax box question at all.** The three previous roll overlays - * each answered it differently (an ordering theorem on Vulcanus rocks, a lattice - * collapse on Nauvis rocks, identical boxes on the spawners). The geyser is a - * single prototype with a single box, so the question does not arise. - */ -const GEYSER_COLLISION_BOX: PlacementCollisionBox = { w: 2.8, h: 2.8 }; - -/** - * Build the Vulcanus resource field stack `renderVulcanusResources` sweeps. - * - * Exported because the cliff overlay needs the same stack for its ore rejection - * when it runs standalone (with a shared `VulcanusStack` it takes - * `stack.resources` instead). Assembling the sub-DAG by hand in a second place - * is precisely the duplication that lets two callers drift onto different - * fields. - */ -export function buildResources(ctx: EvalCtx): VulcanusResources { - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = makeVulcanusBiomes(ctx, helpers, spawn, cracks); - return makeVulcanusResources(ctx, helpers, spawn, biomes, cracks); -} - -/** - * The geyser placement predicate over an ALREADY-BUILT resource stack. - * - * Exported (unlike the ctx-only `makeVulcanusGeyserPlacement` below) so the - * cliff overlay's ore rejection can reuse the composite's one `VulcanusStack` - * instead of building a second field DAG: `memoXY` is single-entry, so a private - * copy would share nothing and pay for the whole tree again. - */ -export function geyserPlacementFrom( - ctx: EvalCtx, - resources: VulcanusResources, - stack?: VulcanusStack, -): (x: number, y: number) => boolean { - // Derived from the ported tile resolver, NOT from rendered pixel colours: the - // chunk resolver asks about tiles outside the render window, and reading the - // ImageData would make the answer window-dependent. - const tileAt = - stack === undefined ? makeVulcanusTileResolver(ctx) : makeVulcanusTileResolverFrom(stack); - return makePlacementSet({ - salt: PLACEMENT_SALT.vulcanusGeyser, - probability: sulfuricAcidGeyserProbability(resources), - tileAllowed: (x, y) => !GEYSER_FORBIDDEN_TILES.has(tileAt(x, y).name), - collisionBox: () => GEYSER_COLLISION_BOX, - }); -} - -/** - * The shipped sulfuric-acid-geyser placement predicate: the roll against - * `sulfuricAcidGeyserProbability`, gated by the lava tile restriction and by - * collision against geysers already placed in the same chunk. Exported so - * `test/entityDensity.spec.ts` measures the exact predicate the renderer paints. - * - * ## The prototype data, from source (2.1.12) - * - * | | sulfuric-acid-geyser | - * | --- | --- | - * | type | `resource` | - * | autoplace order | `c` (every other resource is `b`) | - * | probability | `vulcanus_sulfuric_acid_geyser_probability`, no `random_penalty` | - * | collision_box | 2.8 x 2.8 | - * | map_generator_bounding_box | **not declared** - so the collision box is the map-gen box | - * | tile_restriction | none - the lava gate comes from the collision MASK | - * | collision_mask | `resource` layer only (the `type = "resource"` default) | - * - * ## Measured, against `test/fixtures/oracle-entity-counts.seed123456.json` - * - * Factorio 2.1.12, seed 123456. **Only oracle region 4 has a usable - * denominator**: regions 2 `[0,0]` and 3 `[4096,4096]` contain no sulfur at all - * (the probability is <= 0 at every one of their 262144 tiles), so the game has - * 0 geysers there and so does this model. Region 4 `[-256,-256]` has 56. - * - * | variant | region 4 (game 56) | - * | --- | --- | - * | bare roll, no gates | 81 (44.6%) | - * | + lava tile restriction only | 81 (44.6%) | - * | + collision only | 56 (0.0%) | - * | **+ both gates (shipped)** | **56 (0.0%)** | - * - * Collision does all of the work here and the tile restriction none - see - * `GEYSER_FORBIDDEN_TILES` for why that 0 is a property of this window rather - * than of the gate. - * - * **Do not read the exact 56 as precision.** n = 56 is a small denominator - * (Poisson sigma ~7.5, i.e. 13%) and the salt is arbitrary. Re-running region 4 - * over eight salts gives **46-63** placements (rel 0.036-0.179), mean 55.3 - * against the game's 56 - so the MODEL is unbiased and the exact hit is one - * draw from that spread. `PLACEMENT_SALT.vulcanusGeyser` is fixed, so the test - * is deterministic, but a salt change is a real ~+/-8 move. - * - * ## What is not modelled, and why the agreement may be luckier than it looks - * - * The geyser's autoplace `order = "c"` carries the game's own comment: *"Other - * resources are 'b'; oil won't get placed if something else is already there."* - * Under the sequential-group reading in `placement-roll-NOTES.md` that puts the - * geyser LAST of every Vulcanus autoplacer - after rocks - * (`a[landscape]-c[rock]-*`) and after the three solid ores (`b`) - so it is the - * overlay most exposed to the cross-overlay occupancy Task 6 measured as the - * dominant residual for enemy bases. Region 4 is also the spawn-centred window - * where that effect concentrates. Nothing here models it; the model lands on the - * game's count with it left out. - */ -export function makeVulcanusGeyserPlacement(ctx: EvalCtx): (x: number, y: number) => boolean { - return geyserPlacementFrom(ctx, buildResources(ctx)); -} - -/** - * The geyser probability the renderer rolls against, built from a bare ctx. - * Exported so `test/entityDensity.spec.ts`'s ungated roll-vs-field-integral - * check integrates the same field. - */ -export function makeVulcanusGeyserProbability(ctx: EvalCtx): (x: number, y: number) => number { - return sulfuricAcidGeyserProbability(buildResources(ctx)); -} - -export interface RenderVulcanusResourcesOptions { - /** Shared Vulcanus field stack - see `RenderVulcanusTerrainOptions.stack`. */ - stack?: VulcanusStack; - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** Non-seed resolver params (notably `vulcanusResourceControls`, `startingPositions`). */ - readonly ctx?: Omit; - /** - * World box to sweep for geyser roll hits. Defaults to this render's own pixel - * box. The tiled renderer widens it by `PLACEMENT_MARK_RADIUS_PX` pixels' - * worth of tiles (clamped to the full image) so a hit centred just outside - * this tile still paints the part of its 3x3 mark that falls inside. - * `paintMark` clips to the pixel grid, so a wider sweep can never paint - * outside this tile's own bounds. The thresholded ores paint 1x1 and ignore - * this. - */ - readonly sweepBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; -} - -export function renderVulcanusResources( - base: ImageData, - opts: RenderVulcanusResourcesOptions, -): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const ctx = opts.stack?.ctx ?? withCtxDefaults({ seed0: opts.seed0, ...opts.ctx }); - const resources = opts.stack?.resources ?? buildResources(ctx); - - const controls = ctx.vulcanusResourceControls; - const active = VULCANUS_RESOURCE_CATALOG.filter((p) => p.levers(controls).size > 0); - if (active.length === 0) return; - - // Pass 1: the rolled entries, painted as 3x3 marks. Unlike rocks (1x1, "a - // block would merge scattered rocks into a blob"), a geyser is a 2.8 x 2.8 - // entity placed roughly once per 3000 tiles, so a single pixel disappears - - // the same reasoning enemy bases use for the same mark. - for (const params of active) { - if (params.placement !== "roll") continue; - const placed = geyserPlacementFrom(ctx, resources, opts.stack); - const box = opts.sweepBox; - const pxStart = box ? Math.round((box.x0 - originX) / tpp) : 0; - const pxEnd = box ? Math.round((box.x1 - originX) / tpp) : width; - const pyStart = box ? Math.round((box.y0 - originY) / tpp) : 0; - const pyEnd = box ? Math.round((box.y1 - originY) / tpp) : height; - for (let py = pyStart; py < pyEnd; py++) { - const wy = originY + py * tpp; - for (let px = pxStart; px < pxEnd; px++) { - const wx = originX + px * tpp; - if (!placed(wx, wy)) continue; - paintMark(base, px, py, params.mapColor, PLACEMENT_MARK_RADIUS_PX); - } - } - } - - // Pass 2: the thresholded ores, over the top - see the module comment on paint - // order. First in catalog order wins a pixel. - const thresholded = active - .filter((p) => p.placement === "threshold") - .map((params) => ({ params, region: params.region(resources) })); - if (thresholded.length === 0) return; - - for (let py = 0; py < height; py++) { - const wy = originY + py * tpp; - for (let px = 0; px < width; px++) { - const wx = originX + px * tpp; - for (const r of thresholded) { - // probability = (size > 0) * 1000 * region (rp -> 1); draw at >= 0.5. - const probability = 1000 * r.region(wx, wy); - if (probability < PROBABILITY_THRESHOLD) continue; - const o = (py * width + px) * 4; - base.data[o] = r.params.mapColor[0]; - base.data[o + 1] = r.params.mapColor[1]; - base.data[o + 2] = r.params.mapColor[2]; - base.data[o + 3] = 255; - break; // first in catalog order wins - } - } - } -} diff --git a/src/noise/preview/renderVulcanusRocks.ts b/src/noise/preview/renderVulcanusRocks.ts deleted file mode 100644 index 92c6e299..00000000 --- a/src/noise/preview/renderVulcanusRocks.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Composite the Vulcanus rock overlay onto a terrain ImageData: sweep the same - * pixel grid as renderVulcanusTerrain, roll the game's per-tile placement draw - * against the rock probability field, and paint a single `ROCK_MAP_COLOR` - * pixel wherever it wins. Mutates `base` in place. Mirrors renderRocks - * (Nauvis), including its 3x3 mark - see `VULCANUS_ROCK_MARK_RADIUS_PX`, which - * records why the earlier 1x1 choice was wrong: the game's own preview covers - * 5.17% of an origin-centred 1024-tile window in rock colour, and a 1x1 mark - * drew 0.37%. - * - * All four Vulcanus rock entities declare `map_color = {129, 105, 78}` - * (`space-age/prototypes/decorative/decoratives-vulcanus.lua`), identical to - * Nauvis's rocks, so `ROCK_MAP_COLOR` is shared rather than duplicated. - * - * Two differences from the Nauvis renderer: - * - * - **No water exclusion.** Vulcanus has no water tile. - * - **No levers.** Vulcanus deliberately omits the `rocks` autoplace control - * (see `vulcanusRockField.ts`), so there is no frequency or size to thread. - * - * This rolls rather than thresholds: it draws `makePlacementSet`'s per-tile - * `U` and places where `U < density(x, y)` AND the tile-restriction and - * collision gates pass. Positions are not tile-exact - there is no - * cross-overlay arbitration against other autoplacers and no jitter draws - * within the tile (see `placementRoll.ts`) - but density is the property under - * test, and this is a faithful roll against it rather than a threshold on it. - * - * The 3x3 mark CAN straddle a tile seam, so this takes a halo-widened - * `sweepBox` like the other mark-painting overlays; `tiledEquality.spec.ts`'s - * Vulcanus rocks/all/ragged cases fail without it. The collision gate is - * unaffected either way: it is resolved a whole chunk at a time, independent of - * the render window (see `makePlacementSet`). - */ -import type { EvalCtx, EvalCtxInput } from "../eval/ctx"; -import { withCtxDefaults } from "../eval/ctx"; -import { PLACEMENT_SALT, makePlacementSet } from "../placement/placementRoll"; -import type { PlacementCollisionBox } from "../placement/placementRoll"; -import { - ROCK_FIELD_LATTICE, - ROCK_MAP_COLOR, - VULCANUS_ROCK_MARK_RADIUS_PX, - latticeSnapped, -} from "../rocks/rockCatalog"; -import { makeVulcanusRockFields } from "../rocks/vulcanusRockField"; -import { - type VulcanusStack, - makeVulcanusTileResolver, - makeVulcanusTileResolverFrom, -} from "../tiles/vulcanusCatalog"; -import { paintMark } from "./renderCliffs"; - -export interface RenderVulcanusRocksOptions { - /** - * PROTOTYPE (issue #19 follow-up): reuse the caller's Vulcanus stack instead - * of building a fifth private copy. Only pays when that stack was built with - * `cacheShared`, because this overlay traverses chunk-major. - */ - sharedStack?: VulcanusStack; - readonly seed0: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** Non-seed resolver params (notably `startingPositions`). */ - readonly ctx?: Omit; - /** - * World box to sweep for rock placements. Defaults to this render's own pixel - * box; the tiled renderer widens it by `VULCANUS_ROCK_MARK_RADIUS_PX` pixels' - * worth of tiles so a rock centred just outside this tile still paints the part - * of its mark that falls inside. `paintMark` clips to the pixel grid, so a - * wider sweep never paints outside this tile's bounds. - * - * Needed only because the mark is 3x3. It was absent while Vulcanus rocks - * painted 1x1, and `test/tiledEquality.spec.ts` failed on three cases the - * moment the mark grew. - */ - readonly sweepBox?: { - readonly x0: number; - readonly y0: number; - readonly x1: number; - readonly y1: number; - }; -} - -/** - * The two Vulcanus tiles no rock may sit on. All four rock prototypes restrict - * to `vulcanus_tiles_cold` / `vulcanus_tiles_hot` - * (`space-age/prototypes/decorative/decoratives-vulcanus.lua:37-60`), and the - * union of those two lists is every Vulcanus tile EXCEPT these. - */ -const ROCK_FORBIDDEN_TILES = new Set(["lava", "lava-hot"]); - -/** - * `huge-volcanic-rock`'s collision box, 3 x 2.2 tiles. - * - * **Why the huge box everywhere, and not the box of whichever prototype wins the - * tile.** The obvious rule - `density` is `max(rockHuge, rockBig)`, so use the box - * of the argmax - is degenerate. `rockBig >= rockHuge` is a theorem, not a seed - * accident: the caps satisfy `0.2*(1 - 0.5a) >= 0.2*(1 - 0.75a)` for all - * `a = vulcanus_ashlands_biome` in `[0, 1]`, and the sloped branches satisfy - * `-1.0 + T > -1.2 + T` unconditionally, so the `min` of each pair is `>=` too. - * Measured `hugeWinShare = 0.0000` over all three oracle regions, with 16-19% exact - * ties where both caps bind at `a = 0`. So an argmax rule picks the small 1.5 x 1.5 - * box everywhere. Measured relative error against the game - * (`test/fixtures/oracle-entity-counts.seed123456.json`, regions 2/3/4): - * - * | box rule | region 2 | region 3 | region 4 | - * | --- | --- | --- | --- | - * | argmax (`>`), i.e. big everywhere | 23.5% | 27.1% | 13.1% | - * | argmax with ties to huge | 18.6% | 22.3% | 10.2% | - * | **huge everywhere** | **0.2%** | **0.6%** | **7.5%** | - * - * The game's own population is ~28% huge (region 2: 320 huge, 813 big), which the - * max-probability arbitration this port models cannot produce at all - it predicts - * 0% huge. So the tile-level huge/big identity is known WRONG here, not merely - * unvalidated; the claim this overlay makes is density, not identity. The - * falsification and a candidate mechanism (per-group arbitration, huge sorting - * first by autoplace order) are written up in `placement-roll-NOTES.md`. - * - * **What the measurement does and does not settle about the box.** It is not a - * derivation - the exclusion radius was CHOSEN by comparing two candidates. What - * the counts support is that the game sits BETWEEN the two models, close to the - * huge end: - * - * | region | all-huge (shipped) | game | all-big | game - all-huge | - * | --- | --- | --- | --- | --- | - * | 2 | 1131 | 1133 | 1399 | +2 (+0.2%) | - * | 3 | 1359 | 1367 | 1738 | +8 (+0.6%) | - * | 4 | 1341 | 1450 | 1640 | +109 (+7.5%) | - * - * The all-huge model **under**-counts in all three regions, and the all-big model - * overshoots by 13-27%. So the game's effective exclusion is *at most* huge-sized - - * slightly weaker than uniform-huge, nowhere near as weak as uniform-big. Task 4's - * "the truth sits between the two boxes" reading is the correct one and holds in - * every region. - * - * That residual points the same way as the open anomaly above rather than away from - * it: a population that is ~28% huge and ~72% big would place *more* rocks than a - * uniform-huge model, because the big rocks' smaller boxes let neighbours in - which - * is exactly the direction and rough magnitude of the shortfall. Stated as - * consistency, NOT as evidence: this overlay does not model the mixed population, - * and several unmodelled things push the same way (the game also arbitrates against - * ~1500 other entities per region, and collision is not modelled across chunk - * boundaries). - * - * Region 4's much larger 7.5% residual is worth a caveat rather than a conclusion. - * It is the densest of the three (1450 rocks vs 1133 and 1367) and the residual is - * monotone in that ordering, which is what a mixed population would predict - but a - * 28% density increase against a 40x residual increase is nowhere near proportional, - * so density alone does not explain it. Region 4 is also the spawn-centred window - * and the only one with geysers (56), so unmodelled cross-overlay arbitration - * concentrates there too. - * - * Both candidate boxes are real prototype data; measurement chose between them, and - * a different mechanism could reproduce the same totals. - */ -const VOLCANIC_ROCK_COLLISION_BOX: PlacementCollisionBox = { w: 3, h: 2.2 }; - -/** - * The shipped Vulcanus rock placement predicate: the roll against `density`, - * gated by tile restriction and collision. Exported so `entityDensity.spec.ts` - * measures the exact predicate the renderer paints, not a re-derivation of it. - */ -export function makeVulcanusRockPlacement( - ctx: EvalCtx, - shared?: VulcanusStack, -): (x: number, y: number) => boolean { - const { density } = makeVulcanusRockFields(ctx, shared); - // Derived from the ported tile resolver, NOT from rendered pixel colours: the - // chunk resolver asks about tiles outside the render window, and reading the - // ImageData would make the answer window-dependent. - const tileAt = - shared === undefined ? makeVulcanusTileResolver(ctx) : makeVulcanusTileResolverFrom(shared); - return makePlacementSet({ - salt: PLACEMENT_SALT.vulcanusRocks, - // Snapped to `ROCK_FIELD_LATTICE`, which ships at 1 (a no-op that returns - // `density` itself). The wrapper stays so the lattice is a one-constant - // experiment rather than a rewrite - see `rockCatalog.ts`. - probability: latticeSnapped(density, ROCK_FIELD_LATTICE), - tileAllowed: (x, y) => !ROCK_FORBIDDEN_TILES.has(tileAt(x, y).name), - collisionBox: () => VOLCANIC_ROCK_COLLISION_BOX, - }); -} - -export function renderVulcanusRocks(base: ImageData, opts: RenderVulcanusRocksOptions): void { - const { width, height } = base; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const ctx = withCtxDefaults({ seed0: opts.seed0, ...opts.ctx }); - const placed = makeVulcanusRockPlacement(ctx, opts.sharedStack); - - const box = opts.sweepBox; - const pxStart = box ? Math.round((box.x0 - originX) / tpp) : 0; - const pxEnd = box ? Math.round((box.x1 - originX) / tpp) : width; - const pyStart = box ? Math.round((box.y0 - originY) / tpp) : 0; - const pyEnd = box ? Math.round((box.y1 - originY) / tpp) : height; - - for (let py = pyStart; py < pyEnd; py++) { - const wy = originY + py * tpp; - for (let px = pxStart; px < pxEnd; px++) { - const wx = originX + px * tpp; - if (!placed(wx, wy)) continue; - paintMark(base, px, py, ROCK_MAP_COLOR, VULCANUS_ROCK_MARK_RADIUS_PX); - } - } -} diff --git a/src/noise/preview/renderVulcanusTerrain.ts b/src/noise/preview/renderVulcanusTerrain.ts deleted file mode 100644 index 1c5c8b00..00000000 --- a/src/noise/preview/renderVulcanusTerrain.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { EvalCtxInput } from "../eval/ctx"; -import { - type VulcanusStack, - makeVulcanusTileResolver, - makeVulcanusTileResolverFrom, -} from "../tiles/vulcanusCatalog"; - -export interface RenderVulcanusTerrainOptions { - /** - * Vulcanus field stack to render through. The composite path builds ONE - * cached stack and hands the same instance to every overlay, so each pass - * reuses the field values the others already computed. - */ - stack?: VulcanusStack; - /** Map seed (= map_seed / seed0). Callers resolve a null "random" seed first. */ - readonly seed0: number; - /** Output pixel dimensions. */ - readonly width: number; - readonly height: number; - /** World tile at the top-left pixel. Default (0, 0). */ - readonly originX?: number; - readonly originY?: number; - /** World tiles per pixel. Default 1. */ - readonly tilesPerPixel?: number; - /** - * Non-seed resolver params (see `EvalCtx`'s Vulcanus fields, - * `withCtxDefaults`). `startingPositions` (which affects the - * `lava_spawn_excluder` distance term) is threaded through, and since V2 - * restored the resource-coupling terms into the tile catalog - * (`docs/noise/vulcanus-resources-NOTES.md`), `vulcanusResourceControls` is - * now threaded on every terrain-family call too - the tile catalog reads - * `metalTile`/`calciteRegion`/`sulfuricAcidRegionPatchy`, all of which depend - * on the resource control levers (size/frequency), not just the seed. - */ - readonly ctx?: Omit; -} - -/** - * Sweep a `width x height` pixel grid over world space and return an - * `ImageData` painted with each pixel's winning Vulcanus tile's `map_color` - * (Task 11 - mirrors `renderTerrain`, but resolves through - * `makeVulcanusTileResolver` (Task 10) instead of the Nauvis catalog). - * - * Deliberately has NO water/deepwater early-out: Vulcanus has no water tile at - * all (lava plays that visual role but is resolved by the same argmax as every - * other tile), so the Nauvis fast-path's premise does not apply here. Every - * pixel runs the full 19-tile argmax. - */ -export function renderVulcanusTerrain(opts: RenderVulcanusTerrainOptions): ImageData { - const { width, height, seed0 } = opts; - const originX = opts.originX ?? 0; - const originY = opts.originY ?? 0; - const tpp = opts.tilesPerPixel ?? 1; - - const resolve = - opts.stack === undefined - ? makeVulcanusTileResolver({ seed0, ...opts.ctx }) - : makeVulcanusTileResolverFrom(opts.stack); - - const data = new Uint8ClampedArray(width * height * 4); - - for (let py = 0; py < height; py++) { - const wy = originY + py * tpp; - for (let px = 0; px < width; px++) { - const wx = originX + px * tpp; - - const color = resolve(wx, wy).color; - - const o = (py * width + px) * 4; - data[o] = color[0]; - data[o + 1] = color[1]; - data[o + 2] = color[2]; - data[o + 3] = 255; - } - } - - return new ImageData(data, width, height); -} diff --git a/src/noise/quickMultioctaveNoise.ts b/src/noise/quickMultioctaveNoise.ts deleted file mode 100644 index 79f6978c..00000000 --- a/src/noise/quickMultioctaveNoise.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * A reimplementation of Factorio's `quick_multioctave_noise` primitive - * (`NoiseExpressions::QuickMultioctaveNoise`), reverse-engineered against Factorio - * 2.1.11 - by disassembling `QuickMultioctaveNoise::run` and fitting the committed - * oracle. See docs/noise/quick-multioctave-noise-NOTES.md. Built on {@link basisNoise}. - * - * The shape: - * - * quick(x, y) = SUM_{k=0}^{N-1} OS*OOSM^k * - * basis( (x + offset_x) * IS*OISM^k , y * IS*OISM^k ; - * tables(octaveSeed0(seed0, seed1, k), seed1) ) - * - * i.e. N octaves of `basis_noise`, each with input scale multiplied by - * `octave_input_scale_multiplier` (OISM) and output contribution multiplied by - * `octave_output_scale_multiplier` (OOSM). Unlike the plain `multioctave_noise` - * op there is NO RMS normalisation (it is the "raw" building block; the - * `quick_multioctave_noise_persistence` Lua wrapper pre-scales `input_scale` and - * `output_scale` to compensate) and NO per-octave x offset in noise space - octaves - * are decorrelated by re-seeding instead: each octave gets its own distinct basis - * seed word, a flat `seed0 + k`; see {@link octaveSeed0} for the exact derivation - * and the low-bit subtlety that once masked this. - * `offset_x` is a single world-space x translation applied to every octave - * (`(x + offset_x)` before scaling), NOT the k*17.17/offset_x per-octave shift the - * plain / variable-persistence ops use. - * - * The temperature / moisture / aux climate trees use this op (each passes - * `offset_x = / var('control::frequency')`). - * - * **The arithmetic is f32, rounded after every operation.** This op used to - * evaluate in pure f64 and score 38 of 190 exact against the committed oracle, - * with a near/far split its spec blamed on "the game's f32 coordinate pipeline - * diverges from our f64 - the documented f32 floor". There was no floor. The op - * is now **190/190 bit-exact, worst error exactly 0**, and the near/far split is - * gone with it - the same correction the plain and variable-persistence relatives - * already took (see their notes, and `src/noise/eval/f32.ts`). - * - * Four ingredients, each measured load-bearing by turning it off alone and - * re-scoring the whole fixture: - * - * | leave one out | exact | - * | --- | --- | - * | all four | **190/190** | - * | params not narrowed to f32 | 109/190 | - * | `amp * basis` not rounded before the add | 132/190 | - * | scale/amp by `OISM**k` instead of a running chain | 143/190 | - * | scale/amp chain steps not rounded | 137/190 | - * | none of them (the old f64 shape) | 38/190 | - * - * Narrowing params matters because the callers' values have no exact f32 form - - * `octave_output_scale_multiplier` 0.6/0.65/0.7, `input_scale` 0.1/0.08/(1/6), - * `octave_input_scale_multiplier` 0.55. That is `f32.ts`'s "narrow the CONSTANT" - * case, and no amount of rounding the result recovers it. - * - * Note what an error bound could not see here: dropping only the `amp * basis` - * rounding leaves the worst residual at 4.8e-7 - visually perfect - while 58 - * points stop being bit-exact. Exact-match counting is the only scoring that - * discriminates, which is why this op's spec now asserts counts and zeros. - * - * NOT wired into the app - a building block for a client-side map preview. - */ - -import { basisNoise, basisNoiseTablesFromSeed, type BasisNoiseTables } from "./basisNoise"; -import { f32 } from "./eval/f32"; -import { fastPow } from "./fastApprox"; - -export interface QuickMultioctaveParams { - /** Map seed (basis seed word). */ - readonly seed0: number; - /** Per-call seed selector (distinguishes the many multioctave calls a program makes). */ - readonly seed1: number; - /** Octave count (>= 1). */ - readonly octaves: number; - /** Base input scale (noise units per world tile) for octave 0. */ - readonly inputScale: number; - /** Overall output multiplier applied to octave 0. */ - readonly outputScale: number; - /** Amplitude ratio between successive octaves (`octave_output_scale_multiplier`). */ - readonly octaveOutputScaleMultiplier: number; - /** Input-scale ratio between successive octaves (`octave_input_scale_multiplier`). */ - readonly octaveInputScaleMultiplier: number; - /** World-space x translation applied to every octave (`(x + offsetX)` before scaling). */ - readonly offsetX: number; -} - -/** - * The `seed0` to feed {@link basisNoiseTablesFromSeed} for octave `k`: a simple - * per-octave `+1` on top of the map seed, `seed0 + k` (`>>> 0` keeps it an - * unsigned 32-bit word). `seed1` is not part of this derivation - it is only - * `basisNoiseTablesFromSeed`'s own `+ 7*(seed1>>8)` term (applied once there) - * that folds `seed1` into the final basis word. - * - * An earlier version of this function derived a `phase = (7*(seed1>>8)) & 1` - * and a "+2 every pair of octaves" cadence instead of a flat `+1`. That was a - * mistaken over-fit: `taus88`'s `s1` update masks its input with - * `0xfffffffe` (clears the low bit) before the first left-shift, so for an - * EVEN starting word `W`, `basisNoiseTablesFromSeed(W, seed1)` and - * `basisNoiseTablesFromSeed(W + 1, seed1)` happen to produce byte-identical - * tables - which makes "+2 per pair" and "+1 per octave" numerically - * indistinguishable whenever the pair's base word is even. Every prior oracle - * capture used `seed0 = 123456` (even), so the coincidence was never exposed. - * Task 10's tile-resolver parity test (3 seeds, one of them ODD - 654321) - * caught it: per-octave isolation against the live game (quick_multioctave_noise - * sampled at octaves=1..4 and differenced) showed octave 0 and 2 matching the - * old formula but octaves 1 and 3 diverging by ~0.02-0.05 - exactly the two - * octaves the old formula reused an even-derived word for, when the true word - * for an ODD seed0 is one higher (odd) and does NOT collide. The flat `+1` - * reproduces the live game to the basis floor for both parities, and remains - * bit-identical to the old formula's output at seed 123456 (validated against - * the full `oracle-quick-multioctave` fixture, including its one phase>=1 - * case, seed1=999). - */ -function octaveSeed0(seed0: number, _seed1: number, k: number): number { - return (seed0 + k) >>> 0; -} - -/** The per-octave tables, input scales and amplitudes, plus the f32 x offset. */ -interface QuickOctaves { - readonly tables: BasisNoiseTables[]; - readonly scales: number[]; - readonly amps: number[]; - readonly offsetX: number; -} - -/** - * Derive the per-octave terms exactly as `QuickMultioctaveNoise::run` emits them. - * - * `run` is a register-program builder, not a runtime loop: it unrolls N explicit - * `BasisNoise` ops, multiplying the running input scale (`s8 *= s12`) and output - * scale (`s9 *= s13`) per octave. Those registers are **f32**, so the chain is a - * chain - each step rounds, and the k-th scale is not `input_scale * OISM**k`. - * That distinction is worth 143/190 against 190/190, so it is measured rather - * than stylistic; see the table in the module header. - * - * The four incoming parameters are narrowed here because the game holds them in - * f32 constant slots, and the values callers actually pass (0.6, 0.65, 0.7, 0.1, - * 0.08, 1/6, 0.55) have no exact f32 form. - * - * Every octave gets its own distinct seed word (flat `seed0 + k`), so the tables - * are built once per octave here rather than cached against the previous word - - * consecutive octaves never share one. See {@link octaveSeed0}. - */ -function octaveTerms(params: QuickMultioctaveParams): QuickOctaves { - const { seed0, seed1, octaves } = params; - const oism = f32(params.octaveInputScaleMultiplier); - const oosm = f32(params.octaveOutputScaleMultiplier); - - const tables: BasisNoiseTables[] = []; - const scales: number[] = []; - const amps: number[] = []; - let scale = f32(params.inputScale); - let amp = f32(params.outputScale); - for (let k = 0; k < octaves; k++) { - tables.push(basisNoiseTablesFromSeed(octaveSeed0(seed0, seed1, k), seed1)); - scales.push(scale); - amps.push(amp); - scale = f32(scale * oism); - amp = f32(amp * oosm); - } - return { tables, scales, amps, offsetX: f32(params.offsetX) }; -} - -/** - * Sum the octaves in the game's order, rounding to f32 after every operation. - * - * Two roundings here carry the bulk of the fix, and both were confirmed by - * removing them one at a time and re-scoring the whole fixture: - * - * - **`amp * basis(...)` is rounded before it is added.** Each is its own - * register op, so the product lands in f32 before the accumulate. Dropping - * just this one costs 58 exact matches (190 -> 132) while leaving the worst - * residual at 4.768e-7 - which is exactly why this op is scored by exact - * count and not by a bound. - * - **The running total is f32.** `out[i] = out[i] + ...` in the vector kernel, - * never an f64 accumulator narrowed once on return. - * - * `x + offset_x` is hoisted out of the loop because it does not depend on k; - * that is the same arithmetic, not a shortcut. **Whether the game rounds that - * add before the multiply is NOT resolved by this fixture** - narrowing only - * the product scores 190/190 and worst 0 as well, because every - * `(position + offset_x)` the fixture uses is already exact in f32. The inner - * narrowing is kept because it is what a register machine does and what - * {@link variablePersistenceMultioctaveNoise}'s identical `(x + offset_x)` - * step does; a caller passing a derived x is where the two forms would part, - * and no fixture covers that yet. - * - * The incoming `x`/`y` are narrowed for the reason #191 gives - the noise - * machine hands every expression an f32 - but note this fixture cannot see - * that either: all 38 of its positions are already on the f32 grid, and - * turning the narrowing off leaves the score at 190/190. - */ -function sumOctaves(x: number, y: number, t: QuickOctaves): number { - const xo = f32(f32(x) + t.offsetX); - const yf = f32(y); - let sum = 0; - for (let k = 0; k < t.scales.length; k++) { - const s = t.scales[k]; - sum = f32(sum + f32(t.amps[k] * basisNoise(f32(xo * s), f32(yf * s), t.tables[k]))); - } - return sum; -} - -/** - * Evaluate `quick_multioctave_noise` at world coordinates `(x, y)`. Bit-exact - * against the committed oracle: 190/190, worst error 0. - */ -export function quickMultioctaveNoise( - x: number, - y: number, - params: QuickMultioctaveParams, -): number { - return sumOctaves(x, y, octaveTerms(params)); -} - -/** - * Build a closure that evaluates `quick_multioctave_noise` for a fixed parameter set, - * with the per-octave basis tables, input scales and amplitudes derived once up front - * (the common case for rendering a grid at one seed). Returns `(x, y) => number`, - * numerically identical to {@link quickMultioctaveNoise} - both route through the same - * {@link octaveTerms} / {@link sumOctaves} pair, so they cannot drift apart. - */ -export function makeQuickMultioctaveNoise( - params: QuickMultioctaveParams, -): (x: number, y: number) => number { - const t = octaveTerms(params); - return (x: number, y: number): number => sumOctaves(x, y, t); -} - -/** - * The noise machine's `^`, in f32. - * - * It is **three different functions**, dispatched on the exponent - exact - * exponentiation by squaring for an integer, exact `sqrt` for 0.5, and - * fastapprox (`Math::powSafe`) otherwise. That was settled against - * `oracle-fastpow.seed123456.json` at 123/123 per branch (#161, #163), and the - * 0.5 case was a refutation of the then-current model rather than a - * confirmation - do not collapse these back into one call. - * - * Only the integral branch is exercised by anything here (`octaves` is a whole - * number in every base-game caller), but the other two are spelled out because - * a wrong branch is silent: it returns a plausible number. - * - * **Exported for the Rust port's tier-2 parity check** (`test/wasmEvalParity.spec.ts`), - * which compares this against `fmw_noise::fast_approx::noise_machine_pow` over a - * sweep. Comparing the port against a copy of this function reimplemented in the - * spec would prove nothing, so the shipped one is what gets exported. It has no - * other caller outside this file. - */ -export function noiseMachinePow(base: number, exponent: number): number { - if (exponent === 0.5) return f32(Math.sqrt(f32(base))); - if (!Number.isInteger(exponent) || exponent < 0) return f32(fastPow(f32(base), f32(exponent))); - let result = 1; - let b = f32(base); - let e = exponent; - while (e > 0) { - if (e & 1) result = f32(result * b); - b = f32(b * b); - e >>= 1; - } - return result; -} - -export interface QuickMultioctavePersistenceParams { - /** Map seed (basis seed word). */ - readonly seed0: number; - /** Per-call seed selector. */ - readonly seed1: number; - /** Octave count (>= 1). */ - readonly octaves: number; - /** Base input scale (noise units per world tile). */ - readonly inputScale: number; - /** Overall output multiplier. */ - readonly outputScale: number; - /** Input-scale ratio between successive octaves. */ - readonly octaveInputScaleMultiplier: number; - /** Amplitude ratio between successive octaves. */ - readonly persistence: number; -} - -/** - * `quick_multioctave_noise_persistence` - the Lua wrapper - * (`core/prototypes/noise-functions.lua`) over {@link quickMultioctaveNoise}. It - * normalises the raw quick op by pre-scaling `input_scale` and `output_scale`, and - * maps `persistence` to the octave output multiplier: - * - * input_scale = input_scale * octave_input_scale_multiplier^(octaves - 1) - * output_scale = output_scale * 2^(octaves - 1) - * octave_output_scale_multiplier = persistence - * octave_input_scale_multiplier = 1 / octave_input_scale_multiplier - * - * so the finest octave lands at `input_scale` and the sum is `2^(N-1)`-scaled. The - * elevation tree's `starting_lake_noise` uses this. `offset_x` defaults to 0. - * - * **The transform is f32, not f64, and that is worth 1.964e-3.** It is tempting - * to read "Lua wrapper" as "Lua arithmetic, therefore doubles". It is not: the - * wrapper is a `noise-function` whose body is an *expression string* - * (`core/prototypes/noise-functions.lua`), which the game's noise machine - * compiles and folds - in f32, one operation at a time, like everything else it - * evaluates. Doing the transform in f64 left this wrapper at 114/152 exact and - * worst 1.964e-3 even after the op underneath it became bit-exact; doing it in - * f32 makes it **152/152, worst 0**. - * - * `^` here has an integral exponent, and the noise machine's `^` is three - * different functions selected by that exponent - exact exponentiation by - * squaring for integers, exact `sqrt` for 0.5, fastapprox otherwise (#161, - * #163). {@link noiseMachinePow} implements that dispatch. **This fixture cannot - * discriminate the integral branch**: `Math.pow` narrowed to f32 also scores - * 152/152 here, because the only bases are 0.5 and 0.6 at exponents 0, 2, 3 and - * 4. Squaring is used because it is what the game does, not because the fixture - * chose it. - */ -export function quickMultioctaveNoisePersistence( - x: number, - y: number, - params: QuickMultioctavePersistenceParams, -): number { - return makeQuickMultioctaveNoisePersistence(params)(x, y); -} - -/** - * Build a closure that evaluates `quick_multioctave_noise_persistence` for a fixed - * parameter set, with the per-octave basis tables derived once up front (the common - * case for rendering a grid at one seed). Applies the same param transform as - * {@link quickMultioctaveNoisePersistence} but delegates to - * {@link makeQuickMultioctaveNoise} so the tables are hoisted. Returns `(x, y) => number`. - */ -export function makeQuickMultioctaveNoisePersistence( - params: QuickMultioctavePersistenceParams, -): (x: number, y: number) => number { - const { octaves, octaveInputScaleMultiplier: oism } = params; - const oismF = f32(oism); - return makeQuickMultioctaveNoise({ - seed0: params.seed0, - seed1: params.seed1, - octaves, - inputScale: f32(f32(params.inputScale) * noiseMachinePow(oismF, octaves - 1)), - outputScale: f32(f32(params.outputScale) * noiseMachinePow(2, octaves - 1)), - octaveOutputScaleMultiplier: f32(params.persistence), - octaveInputScaleMultiplier: f32(1 / oismF), - offsetX: 0, - }); -} diff --git a/src/noise/randomPenalty.ts b/src/noise/randomPenalty.ts deleted file mode 100644 index 376b1bd8..00000000 --- a/src/noise/randomPenalty.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Factorio's `random_penalty` noise operation (`NoiseOperations::RandomPenalty`), - * reverse-engineered from the non-stripped 2.1.11 binary - * (`RandomPenalty.cpp` / `RandomPenalty::run`) and verified against the headless - * oracle. See docs/noise/random-penalty-NOTES.md. - * - * output[i] = source[i] - amplitude * (taus88_next() / 2^32) // U in [0,1) - * - * Two facts make this a BATCH op, not a pure per-position function: - * - * 1. The RNG is seeded ONCE, from the FIRST position in the batch: - * word = max(341, 0x3FBE2C + 7919*trunc(x0) + 7907*trunc(y0 + seed)) (u32) - * (same RNG family as spot_noise: base 0x3FBE2C, primes 7919/7907, the 341 - * clamp; no map_seed dependence; `seed` is folded into y before truncation.) - * taus88 state s1=s2=s3=word. - * 2. The taus88 stream is then consumed across the batch, processed from the LAST - * element to the FIRST (the game's index counts down). A tile whose `source` is - * <= 0 is passed through unchanged and consumes NO draw (the documented - * "source must be > 0" guard). - * - * So the value at a given (x, y) depends on the whole batch and its order - which - * is why a bare `calculate_tile_properties` probe cannot oracle this in isolation. - * Callers must supply the batch in the same order the game evaluates it. - * - * ## This op computes in f64 and narrows ONCE - it is the exception to the f32 rule - * - * Everywhere else in `src/noise/` the rule is f32 after every operation (see - * `eval/f32.ts`). Here it would produce a WRONG answer. `RandomPenalty::run` - * widens both f32 inputs to double, runs the whole chain in double, and narrows - * a single time at the store: - * - * +348 ldr s6, [x11, x8, lsl #2] // source, f32 in the register buffer - * +352 fcvt d5, s6 // widened to DOUBLE - * +416 ucvtf d6, w14 // the u32 draw -> DOUBLE - * +424 fmul d6, d6, d7 // * -2^-32 (a DOUBLE constant, 0xBDF0...) - * +432 fcvt d7, s7 // amplitude, f32 constant -> DOUBLE - * +436 fmul d6, d6, d7 // * amplitude, in DOUBLE - * +440 fadd d5, d5, d6 // + source, in DOUBLE - * +328 fcvt s5, d5 // narrowed to f32 exactly once - * +332 str s5, [x10, x8, lsl #2] // and stored as f32 - * - * So a Rust port must use `f64` internally and cast to `f32` on the way out. - * Writing this one in f32 throughout is the mistake this comment exists to stop. - * - * The `f32` on the store is load-bearing and was missing until 2026-08-18: 36 of - * the fixture's 40 values are not f32 without it, worst gap 1.668e-5, and the - * only consumer (`resources/regularPatches.ts`) multiplies the returned value. - * Narrowing first changes that product in 1240 of 5840 swept cases, worst 1.19e-7 - * relative. `test/randomPenalty.spec.ts` asserts the return is f32 directly, so - * removing the narrowing goes red rather than being absorbed by a bound. - * - * Two narrowings the binary also does are deliberately NOT reproduced, because - * nothing can currently observe them and an unobservable change is - * indistinguishable from a mistake (the rule #191 sets out): - * - * - `source` is read as f32 at +348. Every shipped source value is f32-exact. - * - `amplitude` is read as f32 at +428. `random_penalty_between(min, max, 1)` - * gives 2-0.25, 1-1 and 4-2 across the whole resource catalog, and - * `random_penalty_at(6, 1)` gives 6 - all f32-exact. Only - * `random_penalty_inverse`, whose amplitude is `1/penalty`, could produce a - * non-f32 amplitude, and nothing in base or space-age calls it. Measured: at - * amplitude 1/3 the two readings differ on 1 of 8 outputs by 5.96e-8. - */ -import { f32 } from "./eval/f32"; -import { seededState, taus88Next } from "./taus88"; - -/** 2^32, the normalization the binary applies (int32 draw * 2^-32 -> [0,1)). */ -const TWO_POW_32 = 4294967296; -/** taus88 all-zero fixed-point guard, applied to the final word (unsigned). */ -const WORD_FLOOR = 0x155; // 341 - -/** A batch position, in world tiles (fractional allowed; truncated toward zero). */ -export interface RandomPenaltyPosition { - readonly x: number; - readonly y: number; -} - -/** - * The per-region/per-batch seed word: `max(341, 0x3FBE2C + 7919*trunc(x0) + - * 7907*trunc(y0 + seed))` in unsigned 32-bit arithmetic, from the first batch - * position's coordinates. Exposed for tests and for callers that reproduce the - * stream directly. - */ -export function randomPenaltyWord(x0: number, y0: number, seed: number): number { - const xi = Math.trunc(x0) | 0; - const yi = Math.trunc(y0 + seed) | 0; - const w = (0x3fbe2c + Math.imul(xi, 7919) + Math.imul(yi, 7907)) >>> 0; - return (w > WORD_FLOOR ? w : WORD_FLOOR) >>> 0; -} - -/** - * Evaluate `random_penalty{source, amplitude, seed}` over an ordered batch, - * reproducing `RandomPenalty::run` bit-for-bit. `source[i]` is the (already - * evaluated) source value at `positions[i]`; the result aligns with `positions`. - * - * The RNG is seeded from `positions[0]` and streamed from the last element to the - * first; a `source[i] <= 0` element passes through unchanged and consumes no draw. - */ -export function randomPenaltyBatch( - positions: readonly RandomPenaltyPosition[], - source: readonly number[], - params: { seed: number; amplitude: number }, -): number[] { - const { seed, amplitude } = params; - const out: number[] = Array.from({ length: positions.length }, () => 0); - if (positions.length === 0) return out; - - const st = seededState(randomPenaltyWord(positions[0].x, positions[0].y, seed)); - // Processed last element -> first, matching the binary's descending index. - for (let i = positions.length - 1; i >= 0; i--) { - const s = source[i]; - if (s > 0) { - const u = taus88Next(st) / TWO_POW_32; - // The chain above is f64 on purpose (see the header). The `f32` here is - // the op's single narrowing - `fcvt s5, d5` at +328, then `str s5`. - out[i] = f32(s - amplitude * u); - } else { - // The pass-through path stores `source` unchanged, and `source` was read - // from an f32 register slot, so this needs no narrowing of its own. - out[i] = s; - } - } - return out; -} diff --git a/src/noise/resources/regularPatches.ts b/src/noise/resources/regularPatches.ts deleted file mode 100644 index 189ada24..00000000 --- a/src/noise/resources/regularPatches.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * The `regular_patches` branch of `resource_autoplace_all_patches` (the whole-map - * ore patches), ported over the solved primitives: `selectSpots` (spot selection), - * `basisNoise` (blob noise) and `randomPenalty` (per-spot size jitter). Starting - * patches and the outer `max(starting, regular)` are M3b. - * - * regular_patches = spotField + (blobs0 + basis_noise{1/64,1.5} - 1/3) * blobAmplitude(distance) - * spotField = max(basement_value, max over nearby spots of (peak - dist*slope)) - * blobs0 = basis_noise{1/8,1} + basis_noise{1/24,1} - * - * The empirical unknown, resolved against the oracle (docs/noise/random-penalty-NOTES.md - * "Composition inside spot selection - RESOLVED"): a spot's - * `regular_spot_quantity_expression = random_penalty_between(min,max,1) * - * quantityBase(distance)`. `random_penalty` is a batch op, and the game evaluates the - * quantity expression over ALL skip-set accepted spots as ONE batch (in acceptance - * order, seeded from the first spot, streamed) before the trim - NOT per spot. This is - * supplied via selectSpots' `quantityBatch`. - * - * Precision: the game's spot_noise op renders the cone in the f32 noise machine, with - * the radius cube root through its fastapprox `pow` ({@link fastCbrt}). Matching that - * (fastCbrt + f32 cone/quantity arithmetic here) pins the field to the game within - * ~0.7 units everywhere; exact Math.cbrt + f64 left a ~3-unit / 4.8e-2-relative - * residual at cone edges. See docs/noise/random-penalty-NOTES.md and test/regularPatches.spec.ts. - */ -import { basisNoise, basisNoiseTablesFromSeed, type BasisNoiseTables } from "../basisNoise"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { f32 } from "../eval/f32"; -import { fastCbrt } from "../fastApprox"; -import { randomPenaltyBatch } from "../randomPenalty"; -import { selectSpots, type SelectedSpot } from "../spotSelection"; -import type { SpotRegionKey } from "../spotCandidates"; -import type { ResourceParams } from "./resourceCatalog"; -import { - basementValue, - DOUBLE_DENSITY_DISTANCE, - REGULAR_PATCH_FADE_IN_DISTANCE, - regularBlobAmplitudeAt, - regularDensityAt, - regularSpotQuantityBaseAt, - type ResourceControls, -} from "./resourceMath"; - -/** suggested_minimum_candidate_point_spacing for the regular set (= 32*sqrt(2)). */ -const REGULAR_SPACING = 45.254833995939045; -const REGION_SIZE = 1024; -/** maximum_spot_basement_radius - the per-query cone cull radius. */ -const MAX_SPOT_BASEMENT_RADIUS = 128; -/** spot_radius_expression cap: min(32, rq * quantity^(1/3)). */ -const MAX_SPOT_RADIUS = 32; - -export interface RegularPatchesCtx { - readonly seed0: number; - /** control::frequency, size, richness (the noise-function multipliers). */ - readonly controls: { frequency: number; size: number; richness: number }; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; - /** regular_patch_set_count (skip_span). 1 for the isolated oracle; 6 in the app. */ - readonly skipSpan?: number; - /** regular_patch_set_index (skip_offset). */ - readonly skipOffset?: number; -} - -export interface RegularPatches { - /** Raw `regular_patches` field value (= the oracle's all_patches with has_starting=0). */ - field(x: number, y: number): number; - /** clamp(field, 0, 1) - the M3a solid-footprint probability (stipple deferred). */ - probability(x: number, y: number): number; - /** The autoplace richness at (x, y) (0 where size <= 0). */ - richness(x: number, y: number): number; -} - -const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi); -/** region index for a coordinate (regions centred on multiples of REGION_SIZE). */ -const regionIndex = (c: number): number => Math.floor((c + REGION_SIZE / 2) / REGION_SIZE); - -export function makeRegularPatches(params: ResourceParams, ctx: RegularPatchesCtx): RegularPatches { - const controls: ResourceControls = { frequency: ctx.controls.frequency, size: ctx.controls.size }; - const spawn: readonly Point[] = ctx.startingPositions ?? [{ x: 0, y: 0 }]; - const distanceAt = (x: number, y: number): number => - distanceFromNearestPoint(x, y, spawn as Point[]); - - const tables: BasisNoiseTables = basisNoiseTablesFromSeed(ctx.seed0, params.seed1); - const basement = basementValue(params, controls); - - const skipSpan = ctx.skipSpan ?? 1; - const skipOffset = ctx.skipOffset ?? 0; - - // regular_spot_quantity_expression = random_penalty_between(min, max, 1) * - // quantityBase(distance), evaluated at ALL skip-set spots as ONE batch (in - // acceptance order) - random_penalty is a batch op seeded from the first spot and - // streamed, so a spot's jitter depends on the whole spot list, not just itself. - const source = params.randomSpotSizeMax; - const amplitude = params.randomSpotSizeMax - params.randomSpotSizeMin; - const spotQuantityBatch = (spots: readonly { x: number; y: number }[]): number[] => { - const jitter = randomPenaltyBatch( - spots, - spots.map(() => source), - { seed: 1, amplitude }, - ); - return spots.map((s, i) => - f32(jitter[i] * f32(regularSpotQuantityBaseAt(distanceAt(s.x, s.y), params, controls))), - ); - }; - - // Selected spots per region, memoized (all resources in a set share the stream). - const regionCache = new Map(); - const regionSpots = (rX: number, rY: number): SelectedSpot[] => { - const key = `${rX},${rY}`; - let spots = regionCache.get(key); - if (spots) return spots; - const regionKey: SpotRegionKey = { - seed0: ctx.seed0, - seed1: params.seed1, - regionX: rX, - regionY: rY, - }; - spots = selectSpots(regionKey, { - density: (x, y) => regularDensityAt(distanceAt(x, y), params, controls), - quantity: () => 0, // unused: quantityBatch overrides - quantityBatch: spotQuantityBatch, - favorability: () => 1, - regionSize: REGION_SIZE, - candidateSpotCount: params.candidateSpotCount, - spacing: REGULAR_SPACING, - skipSpan, - skipOffset, - hardRegionTargetQuantity: false, - }); - regionCache.set(key, spots); - return spots; - }; - - const spotFieldAt = (x: number, y: number): number => { - let best = basement; - const rXlo = regionIndex(x - MAX_SPOT_BASEMENT_RADIUS); - const rXhi = regionIndex(x + MAX_SPOT_BASEMENT_RADIUS); - const rYlo = regionIndex(y - MAX_SPOT_BASEMENT_RADIUS); - const rYhi = regionIndex(y + MAX_SPOT_BASEMENT_RADIUS); - for (let rX = rXlo; rX <= rXhi; rX++) { - for (let rY = rYlo; rY <= rYhi; rY++) { - for (const s of regionSpots(rX, rY)) { - const dx = x - s.x; - const dy = y - s.y; - const d2 = dx * dx + dy * dy; - if (d2 > MAX_SPOT_BASEMENT_RADIUS * MAX_SPOT_BASEMENT_RADIUS) continue; - // The cone is rendered per-tile by the game's spot_noise op, in the f32 - // noise machine (cube root via fastapprox `pow`); f64/exact-cbrt leaves the - // ~1e-3 residual at cone edges (docs/noise/random-penalty-NOTES.md). - // regular patches have no hard-target shrink, so coneScale === 1. - const radius = Math.min( - MAX_SPOT_RADIUS, - f32(params.regularRqFactor * fastCbrt(s.quantity)), - ); - const peak = f32(f32(3 * s.quantity) / f32(f32(Math.PI * radius) * radius)); - const cone = f32(peak - f32(f32(Math.sqrt(d2)) * f32(peak / radius))); - if (cone > best) best = cone; - } - } - } - return best; - }; - - const blobTermAt = (x: number, y: number): number => { - const blobs0 = basisNoise(x / 8, y / 8, tables) + basisNoise(x / 24, y / 24, tables); - const extra = 1.5 * basisNoise(x / 64, y / 64, tables); - return (blobs0 + extra - 1 / 3) * regularBlobAmplitudeAt(distanceAt(x, y), params, controls); - }; - - const field = (x: number, y: number): number => spotFieldAt(x, y) + blobTermAt(x, y); - - const richnessDistanceFactor = (distance: number): number => { - // max((double_density_distance - fade_in + distance) / (double_density_distance*2), 1) - // fade_in term applies because none of the base resources pass has_starting = nil. - return Math.max( - (DOUBLE_DENSITY_DISTANCE - REGULAR_PATCH_FADE_IN_DISTANCE + distance) / - (DOUBLE_DENSITY_DISTANCE * 2), - 1, - ); - }; - - return { - field, - probability: (x, y) => (ctx.controls.size > 0 ? clamp(field(x, y), 0, 1) : 0), - richness: (x, y) => { - if (ctx.controls.size <= 0) return 0; - let r = field(x, y) / params.randomProbability; - r += params.additionalRichness; - if (params.minimumRichness > 0) r = Math.max(r, params.minimumRichness); - return ( - params.richnessPostMultiplier * - ctx.controls.richness * - r * - richnessDistanceFactor(distanceAt(x, y)) - ); - }, - }; -} diff --git a/src/noise/resources/resolveResource.ts b/src/noise/resources/resolveResource.ts deleted file mode 100644 index dc9a2a1e..00000000 --- a/src/noise/resources/resolveResource.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Order-priority overlay resolver: at a world tile, which resource patch (if any) - * is drawn. The game places resources in autoplace `order` sequence and a later - * patch overwrites an earlier one where they overlap; the map preview mirrors that - * by letting the FIRST resource in order-priority whose `probability >= 0.5` win - * (M3a solid-footprint - the per-tile stipple roll is deferred to M3.5). - * - * Priority = autoplace `order` ("b" before "c"), then `patchSetIndex` (init order) - * within an order. All six resources share one candidate stream partitioned by - * `skip_span = 6` / `skip_offset = patchSetIndex` (regular set), so each resource's - * field is built with those skip params (unlike the pure-regular oracle, which uses - * span 1). See docs/superpowers/plans/2026-07-19-milestone3a-regular-patches.md T5. - * - * M3b adds the starting (near-spawn guaranteed) patches for the four solids: each - * resource's field is now `makeResourcePatches` = `max(starting, regular)` (solids) - * or plain regular (oil/uranium, unchanged). The four solids' starting-set stream is - * partitioned by `skip_span = 4` / `skip_offset = patchSetIndex` (only iron, copper, - * coal, stone have `hasStartingAreaPlacement`, and they register first, so their - * starting index equals their regular `patchSetIndex`). The starting favorability - * couples to the map's `elevation` property (elevation_nauvis on default Nauvis), - * hence the `segmentationMultiplier`/`waterLevel`/`startingLakePositions` ctx fields. - */ -import type { Point } from "../distanceFromNearestPoint"; -import { makeResourcePatches, type ResourcePatches } from "./resourcePatches"; -import { - RESOURCE_CATALOG, - type ResourceControlLevers, - type ResourceParams, -} from "./resourceCatalog"; - -export interface ResourceResolverCtx { - readonly seed0: number; - /** Per-resource control levers, keyed by `controlName`; missing => all-default. */ - readonly controls: Record; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; - /** elevation inputs for the starting favorability coupling (solids only). */ - readonly segmentationMultiplier?: number; - readonly waterLevel?: number; - readonly startingLakePositions?: readonly Point[]; -} - -const DEFAULT_LEVERS: ResourceControlLevers = { frequency: 1, size: 1, richness: 1 }; - -/** The regular set shares one candidate stream across all 6 resources. */ -const REGULAR_SKIP_SPAN = 6; -/** The starting set shares one candidate stream across the 4 solids. */ -const STARTING_SKIP_SPAN = 4; - -const orderRank = (o: "b" | "c"): number => (o === "b" ? 0 : 1); - -/** - * Draw priority between two resources: negative when `a` is drawn in preference to - * `b` (autoplace `order` "b" before "c", then lower `patchSetIndex`). - * - * Exported because `renderResources` needs the *same* rule for a resource this - * resolver deliberately does not hold - crude oil is painted in its own pass, and - * whether the threshold pass may overwrite an oil mark is exactly this comparison. - * Two copies of the rule is how the oil-vs-uranium inversion of #22 item 3 got in. - */ -export function comparePriority(a: ResourceParams, b: ResourceParams): number { - return orderRank(a.order) - orderRank(b.order) || a.patchSetIndex - b.patchSetIndex; -} - -/** - * The order-priority winner among the resources present (`probability >= 0.5`) at a - * tile: "b" before "c", then lower `patchSetIndex`. Returns `null` if none present. - * Pure - the field evaluation lives in {@link makeResourceResolver}. - */ -export function pickWinner(present: readonly ResourceParams[]): ResourceParams | null { - let best: ResourceParams | null = null; - for (const p of present) { - if (best === null || comparePriority(p, best) < 0) best = p; - } - return best; -} - -/** - * Build a resolver `(x, y) => ResourceParams | null` over the THRESHOLD catalog - * resources whose `size` control is > 0, returning the order-priority winner where - * `probability >= 0.5`. - * - * **Crude oil is deliberately absent from the result**, because it is the one - * `placement: "roll"` resource; `renderResources` paints it separately. A caller - * that wants "which resource is at this tile, oil included" must consult - * `makeNauvisOilPlacement` as well. `pickWinner` still ranks oil correctly - it is - * a pure priority function over whatever it is handed. - */ -export function makeResourceResolver( - ctx: ResourceResolverCtx, -): (x: number, y: number) => ResourceParams | null { - const fields: { params: ResourceParams; patches: ResourcePatches }[] = []; - for (const params of RESOURCE_CATALOG) { - // Roll resources (crude oil alone) are not thresholded and are not resolved - // here - `renderResources` paints them from `makeNauvisOilPlacement` in its - // own pass, because a roll needs the chunk stream and the collision gate that - // a per-tile pure resolver cannot express. Leaving oil in this loop is what - // used to paint its whole patch extent as solid ore. - if (params.placement === "roll") continue; - const levers = ctx.controls[params.controlName] ?? DEFAULT_LEVERS; - if (levers.size <= 0) continue; // a disabled resource never appears - fields.push({ - params, - patches: makeResourcePatches(params, { - seed0: ctx.seed0, - controls: levers, - startingPositions: ctx.startingPositions, - segmentationMultiplier: ctx.segmentationMultiplier, - waterLevel: ctx.waterLevel, - startingLakePositions: ctx.startingLakePositions, - regularSkipSpan: REGULAR_SKIP_SPAN, - regularSkipOffset: params.patchSetIndex, - startingSkipSpan: STARTING_SKIP_SPAN, - startingSkipOffset: params.patchSetIndex, - }), - }); - } - // Evaluate in priority order so the first present resource is the winner - no need - // to build the full `present` list per tile. - fields.sort((a, b) => comparePriority(a.params, b.params)); - - return (x, y) => { - for (const f of fields) { - if (f.patches.probability(x, y) >= 0.5) return f.params; - } - return null; - }; -} diff --git a/src/noise/resources/resourceMath.ts b/src/noise/resources/resourceMath.ts deleted file mode 100644 index 065a289b..00000000 --- a/src/noise/resources/resourceMath.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * The `distance`-dependent local functions and scalar local_expressions of - * `resource_autoplace_all_patches` (core/prototypes/noise-functions.lua), ported - * verbatim. Pure math, no RNG - the spot RNG lives in regularPatches.ts. - * - * `controls` are the frequency_multiplier / size_multiplier (= control::frequency - * / control::size). `sign` mirrors the Lua `has_starting_area_placement` ternary - * argument: -1 (no special starting area), 0 (false), 1 (true). None of the six base - * resources pass nil, so `sign` is 1 (iron/copper/coal/stone) or 0 (oil/uranium) - - * and the `sign === -1` branches never fire for them, but are kept for fidelity. - */ -import { fastCbrt } from "../fastApprox"; -import type { ResourceParams } from "./resourceCatalog"; - -export const DOUBLE_DENSITY_DISTANCE = 1300; -export const REGULAR_PATCH_FADE_IN_DISTANCE = 300; -export const STARTING_RESOURCE_PLACEMENT_RADIUS = 150; -/** (params.regular_blob_amplitude_multiplier or 1) / 8 - constant for the 6 base resources. */ -const REGULAR_BLOB_AMPLITUDE_MULTIPLIER = 1 / 8; -/** (params.starting_blob_amplitude_multiplier or 1) / 8. */ -const STARTING_BLOB_AMPLITUDE_MULTIPLIER = 1 / 8; -const STARTING_PATCHES_SPLIT = 0.5; - -/** control::frequency and control::size, as the noise-function multipliers. */ -export interface ResourceControls { - readonly frequency: number; - readonly size: number; -} - -const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi); - -/** -1 (no starting area), 0 (false), 1 (true). Base resources are only 1 or 0. */ -function startingSign(params: ResourceParams): -1 | 0 | 1 { - return params.hasStartingAreaPlacement ? 1 : 0; -} - -/** size_effective_distance_at(distance). */ -export function sizeEffectiveDistanceAt(distance: number, params: ResourceParams): number { - return startingSign(params) === -1 ? distance : distance - REGULAR_PATCH_FADE_IN_DISTANCE; -} - -/** regular_density_at(distance): base density scaled by controls, spawn fade-in, and the double-density ramp. */ -export function regularDensityAt( - distance: number, - params: ResourceParams, - controls: ResourceControls, -): number { - const fadeIn = - startingSign(params) === -1 - ? 1 - : clamp( - (distance - STARTING_RESOURCE_PLACEMENT_RADIUS) / REGULAR_PATCH_FADE_IN_DISTANCE, - 0, - 1, - ); - const doubleUp = - 1 + clamp(sizeEffectiveDistanceAt(distance, params) / DOUBLE_DENSITY_DISTANCE, 0, 1); - return params.baseDensity * controls.frequency * controls.size * fadeIn * doubleUp; -} - -/** regular_spot_quantity_base_at(distance): stuff-per-spot before the random_penalty jitter. */ -export function regularSpotQuantityBaseAt( - distance: number, - params: ResourceParams, - controls: ResourceControls, -): number { - return ( - (1000000 / params.baseSpotsPerKm2 / controls.frequency) * - regularDensityAt(distance, params, controls) - ); -} - -/** regular_spot_height_typical_at(distance): the typical cone peak at that distance. */ -export function regularSpotHeightTypicalAt( - distance: number, - params: ResourceParams, - controls: ResourceControls, -): number { - const meanSize = (params.randomSpotSizeMin + params.randomSpotSizeMax) / 2; - const q = meanSize * regularSpotQuantityBaseAt(distance, params, controls); - // The game's noise machine evaluates this cube root through its fastapprox `pow` - // (docs/noise/random-penalty-NOTES.md, the fastapprox-cbrt residual) - exact - // Math.cbrt leaves a ~7e-5 relative error that dominates the blob term. - return fastCbrt(q) / ((Math.PI / 3) * params.regularRqFactor * params.regularRqFactor); -} - -/** regular_blob_amplitude_maximum_distance. */ -export function regularBlobAmplitudeMaximumDistance(params: ResourceParams): number { - return startingSign(params) === -1 - ? DOUBLE_DENSITY_DISTANCE - : DOUBLE_DENSITY_DISTANCE + REGULAR_PATCH_FADE_IN_DISTANCE; -} - -/** regular_blob_amplitude_at(distance). */ -export function regularBlobAmplitudeAt( - distance: number, - params: ResourceParams, - controls: ResourceControls, -): number { - const atMax = regularSpotHeightTypicalAt( - regularBlobAmplitudeMaximumDistance(params), - params, - controls, - ); - const atD = regularSpotHeightTypicalAt(distance, params, controls); - return REGULAR_BLOB_AMPLITUDE_MULTIPLIER * Math.min(atMax, atD); -} - -/** starting_amount: total resource "stuff" allotted to the starting area, before the split. */ -export function startingAmount(params: ResourceParams, controls: ResourceControls): number { - return 20000 * params.baseDensity * (controls.frequency + 1) * controls.size; -} - -/** starting_area_spot_quantity: starting_amount spread across the starting-area spots. */ -export function startingAreaSpotQuantity( - params: ResourceParams, - controls: ResourceControls, -): number { - return startingAmount(params, controls) / STARTING_PATCHES_SPLIT / controls.frequency; -} - -/** starting_modulation(distance): 1 inside the starting-area placement radius, 0 outside (inclusive of the boundary). */ -export function startingModulation(distance: number): number { - return distance < STARTING_RESOURCE_PLACEMENT_RADIUS ? 1 : 0; -} - -/** starting_density_at(distance): starting_amount spread over the starting-area disc, gated by starting_modulation. */ -export function startingDensityAt( - distance: number, - params: ResourceParams, - controls: ResourceControls, -): number { - return ( - (startingAmount(params, controls) / - (Math.PI * STARTING_RESOURCE_PLACEMENT_RADIUS * STARTING_RESOURCE_PLACEMENT_RADIUS)) * - startingModulation(distance) - ); -} - -/** starting_spot_radius: the typical starting-area spot radius (fastapprox cube root, matching regularSpotHeightTypicalAt). */ -export function startingSpotRadius(params: ResourceParams, controls: ResourceControls): number { - return params.startingRqFactor * fastCbrt(startingAreaSpotQuantity(params, controls)); -} - -/** - * starting_favorability_base_at(distance, elevation): the full starting-area spot - * favorability. In Factorio 2.1.11 it is deterministic (no random_penalty term). The - * game's spot_favorability_expression is: - * - * starting_resources_lake_mask * starting_modulation * origin_excluder * 2 - * - min(1, distance / starting_resource_placement_radius) - * - * where starting_resources_lake_mask = clamp((elevation - 1)/10, 0, 1) and `elevation` - * is the map's elevation property (= elevation_nauvis on the default Nauvis map), - * origin_excluder = distance > 40 (avoid the crash site), and starting_modulation = - * starting_resource_placement_radius > distance. - */ -// _params/_controls: unused today (this is purely the distance/elevation term), but kept -// in the signature for a stable (distance, elevation, params, controls) shape across -// the starting-patch local functions. -export function startingFavorabilityBaseAt( - distance: number, - elevation: number, - _params: ResourceParams, - _controls: ResourceControls, -): number { - const originExcluder = distance > 40 ? 1 : 0; - return ( - clamp((elevation - 1) / 10, 0, 1) * startingModulation(distance) * originExcluder * 2 - - Math.min(1, distance / STARTING_RESOURCE_PLACEMENT_RADIUS) - ); -} - -/** starting_blob_amplitude - a scalar; referenced by basement_value even for regular-only. */ -export function startingBlobAmplitude(params: ResourceParams, controls: ResourceControls): number { - return ( - (STARTING_BLOB_AMPLITUDE_MULTIPLIER / - ((Math.PI / 3) * params.startingRqFactor * params.startingRqFactor)) * - fastCbrt(startingAreaSpotQuantity(params, controls)) - ); -} - -/** - * basement_value = -6 * max(regular_blob_amplitude_at(max_distance), starting_blob_amplitude). - * The constant floor the spot field is initialized to and clamped at; both spot_noise - * calls in the expression share it, so it references the starting term even here. - */ -export function basementValue(params: ResourceParams, controls: ResourceControls): number { - const regular = regularBlobAmplitudeAt( - regularBlobAmplitudeMaximumDistance(params), - params, - controls, - ); - return -6 * Math.max(regular, startingBlobAmplitude(params, controls)); -} diff --git a/src/noise/resources/resourcePatches.ts b/src/noise/resources/resourcePatches.ts deleted file mode 100644 index f7bd3238..00000000 --- a/src/noise/resources/resourcePatches.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The outer `resource_autoplace_all_patches` expression - the combination of the - * regular (whole-map) and starting (near-spawn) patch fields: - * - * all_patches = if(has_starting_area_placement == 1, - * max(starting_patches, regular_patches), - * regular_patches) - * - * - Oil/uranium (`hasStartingAreaPlacement === false`) have no starting field, so - * this delegates verbatim to `makeRegularPatches` (M3a) - the deferred oil items - * (`random_probability` probability factor, M3.5 stipple) stay exactly as they - * are; M3b does NOT touch them. - * - Solids (`hasStartingAreaPlacement === true`) build BOTH fields and expose - * `field = max(starting, regular)`. The four solids all have - * `randomProbability === 1`, `additionalRichness === 0`, `minimumRichness === 0`, - * so the probability/richness wrappers are trivial (2-3 lines duplicated from - * regularPatches - NOT worth a shared helper per the brief's YAGNI note). - * - * The starting favorability couples to the map's `elevation` PROPERTY, which on the - * default Nauvis map is `elevation_nauvis` (NOT the literal `elevation_lakes`, as an - * earlier draft assumed - proven against the headless oracle in - * test/resourcePatches.spec.ts). `makeStartingPatches` builds that elevation. - */ -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import type { ResourceParams } from "./resourceCatalog"; -import { makeRegularPatches, type RegularPatches } from "./regularPatches"; -import { makeStartingPatches } from "./startingPatches"; -import { DOUBLE_DENSITY_DISTANCE, REGULAR_PATCH_FADE_IN_DISTANCE } from "./resourceMath"; - -export interface ResourcePatchesCtx { - readonly seed0: number; - readonly controls: { frequency: number; size: number; richness: number }; - readonly startingPositions?: readonly Point[]; - /** elevation inputs for the starting favorability (only used by solids). */ - readonly segmentationMultiplier?: number; - readonly waterLevel?: number; - readonly startingLakePositions?: readonly Point[]; - /** regular set skip params (span/offset). 1/0 for the isolated oracle; 6/index in the app. */ - readonly regularSkipSpan?: number; - readonly regularSkipOffset?: number; - /** starting set skip params. 1/0 for the isolated oracle; 4/index in the app. */ - readonly startingSkipSpan?: number; - readonly startingSkipOffset?: number; -} - -export interface ResourcePatches { - /** Raw `all_patches` field value. */ - field(x: number, y: number): number; - /** clamp(field, 0, 1) - the solid-footprint probability (stipple deferred). */ - probability(x: number, y: number): number; - /** The autoplace richness at (x, y) (0 where size <= 0). */ - richness(x: number, y: number): number; -} - -const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi); - -export function makeResourcePatches( - params: ResourceParams, - ctx: ResourcePatchesCtx, -): ResourcePatches { - const regular: RegularPatches = makeRegularPatches(params, { - seed0: ctx.seed0, - controls: ctx.controls, - startingPositions: ctx.startingPositions, - skipSpan: ctx.regularSkipSpan ?? 1, - skipOffset: ctx.regularSkipOffset ?? 0, - }); - - // Oil/uranium: no starting placement - delegate unchanged (keeps oil's general - // wrapper math - random_probability, additional_richness - in one place). - if (!params.hasStartingAreaPlacement) { - return regular; - } - - // Solids: all_patches = max(starting_patches, regular_patches). - const starting = makeStartingPatches(params, { - seed0: ctx.seed0, - controls: ctx.controls, - startingPositions: ctx.startingPositions, - segmentationMultiplier: ctx.segmentationMultiplier, - waterLevel: ctx.waterLevel, - startingLakePositions: ctx.startingLakePositions, - skipSpan: ctx.startingSkipSpan ?? 1, - skipOffset: ctx.startingSkipOffset ?? 0, - }); - - const spawn: readonly Point[] = ctx.startingPositions ?? [{ x: 0, y: 0 }]; - const distanceAt = (x: number, y: number): number => - distanceFromNearestPoint(x, y, spawn as Point[]); - - const field = (x: number, y: number): number => - Math.max(starting.field(x, y), regular.field(x, y)); - - // Same fade-in-adjusted richness distance factor as regularPatches.ts. - const richnessDistanceFactor = (distance: number): number => - Math.max( - (DOUBLE_DENSITY_DISTANCE - REGULAR_PATCH_FADE_IN_DISTANCE + distance) / - (DOUBLE_DENSITY_DISTANCE * 2), - 1, - ); - - return { - field, - // The four solids all have randomProbability=1, additionalRichness=0, - // minimumRichness=0, so the wrappers reduce to these trivial forms. - probability: (x, y) => (ctx.controls.size > 0 ? clamp(field(x, y), 0, 1) : 0), - richness: (x, y) => { - if (ctx.controls.size <= 0) return 0; - return ( - params.richnessPostMultiplier * - ctx.controls.richness * - field(x, y) * - richnessDistanceFactor(distanceAt(x, y)) - ); - }, - }; -} diff --git a/src/noise/resources/startingPatches.ts b/src/noise/resources/startingPatches.ts deleted file mode 100644 index 55049449..00000000 --- a/src/noise/resources/startingPatches.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * The `starting_patches` branch of `resource_autoplace_all_patches` (the near-spawn - * ore patches), a close sibling of `regularPatches.ts` over the same solved - * primitives (`selectSpots`, `basisNoise`), plus the map's `elevation` property - * (elevation_nauvis on the default map) for the favorability coupling and - * `distance_from_nearest_point` for distance. - * - * starting_patches = spotField + (blobs0 - 1/4) * startingBlobAmplitude - * spotField = max(basement_value, max over nearby spots of (peak - dist*slope)) - * blobs0 = basis_noise{1/8,1} + basis_noise{1/24,1} - * - * All the following were verified against a has_starting=1 headless oracle in M3b - * Task 5 (an earlier Task-3 draft had several of them wrong): - * - region_size = starting_resource_placement_radius * 3 = 450 (not 1024), - * candidate_spot_count = 32, spacing = 48, hard_region_target_quantity = true - * (the last kept spot's cone shrinks self-similarly to hit the budget exactly). - * - The candidate stream is seeded with `seed1 = params.seed1 + 1` (a distinct - * stream from the regular set), but the blob noise (`blobs0`) still uses the - * bare `params.seed1`. - * - Spot QUANTITY is the constant `startingAreaSpotQuantity`; spot FAVORABILITY is - * DETERMINISTIC: `starting_resources_lake_mask * starting_modulation * - * origin_excluder * 2 - min(1, distance / starting_resource_placement_radius)`, - * where the lake mask reads the map `elevation` (there is NO random_penalty term). - * - spot_radius uses the CONSTANT starting_area_spot_quantity (the coneScale shrink - * is applied once, not on top of a per-spot cbrt). - * - maximum_spot_basement_radius = 2 * rq * saq^(1/3) is a HARD cull (the cone is - * still above basement there), not the safe 128 over-cull regular patches use. - * - The cone radius has no `min(32, ...)` cap (regular caps at 32; starting can be - * larger). No `basis_noise{1/64,1.5}` term in the blob (that is regular-only). - */ -import { basisNoise, basisNoiseTablesFromSeed, type BasisNoiseTables } from "../basisNoise"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { f32 } from "../eval/f32"; -import { makeElevationNauvis } from "../expressions/elevationNauvis"; -import { fastCbrt } from "../fastApprox"; -import { selectSpots, type SelectedSpot } from "../spotSelection"; -import type { SpotRegionKey } from "../spotCandidates"; -import type { ResourceParams } from "./resourceCatalog"; -import { - basementValue, - startingAreaSpotQuantity, - startingBlobAmplitude, - startingDensityAt, - startingFavorabilityBaseAt, - type ResourceControls, -} from "./resourceMath"; - -/** suggested_minimum_candidate_point_spacing for the starting set. */ -const STARTING_SPACING = 48; -/** region_size = starting_resource_placement_radius * 3 = 150 * 3. */ -const STARTING_REGION_SIZE = 450; -const STARTING_CANDIDATE_SPOT_COUNT = 32; - -export interface StartingPatchesCtx { - readonly seed0: number; - /** control::frequency, size, richness (the noise-function multipliers). */ - readonly controls: { frequency: number; size: number; richness: number }; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; - /** elevation inputs for the favorability coupling (elevation_nauvis on default). */ - readonly segmentationMultiplier?: number; - readonly waterLevel?: number; - readonly startingLakePositions?: readonly Point[]; - /** starting_patch_set_count (skip_span). 1 for the isolated oracle; 4 in the app. */ - readonly skipSpan?: number; - /** starting_patch_set_index (skip_offset). */ - readonly skipOffset?: number; -} - -export interface StartingPatches { - /** Raw `starting_patches` field value (the counterpart to regular's `field`). */ - field(x: number, y: number): number; -} - -/** region index for a coordinate (regions centred on multiples of STARTING_REGION_SIZE). */ -const regionIndex = (c: number): number => - Math.floor((c + STARTING_REGION_SIZE / 2) / STARTING_REGION_SIZE); - -export function makeStartingPatches( - params: ResourceParams, - ctx: StartingPatchesCtx, -): StartingPatches { - const controls: ResourceControls = { frequency: ctx.controls.frequency, size: ctx.controls.size }; - const spawn: readonly Point[] = ctx.startingPositions ?? [{ x: 0, y: 0 }]; - const distanceAt = (x: number, y: number): number => - distanceFromNearestPoint(x, y, spawn as Point[]); - - // starting_resources_lake_mask = clamp((elevation - 1)/10, 0, 1) couples to the - // map's `elevation` PROPERTY, which on the default Nauvis map is elevation_nauvis - // (NOT the literal elevation_lakes - verified against the game oracle: the - // has_starting=1 fixture keeps the spot elevation_nauvis's favorability picks, not - // elevation_lakes's). A non-default map type (Lakes/Island) would feed its own - // elevation here; that generalization is deferred until the resolver needs it. - const elevation = makeElevationNauvis({ - seed0: ctx.seed0, - waterLevel: ctx.waterLevel, - segmentationMultiplier: ctx.segmentationMultiplier, - startingPositions: spawn as Point[], - startingLakePositions: ctx.startingLakePositions as Point[] | undefined, - }); - - // blobs0 uses the bare seed1 (NOT +1 - that offset is candidate-stream only). - const tables: BasisNoiseTables = basisNoiseTablesFromSeed(ctx.seed0, params.seed1); - const basement = basementValue(params, controls); - - const skipSpan = ctx.skipSpan ?? 1; - const skipOffset = ctx.skipOffset ?? 0; - - const quantity = startingAreaSpotQuantity(params, controls); - - // maximum_spot_basement_radius = 2 * starting_rq_factor * starting_area_spot_quantity^(1/3). - // This is a HARD cull, not just a scan bound: unlike regular patches (radius capped - // at 32, cull 128, where the cone is already far below basement at the cull), the - // starting cone (radius ~10.5) is still ABOVE basement at this ~29.5-tile cull, so - // the cutoff produces a real discontinuous drop to basement - the game's behavior. - const maxBasementRadius = 2 * params.startingRqFactor * fastCbrt(quantity); - - // spot_favorability_expression = starting_resources_lake_mask * starting_modulation - // * origin_excluder * 2 - min(1, distance / starting_resource_placement_radius). It - // is DETERMINISTIC - there is no random_penalty term (an earlier draft added one; - // the game expression, core/prototypes/noise-functions.lua, has none). - const favorability = (x: number, y: number): number => - startingFavorabilityBaseAt(distanceAt(x, y), elevation(x, y), params, controls); - - // Selected spots per region, memoized (all resources in a set share the stream). - const regionCache = new Map(); - const regionSpots = (rX: number, rY: number): SelectedSpot[] => { - const key = `${rX},${rY}`; - let spots = regionCache.get(key); - if (spots) return spots; - const regionKey: SpotRegionKey = { - seed0: ctx.seed0, - seed1: params.seed1 + 1, - regionX: rX, - regionY: rY, - }; - spots = selectSpots(regionKey, { - density: (x, y) => startingDensityAt(distanceAt(x, y), params, controls), - quantity: () => quantity, - favorability, - regionSize: STARTING_REGION_SIZE, - candidateSpotCount: STARTING_CANDIDATE_SPOT_COUNT, - spacing: STARTING_SPACING, - skipSpan, - skipOffset, - hardRegionTargetQuantity: true, - }); - regionCache.set(key, spots); - return spots; - }; - - const spotFieldAt = (x: number, y: number): number => { - let best = basement; - const rXlo = regionIndex(x - maxBasementRadius); - const rXhi = regionIndex(x + maxBasementRadius); - const rYlo = regionIndex(y - maxBasementRadius); - const rYhi = regionIndex(y + maxBasementRadius); - for (let rX = rXlo; rX <= rXhi; rX++) { - for (let rY = rYlo; rY <= rYhi; rY++) { - for (const s of regionSpots(rX, rY)) { - const dx = x - s.x; - const dy = y - s.y; - const d2 = dx * dx + dy * dy; - if (d2 > maxBasementRadius * maxBasementRadius) continue; - // The cone is rendered per-tile in the f32 noise machine (cube root via - // fastapprox `pow`), matching regularPatches. Starting patches have no - // min(32, ...) radius cap; the hard-target trim's coneScale shrinks the - // last kept spot's radius and peak self-similarly. - // spot_radius_expression = starting_rq_factor * starting_area_spot_quantity^(1/3) - // uses the CONSTANT quantity (same for every spot); the hard-target trim then - // shrinks the last spot's radius (and peak) self-similarly by coneScale. Using - // s.quantity here would double-apply the shrink to that last spot. - const rBase = f32(params.startingRqFactor * fastCbrt(quantity)); - const radius = f32(rBase * s.coneScale); - const peak = f32(f32(3 * s.quantity) / f32(f32(Math.PI * radius) * radius)); - const cone = f32(peak - f32(f32(Math.sqrt(d2)) * f32(peak / radius))); - if (cone > best) best = cone; - } - } - } - return best; - }; - - const blobTermAt = (x: number, y: number): number => { - const blobs0 = basisNoise(x / 8, y / 8, tables) + basisNoise(x / 24, y / 24, tables); - return (blobs0 - 1 / 4) * startingBlobAmplitude(params, controls); - }; - - const field = (x: number, y: number): number => spotFieldAt(x, y) + blobTermAt(x, y); - - return { field }; -} diff --git a/src/noise/resources/vulcanusResourceCatalog.ts b/src/noise/resources/vulcanusResourceCatalog.ts index 04cf45fe..50eb01ff 100644 --- a/src/noise/resources/vulcanusResourceCatalog.ts +++ b/src/noise/resources/vulcanusResourceCatalog.ts @@ -9,8 +9,15 @@ * before `space-age`, coal's true global registration index actually *precedes* * tungsten-ore and calcite, not follows them as this array's order suggests. * - * Among those three the order is functionally inert: all three autoplace - * `order = "b"`, so ties fall back to registration order, but their + * **This module is a table, not an engine.** The probability expressions, the + * footprint test and the placement threshold were ported to Rust in #227 and + * live in `crates/fmw-noise/src/resources/vulcanus_catalog.rs`; the renderer + * that walks them is `crates/fmw-wasm/src/render.rs:1400-1443`. What survives + * here is the order and the map colours, which + * `test/wasmVulcanusRenderParity.spec.ts` grades the engine's pixels against. + * + * Among the three solid ores the order is functionally inert: all three + * autoplace `order = "b"`, so ties fall back to registration order, but their * favorabilities gate on disjoint biomes (basalts / mountains / ashlands), so * two of them are never simultaneously eligible at the same pixel and the * tie-break never fires. Listed in this order for readability, not correctness - @@ -23,67 +30,30 @@ * be eligible at the same pixel and the tie-break fires for the first time. * **The geyser is last on purpose.** The game arbitrates a tile among competing * autoplacers by maximum probability; calcite's probability saturates to ~1 - * inside its footprint while the geyser's peaks below 0.09 (measured - see - * `sulfuricAcidGeyserProbability`), so calcite wins that pixel. The renderer - * reproduces that outcome by painting the geyser's roll marks FIRST and the - * three thresholded ores over the top, so a solid ore still wins a shared pixel - * (`renderVulcanusResources.ts`). - */ -import type { VulcanusResourceControls, VulcanusResourceLevers } from "../eval/ctx"; -import type { VulcanusResources } from "../expressions/vulcanusResources"; - -/** - * The threshold a `"threshold"` entry's probability must clear for the game to - * have placed an ore entity on that tile: `probability >= 0.5`. - * - * **This lives here rather than in the renderer because it now has two - * consumers.** `renderVulcanusResources` paints with it, and - * `makeVulcanusOreRejection` (`../cliffs/vulcanusOreRejection.ts`) asks the same - * question to decide whether an ore entity suppresses a cliff. Two copies of the - * number could drift apart and the cliff overlay would then reject against a - * footprint the ore overlay does not draw - a disagreement that would be - * invisible in both renders. `test/cliffOreRejection.spec.ts` pins the two - * footprints equal on top of sharing this constant. + * inside its footprint while the geyser's peaks below 0.09 (measured at + * **0.0858645**, at (2481, -1985) on seed 123456, where `patchy` is 1.2172893 - + * see `sulfuric_acid_geyser_probability` in the Rust catalog), so calcite wins + * that pixel. The renderer reproduces that outcome by painting the geyser's roll + * marks FIRST and the three thresholded ores over the top, so a solid ore still + * wins a shared pixel. */ -export const RESOURCE_PROBABILITY_THRESHOLD = 0.5; - -/** - * Does the game hold a solid-ore entity on the tile whose centre is - * `(x + 0.5, y + 0.5)`? - * - * The three solid ores THRESHOLD (see `VulcanusResourcePlacement`), so their - * footprint is exactly `1000 * region >= RESOURCE_PROBABILITY_THRESHOLD` over - * the entries whose `size` lever is positive. A disabled ore occupies nothing, - * which is not a special case bolted on: it is the same `size = 0` lever the - * game itself was driven with to establish that ore suppresses cliffs (#99). - * - * The geyser is deliberately absent - it ROLLS rather than thresholds, so it has - * no footprint expressible this way. Callers that want it pass their own - * predicate. - */ -export function makeVulcanusOreFootprint( - resources: VulcanusResources, - controls: VulcanusResourceControls, -): (x: number, y: number) => boolean { - const active = VULCANUS_RESOURCE_CATALOG.filter( - (p) => p.placement === "threshold" && p.levers(controls).size > 0, - ).map((p) => p.region(resources)); - if (active.length === 0) return () => false; - return (x, y) => active.some((region) => 1000 * region(x, y) >= RESOURCE_PROBABILITY_THRESHOLD); -} /** * How this entry decides where it is drawn. * - * - `"threshold"` - draw wherever the entry's own probability clears - * `PROBABILITY_THRESHOLD`, i.e. paint the patch as a solid footprint. Right - * for the three solid ores, whose probability saturates to ~1 inside a patch - * and 0 outside: the threshold *is* the patch boundary. - * - `"roll"` - draw where the game's per-tile placement draw beats `probability` - * (`docs/noise/placement-roll-NOTES.md`), subject to the two arbitration - * gates. Right for the geyser, whose probability never exceeds ~0.09 - * anywhere: there is no threshold that yields a footprint, because a geyser - * is an individual entity the game rolls for, not a patch. + * - `"threshold"` - draw wherever the entry's own probability clears the + * placement threshold, i.e. paint the patch as a solid footprint. Right for + * the three solid ores, whose probability saturates to ~1 inside a patch and 0 + * outside: the threshold *is* the patch boundary. + * - `"roll"` - draw where the game's per-tile placement draw beats + * `probability` (`docs/noise/placement-roll-NOTES.md`), subject to the two + * arbitration gates. Right for the geyser, whose probability never exceeds + * ~0.09 anywhere: there is no threshold that yields a footprint, because a + * geyser is an individual entity the game rolls for, not a patch. + * + * Mirrors `VulcanusResourcePlacement` in + * `crates/fmw-noise/src/resources/vulcanus_catalog.rs`, which is what actually + * branches on it. */ export type VulcanusResourcePlacement = "threshold" | "roll"; @@ -94,75 +64,8 @@ export interface VulcanusResourceParams { readonly controlName: string; /** `map_color`, scaled to 0..255 (rounded), as the game's preview tints it. */ readonly mapColor: readonly [number, number, number]; - /** - * Which `VulcanusResources` region this entry is built from. - * - * For a `"threshold"` entry this is the game's own probability expression up - * to the `1000 *` scale the renderer applies, and it decides the footprint. - * For the `"roll"` entry the renderer does NOT consult it - it is the field - * `probability` is a formula over, kept here because the geyser's extent - * ("where the game would roll at all") is still `region > 0`. - */ - readonly region: (r: VulcanusResources) => (x: number, y: number) => number; - /** Which `VulcanusResourceControls` entry gates this ore's size/frequency. */ - readonly levers: (c: VulcanusResourceControls) => VulcanusResourceLevers; - /** How the renderer turns this entry into pixels. */ + /** How the engine turns this entry into pixels. */ readonly placement: VulcanusResourcePlacement; - /** - * The game's `entity::probability` at a tile, for `"roll"` entries. May - * be negative where the entry cannot place - a negative probability simply - * never wins the roll, exactly as in the game's expression. - */ - readonly probability?: (r: VulcanusResources) => (x: number, y: number) => number; -} - -/** - * `vulcanus_sulfuric_acid_geyser_probability`, verbatim from - * `space-age/prototypes/planet/planet-vulcanus-map-gen.lua:849` (2.1.12): - * - * ``` - * (control:sulfuric_acid_geyser:size > 0) - * * (0.025 * ((vulcanus_sulfuric_acid_region_patchy > 0) - * + 2 * vulcanus_sulfuric_acid_region_patchy)) - * ``` - * - * It reaches the geyser via - * `property_expression_names["entity:sulfuric-acid-geyser:probability"]` - * (`planet-map-gen.lua:21`), which replaces the prototype's own - * `probability_expression = 0`. **There is no `random_penalty` wrapper** - unlike - * its calcite/coal/tungsten neighbours in the same file, and unlike the Nauvis - * spawners, both of which do wrap theirs. Read from source rather than trusted - * from the comment this replaced. - * - * The leading `size > 0` factor is applied by the renderer's `enabled` filter, - * so it is not repeated here. - * - * **The peak is not 0.065, and it is not 0.0883 either.** 0.065 sat in this - * file as a reasoned bound (assuming `region <= 1` and `patches <= 0.8`) and it - * is wrong: `region` is a `max` against `vulcanus_starting_sulfur`, which is - * not capped at 1. It was replaced by a measurement - sweeping +/-3000 tiles at - * seed 123456 on a 7-tile grid and refining around the argmax found the peak at - * (2481, -1985), "where `patchy` is 1.217", and recorded **0.0883**. - * - * Those two numbers do not agree with each other, which the Rust port noticed - * (2026-08-24) while pinning the peak as a test. This expression at - * `patchy = 1.217` is `0.025 * (1 + 2*1.217) = 0.08585`, and evaluating the - * chain at that exact position at seed 123456 gives `patchy = 1.2172893` and - * **0.0858645**. So the position and the `patchy` are right and the recorded - * probability is not; 0.0883 would need a `patchy` of 1.266. - * - * Nothing depends on the difference - both are two orders of magnitude below - * calcite's saturated ~1, which is all the catalog ordering argument needs. - * Corrected rather than left, because a number nobody re-derives is a number - * that gets quoted. - */ -export function sulfuricAcidGeyserProbability( - r: VulcanusResources, -): (x: number, y: number) => number { - return (x, y) => { - const patchy = r.sulfuricAcidRegionPatchy(x, y); - return 0.025 * ((patchy > 0 ? 1 : 0) + 2 * patchy); - }; } export const VULCANUS_RESOURCE_CATALOG: readonly VulcanusResourceParams[] = [ @@ -171,8 +74,6 @@ export const VULCANUS_RESOURCE_CATALOG: readonly VulcanusResourceParams[] = [ controlName: "tungsten_ore", // map_color = {r = 98/256, g = 86/256, b = 150/256} -> Math.round(v * 255) mapColor: [98, 86, 149], - region: (r) => (x, y) => r.tungstenRegion(x, y), - levers: (c) => c.tungstenOre, placement: "threshold", }, { @@ -180,8 +81,6 @@ export const VULCANUS_RESOURCE_CATALOG: readonly VulcanusResourceParams[] = [ controlName: "calcite", // map_color = {0.8, 0.7, 0.7} mapColor: [204, 179, 179], - region: (r) => (x, y) => r.calciteRegion(x, y), - levers: (c) => c.calcite, placement: "threshold", }, { @@ -189,35 +88,26 @@ export const VULCANUS_RESOURCE_CATALOG: readonly VulcanusResourceParams[] = [ controlName: "vulcanus_coal", // map_color = {0, 0, 0} (base/prototypes/entity/resources.lua) mapColor: [0, 0, 0], - region: (r) => (x, y) => r.coalRegion(x, y), - levers: (c) => c.vulcanusCoal, placement: "threshold", }, { // The geyser is NOT a solid patch: every geyser in-game comes from a - // per-tile RNG roll against `sulfuricAcidGeyserProbability`, which peaks - // below 0.09, so no threshold on it yields a footprint. Until 2026-07-27 - // this entry thresholded anyway and drew the whole *patch extent* - the - // region where the game would roll at all - which overstates the geysers' - // area by **4.2x** (measured: 371 placements at 2.8 x 2.8 against 12130 - // footprint tiles over a +/-2000-tile sample, 0.240). Earlier text here and - // in the notes said "more than an order of magnitude"; that was reasoned - // from the pre-collision roll rate, never measured, and is wrong. It now - // rolls (`docs/noise/placement-roll-NOTES.md`), and the roll's density is - // validated against the game in `test/entityDensity.spec.ts`. - // - // `region` stays `sulfuricAcidRegionPatchy` - the field the probability is - // built from, and NOT the plain `sulfuricAcidRegion` that richness uses - - // because `probability > 0` is exactly `patchy > 0`, so it is still the - // right answer to "could a geyser roll here". The renderer no longer draws - // it. + // per-tile RNG roll against the probability expression, which peaks below + // 0.09, so no threshold on it yields a footprint. Until 2026-07-27 this + // entry thresholded anyway and drew the whole *patch extent* - the region + // where the game would roll at all - which overstates the geysers' area by + // **4.2x** (measured: 371 placements at 2.8 x 2.8 against 12130 footprint + // tiles over a +/-2000-tile sample, 0.240). Earlier text here and in the + // notes said "more than an order of magnitude"; that was reasoned from the + // pre-collision roll rate, never measured, and is wrong. It now rolls + // (`docs/noise/placement-roll-NOTES.md`), and the roll's density is + // validated against the game's own counts in `test/oracle/entityCounts.ts`, + // whose fourth region exists precisely to give the geyser overlay something + // to compare against. name: "sulfuric-acid-geyser", controlName: "sulfuric_acid_geyser", // map_color = {0.78, 0.78, 0.1} (space-age/prototypes/entity/resources.lua) mapColor: [199, 199, 26], - region: (r) => (x, y) => r.sulfuricAcidRegionPatchy(x, y), - levers: (c) => c.sulfuricAcidGeyser, placement: "roll", - probability: sulfuricAcidGeyserProbability, }, ]; diff --git a/src/noise/rocks/rockField.ts b/src/noise/rocks/rockField.ts deleted file mode 100644 index a9e7a768..00000000 --- a/src/noise/rocks/rockField.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * The Nauvis rocks placement-probability field: `clamp(max_i probability_i, 0, 1)` - * over the three charted rock prototypes (huge-rock, big-rock, big-sand-rock). - * Per-tile arbitration is max probability (docs/noise/placement-roll-NOTES.md), so - * the max is exact - it is the probability the game rolls where a rock wins. - * - * probability = multiplier * control:rocks:size * (region_box + rock_density - penalty) - * See rockCatalog.ts / the plan header for the per-prototype constants. - * - * **Why exactly three prototypes.** `base/prototypes/decorative/decoratives.lua` - * (2.1.12) gives EIGHT prototypes `autoplace.control = "rocks"`, but five of them - * - medium-rock, small-rock, tiny-rock, medium-sand-rock, small-sand-rock - are - * `type = "optimized-decorative"`. Decoratives are generated by a different pass - * and are not entities, so they neither appear in the game's entity counts nor - * compete in the entity placement arbitration. The three that are - * `type = "simple-entity"` are exactly huge-rock, big-rock and big-sand-rock. - * - * `renderRocks.ts` rolls this field through `makePlacementSet` rather than - * thresholding it; the per-prototype probabilities are exposed by - * {@link makeRockFields} because the collision gate needs the winning - * prototype's box, and unlike Vulcanus the Nauvis argmax is NOT degenerate. - */ -import { clamp } from "../eval/math"; -import { sliderRescale } from "../eval/math"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { makeAux } from "../expressions/aux"; -import { makeMoisture } from "../expressions/moisture"; -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import { rangeSelectBase, ROCK_SEED1 } from "./rockCatalog"; - -export interface RockFieldParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:rocks:frequency; default 1. */ - readonly rocksFrequency?: number; - /** control:rocks:size; default 1. */ - readonly rocksSize?: number; - /** control:water:frequency; default 1. Threads into moisture/aux shared noise. */ - readonly segmentationMultiplier?: number; - readonly moistureFrequency?: number; - readonly moistureBias?: number; - readonly auxFrequency?: number; - readonly auxBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; -} - -/** The three Nauvis rock prototypes' probabilities at one tile, unclamped. */ -export interface RockProbabilities { - /** `huge-rock`'s autoplace probability. */ - readonly huge: number; - /** `big-rock`'s autoplace probability. */ - readonly big: number; - /** `big-sand-rock`'s autoplace probability. */ - readonly sand: number; -} - -export interface RockFields { - /** - * `clamp(max(huge, big, sand), 0, 1)` - what the overlay's placement roll rolls - * against. Allocation-free; this is the per-tile hot path. - */ - readonly density: (x: number, y: number) => number; - /** - * The three probabilities separately, for picking the winning prototype's - * collision box. Allocates, and is only meant to be called on tiles that have - * already passed both the roll and the water gate - 252 of 262144 tiles - * (0.096%) in oracle region 0 and 60 of 262144 (0.023%) in region 1, measured; - * see the table on `makeNauvisRockPlacement`. - */ - readonly at: (x: number, y: number) => RockProbabilities; -} - -/** Build the Nauvis rock probability fields for one seed and set of levers. */ -export function makeRockFields(params: RockFieldParams): RockFields { - const seed0 = params.seed0; - const freq = params.rocksFrequency ?? 1; - const size = params.rocksSize ?? 1; - const spawn: readonly Point[] = params.startingPositions ?? [{ x: 0, y: 0 }]; - - const noise = makeMultioctaveNoise({ - seed0, - seed1: ROCK_SEED1, - octaves: 4, - persistence: 0.9, - inputScale: 0.15 * freq, - outputScale: 1, - }); - const moisture = makeMoisture({ - seed0, - segmentationMultiplier: params.segmentationMultiplier, - moistureFrequency: params.moistureFrequency, - moistureBias: params.moistureBias, - startingAreaMoistureSize: params.startingAreaMoistureSize, - startingAreaMoistureFrequency: params.startingAreaMoistureFrequency, - startingPositions: [...spawn], - }); - const aux = makeAux({ - seed0, - segmentationMultiplier: params.segmentationMultiplier, - frequency: params.auxFrequency, - bias: params.auxBias, - }); - - // control:rocks:size enters twice: as the outer multiplier and inside rock_noise - // via slider_rescale. sizeTerm is the size-dependent, position-independent tail - // of rock_noise, hoisted out of the per-pixel loop. - const sizeTerm = 0.25 + 0.75 * (sliderRescale(size, 1.5) - 1); - - // Written by `evalParts` and read immediately by its two callers. Closure - // scratch rather than a returned object so `density` - which every tile of - // every chunk goes through - allocates nothing. - let pHuge = 0; - let pBig = 0; - let pSand = 0; - - const evalParts = (x: number, y: number): void => { - const rockNoise = noise(x, y) + sizeTerm; - const distance = distanceFromNearestPoint(x, y, spawn); - const rockDensity = rockNoise - Math.max(0, 1.1 - distance / 32); - - const m = moisture(x, y); - const moistBand = rangeSelectBase(m, 0.35, 1, 0.2, -10, 0); - pHuge = 0.07 * size * (moistBand + rockDensity - 1.7); - pBig = 0.17 * size * (moistBand + rockDensity - 1.6); - - const a = aux(x, y); - const sandBand = Math.min( - rangeSelectBase(a, 0.3, 1, 0.3, -10, 0), - rangeSelectBase(m, 0, 0.3, 0.2, -10, 0), - ); - pSand = 0.1 * size * (sandBand + rockDensity - 1.6); - }; - - return { - density: (x, y) => { - evalParts(x, y); - return clamp(Math.max(pHuge, pBig, pSand), 0, 1); - }, - at: (x, y) => { - evalParts(x, y); - return { huge: pHuge, big: pBig, sand: pSand }; - }, - }; -} - -/** `makeRockFields(params).density` - the field on its own. */ -export function makeRockDensity(params: RockFieldParams): (x: number, y: number) => number { - return makeRockFields(params).density; -} diff --git a/src/noise/rocks/vulcanusRockField.ts b/src/noise/rocks/vulcanusRockField.ts deleted file mode 100644 index d5fbd385..00000000 --- a/src/noise/rocks/vulcanusRockField.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * The Vulcanus rock placement-probability field. - * - * Vulcanus lists four rock ENTITIES in `planet_map_gen.vulcanus()`'s - * `autoplace_settings.entity`: `huge-volcanic-rock`, `big-volcanic-rock` and - * their `-hot` variants. Between them they use only **two** probability - * expressions - the hot variants reuse the cold ones' - so the field is - * - * density = clamp(max(vulcanus_rock_huge, vulcanus_rock_big), 0, 1) - * - * Per-tile arbitration is max probability - * (`docs/noise/placement-roll-NOTES.md`), so taking the max is exact: it is the - * probability the game rolls where a rock wins. - * - * From `space-age/prototypes/decorative/decoratives-vulcanus.lua:308-318`: - * - * ``` - * vulcanus_rock_huge = min(0.2 * (1 - 0.75 * vulcanus_ashlands_biome), - * -1.2 + 1.2 * min(aux, -0.1 + 1.1 * moisture) - * + vulcanus_rock_noise - * + 0.5 * vulcanus_decorative_knockout) - * vulcanus_rock_big = min(0.2 * (1 - 0.5 * vulcanus_ashlands_biome), - * -1.0 + ) - * ``` - * - * The file also defines `vulcanus_rock_medium/cluster/small/tiny`. Those are - * **decoratives**, not entities - they appear in `autoplace_settings.decorative` - * - and the game's map preview charts entities, not decoratives, so they are - * deliberately not part of this field. - * - * **There is no `rocks` slider on Vulcanus.** The planet's `autoplace_controls` - * list carries the entry commented out, with the reason in the source: - * `--["rocks"] = {}, -- can't add the rocks control otherwise nauvis rocks spawn` - * (`planet-map-gen.lua:43`). So unlike Nauvis's `makeRockDensity`, nothing here - * takes a frequency or size lever - `vulcanus_rock_noise` even has its - * `control:rocks:frequency` term commented out at its definition site. - * - * The overlay (`renderVulcanusRocks.ts`) no longer thresholds `density` - it - * rolls the field through `makePlacementSet` - * (`src/noise/placement/placementRoll.ts`), placing where the roll's per-tile - * `U < density(x, y)` AND the game's two arbitration gates pass: the rocks' - * `tile_restriction` (no `lava` / `lava-hot`) and collision rejection against - * rocks already placed in the same chunk. Rolling `density` alone over-places by - * ~2x against the game - see `test/entityDensity.spec.ts`. - */ - -import { clamp } from "../eval/math"; -import type { EvalCtx } from "../eval/ctx"; -import { makeVulcanusBiomes, type VulcanusBiomes } from "../expressions/vulcanusBiomes"; -import { makeVulcanusClimate, type VulcanusClimate } from "../expressions/vulcanusClimate"; -import { makeVulcanusCracks } from "../expressions/vulcanusCracks"; -import { makeVulcanusHelpers } from "../expressions/vulcanusHelpers"; -import { makeVulcanusSpawn } from "../expressions/vulcanusSpawn"; -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import { makeVulcanusRockNoise } from "../tiles/vulcanusCatalog"; - -/** `seed1` of `vulcanus_decorative_knockout`'s multioctave call. */ -export const DECORATIVE_KNOCKOUT_SEED1 = 1300000; - -/** - * `vulcanus_decorative_knockout` (`planet-vulcanus-map-gen.lua:867`), commented - * there as "small wavelength noise (5 tiles-ish) to make decoratives patchy": - * - * ``` - * multioctave_noise{x = x, y = y, persistence = 0.7, seed0 = map_seed, - * seed1 = 1300000, octaves = 2, input_scale = 1/3} - * ``` - * - * No `output_scale` is given, so it defaults to 1. - */ -export function makeVulcanusDecorativeKnockout(seed0: number): (x: number, y: number) => number { - return makeMultioctaveNoise({ - seed0, - seed1: DECORATIVE_KNOCKOUT_SEED1, - octaves: 2, - persistence: 0.7, - inputScale: 1 / 3, - outputScale: 1, - }); -} - -export interface VulcanusRockFields { - /** `vulcanus_rock_huge`. */ - readonly rockHuge: (x: number, y: number) => number; - /** `vulcanus_rock_big`. */ - readonly rockBig: (x: number, y: number) => number; - /** `clamp(max(huge, big), 0, 1)` - what the overlay's placement roll rolls against. */ - readonly density: (x: number, y: number) => number; -} - -/** Build the Vulcanus rock probability fields for one seed/ctx. */ -export function makeVulcanusRockFields( - ctx: EvalCtx, - sharedStack?: { biomes: VulcanusBiomes; climate: VulcanusClimate }, -): VulcanusRockFields { - // PROTOTYPE (issue #19 follow-up): when a shared stack is passed, `biomes` - // and `climate` are the SAME objects the terrain resolver uses. That is what - // lets `memoRegion` serve this overlay work that terrain already did - the - // two traverse in different orders (chunk-major here, row-major there), so - // sharing the objects is necessary but only pays with a multi-entry cache. - const helpers = makeVulcanusHelpers(ctx); - const spawn = makeVulcanusSpawn(ctx, helpers); - const cracks = makeVulcanusCracks(ctx, helpers); - const biomes = sharedStack?.biomes ?? makeVulcanusBiomes(ctx, helpers, spawn, cracks); - const climate = sharedStack?.climate ?? makeVulcanusClimate(ctx, helpers, cracks); - const rockNoise = makeVulcanusRockNoise(ctx.seed0); - const knockout = makeVulcanusDecorativeKnockout(ctx.seed0); - - // The three terms both expressions share, before their own offset and cap. - const shared = (x: number, y: number): number => - 1.2 * Math.min(climate.aux(x, y), -0.1 + 1.1 * climate.moisture(x, y)) + - rockNoise(x, y) + - 0.5 * knockout(x, y); - - const rockHuge = (x: number, y: number): number => - Math.min(0.2 * (1 - 0.75 * biomes.ashlandsBiome(x, y)), -1.2 + shared(x, y)); - - const rockBig = (x: number, y: number): number => - Math.min(0.2 * (1 - 0.5 * biomes.ashlandsBiome(x, y)), -1.0 + shared(x, y)); - - const density = (x: number, y: number): number => - clamp(Math.max(rockHuge(x, y), rockBig(x, y)), 0, 1); - - return { rockHuge, rockBig, density }; -} diff --git a/src/noise/startingLakes.ts b/src/noise/startingLakes.ts deleted file mode 100644 index ef742a0f..00000000 --- a/src/noise/startingLakes.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Factorio's `starting_lake_positions`, reverse-engineered from - * `MapGenSettings::getStartingLakePositions() const` (non-stripped 2.1.11 Mach-O, - * arm64 0x10160a2fc). One lake per starting position, in order: a single taus88 - * draw picks an angle; the lake sits at a FIXED radius of 75 tiles around the - * spawn. Verified exact against test/fixtures/oracle-elevation-lakes.seed123456 - * (lake (45,-59) reproduces all 9 near-spawn distances to 0). See - * docs/superpowers/specs/2026-07-18-starting-lake-positions-design.md. - * - * NOT random beyond the angle: radius, phase-quantisation and the fast-sine poly - * are all fixed. The intermediate f32 round-trip (Math.fround) and truncation - * toward zero (Math.trunc) are load-bearing - do not "clean them up". - */ -import { seededState, taus88Next } from "./taus88"; -import type { Point } from "./distanceFromNearestPoint"; - -const MIN_SEED_WORD = 0x155; -const TWO_POW_NEG32 = 2.3283064365386963e-10; // 0x3DF0000000000000 -const TWO_PI = 6.283185307179586; // 0x401921FB54442D18 -const INV_TWO_PI = 0.15915494309189535; // 0x3FC45F306DC9C883 -const RADIUS = 75.0; // 0x4052C00000000000 - -// Minimax coefficients (0x4044ABBC02329376 .. 0x4043D4243780214B). -const C1 = 41.34167506665737; -const C2 = 6.283185269630412; -const C3 = 76.56887678023256; -const C4 = 81.60201529595571; -const C5 = 39.65735524898863; - -/** Inlined fast approximation of cos(2*pi*t) for t in turns (matches the game). */ -function sinlike(t: number): number { - const r = Math.trunc(t + (t > 0 ? 0.5 : -0.5)); - const x = 0.25 - Math.abs(t - r); - const x2 = x * x; - const x4 = x2 * x2; - const x8 = x4 * x4; - let poly = C2 - x2 * C1 + x4 * (C4 - x2 * C3); - poly = x8 * C5 + poly; - return x * poly; -} - -/** - * The game's `starting_lake_positions`: one lake per starting position, computed - * from `(seed0, startingPositions)` alone. Positions are world tiles. - */ -export function startingLakePositions(seed0: number, startingPositions: readonly Point[]): Point[] { - const word = Math.max(seed0 >>> 0, MIN_SEED_WORD); - const st = seededState(word); - const lakes: Point[] = []; - for (const spawn of startingPositions) { - const u = taus88Next(st) * TWO_POW_NEG32; - const t = Math.fround(u * TWO_PI) * INV_TWO_PI; - lakes.push({ - x: Math.trunc(spawn.x + RADIUS * sinlike(t)), - y: Math.trunc(spawn.y + RADIUS * sinlike(t - 0.25)), - }); - } - return lakes; -} diff --git a/src/noise/tiles/catalog.ts b/src/noise/tiles/catalog.ts deleted file mode 100644 index 9c399b64..00000000 --- a/src/noise/tiles/catalog.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * The 21 Nauvis autoplace tiles as data (Task 9): name, `map_color`, and a - * `probability(env)` closure transcribed verbatim from the design spec's tile - * table (docs/superpowers/specs/2026-07-19-milestone2-climate-terrain-design.md, - * "The 21 Nauvis tiles" section), which is itself transcribed from the game's - * `base/prototypes/tile/tiles.lua` `probability_expression` + `map_color` fields. - * - * Tile selection is a pure argmax over these 21 `probability(env)` values (Task - * 10's `resolveTile`); this module only builds the catalog, it does not resolve. - * - * Every land tile's expression is `expression_in_range_base(...)` (climate box - * over aux/moisture) plus a per-tile `noise_layer_noise(N)` jitter, some as a - * `max(...)` of two climate boxes. The two water tiles use `water_base` only - - * no noise layer, no climate dependence. `sand-1` additionally ORs in an - * unbounded (`peakMaximum = Infinity`) coastal `expression_in_range` term over - * (elevation, aux) - see `expressionInRange`'s doc comment for why the plateau - * is uncapped there. - */ - -import { max } from "../eval/math"; -import { expressionInRange } from "./expressionInRange"; -import { expressionInRangeBase, makeNoiseLayerNoise, waterBase } from "./helpers"; - -export interface TileEnv { - x: number; - y: number; - elevation: number; - aux: number; - moisture: number; -} - -export interface Tile { - name: string; - /** `[r, g, b, 255]`, 0-255 verbatim from the spec's `map_color` column. */ - color: [number, number, number, number]; - probability(env: TileEnv): number; -} - -/** - * Builds the 21-tile catalog for a given map seed (`seed0`). Each land tile's - * `noise_layer_noise(N)` closure is constructed once here (via - * `makeNoiseLayerNoise(seed0, N)`) and captured by that tile's `probability`, - * rather than rebuilt per call. - */ -export function makeTileCatalog(seed0: number): Tile[] { - // noise_layer_noise seeds used across the 19 land tiles: 6-13, 19-22, 30-33, 36-38. - const noiseLayer6 = makeNoiseLayerNoise(seed0, 6); - const noiseLayer7 = makeNoiseLayerNoise(seed0, 7); - const noiseLayer8 = makeNoiseLayerNoise(seed0, 8); - const noiseLayer9 = makeNoiseLayerNoise(seed0, 9); - const noiseLayer10 = makeNoiseLayerNoise(seed0, 10); - const noiseLayer11 = makeNoiseLayerNoise(seed0, 11); - const noiseLayer12 = makeNoiseLayerNoise(seed0, 12); - const noiseLayer13 = makeNoiseLayerNoise(seed0, 13); - const noiseLayer19 = makeNoiseLayerNoise(seed0, 19); - const noiseLayer20 = makeNoiseLayerNoise(seed0, 20); - const noiseLayer21 = makeNoiseLayerNoise(seed0, 21); - const noiseLayer22 = makeNoiseLayerNoise(seed0, 22); - const noiseLayer30 = makeNoiseLayerNoise(seed0, 30); - const noiseLayer31 = makeNoiseLayerNoise(seed0, 31); - const noiseLayer32 = makeNoiseLayerNoise(seed0, 32); - const noiseLayer33 = makeNoiseLayerNoise(seed0, 33); - const noiseLayer36 = makeNoiseLayerNoise(seed0, 36); - const noiseLayer37 = makeNoiseLayerNoise(seed0, 37); - const noiseLayer38 = makeNoiseLayerNoise(seed0, 38); - - return [ - { - // water_base(-2, 200) - name: "deepwater", - color: [38, 64, 73, 255], - probability: (env) => waterBase(env.elevation, -2, 200), - }, - { - // water_base(0, 100) - name: "water", - color: [51, 83, 95, 255], - probability: (env) => waterBase(env.elevation, 0, 100), - }, - { - // expression_in_range_base(-10,0.7,11,11) + noise_layer_noise(19) - name: "grass-1", - color: [55, 53, 11, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.7, 11, 11) + noiseLayer19(env.x, env.y), - }, - { - // expression_in_range_base(0.45,0.45,11,0.8) + noise_layer_noise(20) - name: "grass-2", - color: [66, 57, 15, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, 0.45, 0.45, 11, 0.8) + - noiseLayer20(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.6,0.65,0.9) + noise_layer_noise(21) - name: "grass-3", - color: [65, 52, 28, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.6, 0.65, 0.9) + - noiseLayer21(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.5,0.55,0.7) + noise_layer_noise(22) - name: "grass-4", - color: [59, 40, 18, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.5, 0.55, 0.7) + - noiseLayer22(env.x, env.y), - }, - { - // expression_in_range_base(0.45,-10,0.55,0.35) + noise_layer_noise(13) - name: "dry-dirt", - color: [94, 66, 37, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, 0.45, -10, 0.55, 0.35) + - noiseLayer13(env.x, env.y), - }, - { - // max(expression_in_range_base(-10,0.25,0.45,0.3), expression_in_range_base(0.4,-10,0.45,0.25)) + noise_layer_noise(6) - name: "dirt-1", - color: [141, 104, 60, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, -10, 0.25, 0.45, 0.3), - expressionInRangeBase(env.aux, env.moisture, 0.4, -10, 0.45, 0.25), - ) + noiseLayer6(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.3,0.45,0.35) + noise_layer_noise(7) - name: "dirt-2", - color: [136, 96, 59, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.3, 0.45, 0.35) + - noiseLayer7(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.35,0.55,0.4) + noise_layer_noise(8) - name: "dirt-3", - color: [133, 92, 53, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.35, 0.55, 0.4) + - noiseLayer8(env.x, env.y), - }, - { - // max(expression_in_range_base(0.55,-10,0.6,0.35), expression_in_range_base(0.6,0.3,11,0.35)) + noise_layer_noise(9) - name: "dirt-4", - color: [103, 72, 43, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, 0.55, -10, 0.6, 0.35), - expressionInRangeBase(env.aux, env.moisture, 0.6, 0.3, 11, 0.35), - ) + noiseLayer9(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.4,0.55,0.45) + noise_layer_noise(10) - name: "dirt-5", - color: [91, 63, 38, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.4, 0.55, 0.45) + - noiseLayer10(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.45,0.55,0.5) + noise_layer_noise(11) - name: "dirt-6", - color: [80, 55, 31, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.45, 0.55, 0.5) + - noiseLayer11(env.x, env.y), - }, - { - // expression_in_range_base(-10,0.5,0.55,0.55) + noise_layer_noise(12) - name: "dirt-7", - color: [80, 54, 28, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, -10, 0.5, 0.55, 0.55) + - noiseLayer12(env.x, env.y), - }, - { - // max(expression_in_range_base(-10,-10,0.25,0.15), - // expression_in_range(5, inf, elevation, aux, -1.5, 0.5, 1.5, 1)) + noise_layer_noise(36) - name: "sand-1", - color: [138, 103, 58, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, -10, -10, 0.25, 0.15), - expressionInRange(5, Infinity, [env.elevation, env.aux], [-1.5, 0.5], [1.5, 1]), - ) + noiseLayer36(env.x, env.y), - }, - { - // max(expression_in_range_base(-10,0.15,0.3,0.2), expression_in_range_base(0.25,-10,0.3,0.15)) + noise_layer_noise(37) - name: "sand-2", - color: [128, 93, 52, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, -10, 0.15, 0.3, 0.2), - expressionInRangeBase(env.aux, env.moisture, 0.25, -10, 0.3, 0.15), - ) + noiseLayer37(env.x, env.y), - }, - { - // max(expression_in_range_base(-10,0.2,0.4,0.25), expression_in_range_base(0.3,-10,0.4,0.2)) + noise_layer_noise(38) - name: "sand-3", - color: [115, 83, 47, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, -10, 0.2, 0.4, 0.25), - expressionInRangeBase(env.aux, env.moisture, 0.3, -10, 0.4, 0.2), - ) + noiseLayer38(env.x, env.y), - }, - { - // expression_in_range_base(0.55,0.35,11,0.5) + noise_layer_noise(30) - name: "red-desert-0", - color: [103, 70, 32, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, 0.55, 0.35, 11, 0.5) + - noiseLayer30(env.x, env.y), - }, - { - // max(expression_in_range_base(0.6,-10,0.7,0.3), expression_in_range_base(0.7,0.25,11,0.3)) + noise_layer_noise(31) - name: "red-desert-1", - color: [116, 81, 39, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, 0.6, -10, 0.7, 0.3), - expressionInRangeBase(env.aux, env.moisture, 0.7, 0.25, 11, 0.3), - ) + noiseLayer31(env.x, env.y), - }, - { - // max(expression_in_range_base(0.7,-10,0.8,0.25), expression_in_range_base(0.8,0.2,11,0.25)) + noise_layer_noise(32) - name: "red-desert-2", - color: [116, 84, 43, 255], - probability: (env) => - max( - expressionInRangeBase(env.aux, env.moisture, 0.7, -10, 0.8, 0.25), - expressionInRangeBase(env.aux, env.moisture, 0.8, 0.2, 11, 0.25), - ) + noiseLayer32(env.x, env.y), - }, - { - // expression_in_range_base(0.8,-10,11,0.2) + noise_layer_noise(33) - name: "red-desert-3", - color: [128, 93, 52, 255], - probability: (env) => - expressionInRangeBase(env.aux, env.moisture, 0.8, -10, 11, 0.2) + - noiseLayer33(env.x, env.y), - }, - ]; -} diff --git a/src/noise/tiles/expressionInRange.ts b/src/noise/tiles/expressionInRange.ts deleted file mode 100644 index acd924e5..00000000 --- a/src/noise/tiles/expressionInRange.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { f32 } from "../eval/f32"; - -/** - * The native Factorio `expression_in_range(peak_multiplier, peak_maximum, - * expr_1..N, from_1..N, to_1..N)` builtin, reverse-engineered from the headless - * oracle (see docs/noise/expression-in-range-NOTES.md). Used by the tile-autoplace - * system to make a tile probable only inside an N-dimensional box of climate - * values, with a linear falloff outside. - * - * Derived formula: - * - * m = min over all dims i of min(value_i - from_i, to_i - value_i) - * result = min(peak_maximum, peak_multiplier * m) - * - * **Every step is rounded to f32, and that makes this EXACT** (issue #162). The - * arithmetic used to run in f64 and rounded once at the end, which left a worst - * residual of ~9.5e-7 that the spec accepted under an `8e-3` floor - a ceiling - * ~8400x looser than the actual error, so it endorsed almost anything. The noise - * machine evaluates in f32 registers; reproducing that takes the residual to - * **exactly 0 on all 404 committed oracle samples** (three sweeps, 121 + 121 + - * 162), where the f64 form matched only 285 of them. - * - * This is the same class of fix as `fastApprox`'s per-operation rounding: the - * formula was right all along and the precision of the intermediate steps was - * the whole error. Do not "simplify" these `f32` calls away. - * - * Per dimension, `min(value - from, to - value)` is the signed distance to the - * nearer edge of `[from, to]`: positive inside, zero on an edge, negative outside. - * Taking the min across dims makes the box a hard AND (any dim out of range pulls - * the result down). Scaling by `peak_multiplier` sets the falloff slope; clamping - * at `peak_multiplier * m` <= `peak_maximum` caps the in-range plateau. There is NO - * lower clamp - the value falls linearly without bound outside the range. - * - * `peak_maximum` may be `Infinity` (sand-1's unbounded coastal term - * `expression_in_range(5, inf, ...)`), in which case the plateau is uncapped and - * in-range values exceed 1 (~`peak_multiplier * halfWidth`). Do NOT clamp that case. - */ -export function expressionInRange( - peakMultiplier: number, - peakMaximum: number, - values: number[], - froms: number[], - tos: number[], -): number { - let m = Infinity; - for (let i = 0; i < values.length; i++) { - const v = f32(values[i]); - const edgeDistance = Math.min(f32(v - f32(froms[i])), f32(f32(tos[i]) - v)); - if (edgeDistance < m) m = edgeDistance; - } - // peakMaximum stays unrounded: it is `Infinity` at sand-1's call site, and - // Math.fround(Infinity) is Infinity, but leaving it alone keeps that obvious. - return Math.min(peakMaximum, f32(f32(peakMultiplier) * m)); -} diff --git a/src/noise/tiles/helpers.ts b/src/noise/tiles/helpers.ts deleted file mode 100644 index c6b502d2..00000000 --- a/src/noise/tiles/helpers.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Tile-autoplace helper functions: the small building blocks the 21-tile - * catalog (Task 9) composes into each tile's probability expression. Each one - * is pure composition over already-validated primitives - {@link expressionInRange} - * (Task 1, RE'd against the headless oracle) and {@link makeMultioctaveNoise} - so - * no new oracle capture is needed here. - */ - -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import { expressionInRange } from "./expressionInRange"; - -/** - * The game's `expression_in_range_base(aux_from, moisture_from, aux_to, moisture_to)`, - * a curried helper that fixes `peak_multiplier = 20` and `peak_maximum = 1` and - * regroups the two climate axes (aux, moisture) into `expression_in_range`'s - * flat `expr/from/to` argument lists: - * - * expression_in_range_base(aux_from, moisture_from, aux_to, moisture_to) - * = expression_in_range(20, 1, aux, moisture, aux_from, moisture_from, aux_to, moisture_to) - * - * Used throughout the tile catalog to make a tile probable only inside an - * (aux, moisture) climate box, with a hard AND across both axes (via - * `expressionInRange`'s min-of-edge-distances) and a plateau capped at 1. - */ -export function expressionInRangeBase( - aux: number, - moisture: number, - auxFrom: number, - moistureFrom: number, - auxTo: number, - moistureTo: number, -): number { - return expressionInRange(20, 1, [aux, moisture], [auxFrom, moistureFrom], [auxTo, moistureTo]); -} - -/** - * The game's `water_base(max_elevation, influence)`: - * - * water_base(max_elevation, influence) = - * if(max_elevation >= elevation, influence * min(max_elevation - elevation, 1), -inf) - * - * `elevation` is the runtime per-tile value, so it is the first parameter here; - * `maxElevation`/`influence` are the tile's constants. Below `maxElevation` the - * result ramps up linearly over the last 1 unit of headroom to a plateau of - * `influence`; at or above `maxElevation` the tile is excluded entirely - * (`-Infinity`, never selected by the resolver's argmax). - */ -export function waterBase(elevation: number, maxElevation: number, influence: number): number { - return maxElevation >= elevation ? influence * Math.min(maxElevation - elevation, 1) : -Infinity; -} - -/** - * The game's `noise_layer_noise(seed)`: - * - * noise_layer_noise(seed) = multioctave_noise{ - * persistence = 0.7, seed1 = seed, octaves = 4, - * input_scale = 1/6, output_scale = 2/3, - * } - * - * `seed0` is the map seed; `seed1` is the per-layer seed selector (the game's - * `seed` argument). Returns a closure `(x, y) => number` built once per - * (seed0, seed1) pair - the common case for rendering a grid at one seed. - */ -export function makeNoiseLayerNoise( - seed0: number, - seed1: number, -): (x: number, y: number) => number { - return makeMultioctaveNoise({ - seed0, - seed1, - octaves: 4, - persistence: 0.7, - inputScale: 1 / 6, - outputScale: 2 / 3, - }); -} diff --git a/src/noise/tiles/resolve.ts b/src/noise/tiles/resolve.ts deleted file mode 100644 index a6b9f90a..00000000 --- a/src/noise/tiles/resolve.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Point } from "../distanceFromNearestPoint"; -import { makeAux } from "../expressions/aux"; -import { makeElevationNauvis } from "../expressions/elevationNauvis"; -import { makeMoisture } from "../expressions/moisture"; -import { makeTileCatalog } from "./catalog"; -import type { Tile } from "./catalog"; - -export interface TileResolverParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:water:frequency; default 1. Threads into elevation/aux/moisture. */ - readonly segmentationMultiplier?: number; - /** control:moisture:frequency; default 1. */ - readonly moistureFrequency?: number; - /** control:moisture:bias; default 0. */ - readonly moistureBias?: number; - /** control:aux:frequency; default 1. */ - readonly auxFrequency?: number; - /** control:aux:bias; default 0. */ - readonly auxBias?: number; - /** control:starting_area_moisture:size; default 1 (degenerate at default - see makeMoisture). */ - readonly startingAreaMoistureSize?: number; - /** control:starting_area_moisture:frequency; default 1. */ - readonly startingAreaMoistureFrequency?: number; - /** Spawn points threaded into elevation/moisture's distance terms. Default single origin spawn. */ - readonly startingPositions?: Point[]; -} - -/** - * Task 10: builds the tile-autoplace argmax for one seed (default Nauvis - * elevation + default climate - freq 1, bias 0 - since this is what the - * `oracle-tile-names` fixtures capture). Compiles the elevation, aux, moisture - * evaluators and the 21-tile catalog once, then returns an `(x, y) => Tile` - * resolver: at each point it evaluates elevation/aux/moisture, evaluates every - * catalog tile's `probability(env)`, and returns the tile with the maximum - * probability (argmax; ties keep the first tile in catalog order, since a - * strict `>` comparison never replaces the running winner on an exact tie). - * - * Task 12b: the climate params (moisture/aux frequency+bias, starting-area - * moisture, startingPositions) all default to the game's own defaults, so - * calling this with none of them supplied is byte-for-byte the same path - * Task 10 validated against the oracle at 100%. Non-default climate values are - * faithful ports of the game's noise-programs.lua tree (same as the - * already-shipped starting-area/starting-lake elevation levers) but are NOT - * themselves oracle-validated point-by-point - only the all-defaults path is. - */ -export function makeTileResolver(params: TileResolverParams): (x: number, y: number) => Tile { - const seed0 = params.seed0; - const segmentationMultiplier = params.segmentationMultiplier ?? 1; - const startingPositions = params.startingPositions ?? [{ x: 0, y: 0 }]; - - const elevationAt = makeElevationNauvis({ seed0, segmentationMultiplier, startingPositions }); - const auxAt = makeAux({ - seed0, - segmentationMultiplier, - frequency: params.auxFrequency, - bias: params.auxBias, - }); - const moistureAt = makeMoisture({ - seed0, - segmentationMultiplier, - moistureFrequency: params.moistureFrequency, - moistureBias: params.moistureBias, - startingAreaMoistureSize: params.startingAreaMoistureSize, - startingAreaMoistureFrequency: params.startingAreaMoistureFrequency, - startingPositions, - }); - const catalog = makeTileCatalog(seed0); - - return (x: number, y: number): Tile => { - const elevation = elevationAt(x, y); - const aux = auxAt(x, y); - const moisture = moistureAt(x, y); - const env = { x, y, elevation, aux, moisture }; - - let winner = catalog[0]; - let winnerProbability = winner.probability(env); - for (let i = 1; i < catalog.length; i++) { - const tile = catalog[i]; - const probability = tile.probability(env); - if (probability > winnerProbability) { - winner = tile; - winnerProbability = probability; - } - } - return winner; - }; -} diff --git a/src/noise/trees/asymmetricRamps.ts b/src/noise/trees/asymmetricRamps.ts deleted file mode 100644 index fc927fa2..00000000 --- a/src/noise/trees/asymmetricRamps.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * `asymmetric_ramps` from core/prototypes/noise-functions.lua:114-124. - * - * Two opposing linear ramps combined with `min`: output crosses 0 at `fromTop` - * and `toTop`, and -1 at `fromBottom` and `toBottom`. The peak value depends on - * how far apart the tops are, so it is positive when they are apart and negative - * when they cross each other. - * - * There is deliberately no clamp and no upper bound - the game's comment says it - * is "designed to be used with a group of asymmetric_ramps inside a shared min()", - * which is exactly how every tree species uses it. - */ -export function asymmetricRamps( - input: number, - fromBottom: number, - fromTop: number, - toTop: number, - toBottom: number, -): number { - return Math.min((input - fromTop) / (fromTop - fromBottom), (toTop - input) / (toBottom - toTop)); -} diff --git a/src/noise/trees/treeCatalog.ts b/src/noise/trees/treeCatalog.ts deleted file mode 100644 index 4da196d5..00000000 --- a/src/noise/trees/treeCatalog.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * The 15 Nauvis tree species autoplace probability expressions, as data. - * - * Every species in base/prototypes/entity/trees.lua @ 2.1.11 shares exactly one - * expression shape, so a species is fully described by a parameter row: - * - * min(cap, - * trees_forest_path_cutout_faded, - * min(0, asymmetric_ramps{input=temperature, ...tempRamp}, - * asymmetric_ramps{input=moisture, ...moistRamp}) - * + min(0, distance/20 - 3) - * - sizeOffset + 0.2 * control:trees:size - * + tree_small_noise * 0.1 - * + multioctave_noise{persistence 0.65, octaves 3, seed1 = , - * input_scale = (1/inputScaleDiv) * control:trees:frequency, - * output_scale = outputScale}) - * - * `sizeOffset` is 0.5 for 13 of the 15 species and 0.45 for `tree_05`/`tree_07` - * (see the field doc below) - the one genuinely per-species term. - * - * That shape claim is checked by `treeCatalogExpressions.spec.ts`, which rebuilds - * each row's Lua string and diffs it against the checked-in game data character - * for character. Do NOT re-verify this by filtering common terms out of the Lua - * and eyeballing the remainder: that is what was done originally, the filter - * dropped every line containing `control:trees:size`, and `sizeOffset` - the one - * term that varies - was the one term excluded from the check. - * - * `seed1` is a STRING in the Lua; Factorio hashes it with crc32 (see - * nauvisShared.ts:9). The numbers here are those hashes, precomputed so this - * module has no runtime dependency on the codec. treeCatalog.spec.ts asserts each - * one against `crc32`, and the oracle fixtures prove the whole assumption. - * - * Rows are ordered by descending `cap`; treeField's early-out converges faster - * that way. Order does not affect the result (the composition is a max). - */ -export interface TreeSpecies { - /** The game's noise-expression name, e.g. "tree_01" (used for oracle sampling). */ - readonly name: string; - /** The string passed as `seed1` in the Lua, e.g. "tree-01". */ - readonly seed1Name: string; - /** `crc32(utf8(seed1Name))` - the numeric seed1 the game actually uses. */ - readonly seed1: number; - /** The species' upper bound (the leading `min(cap, ...)`). */ - readonly cap: number; - /** `asymmetric_ramps{input=temperature}` args: from_bottom, from_top, to_top, to_bottom. */ - readonly tempRamp: readonly [number, number, number, number]; - /** `asymmetric_ramps{input=moisture}` args: from_bottom, from_top, to_top, to_bottom. */ - readonly moistRamp: readonly [number, number, number, number]; - /** `input_scale = (1 / inputScaleDiv) * control:trees:frequency`. */ - readonly inputScaleDiv: number; - /** The species noise term's `output_scale`. */ - readonly outputScale: number; - /** - * The constant additive term in `- sizeOffset + 0.2 * control:trees:size`. - * - * Verified against `~/GitHub/factorio-data` @ tag 2.1.11, - * `base/prototypes/entity/trees.lua`: `tree_05` and `tree_07` use `0.45`; the - * other 13 species use `0.5`. This was the one genuinely per-species term - * hiding in an otherwise-uniform expression shape - every other term (the - * distance term, `tree_small_noise * 0.1`, persistence 0.65, octaves 3, and - * the `trees_forest_path_cutout_faded` bound) is uniform across all 15 - * species. Caught by the oracle: modeling this as a shared constant made - * tree_05 and tree_07 disagree with the real game by a near-constant 5.01e-2 - * everywhere (see test/treeOracle.spec.ts). - */ - readonly sizeOffset: number; -} - -/** `tree_small_noise`'s seed1: `crc32(utf8("tree-small"))`. */ -export const TREE_SMALL_NOISE_SEED1 = 2343395516; - -export const TREE_SPECIES: readonly TreeSpecies[] = [ - { - name: "tree_01", - seed1Name: "tree-01", - seed1: 545692666, - cap: 0.45, - tempRamp: [0, 10, 14, 15], - moistRamp: [0.6, 0.7, 1, 2], - inputScaleDiv: 25, - outputScale: 0.8, - sizeOffset: 0.5, - }, - { - name: "tree_04", - seed1Name: "tree-04", - seed1: 1357672309, - cap: 0.45, - tempRamp: [13, 14, 16, 17], - moistRamp: [0.7, 0.9, 1, 2], - inputScaleDiv: 30, - outputScale: 0.8, - sizeOffset: 0.5, - }, - { - name: "tree_05", - seed1Name: "tree-05", - seed1: 669736931, - cap: 0.45, - tempRamp: [15, 16, 35, 45], - moistRamp: [0.6, 0.7, 1, 2], - inputScaleDiv: 40, - outputScale: 0.8, - sizeOffset: 0.45, - }, - { - name: "tree_02", - seed1Name: "tree-02", - seed1: 3113208384, - cap: 0.4, - tempRamp: [0, 10, 14, 15], - moistRamp: [0.4, 0.5, 0.7, 0.8], - inputScaleDiv: 25, - outputScale: 0.75, - sizeOffset: 0.5, - }, - { - name: "tree_03", - seed1Name: "tree-03", - seed1: 3465083606, - cap: 0.4, - tempRamp: [15, 16, 35, 45], - moistRamp: [0.4, 0.5, 0.7, 0.8], - inputScaleDiv: 35, - outputScale: 0.75, - sizeOffset: 0.5, - }, - { - name: "tree_07", - seed1Name: "tree-07", - seed1: 3387244239, - cap: 0.4, - tempRamp: [13, 14, 16, 17], - moistRamp: [0.5, 0.6, 0.9, 1], - inputScaleDiv: 40, - outputScale: 0.75, - sizeOffset: 0.45, - }, - { - name: "tree_02_red", - seed1Name: "tree-02-red", - seed1: 2142693989, - cap: 0.3, - tempRamp: [0, 10, 14, 15], - moistRamp: [0.2, 0.3, 0.5, 0.6], - inputScaleDiv: 25, - outputScale: 0.7, - sizeOffset: 0.5, - }, - { - name: "tree_08", - seed1Name: "tree-08", - seed1: 1499079518, - cap: 0.3, - tempRamp: [13, 14, 16, 17], - moistRamp: [0.3, 0.4, 0.6, 0.7], - inputScaleDiv: 30, - outputScale: 0.7, - sizeOffset: 0.5, - }, - { - name: "tree_09", - seed1Name: "tree-09", - seed1: 777851848, - cap: 0.3, - tempRamp: [15, 16, 35, 45], - moistRamp: [0.2, 0.3, 0.5, 0.6], - inputScaleDiv: 25, - outputScale: 0.7, - sizeOffset: 0.5, - }, - { - name: "tree_06", - seed1Name: "tree-06", - seed1: 3202485849, - cap: 0.2, - tempRamp: [0, 10, 14, 15], - moistRamp: [0.1, 0.2, 0.3, 0.4], - inputScaleDiv: 22, - outputScale: 0.6, - sizeOffset: 0.5, - }, - { - name: "tree_08_brown", - seed1Name: "tree-08-brown", - seed1: 3606254248, - cap: 0.2, - tempRamp: [13, 14, 16, 17], - moistRamp: [0.2, 0.3, 0.4, 0.5], - inputScaleDiv: 30, - outputScale: 0.6, - sizeOffset: 0.5, - }, - { - name: "tree_09_brown", - seed1Name: "tree-09-brown", - seed1: 1887705372, - cap: 0.2, - tempRamp: [15, 16, 35, 45], - moistRamp: [0.1, 0.2, 0.3, 0.4], - inputScaleDiv: 25, - outputScale: 0.6, - sizeOffset: 0.5, - }, - { - name: "tree_06_brown", - seed1Name: "tree-06-brown", - seed1: 2261543413, - cap: 0.1, - tempRamp: [0, 10, 14, 15], - moistRamp: [0, 0.1, 0.2, 0.3], - inputScaleDiv: 22, - outputScale: 0.5, - sizeOffset: 0.5, - }, - { - name: "tree_08_red", - seed1Name: "tree-08-red", - seed1: 889647812, - cap: 0.1, - tempRamp: [13, 14, 16, 17], - moistRamp: [0.1, 0.2, 0.3, 0.4], - inputScaleDiv: 30, - outputScale: 0.5, - sizeOffset: 0.5, - }, - { - name: "tree_09_red", - seed1Name: "tree-09-red", - seed1: 140958580, - cap: 0.1, - tempRamp: [15, 16, 35, 45], - moistRamp: [0, 0.1, 0.2, 0.3], - inputScaleDiv: 25, - outputScale: 0.5, - sizeOffset: 0.5, - }, -]; diff --git a/src/noise/trees/treeField.ts b/src/noise/trees/treeField.ts deleted file mode 100644 index 29210ee5..00000000 --- a/src/noise/trees/treeField.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { asymmetricRamps } from "./asymmetricRamps"; -import { clamp } from "../eval/math"; -import { distanceFromNearestPoint, type Point } from "../distanceFromNearestPoint"; -import { fastPow } from "../fastApprox"; -import { makeMoisture } from "../expressions/moisture"; -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import { makeNauvisShared } from "../expressions/nauvisShared"; -import { makeTemperature } from "../expressions/temperature"; -import { makeTreeShared } from "./treeShared"; -import { TREE_SPECIES, type TreeSpecies } from "./treeCatalog"; - -/** - * A conservative upper bound on `|basisNoise|`, used to bound each species' noise - * term so the density max can skip evaluating octaves that cannot win. - * - * This is a MEASURED maximum plus a safety margin, not an analytic bound - the - * basis range is not a clean +/-sqrt(3) (see docs/noise/basis-noise-NOTES.md). - * treeFieldEarlyOut.spec.ts asserts both that the bound holds against hard - * sampling AND that the early-out result is bit-identical to full evaluation, so - * a wrong value fails loudly instead of silently clipping forests. - */ -export const BASIS_ABS_MAX = 1.8; - -/** - * Every species' own noise term uses these. Shared with {@link maxNoiseFor} so the - * early-out bound cannot silently desync from the noise it is meant to bound. - */ -const TREE_OCTAVES = 3; -const TREE_PERSISTENCE = 0.65; - -export interface TreeFieldParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:trees:frequency; default 1. */ - readonly treesFrequency?: number; - /** control:trees:size; default 1. */ - readonly treesSize?: number; - /** control:water:frequency; default 1. */ - readonly segmentationMultiplier?: number; - /** Climate levers, forwarded to makeMoisture. */ - readonly moistureFrequency?: number; - readonly moistureBias?: number; - /** - * `control:temperature:frequency` / `:bias`. The app has no UI for these, but - * `climateReads` parses them out of an imported exchange string's - * property_expression_names, and trees are the only consumer of `temperature` - * - so dropping them here silently renders the wrong forest layout. - */ - readonly temperatureFrequency?: number; - readonly temperatureBias?: number; - readonly startingAreaMoistureSize?: number; - readonly startingAreaMoistureFrequency?: number; - /** Spawn points for `distance`. Default single origin spawn. */ - readonly startingPositions?: readonly Point[]; -} - -export interface TreeSpeciesField { - readonly species: TreeSpecies; - readonly evalAt: (x: number, y: number) => number; - /** - * The species value minus its own noise term. Adding {@link maxNoise} to it - * bounds {@link evalAt} from above; on its own it is simply that sum without - * the noise, so it sits *below* `evalAt` wherever the noise is positive. - */ - readonly cheapAt: (x: number, y: number) => number; - /** - * {@link cheapAt} with the per-pixel terms supplied by the caller, so a loop - * over all 15 species evaluates the shared climate stack once instead of once - * each. Positional rather than an object to keep the hot path allocation-free; - * `makeTreeDensity` is the intended caller. - */ - readonly cheapFrom: ( - temperature: number, - moisture: number, - distanceTerm: number, - smallTerm: number, - ) => number; - readonly noiseAt: (x: number, y: number) => number; - /** The largest magnitude this species' noise term can reach. */ - readonly maxNoise: number; -} - -/** - * The species-independent half of a tree evaluation, plus the fields needed to - * compute it. `makeTreeDensity` evaluates these once per pixel; every species - * then reuses the results. - */ -interface TreeFields { - readonly fields: TreeSpeciesField[]; - readonly temperature: (x: number, y: number) => number; - readonly moisture: (x: number, y: number) => number; - readonly smallNoise: (x: number, y: number) => number; - readonly forestPathCutout: (x: number, y: number) => number; - readonly startingPositions: readonly Point[]; -} - -/** - * `|multioctave_noise|` cannot exceed `outputScale * norm * (sum of octave - * amplitudes) * BASIS_ABS_MAX`. With octaves 3 and persistence 0.65 the octave - * amplitudes are `norm * (1, 1/P, 1/P^2)`; `norm` is the RMS normalisation - * multioctaveNoise applies. Mirrors multioctaveNoise.ts:70-99. - */ -function maxNoiseFor(species: TreeSpecies): number { - const P = TREE_PERSISTENCE; - const octaves = TREE_OCTAVES; - const invP2 = 1 / (P * P); - // fastPow, NOT `**` - multioctaveNoise normalises with the game's fastapprox - // pow, and the bound must be computed the same way or it is not a bound. - const norm = Math.sqrt((invP2 - 1) / (fastPow(invP2, octaves) - 1)); - let amps = 0; - let amp = norm; - for (let k = 0; k < octaves; k++) { - amps += amp; - amp /= P; - } - return species.outputScale * amps * BASIS_ABS_MAX; -} - -/** - * Compile all 15 Nauvis tree species probability expressions for one seed. - * - * `temperature` has been ported and oracle-validated since M2 but was never - * evaluated by anything - tile selection turned out to be aux + moisture only. - * This is its first consumer, which is why `control:temperature:*` only started - * mattering here: the app has no UI for it, but an imported exchange string can - * carry one, so the levers are threaded through rather than defaulted. - */ -export function makeTreeSpeciesFields(params: TreeFieldParams): TreeSpeciesField[] { - return makeTreeFields(params).fields; -} - -/** {@link makeTreeSpeciesFields}, also handing back the shared per-pixel fields. */ -function makeTreeFields(params: TreeFieldParams): TreeFields { - const seed0 = params.seed0; - const treesFrequency = params.treesFrequency ?? 1; - const treesSize = params.treesSize ?? 1; - const startingPositions = params.startingPositions ?? [{ x: 0, y: 0 }]; - - const nz = makeNauvisShared({ - seed0, - segmentationMultiplier: params.segmentationMultiplier, - }); - const { smallNoise, forestPathCutout, forestPathCutoutFaded } = makeTreeShared( - { seed0, segmentationMultiplier: params.segmentationMultiplier }, - nz, - ); - const temperature = makeTemperature({ - seed0, - frequency: params.temperatureFrequency, - bias: params.temperatureBias, - }); - const moisture = makeMoisture({ - seed0, - segmentationMultiplier: params.segmentationMultiplier, - moistureFrequency: params.moistureFrequency, - moistureBias: params.moistureBias, - startingAreaMoistureSize: params.startingAreaMoistureSize, - startingAreaMoistureFrequency: params.startingAreaMoistureFrequency, - startingPositions: [...startingPositions], - }); - - const fields = TREE_SPECIES.map((species): TreeSpeciesField => { - const noise = makeMultioctaveNoise({ - seed0, - seed1: species.seed1, - octaves: TREE_OCTAVES, - persistence: TREE_PERSISTENCE, - inputScale: (1 / species.inputScaleDiv) * treesFrequency, - outputScale: species.outputScale, - }); - - // The size lever is a flat additive term, per-species (tree_05/tree_07 use - // -0.45 where the other 13 species use -0.5 - see TreeSpecies.sizeOffset). - // Hoisted here so it's computed once per species, not once per pixel. - const sizeTerm = -species.sizeOffset + 0.2 * treesSize; - - // The term ORDER here is load-bearing: `treeFieldEarlyOut.spec.ts` asserts - // makeTreeDensity is bit-identical to full evaluation, and float addition is - // not associative. Keep the four addends in this sequence. - const cheapFrom = (t: number, m: number, distanceTerm: number, smallTerm: number): number => { - const climate = Math.min( - 0, - asymmetricRamps(t, ...species.tempRamp), - asymmetricRamps(m, ...species.moistRamp), - ); - return climate + distanceTerm + sizeTerm + smallTerm; - }; - - const cheapAt = (x: number, y: number): number => - cheapFrom( - temperature(x, y), - moisture(x, y), - Math.min(0, distanceFromNearestPoint(x, y, startingPositions) / 20 - 3), - smallNoise(x, y) * 0.1, - ); - - const evalAt = (x: number, y: number): number => - Math.min(species.cap, forestPathCutoutFaded(x, y), cheapAt(x, y) + noise(x, y)); - - return { species, evalAt, cheapAt, cheapFrom, noiseAt: noise, maxNoise: maxNoiseFor(species) }; - }); - - return { fields, temperature, moisture, smallNoise, forestPathCutout, startingPositions }; -} - -/** - * The per-pixel tree density: `clamp(max_i p_i, 0, 1)`. - * - * `max` is not an approximation. Per docs/noise/placement-roll-NOTES.md, the game's - * `EntityMapGenerationTask::generateEntities` arbitrates a single winning entity per - * tile by MAX probability and then rolls once against it, so `max_i p_i` is exactly - * the probability the game rolls on a tile where a tree wins. The density-shaded - * render is therefore the expected value of what the game draws, and the eventual - * placement-stipple project (Phase 2) consumes this identical field. - */ -export function makeTreeDensity(params: TreeFieldParams): (x: number, y: number) => number { - const { fields, temperature, moisture, smallNoise, forestPathCutout, startingPositions } = - makeTreeFields(params); - - return (x: number, y: number): number => { - // The species-independent terms, evaluated ONCE per pixel. The climate stack - // (a 4-octave temperature and moisture's quick-multioctave plus billow cutout) - // costs more than the 3-octave species noise the early-out saves, so computing - // it per species - as this did originally - dominated the whole render. - const t = temperature(x, y); - const m = moisture(x, y); - const distanceTerm = Math.min(0, distanceFromNearestPoint(x, y, startingPositions) / 20 - 3); - const smallTerm = smallNoise(x, y) * 0.1; - - // `trees_forest_path_cutout_faded`, inlined so it reuses smallTerm rather than - // re-evaluating tree_small_noise, and deferred until a species actually needs - // it (pixels where every species is skipped never pay for the billows). - let cutoutFaded = 0; - let haveCutout = false; - - let best = 0; - for (const f of fields) { - // `cap` and the cutout both bound the species from above, as does - // `cheap + maxNoise`. If none can beat `best`, the 3-octave noise cannot - // change the answer - skip it. Catalog order (descending cap) raises `best` - // early, which maximises how often this fires. - if (f.species.cap <= best) continue; - const cheap = f.cheapFrom(t, m, distanceTerm, smallTerm); - if (cheap + f.maxNoise <= best) continue; - if (!haveCutout) { - cutoutFaded = forestPathCutout(x, y) * 0.3 + smallTerm; - haveCutout = true; - } - const v = Math.min(f.species.cap, cutoutFaded, cheap + f.noiseAt(x, y)); - if (v > best) best = v; - } - return clamp(best, 0, 1); - }; -} diff --git a/src/noise/trees/treeShared.ts b/src/noise/trees/treeShared.ts deleted file mode 100644 index b5c9ef9f..00000000 --- a/src/noise/trees/treeShared.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { makeMultioctaveNoise } from "../multioctaveNoise"; -import { makeNauvisShared, type NauvisShared } from "../expressions/nauvisShared"; -import { TREE_SMALL_NOISE_SEED1 } from "./treeCatalog"; - -export interface TreeSharedParams { - /** Map seed (= map_seed / seed0). */ - readonly seed0: number; - /** control:water:frequency; default 1. Threads into the billow fields. */ - readonly segmentationMultiplier?: number; -} - -export interface TreeShared { - /** `tree_small_noise` - noise-programs.lua:427. */ - readonly smallNoise: (x: number, y: number) => number; - /** `trees_forest_path_cutout` - noise-programs.lua:439. */ - readonly forestPathCutout: (x: number, y: number) => number; - /** `trees_forest_path_cutout_faded` - noise-programs.lua:444. */ - readonly forestPathCutoutFaded: (x: number, y: number) => number; -} - -/** - * The tree-specific shared noise fields from core/prototypes/noise-programs.lua: - * - * tree_small_noise = multioctave_noise{persistence 0.75, octaves 3, - * seed1 'tree-small', - * input_scale 0.2, output_scale 0.5} - * forest_paths = (forest_path_billows - 0.07) * 3 - * nauvis_hills_paths = (nauvis_hills - 0.1) * 3 - * nauvis_bridge_paths = (nauvis_bridge_billows - 0.07) * 5 - * trees_forest_path_cutout = min(nauvis_bridge_paths, nauvis_hills_paths, forest_paths) - * trees_forest_path_cutout_faded = trees_forest_path_cutout * 0.3 + tree_small_noise * 0.1 - * - * These are what carve the forest paths (and, via moisture, the same cutouts the - * climate tree already uses). `tree_small_noise`'s `input_scale` is flat 0.2 - it - * is NOT scaled by `control:trees:frequency`, unlike each species' own noise term. - * - * Pass an existing {@link NauvisShared} to reuse its closures instead of rebuilding - * the billow fields (the render path already has one). - */ -export function makeTreeShared(params: TreeSharedParams, shared?: NauvisShared): TreeShared { - const seed0 = params.seed0; - const nz = - shared ?? makeNauvisShared({ seed0, segmentationMultiplier: params.segmentationMultiplier }); - - const smallNoise = makeMultioctaveNoise({ - seed0, - seed1: TREE_SMALL_NOISE_SEED1, - octaves: 3, - persistence: 0.75, - inputScale: 0.2, - outputScale: 0.5, - }); - - const forestPathCutout = (x: number, y: number): number => - Math.min( - (nz.bridgeBillows(x, y) - 0.07) * 5, - (nz.hills(x, y) - 0.1) * 3, - (nz.forestPathBillows(x, y) - 0.07) * 3, - ); - - const forestPathCutoutFaded = (x: number, y: number): number => - forestPathCutout(x, y) * 0.3 + smallNoise(x, y) * 0.1; - - return { smallNoise, forestPathCutout, forestPathCutoutFaded }; -} diff --git a/src/noise/variablePersistenceMultioctaveNoise.ts b/src/noise/variablePersistenceMultioctaveNoise.ts deleted file mode 100644 index c360a2f6..00000000 --- a/src/noise/variablePersistenceMultioctaveNoise.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * A reimplementation of Factorio's `variable_persistence_multioctave_noise` - * primitive (`NoiseOperations::VariablePersistenceMultioctaveNoise`), - * reverse-engineered against Factorio 2.1.11 - by disassembling its register - * `run` (`NoiseOperations::VariablePersistenceMultioctaveNoise::run` @0x10174a318) - * and fitting the committed oracle. See - * docs/noise/variable-persistence-multioctave-noise-NOTES.md. Built on - * {@link basisNoise}. - * - * This is the op the **elevation** tree uses (nauvis `make_0_12like_lakes`). Its - * defining feature: `persistence` is a spatially-varying value (a noise - * *expression* the game evaluates per tile), so successive octaves are attenuated - * by a persistence that changes across the map. Here the caller supplies that - * per-tile `persistence` scalar. - * - * The shape: - * - * varPers(x, y) = f32( gain * HORNER_{k=0..N-1} basis( f32(f32(x + offset_x)*S_k) , - * f32(y*S_k) ) ) - * - * S_0 = f32(input_scale * 0.5) (finest octave scale = input_scale/2) - * S_k+1 = f32(S_k * 0.5) - * gain = output_scale * 2^N - * p = persistence at this tile - * N = octaves - * - * i.e. N octaves of `basis_noise` sharing ONE (seed0, seed1) - each octave halves - * the input scale (lacunarity 1/2) and is weighted by a power of the per-tile - * persistence, combined in Horner order (finest octave gets the smallest weight - * p^(N-1), coarsest gets 1), with every step rounded to f32. Two things - * distinguish it from the relatives: - * - * 1. **No RMS normalisation.** It is the raw weighted sum times a `2^N` gain. The - * `amplitude_corrected_multioctave_noise` Lua wrapper is what normalises, by - * passing `output_scale = (1 - p)/2^N/(1 - p^N) * amplitude`. (The RMS-norm - * branch in the `Noise::multioctaveNoise(...,float const*,...)` float overload - * is a *different* entry point; the register `run` path the game evaluates via - * `calculate_tile_properties` has none.) - * 2. **`offset_x` is a single world-space x translation** `(x + offset_x)*scale`, - * applied identically to every octave - like `quick_multioctave_noise`. - * - * **There is NO per-octave x shift.** This file used to carry a fitted - * `k*(-7936)`; `::run`'s octave loop reloads the x/y offsets from the same two - * constant slots every iteration and has no counter-scaled term at all. See the - * comment on the removal below - the fitted value was an alias of zero. - * - * Verified against the game at **1.1e-5 worst over all 266 oracle samples**, and - * that residual is `basisNoise`'s own f32 floor amplified by this op's gain, not a - * modelling gap: `worst/gain` is 1.2e-7 to 2.4e-7 - one to two f32 ulps - in every - * one of the seven cases, including the two with `offset_x` of 5000 and 40000. - * NOT wired into the app - a building block for a client-side map preview. - */ - -import { basisNoise, basisNoiseTablesFromSeed, type BasisNoiseTables } from "./basisNoise"; -import { f32 } from "./eval/f32"; - -/* - * `OCTAVE_SHIFT` is GONE, and its absence is the fix. - * - * A plain block comment, not a `/**` one: it documents a constant that no - * longer exists, so binding it to whatever declaration happens to follow would - * be wrong. The module header above points here. - * - * It was `-7936`, fitted as "independent of seed, input_scale, offset_x and - * persistence to the noise floor". The fit was not measuring a shift - it was - * measuring nothing. The basis lattice has period 256 per axis, and - * `-7936 == -31 * 256`, so it names the same field as a shift of **zero**. The - * old comment's own "false minima near -4864 / -3840" are `-19*256` and `-15*256` - * - every candidate the scan surfaced was a multiple of 256, which is what a - * completely flat fit direction looks like from the inside. - * - * `VariablePersistenceMultioctaveNoise::run`'s octave loop settles it: it reloads - * the x and y offsets from the same two constant slots (`+0xa2c`, `+0xa30`) on - * every iteration and contains no counter-scaled term. There is no per-octave - * shift; octaves decorrelate through lacunarity alone. - * - * In f64 removing it changes nothing (measured: identical worst and identical - * f32-exact count). In f32 it is the difference between **3.6e-1 and 1.1e-5** - - * a shift of -7936*5 lands where an f32 ulp is ~3.9e-3. Same defect and same - * pairing as the plain op's `-1774.83`; see docs/noise/multioctave-noise-NOTES.md. - */ - -export interface VariablePersistenceMultioctaveParams { - /** Map seed (basis seed word). */ - readonly seed0: number; - /** Per-call seed selector (distinguishes the many multioctave calls a program makes). */ - readonly seed1: number; - /** Octave count (>= 1). */ - readonly octaves: number; - /** Base input scale (noise units per world tile); octave 0 uses `input_scale/2`. */ - readonly inputScale: number; - /** Overall output multiplier (the op additionally applies a `2^octaves` gain). */ - readonly outputScale: number; - /** World-space x translation applied to every octave (`(x + offsetX)` before scaling). */ - readonly offsetX: number; -} - -/** - * Evaluate `variable_persistence_multioctave_noise` at world coordinates `(x, y)` - * with a per-tile `persistence`. Pass a prebuilt `tables` to skip the seed - * derivation when sweeping many points at one seed (the common case for rendering); - * the seed is shared across octaves, so a single tables object serves them all. - * - * The octaves are combined in Horner order exactly as the game's `run` does (add - * the octave, then multiply the running accumulator by the tile's persistence - - * except after the last octave), so octave k carries weight `p^(N-1-k)`. - */ -export function variablePersistenceMultioctaveNoise( - x: number, - y: number, - persistence: number, - params: VariablePersistenceMultioctaveParams, - tables: BasisNoiseTables = basisNoiseTablesFromSeed(params.seed0, params.seed1), -): number { - const { octaves, inputScale, outputScale, offsetX } = params; - - let acc = 0; - // `y` is narrowed for the same reason `x` is: the noise machine holds its - // coordinate values at f32, so the scale multiply is an f32 operation on both - // operands. `x` has always been narrowed here through the `offset_x` add; `y` - // had no add to narrow it and so was silently multiplied in f64 (#191). - const yf = f32(y); - let scale = f32(f32(inputScale) * 0.5); // octave 0 = input_scale / 2 - for (let k = 0; k < octaves; k++) { - acc = f32(acc + basisNoise(f32(f32(x + offsetX) * scale), f32(yf * scale), tables)); - if (k < octaves - 1) acc = f32(acc * persistence); - scale = f32(scale * 0.5); - } - return f32(acc * f32(outputScale * 2 ** octaves)); -} - -/** - * Build a closure that evaluates `variable_persistence_multioctave_noise` for a - * fixed parameter set, with the per-octave scales (and the shared basis tables) - * derived once up front (the common case for rendering a grid at one seed). The - * returned function takes `(x, y, persistence)` - persistence still varies per tile. - */ -export function makeVariablePersistenceMultioctaveNoise( - params: VariablePersistenceMultioctaveParams, -): (x: number, y: number, persistence: number) => number { - const { seed0, seed1, octaves, inputScale, outputScale, offsetX } = params; - const tables = basisNoiseTablesFromSeed(seed0, seed1); - const gain = f32(outputScale * 2 ** octaves); - - const octaveScale: number[] = []; - let scale = f32(f32(inputScale) * 0.5); - for (let k = 0; k < octaves; k++) { - octaveScale.push(scale); - scale = f32(scale * 0.5); - } - - return (x: number, y: number, persistence: number): number => { - let acc = 0; - const yf = f32(y); - for (let k = 0; k < octaves; k++) { - const s = octaveScale[k]; - acc = f32(acc + basisNoise(f32(f32(x + offsetX) * s), f32(yf * s), tables)); - if (k < octaves - 1) acc = f32(acc * persistence); - } - return f32(acc * gain); - }; -} - -export interface AmplitudeCorrectedMultioctaveParams { - /** Map seed (basis seed word). */ - readonly seed0: number; - /** Per-call seed selector. */ - readonly seed1: number; - /** Octave count (>= 1). */ - readonly octaves: number; - /** Base input scale (noise units per world tile); octave 0 uses `input_scale/2`. */ - readonly inputScale: number; - /** World-space x translation applied to every octave. */ - readonly offsetX: number; - /** Constant persistence (amplitude ratio between successive octaves). */ - readonly persistence: number; - /** Target output amplitude (the corrected sum is scaled to roughly this). */ - readonly amplitude: number; -} - -/** - * `amplitude_corrected_multioctave_noise` - the Lua wrapper - * (`core/prototypes/noise-functions.lua`) over - * {@link variablePersistenceMultioctaveNoise}. It just chooses the op's - * `output_scale` so the `2^N`-gained geometric sum ends up at roughly `amplitude`: - * - * output_scale = (1 - p) / 2^N / (1 - p^N) * amplitude - * - * (the `1 - p^N` is the geometric-series sum of the octave weights; the `/2^N` - * cancels the op's gain). The wrapper's `persistence` is a constant here - and the - * game's degenerate constant-persistence path yields the same math as the variable - * op (verified against the oracle to the basis floor), so this is a direct call. - * - * At `p == 1` the `(1 - p)/(1 - p^N)` ratio is 0/0; its limit is `1/N`, so - * `output_scale = amplitude / (N * 2^N)`. - * - * The elevation tree uses this to build the *persistence field* it then feeds to - * `make_0_12like_lakes` (`persistence = clamp(amplitude_corrected... + 0.3, 0.1, 0.9)`). - */ -export function amplitudeCorrectedMultioctaveNoise( - x: number, - y: number, - params: AmplitudeCorrectedMultioctaveParams, - tables: BasisNoiseTables = basisNoiseTablesFromSeed(params.seed0, params.seed1), -): number { - const { octaves, persistence: p, amplitude } = params; - const ratio = p === 1 ? 1 / octaves : (1 - p) / (1 - p ** octaves); - const outputScale = (ratio / 2 ** octaves) * amplitude; - return variablePersistenceMultioctaveNoise( - x, - y, - p, - { - seed0: params.seed0, - seed1: params.seed1, - octaves, - inputScale: params.inputScale, - outputScale, - offsetX: params.offsetX, - }, - tables, - ); -} diff --git a/test/elevationRenderRequest.spec.ts b/test/elevationRenderRequest.spec.ts index 66db4cce..cbb9c476 100644 --- a/test/elevationRenderRequest.spec.ts +++ b/test/elevationRenderRequest.spec.ts @@ -1,9 +1,13 @@ -import { describe, it, expect } from "vite-plus/test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeAll, describe, it, expect } from "vite-plus/test"; import { cliffCellQueryBox, runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; +import { compileEngine, instantiateEngine, type EngineExports } from "../src/noise/wasm/engine"; import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; import { ENEMY_MAP_COLOR } from "../src/noise/enemies/enemyCatalog"; import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; @@ -23,9 +27,25 @@ const REQ: ElevationRenderRequest = { startingPositions: [{ x: 0, y: 0 }], }; +/** + * The engine every Nauvis and Vulcanus render below needs as of #227. + * + * These are behavioural rows rather than parity rows - they ask what the + * renderer draws, not whether two renderers agree - so they simply move onto + * the surviving path. Fulgora still renders in TypeScript (#363), and passing + * an engine to a Fulgora request is harmless: only the branches that need one + * ask for it. + */ +let engine: EngineExports; + +beforeAll(async () => { + const wasmPath = join(import.meta.dirname, "..", "src", "noise", "wasm", "engine.wasm"); + engine = await instantiateEngine(await compileEngine(readFileSync(wasmPath))); +}); + describe("runRenderRequest", () => { it("echoes id/width/height and returns an RGBA buffer of the right size", () => { - const r = runRenderRequest(REQ); + const r = runRenderRequest(REQ, engine); expect(r.id).toBe(7); expect(r.width).toBe(8); expect(r.height).toBe(6); @@ -47,9 +67,9 @@ describe("runRenderRequest", () => { startingPositions: [{ x: 0, y: 0 }], view: "terrain", }; - const terrain = new Uint8ClampedArray(runRenderRequest(terrainReq).buffer); + const terrain = new Uint8ClampedArray(runRenderRequest(terrainReq, engine).buffer); const withOre = new Uint8ClampedArray( - runRenderRequest({ ...terrainReq, view: "resources" }).buffer, + runRenderRequest({ ...terrainReq, view: "resources" }, engine).buffer, ); const catalogColors = new Set(RESOURCE_CATALOG.map((r) => r.mapColor.join(","))); @@ -86,9 +106,9 @@ describe("runRenderRequest", () => { startingPositions: [{ x: 0, y: 0 }], view: "terrain", }; - const terrain = new Uint8ClampedArray(runRenderRequest(terrainReq).buffer); + const terrain = new Uint8ClampedArray(runRenderRequest(terrainReq, engine).buffer); const withOre = new Uint8ClampedArray( - runRenderRequest({ ...terrainReq, view: "resources" }).buffer, + runRenderRequest({ ...terrainReq, view: "resources" }, engine).buffer, ); const water = new Set([ [38, 64, 73].join(","), // deepwater @@ -132,12 +152,15 @@ describe("runRenderRequest", () => { } return n; }; - const withIron = new Uint8ClampedArray(runRenderRequest(base).buffer); + const withIron = new Uint8ClampedArray(runRenderRequest(base, engine).buffer); const noIron = new Uint8ClampedArray( - runRenderRequest({ - ...base, - resourceControls: { "iron-ore": { frequency: 1, size: 0, richness: 1 } }, - }).buffer, + runRenderRequest( + { + ...base, + resourceControls: { "iron-ore": { frequency: 1, size: 0, richness: 1 } }, + }, + engine, + ).buffer, ); expect(countIron(withIron)).toBeGreaterThan(0); expect(countIron(noIron)).toBe(0); @@ -165,8 +188,10 @@ describe("runRenderRequest", () => { view: "enemies", enemyControls: { frequency: 1, size: 1 }, }; - const terrain = new Uint8ClampedArray(runRenderRequest({ ...req, view: "terrain" }).buffer); - const withEnemies = new Uint8ClampedArray(runRenderRequest(req).buffer); + const terrain = new Uint8ClampedArray( + runRenderRequest({ ...req, view: "terrain" }, engine).buffer, + ); + const withEnemies = new Uint8ClampedArray(runRenderRequest(req, engine).buffer); expect(Array.from(withEnemies)).not.toEqual(Array.from(terrain)); let changed = 0; for (let i = 0; i < terrain.length; i += 4) { @@ -203,8 +228,10 @@ describe("runRenderRequest", () => { cliffControls: { frequency: 1, continuity: 1 }, cliffSettings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, }; - const terrain = new Uint8ClampedArray(runRenderRequest({ ...req, view: "terrain" }).buffer); - const withCliffs = new Uint8ClampedArray(runRenderRequest(req).buffer); + const terrain = new Uint8ClampedArray( + runRenderRequest({ ...req, view: "terrain" }, engine).buffer, + ); + const withCliffs = new Uint8ClampedArray(runRenderRequest(req, engine).buffer); expect(Array.from(withCliffs)).not.toEqual(Array.from(terrain)); let painted = 0; @@ -235,13 +262,16 @@ describe("runRenderRequest", () => { startingPositions: [{ x: 0, y: 0 }], view: "cliffs", }; - const withDefaults = new Uint8ClampedArray(runRenderRequest(req).buffer); + const withDefaults = new Uint8ClampedArray(runRenderRequest(req, engine).buffer); const explicit = new Uint8ClampedArray( - runRenderRequest({ - ...req, - cliffControls: { frequency: 1, continuity: 1 }, - cliffSettings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, - }).buffer, + runRenderRequest( + { + ...req, + cliffControls: { frequency: 1, continuity: 1 }, + cliffSettings: { cliffElevation0: 10, cliffElevationInterval: 40, richness: 1 }, + }, + engine, + ).buffer, ); expect(Array.from(withDefaults)).toEqual(Array.from(explicit)); }); @@ -300,9 +330,9 @@ describe("runRenderRequest", () => { startingPositions: [{ x: 0, y: 0 }], view: "terrain", }; - const terrain = new Uint8ClampedArray(runRenderRequest(req).buffer); + const terrain = new Uint8ClampedArray(runRenderRequest(req, engine).buffer); const bufFor = (view: ElevationRenderRequest["view"]) => - new Uint8ClampedArray(runRenderRequest({ ...req, view }).buffer); + new Uint8ClampedArray(runRenderRequest({ ...req, view }, engine).buffer); const diffPixels = (buf: Uint8ClampedArray): Set => { const s = new Set(); for (let i = 0; i < terrain.length; i += 4) @@ -407,9 +437,9 @@ describe("runRenderRequest", () => { startingPositions: [{ x: 0, y: 0 }], view: "terrain", }; - const terrain = new Uint8ClampedArray(runRenderRequest(req).buffer); + const terrain = new Uint8ClampedArray(runRenderRequest(req, engine).buffer); const bufFor = (view: ElevationRenderRequest["view"]) => - new Uint8ClampedArray(runRenderRequest({ ...req, view }).buffer); + new Uint8ClampedArray(runRenderRequest({ ...req, view }, engine).buffer); const diffPixels = (buf: Uint8ClampedArray): Set => { const s = new Set(); for (let i = 0; i < terrain.length; i += 4) @@ -468,8 +498,8 @@ describe("view: trees", () => { }; it("renders terrain with the tree overlay composited on top", () => { - const terrain = runRenderRequest({ ...base, view: "terrain" }); - const trees = runRenderRequest({ ...base, view: "trees" }); + const terrain = runRenderRequest({ ...base, view: "terrain" }, engine); + const trees = runRenderRequest({ ...base, view: "trees" }, engine); expect(Array.from(new Uint8ClampedArray(trees.buffer))).not.toEqual( Array.from(new Uint8ClampedArray(terrain.buffer)), ); @@ -481,9 +511,9 @@ describe("view: trees", () => { // silently wrong forest layout. A bias this large pushes temperature out of // every species' ramp, so the overlay must vanish back to bare terrain. it("forwards temperatureBias through to the tree overlay", () => { - const terrain = runRenderRequest({ ...base, view: "terrain" }); - const trees = runRenderRequest({ ...base, view: "trees" }); - const frozen = runRenderRequest({ ...base, view: "trees", temperatureBias: -1000 }); + const terrain = runRenderRequest({ ...base, view: "terrain" }, engine); + const trees = runRenderRequest({ ...base, view: "trees" }, engine); + const frozen = runRenderRequest({ ...base, view: "trees", temperatureBias: -1000 }, engine); // The overlay is doing something to begin with... expect(Array.from(new Uint8ClampedArray(trees.buffer))).not.toEqual( Array.from(new Uint8ClampedArray(terrain.buffer)), @@ -495,40 +525,46 @@ describe("view: trees", () => { }); it("forwards temperatureFrequency through to the tree overlay", () => { - const trees = runRenderRequest({ ...base, view: "trees" }); - const warped = runRenderRequest({ ...base, view: "trees", temperatureFrequency: 4 }); + const trees = runRenderRequest({ ...base, view: "trees" }, engine); + const warped = runRenderRequest({ ...base, view: "trees", temperatureFrequency: 4 }, engine); expect(Array.from(new Uint8ClampedArray(warped.buffer))).not.toEqual( Array.from(new Uint8ClampedArray(trees.buffer)), ); }); it("includes trees in the all-composite", () => { - const withoutTrees = runRenderRequest({ ...base, view: "cliffs" }); - const all = runRenderRequest({ ...base, view: "all" }); + const withoutTrees = runRenderRequest({ ...base, view: "cliffs" }, engine); + const all = runRenderRequest({ ...base, view: "all" }, engine); expect(Array.from(new Uint8ClampedArray(all.buffer))).not.toEqual( Array.from(new Uint8ClampedArray(withoutTrees.buffer)), ); }); it("honors treeControls", () => { - const a = runRenderRequest({ ...base, view: "trees" }); - const b = runRenderRequest({ - ...base, - view: "trees", - treeControls: { frequency: 3, size: 2 }, - }); + const a = runRenderRequest({ ...base, view: "trees" }, engine); + const b = runRenderRequest( + { + ...base, + view: "trees", + treeControls: { frequency: 3, size: 2 }, + }, + engine, + ); expect(Array.from(new Uint8ClampedArray(a.buffer))).not.toEqual( Array.from(new Uint8ClampedArray(b.buffer)), ); }); it("defaults treeControls to 1/1 when omitted", () => { - const implicit = runRenderRequest({ ...base, view: "trees" }); - const explicit = runRenderRequest({ - ...base, - view: "trees", - treeControls: { frequency: 1, size: 1 }, - }); + const implicit = runRenderRequest({ ...base, view: "trees" }, engine); + const explicit = runRenderRequest( + { + ...base, + view: "trees", + treeControls: { frequency: 1, size: 1 }, + }, + engine, + ); expect(Array.from(new Uint8ClampedArray(implicit.buffer))).toEqual( Array.from(new Uint8ClampedArray(explicit.buffer)), ); @@ -604,17 +640,20 @@ describe("fulgora scrap is gated on the view", () => { * nothing behind it. These rows are what give that string its meaning. */ function fulgoraImage(view: string): Uint8ClampedArray { - const r = runRenderRequest({ - id: 1, - seed0: 123456, - planet: "fulgora", - view, - width: 256, - height: 256, - originX: -128, - originY: -128, - tilesPerPixel: 1, - } as unknown as ElevationRenderRequest); + const r = runRenderRequest( + { + id: 1, + seed0: 123456, + planet: "fulgora", + view, + width: 256, + height: 256, + originX: -128, + originY: -128, + tilesPerPixel: 1, + } as unknown as ElevationRenderRequest, + engine, + ); return new Uint8ClampedArray(r.buffer); } @@ -649,49 +688,24 @@ describe("fulgora scrap is gated on the view", () => { expect(scrapPixels("terrain")).toBe(0); }, 120000); - it("KNOWN HOLE: view 'elevation' renders the NAUVIS field, not Fulgora at all", () => { - // Not a scrap bug - a wrong-planet bug, and the reason the panel fix that - // accompanies this test matters more than "the overlay was missing". - // - // `runRenderRequest` puts its whole planet dispatch inside a view test that - // lists terrain/resources/enemies/cliffs/trees/rocks/all. "elevation" is - // not in that list, so it never reaches the Fulgora branch and falls - // through to `renderElevation`, which evaluates the Nauvis elevation - // field. The output is BYTE-IDENTICAL to `planet: "nauvis"` at the same - // seed0 - measured, which is what the second assertion below pins. - // - // This is exactly the failure `ElevationPreviewPanel`'s `supported` comment - // says its guard exists to prevent ("would silently fall through - // runRenderRequest's dispatch ... and render mislabeled Nauvis colors"). - // That guard covers the PLANET axis; the map-type axis re-opened the same - // hole, because `effectiveView` forced "elevation" whenever the preset's - // own map type was Lakes or Island. - // - // The panel no longer asks for it (see elevationPreviewPanel.spec.ts), so - // this is unreachable from the UI. It is pinned rather than fixed because + it("refuses view 'elevation' on Fulgora rather than drawing the Nauvis field", () => { + // **The hole this row used to pin is closed, and this is the flip its own + // comment asked for.** It read: "It is pinned rather than fixed because // closing it properly means making `runRenderRequest` itself refuse a // Nauvis-only view for a non-Nauvis planet, which changes Vulcanus too - - // a wider change than this fix. Vulcanus has the identical hole and is - // likewise unreachable. If that lands, this test should flip to asserting - // the refusal. - const fulgoraElevation = fulgoraImage("elevation"); - const nauvisElevation = new Uint8ClampedArray( - runRenderRequest({ - id: 1, - seed0: 123456, - planet: "nauvis", - view: "elevation", - width: 256, - height: 256, - originX: -128, - originY: -128, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - } as unknown as ElevationRenderRequest).buffer, - ); - expect(scrapPixels("elevation")).toBe(0); - expect(Array.from(fulgoraElevation)).toEqual(Array.from(nauvisElevation)); + // a wider change than this fix. If that lands, this test should flip to + // asserting the refusal." + // + // #227 is that wider change. The dispatch used to put the whole planet + // test inside a view test listing terrain/resources/enemies/cliffs/trees/ + // rocks/all; "elevation" was not in that list, so a Fulgora request never + // reached the Fulgora branch and fell through to `renderElevation`, which + // evaluates the NAUVIS elevation field. The output was byte-identical to + // `planet: "nauvis"` at the same seed - a wrong-planet bug, not a missing + // overlay. + // + // Vulcanus had the identical hole and it closes the same way: the refusal + // is on the pair, not on Fulgora. + expect(() => fulgoraImage("elevation")).toThrow(/no renderer for planet fulgora/); }, 120000); }); diff --git a/test/fulgoraSurfaceSeed.spec.ts b/test/fulgoraSurfaceSeed.spec.ts index 0b2b1b95..0dc1c592 100644 --- a/test/fulgoraSurfaceSeed.spec.ts +++ b/test/fulgoraSurfaceSeed.spec.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { describe, expect, it } from "vite-plus/test"; import { surfaceSeedForPlanet } from "../src/model/planetSurfaceSeed"; @@ -6,6 +9,7 @@ import { runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; +import { compileEngine, instantiateEngine } from "../src/noise/wasm/engine"; /** * The one Fulgora defect no oracle fixture can catch. @@ -99,8 +103,13 @@ describe("fulgora render request dispatch", () => { expect(Array.from(got)).toEqual(Array.from(direct.data)); }); - it("planet 'fulgora' differs from the Nauvis terrain render at the same point", () => { - const nauvis = new Uint8ClampedArray(runRenderRequest({ ...BASE, planet: "nauvis" }).buffer); + it("planet 'fulgora' differs from the Nauvis terrain render at the same point", async () => { + // Nauvis needs the engine as of #227 and Fulgora does not, which is #363 + // rather than an asymmetry this test cares about. The claim is only that + // the two planets draw different pictures at the same point. + const wasmPath = join(import.meta.dirname, "..", "src", "noise", "wasm", "engine.wasm"); + const e = await instantiateEngine(await compileEngine(readFileSync(wasmPath))); + const nauvis = new Uint8ClampedArray(runRenderRequest({ ...BASE, planet: "nauvis" }, e).buffer); const fulgora = new Uint8ClampedArray(runRenderRequest({ ...BASE, planet: "fulgora" }).buffer); expect(Array.from(fulgora)).not.toEqual(Array.from(nauvis)); }); diff --git a/test/tiledEquality.spec.ts b/test/tiledEquality.spec.ts index 6c453abd..c0a9302e 100644 --- a/test/tiledEquality.spec.ts +++ b/test/tiledEquality.spec.ts @@ -1,9 +1,13 @@ -import { describe, it, expect } from "vite-plus/test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeAll, describe, it, expect } from "vite-plus/test"; import { runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; import { planTiles, stitchTiles, type ImageBox } from "../src/noise/preview/tiling"; +import { compileEngine, instantiateEngine, type EngineExports } from "../src/noise/wasm/engine"; // The definitive correctness gate for region tiling: rendering an area as tiles // must reproduce the single whole-image render byte for byte. The renderer is @@ -19,6 +23,23 @@ const SEAM_SEED = 123456; const SEAM_ORIGIN_X = 320; const SEAM_ORIGIN_Y = 64; +/** + * The engine every render below goes through. + * + * **This spec used to pass none at all**, which meant it graded the TypeScript + * renderers and could not see the WASM gate: a tiling bug that existed only on + * the engine path was invisible here. #227 deleted those renderers, so the + * engine is now the only thing to render with - and the gate this spec could + * never reach is the one it now tests. One instance for the file: renders are + * sequential and synchronous, which is exactly how the worker drives it. + */ +let engine: EngineExports; + +beforeAll(async () => { + const wasmPath = join(import.meta.dirname, "..", "src", "noise", "wasm", "engine.wasm"); + engine = await instantiateEngine(await compileEngine(readFileSync(wasmPath))); +}); + function baseReq(over: Partial = {}): ElevationRenderRequest { return { id: 0, @@ -52,14 +73,17 @@ function renderTiled(req: ElevationRenderRequest, tileSize: number): Uint8Clampe height: full.height, }; const tiles = planTiles(full, tileSize).map((t) => { - const out = runRenderRequest({ - ...req, - originX: t.originX, - originY: t.originY, - width: t.width, - height: t.height, - fullImage, - }); + const out = runRenderRequest( + { + ...req, + originX: t.originX, + originY: t.originY, + width: t.width, + height: t.height, + fullImage, + }, + engine, + ); return { dx: t.dx, dy: t.dy, @@ -72,7 +96,7 @@ function renderTiled(req: ElevationRenderRequest, tileSize: number): Uint8Clampe } function renderWhole(req: ElevationRenderRequest): Uint8ClampedArray { - return new Uint8ClampedArray(runRenderRequest(req).buffer); + return new Uint8ClampedArray(runRenderRequest(req, engine).buffer); } describe("tiled render equals untiled render", () => { diff --git a/test/viewNormalisation.spec.ts b/test/viewNormalisation.spec.ts index 6e2e09e9..3b15070f 100644 --- a/test/viewNormalisation.spec.ts +++ b/test/viewNormalisation.spec.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + ENGINE_REQUIRED, runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; @@ -132,23 +133,22 @@ describe("the four views with no renderer of their own render that planet's terr }, 120000); /** - * **The arm #227 deletes.** + * The block that used to sit here rendered each pair with no engine at all + * and asserted the two arms agreed - the render these pairs drew before + * `servedView` existed, and the one #362 had to preserve. #227 deleted + * `renderTerrain` and `renderVulcanusTerrain`, so there is no second arm to + * ask, and it went as its own comment said it would. The engine-side + * assertions above were deliberately kept separate rather than folded into + * it, so nothing else moves with it. * - * While the TypeScript terrain renderers still exist, each of the four can be - * rendered with no engine at all, and that render is the one this change had - * to preserve - it is what these pairs drew before `servedView` existed. Once - * `renderTerrain` and `renderVulcanusTerrain` are deleted there is no second - * arm and this block goes with them, which is why the engine-side assertions - * are kept above rather than folded in here. + * `runRenderRequest` now refuses all four without an engine, which is the + * remaining observable and the one this asserts. */ - it("renders identically with and without the engine, for all four", async () => { - const e = await engine(); + it("refuses all four without an engine, now that there is no fallback", () => { for (const c of FALL_THROUGH) { - const withEngine = pixels(c.planet, c.view, e); - const withoutEngine = pixels(c.planet, c.view); - expect(Array.from(withEngine), `${c.label}: engine vs none`).toEqual( - Array.from(withoutEngine), + expect(() => pixels(c.planet, c.view), `${c.label}: must need the engine`).toThrow( + ENGINE_REQUIRED, ); } - }, 120000); + }); }); diff --git a/test/vulcanusStackCache.spec.ts b/test/vulcanusStackCache.spec.ts deleted file mode 100644 index 5c422fe0..00000000 --- a/test/vulcanusStackCache.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { runRenderRequest } from "../src/noise/preview/elevationRenderRequest"; - -/** - * The Vulcanus composite renders through ONE shared, cached field stack - * (`makeVulcanusStack(..., { cacheShared: true })`) instead of each overlay - * building its own. That must be a pure performance change: the shared path has - * to produce the SAME BYTES as per-renderer stacks, which is what - * `unsharedStacks: true` renders for comparison. - * - * Two things could break it, and both are silent: - * - * - **A stale or aliased cache value.** `memoRegion` keys on the integer tile, - * and bypasses rather than aliases for non-integer or out-of-range - * coordinates - the cliff lattice samples at y + 0.5, and rounding those onto - * integer keys would return a different point's value. - * - **A lost `this` binding.** The cached fields are wrapped in arrows, not - * passed as method references, so they keep their receiver whatever the - * underlying implementation does. - */ -/** - * 256x256, not 128x128, and these origins specifically. At 128x128 the Vulcanus - * resource overlay paints NOTHING in five of seven sampled windows - including - * (0,0) - so a byte-equality test there compares two empty overlays and passes - * without exercising the fused path at all. Ore pixels repainted per window, - * measured 2026-07-28: (0,0) 1026, (1500,1500) 4485, (-256,-256) 3412. - */ -const SIZE = 256; -const REGIONS = [ - { originX: 0, originY: 0, label: "origin (0,0) - near spawn", minOre: 500 }, - { originX: 1500, originY: 1500, label: "origin (1500,1500) - far field", minOre: 2000 }, - { originX: -256, originY: -256, label: "origin (-256,-256) - geyser-bearing", minOre: 1500 }, -]; - -describe("Vulcanus shared cached stack is byte-identical to per-renderer stacks", () => { - for (const view of ["resources", "rocks", "all"] as const) { - for (const r of REGIONS) { - it(`view=${view} @ ${r.label}`, () => { - const base = { - id: 0, - seed0: 123456, - width: SIZE, - height: SIZE, - originX: r.originX, - originY: r.originY, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - mapType: "nauvis" as const, - planet: "vulcanus" as const, - view, - }; - const unshared = new Uint8Array(runRenderRequest({ ...base, unsharedStacks: true }).buffer); - const shared = new Uint8Array(runRenderRequest({ ...base }).buffer); - - expect(shared.length).toBe(unshared.length); - let firstDiff = -1; - let diffs = 0; - for (let i = 0; i < unshared.length; i++) - if (unshared[i] !== shared[i]) { - diffs++; - if (firstDiff < 0) firstDiff = i; - } - if (diffs > 0) { - const px = Math.floor(firstDiff / 4); - throw new Error( - `${String(diffs)} differing bytes; first at pixel (${String(px % SIZE)},${String(Math.floor(px / SIZE))})`, - ); - } - expect(diffs).toBe(0); - }, 120000); - } - } - - for (const r of REGIONS) { - it(`is not vacuous @ ${r.label} - the overlay really paints there`, () => { - // Without this, byte-equality passes just as happily over a window where - // the resource overlay painted nothing - which is exactly what a 128x128 - // window at (0,0) does. The fused path only changes the ore pass, so the - // ore pass has to be non-empty for the comparison to mean anything. - const base = { - id: 0, - seed0: 123456, - width: SIZE, - height: SIZE, - originX: r.originX, - originY: r.originY, - tilesPerPixel: 1, - waterLevel: 0, - segmentationMultiplier: 1, - startingPositions: [{ x: 0, y: 0 }], - mapType: "nauvis" as const, - planet: "vulcanus" as const, - }; - const terrain = new Uint8Array(runRenderRequest({ ...base, view: "terrain" }).buffer); - const resources = new Uint8Array(runRenderRequest({ ...base, view: "resources" }).buffer); - let changed = 0; - for (let i = 0; i < terrain.length; i += 4) - if ( - terrain[i] !== resources[i] || - terrain[i + 1] !== resources[i + 1] || - terrain[i + 2] !== resources[i + 2] - ) - changed++; - expect(changed).toBeGreaterThan(r.minOre); - }, 120000); - } -}); diff --git a/test/wasmElevationRenderParity.spec.ts b/test/wasmElevationRenderParity.spec.ts index 0307bf1c..038e9910 100644 --- a/test/wasmElevationRenderParity.spec.ts +++ b/test/wasmElevationRenderParity.spec.ts @@ -103,10 +103,11 @@ const MAP_TYPES = ["lakes", "nauvis", "island"] as const; /** * The tier-3 freeze section for this spec. See `tier3Frozen.ts`. * - * The last of the three render specs to be frozen. Its TypeScript arm is - * `pixels(req)` with the engine left off; #227 deletes what that reaches, after - * which it would be the same code as the wasm arm and every comparison here - * would pass while grading nothing. + * The last of the three render specs to be frozen. Its TypeScript arm was + * `pixels(req)` with the engine left off; #227 deleted what that reached, so + * the call now refuses rather than returning a second opinion. Had the freeze + * not landed first, every comparison here would have passed while grading + * nothing. */ const SECTION = "elevation:render"; @@ -132,8 +133,17 @@ afterAll(flushRecording); * three times; keyed on the window alone, the three trees would overwrite each * other and the section would hold 4 rows where 12 ran. */ -function freeze(label: string, name: string, wasm: ArrayLike, ts: ArrayLike): void { - expectFrozen(SECTION, label, name, foldPixels(wasm), foldPixels(ts)); +/** + * `ts` is omitted where #227 deleted the TypeScript renderer this block used to + * compare against - see `tier3Frozen.ts`. + */ +function freeze( + label: string, + name: string, + wasm: ArrayLike, + ts?: ArrayLike, +): void { + expectFrozen(SECTION, label, name, foldPixels(wasm), ts && foldPixels(ts)); } function request( @@ -181,17 +191,15 @@ const WATER_KEY = `${WATER_RGBA[0]},${WATER_RGBA[1]},${WATER_RGBA[2]}`; const LAND_KEY = `${LAND_RGBA[0]},${LAND_RGBA[1]},${LAND_RGBA[2]}`; describe.each(MAP_TYPES)( - "the WASM engine renders the %s elevation view exactly as the TypeScript does", + "the WASM engine renders the %s elevation view to its frozen bytes", (mapType) => { - it("is byte-identical across four windows", async () => { + it("matches its frozen bytes across four windows", async () => { const e = await engine(); for (const w of WINDOWS) { const req = request(w, mapType); const wasm = pixels(req, e); - const ts = pixels(req); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(`${mapType} ${w.label}`, "elevation", wasm, ts); + freeze(`${mapType} ${w.label}`, "elevation", wasm); } }, 300000); @@ -213,15 +221,15 @@ describe.each(MAP_TYPES)( describe("the module SERVES the elevation views rather than the gate falling back", () => { /** - * **Every byte-identical assertion above is vacuous without this one.** A - * `runRenderRequest(req, engine)` that quietly declined the engine and ran the - * TypeScript satisfies `wasm === ts` perfectly, and that is precisely the - * failure this port could have: a `view` code the module does not name comes - * back `unsupported planet or view`, and the gate falls through. + * **Every frozen assertion above is vacuous without this one.** Before #227 + * a `runRenderRequest(req, engine)` that quietly declined the engine and ran + * the TypeScript satisfied `wasm === ts` perfectly, and that was precisely + * the failure this port could have: a `view` code the module does not name + * comes back `unsupported planet or view`, and the gate falls through. * * So this reaches `renderThroughWasm` DIRECTLY, with no fallback in front of - * it, and requires the module's own pixels to match the TypeScript's. If the - * module refused the code, this throws rather than passing quietly. + * it, and requires the module's own pixels to match the gated render's. If + * the module refused the code, this throws rather than passing quietly. * * Read `#227`'s lesson literally: gate-reading missed two unported render * paths in both directions. Plant the call, do not read the match arm. @@ -274,29 +282,34 @@ describe("the module SERVES the elevation views rather than the gate falling bac startingPositions: [{ x: 0, y: 0 }], } as never), ); - const ts = pixels(request(w, mapType)); expect(direct.length, "module returned no pixels").toBe(w.width * w.height * 4); - expect(Array.from(direct), `${mapType}: module vs TypeScript`).toEqual(Array.from(ts)); + // The gated render has to agree with the direct one. That is what says + // the gate reaches this view code rather than some other path: before + // #227 the same claim was made by comparing the direct render against the + // TypeScript, and the TypeScript is what has gone. + expect(Array.from(direct), `${mapType}: module vs gated`).toEqual( + Array.from(pixels(request(w, mapType), e)), + ); // Frozen on the DIRECT module render rather than the gated one, which is // what makes this row worth keeping after the deletion: it pins the // module's own answer for the view code, with no fallback in front of it. - freeze(`serves ${mapType}`, "elevation", direct, ts); + freeze(`serves ${mapType}`, "elevation", direct); }, 300000, ); }); -describe("the elevation levers move both paths together", () => { - // The Nauvis param block is read RAW by the module and defaulted on the - // TypeScript side, so a wrong value in the caller would be a silent - // divergence rather than an error. Moving a lever and requiring both paths to - // move together is what catches one. +describe("the elevation levers move the render", () => { + // The Nauvis param block is read RAW by the module, so a wrong value in the + // caller would be a silent divergence rather than an error. Moving a lever + // and requiring the render to move with it is what catches one; before #227 + // the same blocks also required the TypeScript path to move identically. // `spawn 128 @8`. The lever arms need a window WIDE enough for the lever to // bite: at 1 tile/px the same levers moved nothing measurable, which is what // failed the first draft of this file. The window was wrong, not the lever. const w = WINDOWS[1]; - it("waterLevel moves the render, and identically on both paths", async () => { + it("waterLevel moves the render", async () => { const e = await engine(); // The terrain view deliberately IGNORES waterLevel (#326). The elevation // view does not - `renderElevation` passes it into the tree - so this also @@ -313,15 +326,13 @@ describe("the elevation levers move both paths together", () => { const base = request(w, "lakes"); const moved = request(w, "lakes", { waterLevel: 20 }); const movedWasm = pixels(moved, e); - const movedTs = pixels(moved); - expect(Array.from(movedWasm), "wasm vs ts at waterLevel 20").toEqual(Array.from(movedTs)); expect(Array.from(movedWasm), "waterLevel moved nothing").not.toEqual( Array.from(pixels(base, e)), ); - freeze("waterLevel 20 on lakes", "elevation", movedWasm, movedTs); + freeze("waterLevel 20 on lakes", "elevation", movedWasm); }, 300000); - it("segmentationMultiplier moves the render, and identically on both paths", async () => { + it("segmentationMultiplier moves the render", async () => { const e = await engine(); // Measured on `island` at this window: 1.7% water at the default and 51.7% // at `segmentationMultiplier: 2`. Island is the sharpest of the three here, @@ -329,18 +340,16 @@ describe("the elevation levers move both paths together", () => { const base = request(w, "island"); const moved = request(w, "island", { segmentationMultiplier: 2 }); const movedWasm = pixels(moved, e); - const movedTs = pixels(moved); // Island quarters segmentation inside `to_island`. A path that applied the - // divide twice, or skipped it, still renders - so the two-path comparison - // under a MOVED lever is what grades it, not the default render. - expect(Array.from(movedWasm), "wasm vs ts at segmentation 2").toEqual(Array.from(movedTs)); + // divide twice, or skipped it, still renders - so the frozen fold under a + // MOVED lever is what grades it, not the default render. expect(Array.from(movedWasm), "segmentation moved nothing").not.toEqual( Array.from(pixels(base, e)), ); - freeze("segmentationMultiplier 2 on island", "elevation", movedWasm, movedTs); + freeze("segmentationMultiplier 2 on island", "elevation", movedWasm); }, 300000); - it("a moved spawn renders THROUGH the engine, byte-identical to the TypeScript", async () => { + it("a moved spawn renders THROUGH the engine, to its own frozen bytes", async () => { const e = await engine(); // The spawn reaches the starting lakes, so a module that fixed it at the // origin would differ near the moved point rather than everywhere. @@ -348,12 +357,10 @@ describe("the elevation levers move both paths together", () => { const moved = request(w2, "lakes", { startingPositions: [{ x: 500, y: -500 }] }); const origin = request(w2, "lakes"); const movedWasm = pixels(moved, e); - const movedTs = pixels(moved); - expect(Array.from(movedWasm), "wasm vs ts at a moved spawn").toEqual(Array.from(movedTs)); expect(Array.from(movedWasm), "the spawn moved nothing").not.toEqual( Array.from(pixels(origin, e)), ); - freeze("moved spawn on lakes", "elevation", movedWasm, movedTs); + freeze("moved spawn on lakes", "elevation", movedWasm); }, 300000); }); diff --git a/test/wasmEvalParity.spec.ts b/test/wasmEvalParity.spec.ts index 6a0df5ce..f0142e60 100644 --- a/test/wasmEvalParity.spec.ts +++ b/test/wasmEvalParity.spec.ts @@ -27,7 +27,6 @@ import { multisample } from "../src/noise/eval/multisample"; import { basisNoiseExpr } from "../src/noise/eval/primitives"; import { seedNormalized, seedSmall } from "../src/noise/expressions/vulcanusSeed"; import { fastCbrt } from "../src/noise/fastApprox"; -import { noiseMachinePow } from "../src/noise/quickMultioctaveNoise"; /** * Tier 2 of the Rust port's gate for the `eval` layer (#221): strict bit @@ -107,7 +106,13 @@ function foldAll(values: readonly number[]): bigint { const f32 = Math.fround; -describe("Rust and TypeScript agree bit for bit on the noise machine's `^`", () => { +/** + * `noiseMachinePow` went with `quickMultioctaveNoise.ts` in #227, so the five + * exponent rows are graded against the frozen table alone - `tier2Frozen.ts` + * explains why that is still worth running inside `wasm32-unknown-unknown`. + * `fastCbrt` survives in `fastApprox.ts`, so its row keeps both arms. + */ +describe("The noise machine's `^` folds to its frozen checksums", () => { // Bases stay positive - `fastLog2` of a non-positive base is not a value // either port promises anything about. The step is not a binary fraction, so // the sweep does not sit on values where every candidate agrees. @@ -117,7 +122,7 @@ describe("Rust and TypeScript agree bit for bit on the noise machine's `^`", () const bases = (): number[] => Array.from({ length: N }, (_, i) => f32(X0 + i * STEP)); - it("folds 400 bases identically for each of the three branches, plus fastCbrt", async () => { + it("folds 400 bases to the frozen checksum for each of the three branches, plus fastCbrt", async () => { const engine = await instantiate(); const xs = bases(); @@ -130,7 +135,6 @@ describe("Rust and TypeScript agree bit for bit on the noise machine's `^`", () `pow exponent=${exponent}`, "checksum_pow", u64(engine.checksum_pow(exponent, 0, X0, STEP, N)), - foldAll(xs.map((x) => noiseMachinePow(x, exponent))), ); } @@ -145,14 +149,26 @@ describe("Rust and TypeScript agree bit for bit on the noise machine's `^`", () ); }); - it("would not agree if a branch were chosen differently", () => { + it("would not agree if a branch were chosen differently", async () => { // Anti-vacuity for the block above: the three branches really do return // different numbers, so folding them cannot coincide. Without this, an // exponent where all three agreed would make the test above vacuous. - const xs = bases(); - const squaring = foldAll(xs.map((x) => noiseMachinePow(x, 2))); - const viaFastapprox = foldAll(xs.map((x) => noiseMachinePow(x, 2.0000001))); - const viaSqrt = foldAll(xs.map((x) => noiseMachinePow(x, 0.5))); + // + // Asked of the engine since #227 deleted the TypeScript arm. It is the same + // claim, made about the side that is still running. + // + // The exponent that leaves the squaring branch is ONE f32 ULP past 2, not + // the 2.0000001 the TypeScript arm was handed. `checksum_pow` takes its + // exponent as an `f32` (`crates/fmw-wasm/src/lib.rs`), so 2.0000001 + // narrows to exactly 2 at the boundary and picks squaring after all. + // Measured: written with 2.0000001 this test failed, both folds equal. + const justPastTwo = 2 + 2 ** -22; + expect(Math.fround(justPastTwo), "the perturbation must survive the f32 boundary").not.toBe(2); + + const engine = await instantiate(); + const squaring = u64(engine.checksum_pow(2, 0, X0, STEP, N)); + const viaFastapprox = u64(engine.checksum_pow(justPastTwo, 0, X0, STEP, N)); + const viaSqrt = u64(engine.checksum_pow(0.5, 0, X0, STEP, N)); expect(squaring).not.toBe(viaFastapprox); expect(squaring).not.toBe(viaSqrt); }); diff --git a/test/wasmMultioctaveParity.spec.ts b/test/wasmMultioctaveParity.spec.ts index 62cec0ef..08741111 100644 --- a/test/wasmMultioctaveParity.spec.ts +++ b/test/wasmMultioctaveParity.spec.ts @@ -23,8 +23,6 @@ afterAll(flushRecording); expectRecordedRows(PLANET, 9); import { makeMultioctaveNoise } from "../src/noise/multioctaveNoise"; -import { makeQuickMultioctaveNoise } from "../src/noise/quickMultioctaveNoise"; -import { makeVariablePersistenceMultioctaveNoise } from "../src/noise/variablePersistenceMultioctaveNoise"; /** * Tier 2 of the Rust port's gate for the multioctave family: strict bit @@ -233,7 +231,13 @@ describe("Rust and TypeScript multioctave_noise agree bit for bit", () => { }); }); -describe("Rust and TypeScript variable_persistence_multioctave_noise agree bit for bit", () => { +/** + * The TypeScript arm went with `variablePersistenceMultioctaveNoise.ts` in + * #227, so these rows are graded against the frozen table alone. See + * `tier2Frozen.ts` for why that is still the only thing running this op's + * arithmetic inside `wasm32-unknown-unknown`. + */ +describe("variable_persistence_multioctave_noise folds to its frozen checksums", () => { // Persistence is a single value per call here rather than per point. The real // op takes a spatially varying one, but computing a per-point persistence // would put arithmetic that is NOT the op under test on both sides of the @@ -262,7 +266,7 @@ describe("Rust and TypeScript variable_persistence_multioctave_noise agree bit f { seed0: 654321, seed1: 7, octaves: 3, inputScale: 0.2, outputScale: 2, offsetX: 5000, p: 0.9 }, ] as const; - it("folds 4,096 grid points to the identical checksum, over several cases", async () => { + it("folds 4,096 grid points to the frozen checksum, over several cases", async () => { const engine = await instantiate(); for (const c of CASES) { const fromWasm = u64( @@ -280,27 +284,18 @@ describe("Rust and TypeScript variable_persistence_multioctave_noise agree bit f N, ), ); - const fn = makeVariablePersistenceMultioctaveNoise({ - seed0: c.seed0, - seed1: c.seed1, - octaves: c.octaves, - inputScale: c.inputScale, - outputScale: c.outputScale, - offsetX: c.offsetX, - }); - // Handed to both sides UN-narrowed. This used to be `Math.fround(c.p)`, - // with a comment explaining that the WASM boundary took `persistence` as - // an f32 - which was true, and was the bug: the accumulator multiply is - // `f32(acc * persistence)` against an f64 persistence, so narrowing here - // made the two sides agree by construction on the one term that actually - // differed. Two of the cases above (0.62, 0.9) are not f32-exact, so this - // comparison now grades the operand width. See #226 and #254. + // `p` reaches the engine UN-narrowed, and the cases keep it that way. + // The comparison this replaced used to narrow it with `Math.fround`, + // which made the two sides agree by construction on the one term that + // actually differed - the accumulator multiply is `f32(acc * persistence)` + // against an f64 persistence. Two of the cases above (0.62, 0.9) are not + // f32-exact, so the operand width is still inside what the frozen value + // pins. See #226 and #254. expectFrozen( PLANET, `varpersist octaves=${c.octaves} offset=${c.offsetX} p=${c.p}`, "checksum_variable_persistence", fromWasm, - foldGrid((x, y) => fn(x, y, c.p), X0, Y0, STEP, N), ); } }); @@ -330,7 +325,11 @@ describe("Rust and TypeScript variable_persistence_multioctave_noise agree bit f }); }); -describe("Rust and TypeScript quick_multioctave_noise agree bit for bit", () => { +/** + * The TypeScript arm went with `quickMultioctaveNoise.ts` in #227, so these + * rows are graded against the frozen table alone. See `tier2Frozen.ts`. + */ +describe("quick_multioctave_noise folds to its frozen checksums", () => { // The climate trees' own shapes, plus the fixture's 6-octave case. The // multipliers deliberately include values with no exact f32 form (0.6, 0.65, // 0.55), because narrowing the parameters is the single biggest term of this @@ -368,7 +367,7 @@ describe("Rust and TypeScript quick_multioctave_noise agree bit for bit", () => }, ] as const; - it("folds 4,096 grid points to the identical checksum, over several cases", async () => { + it("folds 4,096 grid points to the frozen checksum, over several cases", async () => { const engine = await instantiate(); for (const c of CASES) { const fromWasm = u64( @@ -387,22 +386,11 @@ describe("Rust and TypeScript quick_multioctave_noise agree bit for bit", () => N, ), ); - const fn = makeQuickMultioctaveNoise({ - seed0: c.seed0, - seed1: c.seed1, - octaves: c.octaves, - inputScale: c.inputScale, - outputScale: c.outputScale, - octaveOutputScaleMultiplier: c.oosm, - octaveInputScaleMultiplier: c.oism, - offsetX: c.offsetX, - }); expectFrozen( PLANET, `quick octaves=${c.octaves} oism=${c.oism} seed1=${c.seed1}`, "checksum_quick_multioctave", fromWasm, - foldGrid(fn, X0, Y0, STEP, N), ); } }); diff --git a/test/wasmNauvisParity.spec.ts b/test/wasmNauvisParity.spec.ts index 8890999d..8788e225 100644 --- a/test/wasmNauvisParity.spec.ts +++ b/test/wasmNauvisParity.spec.ts @@ -13,30 +13,6 @@ import { import { encodeRenderRequest, type WasmRenderRequest } from "../src/noise/wasm/request"; -import { basisNoise, basisNoiseTablesFromSeed } from "../src/noise/basisNoise"; -import { makeAux } from "../src/noise/expressions/aux"; -import { makeElevationIsland } from "../src/noise/expressions/elevationIsland"; -import { makeElevationLakes } from "../src/noise/expressions/elevationLakes"; -import { makeElevationNauvis } from "../src/noise/expressions/elevationNauvis"; -import { makeMoisture } from "../src/noise/expressions/moisture"; -import { - makeNauvisShared, - NAUVIS_OFFSET_X_SEED1, - NAUVIS_OFFSET_Y_SEED1, -} from "../src/noise/expressions/nauvisShared"; -import { makeTemperature } from "../src/noise/expressions/temperature"; -import { makeTileCatalog } from "../src/noise/tiles/catalog"; -import { RESOURCE_CATALOG } from "../src/noise/resources/resourceCatalog"; -import { makeResourcePatches } from "../src/noise/resources/resourcePatches"; -import { makeResourceResolver } from "../src/noise/resources/resolveResource"; -import { TREE_SPECIES } from "../src/noise/trees/treeCatalog"; -import { makeTreeShared } from "../src/noise/trees/treeShared"; -import { makeTreeDensity, makeTreeSpeciesFields } from "../src/noise/trees/treeField"; -import { makeCliffElevation, makeCliffiness } from "../src/noise/cliffs/cliffFields"; -import { makeRockFields } from "../src/noise/rocks/rockField"; -import { makeEnemyBaseField } from "../src/noise/enemies/enemyBaseField"; -import { ENEMY_BASEMENT } from "../src/noise/enemies/enemyCatalog"; - /** * Tier 2 of the Rust port's gate for the Nauvis expression core (#226): strict * bit equality between the two ports over a swept grid, folded @@ -110,33 +86,6 @@ async function instantiate(): Promise { /** A WASM `u64` arrives in JavaScript as a SIGNED BigInt. See wasmEngine.spec.ts. */ const u64 = (x: bigint): bigint => BigInt.asUintN(64, x); -const FNV_OFFSET_BASIS = 0xcbf29ce484222325n; -const FNV_PRIME = 0x100000001b3n; -const MASK64 = (1n << 64n) - 1n; - -const scratch = new DataView(new ArrayBuffer(8)); -function foldF64(acc: bigint, value: number): bigint { - let hash = acc === 0n ? FNV_OFFSET_BASIS : acc; - scratch.setFloat64(0, value, true); - for (let i = 0; i < 8; i++) { - hash ^= BigInt(scratch.getUint8(i)); - hash = (hash * FNV_PRIME) & MASK64; - } - return hash; -} - -/** Rows outer, exactly as `checksum_nauvis` sweeps. */ -function foldGrid(f: (x: number, y: number) => number, c: Case): bigint { - let acc = 0n; - for (let j = 0; j < c.n; j++) { - const y = c.y0 + j * c.step; - for (let i = 0; i < c.n; i++) { - acc = foldF64(acc, f(c.x0 + i * c.step, y)); - } - } - return acc; -} - const SEED0 = 123456; /** @@ -148,34 +97,6 @@ const SEED0 = 123456; */ const OFF_GRID_POSITIONS = 2841; -/** How many swept positions have a non-zero tree density. Frozen; see the test. */ -const TREE_DENSITY_HITS = 927; - -/** Swept positions whose coordinates ARE both f32-exact. Frozen with its complement. */ -const ON_GRID_POSITIONS = 63; - -/** How many swept positions the cliff gate answers 10 at. Frozen; see the test. */ -const CLIFF_GATE_HITS = 327; - -/** How many swept positions have a non-zero rock density. Frozen; see the test. */ -const ROCK_DENSITY_HITS = 72; - -/** - * Per case, how many swept positions a cone reaches. Frozen; see the test. - * - * **The moved-spawn case contributes 0, and that is correct rather than a gap.** - * Enemy bases are suppressed inside the starting area, and that case's window - * sits inside the area its own moved spawn creates - which is the point of the - * case. It grades the spawn-relative fields (`elevation_nauvis`'s distance - * term, `moisture`'s blend, the starting patches); the other five grade the - * enemy layer. A zero here would be a problem only if every case had one, which - * is what this array being per-case makes visible. - */ -const ENEMY_LIVE_PER_CASE = [331, 412, 484, 401, 393, 0]; - -/** How many swept positions have a positive enemy probability. Frozen. */ -const ENEMY_POSITIVE_POSITIONS = 160; - interface Case { readonly label: string; /** @@ -363,357 +284,6 @@ const CASES: readonly Case[] = [ }, ]; -/** - * The TypeScript accessors, in the order `NauvisStack::field` selects them. - * - * Fields 5 and 6 are reconstructed here rather than read off `makeNauvisShared`, - * which does not expose the two raw warp fields. That is the same - * reconstruction `test/nauvisShared.spec.ts` uses, and it is the one pair in - * this list whose TypeScript side is spec-local rather than shipped code - so - * read their agreement as covering the Rust accessor and the seeds, not the - * shipped call site. Fields 7 and 8 consume them through shipped code. - */ -function tsFields(c: Case): ((x: number, y: number) => number)[] { - const shared = makeNauvisShared({ - seed0: SEED0, - segmentationMultiplier: c.segmentationMultiplier, - }); - const offsetInputScale = shared.nauvisSeg / 500; - const rawXTables = basisNoiseTablesFromSeed(SEED0, NAUVIS_OFFSET_X_SEED1); - const rawYTables = basisNoiseTablesFromSeed(SEED0, NAUVIS_OFFSET_Y_SEED1); - - const spawn = c.startingPositions?.map((q) => ({ ...q })); - const elevationCommon = { - seed0: SEED0, - waterLevel: c.waterLevel, - segmentationMultiplier: c.segmentationMultiplier, - startingPositions: spawn, - }; - - // Hoisted, because the tile layer reads all three and rebuilding them per - // tile would be 21 copies of the same chain. - const elevationNauvis = makeElevationNauvis(elevationCommon); - const auxAt = makeAux({ - seed0: SEED0, - segmentationMultiplier: c.segmentationMultiplier, - frequency: c.auxFrequency, - bias: c.auxBias, - }); - const moistureAt = makeMoisture({ - seed0: SEED0, - segmentationMultiplier: c.segmentationMultiplier, - moistureFrequency: c.moistureFrequency, - moistureBias: c.moistureBias, - startingAreaMoistureSize: c.startingAreaMoistureSize, - startingAreaMoistureFrequency: c.startingAreaMoistureFrequency, - startingPositions: spawn, - }); - - // `makeTileResolver` is deliberately NOT used to build this env, and the - // reason is a live bug rather than a style choice: `TileResolverParams` has - // no `waterLevel` field, so the resolver builds its elevation tree at - // water level 0 whatever the caller asked for. That is issue #320 - it costs - // 322 of 2,401 resolved tiles at `waterLevel = 5`. Tier 2 grades the tile - // FORMULAS, so it reads the shipped `probability` closures over an env built - // from the shipped expression trees, and leaves the plumbing gap to its own - // change. - const catalog = makeTileCatalog(SEED0); - const envAt = (x: number, y: number) => ({ - x, - y, - elevation: elevationNauvis(x, y), - aux: auxAt(x, y), - moisture: moistureAt(x, y), - }); - - return [ - shared.hills, - shared.cliffLevel, - shared.plateaus, - shared.bridgeBillows, - shared.forestPathBillows, - (x, y) => basisNoise(x * offsetInputScale, y * offsetInputScale, rawXTables), - (x, y) => basisNoise(x * offsetInputScale, y * offsetInputScale, rawYTables), - shared.hillsOffset, - shared.cliffRingbreak, - elevationNauvis, - makeElevationNauvis({ ...elevationCommon, withCliffElevation: false }), - makeElevationLakes(elevationCommon), - makeElevationIsland(elevationCommon), - auxAt, - moistureAt, - makeTemperature({ - seed0: SEED0, - frequency: c.temperatureFrequency, - bias: c.temperatureBias, - }), - // The 21 tile probabilities, in catalog order, then the argmax over them. - ...catalog.map((t) => (x: number, y: number) => t.probability(envAt(x, y))), - (x: number, y: number) => { - const env = envAt(x, y); - let winner = 0; - let best = catalog[0].probability(env); - for (let i = 1; i < catalog.length; i++) { - const p = catalog[i].probability(env); - if (p > best) { - best = p; - winner = i; - } - } - return winner; - }, - // The resource layer: six `field`s, six `probability`s, six `richness`es, - // then the resolver's winner. - ...resourceFields(c), - // The tree layer: the three shared fields, the 15 species, the density. - ...treeFields(c), - // The cliff and rock layer. - ...cliffRockFields(c), - // The enemy-base layer. - ...enemyFields(c), - ]; -} - -/** - * The enemy-base block's accessors, in the order the Rust selector expects. - * - * **Two fields, not three - the spot field is deliberately NOT folded on its - * own.** The first draft did fold it, on the argument that a `max` hides its - * operands the way the tile argmax and the rock max do. That argument is - * backwards here, and checking the magnitudes is what showed it: the spot field - * runs from -1000 to about +1, while the blob term is roughly +/-0.15 and the - * starting-area term is 0 beyond 150 tiles. So the composed field is DOMINATED - * by the spot field rather than hiding it, and a spot that survived the trim on - * one port and not the other moves `enemyBaseField` by hundreds. - * - * Folding it separately would also have cost something real: `makeEnemyBaseField` - * does not expose it, so this side would have had to reimplement the region - * scan - about 40 lines reproducing `selectSpots` wiring that the shipped - * TypeScript would then never be compared against. That is the private-copy - * trap `checksum_vulcanus` records, and paying it for coverage the composed - * field already gives would be the worst of both. - * - * `probability` folds even though it is 0 almost everywhere, because the cap - * and the clamp are its own wiring and nothing else covers them - - * `test/enemyBaseField.spec.ts` only calls `field`. It folds through the - * SHIPPED `f.probability` accessor for that reason. It used to rebuild - * `clamp(min(field, ENEMY_PLACEMENT_CAP), 0, 1)` inline, which is exactly the - * private-copy trap named two paragraphs above: the sweep graded the Rust - * against a local copy of the wiring, so the shipped cap and clamp that - * `renderEnemies.ts` actually reads stayed uncovered, and the sentence above - * claiming otherwise was false. Both forms give identical values today, so the - * frozen checksums did not move when it changed - which is the point. A test - * whose numbers are the same either way is not the same test. - * - * ## What this block can and cannot see, measured by planting - * - * | planted break | this spec | - * | --- | --- | - * | `- 0.3` becomes `- 0.30001` | RED, names `enemyBaseField` | - * | `15 + 4*intensity` becomes `4.00001` | RED, names `enemyBaseField` | - * | `quantity * (1 + 1e-7)` | RED | - * | `quantity * (1 + 1e-9)`, `(1 + 1e-12)` | not seen | - * | `radius ** 3` becomes `radius * radius * radius` | **not seen** | - * - * The last two rows are one result. The cone's `peak` is `f32(f32(3q) / ...)`, - * and an f32 carries about 1.2e-7 of relative precision, so a relative change in - * `quantity` below that rounds away before it reaches any folded value. One f64 - * ULP is 2.2e-16 - two orders of magnitude under the floor - which is why - * swapping `Math.pow` for a plain product is invisible here even though the two - * genuinely disagree at 25.4% of the radii in play. - * - * That is worth knowing rather than worrying about: it also means a one-ULP - * wasm-libm difference in `powf` cannot reach these fields either. - */ -function enemyFields(c: Case): ((x: number, y: number) => number)[] { - const f = makeEnemyBaseField({ - seed0: SEED0, - controls: { frequency: c.enemyFrequency, size: c.enemySize }, - startingPositions: c.startingPositions?.map((q) => ({ ...q })), - }); - return [(x, y) => f.field(x, y), (x, y) => f.probability(x, y)]; -} - -/** - * The cliff and rock block's accessors, in the order the Rust selector expects. - * - * The three rock probabilities are folded separately from the density above - * them, and that is not symmetry with the tile block - it is stronger here. - * `density` CLAMPS to `[0, 1]` on top of taking a max, and Nauvis rocks are - * sparse: measured at seed 123456, only 76 to 166 positions of a 64x64 window - * have a non-zero density. So a fold of the density alone is mostly a fold of - * zeros, and `huge` and `big` differ only by a constant factor and a constant - * offset, so the max picks the same one of them nearly everywhere. - * - * **`cliffiness` crosses as its 0/10 GATE and nothing finer.** `makeCliffiness` - * returns only the gate, so recovering `main_cliffiness` here would mean - * rebuilding its six sub-terms in this spec from the same parts the Rust reads - * - the private-copy trap `checksum_vulcanus` records, where both sides - * reproduce the same wiring and the comparison sees nothing. - * - * **How blind that leaves it was measured by planting**, not estimated. Shifting - * `base_cliffiness` by changing its `- 0.01` term, over the 2,420 swept - * positions: - * - * | shift in `main_cliffiness` | this spec | - * | --- | --- | - * | 6e-6, 3e-5, 6e-5, 6e-4 | GREEN - not seen | - * | 6e-3 and larger | RED, naming `cliffiness` | - * - * So the fold catches a wrong term at the 1e-2 scale and nothing finer. Two - * controls ran beside it and both went RED naming the right field: a 1e-7 - * relative change to `cliff_elevation`'s `30 *`, and a 1.4e-5 relative change - * to `rock:huge`'s `0.07 *`. The blindness is the gate's, not the sweep's. - * - * `cliff_elevation` is what grades that region at all - it is continuous and - * shares four of the six sub-terms' `makeNauvisShared`. - */ -function cliffRockFields(c: Case): ((x: number, y: number) => number)[] { - const cliffCtx = { - seed0: SEED0, - controls: { frequency: c.cliffFrequency, continuity: c.cliffContinuity }, - settings: { - cliffElevation0: 10, - cliffElevationInterval: c.cliffElevationInterval, - richness: c.cliffRichness, - }, - segmentationMultiplier: c.segmentationMultiplier, - waterLevel: c.waterLevel, - startingPositions: c.startingPositions?.map((q) => ({ ...q })), - }; - const rocks = makeRockFields({ - seed0: SEED0, - rocksFrequency: c.rocksFrequency, - rocksSize: c.rocksSize, - segmentationMultiplier: c.segmentationMultiplier, - moistureFrequency: c.moistureFrequency, - moistureBias: c.moistureBias, - auxFrequency: c.auxFrequency, - auxBias: c.auxBias, - startingAreaMoistureSize: c.startingAreaMoistureSize, - startingAreaMoistureFrequency: c.startingAreaMoistureFrequency, - }); - return [ - makeCliffElevation(cliffCtx), - makeCliffiness(cliffCtx), - (x, y) => rocks.at(x, y).huge, - (x, y) => rocks.at(x, y).big, - (x, y) => rocks.at(x, y).sand, - rocks.density, - ]; -} - -/** - * The tree block's accessors, in the order the Rust selector expects. - * - * All 15 species are folded individually rather than only the density over - * them, for the reason the tile layer measured: a `max` absorbs almost - * anything. The density is one number per pixel that moves only when the - * WINNING species changes value, so folding it alone would grade fifteen - * climate boxes with a number that cannot see fourteen of them. - * - * Both forest-path cutouts are folded, not only the faded one the species read, - * because `makeTreeDensity` reaches the RAW cutout directly - it inlines - * `cutout * 0.3 + smallTerm` to avoid a second `tree_small_noise` call. A fold - * of the faded one alone would not cover that call site. - */ -function treeFields(c: Case): ((x: number, y: number) => number)[] { - const params = { - seed0: SEED0, - treesFrequency: c.treesFrequency, - treesSize: c.treesSize, - segmentationMultiplier: c.segmentationMultiplier, - moistureFrequency: c.moistureFrequency, - moistureBias: c.moistureBias, - temperatureFrequency: c.temperatureFrequency, - temperatureBias: c.temperatureBias, - startingAreaMoistureSize: c.startingAreaMoistureSize, - startingAreaMoistureFrequency: c.startingAreaMoistureFrequency, - startingPositions: c.startingPositions?.map((q) => ({ ...q })), - }; - const shared = makeTreeShared({ - seed0: SEED0, - segmentationMultiplier: c.segmentationMultiplier, - }); - const species = makeTreeSpeciesFields(params); - const density = makeTreeDensity(params); - return [ - shared.smallNoise, - shared.forestPathCutout, - shared.forestPathCutoutFaded, - ...species.map((f) => f.evalAt), - density, - ]; -} - -/** - * The resource block's accessors, in the order the Rust selector expects. - * - * **These six are built HERE from the documented skip constants, while the Rust - * side reads five of them off the shipped `ResourceResolver`.** That asymmetry - * is deliberate and it is what makes the comparison worth something. The - * TypeScript resolver returns a bare closure and exposes none of its per-resource - * fields, so there is no way to reach them through it - and building the same - * private copy on both sides would have reproduced any mis-wiring identically - * and stayed invisible, which is the trap `checksum_vulcanus` records. Reaching - * the same numbers by two different routes is evidence that the resolver really - * does partition the two candidate streams the way its own documentation says. - * - * Crude oil is the one resource the resolver deliberately does not hold - it is - * the `placement: "roll"` entry - so the Rust side builds it separately with - * these same skip parameters. Its FIELD is still folded here: the renderer's - * oil pass will need it, and leaving it out would carry it into #227 ungraded. - * - * All three wrappers are folded for all six, not just the resolver's winner. - * The winner is one integer per position that moves only when a probability - * crosses 0.5, so folding it alone would grade eighteen formulas with a number - * that cannot see any of them - the tile layer measured exactly that, where a - * one-digit slip in a climate box moved one probability and left the argmax - * still. `richness` never reaches the winner at all. - */ -function resourceFields(c: Case): ((x: number, y: number) => number)[] { - const levers = { - frequency: c.resourceFrequency, - size: c.resourceSize, - richness: c.resourceRichness, - }; - const common = { - seed0: SEED0, - controls: levers, - segmentationMultiplier: c.segmentationMultiplier, - waterLevel: c.waterLevel, - startingPositions: c.startingPositions?.map((q) => ({ ...q })), - }; - // `skip_span` 6 for the regular set and 4 for the starting set, offset by - // `patchSetIndex` - the constants `makeResourceResolver` uses. Restated here - // rather than imported, because they are private to that module and because - // the Rust half reaches them through the resolver instead; see above. - const patches = RESOURCE_CATALOG.map((params) => - makeResourcePatches(params, { - ...common, - regularSkipSpan: 6, - regularSkipOffset: params.patchSetIndex, - startingSkipSpan: 4, - startingSkipOffset: params.patchSetIndex, - }), - ); - const resolver = makeResourceResolver({ - seed0: SEED0, - controls: Object.fromEntries(RESOURCE_CATALOG.map((r) => [r.controlName, levers])), - segmentationMultiplier: c.segmentationMultiplier, - waterLevel: c.waterLevel, - }); - return [ - ...patches.map((p) => (x: number, y: number) => p.field(x, y)), - ...patches.map((p) => (x: number, y: number) => p.probability(x, y)), - ...patches.map((p) => (x: number, y: number) => p.richness(x, y)), - // The winner as its CATALOG index, or 6 for "nothing is drawn here" - - // catalog index rather than position in the resolver's own list, so a - // resource dropped by a `size` lever cannot silently renumber the others. - (x: number, y: number) => resolver(x, y)?.patchSetIndex ?? RESOURCE_CATALOG.length, - ]; -} - const FIELD_NAMES = [ "hills", "cliffLevel", @@ -882,7 +452,22 @@ afterAll(flushRecording); */ expectRecordedRows(PLANET, FIELD_NAMES.length * CASES.length); -describe("Rust and TypeScript agree bit for bit across the Nauvis expression core", () => { +/** + * #227 deleted the TypeScript arm of every field in this spec, so the folds are + * graded against the frozen table alone. `tier2Frozen.ts` explains why that is + * still worth running: this is the only place the port's Nauvis arithmetic + * executes inside `wasm32-unknown-unknown` rather than against the host libm. + * + * **Seven anti-vacuity guards went with that arm** - the two field-name order + * checks, the enemy sweep, the cliff gate and rock density, the tree density, + * the resolved-tile-index range check, and "every resource is actually drawn". + * Each counted per-point hits, and `checksum_nauvis` returns a fold, which + * cannot be decomposed back into counts. Restoring them needs an engine export + * that answers a predicate over the sweep rather than a checksum of it. The + * numbers they froze are recorded in the issue so they can be re-measured + * rather than re-derived. + */ +describe("The Nauvis expression core folds to its frozen checksums", () => { it("covers every field the module exposes", async () => { // The module owns the count, so a field added to the Rust chain cannot // silently go untested - this assertion names the gap instead. @@ -890,31 +475,25 @@ describe("Rust and TypeScript agree bit for bit across the Nauvis expression cor expect(engine.nauvis_field_count()).toBe(FIELD_NAMES.length); }); - it("folds every field to the identical checksum, over every case", async () => { + it("folds every field to its frozen checksum, over every case", async () => { const engine = await instantiate(); for (const c of CASES) { - const fields = tsFields(c); - expect(fields.length, "accessor list length").toBe(FIELD_NAMES.length); - for (let f = 0; f < fields.length; f++) { + for (let f = 0; f < FIELD_NAMES.length; f++) { const name = FIELD_NAMES[f] as string; const wasm = wasmChecksum(engine, c, f); - const ts = foldGrid(fields[f], c); - // Recording still compares the two arms first, so the table can only - // ever capture a value both ports already agree on. + // There is no second arm left to compare - #227 deleted the whole + // Nauvis expression core from TypeScript. A record run therefore + // captures the engine unchecked, which is why re-recording is a + // deliberate act and not a repair. See `tier2Frozen.ts`. if (RECORDING) { - expect(wasm, `${c.label}: ${name}`).toBe(ts); record(PLANET, c.label, name, wasm); continue; } - // Both arms against the frozen value, not against each other. That is - // what survives #227: when the TypeScript arm goes, the wasm one keeps - // running against a number captured while the two demonstrably agreed. const want = frozen(PLANET, c.label, name); expect(want, `no frozen checksum for ${c.label}: ${name}`).toBeDefined(); expect(wasm, `wasm ${c.label}: ${name}`).toBe(want); - expect(ts, `TypeScript ${c.label}: ${name}`).toBe(want); } } }, 120000); @@ -960,46 +539,6 @@ describe("Rust and TypeScript agree bit for bit across the Nauvis expression cor expect(offGrid({ x0: 3000.75, y0: 3000.75, step: 8, n: 22 })).toBe(0); }); - it("the tile field names match the catalog's own order", () => { - // FIELD_NAMES spells the 21 tiles out rather than deriving them, so that a - // reordering fails here instead of silently relabelling every downstream - // failure. This is the check that keeps the two in step. - const fromCatalog = makeTileCatalog(SEED0).map((t) => `tile:${t.name}`); - const fromNames = FIELD_NAMES.filter((n) => n.startsWith("tile:")); - expect(fromNames).toEqual(fromCatalog); - expect(fromCatalog).toHaveLength(21); - // And the tile block sits immediately after the 16 expression fields, so - // `FIELD_NAMES[16 + i]` really is `TILE_ORDER[i]`'s probability. - expect(FIELD_NAMES[16]).toBe("tile:deepwater"); - expect(FIELD_NAMES[36]).toBe("tile:red-desert-3"); - expect(FIELD_NAMES[37]).toBe("resolvedTileIndex"); - }); - - it("the tree field names match the catalog's own order", () => { - // Same guard as the tiles': FIELD_NAMES spells the 15 species out rather - // than deriving them, so a catalog reordering fails here instead of - // silently relabelling every downstream failure. - const fromCatalog = TREE_SPECIES.map((t) => `tree:${t.name}`); - const fromNames = FIELD_NAMES.filter( - (n) => n.startsWith("tree:") && !n.startsWith("tree:small") && !n.startsWith("tree:forest"), - ); - expect(fromNames).toEqual(fromCatalog); - expect(fromCatalog).toHaveLength(15); - // And the block sits where the selector says: three shared fields, then the - // species, then the density. - const base = FIELD_NAMES.indexOf("tree:small_noise"); - expect(base).toBe(57); - expect(FIELD_NAMES[base + 1]).toBe("tree:forest_path_cutout"); - expect(FIELD_NAMES[base + 2]).toBe("tree:forest_path_cutout_faded"); - expect(FIELD_NAMES[base + 3]).toBe("tree:tree_01"); - // Indexed from the block's own BASE, not from the end of FIELD_NAMES. The - // end used to be the tree density; the cliff and rock block moved it, and - // an assertion written as `length - 1` fails on a change that has nothing - // to do with trees. - expect(FIELD_NAMES[base + 18]).toBe("treeDensity"); - expect(FIELD_NAMES[base + 19]).toBe("cliffElevation"); - }); - it("the cliff and rock block sits where the selector says", () => { // Same guard as the tile and tree blocks: spelled out rather than derived, // so a reordering fails here instead of relabelling every failure below. @@ -1027,132 +566,6 @@ describe("Rust and TypeScript agree bit for bit across the Nauvis expression cor expect(FIELD_NAMES).toHaveLength(84); }); - it("the enemy sweep reaches spots rather than folding the basement", async () => { - // The enemy field is a `max` against a basement of -1000. A window no cone - // reaches folds that same constant on both sides at every position - - // perfectly bit-identical, and comparing nothing. The existing windows were - // chosen for ore and for trees, so whether any of them carries an enemy base - // is a question rather than an assumption. - // - // `oracle-enemy-base` is 96% basement, which is the scale of the risk. - const engine = await instantiate(); - let live = 0; - let positive = 0; - let positions = 0; - const perCase: number[] = []; - for (const c of CASES) { - const [field, probability] = enemyFields(c); - let here = 0; - for (let j = 0; j < c.n; j++) { - for (let i = 0; i < c.n; i++) { - const x = c.x0 + i * c.step; - const y = c.y0 + j * c.step; - positions++; - // "Well above the basement" rather than "not exactly -1000": the blob - // and starting-area terms move a basement position by a fraction, so - // an exact comparison would call every position live. - if (field(x, y) > ENEMY_BASEMENT + 100) { - live++; - here++; - } - if (probability(x, y) > 0) positive++; - } - } - perCase.push(here); - } - expect(positions).toBe(OFF_GRID_POSITIONS + ON_GRID_POSITIONS); - // Frozen, so a window drifting off every enemy base is a failure rather - // than a silent loss of coverage - the resource block's lesson. - expect(perCase).toEqual(ENEMY_LIVE_PER_CASE); - expect(live).toBeGreaterThan(0); - expect(positive).toBe(ENEMY_POSITIVE_POSITIONS); - expect(engine.nauvis_field_count()).toBe(FIELD_NAMES.length); - }, 120000); - - it("the cliff gate answers both ways and the rock density is not vacuous", async () => { - // Two anti-vacuity checks, one per field that can degenerate. - // - // `cliffiness` is 0 or 10 and nothing else, so a window where every - // position gives the same answer folds a constant on both sides and grades - // nothing - the same objection the resource `probability` fields raised. - // `rockDensity` clamps to 0 wherever no rock wins, and Nauvis rocks are - // sparse enough that a window can genuinely miss them all. - const engine = await instantiate(); - let cliffy = 0; - let bare = 0; - let rocks = 0; - let positions = 0; - for (const c of CASES) { - const [, cliffiness, , , , rockDensity] = cliffRockFields(c); - for (let j = 0; j < c.n; j++) { - for (let i = 0; i < c.n; i++) { - const x = c.x0 + i * c.step; - const y = c.y0 + j * c.step; - positions++; - if (cliffiness(x, y) === 10) cliffy++; - else bare++; - if (rockDensity(x, y) > 0) rocks++; - } - } - } - expect(positions).toBe(OFF_GRID_POSITIONS + ON_GRID_POSITIONS); - // Frozen, so a window drifting off every cliff or every rock is a failure - // rather than a silent loss of coverage. - expect(cliffy).toBe(CLIFF_GATE_HITS); - expect(bare).toBe(positions - CLIFF_GATE_HITS); - expect(rocks).toBe(ROCK_DENSITY_HITS); - expect(engine.nauvis_field_count()).toBe(FIELD_NAMES.length); - }, 120000); - - it("the tree density is not vacuous over the swept windows", async () => { - // A `probability` field folds zeros where its resource is absent, and a - // tree density does the same where no species wins. Bit-identical zeros on - // both sides is agreement about nothing, so at least one window has to - // contain a forest. - const engine = await instantiate(); - // The tree block's density is its LAST field, found from the block's own - // base rather than from the end of FIELD_NAMES - see the name test above. - let drawn = 0; - for (const c of CASES) { - const treeBlock = treeFields(c); - const density = treeBlock[treeBlock.length - 1]; - for (let j = 0; j < c.n; j++) { - for (let i = 0; i < c.n; i++) { - if (density(c.x0 + i * c.step, c.y0 + j * c.step) > 0) drawn++; - } - } - } - expect(drawn).toBeGreaterThan(0); - // Frozen, so a window drifting off every forest is a failure rather than a - // silent loss of coverage. - expect(drawn).toBe(TREE_DENSITY_HITS); - // And the module agrees the block exists at all. - expect(engine.nauvis_field_count()).toBe(FIELD_NAMES.length); - }, 120000); - - it("the resolved tile index really is an index into the catalog", async () => { - // `resolvedTileIndex` crosses the ABI as an f64, so a wrong widening or an - // off-by-one would still fold to *some* number on both sides. This pins - // that the values are integral and inside 0..21 - which the checksum - // cannot say, because it folds raw bits. - const c = CASES[0]; - const fields = tsFields(c); - const resolved = fields[37]; - const seen = new Set(); - for (let j = 0; j < c.n; j++) { - for (let i = 0; i < c.n; i++) { - const v = resolved(c.x0 + i * c.step, c.y0 + j * c.step); - expect(Number.isInteger(v)).toBe(true); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThan(21); - seen.add(v); - } - } - // Anti-vacuity: a window that resolved to one constant tile would satisfy - // everything above and grade nothing. - expect(seen.size).toBeGreaterThan(1); - }); - it("the cases actually differ from each other, field by field", async () => { // Cases that folded to the same numbers would be one case run five times. // @@ -1182,33 +595,4 @@ describe("Rust and TypeScript agree bit for bit across the Nauvis expression cor // own two, so it is the sharpest check that the control block is wired. expect(wasmChecksum(engine, CASES[0], 15)).not.toBe(wasmChecksum(engine, CASES[2], 15)); }); - - it("every resource is actually drawn somewhere in the sweep", () => { - // Anti-vacuity for the resource block, and the reason the two wide windows - // exist. A `probability` field folds 484 zeros wherever its resource is - // absent, so a sweep that contained no ore would be bit-identical on both - // sides while comparing nothing at all. This asserts each of the six is - // present in at least one case, and counts them so a window drifting off - // its patches is a failure rather than a silent loss of coverage. - const drawn = RESOURCE_CATALOG.map(() => 0); - for (const c of CASES) { - const fields = resourceFields(c); - for (let r = 0; r < RESOURCE_CATALOG.length; r++) { - const probability = fields[6 + r]; - for (let j = 0; j < c.n; j++) { - for (let i = 0; i < c.n; i++) { - if (probability(c.x0 + i * c.step, c.y0 + j * c.step) > 0) drawn[r]++; - } - } - } - } - for (let r = 0; r < RESOURCE_CATALOG.length; r++) { - expect(drawn[r], `${RESOURCE_CATALOG[r].name} is absent from every window`).toBeGreaterThan( - 0, - ); - } - // Frozen, so a window that drifts off its patches is caught rather than - // absorbed. Measured on the TypeScript side across all five cases. - expect(drawn).toEqual([7, 3, 5, 4, 4, 1]); - }, 120000); }); diff --git a/test/wasmNauvisRenderParity.spec.ts b/test/wasmNauvisRenderParity.spec.ts index c7bfc627..c0b42ced 100644 --- a/test/wasmNauvisRenderParity.spec.ts +++ b/test/wasmNauvisRenderParity.spec.ts @@ -16,6 +16,7 @@ import { import { decodePng } from "./oracle/decodePng"; import { compileEngine, instantiateEngine, renderThroughWasm } from "../src/noise/wasm/engine"; import { + ENGINE_REQUIRED, runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; @@ -49,10 +50,12 @@ const SEED = 123456; /** * The tier-3 freeze section for this spec. See `tier3Frozen.ts`. * - * Every Rust-against-TypeScript render below is ALSO checked against a frozen - * checksum, so the assertion survives #227 deleting the TypeScript arm. While - * both renderers exist all three agree; afterwards the wasm arm keeps running - * against a value captured while the two demonstrably agreed. + * Every render below is checked against a frozen checksum. #227 has now + * deleted the TypeScript arm, so that is the only check left: the wasm arm runs + * against a value captured while the two demonstrably agreed. Had the freeze + * not landed first, `runRenderRequest(req)` with the engine left off would have + * become the same code as the wasm arm and every comparison would have passed + * while grading nothing. */ const SECTION = "nauvis:render"; @@ -80,8 +83,17 @@ afterAll(flushRecording); * section - which is why the composite block prefixes its per-view rows with * `routes` rather than reusing a bare window label. */ -function freeze(label: string, name: string, wasm: ArrayLike, ts: ArrayLike): void { - expectFrozen(SECTION, label, name, foldPixels(wasm), foldPixels(ts)); +/** + * `ts` is omitted where #227 deleted the TypeScript renderer this block used to + * compare against - see `tier3Frozen.ts`. + */ +function freeze( + label: string, + name: string, + wasm: ArrayLike, + ts?: ArrayLike, +): void { + expectFrozen(SECTION, label, name, foldPixels(wasm), ts && foldPixels(ts)); } /** @@ -246,16 +258,14 @@ async function engine() { return instantiateEngine(compiled); } -describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does", () => { +describe("the WASM engine renders Nauvis terrain to its frozen bytes", () => { it("is byte-identical across four windows", async () => { const e = await engine(); for (const w of WINDOWS) { const req = request(w); const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "terrain", wasm, ts); + freeze(w.label, "terrain", wasm); } }, 300000); @@ -274,7 +284,7 @@ describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does" } }, 300000); - it("moving the climate levers moves the render on both paths together", async () => { + it("moving the climate levers moves the render", async () => { // The eight-lever block is defaulted on the TypeScript side inside // `makeMoisture` / `makeAux` and read raw by the module, so a wrong default // in `renderNauvisThroughWasm` would be a silent divergence. Moving each @@ -298,12 +308,10 @@ describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does" originY: -256, tilesPerPixel: 4, }; - const flat = (r: ElevationRenderRequest, eng?: typeof e) => + const flat = (r: ElevationRenderRequest, eng: typeof e) => Array.from(new Uint8ClampedArray(runRenderRequest(r, eng).buffer)); const baseWasm = flat(base, e); - const baseTs = flat(base); - expect(baseTs).toEqual(baseWasm); - freeze("climate base", "terrain", baseWasm, baseTs); + freeze("climate base", "terrain", baseWasm); const moved: Partial[] = [ { segmentationMultiplier: 2 }, @@ -318,16 +326,14 @@ describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does" for (const patch of moved) { const req = { ...base, ...patch } as ElevationRenderRequest; const w = flat(req, e); - const t = flat(req); const name = Object.keys(patch)[0]; // JOINED rather than `[0]`, because the last two patches share a first // key - `startingAreaMoistureSize` alone, then the same key with the // frequency moved with it. A row name taken from `[0]` would collide, and // the section would silently record six rows where seven ran. const rowName = Object.keys(patch).join("+"); - expect(w, `${name}: wasm vs ts`).toEqual(t); expect(w, `${name}: must actually move the render`).not.toEqual(baseWasm); - freeze(`climate ${rowName}`, "terrain", w, t); + freeze(`climate ${rowName}`, "terrain", w); } // **`startingAreaMoistureFrequency` alone is INERT, and that is a property @@ -343,12 +349,10 @@ describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does" const freqOnly = { ...base, startingAreaMoistureFrequency: 3 } as ElevationRenderRequest; const freqOnlyWasm = flat(freqOnly, e); expect(freqOnlyWasm, "frequency alone must stay inert at the default size").toEqual(baseWasm); - const freqOnlyTs = flat(freqOnly); - expect(freqOnlyTs).toEqual(baseWasm); - freeze("climate startingAreaMoistureFrequency alone", "terrain", freqOnlyWasm, freqOnlyTs); + freeze("climate startingAreaMoistureFrequency alone", "terrain", freqOnlyWasm); }, 300000); - it("a moved spawn renders THROUGH the engine, byte-identical to the TypeScript", async () => { + it("a moved spawn renders THROUGH the engine, to its own frozen bytes", async () => { // The Nauvis block carries the spawn list as of #227, so the engine is no // longer refused here. That inverts what this test proves. It used to check // that `runRenderRequest` REFUSED the engine for a moved spawn, and it @@ -366,9 +370,7 @@ describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does" startingPositions: [{ x: 512, y: -256 }], }; const withEngine = Array.from(new Uint8ClampedArray(runRenderRequest(moved, e).buffer)); - const withoutEngine = Array.from(new Uint8ClampedArray(runRenderRequest(moved).buffer)); - expect(withEngine).toEqual(withoutEngine); - freeze("spawn moved", "terrain", withEngine, withoutEngine); + freeze("spawn moved", "terrain", withEngine); // Anti-vacuity, and it is what makes the equality above mean something: the // spawn has to actually move the render. If it did not, a module that @@ -389,33 +391,39 @@ describe("the WASM engine renders Nauvis terrain exactly as the TypeScript does" ], }; const twoWasm = Array.from(new Uint8ClampedArray(runRenderRequest(two, e).buffer)); - const twoTs = Array.from(new Uint8ClampedArray(runRenderRequest(two).buffer)); - expect(twoWasm).toEqual(twoTs); expect(twoWasm).not.toEqual(withEngine); - freeze("spawn two points", "terrain", twoWasm, twoTs); + freeze("spawn two points", "terrain", twoWasm); }, 300000); - it("refuses the engine for a spawn list longer than the ABI cap", async () => { - // The cap is a real edge rather than a formality: over it the writer throws - // instead of silently dropping points, so `runRenderRequest` has to keep - // such a request on the TypeScript path. Nine points, one past the eight - // the block holds. + it("refuses a spawn list longer than the ABI cap, rather than dropping points", async () => { + // The block this replaces asserted the OPPOSITE, and said so: "This test + // belongs to the carve-out, and the #227 deletion removes both together." + // While the TypeScript renderer existed, a list over the cap was kept off + // the engine and rendered there instead; it was deliberately not frozen, + // because both of its arms were that renderer. + // + // With the carve-out gone the cap is a refusal. Over it the writer throws + // rather than silently dropping points, which is the property worth having + // - a dropped spawn would move the render and nothing would say so. Nine + // points, one past the eight the block holds. // - // **Deliberately NOT frozen.** Both arms here are the TypeScript renderer - - // that is the whole claim - so a frozen row would capture a picture the - // engine can never reproduce, and would fail the moment the carve-out goes. - // This test belongs to the carve-out, and the #227 deletion removes both - // together. The spawn census on that issue is why removing it is safe: the - // most starting points any exchange string in the repo carries is two. + // Removing the carve-out is safe for the reason the spawn census on #227 + // gives: the most starting points any exchange string in the repo carries + // is two. const e = await engine(); const many = { ...request(WINDOWS[0]), startingPositions: Array.from({ length: 9 }, (_, i) => ({ x: i * 64, y: -i * 32 })), }; - expect(() => runRenderRequest(many, e)).not.toThrow(); - expect(Array.from(new Uint8ClampedArray(runRenderRequest(many, e).buffer))).toEqual( - Array.from(new Uint8ClampedArray(runRenderRequest(many).buffer)), - ); + expect(() => runRenderRequest(many, e)).toThrow(/ABI cap/); + + // And eight is not over the cap, so the refusal is the list's length rather + // than the presence of a list. + const eight = { + ...request(WINDOWS[0]), + startingPositions: Array.from({ length: 8 }, (_, i) => ({ x: i * 64, y: -i * 32 })), + }; + expect(() => runRenderRequest(eight, e)).not.toThrow(); }, 300000); }); @@ -460,7 +468,7 @@ const DENSE_WINDOW: Window = { const OVERLAY_WINDOWS: readonly Window[] = [...WINDOWS, DENSE_WINDOW]; -describe("the WASM engine renders the Nauvis tree overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Nauvis tree overlay to its frozen bytes", () => { const treeRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "trees" }); it("serves the trees view rather than refusing it", async () => { @@ -481,10 +489,8 @@ describe("the WASM engine renders the Nauvis tree overlay exactly as the TypeScr const e = await engine(); for (const w of OVERLAY_WINDOWS) { const wasm = new Uint8ClampedArray(runRenderRequest(treeRequest(w), e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(treeRequest(w)).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "trees", wasm, ts); + freeze(w.label, "trees", wasm); } }, 300000); @@ -520,7 +526,7 @@ describe("the WASM engine renders the Nauvis tree overlay exactly as the TypeScr expect(counts).toEqual(TREE_PIXELS_PER_WINDOW); }, 300000); - it("moving each tree lever moves the render on both paths together", async () => { + it("moving each tree lever moves the render", async () => { // The four levers this slice adds to the ABI block. A lever written to the // wrong offset decodes as a neighbour's value, which the round-trip fixture // cannot see - only rendering with it moved can. Each patch was measured on @@ -532,12 +538,10 @@ describe("the WASM engine renders the Nauvis tree overlay exactly as the TypeScr width: 128, height: 128, }; - const flat = (req: ElevationRenderRequest, eng?: typeof e): number[] => + const flat = (req: ElevationRenderRequest, eng: typeof e): number[] => Array.from(new Uint8ClampedArray(runRenderRequest(req, eng).buffer)); const baseWasm = flat(base, e); - const baseTs = flat(base); - expect(baseTs).toEqual(baseWasm); - freeze("trees base", "trees", baseWasm, baseTs); + freeze("trees base", "trees", baseWasm); const patches: readonly (readonly [string, Partial])[] = [ ["treeControls.frequency", { treeControls: { frequency: 3, size: 1 } }], @@ -548,13 +552,11 @@ describe("the WASM engine renders the Nauvis tree overlay exactly as the TypeScr for (const [label, patch] of patches) { const req = { ...base, ...patch } as ElevationRenderRequest; const moved = flat(req, e); - const movedTs = flat(req); - expect(moved, `${label}: the two paths must agree`).toEqual(movedTs); expect(moved, `${label}: must actually move the render`).not.toEqual(baseWasm); // `label` alone is the row key across all four overlay blocks, because // every lever name is already qualified - treeControls, rockControls, // enemyControls, cliffControls, cliffSettings, waterLevel. - freeze(label, "lever", moved, movedTs); + freeze(label, "lever", moved); } }, 300000); @@ -613,7 +615,7 @@ const ROCK_PIXELS_PER_WINDOW = [52, 18, 27, 18, 157]; /** `ROCK_MAP_COLOR` in `src/noise/rocks/rockCatalog.ts`. Both planets share it. */ const ROCK_RGB = [129, 105, 78] as const; -describe("the WASM engine renders the Nauvis rock overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Nauvis rock overlay to its frozen bytes", () => { const rockRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "rocks" }); it("serves the rocks view rather than refusing it", async () => { @@ -631,10 +633,8 @@ describe("the WASM engine renders the Nauvis rock overlay exactly as the TypeScr const e = await engine(); for (const w of OVERLAY_WINDOWS) { const wasm = new Uint8ClampedArray(runRenderRequest(rockRequest(w), e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(rockRequest(w)).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "rocks", wasm, ts); + freeze(w.label, "rocks", wasm); } }, 300000); @@ -672,30 +672,26 @@ describe("the WASM engine renders the Nauvis rock overlay exactly as the TypeScr expect(counts).toEqual(ROCK_PIXELS_PER_WINDOW); }, 300000); - it("moving each rock lever moves the render on both paths together", async () => { + it("moving each rock lever moves the render", async () => { // Measured on the TypeScript path first: 930 and 1,248 bytes change, so // neither comparison is vacuous. const e = await engine(); const base = rockRequest(DENSE_WINDOW); - const flat = (req: ElevationRenderRequest, eng?: typeof e): number[] => + const flat = (req: ElevationRenderRequest, eng: typeof e): number[] => Array.from(new Uint8ClampedArray(runRenderRequest(req, eng).buffer)); const baseWasm = flat(base, e); - const baseTs = flat(base); - expect(baseTs).toEqual(baseWasm); - freeze("rocks base", "rocks", baseWasm, baseTs); + freeze("rocks base", "rocks", baseWasm); for (const [label, patch] of [ ["rockControls.frequency", { rockControls: { frequency: 3, size: 1 } }], ["rockControls.size", { rockControls: { frequency: 1, size: 3 } }], ] as const) { const req = { ...base, ...patch } as ElevationRenderRequest; const moved = flat(req, e); - const movedTs = flat(req); - expect(moved, `${label}: the two paths must agree`).toEqual(movedTs); expect(moved, `${label}: must actually move the render`).not.toEqual(baseWasm); // `label` alone is the row key across all four overlay blocks, because // every lever name is already qualified - treeControls, rockControls, // enemyControls, cliffControls, cliffSettings, waterLevel. - freeze(label, "lever", moved, movedTs); + freeze(label, "lever", moved); } }, 300000); @@ -809,7 +805,7 @@ const ENEMY_PIXELS_PER_WINDOW = [150, 44, 116, 84, 208]; */ const ENEMY_OVERLAY_RGB = [255, 26, 26] as const; -describe("the WASM engine renders the Nauvis enemy overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Nauvis enemy overlay to its frozen bytes", () => { const enemyRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "enemies" }); it("serves the enemies view rather than refusing it", async () => { @@ -823,10 +819,8 @@ describe("the WASM engine renders the Nauvis enemy overlay exactly as the TypeSc const e = await engine(); for (const w of ENEMY_WINDOWS) { const wasm = new Uint8ClampedArray(runRenderRequest(enemyRequest(w), e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(enemyRequest(w)).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "enemies", wasm, ts); + freeze(w.label, "enemies", wasm); } }, 300000); @@ -862,31 +856,27 @@ describe("the WASM engine renders the Nauvis enemy overlay exactly as the TypeSc expect(counts).toEqual(ENEMY_PIXELS_PER_WINDOW); }, 300000); - it("moving each enemy lever moves the render on both paths together", async () => { + it("moving each enemy lever moves the render", async () => { // The window is the FAR one, not the near-spawn one every other block uses: // `frequency` moves 0 bytes near spawn, so that test would be vacuous. Here // the two levers move 328 and 587 bytes, measured on the TypeScript path. const e = await engine(); const base = enemyRequest(ENEMY_WINDOWS[4]); - const flat = (req: ElevationRenderRequest, eng?: typeof e): number[] => + const flat = (req: ElevationRenderRequest, eng: typeof e): number[] => Array.from(new Uint8ClampedArray(runRenderRequest(req, eng).buffer)); const baseWasm = flat(base, e); - const baseTs = flat(base); - expect(baseTs).toEqual(baseWasm); - freeze("enemies base", "enemies", baseWasm, baseTs); + freeze("enemies base", "enemies", baseWasm); for (const [label, patch] of [ ["enemyControls.frequency", { enemyControls: { frequency: 3, size: 1 } }], ["enemyControls.size", { enemyControls: { frequency: 1, size: 3 } }], ] as const) { const req = { ...base, ...patch } as ElevationRenderRequest; const moved = flat(req, e); - const movedTs = flat(req); - expect(moved, `${label}: the two paths must agree`).toEqual(movedTs); expect(moved, `${label}: must actually move the render`).not.toEqual(baseWasm); // `label` alone is the row key across all four overlay blocks, because // every lever name is already qualified - treeControls, rockControls, // enemyControls, cliffControls, cliffSettings, waterLevel. - freeze(label, "lever", moved, movedTs); + freeze(label, "lever", moved); } }, 300000); @@ -975,7 +965,7 @@ const CLIFF_PIXELS_PER_WINDOW = [425, 152, 1125, 1080, 2584]; /** `CLIFF_MAP_COLOR` in `src/noise/cliffs/cliffCatalog.ts`. Both planets share it. */ const CLIFF_RGB = [144, 119, 87] as const; -describe("the WASM engine renders the Nauvis cliff overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Nauvis cliff overlay to its frozen bytes", () => { const cliffRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "cliffs" }); it("serves the cliffs view rather than refusing it", async () => { @@ -988,10 +978,8 @@ describe("the WASM engine renders the Nauvis cliff overlay exactly as the TypeSc const e = await engine(); for (const w of CLIFF_WINDOWS) { const wasm = new Uint8ClampedArray(runRenderRequest(cliffRequest(w), e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(cliffRequest(w)).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "cliffs", wasm, ts); + freeze(w.label, "cliffs", wasm); } }, 300000); @@ -1027,7 +1015,7 @@ describe("the WASM engine renders the Nauvis cliff overlay exactly as the TypeSc expect(counts).toEqual(CLIFF_PIXELS_PER_WINDOW); }, 300000); - it("moving each cliff lever moves the render on both paths together", async () => { + it("moving each cliff lever moves the render", async () => { // Six levers, and `waterLevel` is one of them - which is the interesting // case. The TERRAIN view ignores it (#326, reproduced deliberately), so // this is the first Nauvis pass where the module must actually READ the @@ -1035,12 +1023,10 @@ describe("the WASM engine renders the Nauvis cliff overlay exactly as the TypeSc // request. const e = await engine(); const base = cliffRequest(CLIFF_WINDOWS[4]); - const flat = (req: ElevationRenderRequest, eng?: typeof e): number[] => + const flat = (req: ElevationRenderRequest, eng: typeof e): number[] => Array.from(new Uint8ClampedArray(runRenderRequest(req, eng).buffer)); const baseWasm = flat(base, e); - const baseTs = flat(base); - expect(baseTs).toEqual(baseWasm); - freeze("cliffs base", "cliffs", baseWasm, baseTs); + freeze("cliffs base", "cliffs", baseWasm); for (const [label, patch] of [ // Frequency has to reach the slider's MINIMUM to grade much - see the // cliff-lever note in CLAUDE.md, measured over 1600 positions. @@ -1058,17 +1044,15 @@ describe("the WASM engine renders the Nauvis cliff overlay exactly as the TypeSc ] as const) { const req = { ...base, ...patch } as ElevationRenderRequest; const moved = flat(req, e); - const movedTs = flat(req); - expect(moved, `${label}: the two paths must agree`).toEqual(movedTs); expect(moved, `${label}: must actually move the render`).not.toEqual(baseWasm); // `label` alone is the row key across all four overlay blocks, because // every lever name is already qualified - treeControls, rockControls, // enemyControls, cliffControls, cliffSettings, waterLevel. - freeze(label, "lever", moved, movedTs); + freeze(label, "lever", moved); } }, 300000); - it("richness 0 disables the overlay entirely on both paths", async () => { + it("richness 0 disables the overlay entirely", async () => { // A separate case because it is the one lever whose effect is REMOVAL. It // must take the render back to bare terrain exactly, not merely change it. const e = await engine(); @@ -1079,10 +1063,8 @@ describe("the WASM engine renders the Nauvis cliff overlay exactly as the TypeSc } as ElevationRenderRequest; const terrain = Array.from(new Uint8ClampedArray(runRenderRequest(request(w), e).buffer)); const offWasm = Array.from(new Uint8ClampedArray(runRenderRequest(off, e).buffer)); - const offTs = Array.from(new Uint8ClampedArray(runRenderRequest(off).buffer)); expect(offWasm).toEqual(terrain); - expect(offTs).toEqual(terrain); - freeze("cliffs richness 0", "cliffs", offWasm, offTs); + freeze("cliffs richness 0", "cliffs", offWasm); }, 300000); it("tiles to the same bytes as one whole render, and the halo is what makes it so", async () => { @@ -1226,7 +1208,7 @@ const ORE_RGB = [ [0, 179, 0], ] as const; -describe("the WASM engine renders the Nauvis resource overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Nauvis resource overlay to its frozen bytes", () => { const resourceRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "resources", @@ -1243,10 +1225,8 @@ describe("the WASM engine renders the Nauvis resource overlay exactly as the Typ const e = await engine(); for (const w of RESOURCE_WINDOWS) { const wasm = new Uint8ClampedArray(runRenderRequest(resourceRequest(w), e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(resourceRequest(w)).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "resources", wasm, ts); + freeze(w.label, "resources", wasm); } }, 300000); @@ -1282,12 +1262,10 @@ describe("the WASM engine renders the Nauvis resource overlay exactly as the Typ const e = await engine(); const w = RESOURCE_WINDOWS[1]; const base = resourceRequest(w); - const flat = (req: ElevationRenderRequest, eng?: typeof e): number[] => + const flat = (req: ElevationRenderRequest, eng: typeof e): number[] => Array.from(new Uint8ClampedArray(runRenderRequest(req, eng).buffer)); const baseWasm = flat(base, e); - const baseTs = flat(base); - expect(baseTs).toEqual(baseWasm); - freeze("resources base", "resources", baseWasm, baseTs); + freeze("resources base", "resources", baseWasm); const withLevers = (name: string): ElevationRenderRequest => ({ ...base, @@ -1295,12 +1273,8 @@ describe("the WASM engine renders the Nauvis resource overlay exactly as the Typ }); const iron = flat(withLevers("iron-ore"), e); const copper = flat(withLevers("copper-ore"), e); - const ironTs = flat(withLevers("iron-ore")); - const copperTs = flat(withLevers("copper-ore")); - expect(iron, "iron: the two paths must agree").toEqual(ironTs); - expect(copper, "copper: the two paths must agree").toEqual(copperTs); - freeze("resources iron-ore levers", "resources", iron, ironTs); - freeze("resources copper-ore levers", "resources", copper, copperTs); + freeze("resources iron-ore levers", "resources", iron); + freeze("resources copper-ore levers", "resources", copper); expect(iron, "iron levers must move the render").not.toEqual(baseWasm); expect(copper, "copper levers must move the render").not.toEqual(baseWasm); expect(iron, "iron and copper must not be the same edit").not.toEqual(copper); @@ -1391,7 +1365,7 @@ const ALL_WINDOWS: readonly Window[] = [ }, ]; -describe("the WASM engine renders the Nauvis `all` composite exactly as the TypeScript does", () => { +describe("the WASM engine renders the Nauvis `all` composite to its frozen bytes", () => { const allRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "all" }); it("serves the all view rather than refusing it", async () => { @@ -1405,10 +1379,8 @@ describe("the WASM engine renders the Nauvis `all` composite exactly as the Type const e = await engine(); for (const w of ALL_WINDOWS) { const wasm = new Uint8ClampedArray(runRenderRequest(allRequest(w), e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(allRequest(w)).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "all", wasm, ts); + freeze(w.label, "all", wasm); } }, 300000); @@ -1500,11 +1472,18 @@ describe("the WASM engine renders the Nauvis `all` composite exactly as the Type ] as const) { const req = { ...request(w), view } as ElevationRenderRequest; const wasm = Array.from(new Uint8ClampedArray(runRenderRequest(req, e).buffer)); - const ts = Array.from(new Uint8ClampedArray(runRenderRequest(req).buffer)); - expect(wasm, `${view}: engine and TypeScript must agree`).toEqual(ts); + + // This used to render the same request twice - once with the engine and + // once without - and assert the two agreed. #227 makes it the sharper + // statement it stood in for: with no TypeScript to fall back to, a view + // that reaches the engine is exactly a view that REFUSES to render + // without one. A view that still returned pixels here would be a view + // served off some other path. + expect(() => runRenderRequest(req), `${view}: must need the engine`).toThrow(ENGINE_REQUIRED); + // Prefixed, because this block sweeps ALL_WINDOWS[2] through every view // and a bare window label would collide with the per-view blocks above. - freeze(`routes ${w.label}`, view, wasm, ts); + freeze(`routes ${w.label}`, view, wasm); } const all = Array.from(new Uint8ClampedArray(runRenderRequest(allRequest(w), e).buffer)); const terrain = Array.from(new Uint8ClampedArray(runRenderRequest(request(w), e).buffer)); diff --git a/test/wasmPrimitiveParity.spec.ts b/test/wasmPrimitiveParity.spec.ts index 573f9266..ea627bad 100644 --- a/test/wasmPrimitiveParity.spec.ts +++ b/test/wasmPrimitiveParity.spec.ts @@ -18,11 +18,8 @@ afterAll(flushRecording); */ expectRecordedRows(PLANET, 18); -import { distanceFromNearestPoint, type Point } from "../src/noise/distanceFromNearestPoint"; -import { randomPenaltyBatch } from "../src/noise/randomPenalty"; import { spotCandidatePoints } from "../src/noise/spotCandidates"; import { selectSpots } from "../src/noise/spotSelection"; -import { startingLakePositions } from "../src/noise/startingLakes"; /** * Tier 2 of the Rust port's gate for the phase-1 primitives that do NOT compose @@ -127,23 +124,17 @@ function foldAll(values: readonly number[]): bigint { return acc; } -/** - * The spawn list both lake exports build, duplicated from `spawns()` in - * `crates/fmw-wasm/src/lib.rs`. The boundary takes scalars, so a list would - * have to go through the scratch region - machinery that would itself need - * testing. Keep the two rules in step. - */ -function spawns(count: number): Point[] { - return Array.from({ length: count }, (_unused, k) => ({ x: k * 1000, y: k * -700 })); -} - // Off the lattice, and a step that is not a simple binary fraction. const X0 = -3.5; const Y0 = 7.25; const STEP = 0.37; const N = 32; -describe("Rust and TypeScript random_penalty agree bit for bit", () => { +/** + * The TypeScript arm went with `randomPenalty.ts` in #227, so these rows are + * graded against the frozen table alone. See `tier2Frozen.ts`. + */ +describe("random_penalty folds to its frozen checksums", () => { // `sourceKind` 1 is `x`, which goes negative over half this grid, so the // `source <= 0` pass-through and the draw it does NOT consume are inside the // comparison. A batch of all-positive sources would never reach that branch. @@ -153,17 +144,7 @@ describe("Rust and TypeScript random_penalty agree bit for bit", () => { { rpSeed: 13, amplitude: 0.5, sourceKind: 1 }, ] as const; - const batchOf = (c: (typeof CASES)[number]): number[] => { - const positions: Point[] = []; - for (let j = 0; j < N; j++) { - const y = Y0 + j * STEP; - for (let i = 0; i < N; i++) positions.push({ x: X0 + i * STEP, y }); - } - const source = positions.map((p) => (c.sourceKind === 0 ? 1 : p.x)); - return randomPenaltyBatch(positions, source, { seed: c.rpSeed, amplitude: c.amplitude }); - }; - - it("folds a 1,024-position batch to the identical checksum, over several cases", async () => { + it("folds a 1,024-position batch to the frozen checksum, over several cases", async () => { const engine = await instantiate(); for (const c of CASES) { const fromWasm = u64( @@ -174,26 +155,24 @@ describe("Rust and TypeScript random_penalty agree bit for bit", () => { `penalty seed=${c.rpSeed} amp=${c.amplitude} src=${c.sourceKind}`, "checksum_random_penalty", fromWasm, - foldAll(batchOf(c)), ); } }); - it("would notice a single value differing by one ULP", async () => { - // The anti-vacuity check for this block. A fold that ignored its input, or - // a comparison of something against itself, would pass the test above. + it("would notice the amplitude moving by one ULP", async () => { + // The anti-vacuity check for this block. A fold that ignored its input + // would pass the test above. + // + // It used to perturb one value of the TypeScript batch by a single ULP and + // show the fold moved. #227 deleted that arm, so the ULP goes into the one + // input still reachable from outside: `amplitude` scales every penalty in + // the batch and crosses the boundary as an f64, so one ULP of it is one ULP + // the fold has to see. const engine = await instantiate(); const c = CASES[0]; - const fromWasm = u64( - engine.checksum_random_penalty(c.rpSeed, c.amplitude, c.sourceKind, X0, Y0, STEP, N), - ); - const perturbed = batchOf(c); - const buf = new Float32Array(1); - const bits = new Uint32Array(buf.buffer); - buf[0] = perturbed[500]; - bits[0] += 1; - perturbed[500] = buf[0]; - expect(foldAll(perturbed)).not.toBe(fromWasm); + const at = (amplitude: number): bigint => + u64(engine.checksum_random_penalty(c.rpSeed, amplitude, c.sourceKind, X0, Y0, STEP, N)); + expect(at(c.amplitude)).not.toBe(at(c.amplitude + Number.EPSILON * c.amplitude)); }); it("is order dependent, which is what makes it a batch op", async () => { @@ -484,7 +463,17 @@ describe("Rust and TypeScript spot_noise selection agree bit for bit", () => { }); }); -describe("Rust and TypeScript starting_lake_positions agree bit for bit", () => { +/** + * The TypeScript arm went with `startingLakes.ts` in #227, so these rows are + * graded against the frozen table alone. See `tier2Frozen.ts`. + * + * The block lost its companion check, "draws one continuous stream, so more + * spawns is not more copies of one lake". That one read each lake's offset from + * its own spawn, and `checksum_starting_lakes` returns a single fold over every + * coordinate, which cannot be decomposed back into per-lake offsets. Restoring + * it needs an engine export that emits the lakes rather than their checksum. + */ +describe("starting_lake_positions folds to its frozen checksums", () => { const CASES = [ { seed0: 123456, spawnCount: 1 }, { seed0: 123456, spawnCount: 4 }, @@ -495,10 +484,7 @@ describe("Rust and TypeScript starting_lake_positions agree bit for bit", () => { seed0: 4294967295, spawnCount: 2 }, ] as const; - const coordsOf = (c: (typeof CASES)[number]): number[] => - startingLakePositions(c.seed0, spawns(c.spawnCount)).flatMap((p) => [p.x, p.y]); - - it("folds every lake to the identical checksum, including below the seed clamp", async () => { + it("folds every lake to the frozen checksum, including below the seed clamp", async () => { const engine = await instantiate(); for (const c of CASES) { const fromWasm = u64(engine.checksum_starting_lakes(c.seed0, c.spawnCount)); @@ -507,22 +493,18 @@ describe("Rust and TypeScript starting_lake_positions agree bit for bit", () => `lakes seed0=${c.seed0} spawns=${c.spawnCount}`, "checksum_starting_lakes", fromWasm, - foldAll(coordsOf(c)), ); } }); - - it("draws one continuous stream, so more spawns is not more copies of one lake", async () => { - // A port that re-seeded per spawn would still agree with itself and pass - // everything above. The lakes sit at radius 75 around DIFFERENT spawns, so - // compare the offsets rather than the absolute positions. - const lakes = startingLakePositions(123456, spawns(4)); - const offsets = lakes.map((p, k) => `${p.x - k * 1000},${p.y - k * -700}`); - expect(new Set(offsets).size).toBeGreaterThan(1); - }); }); -describe("Rust and TypeScript distance_from_nearest_point agree bit for bit", () => { +/** + * `distanceFromNearestPoint` itself survives #227, but its points came from + * `startingLakePositions`, which did not - so there is no TypeScript arm left + * to build the same input on. These rows are graded against the frozen table, + * and both of the block's anti-vacuity checks are asked of the engine instead. + */ +describe("distance_from_nearest_point folds to its frozen checksums", () => { // A cap that the grid actually reaches, and one it never does. With every // point inside the cap the `bestSq < maxSq` branch is the only one that ever // runs, and the capped return would be dead on both sides. @@ -532,19 +514,7 @@ describe("Rust and TypeScript distance_from_nearest_point agree bit for bit", () { seed0: 999, spawnCount: 2, maximumDistance: Infinity }, ] as const; - const valuesOf = (c: (typeof CASES)[number]): number[] => { - const points = startingLakePositions(c.seed0, spawns(c.spawnCount)); - const out: number[] = []; - for (let j = 0; j < N; j++) { - const y = Y0 + j * STEP; - for (let i = 0; i < N; i++) { - out.push(distanceFromNearestPoint(X0 + i * STEP, y, points, c.maximumDistance)); - } - } - return out; - }; - - it("folds 1,024 grid points to the identical checksum, capped and uncapped", async () => { + it("folds 1,024 grid points to the frozen checksum, capped and uncapped", async () => { const engine = await instantiate(); for (const c of CASES) { const fromWasm = u64( @@ -563,7 +533,6 @@ describe("Rust and TypeScript distance_from_nearest_point agree bit for bit", () `distance seed0=${c.seed0} max=${c.maximumDistance}`, "checksum_distance_from_nearest_point", fromWasm, - foldAll(valuesOf(c)), ); } }); @@ -571,26 +540,58 @@ describe("Rust and TypeScript distance_from_nearest_point agree bit for bit", () it("actually reaches the cap on the capped case, and never on the uncapped one", async () => { // Anti-vacuity for the case list above: if no grid point saturated, the two // cases would exercise the same single branch. - expect(valuesOf(CASES[1]).some((v) => v === 50)).toBe(true); - expect(valuesOf(CASES[2]).every((v) => Number.isFinite(v))).toBe(true); + // + // Asked of the engine since #227 took the TypeScript arm. Raising the cap + // moves the fold if and only if some point was being clamped by it, which + // is the claim the old array scan made. The uncapped case is shown the + // other way round: a cap of 1e300 is indistinguishable from Infinity, so + // nothing in that grid comes anywhere near saturating. + const engine = await instantiate(); + const at = (c: (typeof CASES)[number], maximumDistance: number): bigint => + u64( + engine.checksum_distance_from_nearest_point( + c.seed0, + c.spawnCount, + maximumDistance, + X0, + Y0, + STEP, + N, + ), + ); + expect(at(CASES[1], 50)).not.toBe(at(CASES[1], Infinity)); + expect(at(CASES[2], Infinity)).toBe(at(CASES[2], 1e300)); }); - it("would notice a single distance differing by one ULP", async () => { - const engine = await instantiate(); - const c = CASES[0]; - const fromWasm = u64( - engine.checksum_distance_from_nearest_point( - c.seed0, - c.spawnCount, - c.maximumDistance, - X0, - Y0, - STEP, - N, - ), + it("would notice the cap moving by one f32 ULP", async () => { + // The ULP check this block used to make against a perturbed TypeScript + // array. The capped case saturates - the test above proves it - so a move + // in the cap is a move in every value that clamps to it. + // + // One f32 ULP, not one f64 ULP. The export folds + // `f64::from(distance_from_nearest_point(...))` and that function returns + // an `f32`, so a cap nudged below f32 resolution comes back as the very + // same number. Measured: written with `Number.EPSILON` this test failed, + // both folds equal. At 50 the f32 ULP is 2^-18. + const c = CASES[1]; + const nudged = c.maximumDistance + 2 ** -18; + expect(Math.fround(nudged), "the perturbation must survive the f32 return").not.toBe( + c.maximumDistance, ); - const perturbed = valuesOf(c); - perturbed[900] = perturbed[900] + Number.EPSILON * perturbed[900]; - expect(foldAll(perturbed)).not.toBe(fromWasm); + + const engine = await instantiate(); + const at = (maximumDistance: number): bigint => + u64( + engine.checksum_distance_from_nearest_point( + c.seed0, + c.spawnCount, + maximumDistance, + X0, + Y0, + STEP, + N, + ), + ); + expect(at(c.maximumDistance)).not.toBe(at(nudged)); }); }); diff --git a/test/wasmVulcanusParity.spec.ts b/test/wasmVulcanusParity.spec.ts index 2ba4adf2..ac716158 100644 --- a/test/wasmVulcanusParity.spec.ts +++ b/test/wasmVulcanusParity.spec.ts @@ -11,15 +11,9 @@ import { record, } from "./tier2Frozen"; -import { makeCliffinessBasic } from "../src/noise/cliffs/vulcanusCliffFields"; import { distanceFromNearestPoint } from "../src/noise/distanceFromNearestPoint"; import { type EvalCtxInput, withCtxDefaults } from "../src/noise/eval/ctx"; import { makeVulcanusTemperature } from "../src/noise/expressions/vulcanusElevation"; -import { sulfuricAcidGeyserProbability } from "../src/noise/resources/vulcanusResourceCatalog"; -import { - makeVulcanusDecorativeKnockout, - makeVulcanusRockFields, -} from "../src/noise/rocks/vulcanusRockField"; import { makeMountainLavaSpots, makeVulcanusRockNoise, @@ -217,6 +211,29 @@ const FIELD_NAMES = [ "resolvedTile", ]; +/** + * The six fields whose TypeScript arm #227 deleted, in field order. + * + * `vulcanusCliffFields.ts`, `vulcanusRockField.ts` and the two math functions + * trimmed out of `vulcanusResourceCatalog.ts` all went with the source + * deletion. The engine still folds these six, and the frozen table still holds + * the value both ports agreed on when it was captured - see `tier2Frozen.ts`. + * What is gone is the second opinion, for these six only; the other 68 fields + * are still graded against live TypeScript. + * + * The list is asserted against what `tsFields` actually withholds, so a + * seventh field losing its arm is a test failure rather than a silent + * downgrade. + */ +const NO_TS_ARM: readonly string[] = [ + "geyserProbability", + "cliffinessBasic", + "decorativeKnockout", + "rockHuge", + "rockBig", + "rockDensity", +]; + /** The index of a named field, so an assertion never hard-codes a position. */ function fieldIndex(name: string): number { const at = FIELD_NAMES.indexOf(name); @@ -315,8 +332,14 @@ function ctxInput(s: Sliders): EvalCtxInput { return { seed0: SEED0, ...s.ctx }; } -/** Every field, at every point of `w`, in the module's field order. */ -function tsFields(s: Sliders, w: Window): number[][] { +/** + * Every field, at every point of `w`, in the module's field order. + * + * `undefined` where #227 deleted the reference implementation - see + * `NO_TS_ARM`. The slot is held rather than dropped so field indices still + * line up with `FIELD_NAMES` and with the module's own field order. + */ +function tsFields(s: Sliders, w: Window): (number[] | undefined)[] { const input = ctxInput(s); const ctx = withCtxDefaults(input); // ONE stack, so every accessor below reads the same field objects - and @@ -326,12 +349,8 @@ function tsFields(s: Sliders, w: Window): number[][] { const { helpers, spawn, cracks, biomes, climate, elevation, resources } = stack; const temperature = makeVulcanusTemperature(ctx, climate, biomes, elevation); - const geyser = sulfuricAcidGeyserProbability(resources); const mountainLavaSpots = makeMountainLavaSpots(helpers, biomes); const rockNoise = makeVulcanusRockNoise(ctx.seed0); - const knockout = makeVulcanusDecorativeKnockout(ctx.seed0); - const cliffiness = makeCliffinessBasic(ctx.seed0); - const rocks = makeVulcanusRockFields(ctx, stack); const tileFields: VulcanusTileFields = { elev: (x, y) => elevation.elev(x, y), @@ -350,7 +369,8 @@ function tsFields(s: Sliders, w: Window): number[][] { }; const catalog = makeVulcanusTileCatalog(tileFields); - const accessors: ((x: number, y: number) => number)[] = [ + // `null` is a field with no TypeScript left to read - see `NO_TS_ARM`. + const accessors: (((x: number, y: number) => number) | null)[] = [ helpers.wobbleX, helpers.wobbleY, helpers.wobbleLargeX, @@ -396,30 +416,38 @@ function tsFields(s: Sliders, w: Window): number[][] { (x, y) => resources.sulfuricAcidPatches(x, y), (x, y) => resources.sulfuricAcidRegionPatchy(x, y), (x, y) => resources.metalTile(x, y), - geyser, + null, // geyserProbability - `sulfuricAcidGeyserProbability`, deleted by #227 mountainLavaSpots, rockNoise, (x, y) => distanceFromNearestPoint(x, y, ctx.startingPositions), - cliffiness, - knockout, - rocks.rockHuge, - rocks.rockBig, - rocks.density, + null, // cliffinessBasic - `makeCliffinessBasic`, deleted by #227 + null, // decorativeKnockout - `makeVulcanusDecorativeKnockout`, deleted by #227 + null, // rockHuge - `makeVulcanusRockFields`, deleted by #227 + null, // rockBig - same + null, // rockDensity - same ...catalog.map((t) => (x: number, y: number) => t.probability(x, y)), (x, y) => TILE_NAMES.indexOf(resolveVulcanusTile(x, y, catalog).name), ]; - const out: number[][] = accessors.map(() => []); + const out: (number[] | undefined)[] = accessors.map((read) => (read === null ? undefined : [])); for (let j = 0; j < w.size; j++) { const y = w.originY + j * w.tilesPerPixel; for (let i = 0; i < w.size; i++) { const x = w.originX + i * w.tilesPerPixel; - for (const [f, read] of accessors.entries()) (out[f] as number[]).push(read(x, y)); + for (const [f, read] of accessors.entries()) { + if (read !== null) (out[f] as number[]).push(read(x, y)); + } } } return out; } +/** One field's TypeScript fold, or `undefined` where #227 deleted the arm. */ +function tsFold(ts: (number[] | undefined)[], field: number): bigint | undefined { + const values = ts[field]; + return values === undefined ? undefined : foldAll(values); +} + /** The request the module reads its parameters and its sweep geometry from. */ function request(s: Sliders, w: Window): VulcanusRenderRequest { const ctx = withCtxDefaults(ctxInput(s)); @@ -478,12 +506,14 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" const label = `${s.label}, ${w.label}`; for (const [field, name] of FIELD_NAMES.entries()) { const wasm = u64(engine.checksum_vulcanus(len, field)); - const tsFold = foldAll(ts[field] as number[]); + const ref = tsFold(ts, field); // Recording compares the two arms first, so the table can only ever - // capture a value both ports already agree on. + // capture a value both ports already agree on. The six fields in + // `NO_TS_ARM` have no second arm to compare, which is why a record + // run is a deliberate act rather than a repair. if (RECORDING) { - expect(wasm, `${name} (${label})`).toBe(tsFold); + if (ref !== undefined) expect(wasm, `${name} (${label})`).toBe(ref); record(PLANET, label, name, wasm); compared++; continue; @@ -494,7 +524,7 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" const want = frozen(PLANET, label, name); expect(want, `no frozen checksum for ${name} (${label})`).toBeDefined(); expect(wasm, `wasm ${name} (${label})`).toBe(want); - expect(tsFold, `TypeScript ${name} (${label})`).toBe(want); + if (ref !== undefined) expect(ref, `TypeScript ${name} (${label})`).toBe(want); compared++; } } @@ -525,24 +555,28 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" const ts = tsFields(s, OFF_GRID); const len = writeRequest(engine, request(s, OFF_GRID)); - const diverging = FIELD_NAMES.filter( - (_, field) => u64(engine.checksum_vulcanus(len, field)) !== foldAll(ts[field] as number[]), - ); + const diverging = FIELD_NAMES.filter((_, field) => { + const ref = tsFold(ts, field); + return ref !== undefined && u64(engine.checksum_vulcanus(len, field)) !== ref; + }); expect(diverging).toEqual([]); // The off-grid window is a fourth sweep and it dies with the TypeScript arm // like the other three, so it is frozen too. It is the one that closes // #309, which makes it the last sweep anybody would want to lose. + const offGrid: bigint[] = []; for (const [field, name] of FIELD_NAMES.entries()) { const wasm = u64(engine.checksum_vulcanus(len, field)); + offGrid.push(wasm); if (RECORDING) { record(PLANET, OFF_GRID_LABEL, name, wasm); continue; } + const ref = tsFold(ts, field); const want = frozen(PLANET, OFF_GRID_LABEL, name); expect(want, `no frozen checksum for ${name} (${OFF_GRID_LABEL})`).toBeDefined(); expect(wasm, `wasm ${name} (${OFF_GRID_LABEL})`).toBe(want); - expect(foldAll(ts[field] as number[]), `TypeScript ${name} (${OFF_GRID_LABEL})`).toBe(want); + if (ref !== undefined) expect(ref, `TypeScript ${name} (${OFF_GRID_LABEL})`).toBe(want); } // Anti-vacuity, and it is not optional: "nothing diverges" is exactly what a @@ -551,10 +585,16 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" // really a DIFFERENT sweep from the on-grid one - if the folds matched the // spawn window's, the coordinates never left the grid and the comparison // above would be a re-run of the main fold. - const onGrid = tsFields(s, WINDOWS[0] as Window); + // + // Asked of the engine rather than of `tsFields`, because the claim is about + // all 74 fields and six of them no longer have a TypeScript arm. The count + // is unchanged by the switch: every field with both arms is asserted equal + // to its frozen value above, so the two arms cannot disagree about which + // folds moved. Note the on-grid request is written AFTER the off-grid folds + // are read - `writeRequest` reuses the one scratch region. + const onGridLen = writeRequest(engine, request(s, WINDOWS[0] as Window)); const moved = FIELD_NAMES.filter( - (name, field) => - foldAll(ts[field] as number[]) !== foldAll(onGrid[FIELD_NAMES.indexOf(name)] as number[]), + (_, field) => offGrid[field] !== u64(engine.checksum_vulcanus(onGridLen, field)), ); expect(moved.length, "the off-grid window must sweep different points").toBe( FIELD_NAMES.length, @@ -622,16 +662,26 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" expect(u64(engine.checksum_vulcanus(fulgoraLen, 0)), "wrong planet").toBe(0n); }); - it("the second slider setting really is a different chain, so running both says something", () => { + it("the second slider setting really is a different chain, so running both says something", async () => { // Anti-vacuity. At the default sliders `vulcanus_scale_multiplier` is // exactly 1 and every `sliderRescale` returns exactly 1, so a one-setting // spec would not exercise a single lever. + // + // Read from the engine since #227: six fields have no TypeScript arm left + // and the count is over all 74. The main fold pins both arms to the same + // frozen value for the other 68, so the number below cannot move by the + // change of arm alone. + const engine = await instantiate(); const w = WINDOWS[0] as Window; - const a = tsFields(SLIDERS[0] as Sliders, w); - const b = tsFields(SLIDERS[1] as Sliders, w); + const foldsAt = (s: Sliders): bigint[] => { + const len = writeRequest(engine, request(s, w)); + return FIELD_NAMES.map((_, field) => u64(engine.checksum_vulcanus(len, field))); + }; + const a = foldsAt(SLIDERS[0] as Sliders); + const b = foldsAt(SLIDERS[1] as Sliders); let differing = 0; for (const [i] of FIELD_NAMES.entries()) { - if (foldAll(a[i] as number[]) !== foldAll(b[i] as number[])) differing++; + if (a[i] !== b[i]) differing++; } // Frozen at the measured 50 of 74, like every other count in this file, // rather than left as the floor it started as. The argument for a floor was @@ -644,15 +694,21 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" expect(differing).toBe(50); }, 300000); - it("the two windows are different regimes, so running both says something", () => { + it("the two windows are different regimes, so running both says something", async () => { // Anti-vacuity for the geometry. The spawn window exists to reach fields the // far window saturates; if the two folded alike, one of them is redundant. + // Read from the engine for the same reason as the slider guard above. + const engine = await instantiate(); const s = SLIDERS[0] as Sliders; - const a = tsFields(s, WINDOWS[0] as Window); - const b = tsFields(s, WINDOWS[1] as Window); + const foldsIn = (w: Window): bigint[] => { + const len = writeRequest(engine, request(s, w)); + return FIELD_NAMES.map((_, field) => u64(engine.checksum_vulcanus(len, field))); + }; + const a = foldsIn(WINDOWS[0] as Window); + const b = foldsIn(WINDOWS[1] as Window); let differing = 0; for (const [i] of FIELD_NAMES.entries()) { - if (foldAll(a[i] as number[]) !== foldAll(b[i] as number[])) differing++; + if (a[i] !== b[i]) differing++; } // EVERY field, measured - not a floor. The two windows share no fold at all, // which is the strongest form this guard can take. @@ -673,6 +729,15 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" } }, 300000); + it("withholds a TypeScript arm for exactly the six fields #227 deleted", () => { + // Without this, a seventh field quietly losing its reference implementation + // would downgrade that field to a frozen-only check and nothing would say + // so. `NO_TS_ARM` is the claim; this is what makes it one. + const ts = tsFields(SLIDERS[0] as Sliders, WINDOWS[0] as Window); + const withheld = FIELD_NAMES.filter((_, field) => ts[field] === undefined); + expect(withheld).toEqual(NO_TS_ARM); + }, 300000); + it("the sweep places several different tiles, so the argmax fold is not one constant", () => { // Anti-vacuity for the last field, and for the 19 probabilities behind it: a // window that resolved to one tile everywhere would agree between the ports diff --git a/test/wasmVulcanusRenderParity.spec.ts b/test/wasmVulcanusRenderParity.spec.ts index 0b420941..93f49518 100644 --- a/test/wasmVulcanusRenderParity.spec.ts +++ b/test/wasmVulcanusRenderParity.spec.ts @@ -21,6 +21,7 @@ import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; import { ROCK_MAP_COLOR } from "../src/noise/rocks/rockCatalog"; import { VULCANUS_RESOURCE_CATALOG } from "../src/noise/resources/vulcanusResourceCatalog"; import { + ENGINE_REQUIRED, runRenderRequest, type ElevationRenderRequest, } from "../src/noise/preview/elevationRenderRequest"; @@ -52,11 +53,11 @@ const SIZE = 1024; /** * The tier-3 freeze section for this spec. See `tier3Frozen.ts`. * - * Every Rust-against-TypeScript render below is ALSO checked against a frozen - * checksum, so the assertion survives #227 deleting the TypeScript arm. Without - * it, `runRenderRequest(req)` with the engine left off stops being an - * independent arm the moment the Vulcanus branch goes, and the comparison would - * pass while grading nothing. + * Every render below is checked against a frozen checksum. #227 has now + * deleted the TypeScript arm, so that is the only check left: with the Vulcanus + * branch gone, `runRenderRequest(req)` with the engine left off no longer + * returns a second opinion, it refuses. Had the freeze not landed first, the + * comparison would have passed while grading nothing. */ const SECTION = "vulcanus:render"; @@ -77,8 +78,18 @@ expectRecordedRows(SECTION, ROWS); afterAll(flushRecording); /** Freeze one render, and compare the two arms while both exist. */ -function freeze(label: string, name: string, wasm: ArrayLike, ts: ArrayLike): void { - expectFrozen(SECTION, label, name, foldPixels(wasm), foldPixels(ts)); +/** + * `ts` is omitted where #227 deleted the TypeScript renderer this block used to + * compare against - see `tier3Frozen.ts`. The engine's fold is still graded + * against the frozen value captured while the two demonstrably agreed. + */ +function freeze( + label: string, + name: string, + wasm: ArrayLike, + ts?: ArrayLike, +): void { + expectFrozen(SECTION, label, name, foldPixels(wasm), ts && foldPixels(ts)); } /** `surfaceSeedForPlanet("vulcanus", 123456)`. */ @@ -208,16 +219,14 @@ function request(w: Window): ElevationRenderRequest { }; } -describe("the WASM engine renders Vulcanus terrain exactly as the TypeScript does", () => { - it("is byte-identical across four windows", async () => { +describe("the WASM engine renders Vulcanus terrain to its frozen bytes", () => { + it("matches its frozen bytes across four windows", async () => { const e = await engine(); for (const w of WINDOWS) { const req = request(w); const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "terrain", wasm, ts); + freeze(w.label, "terrain", wasm); } }, 300000); @@ -260,11 +269,20 @@ describe("the WASM engine renders Vulcanus terrain exactly as the TypeScript doe for (const view of ["terrain", "cliffs", "rocks", "resources", "all"] as const) { const composite = { ...request(w), view }; const withEngine = new Uint8ClampedArray(runRenderRequest(composite, e).buffer); - const withoutEngine = new Uint8ClampedArray(runRenderRequest(composite).buffer); - expect(Array.from(withEngine), `${view}: engine vs none`).toEqual(Array.from(withoutEngine)); + + // This used to render the same request twice - once with the engine and + // once without - and assert the two agreed. #227 makes that the sharper + // statement it was always standing in for: with no TypeScript left to + // fall back to, a view that reaches the engine is exactly a view that + // REFUSES to render without one. A view that quietly returned pixels here + // would be a view the module still serves off some other path. + expect(() => runRenderRequest(composite), `${view}: must need the engine`).toThrow( + ENGINE_REQUIRED, + ); + // Prefixed: this block sweeps WINDOWS[0] through every view, so a bare // window label would collide with the per-view blocks below. - freeze(`routes ${view}`, view, withEngine, withoutEngine); + freeze(`routes ${view}`, view, withEngine); } // And `all` really does differ from bare terrain here, so the assertion @@ -303,18 +321,16 @@ const isColor = (px: Uint8ClampedArray, i: number, c: readonly number[]): boolea * deterministic, plausible, and wrong at every tile. Byte-identity against the * TypeScript is what says the stream and the greedy collision pass agree. */ -describe("the WASM engine renders the Vulcanus rock overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Vulcanus rock overlay to its frozen bytes", () => { const rockRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "rocks" }); - it("is byte-identical across four windows", async () => { + it("matches its frozen bytes across four windows", async () => { const e = await engine(); for (const w of WINDOWS) { const req = rockRequest(w); const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "rocks", wasm, ts); + freeze(w.label, "rocks", wasm); } }, 300000); @@ -415,7 +431,7 @@ describe("the WASM engine renders the Vulcanus rock overlay exactly as the TypeS * geysers, which is why it is here at all - it is the one window that grades * the rolled pass. */ -describe("the WASM engine renders the Vulcanus resource overlay exactly as the TypeScript does", () => { +describe("the WASM engine renders the Vulcanus resource overlay to its frozen bytes", () => { const ORE_WINDOWS: Window[] = [ { label: "square on a coal patch", @@ -481,15 +497,13 @@ describe("the WASM engine renders the Vulcanus resource overlay exactly as the T tilesPerPixel: w.tilesPerPixel, }); - it("is byte-identical across five windows", async () => { + it("matches its frozen bytes across five windows", async () => { const e = await engine(); for (const w of ORE_WINDOWS) { const req = oreRequest(w); const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "resources", wasm, ts); + freeze(w.label, "resources", wasm); } }, 300000); @@ -580,18 +594,16 @@ describe("the WASM engine renders the Vulcanus resource overlay exactly as the T * overlay shares the whole field DAG below the tile argmax, so splitting them * would build that chain four times. */ -describe("the WASM engine renders the Vulcanus composite exactly as the TypeScript does", () => { +describe("the WASM engine renders the Vulcanus composite to its frozen bytes", () => { const allRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "all" }); - it("is byte-identical across four windows", async () => { + it("matches its frozen bytes across four windows", async () => { const e = await engine(); const counts: number[] = []; for (const w of WINDOWS) { const req = allRequest(w); const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "all", wasm, ts); + freeze(w.label, "all", wasm); counts.push(paintedOver(wasm, new Uint8ClampedArray(runRenderRequest(request(w), e).buffer))); } expect(counts).toEqual(ALL_PIXELS_PER_WINDOW); @@ -657,21 +669,19 @@ describe("the WASM engine renders the Vulcanus composite exactly as the TypeScri * to draw on its own, and the two passes share the whole field DAG below the * tile argmax, which splitting would build twice. */ -describe("the WASM engine renders Vulcanus cliffs exactly as the TypeScript does", () => { +describe("the WASM engine renders Vulcanus cliffs to its frozen bytes", () => { const cliffRequest = (w: Window): ElevationRenderRequest => ({ ...request(w), view: "cliffs", }); - it("is byte-identical across four windows", async () => { + it("matches its frozen bytes across four windows", async () => { const e = await engine(); for (const w of WINDOWS) { const req = cliffRequest(w); const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); - const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); - expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); - freeze(w.label, "cliffs", wasm, ts); + freeze(w.label, "cliffs", wasm); } }, 300000);