From 1126f3fae0fdaa5e2b38e74b7dd0a04e87a1dc3f Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 30 Aug 2026 19:34:29 -0700 Subject: [PATCH] Delete the ported TypeScript under src/noise/ (#227) Phase 7's deletion. The app has rendered from WASM since #362, so the TypeScript math under `src/noise/` has no live caller left. This removes it and rewires the twelve specs that were still reading it. 42 source files go, not the 43 in `deadlist.txt`: `src/noise/resources/vulcanusResourceCatalog.ts` stays and is trimmed in place, 223 lines to 113. Its two math functions (`makeVulcanusOreFootprint`, `sulfuricAcidGeyserProbability`), the placement threshold and the three closure-valued fields are all owned by Rust now; what is left is the order and the map colours, which `wasmVulcanusRenderParity` grades the engine's pixels against. #227's done-when is that `src/noise/` holds orchestration and catalogs only, and that file is now a catalog. It has no imports at all. `src/noise/preview/elevationRenderRequest.ts` loses its TypeScript fallback arms, 309 lines out and 97 in. The four views that used to fall through to plain TypeScript terrain were normalised onto their planet's terrain code in #362, and the error path and the `startingLakePositions` guard landed in #365, so nothing reaches the fallback any more. ## The parity specs are converted, not deleted `tier2Frozen.ts` and `tier3Frozen.ts` were built in #345 and #360 for exactly this step: `expectFrozen` takes its reference arm as an optional last argument, so a spec whose TypeScript is gone drops that argument and keeps grading the engine against a value captured while the two ports demonstrably agreed. Deleting the specs instead would take the port's arithmetic out of `wasm32-unknown-unknown` altogether, which is the one thing `cargo test` cannot cover and the reason the freeze exists. Tier 2: 68 of Vulcanus's 74 fields keep both arms, and the six whose reference implementation went (`geyserProbability`, `cliffinessBasic`, `decorativeKnockout`, `rockHuge`, `rockBig`, `rockDensity`) drop to frozen-only. `NO_TS_ARM` names them and a new test asserts the list matches what `tsFields` actually withholds, so a seventh field losing its arm fails rather than downgrades quietly. Nauvis loses its arm entirely - its whole expression core was in the dead set. Tier 3 needed the same treatment and the notes had cleared it. Those specs have no dead-set imports, which is what was checked; they get their reference arm by calling `runRenderRequest(req)` with the engine argument left off, which the rewrite above now refuses. That was 76 failing tests across seven files, and `tier3Frozen.ts` had written down the trap in advance: after the deletion the no-engine call "is not a weaker arm, it is the SAME arm - so the comparison would pass while grading nothing". `test/tiledEquality.spec.ts` is the one that changes character rather than shrinking. It passed no engine at all, so it graded the TypeScript renderers and could not see the WASM gate; it now runs every render through the engine, which closes a gap the #227 notes had already identified. ## Anti-vacuity guards were re-based, not dropped, wherever the claim survived Vulcanus's slider guard (still 50 of 74 fields) and window guard (still all 74) now read the engine; the counts are unchanged because every field with both arms is pinned to the same frozen value. The off-grid sweep's "these really are different points" check moved the same way. The routing tests in both render specs became the sharper statement they always stood in for: with no fallback left, a view that reaches the engine is exactly a view that REFUSES to render without one. Two of those rewrites failed on the first run, and both were findings rather than test bugs. Recorded where they were measured: - `checksum_pow` takes its exponent as an `f32`, so the 2.0000001 that used to prove the dispatcher leaves the squaring branch narrows to exactly 2. The perturbation is now one f32 ULP, asserted to survive the boundary. - `checksum_distance_from_nearest_point` folds `f64::from(distance_from_nearest_point(...))`, and that function returns an `f32`, so an f64 ULP on the cap comes back as the same number. Three tests flipped because they said in their own comments that they should. `viewNormalisation`'s last block was labelled "the arm #227 deletes"; `elevationRenderRequest.spec`'s KNOWN HOLE row asked to "flip to asserting the refusal" if `runRenderRequest` ever refused a Nauvis-only view on another planet, which is what the rewrite does; and `wasmNauvisRenderParity`'s ABI-cap row said it "belongs to the carve-out, and the #227 deletion removes both together". The cap is now a refusal, with a companion case at eight points so the refusal is about the list's length rather than its presence. Seven Nauvis tier-2 guards could not be re-based: each counts how many swept positions satisfy a predicate, and the export returns an order-sensitive fold that cannot be decomposed back into counts. They are deleted here and their frozen numbers recorded in a follow-up issue, so restoring them behind a predicate-counting export re-measures rather than re-derives. --- src/noise/cliffs/cliffFields.ts | 139 ---- src/noise/cliffs/cliffPlacement.ts | 651 ----------------- src/noise/cliffs/vulcanusCliffFields.ts | 156 ----- src/noise/cliffs/vulcanusOreRejection.ts | 288 -------- src/noise/enemies/enemyBaseField.ts | 137 ---- src/noise/expressions/aux.ts | 49 -- src/noise/expressions/elevationIsland.ts | 39 -- src/noise/expressions/elevationLakes.ts | 155 ---- src/noise/expressions/elevationNauvis.ts | 170 ----- src/noise/expressions/moisture.ts | 90 --- src/noise/expressions/nauvisShared.ts | 155 ---- src/noise/expressions/temperature.ts | 41 -- src/noise/preview/elevationRenderRequest.ts | 406 +++-------- src/noise/preview/renderCliffs.ts | 210 ------ src/noise/preview/renderElevation.ts | 63 -- src/noise/preview/renderEnemies.ts | 323 --------- src/noise/preview/renderResources.ts | 388 ---------- src/noise/preview/renderRocks.ts | 266 ------- src/noise/preview/renderTerrain.ts | 119 ---- src/noise/preview/renderTrees.ts | 131 ---- src/noise/preview/renderVulcanusCliffs.ts | 120 ---- src/noise/preview/renderVulcanusResources.ts | 319 --------- src/noise/preview/renderVulcanusRocks.ts | 209 ------ src/noise/preview/renderVulcanusTerrain.ts | 78 --- src/noise/quickMultioctaveNoise.ts | 341 --------- src/noise/randomPenalty.ts | 124 ---- src/noise/resources/regularPatches.ts | 197 ------ src/noise/resources/resolveResource.ts | 128 ---- src/noise/resources/resourceMath.ts | 195 ------ src/noise/resources/resourcePatches.ts | 117 ---- src/noise/resources/startingPatches.ts | 192 ----- .../resources/vulcanusResourceCatalog.ts | 192 ++--- src/noise/rocks/rockField.ts | 152 ---- src/noise/rocks/vulcanusRockField.ts | 125 ---- src/noise/startingLakes.ts | 59 -- src/noise/tiles/catalog.ts | 247 ------- src/noise/tiles/expressionInRange.ts | 54 -- src/noise/tiles/helpers.ts | 76 -- src/noise/tiles/resolve.ts | 88 --- src/noise/trees/asymmetricRamps.ts | 21 - src/noise/trees/treeCatalog.ts | 238 ------- src/noise/trees/treeField.ts | 254 ------- src/noise/trees/treeShared.ts | 65 -- .../variablePersistenceMultioctaveNoise.ts | 223 ------ test/elevationRenderRequest.spec.ts | 212 +++--- test/fulgoraSurfaceSeed.spec.ts | 13 +- test/tiledEquality.spec.ts | 44 +- test/viewNormalisation.spec.ts | 28 +- test/vulcanusStackCache.spec.ts | 109 --- test/wasmElevationRenderParity.spec.ts | 87 +-- test/wasmEvalParity.spec.ts | 34 +- test/wasmMultioctaveParity.spec.ts | 54 +- test/wasmNauvisParity.spec.ts | 660 +----------------- test/wasmNauvisRenderParity.spec.ts | 211 +++--- test/wasmPrimitiveParity.spec.ts | 175 ++--- test/wasmVulcanusParity.spec.ts | 145 ++-- test/wasmVulcanusRenderParity.spec.ts | 80 ++- 57 files changed, 758 insertions(+), 8884 deletions(-) delete mode 100644 src/noise/cliffs/cliffFields.ts delete mode 100644 src/noise/cliffs/cliffPlacement.ts delete mode 100644 src/noise/cliffs/vulcanusCliffFields.ts delete mode 100644 src/noise/cliffs/vulcanusOreRejection.ts delete mode 100644 src/noise/enemies/enemyBaseField.ts delete mode 100644 src/noise/expressions/aux.ts delete mode 100644 src/noise/expressions/elevationIsland.ts delete mode 100644 src/noise/expressions/elevationLakes.ts delete mode 100644 src/noise/expressions/elevationNauvis.ts delete mode 100644 src/noise/expressions/moisture.ts delete mode 100644 src/noise/expressions/nauvisShared.ts delete mode 100644 src/noise/expressions/temperature.ts delete mode 100644 src/noise/preview/renderCliffs.ts delete mode 100644 src/noise/preview/renderElevation.ts delete mode 100644 src/noise/preview/renderEnemies.ts delete mode 100644 src/noise/preview/renderResources.ts delete mode 100644 src/noise/preview/renderRocks.ts delete mode 100644 src/noise/preview/renderTerrain.ts delete mode 100644 src/noise/preview/renderTrees.ts delete mode 100644 src/noise/preview/renderVulcanusCliffs.ts delete mode 100644 src/noise/preview/renderVulcanusResources.ts delete mode 100644 src/noise/preview/renderVulcanusRocks.ts delete mode 100644 src/noise/preview/renderVulcanusTerrain.ts delete mode 100644 src/noise/quickMultioctaveNoise.ts delete mode 100644 src/noise/randomPenalty.ts delete mode 100644 src/noise/resources/regularPatches.ts delete mode 100644 src/noise/resources/resolveResource.ts delete mode 100644 src/noise/resources/resourceMath.ts delete mode 100644 src/noise/resources/resourcePatches.ts delete mode 100644 src/noise/resources/startingPatches.ts delete mode 100644 src/noise/rocks/rockField.ts delete mode 100644 src/noise/rocks/vulcanusRockField.ts delete mode 100644 src/noise/startingLakes.ts delete mode 100644 src/noise/tiles/catalog.ts delete mode 100644 src/noise/tiles/expressionInRange.ts delete mode 100644 src/noise/tiles/helpers.ts delete mode 100644 src/noise/tiles/resolve.ts delete mode 100644 src/noise/trees/asymmetricRamps.ts delete mode 100644 src/noise/trees/treeCatalog.ts delete mode 100644 src/noise/trees/treeField.ts delete mode 100644 src/noise/trees/treeShared.ts delete mode 100644 src/noise/variablePersistenceMultioctaveNoise.ts delete mode 100644 test/vulcanusStackCache.spec.ts 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);