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
12 changes: 7 additions & 5 deletions docs/noise/cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> | | recall | precision | wrong orientation |
> | --- | --- | --- | --- |
> | **Nauvis** | 1.0000 | 1.0000 | **0 / 334** |
> | **Vulcanus**, as shipped | **0.9675** | **0.9743** | **31 / 1518 = 2.0%** |
> | **Vulcanus**, as shipped | **0.9758** | **0.9727** | 2.0% |
> | **Vulcanus**, no lava rejection | 0.9758 | 0.8719 | 37 / 1531 = 2.4% |
>
> **Read the shipping row.** The renderer applies `tryToAddCliff`'s lava-collision
Expand All @@ -16,10 +16,12 @@
> `test/vulcanusCliffEntities.spec.ts`; the no-rejection row is kept because
> `test/cliffOrientationOracle.spec.ts` deliberately scores the larger set.
>
> What is left on Vulcanus: 51 of the game's 1569 missing, 40 of our 1558
> spurious, 31 matched cells carrying a wrong orientation. **13 of the 51 are a
> TILE question, not a cliff one** - real cliffs whose collision box hits our lava
> at Chebyshev depth 1, our own perimeter, never deeper. Remainder in **#84**.
> What is left on Vulcanus: 38 of the game's 1569 missing and 43 of our 1574
> spurious. **The 13 that used to be a "TILE question" were neither** - not the
> lava mask (a dense 994-position capture found ZERO mismatches) but the
> collision box, which the port had collapsed from `rotbb`'s rotated rectangle to
> its bounding box. Fixed; see `## The lava perimeter was the COLLISION BOX` in
> `vulcanus-cliffs-NOTES.md`. Remainder in **#84**.
>
> **Read `## ROOT CAUSE, 2026-08-01` (further down) before anything else in this
> file.** It is the resolution of issue #18: `multisample`'s offsets are in the
Expand Down
102 changes: 93 additions & 9 deletions docs/noise/vulcanus-cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

> ## STATUS, 2026-08-01: issue #18 is CLOSED; remainder tracked in #84
>
> **As shipped** (with `tryToAddCliff`'s lava-collision rejection, which is what
> the renderer runs): recall **0.9675**, precision **0.9743**, **31 / 1518 = 2.0%**
> wrong orientations. Without the rejection the same code scores recall 0.9758,
> precision 0.8719, 37 / 1531 = 2.4% - and it is that second row, not a defect,
> that #84 opened against. The rejection drops 198 cells, 185 false positives to
> 13 true. Before the fix: recall 0.806 / 0.938 / 0.853 and 12.5% wrong.
> **As shipped**: recall **0.9758**, precision **0.9727**, ratio 1.003. Region
> `[0,0]` reproduces the game's cliff set entirely (recall **1.0000**). Before
> #18's fix: recall 0.806 / 0.938 / 0.853 and 12.5% wrong orientations.
>
> Two rules do that work and both were found late: `tryToAddCliff`'s
> lava-collision rejection (185 false positives across the three oracle regions,
> precision 0.8719 -> 0.9743) and the fact that its box is a **rotated**
> rectangle rather than its bounding box (recovers 13 real cliffs, recall
> 0.9675 -> 0.9758). See the last two sections.
>
> **Root cause: `multisample`'s offsets are in the calling noise program's GRID
> UNITS, not tiles**, so `vulcanus_basalt_lakes_multisample`'s `min` is a 4-tile
Expand All @@ -17,9 +20,9 @@
> `test/multisampleGrid.spec.ts`.
>
> **Every accuracy table below this banner is the PRE-FIX state**, kept because the
> reasoning is the useful part. Do not quote one as current. The one exception is
> the final section, `## The lava rejection accounted for the "excess"`, which is
> post-fix and is where the numbers in this banner come from.
> reasoning is the useful part. Do not quote one as current. The exceptions are
> the last two sections, which are post-fix and are where this banner's numbers
> come from. Note the SECOND of them corrects the first - read both.

