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
78 changes: 78 additions & 0 deletions docs/noise/vulcanus-cliffs-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1985,3 +1985,81 @@ neighbourhood of a rejected cell. Note this is a HYPOTHESIS from one chunk plus
the coverage statistics; it has not been tested, and the obvious control is
whether disputed edges are adjacent to rejected cells at a rate above the base
rate of all crossing edges.

## The rejections act on the CROSSING, not on the entity (2026-08-03, #84)

#107 handed over a hypothesis - "disputed edges sit adjacent to rejected cells
above the base rate of all crossing edges" - and named the control. Run, it is
not an enrichment over a base rate. It is a dichotomy, and it refutes the stage
at which the port applies both Vulcanus cliff rejections.

`test/vulcanusCliffRejectionStage.spec.ts`, over the fine sweep's 41 levels at
`[1500,1500]`:

| | |
| --- | --- |
| edges the port has and the game does not | **1235** |
| ... sitting against a cell the game did not emit | **1233** |
| edges both sides agree on (in region) | **36103** |
| ... sitting against a cell the game did not emit | **0** |

Zero, not "fewer". A cell's edge register is the same array slot as its
neighbour's (#103), so this says the game's absences take their crossings with
them, every time.

### The refutation is structural, not a score

The post-filter reading makes a falsifiable prediction, and it does not need any
model to score well. When cell `N` is rejected, a pure post-filter leaves the
shared crossing in `C`'s code, so `C` is emitted still carrying it. Counted with
the port's own rejection predicate, that should happen **1,662 times** across the
41 levels. The game does it **0 times**.

The vacuity arm is the same counter reading the port's own post-filter output
instead of the game's: it fires on all 1,662. So the zero is a property of the
game's cliffs, not a dead branch. (A first attempt at a vacuity arm compared
against the *next* level's game output and also returned 0 - not a broken
control but a stronger result, since the invariant holds independently at all 41
levels.)

Note what this does and does not settle. `tryToAddCliff` really does ignore
`wouldCollide`'s return value - the disassembly reading behind #71/#73 was not
misread. What is refuted is that the OBSERVABLE output behaves like a
post-filter. Whether `wouldCollide` removes the crossings or the game never
computed them there is not decidable from entity dumps, and the port now models
the first because it is the one that can be written.

### The fix, and what it is worth

`rejectAtCrossingStage` in `cliffPlacement.ts` zeroes a rejected cell's four edge
registers after the repair sweep, before any code is read. Scored two ways:

| | matched | wrong | surplus | missing |
| --- | --- | --- | --- | --- |
| collapsed rule, post-filter | 18130 | 1235 | 1366 | 85 |
| collapsed rule, crossing stage | **18654** | **693** | **1200** | 103 |

and at the SHIPPING settings (smoothing 1, the real 120-tile interval,
`cliffiness_basic` rather than a constant) across the three entity regions:
wrong orientations **33 -> 21**, precision 0.9839 -> 0.9858, and the matched set
**identical** at 1525 - this removes wrong edges and costs no recall at all.

It is not free: 18 more of the game's cells go missing under the collapsed rule,
because an edge taken off a survivor can leave its code non-placing. Reported
rather than buried.

Nauvis is unaffected in both directions - it already matched 1.0000 recall and
precision, and enabling the flag there reproduces the same 282 and 52 cells with
0 wrong orientations. So this is not a regression risk on the other planet, but
neither is it corroboration from it: Nauvis has no cells that could move.

### What is left

