Skip to content

Commit 8d08f20

Browse files
wormeymanclaude
andauthored
fix(cliffs): sample the fields at the game's lattice - Nauvis is now EXACT (#18) (#70)
The port read `cliff_elevation` and `cliffiness` at `(i*4, j*4 + 0.5)`, adding the cliff prototype's `grid_offset {0, 0.5}` to the SAMPLE position. That offset is a CENTRE offset. The game samples the bare `(i*4, j*4)`. Two independent sources: - `CliffGenerator::crossingsForChunk` (arm64 `0x10160c9cc`) reads `grid_size` at `[proto+0xb60]`/`[0xb68]` and NEVER `grid_offset` at `[0xb70]`/`[0xb78]`; its sample origin is `chunkPos << 5` converted to float, step `grid_size`. It computes no other coordinate. - `base/prototypes/entity/entity-util.lua:305` says it outright: "cliffs are auto-placed with centers at (0, 0.5) offset from the grid". Nauvis goes 0.943 -> **1.0000 recall, 1.0000 precision, ratio 1.000** at both seeds - exact agreement with `find_entities_filtered`. Vulcanus recall 0.792/0.870/0.803 -> 0.806/0.938/0.853; its over-placement is unchanged and stays open on #18. Why it hid for two months: the error moves NO placed cliff, because cell centres are derived from their own constants. So `x mod 4 == 2`, `y mod 4 == 2.5`, the preview agreement, and PR #57's substitution of the game's own field values (zero cells moved) all passed - that fixture had itself been captured at the port's assumed site. A substitution test can falsify a value; it cannot falsify the site it was sampled at. Fixture handling: the Vulcanus corner-fields oracle is re-captured at the game's lattice; the previous capture is kept as `-legacy-y0.5`, since it is valid ground truth for the site it names and is what lets a spec show the blind spot. Both carry provenance. Test changes are re-measurements, not accommodations: - `cliffPlacement.spec.ts` now pins Nauvis at EQUALITY, not >= 0.85. - `cliffResidual.spec.ts`'s "wrong cells sit on band boundaries" block asserted the residual exists; there are no wrong cells left, so it now asserts the set is empty. - `cliffSmoothing.spec.ts`'s plane gains a `.37` offset: at `j*4` the old constant put corner elevations exactly ON band edges (measured: all 7 disagreeing cells at distance 0, bilerp giving 7.1e-15), so it tested float rounding rather than smoothing. - `vulcanusCliffs.spec.ts`'s independent blend now indexes corners instead of deriving positions from centres. - `vulcanusOreCliffSeparation.spec.ts`: full-ore false positives 47 -> 46. The game still places zero; that finding is untouched. Claude-Session: https://claude.ai/code/session_01BkKb3S2cGyRFFHSzuCQBgj Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b6de98d commit 8d08f20

14 files changed

Lines changed: 55273 additions & 17096 deletions

