Skip to content

Commit 8243b65

Browse files
wormeymanclaude
andauthored
fix(cliffs): rotbb collision boxes are ROTATED, not their bounding box (#84) (#88)
The lava perimeter that cost 13 real Vulcanus cliffs their placement was never the lava mask. It was the collision box's shape. 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 axis-aligned. The port kept only the bounding box, which overruns the rotated rectangle at all four corners, so it rejected cliffs on lava the game never looks at. CLIFF_ORIENTATION_COLLISION_BOX remains the correct BROAD phase - wouldCollide derives its tile rect from a fixed-point floor over an inclusive rectangle, which cliffCollisionTileBox reproduces. cliffBoxCoversTile adds the narrow phase (separating-axis over the two world axes and the rectangle's own two). | | 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 @ lvl20 | ~1.000 everywhere | Clears 13 of 13 false rejections while keeping 182 of the 185 rejections that remove genuine false positives - the correct shape, not a loosening that trades precision for recall. How it was found, because the route matters more than the fix. The standing explanation - in this spec's own comments and in the notes - was that the tile resolver was "off by about one tile SOMEWHERE". A new dense capture (oracle-vulcanus-lava-boundary, radius-4 neighbourhoods around the 35 accusing tiles) found ZERO lava mismatches over 994 positions and 35/35 at the accusing tiles themselves. The mask was innocent. It was worth capturing 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. Two corrections this forces: - PR #86's "gap 0.067 -> 0.018" is wrong; it is 0.024. That was measured with the over-aggressive AABB rejection, which deleted cells the game keeps and so flattered exactly the ratio it reported. A too-strong correction hides the thing it is correcting. The residual below elevation 120 is real, still open, and 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 the same signature a one-tile-fat mask would. A statistic can only rule a suspect out if it would come out DIFFERENTLY for each candidate. Checked and cleared on the way: the inclusive-floor fringe is real engine behaviour (disassembly), and chunk ordering is not it (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 (~23x enrichment over the 2.0% base rate), always a smaller -to-none variant of ours; those belong to the standing orientation residual, which is unchanged. Guards: cliffOrientedBox.spec.ts pins the geometry, and the levels sweep asserts per-level recall > 0.99. Both verified non-vacuous by planting AABB behaviour - they fail with "expected 0 to be greater than 20" and "expected 0.9514 to be greater than 0.99". Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b843eaf commit 8243b65

10 files changed

Lines changed: 5667 additions & 82 deletions

docs/noise/cliffs-NOTES.md

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

docs/noise/vulcanus-cliffs-NOTES.md

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
> ## STATUS, 2026-08-01: issue #18 is CLOSED; remainder tracked in #84
44
>
5-
> **As shipped** (with `tryToAddCliff`'s lava-collision rejection, which is what
6-
> the renderer runs): recall **0.9675**, precision **0.9743**, **31 / 1518 = 2.0%**
7-
> wrong orientations. Without the rejection the same code scores recall 0.9758,
8-
> precision 0.8719, 37 / 1531 = 2.4% - and it is that second row, not a defect,
9-
> that #84 opened against. The rejection drops 198 cells, 185 false positives to
10-
> 13 true. Before the fix: recall 0.806 / 0.938 / 0.853 and 12.5% wrong.
5+
> **As shipped**: recall **0.9758**, precision **0.9727**, ratio 1.003. Region
6+
> `[0,0]` reproduces the game's cliff set entirely (recall **1.0000**). Before
7+
> #18's fix: recall 0.806 / 0.938 / 0.853 and 12.5% wrong orientations.
8+
>
9+
> Two rules do that work and both were found late: `tryToAddCliff`'s
10+
> lava-collision rejection (185 false positives across the three oracle regions,
11+
> precision 0.8719 -> 0.9743) and the fact that its box is a **rotated**
12+
> rectangle rather than its bounding box (recovers 13 real cliffs, recall
13+
> 0.9675 -> 0.9758). See the last two sections.
1114
>
1215
> **Root cause: `multisample`'s offsets are in the calling noise program's GRID
1316
> UNITS, not tiles**, so `vulcanus_basalt_lakes_multisample`'s `min` is a 4-tile
@@ -17,9 +20,9 @@
1720
> `test/multisampleGrid.spec.ts`.
1821
>
1922
> **Every accuracy table below this banner is the PRE-FIX state**, kept because the
20-
> reasoning is the useful part. Do not quote one as current. The one exception is
21-
> the final section, `## The lava rejection accounted for the "excess"`, which is
22-
> post-fix and is where the numbers in this banner come from.
23+
> reasoning is the useful part. Do not quote one as current. The exceptions are
24+
> the last two sections, which are post-fix and are where this banner's numbers
25+
> come from. Note the SECOND of them corrects the first - read both.
2326
2427
Factorio 2.1.12 (build 87038, mac-arm64). Ported 2026-07-26. Companion to
2528
`cliffs-NOTES.md`, which holds the reverse-engineering of the placement rule
@@ -1051,3 +1054,84 @@ indistinguishable from "the substitution never ran".
10511054
their placement: reading the other elevation channel makes the lava
10521055
classification dramatically worse, not better, so the perimeter error is not a
10531056
channel mistake. It is somewhere else in `vulcanusCatalog`.
1057+
1058+
## The lava perimeter was the COLLISION BOX, not the mask (2026-08-01)
1059+
1060+
The section above concluded that a "sub-tile disagreement about where lava
1061+
stops" cost 13 real cliffs their placement, and `vulcanusCliffEntities.spec.ts`
1062+
carried the same claim: the tile resolver is "off by about one tile SOMEWHERE".
1063+
**That was wrong.** The mask is exact; the collision box was the wrong shape.
1064+
1065+
### The mask was exonerated by a capture designed to convict it
1066+
1067+
`oracle-vulcanus-lava-boundary.seed123456.json` samples Chebyshev radius-4
1068+
neighbourhoods around the 35 tiles our mask calls lava inside a real cliff's box
1069+
- deliberately the hardest positions on the map rather than a representative
1070+
sample. Result over 994 positions: **0 lava mismatches in either direction**, and
1071+
at the 35 accusing tiles themselves **35/35 agreement**. The game has lava
1072+
exactly where we say it does.
1073+
1074+
That capture was worth making because the existing 381-position survey
1075+
structurally could not answer the question: its sensitivity was measured by
1076+
planting scale factors on `lava`'s probability, and `1.02` and `1.2` both still
1077+
pass. A sparse survey cannot see a sub-tile boundary shift.
1078+
1079+
### `rotbb` boxes are ROTATED, and the port used their bounding box
1080+
1081+
`rotbb(x, y, size, intersect)` (`entity-util.lua:9`) returns
1082+
`{{cx - x_dist, cy - y_dist}, {cx + x_dist, cy + y_dist}, 1/8}` - a rectangle
1083+
**plus an orientation of 1/8**, i.e. 45 degrees. Sixteen of the twenty cliff
1084+
orientations are built with it; only the four straight ones are plain
1085+
axis-aligned rectangles.
1086+
1087+
`CLIFF_ORIENTATION_COLLISION_BOX` holds the axis-aligned bounding box. That is
1088+
the correct BROAD phase - `wouldCollide` derives its tile rectangle from a
1089+
fixed-point floor and scans an inclusive rect, which `cliffCollisionTileBox`
1090+
reproduces - but the collision itself is against the rotated rectangle, and the
1091+
AABB overruns it at all four corners. `cliffBoxCoversTile` now runs a
1092+
separating-axis narrow phase; `test/cliffOrientedBox.spec.ts` pins the geometry.
1093+
1094+
A note on `rotbbBox` used to say `intersect` could be dropped because it does not
1095+
move the AABB. True of the AABB, false of the collision: `intersect` sets how the
1096+
diagonal splits, hence which corners are empty.
1097+
1098+
| | AABB (before) | oriented (after) |
1099+
| --- | --- | --- |
1100+
| recall | 0.9675 | **0.9758** |
1101+
| precision | 0.9743 | 0.9727 |
1102+
| `[0,0]` recall | 0.9788 | **1.0000** |
1103+
| level-sweep recall | 0.951 at level 20 | **~1.000 at every level** |
1104+
1105+
It clears **13 of 13** false rejections while keeping 182 of the 185 rejections
1106+
that remove genuine false positives - so it is the correct shape, not a
1107+
loosening that trades precision for recall.
1108+
1109+
### Two corrections this forces
1110+
1111+
- **PR #86's "gap 0.067 -> 0.018" is wrong; it is 0.024.** That figure was
1112+
measured with the over-aggressive AABB rejection, which deleted cells the game
1113+
keeps and so flattered exactly the ratio it was reporting. **A too-strong
1114+
correction hides the thing it is correcting.** The remaining over-placement
1115+
below elevation 120 is real and still open - and it is now pure over-placement,
1116+
with no recall cost.
1117+
- **"All 13 sit at Chebyshev depth 1 in our lava" was a true measurement that
1118+
pointed at the wrong suspect.** The box's four corners ARE its perimeter, so a
1119+
corner-shaped box error produces exactly the signature a one-tile-fat mask
1120+
would. Two mechanisms, one fingerprint. A statistic can only rule a suspect
1121+
out if it would come out DIFFERENTLY for each candidate, and a depth histogram
1122+
comes out the same for both.
1123+
1124+
### What was checked and cleared on the way
1125+
1126+
- **The inclusive-floor fringe is real engine behaviour, not our bug.**
1127+
`wouldCollide` uses `(box + position) >> 8` and an inclusive rect, so a box
1128+
edge landing exactly on a tile boundary does pull that tile in. Already
1129+
established by disassembly; re-confirmed as not the cause.
1130+
- **Chunk ordering is not it.** If the generator read a partly-generated tile
1131+
grid, the offending tiles would sit disproportionately in neighbouring chunks.
1132+
They do not: 2 of 13 cross a chunk boundary against 50 of 185 in the control -
1133+
*less* than baseline.
1134+
- **6 of the 13 also carry a wrong orientation** (against a 2.0% base rate, ~23x
1135+
enrichment), and in every case the game's is a smaller `-to-none` variant of
1136+
ours. A wrong orientation means the wrong box, so the two defects compound.
1137+
Those 6 belong to the standing orientation residual, which is unchanged.

src/noise/cliffs/cliffCatalog.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,13 +274,131 @@ export type CliffCollisionBox = readonly [number, number, number, number];
274274
* `intersect` was. So the AABB is exactly the square `[x, x+size] x [y, y+size]`
275275
* and `intersect` only decides how the diagonal is split inside it.
276276
*
277-
* That is why this helper does not take `intersect`. `test/cliffOrientation.spec.ts`
278-
* re-derives it from the full rotated rectangle instead of restating it here.
277+
* **The AABB is the BROAD phase only - `intersect` is load-bearing after all.**
278+
* A note here used to say `intersect` could be dropped because it does not move
279+
* the AABB. That is true of the AABB and false of the collision: `rotbb` tags
280+
* the box with orientation `1/8` and the engine collides against the ROTATED
281+
* rectangle, whose corners the AABB overruns. `intersect` decides how far the
282+
* diagonal is split, hence which corners are empty. See
283+
* {@link CLIFF_ORIENTATION_ROTBB} and {@link cliffBoxCoversTile}; measured in
284+
* `test/cliffOrientedBox.spec.ts`.
279285
*/
280286
function rotbbBox(x: number, y: number, size: number): CliffCollisionBox {
281287
return [x, y, x + size, y + size];
282288
}
283289

290+
/**
291+
* `rotbb(x, y, size, intersect)`'s four arguments per orientation id, verbatim
292+
* from `create_cliff_data_specification` (`entity-util.lua:85`), or `null` for
293+
* the four straight orientations, whose boxes are written out as plain
294+
* axis-aligned rectangles with no orientation tag.
295+
*
296+
* This exists because {@link CLIFF_ORIENTATION_COLLISION_BOX} is only the
297+
* bounding box. The engine's collision uses the rotated rectangle itself.
298+
*/
299+
export const CLIFF_ORIENTATION_ROTBB: readonly (
300+
| readonly [number, number, number, number]
301+
| null
302+
)[] = [
303+
null, // 0 west-to-east
304+
null, // 1 north-to-south
305+
null, // 2 east-to-west
306+
null, // 3 south-to-north
307+
[-3.5, -3, 4.5, 3], // 4 west-to-north
308+
[-1, -3, 4.5, 1.5], // 5 north-to-east
309+
[-1, -0.5, 3.5, 2.5], // 6 east-to-south
310+
[-2.5, -0.5, 3.5, 1], // 7 south-to-west
311+
[-3.5, -1.5, 4.5, 1.5], // 8 west-to-south
312+
[-2.5, -3, 3.5, 2.5], // 9 north-to-west
313+
[-1, -3, 3.5, 1], // 10 east-to-north
314+
[-1, -1.5, 4.5, 3], // 11 south-to-east
315+
[-3, -1.5, 3, 2], // 12 west-to-none
316+
[0, -1.5, 3, 1], // 13 none-to-east
317+
[0, -0.5, 2.5, 2], // 14 east-to-none
318+
[-2.5, -0.5, 2.51, 0.5], // 15 none-to-west
319+
[-1, -2.5, 3, 1], // 16 north-to-none
320+
[-1, -0.5, 3, 2.5], // 17 none-to-south
321+
[-2, -0.5, 3, 0.5], // 18 south-to-none
322+
[-2, -2.5, 3, 2], // 19 none-to-north
323+
];
324+
325+
const SQRT2 = 1.4142135623730951;
326+
327+
/**
328+
* Does the tile `[tx, tx+1] x [ty, ty+1]` overlap the collision shape of a
329+
* cliff of orientation `id` centred at `(centerX, centerY)`?
330+
*
331+
* For the four straight orientations the shape IS the axis-aligned box, so any
332+
* tile the broad phase enumerated overlaps it and this returns `true`. For the
333+
* sixteen `rotbb` orientations the shape is that rectangle rotated 45 degrees
334+
* clockwise (Factorio orientation `1/8`, and `+y` is south), which the AABB
335+
* overruns at all four corners - a separating-axis test over the two world axes
336+
* and the rectangle's own two decides it.
337+
*
338+
* **Why this is not gold-plating.** Using the AABB drops real cliffs: across the
339+
* three Vulcanus oracle regions the game placed 13 cliffs whose AABB contains
340+
* lava and whose rotated box does not, and it kept every one. Narrowing to the
341+
* oriented rectangle clears **13 of 13** while retaining 182 of the 185
342+
* rejections that were removing genuine false positives - so it is not a
343+
* loosening that trades precision for recall, it is the correct shape.
344+
*/
345+
export function cliffBoxCoversTile(
346+
id: number,
347+
centerX: number,
348+
centerY: number,
349+
tx: number,
350+
ty: number,
351+
): boolean {
352+
const spec = CLIFF_ORIENTATION_ROTBB[id];
353+
if (spec === undefined || spec === null) return true;
354+
const [bx, by, size, intersect] = spec;
355+
const dist = (size / 2) * SQRT2;
356+
const yRatio = intersect / size;
357+
const xDist = (1 - yRatio) * dist;
358+
const yDist = yRatio * dist;
359+
const cx = centerX + bx + size / 2;
360+
const cy = centerY + by + size / 2;
361+
// cos 45 = sin 45; clockwise in screen coords (x east, y south).
362+
const k = Math.SQRT1_2;
363+
const corners: readonly (readonly [number, number])[] = [
364+
[-xDist, -yDist],
365+
[xDist, -yDist],
366+
[xDist, yDist],
367+
[-xDist, yDist],
368+
].map(([u, v]) => [cx + (u - v) * k, cy + (u + v) * k] as const);
369+
const square: readonly (readonly [number, number])[] = [
370+
[tx, ty],
371+
[tx + 1, ty],
372+
[tx + 1, ty + 1],
373+
[tx, ty + 1],
374+
];
375+
const axes: readonly (readonly [number, number])[] = [
376+
[1, 0],
377+
[0, 1],
378+
[k, k],
379+
[-k, k],
380+
];
381+
for (const [ax, ay] of axes) {
382+
let aMin = Infinity;
383+
let aMax = -Infinity;
384+
let bMin = Infinity;
385+
let bMax = -Infinity;
386+
for (const [px, py] of corners) {
387+
const d = px * ax + py * ay;
388+
if (d < aMin) aMin = d;
389+
if (d > aMax) aMax = d;
390+
}
391+
for (const [px, py] of square) {
392+
const d = px * ax + py * ay;
393+
if (d < bMin) bMin = d;
394+
if (d > bMax) bMax = d;
395+
}
396+
// Touching is not overlapping: a tile the rectangle only grazes is free.
397+
if (aMax <= bMin || bMax <= aMin) return false;
398+
}
399+
return true;
400+
}
401+
284402
/**
285403
* `CliffOrientation` id -> the orientation's `collision_bounding_box`, at
286404
* `scale = 1.0` (both `cliff` and `cliff-vulcanus`), relative to the cliff's

src/noise/cliffs/cliffPlacement.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
CLIFF_CELL_CENTER_X,
1414
CLIFF_CELL_CENTER_Y,
1515
CLIFF_GRID_SIZE,
16+
cliffBoxCoversTile,
1617
cliffCollisionTileBox,
18+
cliffOrientationForCode,
1719
getModifiedElevationInterval,
1820
isCliffPlaced,
1921
} from "./cliffCatalog";
@@ -318,14 +320,25 @@ export function makeCliffPlacementFromFields(
318320
* `tryToAddCliff`'s rejection, as a predicate on an already-placed cell: scan
319321
* the orientation's collision box and drop the cell if any tile in it collides.
320322
* With no `tileCollides` supplied this is a constant `false` and costs nothing.
323+
*
324+
* **Two phases, because sixteen of the twenty boxes are rotated.**
325+
* `cliffCollisionTileBox` is the BROAD phase - the axis-aligned tile rectangle
326+
* `wouldCollide` derives with `(box + position) >> 8`. For the four straight
327+
* orientations that is the whole shape. For the sixteen `rotbb` ones the real
328+
* shape is that rectangle turned 45 degrees, so `cliffBoxCoversTile` runs a
329+
* narrow phase and discards the AABB's four empty corners. Skipping it drops
330+
* 13 real Vulcanus cliffs whose corners happen to overhang lava.
321331
*/
322332
const rejected = (code: number, x: number, y: number): boolean => {
323333
if (tileCollides === undefined) return false;
324334
const box = cliffCollisionTileBox(code, x, y);
325335
// `undefined` only for a code that places nothing, which cannot reach here.
326336
if (box === undefined) return false;
337+
const id = cliffOrientationForCode(code);
338+
if (id === undefined) return false;
327339
for (let tx = box.left; tx <= box.right; tx++)
328-
for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true;
340+
for (let ty = box.top; ty <= box.bottom; ty++)
341+
if (tileCollides(tx, ty) && cliffBoxCoversTile(id, x, y, tx, ty)) return true;
329342
return false;
330343
};
331344

0 commit comments

Comments
 (0)