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
122 changes: 119 additions & 3 deletions docs/noise/cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,12 @@ Fields exact, rule structurally confirmed, precision irrelevant, smoothing
confirmed - and 12.5% of shared cells still carry the wrong crossings. The
candidates that survive:

1. **`fixImpossibleCells` in detail.** It only accounts for 1.8 points of the
12.5 as ported, but "our sweep differs subtly from the game's" is not excluded
by that - a different sweep could be both closer AND worth more.
1. ~~**`fixImpossibleCells` in detail.**~~ **Closed 2026-08-01** - see the next
section. All 24 clear orders were swept (the ported `L, T, R, B` wins) and,
decisively, the pass finds only 35 illegal cells and clears 59 edges across
all three regions. It is too small by a factor of five to be the cause. The
original wording here - "a different sweep could be both closer AND worth
more" - was right to keep it open and wrong about the size.
2. ~~**The choice of expression, which no substitution can test.**~~ **Closed
from the data, 2026-07-30.** The worry was real - the corner-field fixtures
capture `vulcanus_elevation` and `cliffiness_basic` **by name**, so if a
Expand All @@ -404,6 +407,119 @@ candidates that survive:
different field - so this was a live way to be wrong, in the same shape as the
`cliff_smoothing` default that cost issue #18 two months.

### Nine more causes falsified, 2026-08-01 - and candidate 1 above is now CLOSED

The list above left `fixImpossibleCells` as the surviving candidate. It is not the
cause, and neither is anything else that can be reached by varying the port.

**The harness, because it is reusable and it is what makes these cheap.** The
smoothing is a transform on the elevation field, so a candidate smoothing can be
*pre-applied to the field* and the placement then run with `smoothing: 0`. That
runs the SHIPPING rule over a candidate transform, needs no edit to
`cliffPlacement.ts`, and scores in ~1.5s for all three regions because one field
cache is shared across every candidate. The control arm reproduces
**1400 matched / 175 wrong** to the digit, which is what licenses the rest.

**1. The smoothing knot geometry is right, and it is a sharp optimum in four
dimensions.** Sweeping `(span, clamp, anchor offset, blend s)`:

| variation | wrong / matched | |
| --- | --- | --- |
| **shipping: span 4, clamp 7, offset 0, s=1** | **175 / 1400** | **12.5%** |
| clamp 8 (the "clean" global bilerp) | 378 / 1124 | 33.6% |
| clamp 6 | 485 / 1189 | 40.8% |
| anchor offset 1..7 | 523-578 | 51-72% |
| s = 0.9 / 0.75 / 0.5 / 0 | 238 / 371 / 530 / 705 | 17.6-62.8% |
| span 8 / span 2 | 367 / 572 | 64.8% / 55.1% |

Both axes were also swept independently (`clampX` x `clampY`, `offsetX` x
`offsetY`): the minimum is at (7,7) and (0,0), not on any off-diagonal. **The
odd clamp-to-7 asymmetry that reads like a misread is 2.7x better than the clean
reading** - so the disassembly is now corroborated by something other than
itself, which is exactly what that section needed.

**2. Cliffiness stays raw.** Smoothing it too: 279/1397 = 20.0%.

**3. Linear interpolation, not a curve.** smoothstep 29.3%, smootherstep 37.3%,
nearest 61.8%, sqrt 50.5%, square 53.9% - against linear's 14.3% in the same
(fix-sweep-free) harness.

**4. The band constants are a sharp optimum too.** `cliff_elevation_0` swept
64..76 bottoms exactly at **70** (12.5%, rising to 25.7% at 65 and 23.1% at 76);
`cliff_elevation_interval` bottoms exactly at **120**. The elevation range over
the sampled corners is -62.3 .. 1226.3, so ~10 bands are genuinely in play and
the interval is not inert.

**5. No structural variant of `crossesCliff` helps.** Dropping the negative-
elevation gate 12.9%, gating on raw elevation instead 12.5%, non-strict
comparison 12.5%, dropping the cliffiness gate 22.5%, anchoring the band on
`min(a,b)` places nothing at all. The "different band" formulation scores
**exactly** 175 - it is provably identical to the max-anchored one except when
`max(a,b)` sits exactly on a boundary.

**6. `fixImpossibleCells` cannot be the cause, by size.** All 24 edge-clearing
priorities were swept: the ported **`L, T, R, B` is the best of all 24** (175,
against 181-217). But the decisive number is that across all three regions the
pass finds only **35 illegal cells and clears 59 edges**. Identical fields plus
an identical rule give identical pre-fix codes, so a pass that touches ~35 cells
cannot produce 175 wrong ones. This closes candidate 1.

**7. The fields were compared as VALUES for the first time, not as outcomes.**
Every previous exoneration ran the substitution and asserted the placement did
not move. That is weaker than it sounds. Comparing our field directly against
`oracle-vulcanus-cliff-corner-fields-entity-regions` over all 12,675 corners:

