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
9 changes: 8 additions & 1 deletion docs/noise/cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
> | | recall | precision | wrong orientation |
> | --- | --- | --- | --- |
> | **Nauvis** | 1.0000 | 1.0000 | **0 / 334** |
> | **Vulcanus**, as shipped | **0.9758** | **0.9727** | 2.0% |
> | **Vulcanus**, as shipped | **0.9720** | **0.9713** | ~2.4% |
> | **Vulcanus**, no lava rejection | 0.9758 | 0.8719 | 37 / 1531 = 2.4% |
>
> The shipped row went 0.9675 -> 0.9758 -> **0.9720** in one day as the collision
> box was corrected twice. The middle value came from a 45-degree oriented-box
> model (#88) that scored best and was **wrong**; disassembly showed the engine
> discards the box's orientation tag entirely. Do not "restore" the better
> number - see `## The collision box, settled by disassembly` in
> `vulcanus-cliffs-NOTES.md`.
>
> **Read the shipping row.** The renderer applies `tryToAddCliff`'s lava-collision
> rejection and the second row does not; leaving it off is what produced the
> "precision 0.872 / 187-cell excess" figure #84 opened with. The rejection drops
Expand Down
83 changes: 76 additions & 7 deletions docs/noise/vulcanus-cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

> ## STATUS, 2026-08-01: issue #18 is CLOSED; remainder tracked in #84
>
> **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
> **As shipped**: recall **0.9720**, precision **0.9713**, ratio 1.001. 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.
> lava-collision rejection (185 false positives across the three oracle regions)
> and the shape of its box, which is the **raw stored rectangle** - the engine
> discards the `1/8` orientation tag. See the last three sections, and note the
> LAST one corrects the one before it.
>
> **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 @@ -22,7 +21,8 @@
> **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 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.
> come from. Each of the last three corrects the one before it - read all three,
> in order, or you will act on a superseded number.

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 @@ -1135,3 +1135,72 @@ loosening that trades precision for recall.
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.

## The collision box, settled by disassembly (2026-08-02)

The section above is **wrong about the mechanism** and its numbers are
superseded. It concluded the engine collides against `rotbb`'s rectangle rotated
45 degrees. It does not. The engine uses the **raw stored rectangle**, and
discards the orientation tag.

### What the binary does

Three steps, all in the 2.1.12 arm64 slice:

1. `EntityMapGenerationTask::tryToAddCliff` (`0x101625038`) switches on the
orientation, loads that entry's box from `proto + 0x5c0 + id*0x48` (20 bytes:
four `int32` edges at `+4`, the orientation word at `+0x14`), and calls
`wouldCollide` with **`Direction = 0`** - literally `mov x4, #0x0`.
2. `EntityMapGenerationTask::wouldCollide` (`0x101625468`) forwards box and
direction to `BoundingBox::BoundingBox(BoundingBox const&, Direction)`
(`0x101c04380`), then floors the result with `(box + position) >> 8` and scans
the inclusive tile rectangle against a 96x96 mask grid.
3. That constructor zeroes the destination, writes the sentinel `0x80010000` into
the destination's orientation word, and dispatches on the direction through a
jump table whose **entry 0 is 0** (read at `0x102d01400`) - the identity arm,
which copies `left_top`/`right_bottom` verbatim and returns. The source box's
own orientation is never read. The rotation arm below it, which calls
`Vector2<double, Vector>::rotate(Direction)`, is reachable only for a non-zero
`Direction`.

Corroborated by the API mirror rather than by disassembly alone: `BoundingBox` is
documented as `{MapPosition, MapPosition}` **or** `{MapPosition, MapPosition,
RealOrientation}` with `orientation` optional, and
`OrientedCliffPrototype::collision_bounding_box` is a plain `BoundingBox`.
Nothing in the docs says collision honours the orientation, and the binary says
it does not.

### Three shapes, and the best-scoring one is wrong

