From 46930ac13b4d9f3655dd891817397d7a88cf88ab Mon Sep 17 00:00:00 2001 From: Eric J Date: Mon, 7 Sep 2026 15:55:21 -0700 Subject: [PATCH] Wire the Vulcanus volcanism sliders into the preview render request The engine has read volcanismFrequency and volcanismSize since phase 5 (#225), and wasmVulcanusParity.spec.ts sweeps both moved. But renderVulcanusThroughWasm wrote a literal 1 for each, so the Terrain tab's "Vulcanus volcanism" slider did nothing on the preview while every render spec stayed green. Same gap the Fulgora islands pair had, for the same reason: slider_rescale(1, 3) is exactly 1, so at the default both terms vanish and a render that ignores the control looks right. elevationCtxFromPreset now reads autoplaceControls.vulcanus_volcanism (default 1/1), the panel passes it as vulcanusVolcanismControls, and the dispatch reads it with `?? 1`. No Rust changed, so engine.wasm is untouched, and all 26 frozen vulcanus:render rows still match. They pass no volcanism field, so they are the default-unchanged guard. test/vulcanusVolcanismDispatch.spec.ts plants a moved slider. Its first draft stayed red AFTER the wiring, because it graded on the parity spec's "square at origin" window, 64 tiles across at 1 tile/px, which sits entirely inside the starting area where volcanism reaches nothing: 0 of 4096 pixels move for either lever. On the "wide, offset" window 1828 of 2304 move for frequency 2 and 1813 for size 3. The spec keeps the origin window as a control frozen at zero, and holds the neutral render on the wide window to that window's frozen tier-3 row. Why now: #84's remaining residual (21 wrong orientations, 22 surplus, 6 missing of 1569) needs a lever that moves the elevation input to the cliff rule while holding the rule fixed. Volcanism is that lever, and the preview could not exercise it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EadT1PyhSjDbjJwvLfo3zr --- src/components/ElevationPreviewPanel.vue | 1 + src/model/elevationPreviewCtx.ts | 17 +++ src/noise/preview/elevationRenderRequest.ts | 20 ++- test/elevationPreviewCtx.spec.ts | 9 ++ test/elevationPreviewPanel.spec.ts | 20 +++ test/vulcanusVolcanismDispatch.spec.ts | 161 ++++++++++++++++++++ 6 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 test/vulcanusVolcanismDispatch.spec.ts diff --git a/src/components/ElevationPreviewPanel.vue b/src/components/ElevationPreviewPanel.vue index a8e9efdc..a04717d8 100644 --- a/src/components/ElevationPreviewPanel.vue +++ b/src/components/ElevationPreviewPanel.vue @@ -231,6 +231,7 @@ async function generate() { treeControls: info.treeControls, rockControls: info.rockControls, fulgoraIslandControls: info.fulgoraIslandControls, + vulcanusVolcanismControls: info.vulcanusVolcanismControls, }, (tile) => { g.putImageData( diff --git a/src/model/elevationPreviewCtx.ts b/src/model/elevationPreviewCtx.ts index 372928fb..611b2eff 100644 --- a/src/model/elevationPreviewCtx.ts +++ b/src/model/elevationPreviewCtx.ts @@ -78,6 +78,19 @@ export interface ElevationPreviewCtx { * `fulgora_natural`'s scaling term vanishes). */ fulgoraIslandControls: { readonly frequency: number; readonly size: number }; + /** + * The `vulcanus_volcanism` autoplace control's frequency/size - consumed only + * when the selected planet is Vulcanus. Defaults to `{ frequency: 1, size: 1 }`. + * + * Neither slider touches the cliff rule or the tile catalog directly: + * frequency is the input scale of the mountain and crack noise, and size sets + * the volcano spot radius, spacing and density - so both move the elevation + * contours every other Vulcanus field sits on. Like the Fulgora pair, each + * defaults to the one value that hides itself: `slider_rescale(1, 3)` is + * exactly 1, so at the default both terms vanish and a render that ignored + * the control would look right until the slider moved. + */ + vulcanusVolcanismControls: { readonly frequency: number; readonly size: number }; /** * Non-seed free variables for renderElevation/renderTerrain * (Omit<..., "seed0">-compatible). The climate fields (aux/moisture @@ -132,6 +145,7 @@ export function elevationCtxFromPreset(preset: Preset): ElevationPreviewCtx { const tc = preset.autoplaceControls.trees; const rk = preset.autoplaceControls.rocks; const fi = preset.autoplaceControls.fulgora_islands; + const vv = preset.autoplaceControls.vulcanus_volcanism; return { supported, mapType, @@ -152,6 +166,9 @@ export function elevationCtxFromPreset(preset: Preset): ElevationPreviewCtx { fulgoraIslandControls: fi ? { frequency: fi.frequency, size: fi.size } : { frequency: 1, size: 1 }, + vulcanusVolcanismControls: vv + ? { frequency: vv.frequency, size: vv.size } + : { frequency: 1, size: 1 }, ctx: { waterLevel: 10 * Math.log2(size), segmentationMultiplier: water?.frequency ?? 1, diff --git a/src/noise/preview/elevationRenderRequest.ts b/src/noise/preview/elevationRenderRequest.ts index a8aa713d..a7d0870d 100644 --- a/src/noise/preview/elevationRenderRequest.ts +++ b/src/noise/preview/elevationRenderRequest.ts @@ -137,6 +137,19 @@ export interface ElevationRenderRequest { * renderer reads the levers the game does rather than hardcoding neutral. */ fulgoraScrapControls?: FulgoraScrapControls; + /** + * The `vulcanus_volcanism` autoplace control's frequency/size + * (`control:vulcanus_volcanism:*`) - consumed only when `planet: "vulcanus"`. + * Defaults to `{ frequency: 1, size: 1 }`, the game's own neutral position. + * + * The engine has read both since phase 5; what was missing was this field. + * `renderVulcanusThroughWasm` wrote a literal 1 for each, so the Terrain + * tab's slider was inert on the preview while every render spec stayed + * green - the Fulgora islands story over again, and for the same reason: + * `slider_rescale(1, 3)` is exactly 1, so at the default both terms vanish. + * `test/vulcanusVolcanismDispatch.spec.ts` plants a moved slider. + */ + vulcanusVolcanismControls?: { readonly frequency?: number; readonly size?: number }; /** * The enemy-base autoplace control's frequency/size (control:enemy-base:*) - * consumed only when `view: "enemies"`. Defaults to `{ frequency: 1, size: 1 }` @@ -348,8 +361,11 @@ function renderVulcanusThroughWasm( originX: req.originX, originY: req.originY, tilesPerPixel: req.tilesPerPixel, - volcanismFrequency: 1, - volcanismSize: 1, + // Defaulted to the game's neutral 1 here, the same way the Fulgora islands + // pair is below. These were literal 1s until the slider was wired - see + // the field's doc on the request type. + volcanismFrequency: req.vulcanusVolcanismControls?.frequency ?? 1, + volcanismSize: req.vulcanusVolcanismControls?.size ?? 1, temperatureBias: 0, tungstenOre: levers(controls?.tungstenOre), vulcanusCoal: levers(controls?.vulcanusCoal), diff --git a/test/elevationPreviewCtx.spec.ts b/test/elevationPreviewCtx.spec.ts index 9979ca99..a63dbdea 100644 --- a/test/elevationPreviewCtx.spec.ts +++ b/test/elevationPreviewCtx.spec.ts @@ -117,6 +117,15 @@ describe("elevationCtxFromPreset", () => { expect(elevationCtxFromPreset(p).treeControls).toEqual({ frequency: 3, size: 2 }); }); + it("reads vulcanus_volcanism frequency/size, defaulting to 1/1 when absent", () => { + const p = lakesPreset(); + delete p.autoplaceControls.vulcanus_volcanism; + expect(elevationCtxFromPreset(p).vulcanusVolcanismControls).toEqual({ frequency: 1, size: 1 }); + + p.autoplaceControls.vulcanus_volcanism = { frequency: 2, size: 3, richness: 1 }; + expect(elevationCtxFromPreset(p).vulcanusVolcanismControls).toEqual({ frequency: 2, size: 3 }); + }); + it("exposes cliffSettings from preset.cliffSettings", () => { const p = lakesPreset(); // A COMPLETE CliffSettings - name/control/cliffSmoothing carry the Default diff --git a/test/elevationPreviewPanel.spec.ts b/test/elevationPreviewPanel.spec.ts index 48e2a8d9..c1a494e0 100644 --- a/test/elevationPreviewPanel.spec.ts +++ b/test/elevationPreviewPanel.spec.ts @@ -608,6 +608,26 @@ describe("ElevationPreviewPanel", () => { }); }); + it("passes the preset's vulcanus_volcanism levers on Generate for Vulcanus", async () => { + // Non-default values on purpose: the request layer used to write a + // literal 1 for both, and a default preset cannot tell that from a + // wired slider. + stubCanvas(); + const renderer = okRenderer(); + const w = setup("nauvis", renderer, { planet: "vulcanus" }); + usePresetsStore().activePreset!.autoplaceControls.vulcanus_volcanism = { + frequency: 2, + size: 3, + richness: 1, + }; + + await w.find('[data-test="generate"]').trigger("click"); + await flushPromises(); + + const arg = (renderer.render as ReturnType).mock.calls[0][0]; + expect(arg.vulcanusVolcanismControls).toEqual({ frequency: 2, size: 3 }); + }); + it("enables the Terrain toggle for Vulcanus even on a non-Nauvis map type", async () => { const w = setup("lakes", okRenderer(), { planet: "vulcanus" }); expect(w.find('[data-test="view-terrain"]').attributes("disabled")).toBeUndefined(); diff --git a/test/vulcanusVolcanismDispatch.spec.ts b/test/vulcanusVolcanismDispatch.spec.ts new file mode 100644 index 00000000..bffb91e5 --- /dev/null +++ b/test/vulcanusVolcanismDispatch.spec.ts @@ -0,0 +1,161 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +import { surfaceSeedForPlanet } from "../src/model/planetSurfaceSeed"; +import { + runRenderRequest, + type ElevationRenderRequest, +} from "../src/noise/preview/elevationRenderRequest"; +import { compileEngine, instantiateEngine, type EngineExports } from "../src/noise/wasm/engine"; +import { foldPixels, frozen } from "./tier3Frozen"; + +/** + * The request dispatch for the Vulcanus volcanism control, tested separately + * from the engine. + * + * The engine has read `volcanismFrequency` and `volcanismSize` since phase 5, + * and `test/wasmVulcanusParity.spec.ts` sweeps both moved. What nothing graded + * was the line in between: `renderVulcanusThroughWasm` wrote a literal `1` for + * each, so the Terrain tab's slider was inert on the preview while every + * render spec stayed green - the same gap the Fulgora islands block in + * `fulgoraSurfaceSeed.spec.ts` was written for. A lever that silently does + * nothing is exactly the failure the request layer can hide, and 1 is the one + * value that hides it: `slider_rescale(1, 3)` is exactly 1, so at the default + * both terms vanish. + * + * **The window must contain the thing it grades.** The first draft of this + * spec used the `square at origin` window - 64 tiles across at 1 tile per + * pixel - and stayed red AFTER the slider was wired, because that whole square + * is inside the starting area, where the volcano spots are excluded and the + * mountain noise is flattened. Measured 2026-09-07 on the wired code, pixels + * moved out of the window when a lever is taken off 1: + * + * | window (parity spec's) | frequency 2 | size 3 | + * | -------------------------- | ----------: | ----------: | + * | square at origin, 1 px/tile | 0/4096 | 0/4096 | + * | wide, offset, 1 px/tile | 1828/2304 | 1813/2304 | + * | tall, coarse, 8 tiles/px | 1583/2304 | 698/2304 | + * | fine, far field, 0.5 t/px | 1214/1536 | 1300/1536 | + * + * So a spec on the origin window passes a hardcoded 1, which is the defect it + * exists to catch. `WIDE_OFFSET` below is the parity spec's second window, + * field for field, so the neutral arm can be held to that spec's frozen row. + */ +let engine: EngineExports; +beforeAll(async () => { + const wasmPath = join(import.meta.dirname, "..", "src", "noise", "wasm", "engine.wasm"); + engine = await instantiateEngine(await compileEngine(readFileSync(wasmPath))); +}); + +const SEED0 = surfaceSeedForPlanet("vulcanus", 123456); + +const COMMON = { + id: 1, + planet: "vulcanus", + view: "terrain", + seed0: SEED0, + waterLevel: 0, + segmentationMultiplier: 1, + startingPositions: [{ x: 0, y: 0 }], +} as const satisfies Partial; + +/** `wasmVulcanusRenderParity.spec.ts`'s `wide, offset` window, field for field. */ +const WIDE_OFFSET: ElevationRenderRequest = { + ...COMMON, + width: 96, + height: 24, + originX: 512.5, + originY: -1024.25, + tilesPerPixel: 1, +}; + +/** That spec's `square at origin` window - the one the slider cannot reach. */ +const SQUARE_AT_ORIGIN: ElevationRenderRequest = { + ...COMMON, + width: 64, + height: 64, + originX: -32, + originY: -32, + tilesPerPixel: 1, +}; + +const NEUTRAL = { frequency: 1, size: 1 } as const; + +function render(req: ElevationRenderRequest): Uint8ClampedArray { + return new Uint8ClampedArray(runRenderRequest(req, engine).buffer); +} + +/** Pixels whose RGB differ between two renders of the same window. */ +function pixelsMoved(a: Uint8ClampedArray, b: Uint8ClampedArray): number { + expect(b.length).toBe(a.length); + let moved = 0; + for (let i = 0; i < a.length; i += 4) { + if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2]) moved++; + } + return moved; +} + +describe("vulcanus volcanism dispatch", () => { + it("threads control:vulcanus_volcanism:frequency through to the render", () => { + const neutral = render({ ...WIDE_OFFSET, vulcanusVolcanismControls: NEUTRAL }); + const moved = render({ ...WIDE_OFFSET, vulcanusVolcanismControls: { frequency: 2, size: 1 } }); + // Most of the window, not a sliver: 1828 of 2304 measured. The bound is + // loose on purpose - the claim is that the lever reaches the render, not + // any particular count. + expect(pixelsMoved(neutral, moved)).toBeGreaterThan(1000); + }); + + it("threads control:vulcanus_volcanism:size through to the render", () => { + const neutral = render({ ...WIDE_OFFSET, vulcanusVolcanismControls: NEUTRAL }); + const moved = render({ ...WIDE_OFFSET, vulcanusVolcanismControls: { frequency: 1, size: 3 } }); + // 1813 of 2304 measured. + expect(pixelsMoved(neutral, moved)).toBeGreaterThan(1000); + }); + + it("omitting the control equals passing the neutral pair", () => { + // So the default really is the game's neutral position, not "unset". + const omitted = render(WIDE_OFFSET); + const neutral = render({ ...WIDE_OFFSET, vulcanusVolcanismControls: NEUTRAL }); + expect(pixelsMoved(omitted, neutral)).toBe(0); + }); + + it("the neutral pair renders the bytes frozen before the control was wired", () => { + // The tier-3 row was recorded while the request layer wrote a literal 1 + // for both levers. Wiring the slider must not move a default render; if + // this row moves, the default is no longer the game's neutral position. + const want = frozen("vulcanus:render", "wide, offset", "terrain"); + expect(want).toBeDefined(); + const neutral = render({ ...WIDE_OFFSET, vulcanusVolcanismControls: NEUTRAL }); + expect(foldPixels(neutral)).toBe(want); + }); + + it("the cliffs view moves with the slider too", () => { + // Volcanism moves the elevation contours the cliff bands sit on - the + // lever #84 wants for separating an elevation-side residual from a + // placement-side one - so the cliffs view has to see it as well as terrain. + const neutral = render({ ...WIDE_OFFSET, view: "cliffs" }); + const moved = render({ + ...WIDE_OFFSET, + view: "cliffs", + vulcanusVolcanismControls: { frequency: 2, size: 3 }, + }); + expect(pixelsMoved(neutral, moved)).toBeGreaterThan(1000); + }); + + it("the square at the origin cannot see the slider - the control for the window choice", () => { + // Deliberately frozen at ZERO. This is the window the first draft graded + // on, and the reason the wired code still read as unwired. If it ever + // moves, volcanism has started reaching the starting area, which is a + // finding about the engine rather than about this spec. + const neutral = render(SQUARE_AT_ORIGIN); + for (const c of [ + { frequency: 2, size: 1 }, + { frequency: 1, size: 3 }, + ]) { + const moved = render({ ...SQUARE_AT_ORIGIN, vulcanusVolcanismControls: c }); + expect(pixelsMoved(neutral, moved), JSON.stringify(c)).toBe(0); + } + }); +});