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
56 changes: 56 additions & 0 deletions docs/noise/cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,62 @@ here refutes the multisample port; what has never been tested is whether it
behaves the same when the calling program's grid is 4 tiles rather than 1.
`test/vulcanusCliffCollapsed.spec.ts` pins all of the above.

## ROOT CAUSE, 2026-08-01: `multisample`'s offsets are in GRID UNITS, not tiles

> **Issue #18 is resolved.** `docs/noise/vulcanus-multisample-NOTES.md` proved
> `multisample(e, dx, dy) == e(x + dx, y + dy)` at 150/150 comparisons, and that
> is correct **for `LuaSurface.calculate_tile_properties`, whose noise program has
> a 1-tile grid**. It was never checked in any other channel. The primitive's own
> docs say it evaluates "in a separate noise program with **a larger grid**" whose
> "sub-grids are copied to the main program", and that phrase is load-bearing.
>
> Asked through the CLIFF GENERATOR - whose grid is the 4-tile corner lattice -
> by routing a probe onto `cliff_elevation` with the rule collapsed, so cliffs
> mark exactly where the routed field crosses 71:
>
> | arm | column | |
> | --- | --- | --- |
> | `x` | 70 | baseline |
> | `multisample(x, 0, 0)` | 70 | identical |
> | `multisample(x, 4, 0)` | **54** | shifted **16 tiles**, not 4 |
> | `multisample(x, 0, 4)` | 70 | null control |
>
> **`dx = 4` moves the field 16 tiles = 4 x the grid step.** So Vulcanus's
> `vulcanus_basalt_lakes_multisample` - a `min` over `{0,1}x{0,1}` - spans **4
> tiles** for cliffs and **1 tile** for every per-tile consumer. `min` is an
> erosion operator, so the cliff channel's elevation is much smoother; the port
> used the 1-tile field for both, making the cliff elevation too rough and
> over-placing by ~40%.
>
> | | before | after |
> | --- | --- | --- |
> | wrong orientation | 175 = 12.5% | **37 = 2.4%** |
> | recall | 0.806 / 0.938 / 0.853 | **1.000 / 0.973 / 0.965** |
> | `[0,0]` (worst region) | 29.8% wrong | **2.5%**, recall 1.000 |
> | level sweep, ratio | 1.20-1.49 below 120 | **1.00-1.09 at every level** |
>
> `VulcanusElevation` now exposes `cliffElevation` beside `elevation`; both hang
> off one stack and share every sub-expression below the multisample, so the cost
> is a second memo table. **Do not collapse them back together** - they are
> different fields, not a cache miss.
>
> Nothing here refutes the multisample port or the per-tile consumers:
> `calculate_tile_properties` and the tile renderer both live in the 1-tile
> channel, where `e(x + dx, y + dy)` is exactly right. What was wrong was using
> one channel's field in the other's consumer. `test/multisampleGrid.spec.ts`.
>
> **The lesson, which is the third form of the same trap this repo keeps hitting.**
> It was not a fixture captured at the wrong SITE (#70's `grid_offset`), nor a
> value that was simply wrong - it was a fixture captured through the wrong
> CHANNEL, agreeing with a port that made the same mistake. Ask which code path
> CONSUMES a value, not only which coordinates it is sampled at. And note that no
> amount of sweeping inside the port could have found this: every arm of the
> 4-dimensional smoothing sweep, the band sweep and the rule sweep was searching a
> family that shared the defect.
>
> The sections below are preserved as the record of the investigation. Their
> measurements stand; read their conclusions as historical.

### The level-set inversion - #18 is ONE TERM of `vulcanus_elev` (2026-08-01)

The collapsed rule is also an **instrument**. A cell carries a cliff exactly when
Expand Down
47 changes: 47 additions & 0 deletions docs/noise/vulcanus-multisample-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,50 @@ const multisampled = Math.min(
where `sampleBasaltLakes` is the `vulcanus_basalt_lakes` expression port,
called at 4 integer-shifted neighbor points and combined with `Math.min` (the
`min(...)` in the Lua source, not part of `multisample` itself).

## AMENDED 2026-08-01: the offsets are in GRID UNITS, not tiles

Everything above is correct **for the channel it was measured in**, and that
qualification turned out to matter more than the result.

`sampleExpression` reads values back through `LuaSurface.calculate_tile_properties`,
whose noise program has a **1-tile grid**. In that channel `dx = 1` shifts by one
tile and the derived rule `multisample(e, dx, dy) == e(x + dx, y + dy)` holds
exactly, as the 150/150 residual-zero table shows.

It does not hold anywhere else. Measured through the **cliff generator**, whose
program walks the 4-tile corner lattice (`test/oracle/capture.ts multisample-grid`,
`test/multisampleGrid.spec.ts`): a probe routed onto `cliff_elevation` with the
placement rule collapsed puts its contour in one cell column, and

| probe | column | |
| --- | --- | --- |
| `x` | 70 | baseline |
| `multisample(x, 0, 0)` | 70 | identity |
| `multisample(x, 4, 0)` | **54** | shifted **16 tiles**, not 4 |
| `multisample(x, 0, 4)` | 70 | null control |

So the true rule is

```
multisample(e, dx, dy) at (x, y) == e(x + dx * G, y + dy * G)
```

where `G` is the **grid step of the noise program doing the calling** - 1 for
`calculate_tile_properties` and the tile renderer, 4 for cliff placement. The
doc text quoted above says exactly this and was read as an implementation note:
"evaluates the expression in a separate noise program with **a larger grid**".

The consequence for this port: `vulcanus_basalt_lakes_multisample`'s `min` over
`{0,1}x{0,1}` is a 1-tile min-filter for tiles and a **4-tile** one for cliffs.
`min` is erosion, so the cliff channel's elevation is markedly smoother. Using
the 1-tile field for cliffs was issue #18's root cause; see the top of
`docs/noise/cliffs-NOTES.md` for the numbers.

**The methodological point.** The measurement here was right, thorough and
non-vacuous - 150 comparisons, residual exactly zero, cross-terms ruled out - and
it still supported a false generalisation, because every one of those comparisons
came through one channel. A primitive documented as depending on the calling
program's grid cannot be characterised from a single caller. When adding an
oracle for a primitive, ask what varies between its CONSUMERS, not only what
varies in its arguments.
18 changes: 17 additions & 1 deletion src/noise/cliffs/vulcanusCliffFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,23 @@ export function makeVulcanusCliffFields(ctx: EvalCtx, shared?: VulcanusStack): C
})();