| box | false rejections | recall | precision | evidence |
| --- | --- | --- | --- | --- |
| AABB `[x, x+size] x [y, y+size]` | 13 | 0.9675 | 0.9743 | none - an assumption |
| 45-degree oriented rect (#88) | **0** | **0.9758** | 0.9727 | empirical fit only |
| **raw stored rect (current)** | 6 | 0.9720 | 0.9713 | **disassembly + API docs** |

**#88 scored best on every metric and was wrong.** It shrank the box past what
the engine uses, and the excess shrinkage also absorbed a *different* defect: 4
of the 6 cliffs the correct box still rejects are cells where our orientation
disagrees with the game's, so we load the wrong box entirely. Those 4 belong to
the standing orientation residual and should stay visible.

Note the raw rectangle is not simply "smaller". `hx + hy` is fixed at
`size/2*sqrt2`, so its area is at most half the AABB's - but with a small
`intersect` it sticks out PAST the AABB in x while collapsing in y. A first
attempt to assert containment on every axis failed for that reason.

Edges are quantised to 1/256 because `MapPosition` is 8-bit fixed point, so
`x_dist`'s `sqrt(2)` cannot reach the engine at full precision.

### The lesson, which is the same one twice in two days

**A correction that scores better than the truth is still wrong, and it is
dangerous precisely because it scores better.** #86 over-reported a collapsing
gap because the AABB box was over-rejecting; #88 then hit 13/13 by over-shrinking
and hid four orientation bugs. Both times the flattering number came from a
too-strong correction. When a fix lands on a metric perfectly, treat that as a
prompt to find the independent evidence, not as the evidence.

The route that worked here was: stop tuning shapes against the metric, and go
read what the engine does. The binary is unstripped and the whole chain took
three `lldb` calls.
170 changes: 52 additions & 118 deletions src/noise/cliffs/cliffCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,38 +263,65 @@ export const CLIFF_CODE_TO_ORIENTATION: Readonly<Record<number, number>> = {
export type CliffCollisionBox = readonly [number, number, number, number];

/**
* `rotbb(x, y, size, intersect)`'s axis-aligned bounding box
* `rotbb(x, y, size, intersect)` as the ENGINE reads it back
* (`base/prototypes/entity/entity-util.lua:9`).
*
* `rotbb` builds a rectangle centred at `(x + size/2, y + size/2)` with
* half-extents `((1 - intersect/size) * d, (intersect/size) * d)` where
* `d = size/2 * sqrt(2)`, and tags it with an orientation of **1/8** - a 45
* degree rotation. Rotating those half-extents by 45 gives an AABB half-extent
* of `(hx + hy) * cos(45) = (d) * cos(45) = size/2` on BOTH axes, whatever
* `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.
* `d = size/2 * sqrt(2)`, and tags it with an orientation of **1/8**.
*
* **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`.
* **The 1/8 tag is DISCARDED for collision, so this returns the raw rectangle.**
* Established by disassembly 2026-08-02, three steps deep:
*
* 1. `EntityMapGenerationTask::tryToAddCliff` (`0x101625038`) loads the
* orientation's box from `proto + 0x5c0 + id*0x48`, copies 20 bytes (four
* `int32` edges at `+4` plus the orientation word at `+0x14`), and calls
* `wouldCollide` with **`Direction = 0`** (`mov x4, #0x0`).
* 2. `EntityMapGenerationTask::wouldCollide` (`0x101625468`) forwards that box
* and direction to `BoundingBox::BoundingBox(BoundingBox const&, Direction)`
* (`0x101c04380`).
* 3. That constructor zeroes the destination, writes the sentinel `0x80010000`
* into the destination's orientation word, and dispatches on the direction
* through a jump table whose **entry 0 is 0** - the identity arm, which
* copies `left_top`/`right_bottom` verbatim and returns. The source box's own
* orientation is never read; the rotation arm below it is reached only for a
* non-zero `Direction`.
*
* So the collision rectangle is the stored rectangle, axis-aligned, and the
* tile scan floors it with `(box + position) >> 8` over an inclusive rect.
*
* **Two shapes were shipped here before this and both were wrong.** The AABB
* `[x, x+size] x [y, y+size]` (until #88) is too big at the corners; a 45-degree
* separating-axis test (#88) is too SMALL, and scored better than the truth
* because it also absorbed the unrelated orientation residual. See
* `test/cliffCollisionBox.spec.ts`.
*
* Edges are quantised to 1/256 because `MapPosition` is 8-bit fixed point, so
* `x_dist`'s `sqrt(2)` cannot survive into the engine at full precision.
*/
function rotbbBox(x: number, y: number, size: number): CliffCollisionBox {
return [x, y, x + size, y + size];
function rotbbBox(x: number, y: number, size: number, intersect: number): CliffCollisionBox {
const dist = (size / 2) * SQRT2;
const yRatio = intersect / size;
const xDist = (1 - yRatio) * dist;
const yDist = yRatio * dist;
const cx = x + size / 2;
const cy = y + size / 2;
const q = (v: number): number => Math.round(v * 256) / 256;
return [q(cx - xDist), q(cy - yDist), q(cx + xDist), q(cy + yDist)];
}

/** The four straight orientations, written as plain boxes in the Lua. */
const CLIFF_STRAIGHT_COLLISION_BOX: readonly CliffCollisionBox[] = [
[-2.0, -1.5, 2.0, 1.5], // 0 west-to-east
[-1.0, -2.0, 1.0, 2.0], // 1 north-to-south
[-2.0, -0.5, 2.0, 0.5], // 2 east-to-west
[-1.0, -2.0, 1.0, 2.0], // 3 south-to-north
];

/**
* `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.
* the four straight orientations. Verified identical to the Lua, in order.
*/
export const CLIFF_ORIENTATION_ROTBB: readonly (
| readonly [number, number, number, number]
Expand Down Expand Up @@ -324,81 +351,6 @@ export const CLIFF_ORIENTATION_ROTBB: readonly (

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 Expand Up @@ -427,28 +379,10 @@ export function cliffBoxCoversTile(
* 0.779) to 888 predicted (ratio **1.003**, precision **0.930**), rejecting 173
* false positives and only 4 true ones. See issue #18.
*/
export const CLIFF_ORIENTATION_COLLISION_BOX: readonly CliffCollisionBox[] = [
[-2.0, -1.5, 2.0, 1.5], // 0 west-to-east
[-1.0, -2.0, 1.0, 2.0], // 1 north-to-south
[-2.0, -0.5, 2.0, 0.5], // 2 east-to-west
[-1.0, -2.0, 1.0, 2.0], // 3 south-to-north
rotbbBox(-3.5, -3, 4.5), // 4 west-to-north
rotbbBox(-1, -3, 4.5), // 5 north-to-east
rotbbBox(-1, -0.5, 3.5), // 6 east-to-south
rotbbBox(-2.5, -0.5, 3.5), // 7 south-to-west
rotbbBox(-3.5, -1.5, 4.5), // 8 west-to-south
rotbbBox(-2.5, -3, 3.5), // 9 north-to-west
rotbbBox(-1, -3, 3.5), // 10 east-to-north
rotbbBox(-1, -1.5, 4.5), // 11 south-to-east
rotbbBox(-3, -1.5, 3), // 12 west-to-none
rotbbBox(0, -1.5, 3), // 13 none-to-east
rotbbBox(0, -0.5, 2.5), // 14 east-to-none
rotbbBox(-2.5, -0.5, 2.51), // 15 none-to-west
rotbbBox(-1, -2.5, 3), // 16 north-to-none
rotbbBox(-1, -0.5, 3), // 17 none-to-south
rotbbBox(-2, -0.5, 3), // 18 south-to-none
rotbbBox(-2, -2.5, 3), // 19 none-to-north
];
export const CLIFF_ORIENTATION_COLLISION_BOX: readonly CliffCollisionBox[] =
CLIFF_ORIENTATION_ROTBB.map((spec, id) =>
spec === null ? CLIFF_STRAIGHT_COLLISION_BOX[id] : rotbbBox(...spec),
);

/**
* The `CliffOrientation` id a cell code places, or `undefined` when the code
Expand Down
18 changes: 5 additions & 13 deletions src/noise/cliffs/cliffPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ import {
CLIFF_CELL_CENTER_X,
CLIFF_CELL_CENTER_Y,
CLIFF_GRID_SIZE,
cliffBoxCoversTile,
cliffCollisionTileBox,
cliffOrientationForCode,
getModifiedElevationInterval,
isCliffPlaced,
} from "./cliffCatalog";
Expand Down Expand Up @@ -321,24 +319,18 @@ export function makeCliffPlacementFromFields(
* 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.
* The box is `cliffCollisionTileBox` and nothing narrows it: `wouldCollide`
* floors the stored rectangle with `(box + position) >> 8` and scans the
* inclusive tile rect, with the box's own `1/8` orientation tag discarded.
* See `rotbbBox` in `cliffCatalog.ts` for the disassembly that establishes it.
*/
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) && cliffBoxCoversTile(id, x, y, tx, ty)) return true;
for (let ty = box.top; ty <= box.bottom; ty++) if (tileCollides(tx, ty)) return true;
return false;
};

Expand Down
Loading