Factorio 2.1.12 (build 87038, mac-arm64). Ported 2026-07-26. Companion to
`cliffs-NOTES.md`, which holds the reverse-engineering of the placement rule
Expand Down Expand Up @@ -1051,3 +1054,84 @@ indistinguishable from "the substitution never ran".
their placement: reading the other elevation channel makes the lava
classification dramatically worse, not better, so the perimeter error is not a
channel mistake. It is somewhere else in `vulcanusCatalog`.

## The lava perimeter was the COLLISION BOX, not the mask (2026-08-01)

The section above concluded that a "sub-tile disagreement about where lava
stops" cost 13 real cliffs their placement, and `vulcanusCliffEntities.spec.ts`
carried the same claim: the tile resolver is "off by about one tile SOMEWHERE".
**That was wrong.** The mask is exact; the collision box was the wrong shape.

### The mask was exonerated by a capture designed to convict it

`oracle-vulcanus-lava-boundary.seed123456.json` samples Chebyshev radius-4
neighbourhoods around the 35 tiles our mask calls lava inside a real cliff's box
- deliberately the hardest positions on the map rather than a representative
sample. Result over 994 positions: **0 lava mismatches in either direction**, and
at the 35 accusing tiles themselves **35/35 agreement**. The game has lava
exactly where we say it does.

That capture was worth making because the existing 381-position survey
structurally could not answer the question: its sensitivity was measured by
planting scale factors on `lava`'s probability, and `1.02` and `1.2` both still
pass. A sparse survey cannot see a sub-tile boundary shift.

### `rotbb` boxes are ROTATED, and the port used their bounding box

`rotbb(x, y, size, intersect)` (`entity-util.lua:9`) returns
`{{cx - x_dist, cy - y_dist}, {cx + x_dist, cy + y_dist}, 1/8}` - a rectangle
**plus an orientation of 1/8**, i.e. 45 degrees. Sixteen of the twenty cliff
orientations are built with it; only the four straight ones are plain
axis-aligned rectangles.

`CLIFF_ORIENTATION_COLLISION_BOX` holds the axis-aligned bounding box. That is
the correct BROAD phase - `wouldCollide` derives its tile rectangle from a
fixed-point floor and scans an inclusive rect, which `cliffCollisionTileBox`
reproduces - but the collision itself is against the rotated rectangle, and the
AABB overruns it at all four corners. `cliffBoxCoversTile` now runs a
separating-axis narrow phase; `test/cliffOrientedBox.spec.ts` pins the geometry.

A note on `rotbbBox` used to say `intersect` could be dropped because it does not
move the AABB. True of the AABB, false of the collision: `intersect` sets how the
diagonal splits, hence which corners are empty.

| | AABB (before) | oriented (after) |
| --- | --- | --- |
| recall | 0.9675 | **0.9758** |
| precision | 0.9743 | 0.9727 |
| `[0,0]` recall | 0.9788 | **1.0000** |
| level-sweep recall | 0.951 at level 20 | **~1.000 at every level** |

It clears **13 of 13** false rejections while keeping 182 of the 185 rejections
that remove genuine false positives - so it is the correct shape, not a
loosening that trades precision for recall.

### Two corrections this forces

- **PR #86's "gap 0.067 -> 0.018" is wrong; it is 0.024.** That figure was
measured with the over-aggressive AABB rejection, which deleted cells the game
keeps and so flattered exactly the ratio it was reporting. **A too-strong
correction hides the thing it is correcting.** The remaining over-placement
below elevation 120 is real and still open - and it is now pure over-placement,
with no recall cost.
- **"All 13 sit at Chebyshev depth 1 in our lava" was a true measurement that
pointed at the wrong suspect.** The box's four corners ARE its perimeter, so a
corner-shaped box error produces exactly the signature a one-tile-fat mask
would. Two mechanisms, one fingerprint. A statistic can only rule a suspect
out if it would come out DIFFERENTLY for each candidate, and a depth histogram
comes out the same for both.

### What was checked and cleared on the way