| | median | max |
| --- | --- | --- |
| elevation \|ours - game\| | 2.7e-4 | **4.8e-2** |
| cliffiness \|ours - game\| | 0 | 6.4e-6 |

The corrections the failures need are **~3.6 elevation units** (below), four
orders of magnitude larger. The fields are right, and now that is measured
rather than inferred from a null result.

**8. The packing, the lattice and the table are cleared from the fixture alone.**
See `test/cliffEdgeConsistency.spec.ts`: the game's own adjacent cliffs agree on
**every** shared edge (Nauvis 147 h + 166 v, Vulcanus 805 h + 834 v, zero
mismatches), and all 1569 `cliff-vulcanus` land exactly on `(cx*4+2, cy*4+2.5)`
while exactly the 8 `crater-cliff`s do not.

**9. The failure shape says "diffuse", not "structural".** The cliffiness gate
blocks **zero** of the edges the game crossed; 141 of 156 are `sameSide` (our two
corner elevations do not straddle a band) and 13 are `band<e0`. Not one
disagreement is a sign flip - every differing edge is a crossing appearing or
disappearing, never reversing. The error rate is flat over in-chunk cell position
(8x8, no structure), flat over position in the smoothing lattice (so it is not
concentrated on interpolated corners), spread over 64 distinct orientation
transitions, and mostly isolated (137 wrong cells have a correct neighbour, 66 a
wrong one).

#### What this leaves, stated as the contradiction it is

Fields right to 5e-2. Smoothing at a sharp four-dimensional optimum. Rule,
bands, packing, table and lattice all confirmed. Fix sweep too small by a factor
of five. And yet 175 of 1400 shared cells still carry different crossings.

The sharpest single statement of the residual: over the 5891 edges of the game's
own cliffs where the cliffiness gate is open, **our smoothed elevation predicts
the game's crossing 88.7% of the time and the raw elevation 60.3%**. So the
smoothing is doing real and largely correct work, and the last ~11% is a
difference between the game's smoothed cliff elevation and a linear bilerp of
the raw field on the chunk-anchored 0/4/7 lattice - a difference of a few
elevation units, at corners where the raw field agrees to 0.05.

**The next step is the binary, not another behavioural sweep.** The space of
transforms reachable by varying the port has been searched and it bottoms out
here. `CliffGenerator::crossingsForChunk` @ `0x10160c9cc` should be re-read for
the smoothing loop specifically - what it reads, in what order, and into what -
rather than for its structure, which is already confirmed.

**One methodological note, because it cost a wrong reading.** Nauvis's fixture
cases are the same region at **different seeds**. Merging them into one cell map
invents four "inconsistent" adjacent pairs out of nothing, which briefly looked
like a finding about chunk-boundary edges. Tally per case. The committed spec
says so at the function.

### The FFF on cliffs (#219) - checked, and mostly confirms the binary

https://factorio.com/blog/post/fff-219 is the design writeup. Read 2026-07-30 so
Expand Down
179 changes: 179 additions & 0 deletions test/cliffEdgeConsistency.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vite-plus/test";

import nauvis from "./fixtures/oracle-cliff-entities.seed123456.json";
import vulcanus from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json";
import {
CLIFF_CELL_CENTER_X,
CLIFF_CELL_CENTER_Y,
CLIFF_CODE_TO_ORIENTATION,
CLIFF_GRID_SIZE,
CLIFF_ORIENTATION_NAMES,
} from "../src/noise/cliffs/cliffCatalog";

interface Cliff {
readonly x: number;
readonly y: number;
readonly name?: string;
readonly orientation: string;
}

/** Orientation name -> the 8-bit crossing code, inverting the placing half of the table. */
const inverseTable = (swap?: readonly [number, number]): Map<string, number> => {
const names = [...CLIFF_ORIENTATION_NAMES];
if (swap !== undefined) {
const [a, b] = swap;
[names[a], names[b]] = [names[b], names[a]];
}
const m = new Map<string, number>();
for (const [codeStr, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION))
m.set(names[id], Number(codeStr));
return m;
};

const key = (x: number, y: number): string => `${String(x)},${String(y)}`;

interface Tally {
onLattice: number;
offLattice: number;
hPairs: number;
vPairs: number;
mismatches: string[];
}