The predicate, not the stage. 693 wrong orientations and 1200 surplus cells
survive under the collapsed rule, and the remaining absences still follow the
same "the game emitted nothing there" shape - so the lava-box + ore predicate is
catching most of what the game suppresses but not all of it. Chasing that is a
question about WHICH cells the game refuses, and the sweep cannot answer it
where the game emits nothing at any level: those corners get no bracket, so the
field there is still unmeasured. A wider sweep (well outside `[700,900]`) is the
measurement that would close that gap.
66 changes: 58 additions & 8 deletions src/noise/cliffs/cliffPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,13 @@ export interface CliffBands {
* orientation, takes that orientation's `collision_bounding_box`, and calls
* `wouldCollide` (`0x101625468`) against the tile mask grid; on a hit the
* cliff is simply **not added**. `generateCliffs` ignores the return value
* entirely - there is no retry, no alternative orientation, and no effect on
* the neighbouring cells - so this is a pure post-filter on the emit loop,
* which is why it can run per-cell in any order and leaves tiling
* byte-identical.
* entirely - there is no retry and no alternative orientation.
*
* That reading of the code is unchanged, but the conclusion drawn from it -
* "and therefore no effect on the neighbouring cells" - **is refuted by the
* game's output**, so see `rejectAtCrossingStage` below before assuming this
* is a post-filter. The observable behaviour is that a rejected cell's
* crossings go with it and its neighbour's orientation changes.
*
* Which tiles collide is planet-specific but the rule is not: a tile collides
* when its `CollisionMask` shares a layer with the cliff's. The cliff mask
Expand Down Expand Up @@ -132,10 +135,26 @@ export interface CliffBands {
* directly, so a filter applied further out would score a different thing than
* it renders.
*
* Like `tileCollides` this is a pure post-filter on the emit loop - it cannot
* affect a neighbouring cell, so it leaves worker tiling byte-identical.
* Like `tileCollides`, whether this acts as a post-filter or on the crossing
* itself is `rejectAtCrossingStage`'s decision, and the measurement says the
* crossing. Either way it stays chunk-local - the zeroing runs over the whole
* chunk, including cells outside the query box - so worker tiling remains
* byte-identical.
*/
readonly cellRejects?: (code: number, x: number, y: number) => boolean;
/**
* EXPERIMENTAL (#84): apply `tileCollides` / `cellRejects` by zeroing the
* rejected cell's four edge registers after the repair sweep, instead of
* filtering the emitted cell. A neighbour sharing one of those edges therefore
* loses it too, and its orientation changes.
*
* The post-filter reading came from `tryToAddCliff` ignoring `wouldCollide`'s
* return value, and it is REFUTED as a description of the observable output:
* see `test/vulcanusCliffRejectionStage.spec.ts`. Under a post-filter, a
* surviving cell keeps an edge whose neighbour was rejected; the game shows
* that 0 times where the model predicts 1,662.
*/
readonly rejectAtCrossingStage?: boolean;
}

/** Cells per chunk axis: a 32-tile chunk over the 4-tile placement grid. */
Expand Down Expand Up @@ -487,6 +506,35 @@ export function makeCliffPlacementFromFields(

fixImpossibleCellsSweep(v, hEdges, n, n);

if (bands.rejectAtCrossingStage === true) {
// Collect first, then clear: a cell's rejection is decided from
// the code the repair left, not from a code a previous cell's
// clearing has already eaten into.
const kill: number[] = [];
for (let cy = 0; cy < n; cy++) {
for (let cx = 0; cx < n; cx++) {
const code = cellCode(
v[cy * (n + 1) + cx],
v[cy * (n + 1) + cx + 1],
hEdges[cy * n + cx],
hEdges[(cy + 1) * n + cx],
);
if (!isCliffPlaced(code)) continue;
const x = (baseX + cx) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X;
const y = (baseY + cy) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y;
if (rejected(code, x, y) || cellRejects?.(code, x, y) === true) kill.push(cx, cy);
}
}
for (let i = 0; i < kill.length; i += 2) {
const cx = kill[i];
const cy = kill[i + 1];
v[cy * (n + 1) + cx] = 0;
v[cy * (n + 1) + cx + 1] = 0;
hEdges[cy * n + cx] = 0;
hEdges[(cy + 1) * n + cx] = 0;
}
}

for (let cy = 0; cy < n; cy++) {
for (let cx = 0; cx < n; cx++) {
const code = cellCode(
Expand All @@ -502,8 +550,10 @@ export function makeCliffPlacementFromFields(
// expensive half (it resolves tiles), and a chunk always
// overhangs the query box.
if (x < x0 || x >= x1 || y < y0 || y >= y1) continue;
if (rejected(code, x, y)) continue;
if (cellRejects?.(code, x, y) === true) continue;
if (bands.rejectAtCrossingStage !== true) {
if (rejected(code, x, y)) continue;
if (cellRejects?.(code, x, y) === true) continue;
}
result.push({ x, y, code });
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/noise/preview/renderVulcanusCliffs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ export function renderVulcanusCliffs(base: ImageData, opts: RenderVulcanusCliffs
smoothing: VULCANUS_CLIFF_SMOOTHING,
tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name),
cellRejects: makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls),
// Both rejections act on the CROSSING, not on the emitted entity (#84).
// A rejected cell's four edges go with it, so a surviving neighbour loses
// the shared one and changes orientation. See
// `test/vulcanusCliffRejectionStage.spec.ts`: the post-filter reading
// predicts 1,662 cases of a survivor keeping such an edge and the game
// shows 0. Worth 33 -> 21 wrong orientations at no cost in recall.
rejectAtCrossingStage: true,
});

const box = opts.cellQueryBox ?? {
Expand Down
Loading