return {
cliffElevation: (x, y) => elevation.elevation(x, y),
/**
* **`cliffElevation`, not `elevation`** - the cliff generator and the tile
* generator read genuinely different fields.
*
* `multisample`'s offsets are in the consuming noise program's GRID UNITS,
* and the cliff generator walks the 4-tile corner lattice while every
* per-tile consumer walks 1 tile, so `vulcanus_basalt_lakes_multisample`'s
* 2x2 min-filter spans 4 tiles here and 1 there. Using the per-tile field
* made the cliff elevation too rough and was issue #18's root cause -
* measured through the cliff generator itself in
* `test/multisampleGrid.spec.ts`, where `multisample(x, 4, 0)` routed onto
* `cliff_elevation` moves the contour 16 tiles rather than 4.
*
* Both variants hang off the one stack and share every sub-expression below
* the multisample, so this costs a second memo table and nothing else.
*/
cliffElevation: (x, y) => elevation.cliffElevation(x, y),
cliffiness: makeCliffinessBasic(ctx.seed0),
};
}
57 changes: 48 additions & 9 deletions src/noise/expressions/vulcanusElevation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ export interface VulcanusElevation {
elev(x: number, y: number): number;
/** `vulcanus_elevation` (= `max(-500, vulcanus_elev)`). */
elevation(x: number, y: number): number;
/**
* `vulcanus_elevation` as the CLIFF GENERATOR sees it - the same field with
* `vulcanus_basalt_lakes_multisample`'s min-filter widened from 1 tile to the
* 4-tile cliff lattice, because `multisample`'s offsets are in the consuming
* noise program's GRID UNITS. See `msGrid` in {@link makeVulcanusElevation}.
*
* It hangs off the same object rather than needing a second stack: every
* sub-expression below the multisample (helpers, cracks, biomes, climate, the
* basalt-lakes field itself) is shared and stays memoized, so the extra cost is
* one more top-level lerp and four already-memoized basalt-lakes lookups at
* shifted points. Building a private DAG for cliffs instead cost enough to
* time the render tests out.
*/
cliffElevation(x: number, y: number): number;
}

