Skip to content

Commit 0b89ab7

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/2.1.15-exchange-format
2 parents 57eb356 + f87b70b commit 0b89ab7

8 files changed

Lines changed: 349 additions & 201 deletions

File tree

CLAUDE.md

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,15 @@ loses_on_none` is 33s in the normal arm and **93s under poison**, taking the
578578
per 512x512 region, ~33s each in a debug build, and that grading lives on the
579579
TypeScript side instead. See the phase-5 notes below for the reasoning.
580580

581+
**Treat this job's cost as a RANGE, not a number: 1m44s to 2m48s.** #310
582+
measured 1m44s on CI against the 2m48s recorded here from an earlier run, on
583+
code whose Rust half did not change between them. That is the same spread the
584+
test shards show (`one-ci-run-measures-the-runner` - identical spec files came
585+
in anywhere from 294s to 469s), and the honest response is to widen the figure
586+
rather than to replace it: a single run here measures the runner at least as
587+
much as the job. Do not "correct" this to whichever number you last saw. If a
588+
change to the Rust really does move this job, show it with more than one run.
589+
581590
- **It runs `bash scripts/verify-rust.sh` directly**, the one deviation from
582591
"the YAML names only package.json scripts". That does not reopen the drift
583592
the rule guards against, because `verify:rust` _is_ that one line, so the
@@ -1611,7 +1620,26 @@ both sides and stay invisible. The sweep is the request's own pixel grid, swept
16111620
rows-outer exactly as `render_vulcanus` sweeps it, so there is one geometry
16121621
convention rather than two.
16131622

1614-
**It found a real divergence on its first run, and the divergence is #309.**
1623+
**The field SELECTOR lives in `fmw-noise`, not in the wasm crate, and copy that
1624+
for Nauvis too.** `VulcanusParity` sits beside `VulcanusStack` in
1625+
`expressions/vulcanus_stack.rs`; the wasm export builds the stack through the
1626+
render helpers and then calls `parity.field(field, x, y)`. The reason is
1627+
ownership of test-only API: the selector needs `elevation_fields` and
1628+
`temperature`, which NO render path reads, and reaching them from another crate
1629+
meant two `pub` methods on a library type that existed solely for a test - and a
1630+
`pub` method cannot be `#[cfg(test)]`-gated, because the wasm crate calls it at
1631+
build time. Keeping the selector in the same module makes both private again.
1632+
The field count moved with it (`VulcanusParity::FIELD_COUNT`), so the count and
1633+
the `match` it bounds cannot drift apart.
1634+
1635+
The move is pure code motion and was checked as such rather than assumed: tier 2
1636+
(74 fields) and tier 3 (byte-identical renders) both pass unchanged. It DOES
1637+
move `engine.wasm` by 142 bytes, because the selector inlines differently once
1638+
it is in the same crate as the layers it reads - which is a reminder that a
1639+
wasm diff is not by itself evidence of a behaviour change.
1640+
1641+
**It found a real divergence on its first run, and the divergence was #309 -
1642+
now fixed, see below.**
16151643
`basisNoiseExpr` forms its coordinate product in f64 and narrows once
16161644
(`primitives.ts:66`); the Rust narrows `x` to f32 first and multiplies two f32s
16171645
(`primitives.rs:87`). They agree at every f32-exact coordinate and differ
@@ -1643,25 +1671,67 @@ is worth more than the bug:
16431671
same property that made `poison::index_result` necessary, and it is the
16441672
standing answer to "tier 3 is byte-identical, so why build tier 2".
16451673

