Skip to content

fix(cliffs): the collision box is the RAW rectangle - #88 was wrong (#84) - #90

Merged
wormeyman merged 2 commits into
mainfrom
docs/cliff-accuracy-figure
Aug 2, 2026
Merged

fix(cliffs): the collision box is the RAW rectangle - #88 was wrong (#84)#90
wormeyman merged 2 commits into
mainfrom
docs/cliff-accuracy-figure

Conversation

@wormeyman

Copy link
Copy Markdown
Owner

Disassembly, prompted by flagging #88's fix as the thing I was least confident about. That flag was right: #88 scored best on every metric and was wrong about the mechanism.

What the engine actually does

It does not collide against rotbb's rectangle rotated 45°. It uses the raw stored rectangle and discards the orientation tag:

  1. EntityMapGenerationTask::tryToAddCliff (0x101625038) loads the box from proto + 0x5c0 + id*0x48 (20 bytes: four int32 edges at +4, orientation word at +0x14) and calls wouldCollide with Direction = 0 — literally mov x4, #0x0.
  2. wouldCollide (0x101625468) forwards box and direction to BoundingBox::BoundingBox(BoundingBox const&, Direction) (0x101c04380), then floors with (box + position) >> 8 over an inclusive tile rect.
  3. That constructor zeroes the destination, writes sentinel 0x80010000 into the destination's orientation word, and dispatches through a jump table whose entry 0 is 0 (read at 0x102d01400) — the identity arm, copying left_top/right_bottom verbatim. The source orientation is never read; the rotate arm is reachable only for a non-zero Direction.

Corroborated by the API mirror rather than disassembly alone: BoundingBox is documented as {MapPosition, MapPosition} or {..., RealOrientation} with orientation optional, and OrientedCliffPrototype::collision_bounding_box is a plain BoundingBox.

Three shapes, and the best-scoring one is wrong

box false rejections recall precision evidence
AABB (until #88) 13 0.9675 0.9743 an assumption
45° oriented rect (#88) 0 0.9758 0.9727 empirical fit only
raw stored rect (this PR) 6 0.9720 0.9713 disassembly + API docs

The middle row is the trap. It shrank the box past the engine's, and the excess shrinkage 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. A model that scores perfectly by hiding a second bug is worse than one that leaves it exposed.

Also

  • test/cliffOrientation.spec.ts asserted the opposite of this until now, on the stated reasoning that the AABB "is what the engine ends up scanning". Rewritten: the shipped box is the raw rectangle, it depends on intersect (the old file asserted independence), and its area is at most half the AABB's — though it is not contained in it, since a small intersect pushes hx past size/2. My first attempt asserted containment on every axis and failed for exactly that reason.
  • cliffBoxCoversTile and the separating-axis narrow phase are deleted. The broad phase alone is the whole rule, which is what the engine does.
  • Edges quantised to 1/256 — MapPosition is 8-bit fixed point, so x_dist's sqrt(2) cannot reach the engine at full precision.
  • Supersedes docs(cliffs): shipping orientation is 2.4%, not 2.0% (#84) #89, which was correcting a figure that has since moved again.

pnpm run verify green: 1311 passed / 3 skipped, 0 warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

wormeyman and others added 2 commits August 1, 2026 22:01
The banner kept the pre-#88 figure. Since the collision box was corrected to
rotbb's rotated rectangle the rejection costs ZERO true positives, so the
shipping matched set is identical to the unfiltered one - 1531 either way, 37
wrong orientations, 2.42%. Measured on both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt
)

Disassembly, prompted by flagging #88's fix as the thing I was least confident
about. It was right to flag: #88 scored best on every metric and was wrong about
the mechanism.

The engine does NOT collide against rotbb's rectangle rotated 45 degrees. It uses
the RAW stored rectangle and discards the orientation tag entirely:

1. EntityMapGenerationTask::tryToAddCliff (0x101625038) loads the orientation'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. wouldCollide (0x101625468) forwards box and direction to
   BoundingBox::BoundingBox(BoundingBox const&, Direction) (0x101c04380), then
   floors with (box + position) >> 8 over an inclusive tile rect.
3. That constructor zeroes the destination, writes sentinel 0x80010000 into the
   destination's orientation word, and dispatches through a jump table whose
   entry 0 is 0 (read at 0x102d01400) - the identity arm, which copies
   left_top/right_bottom verbatim. The source orientation is never read; the
   rotate arm is reachable only for a non-zero Direction.

Corroborated by the API mirror: BoundingBox is documented as {MapPosition,
MapPosition} or {..., RealOrientation} with orientation OPTIONAL, and
OrientedCliffPrototype::collision_bounding_box is a plain BoundingBox.

| box                            | false rej | recall | precision | evidence   |
| ------------------------------ | --------- | ------ | --------- | ---------- |
| AABB (until #88)               | 13        | 0.9675 | 0.9743    | assumption |
| 45-degree oriented rect (#88)  | 0         | 0.9758 | 0.9727    | fit only   |
| raw stored rect (this)         | 6         | 0.9720 | 0.9713    | disasm     |

The middle row is the trap. It shrank the box past the engine's, and the excess
shrinkage 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. A model that scores perfectly by hiding a second bug is
worse than one that leaves it exposed.

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

test/cliffOrientation.spec.ts asserted the OPPOSITE of this until now, on the
stated reasoning that the AABB "is what the engine ends up scanning". That block
is rewritten: the shipped box is the raw rectangle, it DEPENDS on intersect (the
old file asserted independence), and its area is at most half the AABB's - though
it is not contained in it, since a small intersect pushes hx past size/2. An
attempt to assert containment on every axis failed for exactly that reason.

cliffBoxCoversTile and the separating-axis narrow phase are deleted; the broad
phase alone is now the whole rule, which is what the engine does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt
@wormeyman
wormeyman merged commit 64b5b79 into main Aug 2, 2026
2 checks passed
wormeyman added a commit that referenced this pull request Aug 2, 2026
cliffOrientationOracle.spec.ts counts the residual and bounds it. This pins what
it looks like, because the shape is the lead and a change in shape is a change in
cause even when the count holds.

Measured after #83 (multisample grid), #86 (lava rejection) and #90 (raw
collision box):

- 37 of 1531 matched cells, and all 37 differ in EXACTLY ONE edge. Not one
  two-edge difference survives; before #83 the dominant mode was two edges
  (125 of 175), a whole corner on the wrong side of a band.
- Every one is an OVER-detection: the game reports a -to-none orientation and the
  port reports a crossing on that edge, never the reverse.
- Spread over all four edges (L11/R6/T7/B13) and all three regions (7/26/4), so
  it is not a directional off-by-one.

Two candidate causes are already eliminated, which is why the shape is worth
pinning rather than re-deriving next session:

- crossesCliff is EXACT. Disassembled at 0x10160c914 under 2.1.12 (the VA in
  cliffs-NOTES.md had moved); cliffPlacement.ts reproduces it line for line
  including the a<0||b<0 early-out, the boundary<e0 check, and the strict >0.5
  gate and strict crossing comparisons. No >=-vs-> slip exists to find.
- cliffiness_basic is EXONERATED. Substituting the game's own corner cliffiness
  leaves the count at exactly 37/1531.

So the residual is in the grid-4 cliff-elevation field - the one input in the
chain with no direct per-corner oracle. A single-edge, strictly one-directional
over-detection is what a small positive field offset looks like.

The direction assertion is the load-bearing one: if under-detections ever appear,
the cause has changed and that reading is dead.


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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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