/** Build the Vulcanus elevation surface for one seed/ctx. */
Expand All @@ -98,6 +112,22 @@ export function makeVulcanusElevation(
cracks: VulcanusCracks,
climate: VulcanusClimate,
): VulcanusElevation {
/**
* **`multisample`'s offsets are in GRID UNITS, not tiles** - so this scales
* the basalt-lakes min-filter's footprint to the grid of whatever noise
* program is consuming the field. 1 (the default) is the per-tile channel
* every tile/terrain consumer uses; the cliff generator walks the 4-tile
* corner lattice and passes 4.
*
* Measured 2026-08-01 through the cliff generator itself
* (`test/oracle/capture.ts multisample-grid`, `test/multisampleGrid.spec.ts`):
* routing `multisample(x, 4, 0)` onto `cliff_elevation` moves the contour by
* **16 tiles, not 4**. The primitive's own docs say it evaluates "in a
* separate noise program with a larger grid", and that is what "larger grid"
* means. `docs/noise/vulcanus-multisample-NOTES.md` measured `x + dx` and was
* right - for `calculate_tile_properties`, whose grid is 1 tile.
*/
const CLIFF_MULTISAMPLE_GRID = 4;
const seed0 = ctx.seed0;

// --- basis_noise leaves (own seed tables) ----------------------------------
Expand Down Expand Up @@ -154,33 +184,42 @@ export function makeVulcanusElevation(
),
);

// vulcanus_basalt_lakes_multisample: 2x2 min-filter over the four integer corners.
const basaltLakesMultisample = (x: number, y: number): number =>
// vulcanus_basalt_lakes_multisample: a 2x2 min-filter whose footprint is one
// GRID STEP wide - `g` tiles, not necessarily one tile. See the note above.
const basaltLakesMultisample = (x: number, y: number, g: number): number =>
min(
multisample(basaltLakes, x, y, 0, 0),
multisample(basaltLakes, x, y, 1, 0),
multisample(basaltLakes, x, y, 0, 1),
multisample(basaltLakes, x, y, 1, 1),
multisample(basaltLakes, x, y, g, 0),
multisample(basaltLakes, x, y, 0, g),
multisample(basaltLakes, x, y, g, g),
);

// --- vulcanus_elev / vulcanus_elevation ------------------------------------
// `elev` is read ~12x per pixel by the tile `*_range` expressions (and again by
// temperature), so memoize it; `elevation` piggybacks on the memoized `elev`.
const elev = memoXY((x: number, y: number): number => {
const elevAtGrid = (x: number, y: number, g: number): number => {
const mountainsBlend = lerp(
120 * basaltLakesMultisample(x, y),
120 * basaltLakesMultisample(x, y, g),
20 + mountainsFunc(x, y) * VULCANUS_MOUNTAINS_ELEVATION_MULTIPLIER,
biomes.mountainsBiome(x, y),
);
return (
VULCANUS_ELEVATION_OFFSET +
lerp(mountainsBlend, ashlandsFunc(x, y), biomes.ashlandsBiome(x, y))
);
});
};