1646-
**Which form is right is an internal-consistency argument, not a measurement**,
1647-
which is why #309 is an issue rather than a fix that rode along. Both ports'
1648-
multioctave path already narrows the incoming coordinate
1649-
(`multioctaveNoise.ts:203`, `multioctave_noise.rs:137`) - which is exactly why
1650-
the six `wobble_*` fields MATCHED off-grid in the same sweep where the plasma
1651-
fields did not - and the game holds its noise variables at f32. Applying the
1652-
narrowing was measured to leave the full gate green (**2140 passed, 0 failed**,
1653-
`vulcanusPlasmaDecomposition` still 61/61 at worst residual exactly 0), because
1654-
nothing in the suite scores off the f32 grid.
1655-
1656-
**So the parity windows sweep ON the f32 grid, deliberately, and the divergence
1657-
is PINNED rather than described.** `the two ports diverge off the f32 grid`
1658-
freezes it at 32 fields and asserts the MECHANISM: `wobbleX` agrees,
1659-
`hairlineCracks` does not, `resolvedTile` agrees. **That test goes red when #309
1660-
is fixed**, which is the prompt to widen the windows back off the grid and delete
1661-
the pin. A second test asserts the two parity windows really are on the grid and
1662-
the pinning window really is not (20 of its 26 x-coordinates), so a step edited
1663-
to something without an exact binary form fails as a window mistake rather than
1664-
as a port bug.
1674+
**#309 IS FIXED, and it was settled by measurement rather than by the
1675+
internal-consistency argument this file used to record here.** That argument -
1676+
both ports' multioctave already narrows (`multioctaveNoise.ts:203`,
1677+
`multioctave_noise.rs:137`), the game holds its noise variables at f32 - pointed
1678+
the right way but proved nothing, and this section previously said so.
1679+
1680+
**The measurement came from a fixture already committed, not from a new
1681+
capture.** `fulgora_basis` is a multioctave read at Fulgora's DERIVED coordinate
1682+
`wx = ox + wobble_x * wobble_mask`, computed in f64 and therefore off the f32
1683+
grid at **55 of that fixture's 101 positions**. Scored against the game:
1684+
1685+
| `sumOctaves` incoming coordinate | exact | worst residual |
1686+
| -------------------------------- | ----------: | -------------- |
1687+
| **narrowed** (what shipped) | **101/101** | exactly 0 |
1688+
| un-narrowed (planted) | 81/101 | 7.0333e-6 |
1689+
1690+
Twenty positions discriminate, so the game demonstrably narrows the incoming
1691+
coordinate, and the Rust form was the right one. `basisNoiseExpr` now narrows
1692+
`x` and `y` before the `input_scale` multiply.
1693+
1694+
**That is also the measurement #191 asked for, in its own words** - "a caller
1695+
that passes a derived coordinate" - and Fulgora has satisfied it since it
1696+
landed, unnoticed for months. Two lessons, and the second is the transferable
1697+
one:
1698+
1699+
- **Fold the fixtures you already HAVE before capturing more.** The plan here
1700+
was a far-field capture at |x| >= 65536, where the 1/256 grid stops being a
1701+
subset of the f32 grid. It would have worked and it was unnecessary: a
1702+
DERIVED coordinate leaves the f32 grid right next to the origin, so the
1703+
evidence was sitting in `oracle-fulgora-elevation` the whole time.
1704+
- **A "no fixture can grade this" claim is about the fixtures you looked at.**
1705+
It was true of every Vulcanus fixture and false of a Fulgora one.
1706+
1707+
**#191 is two-thirds done and its issue text is stale.** Re-read the code, not
1708+
the issue: `quickMultioctaveNoise` ALREADY narrows both coordinates
1709+
(`quickMultioctaveNoise.ts:192-193`), so only one of its three ops was
1710+
outstanding. `variablePersistenceMultioctaveNoise` narrowed `x` and NOT `y` - in
1711+
**both** ports, which is why tier 2 could not see it: the two agreed with each
1712+
other while both disagreed with the game. `x` was narrowed only as a side effect
1713+
of the `f32(x + offset_x)` add; `y` had no add and so was silently multiplied in
1714+
f64. Both ports now narrow it.
1715+
1716+
The third op, `basisNoise` itself, was deliberately NOT changed. Its disciplined
1717+
callers all narrow before calling, so narrowing inside would be a no-op for
1718+
them - and its remaining direct callers (`nauvisShared.ts:133-134`,
1719+
`startingPatches.ts:185`, `regularPatches.ts:164-165`) are unported NAUVIS
1720+
chains where the whole expression is un-narrowed, not just the coordinate.
1721+
Planting the internal narrowing leaves all 26 of their specs passing, so no
1722+
committed fixture discriminates it. That belongs to #226, scored layer by layer
1723+
under the greedy-accept rule, not to a change that cannot grade it.
1724+
1725+
**The parity windows still sweep ON the f32 grid, and the pin has been
1726+
INVERTED.** `the two ports agree off the f32 grid` now asserts **0 of 74**
1727+
diverging where the pin froze 32, and reverting `primitives.ts` reproduces
1728+
`[ 'hairlineCracks', ...(31) ]` - checked by planting, not assumed. Its
1729+
anti-vacuity is not optional and is easy to get wrong: "nothing diverges" is
1730+
exactly what a sweep evaluating nothing would report, so the test also asserts
1731+
the off-grid window's folds differ from the on-grid window's on all 74 fields.
1732+
The windows themselves are kept as they are because they are tuned for FIELD
1733+
coverage (the `startingArea` range, all 19 tiles placed), not because the
1734+
restriction is still load-bearing.
16651735