docs/noise/client-preview-ROADMAP.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,14 +308,20 @@ Done = ore patches overlaid on land, responding to the frequency/size/richness s
308308
noise fields (`cliff_elevation_nauvis`, `cliffiness_nauvis`) plus a
309309
disasm-derived geometric placement rule (`crossesCliff` + the 4-tile grid +
310310
`toMaybeCliffOrientation` none/not-none predicate), levers wired
311-
(`nauvis_cliff` frequency/continuity + `cliffSettings`). Validated ~94% against
312-
real `find_entities_filtered{type="cliff"}` dumps (frac 0.943 seed 123456,
313-
0.942 seed 777771) and cross-checked against a real
311+
(`nauvis_cliff` frequency/continuity + `cliffSettings`). Validated **100%**
312+
against real `find_entities_filtered{type="cliff"}` dumps (frac 1.000 at
313+
both seeds since 2026-07-30; was 0.943/0.942 while the fields were sampled
314+
half a tile off in y) and cross-checked against a real
314315
`factorio --generate-map-preview` render of the same seed/region (95.7%
315316
spatial agreement at render resolution, using the game's own emitted
316317
`[144,119,87]` pixels as ground truth). Full writeup: `cliffs-NOTES.md`.
317318
**All three items previously listed here as "still deferred" are now
318-
retired, and none of them explained the ~6% Nauvis residual** (2026-07-28,
319+
retired, and none of them explained the ~6% Nauvis residual - which is
320+
itself now RESOLVED (2026-07-30): the fields were sampled at `j*4 + 0.5`
321+
because the port added the prototype's `grid_offset` (a CENTRE offset) to
322+
the sample position. Corrected, Nauvis is **1.0000 recall / 1.0000
323+
precision / ratio 1.000** at both seeds, i.e. exact. The five earlier
324+
causes were falsified on** (2026-07-28,
319325
the day five stated causes were falsified - see
320326
`notes-must-say-how-they-were-measured` and `cliffs-NOTES.md`):
321327
`fixImpossibleCells` was **ported** in PR #32 (`dea73ac`,

docs/noise/cliffs-NOTES.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,34 @@ presets, but all 9 strings in `builtin-presets.json` decode to `u8 = 0` and
208208
either way; what is unknown is whether the wire carries smoothing at all. Worth an
209209
issue if the Nauvis render is ever driven by a Lakes/Island preset.
210210

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

213240
Reimplementing the rule (sample `cliff_elevation_nauvis` + `cliffiness_nauvis` at the
214241
corner lattice via the oracle, apply the crossing rule + lattice) reproduced the real

src/noise/cliffs/cliffCatalog.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,31 @@ export const CLIFF_MARK_SIZE_PX = 4;
5757
*/
5858
export const CLIFF_MARK_BACK_PX = 2;
5959

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

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

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

75-
/** Y offset applied to a cliff cell corner. */
76-
export const CLIFF_CORNER_OFFSET_Y = 0.5;
77-
7896
/** The `nauvis_cliff` autoplace control's frequency/size sliders (size doubles as continuity). */
7997
export interface CliffControls {
8098
frequency: number;

src/noise/cliffs/cliffPlacement.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { makeCliffFields } from "./cliffFields";
1212
import {
1313
CLIFF_CELL_CENTER_X,
1414
CLIFF_CELL_CENTER_Y,
15-
CLIFF_CORNER_OFFSET_Y,
1615
CLIFF_GRID_SIZE,
1716
getModifiedElevationInterval,
1817
isCliffPlaced,
@@ -232,11 +231,17 @@ export function makeCliffPlacementFromFields(
232231
if (bands.disabled === true) return [];
233232

234233
const raw = new Map<string, number>();
234+
/**
235+
* Sampled at the BARE lattice `(i*4, j*4)`. The prototype's `grid_offset`
236+
* is a CENTRE offset, not a sample offset - see `CLIFF_CELL_CENTER_X` -
237+
* and `crossingsForChunk` never reads it. Adding it here (as this did
238+
* until 2026-07-30) moves no cliff and costs ~7 points of recall.
239+
*/
235240
const rawElevation = (i: number, j: number): number => {
236241
const key = `${i},${j}`;
237242
let value = raw.get(key);
238243
if (value === undefined) {
239-
value = cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE + CLIFF_CORNER_OFFSET_Y);
244+
value = cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE);
240245
raw.set(key, value);
241246
}
242247
return value;
@@ -277,7 +282,7 @@ export function makeCliffPlacementFromFields(
277282
let sample = corners.get(key);
278283
if (sample === undefined) {
279284
const wx = i * CLIFF_GRID_SIZE;
280-
const wy = j * CLIFF_GRID_SIZE + CLIFF_CORNER_OFFSET_Y;
285+
const wy = j * CLIFF_GRID_SIZE;
281286
sample = { elev: elevationAt(i, j), cliff: cliffiness(wx, wy) };
282287
corners.set(key, sample);
283288
}

test/cliffPlacement.spec.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,21 @@ describe("cliff placement vs find_entities (~94% drift guard)", () => {
8686
const actual = c.cliffs.map(key);
8787
const matched = actual.filter((k) => predicted.has(k)).length;
8888
const frac = matched / actual.length;
89-
expect(frac).toBeGreaterThanOrEqual(0.85);
90-
91-
// Over-placement guard. Measured ratio is exactly 1.000 at both seeds, so
92-
// this is tight on purpose: cliff placement is deterministic, and anything
93-
// that starts inventing cliffs should fail here rather than hide behind a
94-
// recall figure.
9589
const precision = matched / predicted.size;
96-
expect(precision).toBeGreaterThanOrEqual(0.85);
97-
expect(predicted.size / actual.length).toBeLessThan(1.1);
90+
91+
// **EXACT since 2026-07-30.** 282/282 at seed 123456 and 52/52 at 777771 -
92+
// every real cliff placed, nothing invented. The long-standing ~6%
93+
// residual was the port sampling the fields at `j*4 + 0.5`: it added the
94+
// prototype's `grid_offset {0, 0.5}`, which is a CENTRE offset, to the
95+
// SAMPLE position (see `CLIFF_CELL_CENTER_X`).
96+
//
97+
// Pinned at equality on purpose. Placement is deterministic given the seed
98+
// - there is no roll - so an inequality here would let a real regression
99+
// hide inside the slack, which is how the 0.943 sat unexplained for two
100+
// months while five different causes were proposed for it.
101+
expect(frac).toBe(1);
102+
expect(precision).toBe(1);
103+
expect(predicted.size).toBe(actual.length);
98104
});
99105
}
100106
});

test/cliffResidual.spec.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ describe("Nauvis cliff residual: water rejection cannot be the cause", () => {
127127
}
128128
});
129129

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

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

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

