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
43 changes: 39 additions & 4 deletions crates/fmw-noise/src/placement/roll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ pub const PLACEMENT_MARK_RADIUS_PX: i64 = 1;
/// [`the unit test`](placement_roll_word) can pin the reverse-engineered
/// constants against it.
///
/// Only the two Vulcanus overlays are here. The TypeScript table also carries
/// Nauvis rocks, enemy bases, crude oil, the three `random_penalty` stand-ins
/// and Fulgora scrap; each lands with the overlay that reads it, the same way
/// the resource catalog landed partial with the cliff stack.
/// Each salt lands with the overlay that reads it, the same way the resource
/// catalog landed partial with the cliff stack. As of #363 that is the whole
/// TypeScript table: `PLACEMENT_SALT` in
/// `src/noise/placement/placementRoll.ts` has nine entries and so does this
/// module. **The two tables must agree value for value** - a salt is the
/// entire content of a stream, so a mismatch moves every placed entity while
/// leaving the probability field bit-exact, which no field-level fixture can
/// see.
pub mod salt {
/// Vulcanus rocks. Zero on purpose - see the module docs.
pub const VULCANUS_ROCKS: u32 = 0;
Expand All @@ -97,6 +101,10 @@ pub mod salt {
/// Crude oil's `random_penalty{source = 1, amplitude = 48}` draw, the same
/// batch-op stand-in the two spawner penalties are.
pub const CRUDE_OIL_PENALTY: u32 = 0x0091_c40d;
/// Fulgora scrap - the planet's one resource, and the one that ROLLS
/// rather than thresholding, because its probability is capped at 0.5 by
/// the Lua's own `min` and so never saturates into a patch.
pub const FULGORA_SCRAP: u32 = 0x003b_a58c;
}

/// `max(341, 0x3FBE2C + 7919*chunkX + 7907*chunkY + salt)` in `u32` arithmetic.
Expand Down Expand Up @@ -387,6 +395,33 @@ mod tests {
assert_eq!(placement_roll_word(0, 0, salt), 341);
}

/// Every salt in the table is distinct, and Fulgora scrap's matches the
/// TypeScript's literal.
///
/// A salt IS the stream, so a wrong one moves every placed entity while
/// leaving the probability field bit-exact. No field-level fixture can see
/// that, which is why the value is pinned here against its counterpart in
/// `src/noise/placement/placementRoll.ts` rather than only being distinct.
#[test]
fn the_salt_table_is_distinct_and_matches_the_typescripts() {
let all = [
salt::VULCANUS_ROCKS,
salt::VULCANUS_GEYSER,
salt::NAUVIS_ROCKS,
salt::ENEMY_BASES,
salt::ENEMY_BITER_PENALTY,
salt::ENEMY_SPITTER_PENALTY,
salt::CRUDE_OIL,
salt::CRUDE_OIL_PENALTY,
salt::FULGORA_SCRAP,
];
let mut sorted = all.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), all.len(), "two overlays share a salt");
assert_eq!(salt::FULGORA_SCRAP, 0x003b_a58c);
}

/// Different salts give different streams, which is the whole content of
/// the salt.
#[test]
Expand Down
209 changes: 209 additions & 0 deletions crates/fmw-noise/src/resources/fulgora_catalog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! Fulgora's one resource: scrap, and the roll that turns its probability into
//! placed entities.
//!
//! Ported from `src/noise/resources/fulgoraResourceCatalog.ts`.
//!
//! **There is no `threshold` mode here and no `region` function**, unlike the
//! Nauvis and Vulcanus catalogs, because scrap does not use
//! `resource_autoplace_all_patches`. Its autoplace is a bare
//! `probability_expression` + `richness_expression` pair, and
//! `expressions::fulgora_scrap` caps the probability at 0.5 with the Lua's own
//! `min`, so it never saturates into a patch. It ROLLS.
//!
//! ## This is NOT the scrap footprint
//!
//! `fmw-wasm`'s `VIEW_SCRAP_FOOTPRINT` paints every tile where
//! `probability > 0`. This module paints the subset a random draw accepts,
//! which is the smaller set the app's `"all"` composite draws. The two answer
//! different questions and the footprint view's own doc comment explains why it
//! is deliberately not this: diffing rolled pixels against the game's drawn
//! pixels measures the salt rather than the model.
//!
//! **The gap is large enough to see.** Measured 2026-08-31 by
//! [`places_a_strict_nonempty_subset_of_the_footprint`], over a 128x128 window
//! at the origin, seed 123456, neutral sliders: 708 of 16,384 tiles are in the
//! footprint and 177 of those are placed, so the roll accepts 25.0% of it.
//! Substituting one view for the other would move 531 pixels in that window.
//!
//! ## The collision box is carried and is inert
//!
//! Passing an inert gate looks like an oversight, so it is stated here and
//! asserted in this module's own test rather than left to be rediscovered. The
//! TypeScript passes it for the same reason, and
//! `test/fulgoraScrapDensity.spec.ts` makes the same assertion on that side.

