Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,8 @@ when this file does not. Get it with `shasum -a 256 src/noise/wasm/engine.wasm`.
| 4 (#224) | the rest of Fulgora: masks, roads, ruins, scrap, the tile catalog and `fulgora_stack` | done |
| 5 (#225) | Vulcanus end to end - terrain, cliffs, rocks, resources. **Every Vulcanus view the panel offers renders through the engine** (not `elevation` - see below). | done |
| 6 (#226) | Nauvis - every expression, the TERRAIN render, all FIVE overlays and the `all` composite. The `elevation` view is ported too, as of #227 | done |
| 7 (#227) | delete the ported TypeScript under `src/noise/` | done |
| 8 (#363) | Fulgora's `resources` and `all` composites, so **every planet's DEFAULT view renders through the engine** | done |

Phase 6 has ported every Nauvis _expression_: `nauvis_shared`,
`elevation_lakes` (which also yields `elevation_island` - the same tree at
Expand Down Expand Up @@ -1789,13 +1791,45 @@ below. What stays here is the part that is a RULE rather than a record.

**The request layout is at v2 and is per-planet.** A 56-byte common prefix
declares `params_bytes`, then a per-planet block follows. Fulgora's request is
104 bytes; Vulcanus's is 368.
120 bytes; Vulcanus's is 368; Nauvis's is 568 and is the largest, so
`REQUEST_BYTES` is Nauvis's.

**A planet block can grow with NO version bump, and that is the split working.**
The Vulcanus block has grown three times (248 -> 280 -> 312 -> 368) and
Fulgora's has not moved a byte. `BadParamsLength` refuses a writer whose
declared length disagrees. **A version bump is for a change to the COMMON
prefix**, which every planet reads.
The Vulcanus block has grown three times (248 -> 280 -> 312 -> 368), and
Fulgora's grew for the first time in #363 (48 -> 64, so the request went 104 -> 120) when the scrap overlay needed `control:scrap:frequency` and `:size` to
cross. `BadParamsLength` refuses a writer whose declared length disagrees. **A
version bump is for a change to the COMMON prefix**, which every planet reads.

**The Fulgora block is append-only, and the two scrap sliders sit AFTER the
trig rather than beside the two island sliders.** Grouping them with the other
controls would have moved the trig block, which every existing reader already
knows the offsets of. `test/fixtures/verify-wasm-request.py` - the third
implementation, neither the writer nor the Rust reader - checks both new
offsets, and a planted swap of the two is one of the breaks confirmed caught.

**The module does NOT default the scrap sliders**, and `FulgoraParams`'s
`Default` gives 0 rather than the neutral 1 on purpose, so a writer that forgot
them renders visibly wrong rather than plausibly right. The single place an
absent slider becomes 1 is `writeFulgoraParams` in `src/noise/wasm/request.ts`.
Do not add a second.

**`VIEW_SCRAP_FOOTPRINT` is not the scrap overlay, and #363's issue body was
written believing it was.** The footprint view paints every tile where the
probability is positive; the `all` composite paints the subset a placement ROLL
accepts. Measured over a 128x128 window at seed 123456: 708 footprint tiles
against 177 placed, so substituting one for the other moves 531 pixels. The
footprint is deliberately not a roll, because diffing rolled pixels against the
game's drawn pixels measures the salt rather than the model -
`crates/fmw-wasm/src/render.rs` says so at the constant.

**`control:scrap:frequency` above neutral does not move the picture**, measured
2026-08-31 on a 64x64 window at (-500, 3000), seed 123456: `(4, 1)` is
byte-identical to `(1, 1)` at 149 scrap pixels, while `(0.25, 1)` gives 104.
`size` moves both ways - 194 at 3, 118 at 0.25. This is not a curiosity: a
parity test that moves `frequency` UPWARD grades nothing, and one written that
way was measured passing against a module that ignored the field outright.
`test/wasmFulgoraRenderParity.spec.ts` moves one slider at a time and pins the
dead zone.

**Nauvis's block landed at 64 bytes with no bump and has since grown five
times** - 96 for the tree overlay's four levers, 144 for the rock overlay's two
Expand Down
33 changes: 25 additions & 8 deletions crates/fmw-wasm/src/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,15 @@
//! 48 f64 tiles_per_pixel
//! 56 = COMMON_BYTES
//!
//! fulgora block (48 bytes, so a Fulgora request is 104 - unchanged from v1)
//! fulgora block (64 bytes, so a Fulgora request is 120)
//! +0 f64 islands_frequency
//! +8 f64 islands_size
//! +16 f64 sin_start sine of the starting bearing
//! +24 f64 cos_start
//! +32 f64 sin_vault sine of the vault bearing (the starting one + 180)
//! +40 f64 cos_vault
//! +48 f64 scrap_frequency read by the scrap overlay only
//! +56 f64 scrap_size read by the scrap overlay only
//!
//! vulcanus block (312 bytes, so a Vulcanus request is 368)
//! +0 f64 volcanism_frequency
Expand Down Expand Up @@ -123,11 +125,13 @@
//! crude-oil, uranium-ore
//! ```
//!
//! **The Vulcanus block has grown twice with no version bump - 248 to 280 for
//! the cliff view, 280 to 312 for the rock and resource overlays - and that is
//! the split working rather than a shortcut.** The prefix declares its own
//! block length, [`Status::BadParamsLength`] refuses a writer that disagrees
//! with it, and Fulgora's request has not moved a byte through either change.
//! **Three blocks have now grown with no version bump, which is the split
//! working rather than a shortcut.** Vulcanus went 248 to 280 for the cliff
//! view and 280 to 312 for the rock and resource overlays; Fulgora went 48 to
//! 64 for the scrap overlay (#363), which is the first time its block has
//! moved since v1. The prefix declares its own block length,
//! [`Status::BadParamsLength`] refuses a writer that disagrees with it, and no
//! other planet's request moved a byte through any of the three.
//! Both halves ship together in this repository and
//! `test/fixtures/wasm-request.v2.json` pins the encoding for each planet, so
//! there is no third party whose old requests could still be in flight. A
Expand Down Expand Up @@ -161,7 +165,7 @@ pub const ABI_VERSION: u32 = 2;
pub const COMMON_BYTES: usize = 56;

/// Size of Fulgora's parameter block.
pub const FULGORA_PARAMS_BYTES: usize = 48;
pub const FULGORA_PARAMS_BYTES: usize = 64;

/// Size of Vulcanus's parameter block.
pub const VULCANUS_PARAMS_BYTES: usize = 312;
Expand Down Expand Up @@ -310,6 +314,17 @@ pub struct FulgoraParams {
pub cos_start: f64,
pub sin_vault: f64,
pub cos_vault: f64,
/// `control:scrap:frequency`, wire value. Neutral is 1.
///
/// **Sent rather than defaulted, and `Default` gives 0 rather than 1 here
/// deliberately.** A zero is not the neutral slider, so a caller that
/// forgets to write these fields gets a visibly wrong render rather than a
/// plausible one. The one constructor the app uses fills them from the
/// request, defaulting to 1 there where the neutral value belongs.
pub scrap_frequency: f64,
/// `control:scrap:size`, wire value. Neutral is 1. See
/// [`FulgoraParams::scrap_frequency`].
pub scrap_size: f64,
}

/// Vulcanus's block.
Expand Down Expand Up @@ -609,6 +624,8 @@ pub fn decode(bytes: &[u8]) -> Result<Request, Status> {
cos_start: f64_at(bytes, p + 24),
sin_vault: f64_at(bytes, p + 32),
cos_vault: f64_at(bytes, p + 40),
scrap_frequency: f64_at(bytes, p + 48),
scrap_size: f64_at(bytes, p + 56),
}),
PLANET_NAUVIS => Params::Nauvis(NauvisParams {
water_level: f64_at(bytes, p),
Expand Down Expand Up @@ -925,7 +942,7 @@ mod tests {
/// third party to keep compatible.
#[test]
fn the_split_left_a_fulgora_request_the_size_it_was_in_v1() {
assert_eq!(COMMON_BYTES + FULGORA_PARAMS_BYTES, 104);
assert_eq!(COMMON_BYTES + FULGORA_PARAMS_BYTES, 120);
assert_eq!(COMMON_BYTES + VULCANUS_PARAMS_BYTES, 368);
// **`REQUEST_BYTES` is NAUVIS's now.** It equalled Vulcanus's request
// for three planets and this assertion read `368` the whole time,
Expand Down
47 changes: 41 additions & 6 deletions crates/fmw-wasm/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use crate::abi::{
self, FulgoraParams, NauvisParams, Params, Request, Status, VulcanusBearing, VulcanusParams,
};
use core::cell::RefCell;
use fmw_noise::cliffs::catalog::{modified_elevation_interval, CliffControls, CliffSettings};
use fmw_noise::cliffs::catalog::{CLIFF_MAP_COLOR, CLIFF_MARK_BACK_PX, CLIFF_MARK_SIZE_PX};
use fmw_noise::cliffs::fields::{CliffFieldParams, NauvisCliffFields};
Expand All @@ -31,7 +32,7 @@ use fmw_noise::expressions::starting_spot_at_angle::AngleTrig;
use fmw_noise::expressions::vulcanus_biomes::VulcanusBiomes;
use fmw_noise::expressions::vulcanus_stack::{VulcanusBase, VulcanusStack};
use fmw_noise::placement::roll::PLACEMENT_MARK_RADIUS_PX;
use fmw_noise::resources::fulgora_catalog::SCRAP_MAP_COLOR;
use fmw_noise::resources::fulgora_catalog::{FulgoraScrapPlacement, SCRAP_MAP_COLOR};
use fmw_noise::resources::nauvis_catalog::NAUVIS_RESOURCE_CATALOG;
use fmw_noise::resources::nauvis_oil::{crude_oil, NauvisOilPlacement};
use fmw_noise::resources::resolve_resource::{
Expand Down Expand Up @@ -239,7 +240,7 @@ pub fn render(request: &[u8], out: &mut [u8]) -> Status {
(req.planet, req.view),
(
PLANET_FULGORA,
VIEW_LANDMASK | VIEW_TERRAIN | VIEW_SCRAP_FOOTPRINT
VIEW_LANDMASK | VIEW_TERRAIN | VIEW_SCRAP_FOOTPRINT | VIEW_RESOURCES | VIEW_ALL
) | (
PLANET_VULCANUS,
VIEW_TERRAIN | VIEW_CLIFFS | VIEW_ROCKS | VIEW_RESOURCES | VIEW_ALL
Expand Down Expand Up @@ -295,20 +296,44 @@ fn render_fulgora(req: &Request, p: &FulgoraParams, out: &mut [u8]) {
islands_frequency: p.islands_frequency,
islands_size: p.islands_size,
};
let controls = ScrapControls {
frequency: p.scrap_frequency,
size: p.scrap_size,
};
// ONE stack for the whole window, so the four Voronoi point caches are warm
// across it - the same reason the TypeScript renderer shares a stack.
let mut stack = FulgoraStack::new(
//
// Behind a `RefCell` because the composite views need it in two places at
// once: the terrain pass walks it directly, and the scrap placement reads
// it through `PlacementSource::probability`, which gets `&self`. The two
// never hold a borrow at the same time - the terrain pass drops its borrow
// before asking `placed()`.
let stack = RefCell::new(FulgoraStack::new(
&ctx,
&ScrapControls::default(),
&controls,
AngleTrig::new(p.sin_start, p.cos_start),
AngleTrig::new(p.sin_vault, p.cos_vault),
);
));

// Built for every view rather than only the composites. It is lazy - a
// `PlacementSet` resolves a chunk on first ask and caches it - so a terrain
// render that never calls `placed()` pays for an empty `BTreeMap`.
let placement = FulgoraScrapPlacement::new(&stack);
let placed = placement.placement_set();

// Fulgora has no cliffs and no rocks, so `all` and `resources` are the same
// picture: terrain with the scrap overlay on it. Both codes are served
// because the dispatcher and the panel each ask with a different one, and
// collapsing them here rather than at the call site keeps the two answers
// provably identical.
let overlay = matches!(req.view, VIEW_RESOURCES | VIEW_ALL);

let mut offset = 0usize;
for py in 0..req.height {
let wy = req.origin_y + f64::from(py) * req.tiles_per_pixel;
for px in 0..req.width {
let wx = req.origin_x + f64::from(px) * req.tiles_per_pixel;
let fields = stack.eval(wx, wy);
let fields = stack.borrow_mut().eval(wx, wy);
let color = match req.view {
VIEW_TERRAIN => tile_color(fields.tile()),
VIEW_SCRAP_FOOTPRINT => {
Expand All @@ -318,6 +343,16 @@ fn render_fulgora(req: &Request, p: &FulgoraParams, out: &mut [u8]) {
[0, 0, 0]
}
}
_ if overlay => {
// Terrain first, then the overlay paints over it - the
// TypeScript's order, where `renderFulgoraResources`
// mutates the terrain image in place.
if placed.placed(wx, wy) {
SCRAP_MAP_COLOR
} else {
tile_color(fields.tile())
}
}
_ => match ocean_tile(&fields.elevation) {
None => LAND,
Some(Ocean::Shallow) => SHALLOW,
Expand Down
26 changes: 23 additions & 3 deletions src/noise/preview/elevationRenderRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ function renderVulcanusThroughWasm(
function renderFulgoraThroughWasm(
req: ElevationRenderRequest,
engine: EngineExports,
which: "landmask" | "terrain",
which: "landmask" | "terrain" | "resources" | "all",
): ElevationRenderResult {
const view = renderThroughWasm(engine, {
view: which,
Expand All @@ -374,6 +374,11 @@ function renderFulgoraThroughWasm(
tilesPerPixel: req.tilesPerPixel,
islandsFrequency: req.fulgoraIslandControls?.frequency ?? 1,
islandsSize: req.fulgoraIslandControls?.size ?? 1,
// Passed through UNDEFAULTED on purpose. `writeFulgoraParams` is the one
// place that turns an absent slider into the neutral 1; adding a `?? 1`
// here would be a second copy of that constant, and the two could drift.
scrapFrequency: req.fulgoraScrapControls?.frequency,
scrapSize: req.fulgoraScrapControls?.size,
});
const owned = new Uint8ClampedArray(view);
return { id: req.id, buffer: owned.buffer, width: req.width, height: req.height };
Expand Down Expand Up @@ -640,11 +645,26 @@ export function runRenderRequest(
throw new Error(unsupportedPair(planet, view));
}
if (planet === "fulgora") {
// The one path the Rust engine serves so far (#223). Checked BEFORE the
// Every view an ordinary user can reach, as of #363. Checked BEFORE the
// TypeScript stack is built, because `makeFulgoraStack` derives seed
// tables for eight multioctave fields, and building them only to throw
// them away would be most of the saving.
if (engine !== undefined && (view === "landmask" || view === "terrain")) {
//
// `"all"` is the one that matters: `ElevationPreviewPanel.vue`'s
// `effectiveView` returns it for every non-dev-mode Fulgora request, so
// until this line it was the DEFAULT view that fell to TypeScript while
// the two dev-mode views went to the engine. `"resources"` is the same
// picture - Fulgora has no cliffs and no rocks - and the module collapses
// the two codes onto one arm rather than the caller doing it.
//
// The TypeScript arm below is NOT dead. It still serves an engineless
// request, which is what `test/tiledEquality.spec.ts` and the no-engine
// half of the parity specs run, and it is the thing the engine is graded
// against.
if (
engine !== undefined &&
(view === "landmask" || view === "terrain" || view === "resources" || view === "all")
) {
return renderFulgoraThroughWasm(req, engine, view);
}
// Fulgora has a resources overlay now; it still has no cliffs and no
Expand Down
Binary file modified src/noise/wasm/engine.wasm
Binary file not shown.
26 changes: 23 additions & 3 deletions src/noise/wasm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
* v1 was one fixed 104-byte struct with Fulgora's two island sliders and four
* trig values baked in. Vulcanus needs 31 more `f64`, and Nauvis will need its
* own set again, so the reserved word became `params_bytes` and each planet
* declares its own block. A Fulgora request is still exactly 104 bytes.
* declares its own block. A Fulgora request was 104 bytes until #363 added the
* two scrap sliders, and is 120 now.
*/

import { f32 } from "../eval/f32";
Expand All @@ -37,7 +38,7 @@ export const ABI_VERSION = 2;
export const COMMON_BYTES = 56;

/** Must equal `fmw_wasm::abi::FULGORA_PARAMS_BYTES`. */
export const FULGORA_PARAMS_BYTES = 48;
export const FULGORA_PARAMS_BYTES = 64;

/** Must equal `fmw_wasm::abi::VULCANUS_PARAMS_BYTES`. */
export const VULCANUS_PARAMS_BYTES = 312;
Expand Down Expand Up @@ -127,6 +128,21 @@ export interface FulgoraRenderRequest extends CommonRenderRequest {
readonly planet?: "fulgora";
readonly islandsFrequency: number;
readonly islandsSize: number;
/**
* `control:scrap:frequency` and `control:scrap:size`, wire values, neutral
* at 1.
*
* **Optional, for the reason `planet` is**, so the existing Fulgora call
* sites need no change. `writeFulgoraParams` substitutes the neutral 1.
*
* **The module does NOT default these**, which is why the substitution has to
* happen and why it happens exactly once. `FulgoraParams` reads both straight
* into `ScrapControls`, so an unwritten field would encode 0 - a real slider
* setting, and not the neutral one. One encoder-side default is the whole
* defence, so do not add a second at a call site.
*/
readonly scrapFrequency?: number;
readonly scrapSize?: number;
}

/** One resource autoplace control's two sliders. Richness is not read. */
Expand Down Expand Up @@ -390,7 +406,7 @@ export function vulcanusBearingTrig(seed0: number): { sin: number; cos: number }
* Write a request into `target`, returning the bytes written.
*
* The return value is the LENGTH of this request, not the buffer's capacity -
* a Fulgora request is 104 bytes and a Vulcanus one is 368, and the module is
* a Fulgora request is 120 bytes and a Vulcanus one is 368, and the module is
* handed the length so it can check the declared block against what arrived.
*/
export function encodeRenderRequest(target: Uint8Array, req: WasmRenderRequest): number {
Expand Down Expand Up @@ -533,6 +549,10 @@ function writeFulgoraParams(view: DataView, req: FulgoraRenderRequest): void {
view.setFloat64(p + 24, trig.cosStart, true);
view.setFloat64(p + 32, trig.sinVault, true);
view.setFloat64(p + 40, trig.cosVault, true);
// The ONE place an absent scrap slider becomes the neutral 1. See the field
// docs on `FulgoraRenderRequest`.
view.setFloat64(p + 48, req.scrapFrequency ?? 1, true);
view.setFloat64(p + 56, req.scrapSize ?? 1, true);
}

function writeVulcanusParams(view: DataView, req: VulcanusRenderRequest): void {
Expand Down
Loading