16661736
Two anti-vacuity numbers, both frozen: the two windows differ on **all 74**
16671737
fields, and each places **all 19** tiles, so every probability fold is graded

crates/fmw-noise/src/expressions/vulcanus_stack.rs

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
//! Three lines rather than one, and they are honest about the ownership rather
3838
//! than hiding it behind an arena.
3939
40+
use crate::cliffs::vulcanus_fields::CliffinessBasic;
4041
use crate::distance_from_nearest_point::distance_from_nearest_point;
4142
use crate::eval::ctx::EvalCtx;
4243
use crate::expressions::starting_spot_at_angle::AngleTrig;
@@ -47,7 +48,11 @@ use crate::expressions::vulcanus_elevation::{ElevationFields, VulcanusElevation}
4748
use crate::expressions::vulcanus_helpers::VulcanusHelpers;
4849
use crate::expressions::vulcanus_resources::{OreRegions, ResourceFields, VulcanusResources};
4950
use crate::expressions::vulcanus_spawn::VulcanusSpawn;
51+
use crate::expressions::vulcanus_spawn::WobbleSums;
5052
use crate::multioctave_noise::Prepared;
53+
use crate::resources::vulcanus_catalog::sulfuric_acid_geyser_probability;
54+
use crate::rocks::vulcanus_field::{vulcanus_decorative_knockout, VulcanusRockFields};
55+
use crate::tiles::vulcanus_catalog::TILE_ORDER;
5156
use crate::tiles::vulcanus_catalog::{
5257
resolve_tile, tile_probabilities, vulcanus_rock_noise, MountainLavaSpots, VulcanusTile,
5358
VulcanusTileFields,
@@ -248,8 +253,7 @@ impl<'a> VulcanusStack<'a> {
248253
/// port that dropped the `max` would score identically. The clamp's real
249254
/// test lives in `vulcanus_elevation`, constructing the case the fixture
250255
/// does not.
251-
#[must_use]
252-
pub fn elevation_fields(&self, x: f64, y: f64) -> ElevationFields {
256+
fn elevation_fields(&self, x: f64, y: f64) -> ElevationFields {
253257
self.elevation.eval(x, y)
254258
}
255259

@@ -260,8 +264,7 @@ impl<'a> VulcanusStack<'a> {
260264
/// byte-identical pixels say nothing about it. Tier 1 grades it against the
261265
/// game (244 of 434) and tier 2 is the only thing that would notice it
262266
/// diverging from the TypeScript.
263-
#[must_use]
264-
pub fn temperature(&self, x: f64, y: f64) -> f64 {
267+
fn temperature(&self, x: f64, y: f64) -> f64 {
265268
self.elevation
266269
.temperature(x, y, self.base.ctx.temperature_bias)
267270
}
@@ -332,6 +335,149 @@ impl<'a> VulcanusStack<'a> {
332335
}
333336
}
334337

338+
/// Tier 2's field selector for the Vulcanus graph: one named expression, by index.
339+
///
340+
/// **This lives here rather than in the wasm crate on purpose.** The selector
341+
/// needs `elevation_fields` and `temperature`, which nothing on a render path
342+
/// reads - temperature is not drawn at all, and `elev` versus its clamp is a
343+
/// distinction no fixture can make. Reaching them from another crate meant two
344+
/// `pub` methods on a library type that existed solely for a test, and a `pub`
345+
/// method cannot be `#[cfg(test)]`-gated because the wasm crate calls it at
346+
/// build time. Keeping the selector in this module makes both private again.
347+
///
348+
/// The layers it evaluates per point are deliberately NOT shared with each
349+
/// other - `tile_fields` re-evaluates the biome and resource layers, for
350+
/// instance. That is the point: each field is reached through its own entry
351+
/// point, the same one its consumer uses, so a field wired to the wrong layer
352+
/// shows up as a divergence rather than being smoothed over by a shared
353+
/// intermediate. It runs once in a test, so the redundancy is free.
354+
pub struct VulcanusParity<'a, 'b> {
355+
stack: &'a VulcanusStack<'b>,
356+
rocks: VulcanusRockFields<'a, 'b>,
357+
knockout: Prepared,
358+
cliffiness: CliffinessBasic,
359+
}
360+
361+
impl<'a, 'b> VulcanusParity<'a, 'b> {
362+
/// How many named fields [`Self::field`] can select, `0..FIELD_COUNT`.
363+
///
364+
/// The order is the order the chain evaluates in: helpers, spawn, cracks,
365+
/// climate, biomes, elevation, resources, the tile-support fields, the two
366+
/// overlay fields, then the 19 tile probabilities and the argmax over them.
367+
pub const FIELD_COUNT: u32 = 74;
368+
369+
#[must_use]
370+
pub fn new(stack: &'a VulcanusStack<'b>, seed0: u32) -> Self {
371+
Self {
372+
stack,
373+
// The overlay fields hang off the same stack rather than rebuilding
374+
// the chain, which is what their own constructors are for.
375+
rocks: VulcanusRockFields::new(stack, seed0),
376+
knockout: vulcanus_decorative_knockout(seed0),
377+
cliffiness: CliffinessBasic::for_vulcanus(seed0),
378+
}
379+
}
380+
381+
/// One named field at `(x, y)`. Out-of-range indices resolve to the argmax,
382+
/// matching the exhaustive `match` this was lifted from.
383+
#[must_use]
384+
#[allow(clippy::too_many_lines)]
385+
pub fn field(&self, field: u32, x: f64, y: f64) -> f64 {
386+
let base = self.stack.base;
387+
let sp = base.spawn.eval(x, y, WobbleSums::at(&base.helpers, x, y));
388+
let cr = base.cracks.eval(x, y);
389+
let cl = base.climate.eval(x, y, &cr);
390+
let bi = self.stack.biomes.eval(x, y);
391+
let el = self.stack.elevation_fields(x, y);
392+
let res = self.stack.resources(x, y);
393+
let tf = self.stack.tile_fields(x, y);
394+
let rk = self.rocks.eval(x, y);
395+
396+
match field {
397+
0 => base.helpers.wobble_x(x, y),
398+
1 => base.helpers.wobble_y(x, y),
399+
2 => base.helpers.wobble_large_x(x, y),
400+
3 => base.helpers.wobble_large_y(x, y),
401+
4 => base.helpers.wobble_huge_x(x, y),
402+
5 => base.helpers.wobble_huge_y(x, y),
403+
6 => sp.ashlands_start,
404+
7 => sp.basalts_start,
405+
8 => sp.mountains_start,
406+
9 => sp.starting_area,
407+
10 => sp.starting_circle,
408+
11 => cr.hairline_cracks,
409+
12 => cr.flood_cracks_a,
410+
13 => cr.flood_cracks_b,
411+
14 => cr.flood_paths,
412+
15 => cr.flood_basalts_func,
413+
16 => cl.aux,
414+
17 => cl.moisture,
415+
18 => bi.mountain_volcano_spots,
416+
19 => bi.mountains_raw_volcano,
417+
20 => bi.mountains_biome_full,
418+
21 => bi.ashlands_biome_full,
419+
22 => bi.basalts_biome_full,
420+
23 => bi.mountains_biome,
421+
24 => bi.ashlands_biome,
422+
25 => bi.basalts_biome,
423+
// The RAW `elev` and its clamp, graded separately. No fixture can
424+
// tell them apart - see `VulcanusStack::elevation_fields`.
425+
26 => el.elev,
426+
27 => el.elevation,
427+
// A genuinely different field, not a rounding of the one above:
428+
// `multisample`'s offsets are in the CONSUMING program's grid units
429+
// and the cliff lattice is 4 tiles wide (#83).
430+
28 => self.stack.cliff_elevation(x, y),
431+
// Read by no renderer at all, which is half the argument for this
432+
// selector existing.
433+
29 => self.stack.temperature(x, y),
434+
30 => res.basalts_favorability,
435+
31 => res.mountains_favorability,
436+
32 => res.mountains_sulfur_favorability,
437+
33 => res.ashlands_favorability,
438+
34 => res.starting_tungsten,
439+
35 => res.starting_coal,
440+
36 => res.starting_calcite,
441+
37 => res.starting_sulfur,
442+
38 => res.tungsten_region,
443+
39 => res.coal_region,
444+
40 => res.calcite_region,
445+
41 => res.sulfuric_acid_region,
446+
42 => res.sulfuric_acid_patches,
447+
43 => res.sulfuric_acid_region_patchy,
448+
44 => res.metal_tile,
449+
45 => sulfuric_acid_geyser_probability(res.sulfuric_acid_region_patchy),
450+
46 => tf.mountain_lava_spots,
451+
47 => tf.rock_noise,
452+
48 => tf.distance,
453+
49 => self.cliffiness.eval(x, y),
454+
50 => f64::from(self.knockout.eval(x, y)),
455+
51 => rk.rock_huge,
456+
52 => rk.rock_big,
457+
53 => rk.density,
458+
// The 19 tile probabilities, in `TILE_ORDER`.
459+
54..=72 => tile_probabilities(&tf)[(field - 54) as usize],
460+
// The resolved tile, as its index in `TILE_ORDER`. A number so it
461+
// rides the same comparator as the probabilities it derives from,
462+
// and a DISCRETE one, which is why `resolve_tile` carries
463+
// `poison::index_result` rather than a numeric hook.
464+
_ => tile_index(resolve_tile(&tf)),
465+
}
466+
}
467+
}
468+
469+
/// A resolved tile as its position in [`TILE_ORDER`].
470+
///
471+
/// A linear scan rather than a second table, so it cannot drift from the order
472+
/// the argmax resolves against.
473+
fn tile_index(tile: crate::tiles::vulcanus_catalog::VulcanusTile) -> f64 {
474+
#[allow(clippy::cast_precision_loss)]
475+
TILE_ORDER
476+
.iter()
477+
.position(|&t| t == tile)
478+
.map_or(f64::NAN, |i| i as f64)
479+
}
480+
335481
#[cfg(test)]
336482
mod tests {
337483
use super::*;

crates/fmw-noise/src/variable_persistence_multioctave_noise.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,16 @@ pub fn eval(
8686
tables: &BasisNoiseTables,
8787
) -> f32 {
8888
let xo = (x + terms.offset_x) as f32;
89+
// `y` is narrowed for the same reason `x` is: the noise machine holds its
90+
// coordinate values at f32, so the scale multiply is an f32 operation on
91+
// both operands. `x` was always narrowed here through the `offset_x` add;
92+
// `y` had no add to narrow it and so was silently multiplied in f64 (#191).
93+
let yf = y as f32;
8994
let last = terms.scales.len().saturating_sub(1);
9095
let mut acc = 0.0f32;
9196
for (k, &scale) in terms.scales.iter().enumerate() {
9297
let xk = xo * scale;
93-
let yk = (y * f64::from(scale)) as f32;
98+
let yk = yf * scale;
9499
acc += basis_noise(f64::from(xk), f64::from(yk), tables);
95100
if k < last {
96101
acc *= persistence;

0 commit comments

Comments
 (0)