use crate::expressions::fulgora_stack::FulgoraStack;
use crate::placement::roll::{salt, PlacementCollisionBox, PlacementSet, PlacementSource};
use core::cell::RefCell;

/// `map_color = {0.9, 0.9, 0.9}` from the prototype, times 255.
///
/// Confirmed against the game's own preview pixels rather than from the Lua
/// alone: 1098 of 1825 changed pixels are exactly this triple.
pub const SCRAP_MAP_COLOR: [u8; 3] = [229, 229, 229];

/// Scrap's `collision_box`, read off the RUNNING GAME rather than from the Lua.
///
/// The shared `resource()` helper declares `{{-0.1,-0.1},{0.1,0.1}}`, and the
/// game snaps it to the 1/256 grid, so the half-extent is 0.09765625 and the
/// full extent is twice that.
///
/// **It cannot reject anything**, against the Vulcanus geyser's 1.4 half-extent
/// where collision did all of the work. `PlacementSet`'s overlap test asks
/// whether two centre separations are both under `(w + w) / 2`, which here is
/// 0.1953125; centres sit on integer tiles and no tile is visited twice, so the
/// smallest separation any pair can have is 1. It is passed anyway, to match
/// the TypeScript operation for operation, and
/// [`the_collision_box_cannot_reject_anything`] pins that it is inert.
pub const SCRAP_COLLISION_BOX: PlacementCollisionBox = PlacementCollisionBox {
w: 0.097_656_25 * 2.0,
h: 0.097_656_25 * 2.0,
};

/// The shipped scrap placement source.
///
/// **No `tile_allowed` gate**, and that is a finding rather than an omission.
/// The `fulgora_elevation > fulgora_coastline + 10` term inside the probability
/// already puts expected scrap on ocean at exactly 0.00 over 262,144 tiles, so
/// a water gate would reject nothing. `test/fulgoraScrap.spec.ts` asserts it on
/// the TypeScript side.
///
/// **The controls are already inside the stack.** `FulgoraStack::new` takes the
/// `ScrapControls` and hands them to `FulgoraScrap`, so this type takes none of
/// its own - which keeps the frequency and size levers with exactly one owner
/// rather than two that could disagree.
///
/// The stack is held behind a `RefCell` because `FulgoraStack::eval` needs
/// `&mut self` while [`PlacementSource::probability`] gets `&self`. The borrow
/// never spans a callback: it is taken and dropped inside one statement, which
/// is the same discipline `PlacementSet::placed` applies to its own cache.
pub struct FulgoraScrapPlacement<'a> {
stack: &'a RefCell<FulgoraStack>,
}

impl<'a> FulgoraScrapPlacement<'a> {
#[must_use]
pub fn new(stack: &'a RefCell<FulgoraStack>) -> Self {
Self { stack }
}

/// The placement set for this overlay, ready to be asked `placed(x, y)`.
#[must_use]
pub fn placement_set(&self) -> PlacementSet<'_> {
PlacementSet::new(salt::FULGORA_SCRAP, self)
}
}

impl PlacementSource for FulgoraScrapPlacement<'_> {
fn probability(&self, x: f64, y: f64) -> f64 {
self.stack.borrow_mut().eval(x, y).scrap.probability
}

fn collision_box(&self, _x: f64, _y: f64) -> Option<PlacementCollisionBox> {
Some(SCRAP_COLLISION_BOX)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::expressions::fulgora_scrap::ScrapControls;
use crate::expressions::fulgora_shared::FulgoraCtx;

/// The seed every Fulgora oracle fixture in this repo was captured at.
const SEED: u32 = 123_456;

/// A 128x128 window, 16,384 tiles - big enough to cross 16 chunks, so the
/// per-chunk roll seeding is exercised rather than one chunk's stream.
const WINDOW: i64 = 128;

fn stack() -> RefCell<FulgoraStack> {
let ctx = FulgoraCtx::new(SEED);
RefCell::new(FulgoraStack::with_host_trig(
&ctx,
&ScrapControls::default(),
))
}

/// Sweep the window and count `(footprint, placed)`.
fn counts(collision: bool) -> (usize, usize) {
let cell = stack();
let placement = FulgoraScrapPlacement::new(&cell);
let no_collision = NoCollision(&placement);
let source: &dyn PlacementSource = if collision { &placement } else { &no_collision };
let set = PlacementSet::new(salt::FULGORA_SCRAP, source);
let (mut footprint, mut placed) = (0usize, 0usize);
for y in 0..WINDOW {
for x in 0..WINDOW {
#[allow(clippy::cast_precision_loss)]
let (fx, fy) = (x as f64, y as f64);
if placement.probability(fx, fy) > 0.0 {
footprint += 1;
}
if set.placed(fx, fy) {
placed += 1;
}
}
}
(footprint, placed)
}

/// The same source with the collision box removed, so the two can be
/// compared rather than the box's inertness argued from its size.
struct NoCollision<'a>(&'a FulgoraScrapPlacement<'a>);

impl PlacementSource for NoCollision<'_> {
fn probability(&self, x: f64, y: f64) -> f64 {
self.0.probability(x, y)
}
}