179-
// Nauvis's wrong cells are on a KNIFE EDGE: the median sits 0.07 out of a
180-
// 40-wide band, 0.18% of an interval. That is the number to compare
181-
// Vulcanus against below - not the raw distance, since the two planets run
182-
// different intervals (40 vs 120).
183-
expect(median(mismatched) / 40).toBeLessThan(0.005);
169+
// **The residual is GONE.** Not shrunk - zero. Every cell we place is a
170+
// real cliff (no false positives) and the recall/precision spec in
171+
// `cliffPlacement.spec.ts` now measures 1.0000 / 1.0000 / ratio 1.000 at
172+
// both seeds, up from 0.943 / 0.943.
173+
//
174+
// The cause was the SAMPLE LATTICE, not the rule and not the field: the
175+
// port added the prototype's `grid_offset {0, 0.5}` - a CENTRE offset -
176+
// to the field sample position as well, reading every corner half a tile
177+
// off in y. It moved no placed cliff, so every positional check passed.
178+
// See `CLIFF_CELL_CENTER_X` in cliffCatalog.ts.
179+
//
180+
// This block used to assert the OPPOSITE - that mismatched cells exist
181+
// and sit closer to band edges than matched ones (medians 3.4x and 4.3x
182+
// apart). That measurement was real and is preserved in git; it described
183+
// a marginal decision, not a cause, exactly as `boundary-proximity-is-not
184+
// -a-cause` concluded. `distance` above is kept because the loop still
185+
// partitions on it, which is what proves the mismatched set is empty
186+
// because there is nothing in it - not because the loop never ran.
187+
expect(mismatched.length).toBe(0);
188+
expect(actual.size).toBe(placed.length);
184189
}, 120000);
185190
}
186191
});

test/cliffSmoothing.spec.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,20 @@ describe("cliff_smoothing behaviour", () => {
8383
// A plane. Bilinear interpolation of a plane is the plane itself, whatever
8484
// the knot spacing - so this is a sharp test that the four weights sum to 1
8585
// and that lo/hi bracket the corner rather than merely being near it.
86-
cliffElevation: (x, y) => 0.7 * x + 0.3 * y + 100,
86+
//
87+
// The `.37` is load-bearing and was `100` until 2026-07-30. Corners are
88+
// sampled at `(i*4, j*4)`, so a constant of 100 makes every corner
89+
// elevation `2.8i + 1.2j + 100` - a multiple of 0.4 - and the default bands
90+
// sit at `10 + 40n`, which such a value can hit EXACTLY. Measured: all 7
91+
// cells that then disagreed between smoothing 0 and 1 had a corner at
92+
// distance exactly 0 from a band edge, where the raw path computes 0 and
93+
// the bilerp computes 7.1e-15, so a `>=` goes two ways for reasons that have
94+
// nothing to do with smoothing. `.37` is not a multiple of 0.4, so no corner
95+
// can land on a band edge and the test measures the property it names.
96+
//
97+
// It passed before only because the port sampled at `j*4 + 0.5`, adding
98+
// 0.15 and knocking the plane off the boundaries by accident.
99+
cliffElevation: (x, y) => 0.7 * x + 0.3 * y + 100.37,
87100
cliffiness: alwaysCliffy,
88101
};
89102

test/fixtures/PROVENANCE.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,13 @@
184184
"factorioVersion": "2.1.12",
185185
"evidence": "stated in the fixture's own _comment"
186186
},
187+
"oracle-vulcanus-cliff-corner-fields-legacy-y0.5.seed123456.json": {
188+
"factorioVersion": "2.1.12",
189+
"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."
190+
},
187191
"oracle-vulcanus-cliff-corner-fields.seed123456.json": {
188192
"factorioVersion": "2.1.12",
189-
"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"
193+
"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."
190194
},
191195
"oracle-vulcanus-cliff-entities.seed123456.json": {
192196
"factorioVersion": "2.1.12",

0 commit comments

Comments
 (0)