/**
* Rebuild each case's cliff cells from the game's own `cliff_orientation`, then
* check that every two adjacent cells agree on the edge they share.
*
* Cases are tallied **separately** and never merged. Nauvis's cases are the same
* region at different seeds, so a shared map would collide two unrelated cliffs
* at one cell and invent mismatches - measured, it invents four.
*/
const tally = (cases: { readonly cliffs: readonly Cliff[] }[], only?: string): Tally => {
const table = inverseTable();
const out: Tally = { onLattice: 0, offLattice: 0, hPairs: 0, vPairs: 0, mismatches: [] };
for (const c of cases) {
const cells = new Map<string, number>();
for (const p of c.cliffs) {
if (only !== undefined && p.name !== only) continue;
const cx = Math.round((p.x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE);
const cy = Math.round((p.y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE);
if (
Math.abs(cx * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X - p.x) > 1e-9 ||
Math.abs(cy * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y - p.y) > 1e-9
) {
out.offLattice++;
continue;
}
out.onLattice++;
const code = table.get(p.orientation);
if (code !== undefined) cells.set(key(cx, cy), code);
}
for (const [k, code] of cells) {
const [cx, cy] = k.split(",").map(Number);
const right = cells.get(key(cx + 1, cy));
if (right !== undefined) {
out.hPairs++;
// R of the left cell is L of the right cell: the same vertical edge.
if (((code >> 4) & 3) !== ((right >> 6) & 3)) out.mismatches.push(`h ${k}`);
}
const below = cells.get(key(cx, cy + 1));
if (below !== undefined) {
out.vPairs++;
// B of the upper cell is T of the lower cell.
if ((code & 3) !== ((below >> 2) & 3)) out.mismatches.push(`v ${k}`);
}
}
}
return out;
};

/**
* **The game's own cliffs, checked against each other** (issue #18).
*
* Every other cliff oracle in this repo compares the port's output against the
* game's. This one uses *no field, no rule and no port output at all*: it takes
* the game's `cliff_orientation` for each cliff, inverts
* `CLIFF_CODE_TO_ORIENTATION` to recover the 8-bit crossing code the engine must
* have held, and asserts that two cliffs sitting in adjacent cells agree about
* the edge between them - the engine stores one value per edge, so they must.
*
* What that pins, which nothing else did:
*
* - **The code packing** (`L R T B`, two bits each, in that order). A transposed
* or reordered packing makes neighbours disagree immediately.
* - **The position -> cell mapping** (`cx * 4 + 2`, `cy * 4 + 2.5`). Every real
* cliff must land exactly on that lattice.
* - **The orientation table**, in the inverse direction.
*
* It matters because these three were the last unmeasured links in the Vulcanus
* orientation residual. `cliffOrientationOracle.spec.ts` shows Nauvis matching
* 334/334, which argues the table is right; but Nauvis exercises neither
* `cliff_smoothing` nor a continuous cliffiness field, so "Nauvis is exact"
* could not by itself clear the packing for Vulcanus. This does, and it does it
* from the fixture alone - measured 2026-08-01, both planets agree on **every**
* shared edge.
*/
describe("the game's cliffs agree with each other on shared edges", () => {
const n = tally(nauvis.cases);
const v = tally(vulcanus.cases, "cliff-vulcanus");

it("puts every real cliff exactly on the 4-tile placement lattice", () => {
// Measured 2026-08-01. Pinned exactly: these are the full fixture contents,
// so a shrinking comparison set cannot pass unnoticed.
expect(n.onLattice).toBe(334);
expect(n.offLattice).toBe(0);
expect(v.onLattice).toBe(1569);
expect(v.offLattice).toBe(0);
});

it("finds the 8 crater-cliffs OFF the lattice, which is what makes that test sharp", () => {
// `crater-cliff` is placed by the entity autoplace with jitter, not by
// `generateCliffs`, so its positions are fractional. Without this the
// lattice assertion above could pass vacuously against a rounding that
// accepted anything.
const all = tally(vulcanus.cases);
expect(all.offLattice).toBe(8);
expect(all.onLattice).toBe(1569);
});

it("agrees on every shared edge, on both planets", () => {
expect(n.mismatches).toEqual([]);
expect(v.mismatches).toEqual([]);
// Non-vacuity: without these the loop would pass by comparing nothing.
// Measured 2026-08-01.
expect(n.hPairs).toBe(147);
expect(n.vPairs).toBe(166);
expect(v.hPairs).toBe(805);
expect(v.vPairs).toBe(834);
});

it("FAILS when the orientation table is corrupted - the guard discriminates", () => {
// Swap two orientations in the inverse table and the same data must stop
// being self-consistent. Without this, a check that trivially passed
// (say, because every code decoded to the same edges) would look like
// confirmation. `north-to-south` (5) and `east-to-west` (7) are both common
// in the fixtures, so the swap is guaranteed to be exercised.
const table = inverseTable([5, 7]);
let pairs = 0;
let bad = 0;
for (const c of vulcanus.cases) {
const cells = new Map<string, number>();
for (const p of c.cliffs) {
if (p.name !== "cliff-vulcanus") continue;
const cx = Math.round((p.x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE);
const cy = Math.round((p.y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE);
const code = table.get(p.orientation);
if (code !== undefined) cells.set(key(cx, cy), code);
}
for (const [k, code] of cells) {
const [cx, cy] = k.split(",").map(Number);
const right = cells.get(key(cx + 1, cy));
if (right === undefined) continue;
pairs++;
if (((code >> 4) & 3) !== ((right >> 6) & 3)) bad++;
}
}
expect(pairs).toBe(805);
expect(bad).toBeGreaterThan(0);
});
});