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
14 changes: 10 additions & 4 deletions docs/noise/client-preview-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,20 @@ Done = ore patches overlaid on land, responding to the frequency/size/richness s
noise fields (`cliff_elevation_nauvis`, `cliffiness_nauvis`) plus a
disasm-derived geometric placement rule (`crossesCliff` + the 4-tile grid +
`toMaybeCliffOrientation` none/not-none predicate), levers wired
(`nauvis_cliff` frequency/continuity + `cliffSettings`). Validated ~94% against
real `find_entities_filtered{type="cliff"}` dumps (frac 0.943 seed 123456,
0.942 seed 777771) and cross-checked against a real
(`nauvis_cliff` frequency/continuity + `cliffSettings`). Validated **100%**
against real `find_entities_filtered{type="cliff"}` dumps (frac 1.000 at
both seeds since 2026-07-30; was 0.943/0.942 while the fields were sampled
half a tile off in y) and cross-checked against a real
`factorio --generate-map-preview` render of the same seed/region (95.7%
spatial agreement at render resolution, using the game's own emitted
`[144,119,87]` pixels as ground truth). Full writeup: `cliffs-NOTES.md`.
**All three items previously listed here as "still deferred" are now
retired, and none of them explained the ~6% Nauvis residual** (2026-07-28,
retired, and none of them explained the ~6% Nauvis residual - which is
itself now RESOLVED (2026-07-30): the fields were sampled at `j*4 + 0.5`
because the port added the prototype's `grid_offset` (a CENTRE offset) to
the sample position. Corrected, Nauvis is **1.0000 recall / 1.0000
precision / ratio 1.000** at both seeds, i.e. exact. The five earlier
causes were falsified on** (2026-07-28,
the day five stated causes were falsified - see
`notes-must-say-how-they-were-measured` and `cliffs-NOTES.md`):
`fixImpossibleCells` was **ported** in PR #32 (`dea73ac`,
Expand Down
29 changes: 28 additions & 1 deletion docs/noise/cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,34 @@ presets, but all 9 strings in `builtin-presets.json` decode to `u8 = 0` and
either way; what is unknown is whether the wire carries smoothing at all. Worth an
issue if the Nauvis render is ever driven by a Lakes/Island preset.

## Validation result and the deferred residual
## Validation result: EXACT since 2026-07-30 (the residual is resolved)

> **The ~6% Nauvis residual documented throughout this section is GONE, and the
> cause was none of the six things named for it.** The port sampled the two
> fields at `(i*4, j*4 + 0.5)`, adding the prototype's `grid_offset {0, 0.5}` to
> the SAMPLE position. That offset is a **centre** offset - the game data says so
> at `base/prototypes/entity/entity-util.lua:305` ("cliffs are auto-placed with
> centers at (0, 0.5) offset from the grid") and `CliffGenerator::crossingsForChunk`
> reads `grid_size` (`[proto+0xb60]`/`[0xb68]`) and never `grid_offset`
> (`[0xb70]`/`[0xb78]`), taking its sample origin from `chunkPos << 5`.
>
> Correcting it takes Nauvis from **0.943 / 0.943 to 1.0000 recall, 1.0000
> precision, ratio 1.000 at both seeds** - exact agreement with
> `find_entities_filtered` - and Vulcanus recall from 0.792/0.870/0.803 to
> 0.806/0.938/0.853.
>
> Why it hid for two months, and why five other causes were "confirmed" instead:
> the error moves **no placed cliff**. Cell centres are derived from their own
> constants, so `x mod 4 == 2` / `y mod 4 == 2.5` held, the preview agreement
> held, and PR #57's substitution of the game's own field values changed zero
> cells - because that oracle fixture had itself been captured at the port's
> assumed lattice. A substitution test can falsify a VALUE; it cannot falsify the
> SITE it was sampled at.
>
> Everything below is preserved as the record of the investigation. Read the
> numbers in it as historical.

## Validation result and the deferred residual (historical - see above)

Reimplementing the rule (sample `cliff_elevation_nauvis` + `cliffiness_nauvis` at the
corner lattice via the oracle, apply the crossing rule + lattice) reproduced the real
Expand Down
28 changes: 23 additions & 5 deletions src/noise/cliffs/cliffCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,31 @@ export const CLIFF_MARK_SIZE_PX = 4;
*/
export const CLIFF_MARK_BACK_PX = 2;

/** X coordinate of a cliff cell's center, in cell-local tiles. */
/**
* Cliff cell centre, in cell-local tiles: `grid_size/2 + grid_offset`.
*
* **`grid_offset` belongs HERE, on the centre, and nowhere else.** The
* prototype's `grid_offset` is `{0, 0.5}` for `scale == 1` (both `cliff` and
* `cliff-vulcanus`), and `base/prototypes/entity/entity-util.lua:305` says what
* it is for in as many words: "cliffs are auto-placed with centers at (0, 0.5)
* offset from the grid". So `x = 4/2 + 0 = 2` and `y = 4/2 + 0.5 = 2.5`, which
* is why every dumped cliff satisfies `x mod 4 == 2`, `y mod 4 == 2.5`.
*
* The FIELDS are sampled at the bare lattice `(i*4, j*4)` - no offset. This was
* wrong here until 2026-07-30: a `CLIFF_CORNER_OFFSET_Y = 0.5` added the centre
* offset to the sample position too, displacing every field read half a tile in
* y. It was invisible because it does not move a single placed cliff - the
* centre constants below are independent - so the mod-4 checks, the preview
* agreement and the PR #57 field substitution all passed. Confirmed against the
* binary (`CliffGenerator::crossingsForChunk` reads `grid_size` at
* `[proto+0xb60]`/`[0xb68]` and never `grid_offset` at `[0xb70]`/`[0xb78]`;
* sample origin is `chunkPos << 5` converted to float) and by re-capturing the
* oracle at the correct lattice: Vulcanus recall 0.8701 -> 0.9379, FP 301 ->
* 235 at `[1500,1500]`.
*/
export const CLIFF_CELL_CENTER_X = 2;

/** Y coordinate of a cliff cell's center, in cell-local tiles. */
/** @see CLIFF_CELL_CENTER_X - carries the prototype's `grid_offset.y` of 0.5. */
export const CLIFF_CELL_CENTER_Y = 2.5;

/** Default `cliff_elevation_0` map-gen setting (elevation of the first cliff band). */
Expand All @@ -72,9 +93,6 @@ export const CLIFF_ELEVATION_INTERVAL_DEFAULT = 40;
/** seed1 for the low-frequency cliffiness basis noise. */
export const LOW_FREQ_CLIFFINESS_SEED1 = 86883;

/** Y offset applied to a cliff cell corner. */
export const CLIFF_CORNER_OFFSET_Y = 0.5;

/** The `nauvis_cliff` autoplace control's frequency/size sliders (size doubles as continuity). */
export interface CliffControls {
frequency: number;
Expand Down
11 changes: 8 additions & 3 deletions src/noise/cliffs/cliffPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { makeCliffFields } from "./cliffFields";
import {
CLIFF_CELL_CENTER_X,
CLIFF_CELL_CENTER_Y,
CLIFF_CORNER_OFFSET_Y,
CLIFF_GRID_SIZE,
getModifiedElevationInterval,
isCliffPlaced,
Expand Down Expand Up @@ -232,11 +231,17 @@ export function makeCliffPlacementFromFields(
if (bands.disabled === true) return [];

const raw = new Map<string, number>();
/**
* Sampled at the BARE lattice `(i*4, j*4)`. The prototype's `grid_offset`
* is a CENTRE offset, not a sample offset - see `CLIFF_CELL_CENTER_X` -
* and `crossingsForChunk` never reads it. Adding it here (as this did
* until 2026-07-30) moves no cliff and costs ~7 points of recall.
*/
const rawElevation = (i: number, j: number): number => {
const key = `${i},${j}`;
let value = raw.get(key);
if (value === undefined) {
value = cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE + CLIFF_CORNER_OFFSET_Y);
value = cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE);
raw.set(key, value);
}
return value;
Expand Down Expand Up @@ -277,7 +282,7 @@ export function makeCliffPlacementFromFields(
let sample = corners.get(key);
if (sample === undefined) {
const wx = i * CLIFF_GRID_SIZE;
const wy = j * CLIFF_GRID_SIZE + CLIFF_CORNER_OFFSET_Y;
const wy = j * CLIFF_GRID_SIZE;
sample = { elev: elevationAt(i, j), cliff: cliffiness(wx, wy) };
corners.set(key, sample);
}
Expand Down
22 changes: 14 additions & 8 deletions test/cliffPlacement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,21 @@ describe("cliff placement vs find_entities (~94% drift guard)", () => {
const actual = c.cliffs.map(key);
const matched = actual.filter((k) => predicted.has(k)).length;
const frac = matched / actual.length;
expect(frac).toBeGreaterThanOrEqual(0.85);

// Over-placement guard. Measured ratio is exactly 1.000 at both seeds, so
// this is tight on purpose: cliff placement is deterministic, and anything
// that starts inventing cliffs should fail here rather than hide behind a
// recall figure.
const precision = matched / predicted.size;
expect(precision).toBeGreaterThanOrEqual(0.85);
expect(predicted.size / actual.length).toBeLessThan(1.1);

// **EXACT since 2026-07-30.** 282/282 at seed 123456 and 52/52 at 777771 -
// every real cliff placed, nothing invented. The long-standing ~6%
// residual was the port sampling the fields at `j*4 + 0.5`: it added the
// prototype's `grid_offset {0, 0.5}`, which is a CENTRE offset, to the
// SAMPLE position (see `CLIFF_CELL_CENTER_X`).
//
// Pinned at equality on purpose. Placement is deterministic given the seed
// - there is no roll - so an inequality here would let a real regression
// hide inside the slack, which is how the 0.943 sat unexplained for two
// months while five different causes were proposed for it.
expect(frac).toBe(1);
expect(precision).toBe(1);
expect(predicted.size).toBe(actual.length);
});
}
});
39 changes: 22 additions & 17 deletions test/cliffResidual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ describe("Nauvis cliff residual: water rejection cannot be the cause", () => {
}
});

describe("Nauvis cliff residual: the wrong cells sit on band boundaries", () => {
describe("Nauvis cliff residual: RESOLVED 2026-07-30 - there are no wrong cells", () => {
for (const { seed } of CASES) {
it(`seed ${String(seed)}: mismatched cells are closer to a band edge than matched ones`, () => {
it(`seed ${String(seed)}: every placed cell is a real cliff, and every real cliff is placed`, () => {
// Distance from the nearest cliff band boundary (`10 + 40k`), minimised
// over the cell's four corners. Measured 2026-07-28:
//
Expand Down Expand Up @@ -159,28 +159,33 @@ describe("Nauvis cliff residual: the wrong cells sit on band boundaries", () =>
}
return best;
};
const median = (vals: number[]): number => {
const s = [...vals].sort((a, b) => a - b);
return s[Math.floor(0.5 * (s.length - 1))];
};

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

expect(matched.length).toBeGreaterThan(40);
expect(mismatched.length).toBeGreaterThan(0);
// Pinned loosely: the measured gap is 3.4x and 4.3x, so 1.8x leaves room
// for the port to improve (which would SHRINK the mismatched set and could
// move its median either way) without going green on a regression that
// erased the effect entirely.
expect(median(matched) / median(mismatched)).toBeGreaterThan(1.8);

// Nauvis's wrong cells are on a KNIFE EDGE: the median sits 0.07 out of a
// 40-wide band, 0.18% of an interval. That is the number to compare
// Vulcanus against below - not the raw distance, since the two planets run
// different intervals (40 vs 120).
expect(median(mismatched) / 40).toBeLessThan(0.005);
// **The residual is GONE.** Not shrunk - zero. Every cell we place is a
// real cliff (no false positives) and the recall/precision spec in
// `cliffPlacement.spec.ts` now measures 1.0000 / 1.0000 / ratio 1.000 at
// both seeds, up from 0.943 / 0.943.
//
// The cause was the SAMPLE LATTICE, not the rule and not the field: the
// port added the prototype's `grid_offset {0, 0.5}` - a CENTRE offset -
// to the field sample position as well, reading every corner half a tile
// off in y. It moved no placed cliff, so every positional check passed.
// See `CLIFF_CELL_CENTER_X` in cliffCatalog.ts.
//
// This block used to assert the OPPOSITE - that mismatched cells exist
// and sit closer to band edges than matched ones (medians 3.4x and 4.3x
// apart). That measurement was real and is preserved in git; it described
// a marginal decision, not a cause, exactly as `boundary-proximity-is-not
// -a-cause` concluded. `distance` above is kept because the loop still
// partitions on it, which is what proves the mismatched set is empty
// because there is nothing in it - not because the loop never ran.
expect(mismatched.length).toBe(0);
expect(actual.size).toBe(placed.length);
}, 120000);
}
});
Expand Down
15 changes: 14 additions & 1 deletion test/cliffSmoothing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,20 @@ describe("cliff_smoothing behaviour", () => {
// A plane. Bilinear interpolation of a plane is the plane itself, whatever
// the knot spacing - so this is a sharp test that the four weights sum to 1
// and that lo/hi bracket the corner rather than merely being near it.
cliffElevation: (x, y) => 0.7 * x + 0.3 * y + 100,
//
// The `.37` is load-bearing and was `100` until 2026-07-30. Corners are
// sampled at `(i*4, j*4)`, so a constant of 100 makes every corner
// elevation `2.8i + 1.2j + 100` - a multiple of 0.4 - and the default bands
// sit at `10 + 40n`, which such a value can hit EXACTLY. Measured: all 7
// cells that then disagreed between smoothing 0 and 1 had a corner at
// distance exactly 0 from a band edge, where the raw path computes 0 and
// the bilerp computes 7.1e-15, so a `>=` goes two ways for reasons that have
// nothing to do with smoothing. `.37` is not a multiple of 0.4, so no corner
// can land on a band edge and the test measures the property it names.
//
// It passed before only because the port sampled at `j*4 + 0.5`, adding
// 0.15 and knocking the plane off the boundaries by accident.
cliffElevation: (x, y) => 0.7 * x + 0.3 * y + 100.37,
cliffiness: alwaysCliffy,
};

Expand Down
6 changes: 5 additions & 1 deletion test/fixtures/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,13 @@
"factorioVersion": "2.1.12",
"evidence": "stated in the fixture's own _comment"
},
"oracle-vulcanus-cliff-corner-fields-legacy-y0.5.seed123456.json": {
"factorioVersion": "2.1.12",
"evidence": "captured 2026-07-29 by test/oracle/capture.ts vulcanus-cliff-corner-fields against the installed binary, which pnpm refs:sync --check reported in sync at 2.1.12 at capture time. Superseded 2026-07-30: the sample lattice (j*4+0.5) was the PORT's assumption, not the game's. Retained deliberately - it is valid ground truth for the site it names, and keeping it is what lets a spec show that substituting fields captured at OUR site cannot falsify that site."
},
"oracle-vulcanus-cliff-corner-fields.seed123456.json": {
"factorioVersion": "2.1.12",
"evidence": "captured 2026-07-29 by test/oracle/capture.ts vulcanus-cliff-corner-fields against the installed binary, which pnpm refs:sync --check reported in sync at 2.1.12 at capture time"
"evidence": "re-captured 2026-07-30 by test/oracle/capture.ts vulcanus-cliff-corner-fields against the installed binary, which pnpm refs:sync --check reported in sync at 2.1.12 at capture time. Supersedes the 2026-07-29 capture (kept as the -legacy-y0.5 fixture): that one sampled at j*4+0.5, the port's assumed lattice, not the game's."
},
"oracle-vulcanus-cliff-entities.seed123456.json": {
"factorioVersion": "2.1.12",
Expand Down
Loading