/// The overlay draws something, and draws strictly less than the footprint.
///
/// Both halves matter. A roll that placed nothing would fold zeros and
/// agree with anything; a roll that placed the whole footprint would not be
/// a roll at all, and `VIEW_SCRAP_FOOTPRINT` would already be the answer.
///
/// Measured 2026-08-31: 708 footprint tiles and 177 placed. Neither bound
/// is close, which is what makes this a weak gate on its own - it is listed
/// in `scripts/verify-rust.sh`'s poison set because the perturbation drives
/// `placed` to 16,207 and trips the upper bound, not because 177 is a
/// precise number. The byte-identity spec against the TypeScript composite
/// is the real grading, and it lands with the render arm.
#[test]
fn places_a_strict_nonempty_subset_of_the_footprint() {
let (footprint, placed) = counts(true);
println!("MEASURED footprint={footprint} placed={placed}");
assert!(placed > 0, "the roll placed nothing, so it grades nothing");
assert!(
placed < footprint,
"the roll placed the whole footprint ({placed} of {footprint}), \
so it is not rolling"
);
}

/// The collision box is carried and rejects nothing.
///
/// Measured rather than argued from the box's size, because the argument
/// depends on `PlacementSet`'s overlap test staying centre-to-centre, which
/// this test would catch changing and a comment would not.
///
/// **Deliberately NOT in the poison set**, and the reason is the one
/// `poison.rs` records for the capture-grid snap test: both arms run
/// through the same poisoned `resolve_chunk`, so the perturbation applies
/// to each and cancels. Confirmed 2026-08-31 - under `--features poison`
/// this test stays green while its sibling goes red. A relational test
/// listed in that set would fail the gate for being correct.
#[test]
fn the_collision_box_cannot_reject_anything() {
let (_, with_box) = counts(true);
let (_, without_box) = counts(false);
assert_eq!(
with_box,
without_box,
"the 0.1953125-tile collision box rejected {} placements, so it is \
no longer inert and the doc comment above is wrong",
without_box.saturating_sub(with_box)
);
}
}
1 change: 1 addition & 0 deletions crates/fmw-noise/src/resources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! changing any of it - it inventories the argument orders, the narrowing
//! points and the eight constants that moved between 2.0.77 and 2.1.9.

pub mod fulgora_catalog;
pub mod nauvis_catalog;
pub mod nauvis_oil;
pub mod regular_patches;
Expand Down
8 changes: 7 additions & 1 deletion crates/fmw-wasm/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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::nauvis_catalog::NAUVIS_RESOURCE_CATALOG;
use fmw_noise::resources::nauvis_oil::{crude_oil, NauvisOilPlacement};
use fmw_noise::resources::resolve_resource::{
Expand Down Expand Up @@ -181,7 +182,12 @@ const DEEP: [u8; 3] = [
/// The game's own `map_color` for scrap is `{0.9, 0.9, 0.9} * 255` = 229, and
/// that triple was confirmed against the preview PNG rather than from the Lua
/// alone. This view paints it so the two images can be compared directly.
const SCRAP_FOOTPRINT: [u8; 3] = [229, 229, 229];
///
/// **Re-exported rather than re-typed** (#363). This file held its own literal
/// until the scrap catalog landed in `fmw-noise`; two copies of a map colour
/// with nothing comparing them is exactly the shape #364 removed for the
/// Vulcanus cliff tiles.
const SCRAP_FOOTPRINT: [u8; 3] = SCRAP_MAP_COLOR;

/// The eight land tile colours, read from each tile's `map_color` in
/// `tiles-fulgora.lua` rather than picked by eye.
Expand Down
17 changes: 17 additions & 0 deletions scripts/verify-rust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,23 @@ POISONED_TESTS=(
# test that pins its value.
resources::vulcanus_catalog::tests::the_measured_peak_is_far_below_a_solid_ores

# Fulgora scrap's placement roll (#363). No hook of its own: it composes
# `resolve_chunk`'s `poison::bool_result`, the same one the two
# `placement::roll` entries above read, and its probability comes from
# `expressions::fulgora_scrap`, which `fixtures::reproduces_the_fulgora_scrap_
# probability_at_every_captured_position` already grades.
#
# Watched going red rather than assumed to: the perturbation inverts every
# accept, so the window's placements go from 177 to 16,207 of 16,384 and the
# test's upper bound trips.
#
# Its sibling `the_collision_box_cannot_reject_anything` is deliberately
# ABSENT. That one compares a poisoned arm against a poisoned arm, so the
# perturbation cancels and it stays green - the relational shape `poison.rs`
# records for the capture-grid snap test. Adding it here would fail the gate
# for being correct.
resources::fulgora_catalog::tests::places_a_strict_nonempty_subset_of_the_footprint

# The only grading of `cliffs::connections` against anything - that module is
# on no render path, so without this it would be a 445-line port with unit
# tests and no measurement.
Expand Down
Binary file modified src/noise/wasm/engine.wasm
Binary file not shown.