- **The inclusive-floor fringe is real engine behaviour, not our bug.**
`wouldCollide` uses `(box + position) >> 8` and an inclusive rect, so a box
edge landing exactly on a tile boundary does pull that tile in. Already
established by disassembly; re-confirmed as not the cause.
- **Chunk ordering is not it.** If the generator read a partly-generated tile
grid, the offending tiles would sit disproportionately in neighbouring chunks.
They do not: 2 of 13 cross a chunk boundary against 50 of 185 in the control -
*less* than baseline.
- **6 of the 13 also carry a wrong orientation** (against a 2.0% base rate, ~23x
enrichment), and in every case the game's is a smaller `-to-none` variant of
ours. A wrong orientation means the wrong box, so the two defects compound.
Those 6 belong to the standing orientation residual, which is unchanged.
122 changes: 120 additions & 2 deletions src/noise/cliffs/cliffCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,131 @@ export type CliffCollisionBox = readonly [number, number, number, number];
* `intersect` was. So the AABB is exactly the square `[x, x+size] x [y, y+size]`
* and `intersect` only decides how the diagonal is split inside it.
*
* That is why this helper does not take `intersect`. `test/cliffOrientation.spec.ts`
* re-derives it from the full rotated rectangle instead of restating it here.
* **The AABB is the BROAD phase only - `intersect` is load-bearing after all.**
* A note here used to say `intersect` could be dropped because it does not move
* the AABB. That is true of the AABB and false of the collision: `rotbb` tags
* the box with orientation `1/8` and the engine collides against the ROTATED
* rectangle, whose corners the AABB overruns. `intersect` decides how far the
* diagonal is split, hence which corners are empty. See
* {@link CLIFF_ORIENTATION_ROTBB} and {@link cliffBoxCoversTile}; measured in
* `test/cliffOrientedBox.spec.ts`.
*/
function rotbbBox(x: number, y: number, size: number): CliffCollisionBox {
return [x, y, x + size, y + size];
}

/**
* `rotbb(x, y, size, intersect)`'s four arguments per orientation id, verbatim
* from `create_cliff_data_specification` (`entity-util.lua:85`), or `null` for
* the four straight orientations, whose boxes are written out as plain
* axis-aligned rectangles with no orientation tag.
*
* This exists because {@link CLIFF_ORIENTATION_COLLISION_BOX} is only the
* bounding box. The engine's collision uses the rotated rectangle itself.
*/
export const CLIFF_ORIENTATION_ROTBB: readonly (
| readonly [number, number, number, number]
| null
)[] = [
null, // 0 west-to-east
null, // 1 north-to-south
null, // 2 east-to-west
null, // 3 south-to-north
[-3.5, -3, 4.5, 3], // 4 west-to-north
[-1, -3, 4.5, 1.5], // 5 north-to-east
[-1, -0.5, 3.5, 2.5], // 6 east-to-south
[-2.5, -0.5, 3.5, 1], // 7 south-to-west
[-3.5, -1.5, 4.5, 1.5], // 8 west-to-south
[-2.5, -3, 3.5, 2.5], // 9 north-to-west
[-1, -3, 3.5, 1], // 10 east-to-north
[-1, -1.5, 4.5, 3], // 11 south-to-east
[-3, -1.5, 3, 2], // 12 west-to-none
[0, -1.5, 3, 1], // 13 none-to-east
[0, -0.5, 2.5, 2], // 14 east-to-none
[-2.5, -0.5, 2.51, 0.5], // 15 none-to-west
[-1, -2.5, 3, 1], // 16 north-to-none
[-1, -0.5, 3, 2.5], // 17 none-to-south
[-2, -0.5, 3, 0.5], // 18 south-to-none
[-2, -2.5, 3, 2], // 19 none-to-north
];

const SQRT2 = 1.4142135623730951;

