Skip to content

feat(cliffs): land the cliff orientation + collision-box tables (#18) - #71

Merged
wormeyman merged 1 commit into
mainfrom
feat/cliff-orientation-tables
Jul 30, 2026
Merged

feat(cliffs): land the cliff orientation + collision-box tables (#18)#71
wormeyman merged 1 commit into
mainfrom
feat/cliff-orientation-tables

Conversation

@wormeyman

Copy link
Copy Markdown
Collaborator

Reverse-engineering only - no behaviour change. These are the tables the game's cliff collision rejection needs, landed on their own so the RE is reviewable before the rejection itself is wired in (that step gives the cliff overlay a dependency on the Vulcanus tile resolver, which is a render-cost decision).

The finding

EntityMapGenerationTask::tryToAddCliff (0x101625038) does not just record a cell. It switches on the CliffOrientation, loads that orientation's collision_bounding_box from proto + 0x5c0 + id*0x48, and calls EntityMapGenerationTask::wouldCollide (0x101625468) with the prototype's collision mask at proto + 0x2b0. On a hit the cliff is never added. This port runs no such check.

wouldCollide floors the box to tiles with (box + position) >> 8 (MapPosition is 8-bit fixed point) and scans the inclusive rectangle [left..right] x [top..bottom] against a 96x96 per-tile mask grid. It reads tiles only - tryToAddCliff never writes that grid - so the rejection is order-independent, which is what makes it portable to a per-cell renderer.

The cliff mask holds water_tile; tile_collision_masks.lava() sets it, and on Vulcanus lava/lava-hot are the only tiles that do. Measured against oracle-vulcanus-cliff-entities.seed123456:

region ratio precision recall rejects TP / FP
[0,0] 1.184 -> 1.120 0.681 -> 0.700 0.806 -> 0.784 6 / 12
[1500,1500] 1.203 -> 1.003 0.779 -> 0.930 0.938 -> 0.933 4 / 173
[-1200,800] 0.935 -> 0.925 0.912 -> 0.922 0.853 -> 0.853 0 / 4

Control arm: the same lava field sampled 10,000 tiles away rejects 111 TP/40 FP and 361 TP/82 FP - indiscriminate, ratio collapsing to 0.65/0.70. So the real arm is not vacuous. It explains one region's over-placement essentially in full and not the other two.

What this PR adds

  • CLIFF_CODE_TO_ORIENTATION - the orientation was recoverable all along and simply never read. toMaybeCliffOrientation returns one 64-bit word whose low 32 bits are the tri-state CLIFF_PLACING_CODES came from and whose high 32 bits are the id.
  • CLIFF_ORIENTATION_NAMES, from CliffOrientationName::buildMapping's string table.
  • CLIFF_ORIENTATION_COLLISION_BOX. rotbb's 45-degree rectangle has an AABB of exactly [x, x+size] x [y, y+size] whatever intersect was, which is why the shipped helper drops that argument.

The spec is not a restatement

test/cliffOrientation.spec.ts asserts what the transcription has to satisfy: the bijection against the separately-extracted isCliffPlaced table, a from-the-geometry derivation of every orientation name out of its code's own edge crossings, and a re-derivation of the rotbb boxes from the full rotated rectangle (plus a check that the unrotated rectangle differs, so the comparison is not trivially true).

Six planted single-value errors - two id swaps, two box typos, a name reorder, a dropped code - were each confirmed to fail it before it landed. An earlier, weaker version missed two of them, which is why the name check is a full derivation rather than "corners look like corners".

Notes corrections, all actively misleading

  • The lattice section still gave the corner as j*4 + 0.5 - the bug fix(cliffs): sample the fields at the game's lattice - Nauvis is now EXACT (#18) #70 fixed - under a heading claiming "confirmed 100% exact", with its correction 160 lines below.
  • Six symbol addresses had moved by 2.1.12, and fixImpossibleCells was attributed to the wrong class (CellEdgeCliffCrossingArray, not CliffGenerator).
  • Added the extract-then-decompile recipe that actually worked, with its two traps: llvm-objdump silently ignores both --start-address and --disassemble-symbols on the universal binary, and Ghidra 12 reports a duplicate script file as a missing source bundle.

pnpm run verify green: 143 test files, 1266 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_012cd6B2MKoJF5W2T175BDoe

Reverse-engineering only - no behaviour change. These are the tables the
game's cliff collision rejection needs, landed on their own so the RE is
reviewable before the rejection itself is wired in.

The finding they came from: `EntityMapGenerationTask::tryToAddCliff`
(`0x101625038`) does not just record a cell. It switches on the
`CliffOrientation`, loads that orientation's `collision_bounding_box`
from `proto + 0x5c0 + id*0x48`, and calls
`EntityMapGenerationTask::wouldCollide` (`0x101625468`) with the
prototype's collision mask. On a hit the cliff is never added. This port
runs no such check.

`wouldCollide` floors the box to tiles with `(box + position) >> 8` and
scans the inclusive tile rectangle against a 96x96 per-tile mask grid.
It reads tiles only - `tryToAddCliff` never writes that grid - so the
rejection is order-independent. On Vulcanus the only tiles carrying a
layer the cliff mask holds (`water_tile`) are `lava` and `lava-hot`.

Measured against `oracle-vulcanus-cliff-entities.seed123456`, applying
the rule with these boxes moves region `[1500,1500]` from ratio 1.203 /
precision 0.779 to ratio 1.003 / precision 0.930, rejecting 173 false
positives and 4 true ones. Regions `[0,0]` and `[-1200,800]` barely move,
so this is most of one region's over-placement and not the whole
residual. The control arm - the same lava field sampled 10,000 tiles
away - rejects 111 TP/40 FP and 361 TP/82 FP, i.e. indiscriminately,
which is what makes the real arm non-vacuous.

New:

- `CLIFF_CODE_TO_ORIENTATION`. The orientation was recoverable all along
  and simply never read: `toMaybeCliffOrientation` returns one 64-bit
  word whose low 32 bits are the tri-state `CLIFF_PLACING_CODES` was
  extracted from, and whose high 32 bits are the id.
- `CLIFF_ORIENTATION_NAMES`, from `CliffOrientationName::buildMapping`'s
  string table.
- `CLIFF_ORIENTATION_COLLISION_BOX`. `rotbb`'s 45-degree rectangle has an
  AABB of exactly `[x, x+size] x [y, y+size]` whatever `intersect` was,
  which is why the shipped helper drops that argument.

`test/cliffOrientation.spec.ts` asserts what the transcription has to
satisfy rather than restating it: the bijection against the separately
extracted `isCliffPlaced` table, a from-the-geometry derivation of every
orientation name out of its code's edge crossings, and a re-derivation of
the `rotbb` boxes from the full rotated rectangle. Six planted
single-value errors were each confirmed to fail it; an earlier, weaker
version missed two of them.

Notes corrections while here, all of which were actively misleading:

- The lattice section still gave the corner as `j*4 + 0.5` - the bug #70
  fixed - under a heading claiming "confirmed 100% exact", with its
  correction 160 lines below.
- Six symbol addresses had moved by 2.1.12, and `fixImpossibleCells` was
  attributed to the wrong class.
- Added the extract-then-decompile recipe that actually worked, including
  the two traps: `llvm-objdump` silently ignores both `--start-address`
  and `--disassemble-symbols` on the universal binary, and Ghidra 12
  reports a duplicate script as a missing source bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cd6B2MKoJF5W2T175BDoe
@wormeyman
wormeyman merged commit 8a37ebf into main Jul 30, 2026
2 checks passed
@wormeyman
wormeyman deleted the feat/cliff-orientation-tables branch July 30, 2026 20:52
wormeyman added a commit that referenced this pull request Aug 2, 2026
#84) (#86)

#84 opened with two symptoms and they were one measurement error: every arm
compared OUR placement, which did not run tryToAddCliff's lava-collision
rejection, against the GAME's, which always does. On Vulcanus the lava is the
basalt lakes, so the missing deletion rule's effect lands exactly in the
low-elevation range where the residual sat.

Item 1 - the 187-cell excess. Turning the rejection on removes 198 cells across
the three oracle regions: 185 false positives against 13 true.

|                | game | ours | matched | recall | precision | wrong orient |
| -------------- | ---- | ---- | ------- | ------ | --------- | ------------ |
| no rejection   | 1569 | 1756 | 1531    | 0.9758 | 0.8719    | 37 = 2.42%   |
| with rejection | 1569 | 1558 | 1518    | 0.9675 | 0.9743    | 31 = 2.04%   |

Region [0,0] reaches precision exactly 1.000. The port goes from over-placing
12% to under-placing 0.7%.

Item 2 - the surviving regime split. The low/high over-placement gap of 0.067
read as a second-order error in the same multisample term. Running both sides
with the rejection collapses it to 0.018, and the low regime straddles 1.0
instead of sitting above it (level 20: 1.085 -> 0.988). There is no second
multisample defect to find.

What is actually left is a TILE boundary, not a cliff field. The rejection costs
recall in the same regime (0.951 at level 20 -> 1.000 at 140+), and every
contradiction sits at Chebyshev depth 1 in our lava - our own perimeter, never
deeper: 32/32 at level 20, 52/52 across the sweep, 13/13 at default settings.
Stated carefully in the notes, because depth discriminates at default settings
(region 1's 170 correct rejections span depth 1-9, 45 deep in lava) and does NOT
at level 20, where the correct rejections are 32/32 perimeter too.

- vulcanusCliffEntities.spec.ts: new table; guards tightened from
  recall>0.77/precision>0.68/ratio<1.15 to 0.95/0.94/1.05.
- cliffOrientationOracle.spec.ts: bound 175 -> 37, floor 1350 -> 1500 so a
  change that shrinks both has not fixed anything, it has stopped comparing.
  Records why this arm deliberately omits the rejection.
- vulcanusElevationLevels.spec.ts: new arm attributing the split, with a
  non-vacuity guard on cells actually removed (verified by planting a no-op
  rejection: it fails with "expected 0 to be greater than 100").
- Docs: banners carry the shipping-path row; corrected the Vulcanus rendering
  note that claimed the game does not exclude cliffs from lava - inferred from
  the absence of a water TILE rather than measured, and wrong since #71/#73.
- Cleared a pre-existing unused-import warning in cliffResidual.spec.ts.


Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
) (#108)

#107 handed over one untested hypothesis with its control named: "disputed
edges sit adjacent to rejected cells above the base rate of all crossing
edges". 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.

Of the 1235 edges the port has and the game does not, **1233** sit against a
cell the game did not emit. Of the 36,103 in-region edges both sides agree
on, **0** do. A cell's edge register is the same array slot as its
neighbour's (#103), so the game's absences take their crossings with them.

**The refutation is structural, not a score.** A pure post-filter makes a
falsifiable prediction: when cell N is rejected, its surviving neighbour C
still holds the shared crossing and is emitted carrying it. Counted with the
port's own rejection predicate over the fine sweep's 41 levels, that should
happen **1,662 times**. The game does it **0 times**. The vacuity arm is the
identical counter reading the port's own post-filter output, where it fires
on all 1,662 - so the zero is a property of the game's cliffs, not a dead
branch.

`tryToAddCliff` really does ignore `wouldCollide`'s return value; the #71/#73
disassembly reading was not misread. What is refuted is the conclusion drawn
from it - "and therefore no effect on the neighbouring cells". Both doc
comments that asserted it are corrected rather than left standing.

`rejectAtCrossingStage` zeroes a rejected cell's four edge registers after
the repair sweep, before any code is read. Collapsed rule: wrong orientations
**1235 -> 693**, surplus 1366 -> 1200, matched 18130 -> 18654. At the
SHIPPING settings across the three entity regions: wrong orientations
**33 -> 21**, precision 0.9839 -> 0.9858, and the matched set IDENTICAL at
1525 - it removes wrong edges and costs no recall.

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 byte-identical either way (it already matched
1.0000 both directions), so this is not a regression risk there and not
corroboration from there either.

Tiling stays byte-identical - the zeroing runs over the whole chunk,
including cells outside the query box - and `tiledEquality.spec.ts`'s
Vulcanus cliff seam and chunk-boundary cases verify it rather than the
comment asserting it.

What is left is the PREDICATE, not the stage: 693 wrong and 1200 surplus
survive, and the sweep cannot measure the field where the game emits nothing
at any level.

Item of #84; the issue stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExK3ASs5i2KMm9NwWqnZuj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant