diff --git a/src/components/ElevationPreviewPanel.vue b/src/components/ElevationPreviewPanel.vue index a8e9efd..a04717d 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 372928f..611b2ef 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 a8aa713..a7d0870 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 9979ca9..a63dbde 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 48e2a8d..c1a494e 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 0000000..bffb91e --- /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); + } + }); +});