/**
* Does the tile `[tx, tx+1] x [ty, ty+1]` overlap the collision shape of a
* cliff of orientation `id` centred at `(centerX, centerY)`?
*
* For the four straight orientations the shape IS the axis-aligned box, so any
* tile the broad phase enumerated overlaps it and this returns `true`. For the
* sixteen `rotbb` orientations the shape is that rectangle rotated 45 degrees
* clockwise (Factorio orientation `1/8`, and `+y` is south), which the AABB
* overruns at all four corners - a separating-axis test over the two world axes
* and the rectangle's own two decides it.
*
* **Why this is not gold-plating.** Using the AABB drops real cliffs: across the
* three Vulcanus oracle regions the game placed 13 cliffs whose AABB contains
* lava and whose rotated box does not, and it kept every one. Narrowing to the
* oriented rectangle clears **13 of 13** while retaining 182 of the 185
* rejections that were removing genuine false positives - so it is not a
* loosening that trades precision for recall, it is the correct shape.
*/
export function cliffBoxCoversTile(
id: number,
centerX: number,
centerY: number,
tx: number,
ty: number,
): boolean {
const spec = CLIFF_ORIENTATION_ROTBB[id];
if (spec === undefined || spec === null) return true;
const [bx, by, size, intersect] = spec;
const dist = (size / 2) * SQRT2;
const yRatio = intersect / size;
const xDist = (1 - yRatio) * dist;
const yDist = yRatio * dist;
const cx = centerX + bx + size / 2;
const cy = centerY + by + size / 2;
// cos 45 = sin 45; clockwise in screen coords (x east, y south).
const k = Math.SQRT1_2;
const corners: readonly (readonly [number, number])[] = [
[-xDist, -yDist],
[xDist, -yDist],
[xDist, yDist],
[-xDist, yDist],
].map(([u, v]) => [cx + (u - v) * k, cy + (u + v) * k] as const);
const square: readonly (readonly [number, number])[] = [
[tx, ty],
[tx + 1, ty],
[tx + 1, ty + 1],
[tx, ty + 1],
];
const axes: readonly (readonly [number, number])[] = [
[1, 0],
[0, 1],
[k, k],
[-k, k],
];
for (const [ax, ay] of axes) {
let aMin = Infinity;
let aMax = -Infinity;
let bMin = Infinity;
let bMax = -Infinity;
for (const [px, py] of corners) {
const d = px * ax + py * ay;
if (d < aMin) aMin = d;
if (d > aMax) aMax = d;
}
for (const [px, py] of square) {
const d = px * ax + py * ay;
if (d < bMin) bMin = d;
if (d > bMax) bMax = d;
}
// Touching is not overlapping: a tile the rectangle only grazes is free.
if (aMax <= bMin || bMax <= aMin) return false;
}
return true;
}

/**
* `CliffOrientation` id -> the orientation's `collision_bounding_box`, at
* `scale = 1.0` (both `cliff` and `cliff-vulcanus`), relative to the cliff's
Expand Down
15 changes: 14 additions & 1 deletion src/noise/cliffs/cliffPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
CLIFF_CELL_CENTER_X,
CLIFF_CELL_CENTER_Y,
CLIFF_GRID_SIZE,
cliffBoxCoversTile,
cliffCollisionTileBox,
cliffOrientationForCode,
getModifiedElevationInterval,
isCliffPlaced,
} from "./cliffCatalog";
Expand Down Expand Up @@ -318,14 +320,25 @@ export function makeCliffPlacementFromFields(
* `tryToAddCliff`'s rejection, as a predicate on an already-placed cell: scan
* the orientation's collision box and drop the cell if any tile in it collides.
* With no `tileCollides` supplied this is a constant `false` and costs nothing.
*
* **Two phases, because sixteen of the twenty boxes are rotated.**
* `cliffCollisionTileBox` is the BROAD phase - the axis-aligned tile rectangle
* `wouldCollide` derives with `(box + position) >> 8`. For the four straight
* orientations that is the whole shape. For the sixteen `rotbb` ones the real
* shape is that rectangle turned 45 degrees, so `cliffBoxCoversTile` runs a
* narrow phase and discards the AABB's four empty corners. Skipping it drops
* 13 real Vulcanus cliffs whose corners happen to overhang lava.
*/
const rejected = (code: number, x: number, y: number): boolean => {
if (tileCollides === undefined) return false;
const box = cliffCollisionTileBox(code, x, y);
// `undefined` only for a code that places nothing, which cannot reach here.
if (box === undefined) return false;
const id = cliffOrientationForCode(code);
if (id === undefined) return false;
for (let tx = box.left; tx <= box.right; tx++)
for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true;
for (let ty = box.top; ty <= box.bottom; ty++)
if (tileCollides(tx, ty) && cliffBoxCoversTile(id, x, y, tx, ty)) return true;
return false;
};

Expand Down
Loading