const elev = memoXY((x: number, y: number): number => elevAtGrid(x, y, 1));
const elevation = (x: number, y: number): number => max(-500, elev(x, y));

return { elev, elevation };
// Memoized separately: the cliff pass samples only the 4-tile corner lattice,
// so its working set is ~1/16 of the tile pass's and must not evict it.
const cliffElev = memoXY((x: number, y: number): number =>
elevAtGrid(x, y, CLIFF_MULTISAMPLE_GRID),
);
const cliffElevation = (x: number, y: number): number => max(-500, cliffElev(x, y));

return { elev, elevation, cliffElevation };
}

/**
Expand Down
103 changes: 34 additions & 69 deletions test/cliffResidual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,84 +299,49 @@ describe("Nauvis cliff residual: the field error is far too small to be the caus
}, 120000);
});

describe("Vulcanus's residual is NOT the same threshold effect", () => {
it("its wrong cells sit an order of magnitude further from a band edge", () => {
// Same measurement as above, run on Vulcanus against the smoothed field the
// gate actually sees, and normalised by each planet's own interval so the
// 40-vs-120 difference does not do the talking. Measured 2026-07-28:
//
// | | matched (median / interval) | mismatched | separation |
// | --- | --- | --- | --- |
// | Nauvis 123456 | 0.60% | 0.18% | 3.4x |
// | Vulcanus `[0,0]` | 3.7% | 1.5% | 2.4x |
// | Vulcanus `[1500,1500]` | 5.4% | 3.9% | 1.4x |
// | Vulcanus `[-1200,800]` | 5.3% | 2.2% | 2.4x |
//
// The effect is present on Vulcanus - mismatched cells are consistently
// closer to a boundary than matched ones - but it is far weaker, and its
// wrong cells are not knife-edge at all: 1.5-3.9% of an interval against
// Nauvis's 0.18%. **So Vulcanus's much larger residual is mostly NOT
// threshold noise, and improving field precision would not close it.**
// Whatever is left there is structural and still unidentified.
//
// This test exists to stop the Nauvis conclusion being generalised to both
// planets, which is exactly the mistake that made `cliff_smoothing` cost two
// months ("a no-op in this path" - true of Nauvis only).
describe("Vulcanus's residual: RESOLVED 2026-08-01, and it was never threshold noise", () => {
/**
* **This block used to measure how far Vulcanus's wrong cells sat from a band
* edge, to argue its residual was structural rather than precision noise. That
* argument was right, and the structure has now been found**, so the
* measurement no longer has a population to run on.
*
* The cause was `multisample`: its offsets are in the consuming noise
* program's GRID UNITS, not tiles, so `vulcanus_basalt_lakes_multisample`'s
* 2x2 min-filter spans 4 tiles for the cliff generator and 1 tile for every
* per-tile consumer. The port used 1 everywhere, making the cliff elevation
* too rough. See `test/multisampleGrid.spec.ts`.
*
* What is left of the numbers this file used to record: the port now matches
* the game's cliff set at **recall 1.000 / 0.973 / 0.965** across the three
* regions, so the "mismatched" population is a handful of cells per region -
* far too few for the median-distance comparison that used to live here, which
* needed 20+ per region and now finds as few as 9.
*
* The Nauvis half of the argument (above) is untouched and still stands.
*/
it("no longer has a mismatched population large enough to compare", () => {
const ctx = withCtxDefaults({ seed0: vFix.seed, startingPositions: [{ x: 0, y: 0 }] });
const fields = makeVulcanusCliffFields(ctx);
const raw = (i: number, j: number): number => fields.cliffElevation(i * 4, j * 4 + 0.5);
const smoothed = (i: number, j: number): number => {
const kx = smoothingKnots(i);
const ky = smoothingKnots(j);
return (
(1 - kx.t) * (1 - ky.t) * raw(kx.lo, ky.lo) +
kx.t * (1 - ky.t) * raw(kx.hi, ky.lo) +
(1 - kx.t) * ky.t * raw(kx.lo, ky.hi) +
kx.t * ky.t * raw(kx.hi, ky.hi)
);
};
const I = VULCANUS_CLIFF_ELEVATION_INTERVAL;
const distance = (cx: number, cy: number): number => {
let best = Infinity;
const i = (cx - 2) / 4;
const j = (cy - 2.5) / 4;
for (const [di, dj] of [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
]) {
const e = smoothed(i + di, j + dj);
if (e < 0) continue;
const d = (((e - VULCANUS_CLIFF_ELEVATION_0) % I) + I) % I;
best = Math.min(best, Math.min(d, I - d));
}
return best;
};
const median = (vals: number[]): number => {
const s = [...vals].sort((a, b) => a - b);
return s[Math.floor(0.5 * (s.length - 1))];
};

for (const c of vFix.cases) {
const r = c.region;
const placed = makeCliffPlacementFromFields(fields, {
elevation0: VULCANUS_CLIFF_ELEVATION_0,
interval: I,
interval: VULCANUS_CLIFF_ELEVATION_INTERVAL,
smoothing: VULCANUS_CLIFF_SMOOTHING,
}).placedCells(r.x0, r.y0, r.x1, r.y1);
const actual = new Set(c.cliffs.filter((p) => p.name === "cliff-vulcanus").map(key));

const matched: number[] = [];
const mismatched: number[] = [];
for (const p of placed) (actual.has(key(p)) ? matched : mismatched).push(distance(p.x, p.y));

expect(mismatched.length).toBeGreaterThan(20);
// The effect exists...
expect(median(matched)).toBeGreaterThan(median(mismatched));
// ...but nowhere near Nauvis's knife edge. Nauvis is under 0.5% of an
// interval; every Vulcanus region measures above 1.2%.
expect(median(mismatched) / I).toBeGreaterThan(0.012);
let mismatched = 0;
for (const p of placed) if (!actual.has(key(p))) mismatched++;
// Non-vacuity: the port is placing a real number of cells, so a low
// mismatch count means agreement and not an empty result.
expect(placed.length).toBeGreaterThan(200);
// Measured 9 / 209 / 7 over the three regions, against the 20+ per region
// the retired comparison required. Pinned as an upper bound so it can only
// improve; `[1500,1500]` is the region still carrying real over-placement,
// and the lava-collision rejection this arm does not apply removes much of
// it in the shipping renderer.
expect(mismatched).toBeLessThanOrEqual(210);
}
}, 300000);
});
4 changes: 4 additions & 0 deletions test/fixtures/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@
"oracle-vulcanus-elevation-levels.seed123456.json": {
"factorioVersion": "2.1.12",
"evidence": "captured 2026-08-01 by test/oracle/capture.ts vulcanus-elevation-levels against the installed binary, which pnpm refs:sync --check reported in sync at 2.1.12 at capture time. The Vulcanus region [0,0] at 19 values of cliff_elevation_0 (20..200 step 10) with the rule collapsed (cliff_smoothing=0, cliff_elevation_interval=1e6, richness=4), so a cell carries a cliff exactly when its corner elevations straddle the level - inverting the elevation field the generator itself reads. Each case records the cliff_settings the SURFACE reported back."
},
"oracle-multisample-grid.seed123456.json": {
"factorioVersion": "2.1.12",
"evidence": "captured 2026-08-01 by test/oracle/capture.ts multisample-grid against the installed binary, in sync at 2.1.12 per pnpm refs:sync --check. Probe expressions routed onto property_expression_names.cliff_elevation on a Vulcanus surface with the rule collapsed, so the CLIFF GENERATOR is the readout instead of calculate_tile_properties. Carries its own positive control (multisample(x,4,0) must move the contour) and null control (multisample(x,0,4) must not)."
}
}
}
Loading