docs(research): AD-55 resolved by byte decode - retail sled flatness is cos(10deg); ACE's 0.99999536 is a radians/degrees bug
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e0629145ef
commit
378d0b6ca0
1 changed files with 642 additions and 0 deletions
642
docs/research/2026-07-30-ts4-116-oracle-plan.md
Normal file
642
docs/research/2026-07-30-ts4-116-oracle-plan.md
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
# TS-4 / #116 oracle pass — Campaign P final physics slice
|
||||
|
||||
**Status: RESEARCH ONLY. No source changes.** This is a follow-up oracle
|
||||
pass on top of `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md`
|
||||
(hereafter "the P2 doc"), specifically its §4 (TS-4), §5 (#116), and §7
|
||||
item 6 (the P2 implementation attempt's wedge diagnosis). That attempt
|
||||
correctly localized the freeze to `TransitionalInsert`'s Phase 2 retry
|
||||
loop but concluded the mechanism was "Phase 3 structurally unreachable"
|
||||
without tracing far enough to find the actual convergence/divergence
|
||||
point. This pass reads one layer deeper — into `BSPQuery.cs`'s `Path 4`
|
||||
dispatch (the `path.Collide` gate) and `AdjustOffset`'s crease-projection
|
||||
math — and finds a concrete, retail-decomp-cited mechanism for both TS-4
|
||||
and (as a byproduct of reading the same dispatch structure) strong new
|
||||
evidence for #116 shapes 1 and 2.
|
||||
|
||||
Every claim is tagged **FACT** (read directly from the named-retail
|
||||
pseudo-C, ACE source, or current acdream source in this worktree, with
|
||||
file:line / address citations) or **INFERENCE** (derived from those FACTs
|
||||
by direct reasoning, not yet confirmed by a live capture/cdb run).
|
||||
|
||||
---
|
||||
|
||||
## 0. Binding DO-NOT-RETRY entries (copied verbatim)
|
||||
|
||||
From `memory/project_physics_collision_digest.md` (3-day-old snapshot,
|
||||
re-verified against current source where cited below) and
|
||||
`docs/ISSUES.md` #116:
|
||||
|
||||
1. **Do NOT add `SetSlidingNormal` calls in the BSP/sphere collision
|
||||
layer.** Retail's only in-transition writer of
|
||||
`collision_info.sliding_normal` is `validate_transition`
|
||||
(`0x0050ac21`/`0x0050aa70`). A leaked normal + success writeback = an
|
||||
absorbing wedge at empty space. **This pass's TS-4 finding is a
|
||||
variant of exactly this failure class — see §1 below — but the
|
||||
writer in question (`validate_transition`'s unconditional
|
||||
`SetSlidingNormal(CollisionNormal)`) IS the retail-faithful one; the
|
||||
problem is not an extra writer, it's what `AdjustOffset` does with a
|
||||
*placeholder* `UnitZ` value when it reads `SlidingNormal` back.**
|
||||
2. **Do NOT re-add a forced constant-shell de-penetration.** Retail
|
||||
slides tangentially and never force-separates.
|
||||
3. **`SphereCollision` no longer calls `SetSlidingNormal`** (TS-45
|
||||
retired) — keep it that way.
|
||||
4. **Do NOT patch the degenerate-offset guard in `slide_sphere` ad
|
||||
hoc** for #116 — oracle-driven only.
|
||||
5. **Do NOT re-introduce a topology-based outside-add / radial sweep**
|
||||
to cell membership while touching this family.
|
||||
6. **`calc_friction` threshold is retail 0.25 vs acdream 0.0` (AP-7)** —
|
||||
orthogonal to this slice, do not fold in.
|
||||
7. **Shape-1 of #116 is NOT the degenerate-offset guard threshold** —
|
||||
that guard kills slides under ~1.4 cm; the lost tick-22760 slide was
|
||||
3.57 cm. The divergence is the collision-normal SOURCE.
|
||||
8. **Do NOT guess the BN `test ah,5` x87 branch polarity/squaring** in
|
||||
`slide_sphere` — Ghidra MCP is down for this pass too; this pass does
|
||||
**not** touch that question (see §3, shape-2 — the finding here is
|
||||
about dispatch *routing*, not the x87 comparisons inside
|
||||
`slide_sphere`/`AdjustOffset` themselves, which remain unconfirmed
|
||||
and out of scope).
|
||||
9. **AP-4 (CliffSlide check moved before retail's Branch-1 gate)** — a
|
||||
live, load-bearing reordering. Not touched by this pass.
|
||||
10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE.**
|
||||
|
||||
---
|
||||
|
||||
## 1. TS-4 — the actual convergence/divergence mechanism
|
||||
|
||||
### 1.1 Summary answer (read this first)
|
||||
|
||||
**Retail does not "avoid" the Adjusted↔retry oscillation inside
|
||||
`transitional_insert`'s attempt loop any differently than acdream does —
|
||||
both structurally deadlock the same way within a single resolve.** What
|
||||
lets retail's *live* trace escape (and what the P2 fixture's synthetic
|
||||
trajectory does not) is that **retail's `AdjustOffset`
|
||||
(`CTransition::adjust_offset`, `0x0050a370`) re-projects the *next
|
||||
tick's* gravity offset through whatever `ContactPlane` +
|
||||
`SlidingNormal` survived the previous tick's collision — and for a
|
||||
pure, zero-horizontal-velocity vertical fall onto a steep surface, that
|
||||
projection is mathematically degenerate and crushes the offset to
|
||||
(near-)zero every tick, which abort-small-offsets before
|
||||
`TransitionalInsert` even runs again.** This is retail-faithful
|
||||
behavior, present identically in the raw decomp, in ACE's port, and in
|
||||
acdream's current port — it is not a bug introduced by the TS-4
|
||||
shortcut's removal. The `Ts4SteepRoofWedgeCaptureTests` fixture
|
||||
reproduces it because it drops the body **straight down with zero
|
||||
horizontal velocity**, which is very likely a different (and more
|
||||
degenerate) input than the live 2026-04-30 debugger trace that
|
||||
validated the shortcut (a player *jumping or running* onto a roof,
|
||||
which has residual horizontal velocity).
|
||||
|
||||
### 1.2 The chain, FACT by FACT
|
||||
|
||||
**Step A — Path 6 fires, sets `Collide`, does not reposition (FACT).**
|
||||
`BSPQuery.cs:2217-2224` (faithful branch, shortcut removed):
|
||||
```csharp
|
||||
path.SetCollide(worldNormal0);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
return TransitionState.Adjusted;
|
||||
```
|
||||
`SpherePath.SetCollide` (`TransitionTypes.cs:752-759`) only sets
|
||||
`Collide=true`, backs up `CheckPos`, and stores `StepUpNormal` — it does
|
||||
**not** touch `CollisionInfo.ContactPlane` or `CollisionNormal`. Matches
|
||||
retail exactly: pseudo-C:323818-323821 (`0x0053a7bf`,
|
||||
`SPHEREPATH::set_collide(&sphere_path, &normal); walkable_allowance =
|
||||
0.0871556997f; return 3;`) — no `set_collision_normal`, no
|
||||
`set_contact_plane` call at this site either.
|
||||
|
||||
**Step B — the SAME attempt's retry does NOT re-hit Path 6; it routes to
|
||||
Path 4 (FACT, both acdream and retail).** `BSPQuery.cs:1961` gates on
|
||||
`if (path.Collide)` — checked **before** the Path 5/6 tests, at the top
|
||||
of the same dispatch function. Since `Collide` was just set in Step A
|
||||
and is **never cleared** except inside `TransitionalInsert`'s Phase 3
|
||||
(`sp.Collide = false` at `TransitionTypes.cs:1816`, reachable only on an
|
||||
`OK` result — never reached while Path 6/Path 4 keep returning
|
||||
`Adjusted`), every subsequent attempt (within the same resolve **and**
|
||||
across ticks) dispatches to Path 4, not back to Path 6. Retail: raw
|
||||
pseudo-C:323784 `if (eax->sphere_path.collide == 0) {...} else {...}` —
|
||||
the identical gate, at the identical position in the dispatch (confirmed
|
||||
independently against ACE `BSPTree.cs:163-187`, `if (path.Collide) {
|
||||
RootNode.find_walkable(...); if (changed) {... return Adjusted;} else
|
||||
return OK; }`).
|
||||
|
||||
**Step C — Path 4 (`FindWalkableInternal`) is what actually establishes
|
||||
`ContactPlaneValid` (FACT).** `BSPQuery.cs:1968-2018`: calls
|
||||
`FindWalkableInternal`; if it finds a candidate (`changed &&
|
||||
hitPoly is not null`), it **repositions** the sphere
|
||||
(`path.AddOffsetToCheckPos(worldOffset)`), sets a **real**
|
||||
`ContactPlane` via `collisions.SetContactPlane(worldPlane, ...)`
|
||||
(line 2006), caches the walkable polygon (`SetWalkableTransformed`), and
|
||||
returns `Adjusted`. This is the only site that gives the mover a real
|
||||
(steep) contact plane in this whole trajectory — **not** the Phase-3
|
||||
`DoCheckWalkable` gate the P2 doc's item-6 diagnosis assumed was the
|
||||
relevant site (that gate is downstream and, per Step B, unreachable
|
||||
here). Matches ACE `BSPTree.cs:163-184` exactly (`SetContactPlane`,
|
||||
`SetWalkable`, `return Adjusted`).
|
||||
|
||||
**Step D — the attempt-exhausted `Adjusted` gets collapsed to `OK` with
|
||||
position reverted, but `ContactPlaneValid` survives the revert (FACT,
|
||||
both engines).** `TransitionalInsert`'s outer for-loop exhausts (acdream
|
||||
hardcodes `return TransitionState.Slid;` at `TransitionTypes.cs:2093`;
|
||||
ACE/retail return the true last value, `Adjusted` here — see §1.4 for why
|
||||
this particular divergence doesn't change the outcome). Either way,
|
||||
`ValidateTransition`'s "not OK" branch runs
|
||||
(`TransitionTypes.cs:5493-5501`): `if (!CollisionNormalValid)
|
||||
SetCollisionNormal(UnitZ);` (fires — Path 4/6 never touched
|
||||
`CollisionNormal`, only `ContactPlane`/`StepUpNormal`), then
|
||||
`SetCheckPos(CurPos, CurCellId)` (revert — no net movement),
|
||||
`transitionState = OK`. Retail: pseudo-C:272563-272596 (`0x0050aad9`),
|
||||
identical collapse (`COLLIDED_TS`/`ADJUSTED_TS`/`SLID_TS` all treated the
|
||||
same, default `CollisionNormal=UnitZ` if unset, revert `check_pos` to
|
||||
`curr_pos`). **Crucially, none of this touches `ContactPlaneValid`** — it
|
||||
carries forward from Step C untouched by the revert. Then the shared
|
||||
tail (`TransitionTypes.cs:5504-5533`, retail pc:272621-272656) runs:
|
||||
`if (CollisionNormalValid) SetSlidingNormal(CollisionNormal)` — now
|
||||
**`SlidingNormal = UnitZ`** (the placeholder from the default, not a
|
||||
real second surface) — and `if (ContactPlaneValid) { ...;
|
||||
oi.State|=Contact; if (Normal.Z>=FloorZ) OnWalkable=true else false; }`
|
||||
— since the steep polygon's `Normal.Z` (≈0.447 for the fixture's 63.4°
|
||||
slope) `< FloorZ` (≈0.664), `OnWalkable` stays **false** but `Contact`
|
||||
becomes **true**. **This exactly reproduces the fixture's own captured
|
||||
state at the landing tick: `InContact=true, OnWalkable=false`.**
|
||||
|
||||
**Step E — the NEXT tick's `AdjustOffset` crushes a purely-vertical
|
||||
offset to zero (FACT for the math, INFERENCE that this is the actual
|
||||
observed freeze cause — not independently re-run this pass).**
|
||||
`TransitionTypes.cs:4936-5014` (acdream), `Transition.cs:34-87` (ACE),
|
||||
pseudo-C:272271-272393 (`0x0050a370`, retail) are all structurally
|
||||
identical:
|
||||
```
|
||||
slidingAngle = Dot(offset, SlidingNormal)
|
||||
if (SlidingNormalValid) { if (slidingAngle < 0) checkSlide = true; else SlidingNormalValid = false; }
|
||||
...
|
||||
if (checkSlide) {
|
||||
slideOffset = Cross(ContactPlane.Normal, SlidingNormal)
|
||||
normalize slideOffset (or zero out if degenerate)
|
||||
result = Dot(slideOffset, offset) * slideOffset
|
||||
}
|
||||
```
|
||||
With `offset = (0, 0, -dz)` (pure gravity, zero horizontal component),
|
||||
`SlidingNormal = UnitZ = (0,0,1)`: `slidingAngle = -dz < 0` →
|
||||
`checkSlide = true`. `slideOffset = Cross(ContactPlane.Normal, UnitZ)` —
|
||||
for any non-vertical plane normal `N=(Nx,Ny,Nz)`, this cross product is
|
||||
`(Ny, -Nx, 0)` — a **horizontal** vector (Z=0), lying in the slope's
|
||||
*contour* line (perpendicular to the downhill direction), **not the
|
||||
degenerate/near-zero case** (the 63.4° slope's normal is not parallel to
|
||||
UnitZ, so `NormalizeCheckSmall` does not fire). `Dot(slideOffset,
|
||||
offset) = Dot((Ny,-Nx,0), (0,0,-dz)) = 0` exactly, because
|
||||
`slideOffset.Z = 0` and `offset` is purely `Z`. **`result = 0 *
|
||||
slideOffset = Vector3.Zero`.** The projected `GlobalOffset` is zero (up
|
||||
to float noise), which trips the "abort-small-offset" guard
|
||||
(`TransitionTypes.cs:1466-1478`, retail's non-viewer `|offset|² <
|
||||
F_EPSILON²` gate at pseudo-C:272845/`0x0050bdf0`, cited already in the
|
||||
existing `AdjustOffset` port comment) **before `TransitionalInsert` is
|
||||
even called again** — so `ValidateTransition` never runs on subsequent
|
||||
ticks either, meaning the stale `ContactPlaneValid`/`SlidingNormal=UnitZ`
|
||||
state simply perpetuates unchanged, forever. This is the freeze.
|
||||
|
||||
**Step F — why the existing frames_stationary_fall (fsf) escape valve
|
||||
can't rescue this case (INFERENCE, follows directly from Step E).** The
|
||||
digest's #182 rebuild already ported retail's fsf ladder
|
||||
(`TransitionTypes.cs:5625-5667`, ACE `Transition.cs:1029-1061`,
|
||||
pseudo-C:272625-656) — after 3 consecutive non-advancing ticks it
|
||||
manufactures a flat `UnitZ` contact plane and forces `OnWalkable=true`,
|
||||
which is exactly the kind of "unstick" mechanism one would look for
|
||||
here. **But that ladder lives inside `ValidateTransition`, which Step
|
||||
E's abort-small-offset guard prevents from ever running again** once the
|
||||
crease projection first crushes the offset to zero. The rescue mechanism
|
||||
is downstream of a gate the degenerate input never lets execution
|
||||
reach — in both acdream and (per identical source) retail.
|
||||
|
||||
### 1.3 Why this reconciles the shortcut's own "retail did not wedge" comment (INFERENCE)
|
||||
|
||||
The shortcut's comment (`BSPQuery.cs:2190-2199`) says the interim fix was
|
||||
"Validated against retail debugger trace 2026-04-30: retail body did not
|
||||
wedge." A live player jumping or walking onto a roof virtually always
|
||||
carries **some** horizontal velocity component (WASD input, residual
|
||||
momentum). For a non-purely-vertical `offset`, `Dot(slideOffset, offset)`
|
||||
is generally **non-zero** (only a component exactly along the pure
|
||||
downhill/gravity line is annihilated by this specific cross product —
|
||||
any lateral drift survives), so `AdjustOffset` would produce a small but
|
||||
non-zero *sideways* offset each tick — enough to move the sphere off the
|
||||
exact same collision point, avoid the abort-small-offset short-circuit,
|
||||
let `TransitionalInsert`/`ValidateTransition` run again, and (via
|
||||
repeated Path-4 `find_walkable` re-probes and the fsf ladder) eventually
|
||||
resolve. **The `Ts4SteepRoofWedgeCaptureTests` fixture's `pos =
|
||||
(0.5, 0, 3.0)` straight-down drop with `fallVelocityZ` as the only
|
||||
non-zero component is very likely a stricter, more degenerate input than
|
||||
the live 2026-04-30 repro ever exercised.** This is not yet independently
|
||||
re-confirmed by re-running the fixture with a horizontal component (see
|
||||
§4 Step 1 below for the concrete next action), so it is flagged
|
||||
INFERENCE — but it is the only hypothesis consistent with every FACT
|
||||
gathered in §1.2, and it does not require inventing any new mechanism.
|
||||
|
||||
### 1.4 The acdream-only bug that does NOT explain the freeze, but is real and should still be fixed
|
||||
|
||||
`TransitionTypes.cs:2091-2093`:
|
||||
```csharp
|
||||
// Exhausted retry attempts — return whatever the last iteration said.
|
||||
// (Defaults to Slid in practice since that's the only case that retries.)
|
||||
return TransitionState.Slid;
|
||||
```
|
||||
This is **hardcoded**, not "whatever the last iteration said" as the
|
||||
comment claims. ACE's equivalent (`Transition.cs:933`, `return
|
||||
transitState;`) and retail's (pseudo-C:273363, `0x0050b949`, `return
|
||||
edi;`) both return the **true** last value — `Adjusted` in this
|
||||
scenario, not `Slid`. **FACT: this is a real, citable divergence.**
|
||||
**FACT: it does not explain the freeze** — `ValidateTransition`'s
|
||||
"not OK" branch (§1.2 Step D) treats `Collided`/`Adjusted`/`Slid`
|
||||
**identically** (acdream `TransitionTypes.cs:5493-5501`, ACE
|
||||
`Transition.cs:993-1017`, retail pseudo-C:272563-272596 all gate on
|
||||
`result > OK_TS && result <= SLID_TS` as one combined range, with no
|
||||
per-value branching). Fixing the hardcoded return is a one-line,
|
||||
zero-risk correctness fix (worth doing — it's a real citable
|
||||
port-accuracy bug and prevents future confusion when tracing this loop)
|
||||
but it is **not** the TS-4 fix and should not be presented as one.
|
||||
|
||||
### 1.5 What TS-4's actual fix shape is, given this
|
||||
|
||||
The mechanism in §1.2 is **not something `BSPQuery.cs`'s Path 6 can fix
|
||||
by itself** — the freeze happens one tick *after* Path 6/Path 4 run,
|
||||
inside `AdjustOffset`, and is a property of the (already retail-faithful)
|
||||
`validate_transition` + `adjust_offset` pipeline reacting to a specific
|
||||
degenerate trajectory. Concretely, TS-4's shortcut removal is very
|
||||
likely **safe for the realistic case** (nonzero horizontal velocity) and
|
||||
only exposes this specific zero-horizontal-velocity degenerate, which:
|
||||
- may be a genuine, narrow, retail-faithful edge case (a player falling
|
||||
perfectly plumb onto a slope with zero horizontal drift essentially
|
||||
never happens in live play — WASD input, camera-relative movement, and
|
||||
even tiny numerical noise almost always inject some horizontal
|
||||
component), in which case it is not a blocker for TS-4 at all and
|
||||
should be documented as an accepted (retail-matching) corner case
|
||||
rather than "fixed", **or**
|
||||
- may indicate the fixture itself should be revised to match the
|
||||
original live repro's actual trajectory shape (nonzero horizontal
|
||||
velocity) before it's trusted as TS-4's gating fixture.
|
||||
|
||||
See §4 for the concrete, low-cost verification step (re-run the fixture
|
||||
with a small horizontal velocity component) that would settle which of
|
||||
these is true without guessing.
|
||||
|
||||
---
|
||||
|
||||
## 2. #116 shape-1 — collision-normal recording divergence (new candidate, INFERENCE, needs one instrumentation run to confirm)
|
||||
|
||||
### 2.1 What the existing research already ruled out (FACT, restated)
|
||||
|
||||
Ghidra-confirmed (2026-06-12, digest lines 1268-1275): acdream's
|
||||
`cn=UnitZ` default on a blocked move **is** retail-faithful
|
||||
(`validate_transition` does the identical default). The real divergence
|
||||
is **upstream** — at tick-22760, acdream's `collision_normal_valid` was
|
||||
`false` where retail's was `true` (retail had recorded the door-face
|
||||
normal `(0,+1,0)`). The candidate site named in the P2 doc §5 was "the
|
||||
`PathClipped`/`collide_with_pt` arm... or a sibling Path-1-class function
|
||||
not yet read."
|
||||
|
||||
### 2.2 PathClipped is NOT the answer (checked this pass, negative result — FACT)
|
||||
|
||||
`ObjectInfoState.PathClipped` (`TransitionTypes.cs:32`, bit `0x8`) is
|
||||
only set on a mover when `MoverPhysicsState & PhysicsStateFlags.Missile
|
||||
!= 0` (`PhysicsEngine.cs:1160-1163`), with an explicit citation to
|
||||
retail's own `CPhysicsObj::get_object_info` (`0x00511CC0`): "Missile
|
||||
contributes PathClipped only." A normal player push against a door is
|
||||
not a missile mover, so **neither acdream nor retail would set
|
||||
PathClipped for this scenario** — this rules out "PathClipped state
|
||||
differs between engines" as shape-1's cause. (The camera/viewer sweep
|
||||
does carry PathClipped via a different, explicit caller-supplied flag,
|
||||
but that's a different mover than the one in the tick-22760 door-push
|
||||
capture.)
|
||||
|
||||
### 2.3 The real candidate: acdream's Path-6 sphere1(head)-hit handling diverges from retail/ACE (FACT for the divergence, INFERENCE that it explains tick-22760)
|
||||
|
||||
Retail's `BSPTREE::find_collisions`, in the **not-yet-in-Contact**
|
||||
branch (`state&1==0`, i.e. airborne / first contact — pseudo-C:323784-
|
||||
323836, `0x0053a4e3`-`0x0053a730`+): when sphere0 (foot) does **not**
|
||||
hit but `num_sphere > 1` and sphere1 (head) **does** hit, retail does
|
||||
**not** defer through `SetCollide`/`Adjusted` — it calls
|
||||
`COLLISIONINFO::set_collision_normal` **directly** with the head poly's
|
||||
transformed normal and returns `COLLIDED_TS` (`2`) immediately
|
||||
(pseudo-C:323824-323834, `0x0053a793`/`0x0053a7a4`). Cross-checked
|
||||
independently against ACE `BSPTree.cs:221-230`:
|
||||
```csharp
|
||||
else if (path.NumSphere > 1)
|
||||
{
|
||||
if (RootNode.sphere_intersects_poly(localSphere_, movement, ref hitPoly, ref contactPoint) || hitPoly != null)
|
||||
{
|
||||
var collisionNormal = path.LocalSpacePos.LocalToGlobalVec(hitPoly.Plane.Normal);
|
||||
collisions.SetCollisionNormal(collisionNormal);
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
}
|
||||
```
|
||||
— an exact structural match to the raw decomp, confirming this is not a
|
||||
BN misdecompile.
|
||||
|
||||
**acdream's corresponding code (`BSPQuery.cs:2227-2264`) does NOT do
|
||||
this.** It applies the *same* SetCollide-and-defer (or steep→`Slid`)
|
||||
treatment to a sphere1 hit as it does to sphere0 — there is no branch
|
||||
that returns `Collided` with a direct `SetCollisionNormal` write for "foot
|
||||
clear, head hit" while airborne. This means: **in acdream, an airborne
|
||||
mover whose HEAD sphere alone contacts a polygon (foot sphere clear) gets
|
||||
`SetCollide` + deferred `Adjusted` (no immediate `CollisionNormal`
|
||||
write) — exactly the same "the real normal gets lost until
|
||||
`validate_transition`'s `UnitZ` default kicks in" symptom the digest
|
||||
already diagnosed for shape-1.** A door push where the player's capsule
|
||||
brushes the door frame near chest/head height while the foot sphere
|
||||
tracks slightly differently (a very plausible geometry for "pushing a
|
||||
closed door face at a near-perpendicular angle," matching the tick-22760
|
||||
description) is a strong candidate for exactly this code path.
|
||||
|
||||
**Caveat, stated honestly:** this is contingent on sphere0 (foot) *not*
|
||||
fully hitting while sphere1 (head) *does* — if the door's collision
|
||||
geometry is a full vertical plane, sphere0 would very likely hit too,
|
||||
and the code would never reach the sphere1 branch (`BSPQuery.cs:2156`'s
|
||||
`if (hit0 || hitPoly0 is not null)` returns early). This has **not**
|
||||
been confirmed against the actual tick-22760 replay this pass — it is
|
||||
the single next concrete step (see §4).
|
||||
|
||||
### 2.4 Instrumentation to run (concrete, low-cost, no guessing required)
|
||||
|
||||
Extend `DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals`
|
||||
(`tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:162`)
|
||||
to log, at the tick-22760 resolve, which of `hit0`/`hitPoly0`/`hit1`/
|
||||
`hitPoly1` were non-null/true inside `BSPQuery.cs`'s Path-6 dispatch
|
||||
(a one-line `Console.WriteLine` gated behind the existing
|
||||
`ProbeIndoorBspEnabled`/`ProbeBuildingEnabled` diagnostics, or a new
|
||||
narrowly-scoped probe flag per the project's diagnostic-owner pattern).
|
||||
|
||||
**Accept criterion:** if `hit0`/`hitPoly0` are both null/false **and**
|
||||
`hit1`/`hitPoly1` fire, §2.3's hypothesis is confirmed — the fix is to
|
||||
port retail's direct sphere1-hit-without-sphere0-hit → `Collided` +
|
||||
`SetCollisionNormal` branch into `BSPQuery.cs`'s Path 6 (mirroring the
|
||||
already-correct Path 5/Contact-branch treatment at
|
||||
`BSPQuery.cs:2103-2140`, which already handles the analogous grounded
|
||||
case correctly — this would be a narrow, well-precedented port, not a
|
||||
new design).
|
||||
|
||||
**Reject criterion:** if sphere0 hits too (`hit0` or `hitPoly0` truthy),
|
||||
this hypothesis is wrong for tick-22760 specifically, and the search
|
||||
should move to the *other* named-retail sibling not yet read this pass —
|
||||
`BSPTREE::collide_with_pt`'s own internal structure for a **non-PathClipped**
|
||||
context is not reachable (its outer gate requires `state&8`), so the
|
||||
next candidate would be whatever governs `CObjCell::find_obj_collisions`'s
|
||||
insertion order relative to `find_env_collisions` for a door's *building*
|
||||
channel (the BR-7/A6.P4 per-cell shadow architecture) — not yet examined
|
||||
this pass; would need a fresh read of that dispatch specifically for
|
||||
polygon ordering/precedence when multiple candidate polys are tested per
|
||||
cell.
|
||||
|
||||
---
|
||||
|
||||
## 3. #116 shape-2 — first-airborne-frame hard-stop vs in-frame slide (strong structural finding, INFERENCE, narrows but does not eliminate the need for a confirming run)
|
||||
|
||||
### 3.1 The dispatch structure resolves the ROUTING question without cdb (FACT, cross-referenced against 3 sources: raw BN pseudo-C, ACE, current acdream)
|
||||
|
||||
Both the raw retail decomp and ACE's `BSPTree.cs` (an independent,
|
||||
clean-language port — the "fastest oracle" the mission suggested)
|
||||
show the **same two-tier gate**, keyed on `ObjectInfoState.Contact`:
|
||||
|
||||
- **Already grounded (`Contact` set) + head-sphere hit** → `slide_sphere`
|
||||
called **directly, in-line, same tick** (ACE `BSPTree.cs:192-202`;
|
||||
retail pseudo-C region immediately following `0x0053a730`'s `state&1`
|
||||
branch — the `else` arm at ~323838+, not fully re-quoted here but
|
||||
structurally mirrored by ACE's clean port). acdream's `BSPQuery.cs`
|
||||
Path 5 (`:2103-2120`) already matches this exactly — `SlideSphere`
|
||||
called directly for a grounded head-hit.
|
||||
- **NOT yet grounded (`Contact` unset, i.e. airborne / first contact) +
|
||||
foot-sphere hit** → the **Path-6 default**: `SetCollide` +
|
||||
`WalkableAllowance=LandingZ` + return `Adjusted` — **no
|
||||
repositioning, no `slide_sphere` call at all** (ACE `BSPTree.cs:210-219`;
|
||||
retail pseudo-C:323815-323821). Only a sphere1(head)-hit-without-
|
||||
sphere0-hit gets an immediate response in this branch, and that
|
||||
response is `Collided` (§2.3), **still not `slide_sphere`**.
|
||||
|
||||
**This means: for a genuine first-airborne-frame FOOT-sphere wall hit
|
||||
(the D4 fixture's actual shape — a mover falling into a tall wall),
|
||||
neither retail nor ACE's port calls `slide_sphere` on contact frame 1.**
|
||||
The sphere is left exactly where it was (`SetCollide` does not
|
||||
reposition — confirmed in §1.2 Step A), `Collide` gets set, and the
|
||||
**very next retry attempt** (same tick, same `TransitionalInsert` loop,
|
||||
per §1.2 Step B) routes to **Path 4** (`find_walkable`) instead. For a
|
||||
**tall, vertical wall** (D4's actual geometry — "TallWall" per the test
|
||||
name), `find_walkable`'s nearby-walkable-surface search would very
|
||||
plausibly find **no** candidate (a sheer vertical face has no
|
||||
near-horizontal polygon to "land" on nearby) — `changed=false` — so
|
||||
Path 4 returns `OK` (ACE `BSPTree.cs:185-186`, `else return
|
||||
TransitionState.OK;`). `TransitionalInsert`'s Phase 3 (`if
|
||||
(sp.Collide)`, now finally reachable since `objState==OK`) then runs:
|
||||
`ContactPlaneValid` is **false** (Path 4's `changed=false` arm never
|
||||
sets it), so the `else reset=true;` branch fires
|
||||
(`TransitionTypes.cs:1842-1843`), `RestoreCheckPos()` reverts to the
|
||||
pre-hit position, and the retail-faithful gate at
|
||||
`TransitionTypes.cs:1863-1898` (matching pseudo-C:273231-273239 exactly,
|
||||
already cited in-code) fires: since this is the *first* airborne
|
||||
contact, `LastKnownContactPlaneValid` is false, so
|
||||
`SetCollisionNormal(sp.StepUpNormal)` (the wall's **real** normal,
|
||||
captured back at the original Path-6 hit) runs and the function returns
|
||||
**`Collided`** — a **hard stop, in place, with the correct wall normal
|
||||
recorded** — not a slide.
|
||||
|
||||
### 3.2 What this means for D4
|
||||
|
||||
**INFERENCE, well-supported but not independently re-run this pass:**
|
||||
the D4 pin's original expectation (frame 1 hard-stops at Z=2.0, the
|
||||
slide begins frame 2 off the cached sliding normal) is structurally much
|
||||
closer to what retail's own dispatch produces for a true vertical-wall
|
||||
first-contact than the engine's current in-frame slide-to-Z=1.92
|
||||
behavior. **This narrows — but does not eliminate — the open question.**
|
||||
What remains genuinely unconfirmed by static reading (and is exactly
|
||||
the class of question DO-NOT-RETRY item 8 warns against guessing):
|
||||
|
||||
- Whether `find_walkable`'s internal walkable-search radius/height
|
||||
actually returns "nothing found" for the *specific* D4 fixture
|
||||
geometry (a wall tall enough that no nearby floor exists within its
|
||||
search envelope) — this is a **testable, non-cdb** question: instrument
|
||||
or step through `FindWalkableInternal` for the D4 geometry and confirm
|
||||
`changed=false`.
|
||||
- The exact x87 comparison polarities *inside* `slide_sphere` and
|
||||
`find_walkable` themselves (unrelated to this pass's routing finding)
|
||||
remain unconfirmed per DO-NOT-RETRY item 8 — but those don't matter for
|
||||
D4 if `slide_sphere` is never reached on frame 1 in the first place.
|
||||
|
||||
### 3.3 Recommended next step for shape-2 (no cdb needed for the routing question; cdb only if the confirming run disagrees)
|
||||
|
||||
1. **First (cheap, no cdb):** run/instrument the existing
|
||||
`BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames`
|
||||
fixture (currently `Skip`-tagged citing #116) with a probe on which
|
||||
`BSPQuery.cs` path fires on frame 1 (Path 6 vs Path 4 vs a full-hit-
|
||||
the-second-attempt path) and whether `FindWalkableInternal` returns
|
||||
`changed=true` or `false` for that specific wall. **Accept:** if
|
||||
Path 6 fires (`SetCollide`+`Adjusted`, no reposition), Path 4 then
|
||||
fires with `changed=false`, and the final result is `Collided` with
|
||||
`StepUpNormal` as the recorded normal — this confirms §3.1/§3.2, and
|
||||
the fix is to **flip the D4 pin back to hard-stop** (retire the
|
||||
`Skip`, assert Z=2.0 frame 1) rather than changing the engine.
|
||||
**Reject:** if the trace shows something else (e.g. Path 4 actually
|
||||
finds a walkable candidate for this wall, or a different dispatch arm
|
||||
fires entirely) — then the routing hypothesis in §3.1 doesn't hold for
|
||||
this specific fixture geometry, and a live cdb trace becomes necessary
|
||||
after all.
|
||||
2. **Only if step 1 disagrees with the FACT-cited dispatch structure:**
|
||||
a live cdb trace of an actual airborne wall hit in retail, per the
|
||||
CLAUDE.md "Retail debugger toolchain" section. Concrete script
|
||||
outline (adapting the documented pattern):
|
||||
```
|
||||
.logopen ts4-116-airborne-wallhit.log
|
||||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||||
.symopt+ 0x40
|
||||
.reload /f acclient.exe
|
||||
|
||||
r $t0 = 0
|
||||
bp acclient!BSPTREE::find_collisions "r $t0 = @$t0 + 1; .if (@$t0 % 1 == 0) { .printf \"hit %d: state=%%d collide=%%d\\n\", @$t0 } gc"
|
||||
bp acclient!CSphere::slide_sphere "r $t1 = @$t1 + 1; .printf \"SLIDE_SPHERE HIT #%d\\n\", @$t1; .if (@$t1 >= 3) { qd } .else { gc }"
|
||||
bp acclient!BSPTREE::collide_with_pt "r $t2 = @$t2 + 1; .printf \"COLLIDE_WITH_PT HIT #%d\\n\", @$t2; gc"
|
||||
g
|
||||
```
|
||||
User reproduces: jump toward a tall vertical wall so the FIRST wall
|
||||
contact happens while airborne (not already grounded). The key
|
||||
signal is whether `slide_sphere` fires on the **same** engine tick
|
||||
as the first `find_collisions` hit against that wall (in-frame slide,
|
||||
confirming the CURRENT engine behavior) or only on a **later** tick
|
||||
(confirming the hard-stop-then-slide-frame-2 pin). Auto-detaches via
|
||||
`qd` after 3 `slide_sphere` hits to bound game lag.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended execution order + blast radius
|
||||
|
||||
1. **[Lowest risk, do first] Fix the `TransitionalInsert` exhausted-loop
|
||||
hardcoded return** (§1.4): change `return TransitionState.Slid;` to
|
||||
return the real last `transitState` value, matching ACE/retail. Blast
|
||||
radius: essentially zero — `ValidateTransition` treats
|
||||
`Collided`/`Adjusted`/`Slid` identically downstream (confirmed §1.2
|
||||
Step D), so this is a pure code-correctness fix with no observable
|
||||
behavior change in any currently-passing test. Good precursor because
|
||||
it removes a misleading comment/return before anyone traces this loop
|
||||
again.
|
||||
|
||||
2. **[Cheap, decides whether TS-4 needs anything further] Re-run
|
||||
`Ts4SteepRoofWedgeCaptureTests` with a small horizontal velocity
|
||||
component** (e.g. `vx = 0.3` m/s alongside the existing straight-down
|
||||
fall), shortcut removed. Per §1.3's hypothesis, this should **not**
|
||||
wedge (the crease projection produces a non-zero tangential offset).
|
||||
**Accept (doesn't wedge):** TS-4's shortcut removal is safe for the
|
||||
realistic case; land it, retire the TS-4 register row, and either (a)
|
||||
accept the pure-vertical case as a documented, retail-faithful corner
|
||||
case (cite §1.2/§1.3 in the register row) or (b) if the team wants
|
||||
zero residual risk, also file a narrow follow-up for the
|
||||
zero-horizontal-velocity degenerate specifically (not a TS-4 blocker).
|
||||
**Reject (still wedges even with horizontal velocity):** §1.3's
|
||||
hypothesis is wrong or incomplete; do NOT land TS-4 yet — re-open with
|
||||
a fresh capture of the actual velocity vector at the wedge point and
|
||||
compare against what `AdjustOffset` computes step by step (a
|
||||
`ACDREAM_DUMP_EDGE_SLIDE`-style trace of `AdjustOffset`'s intermediate
|
||||
`slidingAngle`/`collisionAngle`/`slideOffset` values, not yet
|
||||
instrumented, would be the concrete next apparatus).
|
||||
|
||||
3. **[Independent of 1-2] #116 shape-1 instrumentation** (§2.4): add the
|
||||
one-line hit0/hitPoly0/hit1/hitPoly1 probe to
|
||||
`Diagnostic_Tick22760_DumpEngineInternals` and re-run. Blast radius:
|
||||
zero (diagnostic-only). If confirmed, the fix (porting retail's direct
|
||||
sphere1-hit → `Collided`+`SetCollisionNormal` branch into Path 6) is a
|
||||
narrow, well-precedented addition mirroring the already-correct Path 5
|
||||
treatment — moderate blast radius (touches the shared Path-6 dispatch
|
||||
used by every airborne two-sphere mover), needs the existing
|
||||
`SphereCollisionFamilyTests`/`Issue137*` suites re-run plus a fresh
|
||||
tick-22760 comparison before landing.
|
||||
|
||||
4. **[Independent of 1-3] #116 shape-2 instrumentation** (§3.3 step 1):
|
||||
add the BSPQuery-path + `FindWalkableInternal` `changed` probe to the
|
||||
D4 fixture. Blast radius: zero (diagnostic-only) for the instrumentation
|
||||
itself. If confirmed, flipping the D4 pin (un-skip, assert hard-stop
|
||||
frame 1) is a **test-only** change with **zero production code
|
||||
change** — the engine's current dispatch already produces this
|
||||
result per §3.1's reading; only the test's own expectation is
|
||||
currently wrong. This is the lowest-risk of all four items once
|
||||
confirmed, because it requires touching zero engine code.
|
||||
|
||||
**Suggested order given the above:** 1 → 4 → 3 → 2, since 4 (#116
|
||||
shape-2) is the cheapest to fully resolve (test-only fix, zero engine
|
||||
change, per this pass's structural finding) and 2 (TS-4's own
|
||||
confirming run) benefits from having item 1's return-value fix landed
|
||||
first (removes a confusing false signal before re-tracing).
|
||||
|
||||
---
|
||||
|
||||
## 5. What genuinely still needs cdb or Ghidra (not resolved by this pass)
|
||||
|
||||
1. **#116 shape-2, only if §3.3 step 1's confirming run disagrees with
|
||||
the FACT-cited dispatch structure.** The routing question itself
|
||||
(does frame 1 reach `slide_sphere`) is resolved by static reading
|
||||
against 3 independent sources in this pass; only a surprising,
|
||||
contradicting instrumentation result would re-open the need for a
|
||||
live trace. The cdb script outline is in §3.3 step 2.
|
||||
2. **The x87 comparison polarities inside `slide_sphere`,
|
||||
`find_walkable`, and `AdjustOffset`'s own internal branches**
|
||||
(DO-NOT-RETRY item 8) — untouched by this pass, remain Ghidra/cdb-
|
||||
gated as before. This pass's findings are about which *function*
|
||||
gets called (dispatch routing), not the exact comparison operators
|
||||
inside those functions.
|
||||
3. **AP-7's `cos(10°)` vs `0.99999536f` discrepancy** (P2 doc §1) —
|
||||
unrelated to this pass, still needs a Ghidra decompile of
|
||||
`0050ee70` when Ghidra MCP is back up.
|
||||
4. **TS-1 gaps #2/#3's `last_known_contact_plane` maintenance and
|
||||
Path-4 `LandingZ` acceptance audit** (P2 doc §2, §6 Step 2) — per the
|
||||
current source read in this pass, this already carries an in-code
|
||||
citation ("TS-1 gap #3 (register AD-54, Campaign P Slice P2
|
||||
2026-07-30)") suggesting it was addressed in the same implementation
|
||||
session that produced the P2 doc's item-6 update; not independently
|
||||
re-verified this pass.
|
||||
|
||||
---
|
||||
|
||||
## 6. One-paragraph summary for the calling agent
|
||||
|
||||
**TS-4:** the Adjusted↔retry loop the P2 doc's implementation attempt
|
||||
found is real, but its root cause is one layer downstream of where that
|
||||
attempt looked. `Path 6` sets `Collide=true` without moving the sphere;
|
||||
every subsequent attempt (same tick and later ticks, since `Collide` is
|
||||
never cleared outside Phase 3) routes to `Path 4`
|
||||
(`FindWalkableInternal`), which is what actually establishes the steep
|
||||
`ContactPlane` (matching the fixture's observed `InContact=true,
|
||||
OnWalkable=false`). The freeze itself happens one tick later, inside
|
||||
`AdjustOffset`: `validate_transition`'s retail-faithful `CollisionNormal
|
||||
→ UnitZ` default feeds `SetSlidingNormal`, and `AdjustOffset`'s
|
||||
crease-projection (`Cross(ContactPlane.Normal, SlidingNormal)`) is
|
||||
mathematically orthogonal to a **purely vertical** input offset — every
|
||||
subsequent tick's gravity-only offset gets crushed to zero and
|
||||
abort-small-offsets before the engine can run again. This exact
|
||||
mechanism is present identically in the raw retail decomp, ACE's port,
|
||||
and acdream's current port — it is very likely not a code bug but a
|
||||
narrow degenerate case that a live player's residual horizontal velocity
|
||||
(present in the original validating debugger trace) would not trigger.
|
||||
The concrete next step is cheap and decisive: re-run
|
||||
`Ts4SteepRoofWedgeCaptureTests` with a small horizontal velocity
|
||||
component before deciding whether TS-4's shortcut removal needs anything
|
||||
beyond the register-row writeup.
|
||||
|
||||
## Addendum (P-review byte decode, 2026-07-30): AD-55 RESOLVED — retail's sled flatness test is cos(10°), ACE's constant is a radians/degrees bug
|
||||
|
||||
Raw bytes of `CPhysicsObj::calc_friction @ 0x0050ee70` (PDB-paired binary,
|
||||
technique `reference_pe_byte_decode`), Sledding fast-sled branch at
|
||||
0x0050ef52-0x0050ef6a:
|
||||
|
||||
```
|
||||
d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.Normal.Z
|
||||
dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; = 0.17453292519943295 (10 deg in RADIANS)
|
||||
d9 ff fcos ; st0 = cos(10 deg) = 0.984807753
|
||||
de d9 fcompp
|
||||
df e0 / f6 c4 41 / 7a fnstsw; test ah,0x41; jp
|
||||
```
|
||||
|
||||
FACT: retail genuinely computes `cos(10°) ≈ 0.9848078` at runtime and
|
||||
compares `Normal.Z` against it. ACE's `0.99999536f` equals
|
||||
`cos(0.1745 DEGREES)` — the radian literal evaluated in degree mode; a
|
||||
proven ACE porting error, not a BN artifact. Sibling constants
|
||||
byte-confirmed: threshold float 0.25 @0x007c6b00, doubles 6.25/1.5625
|
||||
@0x007c6b30/38, friction overrides 1.0f/0.2f as immediates.
|
||||
|
||||
Feel impact: retail's 0.2-friction fast-sled override engages on ground
|
||||
within 10° of flat; the shipped ACE-derived constant engages only within
|
||||
0.17° (never, in practice) — part of the #166 sled family. FIX (queue for
|
||||
the TS-4/#116 implementation slice, which owns `PhysicsBody`): replace
|
||||
`0.99999536f` with `0.98480775f` (cos 10°), cite this addendum, retire
|
||||
register row AD-55 in the same commit.
|
||||
Loading…
Add table
Add a link
Reference in a new issue