fix(physics): S1B — indoor cell membership admits on the part BOX, as retail does (#335, AP-159 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
CellTransit.FindTransitCellsBox ports CEnvCell::find_transit_cells'
part-array overload @0x0052cae0 line-for-line: per-portal x per-part
order, the sphere cheap-reject at F_EPSILON+radius, the box admit whose
"Straddle or crossing-side" rule is exactly retail's `eax != side` under
the PDB Sidedness enum, leads-outside placed AFTER the admit, the
unconditional unloaded-neighbour hint without the sphere overload's
re-test, the destination box_intersects_cell gate with its deliberate
no-break, and add_all_outside_cells after the loop. The box-vs-cell BSP
traversal lands in BOTH representations behind the flat-authoritative
dispatcher with a graph referee whose 20,000 installed comparisons are
pinned by assertion (review F5), zero mismatch.
Dual Opus review: PASS on both lenses. The mandatory D0 pseudocode pass
caught that the contract's own supplementary note misattributed the box
block to the sphere overload — it belongs to a SECOND
check_building_transit overload @0x0052c680, whose portal-side
convention is INVERTED and whose admit differs; the pseudocode doc now
records that trap plus two byte confirmations made at review:
which_side @0x00444720 is strictly > eps for POSITIVE, and
intersect_box's in-plane early exit returns CROSSING(3)
(jp @0x005aa1bc -> mov eax,3), settling review items b1/b2 for the
future bridge porter. The bridge itself stays unported as AP-159's
explicit remainder.
The review also retired #335's severity premise honestly: "over-
inclusive only, never a missed one" is wrong at production shape ratios,
where the box (whole-vertex AABB) legitimately exceeds the sphere
(physics-polygon root sphere). Measured, both populations: rigged
(box << sphere) — 1,520 placements, 978 cells removed, 0 added;
production-ratio (box >= sphere) — 950 placements, 20 removed, 1 ADDED
through the loaded-neighbour gate, which is retail's direction, not a
defect. The no-op guard (review F4) asserts removal is nonzero so an
unwired admit cannot pass silently.
Process note: the implementer authored against this session's worktree
at bec5c69d, 25 commits stale — the recorded worktree-base class. All
six files were byte-identical between bases, the diff transplanted
losslessly, and every verdict-bearing run (referee, direction sweeps,
this clean-room) was re-executed on current main. S2's uncommitted
phase-1 edits were stashed for this landing so the suite verdicts
exactly one changeset.
Also untracks 341-slope-capture.jsonl (an accidental add) and
gitignores it.
Clean-room suite: 11,248 passed / 6 skipped / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1b2580be4c
commit
b3e43d22c9
14 changed files with 1600 additions and 4914 deletions
290
docs/research/2026-08-07-ap159-pseudocode.md
Normal file
290
docs/research/2026-08-07-ap159-pseudocode.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# AP-159 / #335 — pseudocode: the INDOOR box-admit half of retail's part-array `find_transit_cells`
|
||||
|
||||
**Date:** 2026-08-07. Written per the mandatory grep-named → decompile → pseudocode →
|
||||
port workflow, BEFORE any C# is touched, per the pinned contract
|
||||
`docs/research/2026-08-07-ap159-s1b-contract.md`.
|
||||
|
||||
## 0. Resolving the contract's flagged overload-signature ambiguity
|
||||
|
||||
The contract's Oracle map says:
|
||||
|
||||
> **Adjacent overload** @0x0052c820 (lines 309968–310126) also contains a box
|
||||
> refinement block (@0x0052c76c–0x0052c7d2). Its Binary Ninja signature
|
||||
> (`CSphere const* arg4`) is inconsistent with its body indexing parts — a
|
||||
> known BN artifact class.
|
||||
|
||||
Direct disassembly reading resolves this. The box-refinement block at
|
||||
addresses **0x0052c76c–0x0052c7d2** does **not** belong to the function
|
||||
declared at 0x0052c820 — its addresses are numerically *lower* than that
|
||||
declaration and it is textually printed *before* it in the dump. It belongs
|
||||
to the **preceding** function, which starts at line 309867:
|
||||
|
||||
```
|
||||
0052c680 void __thiscall CEnvCell::check_building_transit(
|
||||
class CEnvCell const* this, int32_t arg2, uint32_t const arg3,
|
||||
class CPhysicsPart** arg4, class CELLARRAY* arg5)
|
||||
```
|
||||
|
||||
This is a **second, part-array overload of `check_building_transit`**
|
||||
(distinct from the sphere overload at 0x0052c5d0, lines 309827–309863, which
|
||||
is what `CellTransit.CheckBuildingTransit` already ports). Its signature
|
||||
genuinely takes `CPhysicsPart** arg4` — fully consistent with a body that
|
||||
indexes parts (`arg4[eax_1]`, `arg4[var_4c_1]`). There is no BN artifact here;
|
||||
the confusion was address-adjacency between two *different* functions that
|
||||
happen to sit back-to-back in the binary and share a demangled base name with
|
||||
their sibling overloads.
|
||||
|
||||
The function actually declared at **0x0052c820** (lines 309968–310122) is the
|
||||
**sphere overload of `find_transit_cells`** — signature `CSphere const* arg4`
|
||||
is correct, and its body is pure sphere math (`Frame::globaltolocal`,
|
||||
`CCellStruct::sphere_intersects_cell`, the ±eps straddle test for exterior
|
||||
portals). This is **already ported** as `CellTransit.FindTransitCellsSphere`
|
||||
and needs no changes. It contains no box-refinement block at all.
|
||||
|
||||
The function the contract's D1/D2 actually needs — the **part-array
|
||||
overload of `find_transit_cells`**, cited correctly by address
|
||||
(**0x0052cae0**, lines 310127–310257) — is a **third, separate function**
|
||||
from either of the above, and its disassembly matches the #335 ISSUES.md
|
||||
entry (the true oracle of record) line-for-line: `Position::localtolocal`
|
||||
sphere cheap-reject → `CPhysicsPart::GetBoundingBox` + `BBox::LocalToLocal` +
|
||||
`Plane::intersect_box` box admit → `other_cell_id==0xFFFFFFFF` leads-outside
|
||||
check → `CCellPortal::GetOtherCell` (threading `do_not_load_cells`) →
|
||||
`CCellStruct::box_intersects_cell` destination gate → `add_all_outside_cells`
|
||||
after the portal loop. **No contradiction with the #335 entry exists; only
|
||||
the contract's supplementary "Adjacent overload" note was misattributed.**
|
||||
This section performs the disentanglement the contract's mandatory D0 step
|
||||
asked for.
|
||||
|
||||
**Consequence for D2's building-bridge decision:** the TRUE box-refinement
|
||||
counterpart for the outdoor→indoor building bridge is the part-array
|
||||
`check_building_transit` @0x0052c680 (the function this section just
|
||||
identified), not the sphere overload @0x0052c5d0 the contract names. Its
|
||||
control flow operates on a **single fixed portal per call** (index `ebp`
|
||||
supplied by the caller) rather than looping `this->num_portals`, and its
|
||||
cheap-reject branch structure (`if (ebp_1==1) {...} else if (ebp_1!=0)
|
||||
{label:...} else {...}`) has its own three-way-looking shape that does not
|
||||
cleanly decompose into the same two-branch (`PortalSide ? : `) form the
|
||||
part-array `find_transit_cells` uses — porting it would mean building a new,
|
||||
differently-shaped traversal, not "reusing D1's primitive with a small
|
||||
diff." Per the contract's explicit D2 allowance, this is left unported and
|
||||
reported as the explicit remainder (see the implementation report).
|
||||
|
||||
## 1. `CEnvCell::find_transit_cells` (part-array overload, @0x0052cae0, pc:310127–310257)
|
||||
|
||||
Verbatim shape, addresses inline:
|
||||
|
||||
```
|
||||
find_transit_cells(this, numParts, parts[], cellArray):
|
||||
exitOutside = false
|
||||
for each portal in this.Portals: # do-while, pc:310140–310252
|
||||
portalPlane = ResolvePlane(this, portal) # this->portals[i].portal.plane
|
||||
for each part in parts: # do-while, pc:310147–310246
|
||||
if part == null: continue
|
||||
sphere = part.GfxObj.PhysicsSphere ?? part.GfxObj.DrawingSphere # 0x0052cb34-0052cb45
|
||||
if sphere == null: continue
|
||||
|
||||
# --- cheap reject (sphere) --------------------------------
|
||||
center = this.Pos.LocalToLocal(part.Pos, sphere.Center) # Position::localtolocal @0x0052cb5a
|
||||
rad = sphere.Radius + F_EPSILON # 0x0052cb65, F_EPSILON=0.000199999995f
|
||||
dist = Dot(center, portalPlane.N) + portalPlane.D # 0x0052cba4
|
||||
|
||||
portalSideRaw = portal.portal_side # this->portals[i].portal_side (int, retail live field)
|
||||
if portalSideRaw == 1: # 0x0052cba7 — matches our PortalInfo.PortalSide == true
|
||||
if dist < -rad: continue # skip this part — 0x0052cbb2/0x0052cbc8 (BN artifact
|
||||
# collapsed the two-branch shape into a spurious
|
||||
# 3-way if/elseif/else; the real shape is two branches
|
||||
# both jumping to the SAME box-test label)
|
||||
else: # portalSideRaw == 0, PortalSide == false
|
||||
if dist > rad: continue
|
||||
|
||||
# --- box admit ---------------------------------------------
|
||||
box = part.GfxObj.PhysicsBox # CPhysicsPart::GetBoundingBox @0x0050d600, 0x0052cbdd
|
||||
localBox = BBox.LocalToLocal(box, part.Pos, this.Pos) # @0x005b1e60, 0x0052cbf9
|
||||
sidedness = portalPlane.intersect_box(localBox) # Plane::intersect_box @0x005aa170, 0x0052cc05
|
||||
|
||||
crossingSide = portal.PortalSide ? Positive : Negative
|
||||
if sidedness != Straddle and sidedness != crossingSide:
|
||||
continue # box fully on the "still inside this cell" side — 0x0052cc0c falls through
|
||||
|
||||
# --- destination resolution (only when box crosses) --------
|
||||
if portal.OtherCellId == 0xFFFFFFFF: # 0x0052cc21
|
||||
exitOutside = true
|
||||
break # out of the PART loop; proceed to next portal — 0x0052cc7e/0x0052ccaa
|
||||
|
||||
otherCell = CCellPortal.GetOtherCell(portal, do_not_load_cells) # 0x0052cc2b, threads cellArray+4
|
||||
if otherCell == null:
|
||||
cellArray.add_cell(portal.OtherCellId, null) # unconditional load hint — 0x0052cca5
|
||||
break # next portal — 0x0052ccaa
|
||||
|
||||
destBox = BBox.LocalToLocal(box, part.Pos, otherCell.Pos) # 0x0052cc4a
|
||||
if CCellStruct.box_intersects_cell(otherCell.structure, destBox): # 0x0052cc61 → BSPTREE @0x0053c880
|
||||
cellArray.add_cell(otherCell.ID, otherCell)
|
||||
break # next portal — 0x0052cc8d
|
||||
# else: this part's box didn't actually reach the other cell's
|
||||
# geometry — continue the PART loop (retest remaining parts
|
||||
# against the SAME portal/destination) — 0x0052cc63, no break
|
||||
if exitOutside:
|
||||
CLandCell.add_all_outside_cells(numParts, parts, cellArray) # 0x0052ccea — already ported (#334)
|
||||
```
|
||||
|
||||
Cross-checked against `docs/ISSUES.md` #335 entry (oracle of record) — every
|
||||
step (1–4) and every cited address matches. Also cross-checked against the
|
||||
**pre-existing** ACE-sourced pseudocode at
|
||||
`docs/research/acclient_indoor_transitions_pseudocode.md` §"EnvCell.find_transit_cells
|
||||
(parts/AABB variant)" (written 2026-04/05, predates named-retail), which
|
||||
independently derives the identical shape from ACE's C# port
|
||||
(`EnvCell.cs:245-309`) — including the exact `Positive`/`Negative` sidedness
|
||||
naming this doc uses below. Two independent sources (raw PDB-paired
|
||||
disassembly and an ACE C#-port cross-reference) agree; this doc treats the
|
||||
agreement as confirmation, not as a new independent fact.
|
||||
|
||||
### Structural difference from the already-ported sphere overload
|
||||
|
||||
`CellTransit.FindTransitCellsSphere` special-cases exterior portals
|
||||
(`OtherCellId == 0xFFFF`) with a dedicated symmetric straddle test **before**
|
||||
touching the loaded/unloaded-neighbour logic (matches the sphere overload's
|
||||
own `if (*ecx != 0xffffffff)` branch at the very top of its per-portal body).
|
||||
The part-array overload does **not** do this: cheap-reject and box-admit run
|
||||
uniformly for every portal (interior or exterior); only **after** the box
|
||||
passes admit does it check `other_cell_id==0xFFFFFFFF` to decide
|
||||
interior-vs-exterior handling. This is why D2 needs a genuinely new method
|
||||
(`FindTransitCellsBox`) rather than a small patch to the existing
|
||||
`FindTransitCellsSphere`.
|
||||
|
||||
## 2. `Plane::intersect_box` (@0x005aa170, pc:439037–439122)
|
||||
|
||||
Classifies a box against a plane using **all 8 corners**, short-circuiting
|
||||
per retail's fixed enumeration order (`min`, then all 7 combinations of
|
||||
{min,max}³ in the specific order the compiler emitted — order does not
|
||||
affect the boolean-classification result since it is an AND of per-corner
|
||||
agreement, only which corner short-circuits first). Epsilon is
|
||||
`F_EPSILON = 0.000199999995f` throughout, matching
|
||||
`Plane::which_side` @0x00444720 (PDB-recovered `Sidedness` enum: `Positive`
|
||||
when `dist >= +eps`, an early-return "clearly negative" case the disassembly
|
||||
calls out at the very first corner test, and a third `Straddle`/on-plane
|
||||
case when `-eps <= dist < +eps`).
|
||||
|
||||
```
|
||||
ClassifyBox(plane, box): # shared math for D1
|
||||
corners = the box's 8 corners (any fixed enumeration; result is order-independent)
|
||||
side0 = WhichSide(plane, corners[0], F_EPSILON)
|
||||
if side0 == Straddle: return Straddle
|
||||
for corner in corners[1..7]:
|
||||
if WhichSide(plane, corner, F_EPSILON) != side0:
|
||||
return Straddle
|
||||
return side0 # Positive or Negative — box is uniformly on one side
|
||||
|
||||
WhichSide(plane, point, eps):
|
||||
dist = Dot(plane.N, point) + plane.D
|
||||
if dist >= eps: return Positive
|
||||
if dist < -eps: return Negative
|
||||
return Straddle
|
||||
```
|
||||
|
||||
**Result contract** (matches the contract's note, re-derived independently
|
||||
from the raw disassembly and cross-checked): the caller at
|
||||
`find_transit_cells`'s 0x0052cc0c tests `classification != portal_side_raw`;
|
||||
the caller at `check_building_transit`'s 0x0052c7a0 tests `classification ==
|
||||
3 || classification == side`. Both are consistent with `ClassifyBox`
|
||||
returning `Straddle` whenever corners disagree (never a bare 0/1 in that
|
||||
case) and `Positive`/`Negative` only when **all 8 corners agree**. `Straddle`
|
||||
is by construction never equal to a 0/1 `portal_side_raw`, so "!= portal_side"
|
||||
the two callers' admit rules are NOT equivalent — dual review finding F9/R6. For the pure-side case they are OPPOSITE: `find_transit_cells` (`eax != side`) admits {Positive, Straddle} when side==1, while `check_building_transit` (`eax == 3 || eax == side`) admits {Straddle, Negative} — physically expected (reach-beyond-the-portal vs inside-this-building-cell are opposite questions), and the trap the future bridge porter must not fall into.
|
||||
|
||||
**PortalSide mapping** (verified two ways — direct disassembly branch
|
||||
structure of the part-array `find_transit_cells`'s cheap-reject, AND the
|
||||
pre-existing `acclient_indoor_transitions_pseudocode.md` §"PortalSide flag
|
||||
semantics" cross-reference against ACE): retail's raw `portal_side` field is
|
||||
`1` exactly when our `PortalInfo.PortalSide == true`. The admit rule,
|
||||
restated without needing that raw integer at all:
|
||||
|
||||
```
|
||||
crossingSide = portal.PortalSide ? Positive : Negative
|
||||
ADMIT box-crosses-this-portal iff sidedness == Straddle OR sidedness == crossingSide
|
||||
```
|
||||
|
||||
## 3. `BSPNODE::box_intersects_cell_bsp` (@0x0053c880, pc:325993–326087)
|
||||
|
||||
Structurally the box-shaped sibling of the already-ported
|
||||
`BSPNODE::point_inside_cell_bsp` (:325508, `BSPQuery.PointInsideCellBsp`) and
|
||||
`BSPNODE::sphere_intersects_cell_bsp` (:325546, `BSPQuery.SphereIntersectsCellBsp`):
|
||||
an iterative walk down `pos_node` only, rejecting (returning "outside") the
|
||||
instant the box is found to lie entirely on the **negative** side of a
|
||||
splitting plane, and treating a null `pos_node` (or a leaf) as "inside."
|
||||
Epsilon is again `F_EPSILON` (line 326001: `0.000199999995f`), reusing the
|
||||
SAME 8-corner box-vs-plane classification as `Plane::intersect_box` — the
|
||||
disassembly literally duplicates the corner-enumeration idiom.
|
||||
|
||||
```
|
||||
BoxIntersectsCellBsp(node, boxMin, boxMax):
|
||||
while node is not a leaf:
|
||||
if ClassifyBox(node.SplittingPlane, boxMin, boxMax) == Negative:
|
||||
return false # box entirely behind this splitting plane
|
||||
if node.PosNode is null:
|
||||
return true # solid interior — matches point/sphere siblings
|
||||
node = node.PosNode
|
||||
return true # reached a leaf
|
||||
```
|
||||
|
||||
This is the **one new traversal shipped in two representations** per the
|
||||
Slice I4/I5 house rule: `BSPQuery.BoxIntersectsCellBsp` over `CellBSPNode`
|
||||
(the graph) and `FlatBspQuery.BoxIntersectsCellBsp` over
|
||||
`FlatCellContainmentBsp`/`FlatCellBspNode` (the flat production shadow),
|
||||
sharing the `ClassifyBox`/`WhichSide` math (added to `BSPQuery` as internal
|
||||
statics, exactly the existing pattern `FlatBspQuery` already uses for
|
||||
polygon math like `PolygonHitsSpherePrecise`).
|
||||
|
||||
`CCellStruct::box_intersects_cell` (@0x00533910, pc:317675) is a bare
|
||||
tailcall into `BSPTREE::box_intersects_cell_bsp` (@0x005398b0, pc:323249),
|
||||
itself a bare tailcall into the function above — no additional logic, same
|
||||
shape as `sphere_intersects_cell` → `sphere_intersects_cell_bsp` already
|
||||
established in this codebase.
|
||||
|
||||
## 4. Box transform: retail `BBox::LocalToLocal` (@0x005b1e60)
|
||||
|
||||
Needed at two call sites in §1 (`localBox` into `this` cell's frame,
|
||||
`destBox` into `otherCell`'s frame). Same shape already ported for the
|
||||
OUTDOOR extent walk's `BBox::LocalToGlobal` (`ShadowPartBox.RefitTo`,
|
||||
#334): an eight-corner transform-and-refit, not a min/max-only transform (a
|
||||
rotated box grows, conservatively — retail's own deliberate direction).
|
||||
`LocalToLocal` differs from `LocalToGlobal` only in that the **destination**
|
||||
frame carries its own rotation (a cell's `WorldTransform`/
|
||||
`InverseWorldTransform`), not just a translation offset. D2 adds a sibling
|
||||
`ShadowPartBox.RefitToLocal(Matrix4x4 worldToLocal, ...)` that composes the
|
||||
part's world placement (`WorldPosition`/`WorldRotation`, identical to
|
||||
`RefitTo`) and then applies the destination's full `Matrix4x4` transform
|
||||
per corner before taking min/max — i.e. `RefitTo` is the degenerate case of
|
||||
`RefitToLocal` where the destination frame has no rotation (world axes).
|
||||
|
||||
## 5. Deliverable mapping
|
||||
|
||||
| Contract deliverable | Implementation |
|
||||
|---|---|
|
||||
| D1 box primitives, graph | `BSPQuery.ClassifyBox`/`WhichSide` (shared), `BSPQuery.BoxIntersectsCellBsp` |
|
||||
| D1 box primitives, flat | `FlatBspQuery.BoxIntersectsCellBsp` (delegates classification math to `BSPQuery`) |
|
||||
| D1 dispatcher (matches existing `SphereIntersectsCell`/`PointInsideCell` shape) | `CollisionTraversal.BoxIntersectsCell` |
|
||||
| D1 referee | `tests/AcDream.Core.Tests/Physics/BoxIntersectsCellBspDifferentialTests.cs` |
|
||||
| D2 box transform | `ShadowPartBox.RefitToLocal` |
|
||||
| D2 indoor-arm rewire | `CellTransit.FindTransitCellsBox` (new), called from `BuildShadowCellSetFromParts`'s indoor branch in place of `FindTransitCellsSphere` |
|
||||
| D2 building-bridge remainder | Left unported (see §0); reported as the explicit remainder |
|
||||
| D3 | `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsBoxTests.cs` (synthetic sabotage + inverse guard) + a direction-assertion sweep test |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Byte confirmations (added at the dual review, 2026-08-07 — PDB-paired binary)
|
||||
|
||||
- **`Plane::which_side` @0x00444720, full decode:** `fcom dist, eps; test ah,0x41;
|
||||
jnz` — POSITIVE (0) iff `dist > eps` STRICTLY; then `fcompp dist, -eps; test
|
||||
ah,0x05; jnp` — NEGATIVE (1) iff `dist < -eps` strictly; IN_PLANE (2)
|
||||
otherwise (unordered also lands IN_PLANE). The port's `>= eps` boundary tie
|
||||
is a one-ULP divergence, noted in AP-159's landing record.
|
||||
- **`Plane::intersect_box` @0x005aa170, first-corner-in-plane early exit:** the
|
||||
`jp` at 0x005aa1bc targets 0x005aa2e2 = `mov eax, 3` — the early exit returns
|
||||
**CROSSING (3)**, not IN_PLANE (2). Both live call sites admit 3, so the S1B
|
||||
port is unaffected; the future `check_building_transit` port inherits this as
|
||||
a settled fact instead of review item b2.
|
||||
- `acclient.h:2527`: `Sidedness { POSITIVE=0, NEGATIVE=1, IN_PLANE=2,
|
||||
CROSSING=3 }` — the header answer the PlaneSide doc previously called
|
||||
unobservable. `which_side` returns at most 2; `intersect_box` is the only
|
||||
producer of 3.
|
||||
Loading…
Add table
Add a link
Reference in a new issue