70 KiB
P2 — Collision response-layer edge family: port-ready pseudocode
Status: RETAIL RESPONSE ORDER COMPLETE (2026-07-31). Originally a
research-only doc for Campaign P Slice P2
(docs/plans/2026-07-29-physics-parity-campaign.md §P2). The first TS-4
attempt reproduced a steep-roof fixed point because its fixture discarded
the accepted contact state between frames; §7 item 6 preserves that useful
failure analysis. Slice 1B then completed the exact nested edge/StepDown
dispatcher, Slice 2A restored StepDown's mandatory Placement tail, and Slice
2B repeated the removal with production-shaped state carry and the complete
direction matrix. TS-4 is now retired; see §10.
#166 got a reattribution note in ISSUES.md rather than new code (per §3).
#116's former skipped wall control is now active with the retail Path-6
first-frame hard stop and next-frame slide chronology.
Headline findings that change the plan's assumptions: TS-1 was already
substantially ported (the register row and plan phrasing were stale — see
§2); the one real gap needed no code change (acdream's unified world-space
SpherePath design makes retail's per-cell recache a no-op correction here
— see §2 and the TS-1 register row's retirement text); #166 is very likely
NOT about a literal PhysicsState.Sledding auto-toggle at all (see §3);
AP-7's L.3c regression does not reproduce on the production graphical
root-motion path post-R6, and now ports retail's confirmed 0.25f threshold
(see §1). The apparent TS-4 wedge was a harness-state defect, not a reason to
retain a product compensation: production-shaped vertical, inward,
tangential, uphill, and downhill histories now pass graph/flat raw-bit
parity without the shortcut. Campaign P Slice 1B removed AP-3, AP-4, AD-53,
and AD-54 (§8); Slice 2A retired AP-5 (§9); Slice 2B retired TS-4 (§10).
Every claim below is tagged FACT (grep/read-verified against the named-retail decomp, the register, ISSUES.md, or current acdream source in THIS worktree at the time of writing) or INFERENCE (reasoned but not yet decomp/cdb/Ghidra-confirmed — do not port on an INFERENCE alone without the flagged follow-up).
0. Binding DO-NOT-RETRY entries (copied verbatim from
memory/project_physics_collision_digest.md, 3-day-old snapshot —
re-verify line numbers before the implementation session)
These bind the P2 implementer. Do not re-attempt any of these shapes.
- Do NOT add
SetSlidingNormalcalls in the BSP/sphere collision layer. Retail's only in-transition writer ofcollision_info.sliding_normalisvalidate_transition(0x0050ac21 / 0x0050aa70). A leaked normal + success writeback = an absorbing wedge at empty space. (#137 mechanism-2 lesson; directly governs TS-4 item 4 below.) - Do NOT re-add a forced constant-shell de-penetration to the sphere/cyl response. Retail slides tangentially (crease = collisionNormal × contactPlane.Normal) and never force-separates.
SphereCollisionno longer callsSetSlidingNormal(TS-45 retired) — the only in-transition sliding-normal writer isvalidate_transition. Keep it that way.- Do NOT patch the degenerate-offset guard in
slide_spheread hoc for #116 — the issue explicitly wants an oracle-driven pass, not a symptom patch. (#116 DO-NOT-RETRY, both the digest and ISSUES #116.) - Do NOT re-introduce a topology-based outside-add / radial sweep to cell membership while touching this family — unrelated layer, but the digest's adjacent #98/#116 sessions warn subagents drift there.
calc_frictionthreshold is retail 0.25 vs acdream 0.0 — this is AP-7. The L.3c attempt (naive bump to 0.25, no state gate) regressed normal walking 3 → 0.16 m/s and was reverted. Do NOT repeat a bare threshold bump without decoding the state gate first.- Shape-1 of #116 (tick-22760 lateral-slide loss) is NOT the degenerate-offset guard threshold — that guard kills slides under ~1.4 cm; the lost slide was 3.57 cm, well above it. The real divergence is the collision-normal SOURCE (recording layer), not slide/validate. Do not re-chase the guard threshold for shape-1.
- Do NOT guess the BN
test ah,5x87 branch polarity/squaring inslide_sphere— this exact construct is called out as undecodable from BN alone (the PosHitsSphere-saga warning). Ghidra MCP settled it once already (2026-06-12) for the EPSILON-vs-EpsilonSq bug; Ghidra MCP is DOWN for this research pass — mark any residual x87-ambiguous claim Ghidra-verify, cite ACE as the fallback tiebreaker, do not silently guess. - SUPERSEDED 2026-07-31 by Campaign P Slice 1B. AP-4's CliffSlide-first
compensation was removed only after the complete retail
transitional_insert/edge_slideorder was read and branch-order plus graph/flat multi-frame roof/ledge controls passed. Do not reintroduce the compensation; see §8 and the retired AP-4 row. - TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2
(it's P3) but shares files (
TransitionTypes.csInitPath) — do not fold TS-46 sphere-list work into a P2 commit.
1. AP-7 — friction state gate
The function (FACT — named-retail grep-first)
CPhysicsObj::calc_friction is named at pseudo-C:276694
(address 0050ee70), called from UpdatePhysicsInternal-equivalent at
pseudo-C:278490 (0050f0a… region, CPhysicsObj::calc_friction(this, arg2, var_28) where arg2=dt/quantum, var_28=velocity_mag2 — matches acdream's
calc_friction(float dt, float velocityMag2) signature already).
Full structure read from pseudo-C:276694-276822 (FACT, direct read, not Ghidra):
void CPhysicsObj::calc_friction(dt, velocityMag2) {
if ((transient_state & 2) != 0) { // OnWalkable (see below)
if ((state & MASK) == 0) { // MASK: BN-garbled string constant, see below
// ---- Branch A ----
dot = dot(contact_plane.N, velocity); // order N·v
if (!p_5) // p_5 from (dot < 0.25f) comparison, x87-ambiguous
velocity -= dot * contact_plane.N;
friction = this->friction; // read field (decompiler shows bare read;
// almost certainly `1.0f - this->friction`
// collapsed/elided — see ACE cross-check)
label_50f00e:
velocity *= pow(friction, dt); // shared tail with Branch B
} else {
// ---- Branch B ----
dot = dot(velocity, contact_plane.N); // same value, operand order swapped
if (!p_1) { // p_1 from (dot < 0.25f), x87-ambiguous
friction = 0.2f; // LOCAL default inside this branch
velocity -= dot * contact_plane.N;
p_2 = (velocityMag2 ⋛ 1.5625f); // x87-ambiguous direction
if (p_2) {
p_3 = (velocityMag2 ⋛ 6.25f); // x87-ambiguous direction
if (p_3)
p_4 = (cos(10°=0.17453292519943295 rad) ⋛ contact_plane.N.z); // x87-ambiguous
if (!p_3 || !p_4)
friction = this->friction; // fall back to object's own friction
}
goto label_50f00e;
}
}
}
}
Constants confirmed FACT by direct read: 0.25f (both branches,
independently re-derived — not copy-paste, two separate x87 loads),
0.2f, 1.5625f, 6.25f, 0.17453292519943295 (=10° in radians, fed
to __fcos). The outer gate bit is transient_state & 2.
Cross-check: ACE PhysicsObj.calc_friction (FACT, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141)
public void calc_friction(double quantum, float velocity_mag2)
{
if (!TransientState.HasFlag(TransientStateFlags.OnWalkable)) return;
var angle = Vector3.Dot(Velocity, ContactPlane.Normal);
if (angle >= 0.25f) return;
Velocity -= ContactPlane.Normal * angle;
var friction = Friction; // this->friction — SAME baseline in ALL cases
if (State.HasFlag(PhysicsState.Sledding))
{
if (velocity_mag2 < 1.5625f)
friction = 1.0f;
else if (velocity_mag2 >= 6.25f && ContactPlane.Normal.Z > 0.99999536f)
friction = 0.2f;
}
var scalar = (float)Math.Pow(1.0f - friction, quantum);
Velocity *= scalar;
}
TransientStateFlags.OnWalkable = 0x2 (FACT,
references/ACE/Source/ACE.Server/Physics/PhysicsEngine.cs:11) — matches
the decomp's transient_state & 2 outer gate exactly, and matches
acdream's own TransientStateFlags.OnWalkable bit already.
This resolves the "state gate" mystery differently than the register's
current framing. ACE shows ONE linear function, not two branches — the
state/MASK test that BN rendered as two duplicated blocks is
PhysicsState.Sledding (SLEDDING_PS = 0x800000, confirmed FACT —
acclient.h:2838 places it directly in the PhysicsState enum next to
EDGE_SLIDE_PS = 0x400000, and references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2130
gates on exactly that flag with the exact same 1.5625/6.25/near-1.0
constants). The BN decompiler almost certainly duplicated a single
if (state & SLEDDING_PS) { ... } block into what read as two
near-mirror branches — this is a known BN artifact class
(feedback_bn_decomp_field_names.md). ACE-derived reading, mark
Ghidra-verify: I could not confirm from the raw pseudo-C alone why the
branches read as fully separate blocks rather than one if; a live
Ghidra decompile of 0050ee70 would settle whether the source truly had
duplicated logic (possible if the original C++ had two near-identical
inlined call sites) or whether this is purely a BN rendering artifact. Do
not restructure the port around "two branches" — port ACE's single
linear shape; it is structurally consistent with every constant the raw
decomp independently confirms.
The threshold-direction ambiguity (p_5/p_1/p_2/p_3/p_4, all
test ah,0x5-class x87 flag tests) is NOT independently Ghidra-verified
this pass (Ghidra MCP is down). ACE's clean if (angle >= 0.25f) return;
is adopted as the ACE-derived, Ghidra-verify reading for p_5/p_1
polarity. The p_2/p_3/p_4 velocity-magnitude-band and slope-flatness
polarities are likewise ACE-derived, Ghidra-verify.
⚠️ Constant discrepancy found (FACT, needs Ghidra-verify to resolve):
the raw decomp's slope test literally computes __fcos(0.17453292519943295)
(= cos(10°) ≈ 0.984808) and compares it against contact_plane.N.z.
ACE's port instead compares ContactPlane.Normal.Z > 0.99999536f directly
— no cos() call, and 0.99999536 corresponds to an angle of only
≈0.175° from flat (acos(0.99999536) ≈ 0.175°), not 10°. These are
physically very different tests (cos(10°) accepts any slope within
10° of flat; 0.99999536 accepts only essentially-perfectly-flat ground).
Two hypotheses, neither confirmed:
(a) BN misdecompiled a raw float-constant load as an __fcos() call
(a known BN artifact class — spurious x87 opcode reinterpretation);
(b) ACE's own decompile/port made an independent error and
cos(10°)=0.984808 is correct.
Do not silently pick one. File as an open Ghidra question (§7);
when Ghidra MCP is back, decompile 0050ee70 directly and check
whether the FCOS opcode is actually present at that instruction, or
whether it's a raw FLD of 0.99999536 (or of 0.984808).
Why the L.3c naive threshold bump (0.0 → 0.25) hammered walking — and why that may no longer be true today (FACT + INFERENCE, high confidence, code-derived)
src/AcDream.Core/Physics/PhysicsBody.cs:576-602 is acdream's current
calc_friction. Its threshold is 0.0 (if (dot >= 0f) return;),
attributed to the OLDER, unnamed Ghidra decomp (FUN_0050f940 — a
DIFFERENT address than the named function 0050ee70; the two decomp
passes disagree, and per CLAUDE.md the named decomp wins). The named
decomp's independently-confirmed 0.25f (both branches) is FACT-level
confirmation that ACE's 0.25f reading — and the register's AP-7 row — are
correct, and acdream's in-code comment ("we match the decompile" at 0.0)
was matching the wrong (superseded, unnamed) decomp pass.
The recorded L.3c failure (2026-04-30): bumping the threshold alone to
0.25f, with NO other change, dropped measured forward locomotion from
~3 m/s to ~0.16 m/s (≈5.3% remaining) in
PlayerMovementControllerTests. The math checks out exactly:
Friction = DefaultFriction = 0.95f (confirmed FACT — both
src/AcDream.Core/Physics/PhysicsBody.cs:120 and
references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:15 agree on
0.95f, so this is NOT a divergent constant), and flat-ground walking
has dot(velocity, groundNormal) ≈ 0 (velocity is ~horizontal, normal is
~vertical). With threshold 0.0, dot(≈0) >= 0 triggers the early return
— friction NEVER engaged during flat walking. With threshold 0.25,
dot(≈0) < 0.25 — friction ALWAYS engaged, decaying velocity by
pow(1 − 0.95, dt) = 0.05^dt EVERY tick. At 60 Hz over 1 second:
0.951^60 ≈ 0.049 — a ~95% velocity loss in one second. That is the
observed 3 → 0.16 m/s hammering almost exactly.
INFERENCE, code-derived (high confidence, not yet live-verified): the
L.3c test predates the 2026-07-17 "local player animation-owned grounded
movement" landing (R6). Reading current
src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1742-1756:
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
if (hasAnimationRootMotion)
{
_body.Velocity = new Vector3(0f, 0f, savedWorldVz); // <-- XY ZEROED
}
else
{
Vector3 stateVelocity = _motion.get_state_velocity();
_body.set_local_velocity(
new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
}
}
...
_body.UpdatePhysicsInternal(tickDt); // calls calc_friction internally
When the graphical client's animation root motion drives the walk
(hasAnimationRootMotion == true, the production path since R6),
_body.Velocity.X/Y are forced to zero immediately before
calc_friction runs every tick — because walking displacement now comes
from pmDelta.Origin (the animation Frame delta, applied directly to
_body.Position at lines 1764-1766), not from integrating Velocity.
Friction decaying an already-zero horizontal Velocity is a no-op. This
means the L.3c hammering mechanism is very likely ARCHITECTURALLY MOOT
for the production graphical local-player path today — the regression
that blocked AP-7 in April may not reproduce post-R6.
The else branch (no animation root motion — the headless/test-controller
path, _motion.get_state_velocity()) still feeds real XY speed into
Velocity, so that path (used by Slice K headless bots and any test
without an attached animation source) is still exposed to the same
hammering risk a naive threshold-only port would reintroduce.
Action for the implementer, not yet executed by this research pass:
before porting, re-run PlayerMovementControllerTests (or an equivalent
fresh capture) with a 0.25f threshold and the corrected ACE-derived
single-linear-function shape, checked separately against (a) the
graphical/animated local-player path, (b) the headless/get_state_velocity
path, and (c) remote/NPC movers (RuntimeRemotePhysicsUpdater.cs,
RemoteMotion.cs — confirm whether their Velocity is root-motion-zeroed
the same way, or whether they remain velocity-integrated and therefore
friction-sensitive). Do not assume (a) is safe without a fresh capture —
this section's confidence is code-derived, not measured.
AP-7 verdict
Port shape: replace acdream's two-threshold, dead-Sledding-branch
calc_friction with ACE's single linear function (0.25f threshold, single
friction = Friction baseline, PhysicsState.Sledding-gated override
using the already-present 1.5625/6.25/near-flat constants — acdream
already has these at PhysicsBody.cs:591-597, just unreachable because
nothing ever sets the state bit; see §3). Flag the cos(10°) vs 0.99999536
discrepancy (Ghidra-verify) and pick ACE's 0.99999536f provisionally
since acdream's own current dead code already uses it (least churn) —
do not silently resolve the discrepancy by picking one without a
citation in the eventual commit; carry the open question into the
register row.
2. TS-1 — PrecipiceSlide / EdgeSlide / CliffSlide chain
⚠️ Major finding: the register/plan framing is STALE — TS-1 is already substantially ported (FACT, code-verified)
The register row (docs/architecture/retail-divergence-register.md:238)
says: "PrecipiceSlide context missing — conservative stop-at-edge
instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide", citing
TransitionTypes.cs:1254. Line 1254 today is unrelated stepping-loop
code (the viewer last-step-remainder computation) — the file has moved
substantially since that row was written. Reading the actual current
implementation:
SpherePath.PrecipiceSlide(Transition)—TransitionTypes.cs:943-970— a real port ofSPHEREPATH::precipice_slide(pseudo-C:274316,0050cc80), callingBSPQuery.FindCrossedEdge(a real port ofCPolygon::find_crossed_edge, pseudo-C:322909,00539300—BSPQuery.cs:438-482), sign-flipping via a dot product exactly like retail'sx87_r7_17sign test, then delegating toTransition.SlideSphereInternalexactly like retail's tail call toCSphere::slide_sphere.Transition.CliffSlide(Plane)—TransitionTypes.cs:2037-2102— a real port ofCTransition::cliff_slide(pseudo-C:272397,0050a6d0): cross product of the two plane normals, Z-flattened, rotated 90° in the XY plane ((-Y, X, 0)), degenerate-check, sign-resolved offset application viaSetCollisionNormal. Return-value mapping verified FACT-correct againstacclient.h:6100-6108(OK_TS=1, COLLIDED_TS=2, ADJUSTED_TS=3, SLID_TS=4): the degenerate case returnsTransitionState.OK(matches retail'sreturn 1;=OK_TS); the success case returnsTransitionState.Adjusted(matches retail'sreturn 3;=ADJUSTED_TS). This is correctly ported, not guessed.Transition.EdgeSlideAfterStepDownFailed—TransitionTypes.cs:1907-2035— a dispatcher structurally mirroringCTransition::edge_slide(pseudo-C:273001-273090,0050b3d0), including the AP-4-registered reordering (steep-contact-plane CliffSlide check moved before the!OnWalkable || !EdgeSlidebail, to compensate for acdream's OnWalkable bookkeeping — see DO-NOT-RETRY §0 item 9).
This means TS-1's row and the P2 plan's "port the EdgeSlide → PrecipiceSlide / CliffSlide chain" framing describe work that is largely DONE. The register row was not retired in the same commit that landed this — a process gap, not a code gap. Recommend re-verifying with a live capture before writing new code, not blindly re-porting from scratch.
Retail source, quoted in full (FACT, pseudo-C:273001-273090, 0050b3d0)
TransitionState CTransition::edge_slide(arg2/*out*/, arg3=step_down_height, arg4=zVal) {
state = object_info.state
if ((state & 2) == 0 || (state.byte[1] & 2) == 0) { // NOT walkable-capable / NOT EdgeSlide-capable
walkable = null; check_pos = backup_check_pos; check_cell = backup_cell;
contact_plane_valid = 0; contact_plane_is_water = 0;
*arg2 = OK_TS; return cache_global_sphere(null);
}
if (contact_plane_valid) {
p_1 = (contact_plane.N.z - arg4) ⋛ 0 // x87-ambiguous, ACE-derived: "N.z >= zVal"
if (!p_1) { // contact steeper than allowed (zVal = FloorZ or LandingZ)
walkable = null; restore_check_pos();
*arg2 = cliff_slide(this, &contact_plane);
contact_plane_valid = 0; contact_plane_is_water = 0;
return 0;
}
// else: falls through (contact IS walkable-steep-enough)
}
if (walkable != null) {
restore_check_pos();
contact_plane_valid = 0; contact_plane_is_water = 0;
result = precipice_slide(sphere_path, collision_info);
*arg2 = result;
return (result == COLLIDED_TS);
}
if (contact_plane_valid) { // walkable was null, contact fell through as OK
walkable = null; restore_check_pos(); cell_array_valid = 1;
contact_plane_valid = 0; contact_plane_is_water = 0;
*arg2 = OK_TS; return ...;
}
// ---- back-probe fallback: neither contact nor walkable available ----
offset = global_curr_center - global_sphere.center; // back toward where we came from
add_offset_to_check_pos(offset);
step_down(this, arg3, arg4);
contact_plane_valid = 0; contact_plane_is_water = 0;
restore_check_pos();
if (walkable == 0) {
walkable = null; *arg2 = COLLIDED_TS; cell_array_valid = 1; return ...;
}
contact_plane_valid = 0; contact_plane_is_water = 0;
walkable_scale = sphere_path.walkable_scale;
cache_localspace_sphere(get_walkable_pos(sphere_path), walkable_scale); // <-- NOT PRESENT IN ACDREAM
set_walkable_check_pos(sphere_path, localspace_sphere); // <-- NOT PRESENT IN ACDREAM
result = precipice_slide(sphere_path, collision_info);
*arg2 = result;
return (result == COLLIDED_TS);
}
Precise, evidenced gap #1: the back-probe fallback path skips retail's localspace re-cache before its second precipice_slide call (FACT)
Retail's edge_slide has two distinct precipice_slide call sites:
one direct (when sphere_path.walkable != 0 already), one in the
back-probe fallback (when NEITHER a valid contact plane NOR a walkable
polygon survived) — and ONLY the second site performs
walkable_scale/cache_localspace_sphere/get_walkable_pos/
set_walkable_check_pos first (pseudo-C:274318-274326, 0050b4e0-0050b507).
acdream's SpherePath has no WalkableScale, LocalspaceSphere,
GetWalkablePos, or SetWalkableCheckPos member/method anywhere
(confirmed by grep across TransitionTypes.cs — zero hits), and its
single unified PrecipiceSlide(Transition) method
(TransitionTypes.cs:943-970) is called identically from BOTH the
direct branch3 case (TransitionTypes.cs:2002, matches retail's first
call site correctly — no re-cache needed there either) AND the back-probe
fallback (TransitionTypes.cs:2031, should match retail's second
call site but is missing the re-cache step retail performs first).
Port-ready shape (INFERENCE for the exact field semantics — the
walkable_scale/localspace-sphere machinery itself needs a fresh read of
SPHEREPATH::get_walkable_pos/cache_localspace_sphere/
set_walkable_check_pos, not yet read this pass; FACT that the gap
exists, INFERENCE on the fix shape): add the three missing
SpherePath members/methods, call them in
EdgeSlideAfterStepDownFailed's final fallback block
(TransitionTypes.cs:2015-2031) immediately before the
sp.PrecipiceSlide(this) call at line 2031, matching retail's ordering.
get_walkable_pos/cache_localspace_sphere/set_walkable_check_pos
were not read this pass (budget) — read them fresh before porting
(pseudo-C, search near SPHEREPATH::get_walkable_pos — a hit already
appears in the edge_slide grep at the top of this section's raw quote,
so the symbol exists and is findable).
Precise, evidenced gap #2 (likely unregistered adaptation, not a bug per se): CliffSlide's reference-normal fallback chain is an acdream invention (FACT: not in the retail read; adaptation reasoning IS documented in-code)
Retail's cliff_slide (pseudo-C:272397) uses
this->collision_info.last_known_contact_plane.N directly, with no
fallback chain as the second cross-product operand. acdream's
CliffSlide (TransitionTypes.cs:2037-2070) instead tries THREE
sources in priority order: LastWalkablePlane (if Normal.Z >= FloorZ),
then LastKnownContactPlane (same threshold), then Vector3.UnitZ
world-up. The in-code comment ("L.4-cliffslide-fallback", dated
2026-04-30) explains the reasoning (degenerate cross-product when the
player has been on a continuous steep slope for >1 frame and
LastKnownContactPlane itself became steep) but this reasoning does
not appear to have a corresponding register row (checked AP-4, TS-1 —
neither mentions the fallback chain specifically; AP-4 is about
re-ordering the CliffSlide-vs-Branch1 check, a different concern).
Flag for the implementer: either (a) find this exact
prioritization in a further retail read (unlikely given the raw decomp's
directness, but not yet exhaustively ruled out — last_known_contact_plane
itself might be retail-maintained differently than acdream's equivalent
field, which could make the fallback chain compensate for an upstream
divergence rather than being a pure invention), or (b) register it
explicitly as an AD/AP row with this citation before or alongside the P2
commit. Do not silently leave an unregistered behavioral invention in
place while "retiring" the TS-1 row — that violates the register's
same-commit rule.
Precise, evidenced gap #3 (likely unregistered adaptation): the walkable-polygon steepness reroute in EdgeSlideAfterStepDownFailed
TransitionTypes.cs:1972-1997 — when sp.HasWalkablePolygon is true,
acdream additionally checks sp.WalkablePlane.Normal.Z < FloorZ and, if
so, reroutes to CliffSlide(sp.WalkablePlane) INSTEAD of calling
PrecipiceSlide. Retail's raw edge_slide (if (walkable != null) { ... precipice_slide(...) }) has no steepness branch on the walkable
polygon itself — it always calls precipice_slide once walkable != null. The in-code comment ("L.4-walkable-steep") argues this compensates
for acdream's Path-4 airborne-landing branch accepting steep roofs as
"walkable" under the permissive LandingZ threshold (a claim this
research pass did not independently verify — Path-4 was not read this
session; TS-4 §4 below covers the adjacent but distinct Path-6 concern).
Flag for the implementer: same as gap #2 — verify whether this
reroute is compensating for a real upstream divergence (in which case it
should be an AD/AP row) or is masking a bug that should be fixed at its
source (Path-4's LandingZ acceptance) instead of patched here. Do not
retire TS-1 while leaving this unregistered.
TS-1 verdict
Port shape: narrow, not a rewrite. (1) Add the retail
walkable_scale/cache_localspace_sphere/get_walkable_pos/
set_walkable_check_pos step to the back-probe fallback path only (gap
#1 — a real, missing piece). (2) Audit gaps #2 and #3 against a fresh,
focused retail re-read of last_known_contact_plane maintenance and the
Path-4 landing-acceptance threshold; register whichever holds up as
AD/AP rows, or align to retail exactly if the compensation turns out
unnecessary. (3) Only THEN retire the TS-1 register row, in the same
commit, updating its citation (the current :1254 citation is already
stale and should point at EdgeSlideAfterStepDownFailed/CliffSlide/
PrecipiceSlide instead). Do not re-derive edge_slide/cliff_slide/
precipice_slide from scratch — the existing port is real and largely
correct; re-porting risks discarding correct, already-tested work (the
CLAUDE.md worldbuilder-inventory lesson applies here by analogy: don't
re-port what's already ported and tested).
3. #166 — landing sled (Sledding state set/clear)
Finding: PhysicsState.Sledding appears to be DATA-AUTHORED, not an automatic landing response (FACT, cross-referenced across 3 independent repos)
Searched for a SET (|= SLEDDING_PS) or automatic-toggle call site for
PhysicsState.Sledding in: the named-retail pseudo-C (no string "sled"
anywhere in the 1.4M-line file — grep -in sled returns zero hits; the
raw 0x800000 hex literal also returns zero physics-related hits, only
unrelated Watson/crash-dump flags), references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs
(full file, only 3 hits: the two calc_friction/UpdateObjectInternal
READ sites already covered, no WRITE site), and
references/ACViewer/ACE/... (same ACE-derived code, same result).
The only WRITE sites for PhysicsState.Sledding anywhere in any
reference repo are:
// references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Properties.cs:1105-1109
public bool? Sledding
{
get => GetPhysicsState(PhysicsState.Sledding);
set => SetPhysicsState(PhysicsState.Sledding, value);
}
— a per-weenie boolean game-data property (the same pattern as
Ethereal, Static, etc.), packed onto the broadcast PhysicsState in
WorldObject_Networking.cs:586-588,700-704. This is server/database-set,
not a client-side automatic landing-response toggle. Since CPhysicsObj
is shared code between the retail client and server binaries (the same
class that produced calc_friction, UpdateObjectInternal, etc. — all
independently confirmed to structurally match ACE's port), the total
ABSENCE of a write site anywhere in ACE's ~10,000-line PhysicsObj.cs
is strong evidence that retail's own CPhysicsObj does not
automatically enter Sledding state on a downhill landing either.
INFERENCE (well-supported, not yet cdb/Ghidra-confirmed): ordinary
downhill-jump glide-and-bounce in retail is NOT the literal Sledding
physics state at all for an ordinary player. Sledding is most likely
reserved for specific data-authored world content (dungeon/event objects
with the property baked into their weenie default PhysicsState, e.g.
an actual in-world "sled ride" mechanic) — a narrow, data-driven case
outside a generic movement port's scope. Grepping WCID/weenie class name
lists in references/ACViewer/ACE/.../WeenieClassName.cs for "sled"
found no obviously-named sled-ride weenies, but that catalog is not
exhaustive for retail's original 2013 content and this was not chased
further (out of scope — data content, not an algorithm).
Cross-check against ISSUES.md #166's OWN root-cause text (FACT)
docs/ISSUES.md:4254-4262 (filed 2026-07-03, i.e. BEFORE the P2 campaign
plan's phrasing) already attributes #166 to a named composite of three
rows, and explicitly does not mention a Sledding set/clear
mechanism:
"This is the REGISTER-PREDICTED composite of three known deferred deviations: AD-25 (landing wall-bounce velocity reflection suppressed...), AP-7 (
calc_frictionthreshold 0.0 without retail's 0.25-with-state-gate...), and TS-4 (Path-6 steep-poly slide-tangent shortcut...). Retiring those three rows IS this issue."
Cross-referencing the digest's #182 rebuild notes
(memory/project_physics_collision_digest.md:837-841, 2026-07-07):
AD-25's LOCAL-PLAYER landing-bounce reflection was already ported in
the #182 verbatim UpdateObjectInternal/handle_all_collisions rebuild
("Contact committed BEFORE the reflect... retire AD-25's micro-bounce
(AD-25 narrowed to the remote-DR sweep)"). What remains open for AD-25 is
remote/NPC-only and is explicitly P3 scope
(docs/plans/2026-07-29-physics-parity-campaign.md §P3 item 2), not P2.
#166 verdict: for the LOCAL PLAYER (the case the user actually reported — "jumping down a hill" on their own character), the bounce half (AD-25) is already shipped; what's missing is the glide-deceleration curve (AP-7, §1 above) and the airborne-steep landing chain (TS-4, §4 below). Porting AP-7 + TS-4 correctly should retire #166 for the local player WITHOUT inventing any client-side Sledding auto-toggle — inventing one would be exactly the kind of unargued behavior addition CLAUDE.md's no-guessing rule forbids (no decomp evidence supports it). The campaign plan's P2 item 3 phrasing ("port the landing sled (Sledding state set/clear sites)") appears to rest on an assumption not borne out by this research pass.
Recommendation for the implementer: do NOT build a Sledding
auto-set/clear mechanism speculatively. Land AP-7 + TS-4 first, capture a
fresh downhill-jump-landing trajectory (extend
ACDREAM_CAPTURE_RESOLVE), and check the #166 visual-matrix item (row 5,
"Downhill jump landing: sled glide + bounce") against that alone. If the
glide/bounce still visibly mismatches retail after AP-7+TS-4 land, THAT
capture — not a guess — is what should drive any further Sledding-state
work, and it should go through cdb against live retail (a downhill jump
landing, watching this->state for the 0x800000 bit) before any client
auto-toggle is written. Keep the already-present dead
PhysicsStateFlags.Sledding branch in calc_friction (§1) since it's
cheap, decomp-consistent, and harmless if never entered — but do not
manufacture a caller that sets it.
4. TS-4 — Path-6 steep-poly shortcut removal
The former shortcut (HISTORICAL FACT; removed by Slice 2B)
Before Slice 2B, Path-6 (the default
sphere_intersects_poly → collide_with_pt / SetCollide dispatch) was not
symmetrically wrong. The parsed-graph primary/foot branch tested the hit
polygon's world-space normal and, below PhysicsGlobals.FloorZ, projected
the move along the face, wrote both collision and sliding normals, and
returned Slid. Its secondary/head branch had already been corrected by
#116 to retail's unconditional CollisionNormal + Collided response. The
prepared-flat port still applied the steep shortcut to both spheres and sent
both shallow cases through SetCollide + Adjusted; its head branch was
therefore wrong for every slope. This distinction matters: retail does not
apply one common response to both spheres.
The in-code comment is unusually candid about why: "This is a
deliberate deviation from retail... Validated against retail debugger
trace 2026-04-30: retail body did not wedge; our retail-faithful port DID
wedge because we're missing implementation details of the step_up_slide /
cliff_slide chain on grounded-steep movement." — i.e., this shortcut
was shipped SAME-DAY as (and BECAUSE) the retail-faithful
EdgeSlideAfterStepDownFailed/CliffSlide/PrecipiceSlide chain (TS-1,
§2 above — also dated 2026-04-30, tagged "L.4") still wedged in testing
when tried without this shortcut.
Retail: NO steepness branch in either Path-6 sphere response (FACT, 0053a730 region)
Read directly from the named decomp (the sphere_intersects_poly /
set_collide dispatch for the primary/foot sphere inside
BSPTREE::find_collisions's default path):
if (sphere_intersects_poly(...) || eax_26 != 0) {
localtoglobalvec(sphere_path.localspace_pos, &saved_ebx, &poly->plane.N);
SPHEREPATH::set_collide(&sphere_path, &saved_ebx);
sphere_path.walkable_allowance = 0.0871556997f; // = PhysicsGlobals.LandingZ, exact bit match
return 3; // ADJUSTED_TS
}
There is no steepness test in this foot branch. It calls set_collide
and returns ADJUSTED_TS for a steep roof exactly as for a shallow ramp.
walkable_allowance is always set to LandingZ (the permissive landing
threshold), regardless of polygon slope. If the foot is clear but the
secondary/head sphere hits, retail instead performs the other slope-agnostic
response:
localtoglobalvec(sphere_path.localspace_pos, &normal, &head_poly->plane.N);
COLLISIONINFO::set_collision_normal(&collision_info, &normal);
return 2; // COLLIDED_TS
The head branch neither calls set_collide nor returns ADJUSTED_TS.
Neither sphere response has a steepness branch. This directly confirms the P2
plan's description and the digest's #137-mechanism-2 finding
(memory/project_physics_collision_digest.md:1008-1014): retail's
BSP/sphere collision layer never writes collision_info.sliding_normal
at all — only validate_transition (0x0050ac21/0x0050aa70) does, and
only success-gated. The steepness differentiation (walkable vs.
merely-in-contact, and whether a slide response is needed) happens
STRICTLY DOWNSTREAM, in ValidateTransition's FloorZ OnWalkable test
and — when that surface turns out too steep to be walkable —
EdgeSlideAfterStepDownFailed → CliffSlide/PrecipiceSlide (TS-1's
domain, §2 above).
TS-4 port shape (COMPLETED 2026-07-31)
Delete the primary/foot steep shortcut in both representations. Every foot
hit must call SetCollide, set WalkableAllowance=LandingZ, and return
Adjusted. Preserve the parsed-graph head branch corrected by #116, and
replace the prepared-flat head steep/shallow split with the same unconditional
SetCollisionNormal + Collided response. No Path-6 branch writes a sliding
normal. This is the exact retail foot/head split, not a shared fallback to the
foot behavior.
Port-order coupling with TS-1 (historical guard, now satisfied)
This is the single most important sequencing fact in this whole document. The shortcut's comment proves TS-1's retail-faithful chain was ALREADY BUILT once (same day, same "L.4" slice) and STILL wedged — that is why the shortcut exists instead of the faithful chain. Simply deleting the shortcut today, without first confirming TS-1's gaps (§2: missing localspace re-cache in the back-probe fallback; the two unregistered adaptations) are closed, risks reintroducing the EXACT "stuck in falling animation on the roof" / "walks up steep roofs" wedge that motivated the shortcut in the first place.
Recommended order: (1) close TS-1 gap #1 (the missing
walkable_scale/cache_localspace_sphere/set_walkable_check_pos step)
first, on its own, with the TS-4 shortcut still in place as a safety
net. (2) Capture a grounded-steep-slope trajectory (a roof or steep
terrain walk, matching whatever repro the 2026-04-30 L.4 session used —
check docs/research/ and git log around that date for the specific
repro if it wasn't captured as a fixture) with the shortcut TEMPORARILY
disabled behind a flag or in a scratch branch, and confirm no wedge. (3)
Only once that capture is clean, delete the TS-4 shortcut for real, in
the same commit that closes the TS-4 register row. (4) Re-run the P2
final-matrix items 4-6 (cliff/roof edge, downhill landing, shallow wall
graze) plus a fresh regression sweep before calling TS-4 done — this is
exactly the kind of change the digest's #137 sagas warn compounds subtly
(a leaked SetSlidingNormal "absorbing wedge at empty space" was the
recurring failure mode across three separate historical incidents in the
digest, all triggered by a similar not-quite-faithful shortcut).
5. #116 — slide-response family oracle pass
This item is explicitly an oracle-first investigation, not a known
fix (docs/ISSUES.md:8426 status: "OPEN (narrowed)"; digest
memory/project_physics_collision_digest.md:1509: "OPEN — oracle-first
investigation; NOT cell-set"). This section defines the SCOPE of the
oracle pass precisely, per the mission's request, rather than proposing
a fix — a fix without the live trace below would repeat the exact
mistake the digest's DO-NOT-RETRY table already warns against (§0 items
4, 7, 8).
The two shapes (FACT, restated with citations — both already
independently verified against source by the 2026-06-12 Ghidra session)
Shape-1 — tick-22760 lateral-slide loss. Live retail: blocked
southward push at a cottage door face, KEPT a tiny lateral slide (X
−0.0357 m, collision_normal=(0,+1,0), the door face). acdream's harness
hard-stops both components (collision_normal=(0,0,1), i.e. the
UnitZ ground-fallback default). Ghidra-confirmed
(memory/project_physics_collision_digest.md:1259-1275):
TransitionTypes.cs:3701-3702's UnitZ default on invalid
collision-normal is retail-faithful — retail's validate_transition
(0x0050aa70) has the identical if (collision_normal_valid==0) set_collision_normal(UnitZ). The divergence is UPSTREAM of both slide
and validate: at tick-22760, acdream's collision_normal_valid was
FALSE where retail's was TRUE (retail HAD recorded the door-face normal).
The slide guard threshold is exonerated — the 3.57 cm lost slide is
~18× above the ~1.4 cm degenerate-offset cutoff (F_EPSILON = 0.0002,
compared against SQUARED magnitude — Ghidra-confirmed, see §0 item 8),
so retail's own guard would have kept the slide too.
Shape-2 — D4 first-airborne-frame slide vs. hard-stop. Ghidra
confirms CSphere::slide_sphere (0x00537440) applies its slide
IN-FRAME (add_offset_to_check_pos → returns SLID_TS) — acdream's
current in-frame slide to Z=1.92 on frame 1
(BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames,
tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs:560-602, currently
Skip-tagged citing #116) is likely faithful TO slide_sphere
itself. What's unconfirmed is whether retail's first airborne wall
contact frame REACHES slide_sphere at all, or whether an earlier stage
(collide_with_environment's dispatch, or the absence of a
last_known_contact_plane on the very first airborne frame) intercepts
it with a hard stop before slide_sphere ever runs. The #116 threshold
fix (EpsilonSq → F_EPSILON, shipped bf18a543) did not move D4 —
confirming the D4 offset is a real slide, not a near-degenerate one the
threshold fix would have caught.
What the P2 oracle pass must determine (mission-specified scope)
- Shape-1's real root cause: where does acdream's collision-normal
RECORDING diverge from retail's at tick-22760? Not slide_sphere, not
validate_transition (both exonerated) — the recording path that feeds
collision_info.collision_normal_valid/.collision_normalduring the BSP/environment hit-test itself. The digest's own next-step (memory/project_physics_collision_digest.md:1274-1275) is unchanged by this research pass: instrumentDoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals(tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:162) to trace exactly where, in the BSP hit-test chain feedingFindObjCollisionsInCell/BSPQuery, the door-face normal(0,+1,0)gets computed-and-discarded in acdream but retained in retail. Candidate governing retail functions (need a fresh, focused read — not done this pass, budget):BSPTREE::find_collisions(the same dispatch region read for TS-4 in §4, pseudo-C ~323700-323830) and whatever populatescollision_info.collision_normal/.contact_planeon a BLOCKED (not slid, not adjusted) door-face hit specifically — this is a DIFFERENT code path than either Path-6'sset_collide(which returns ADJUSTED_TS, not COLLIDED_TS) orslide_sphere(called only after a walkable/precipice context exists). A blocked door push is most likely dispatched throughCollideWithPt/collide_with_pt(thePathClippedbranch already visible in Path-6, §4,BSPQuery.cs:2158-2163) or a siblingPath-1-class function not yet read this pass. - Shape-2's real answer: does retail's FIRST airborne wall-contact
frame reach
slide_sphere, or hard-stop upstream? This needs either a Ghidra decompile of the caller chain immediately aboveslide_sphere(checking whether alast_known_contact_planeexistence gate exists before the call on frame 1 of an airborne trajectory) or a live cdb trace of an actual airborne wall hit in retail (per the digest's already-written cdb plan,memory/project_physics_collision_digest.md:8537-8543/ ISSUES.md #116). Ghidra MCP is down this pass — do not guess this one; it directly controls which of D4's two competing expectations (Z=1.92 in-frame slide vs. Z=2.0 hard-stop-then-slide-frame-2) is correct, and a wrong guess "regresses ALL wall-slide behavior" per the digest's own warning (§0 item 8). - Governing retail functions to anchor the oracle pass (FACT, already
cited in ISSUES #116 and this document's own reads):
CSphere::slide_sphere(0x00537440, pseudo-C:321403-321532 per ISSUES #116's own citation — not re-read this pass, already Ghidra-verified for the epsilon fix),validate_transition(0x0050aa70, read indirectly via itsUnitZdefault confirmed in §2/§4's cross-references), and thefind_collisions/environment hit-test recording sites this pass identified as the likely Shape-1 location (BSPTREE::find_collisionsregion, pseudo-C ~323700-323830, the same region TS-4 §4 already partially read for the unconditionalset_collidecall — a focused re-read of the SIBLING branches in that same dispatch, specifically thePathClipped/collide_with_ptarm and whatever arm produces a hard COLLIDED_TS with a recorded normal, is the concrete next research step).
#116 verdict — this is NOT a P2 implementation item, it is a P2
research item with its own follow-up research session
Given the depth already logged in the digest and ISSUES.md (an entire
Ghidra session, a threshold fix already shipped, two shapes precisely
characterized, and an explicit "needs a LIVE cdb session" conclusion
reached independently by that prior work), this research pass concurs
with the existing plan of record: #116 needs (a) an instrumented
replay of DoorBugTrajectoryReplayTests for shape-1, and (b) either a
Ghidra decompile of slide_sphere's caller chain or a live cdb trace of
an airborne wall hit for shape-2, BEFORE any code changes. Do not
patch the degenerate-offset guard, the UnitZ default, or slide_sphere
itself speculatively (DO-NOT-RETRY §0 items 4, 7, 8 all apply directly).
This document does not add new pseudocode for #116 beyond what's already
recorded, because doing so without the trace would be exactly the kind
of guess CLAUDE.md's workflow forbids.
6. Port order + blast radius
Recommended sequence, reasoning, and required fixtures/captures BEFORE each step's behavior change — not a generic "do them in plan order" list, because this research pass found real coupling the plan's slice ordering doesn't surface.
Step 1 — TS-1 gap #1 (localspace re-cache in the back-probe fallback), §2
Do this FIRST, alone. It's the narrowest, most mechanically certain
change (a missing setup step before an existing call, not a behavior
redesign), and per §4's coupling analysis, TS-4's shortcut removal is
UNSAFE until this lands and is proven not to wedge. Blast radius: only
the back-probe fallback arm of EdgeSlideAfterStepDownFailed — the
rarest of the four dispatch arms (reached only when neither a contact
plane nor a walkable polygon survived the step-down probe). Low risk of
regressing the already-working branch3/branch1/branch2 arms.
Required before/after: a targeted unit test exercising the back-probe
arm specifically (check whether one already exists — this pass didn't
find one in BSPStepUpTests.cs/DoorBugTrajectoryReplayTests.cs; if
absent, write one first per TDD discipline). No connected capture needed
for this step alone — it's a narrow internal-consistency fix.
Step 2 — TS-1 gaps #2 and #3 (register audit), §2
Read the raw decomp fresh for last_known_contact_plane maintenance
(what writes it, and whether acdream's equivalent field diverges upstream
— which would justify gap #2's fallback chain as a real compensating
adaptation) and for Path-4's airborne-landing LandingZ acceptance logic
(cited but not read this pass — needed to judge gap #3). Either register
both as AD/AP rows with citations, or remove the acdream-only branches
and re-verify against the existing CliffSlide/PrecipiceSlide test
coverage. This step can run in parallel with Step 1 (different files,
no shared state — a candidate for superpowers:dispatching-parallel-agents
if the implementer wants to split it out).
Step 3 — capture a steep-slope / roof trajectory with TS-4's shortcut disabled
Before touching BSPQuery.cs, build a scratch/flagged path that runs
Path-6 WITHOUT the steepness branch (i.e., always SetCollide +
WalkableAllowance=LandingZ + Adjusted, matching retail) and replay
whatever fixture reproduces the original "stuck in falling animation on
the roof" / "walks up steep roofs" symptom the 2026-04-30 L.4 session
used to justify the shortcut. Find that fixture/repro before writing
new code — check docs/research/2026-04-30-* and git log around that
date; if no fixture survives, this step needs a fresh capture via
ACDREAM_PROBE_RESOLVE=1 against a known steep-roof landblock. Do not
skip this step even though Step 1 theoretically closes the gap that
caused the original wedge — "theoretically closes" is not "proven not
to wedge," and this exact failure mode (a not-quite-faithful shortcut
masking a deeper gap) has recurred at least three times in the digest's
own history (#137 mechanisms 1-3).
Step 4 — land TS-4 (delete the shortcut) only after Step 3 is clean
Small, mechanical diff once Step 3 validates it's safe (§4's port shape). Retire the TS-4 register row in the same commit.
Step 5 — AP-7, independently sequenced (no coupling to Steps 1-4)
AP-7 touches PhysicsBody.calc_friction only — a different file, no
shared call path with the TS-1/TS-4 edge-response chain (friction runs
on GROUNDED, already-resolved velocity; TS-1/TS-4 run during the
collision SWEEP itself, upstream of where friction applies). Can be
done in parallel with Steps 1-4. Required before any code change:
re-run (or write, if it doesn't already isolate the right thing) a
regression test capturing (a) graphical/animated local-player grounded
walk speed (§1's finding: likely unaffected today, but UNVERIFIED —
confirm before claiming victory), (b) headless/get_state_velocity-path
grounded walk speed (§1's finding: likely STILL exposed to the original
L.3c hammering mechanism — this is the one that needs the real fix, not
just the threshold), and (c) a remote/NPC mover sample
(RuntimeRemotePhysicsUpdater.cs/RemoteMotion.cs — not audited this
pass for Velocity-zeroing behavter; check before assuming safety).
Step 6 — #166 visual check, after Steps 1-5
Per §3's verdict: #166 is very likely closed BY PROXY once AP-7 (Step 5)
and TS-4 (Step 4) land — no new code needed beyond those two. Verify
against the P2 visual-matrix item 5 (downhill jump landing) LAST, using
a fresh ACDREAM_CAPTURE_RESOLVE trajectory. Only if it still visibly
mismatches retail after Steps 1-5 does #166 need further work, and that
further work should be capture-driven, not a speculative Sledding
auto-toggle (§3's explicit recommendation).
Step 7 — #116, separately scoped, own research session
Per §5's verdict, this is not an implementation step at all yet — it
needs its own instrumented-replay session (shape-1) and a
Ghidra-availability-gated or live-cdb session (shape-2) before any
pseudocode can be written. Sequence-independent of Steps 1-6 (different
root cause layer — collision-normal recording, not response), but
shares the same slide_sphere/validate_transition machinery TS-1
depends on, so land it AFTER Steps 1-4 settle to avoid two people
touching TransitionTypes.cs's collision-normal plumbing at once.
Fixtures/tests that must exist BEFORE any behavior change lands (mission requirement, consolidated)
- A back-probe-arm-specific unit test for TS-1 Step 1 (write if absent).
- The steep-roof/wedge repro fixture for TS-4 Step 3 (locate or recapture).
- AP-7's three-way regression test (graphical/animated, headless state-velocity, remote/NPC) for Step 5.
- A fresh
ACDREAM_CAPTURE_RESOLVEdownhill-landing trajectory for #166 Step 6. - An instrumented
DoorBugTrajectoryReplayTestscapture for #116 shape-1 (Step 7) — extend the existingDiagnostic_Tick22760_DumpEngineInternalsrather than writing a new harness. - Existing coverage that must NOT regress:
SphereCollisionFamilyTests,Issue137CorridorSeamReplayTests,Issue137SlidingNormalLifecycleTests,WindowOpening_HeadCannotFit_EntryBlocked, and the fullBSPStepUpTests/DoorBugTrajectoryReplayTestssuites (D4 staysSkip-tagged until #116 shape-2 is actually resolved by evidence, not by this campaign's other changes incidentally shifting its numbers).
7. Open questions needing Ghidra/cdb (not guessed here)
Consolidated from all five sections above, each tagged with which item it blocks.
-
[AP-7] Is the BN-rendered "two duplicated branches" in
calc_friction(pseudo-C:276694-276822) a genuine BN decompiler artifact (single retailif (state & SLEDDING_PS)block misrendered), or does retail's actual source have two structurally separate paths? A live Ghidra decompile of0050ee70settles it. Low implementation risk either way (ACE's single-linear-function reading is adopted regardless), but affects how confidently the port shape can be described as "verified" vs. "ACE-derived." -
[AP-7] The
p_5/p_1polarity (does the friction-apply branch trigger ondot < 0.25ordot >= 0.25?) and thep_2/p_3/p_4velocity-magnitude-band/slope-flatness polarities are alltest ah,0x5-class x87 flag tests, none independently Ghidra-verified this pass. ACE's clean reading is adopted as ACE-derived; a Ghidra decompile of0050ee70(same function as item 1) would settle all of these at once. -
[AP-7] The
cos(10°)(raw decomp) vs.0.99999536f(ACE) discrepancy in the Sledding slope-flatness test — genuinely different physical behaviors, not a rounding difference. Needs a Ghidra decompile of0050ee70checking whether the FCOS opcode is real or a BN misread of a raw float constant load. Currently unresolved; §1 provisionally recommends ACE's0.99999536f(least churn from acdream's existing dead code) but flags this explicitly as unconfirmed. -
[TS-1 gap #1]
SPHEREPATH::get_walkable_pos,cache_localspace_sphere, andset_walkable_check_poswere located (symbol exists, called at pseudo-C:274318-274326) but NOT read this pass (effort budget). A fresh grep-and-read of these three functions is needed before porting Step 1 — this is a same-tool (grep-named) follow-up, not a Ghidra/cdb blocker; flagged here only so it isn't lost. -
[TS-1 gaps #2, #3] Whether
last_known_contact_planemaintenance and Path-4'sLandingZacceptance logic genuinely diverge from retail (justifying acdream's CliffSlide fallback chain and the walkable-steepness reroute as real compensating adaptations) or whether they're unnecessary inventions. Needs a fresh, focused named-decomp read (not Ghidra/cdb-gated — just not done this pass). -
[TS-4 / Step 3] ANSWERED 2026-07-30 (implementation session) — NOT sufficient; the wedge reproduces, and its mechanism is now precisely characterized. TS-1's gap #1 fix (this document's §2, landed the same session) does NOT unblock TS-4. A dat-free multi-frame capture (
tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs, usingBSPStepUpFixtures.SlopedUnwalkable's 63.4° slope,PhysicsEngine.ResolveWithTransitionreplayed at 30 Hz with gravity integrated between resolves — the same replay idiom asIssue185OutdoorStairsSeamReplayTests) reproduces the EXACT historical shape with the Path-6 steep shortcut temporarily removed (bothBSPQuery.cssphere0/sphere1 branches): the body falls cleanly (30 ticks, position advancing every tick), lands at(0.500, 0.000, 1.247)at tick 17 withInContact=true, OnWalkable=false(confirming the steep polygon WAS accepted via the permissiveCTransition::check_walkable(0.0871556997f)/LandingZgate exactly as predicted from the retail source read below), then freezes at that exact position for the remaining 16+ ticks with zero movement — the test's own wedge-detection threshold (>15 consecutive frozen ticks =0.5s) trips at tick 33. With the shortcut restored, the same test is green (the shortcut's explicit
AddOffsetToCheckPoskeeps the body moving every tick by construction). The shortcut stays; TS-4 is NOT retired this session.Root-cause diagnosis (
ACDREAM_DUMP_EDGE_SLIDE=1capture against the scratch shortcut-removed build): the freeze is NOT insideEdgeSlideAfterStepDownFailed/CliffSlideat all — none of that dispatch's diagnostic lines (DumpEdgeSlideBranch,DumpStepDownBranchGate, the[steep-roof] PHASE3-RESET*lines) fire even once during the frozen ticks. Every frozen tick instead logs onlyedge-slide: phase2 attempt=0 env=OK obj=Adjustedfollowed byattempt=1 env=OK obj=Adjusted— i.e.TransitionalInsert's Phase 2 object-collision check (FindObjCollisionsInCell,TransitionTypes.cs:1572) returnsAdjustedon BOTH retry attempts, and perTransitionTypes.cs:1591-1596anAdjustedobjStateunconditionallycontinues (retries Phase 1/2 from the top) rather than falling through toward Phase 2.5/Phase 3. Phase 3 — theif (sp.Collide) { ... }block atTransitionTypes.cs:1625that contains theDoCheckWalkablePlacement re-test AND (on walkable failure) the reset-with-conditional-kill_velocitypath — is gated on Phase 1 AND Phase 2 BOTH returningOKsimultaneously (TransitionTypes.cs:1568-1621). Path 6's own unconditionalSetCollide(the retail-faithful code path TS-4 would restore) returnsAdjusted, notOK(matching retail's ownreturn 3; // ADJUSTED_TSat pc:323783, quoted in §4 above) AND does NOT itself reposition the sphere — unlike the interim shortcut, which explicitly callsAddOffsetToCheckPosto push the sphere off the face every time it fires. With no repositioning, the SAME steep polygon at the SAME distance re-triggers Path 6 on the immediate retry, which again returnsAdjusted, forever — an Adjusted↔retry oscillation at a fixed point that the loop's 2-attempt-per-resolve budget silently absorbs (returning the frozen position as though the resolve succeeded), repeating identically on every subsequent tick's fresh resolve call. Phase 3 (and thereforeDoCheckWalkable,CliffSlide, and the TS-1 chain entirely) is structurally unreachable from this state — TS-1's completeness is moot here because the code path that would call into it never runs.What this means for a future attempt: the missing piece is NOT (only) in
EdgeSlideAfterStepDownFailed/CliffSlide— it is in howTransitionalInsert's Phase 1/2/2.5/3 dispatch (TransitionTypes.cs :1568-1710) distinguishes "Phase 2 found a NEW collision, retry from the top" from "Phase 2 registered a touch viasp.Collideand should fall through toward Phase 3 regardless of its ownAdjustedreturn." Retail's owntransitional_insert(pc:273137,0050b6f0) has NOT been read closely enough this pass to say definitively whether it treats a Path-6-sourcedADJUSTED_TSdifferently from an ordinary Adjusted result before this session'scontinue-on-Adjusted structure was written — that fresh, close read (specifically: does retail's loop checksphere_path.collideon EVERY iteration regardless of the latest Phase-2 return value, or only when Phase 2 returns OK?) is the concrete next step, not a second speculative code change. Do not retry the plain shortcut-deletion variant without that read; do not invent a third variant (e.g. teaching Path 6 to reposition the sphere itself) without confirming that's what retail actually does — that would be exactly the kind of guess CLAUDE.md's workflow forbids twice in a row on the same item. -
[#116 shape-1] Where exactly, in the BSP/environment hit-test dispatch (candidate:
BSPTREE::find_collisions'sPathClipped/collide_with_ptarm, pseudo-C ~323700-323830, sibling to theset_collidearm read for TS-4), does acdream fail to record the door-face collision normal that retail records at tick-22760? Needs an instrumented replay (§5 Step 7), not Ghidra/cdb per se, but the candidate function itself would benefit from a clean Ghidra decompile alongside the BN pseudo-C already read. -
[#116 shape-2] Does retail's first airborne wall-contact frame reach
CSphere::slide_sphere(→ in-frame slide, Z=1.92-style), or hard-stop upstream via somelast_known_contact_plane-existence gate (→ Z=2.0-style, sliding deferred to frame 2)? This is the one item in this whole document that the digest, ISSUES.md, AND this research pass all independently converge on: it needs a live cdb trace of retail landing an airborne wall hit (toolchain:docs/architecturereferences the CLAUDE.md "Retail debugger toolchain" section; the digest already has a scoped cdb script sketch atmemory/project_physics_collision_digest.md:8537-8543). A wrong guess here, per the digest's own words, "regresses ALL wall-slide behavior."
8. Campaign P Slice 1B closeout — exact response ordering (2026-07-31)
The follow-up read used the complete named-retail bodies, not the earlier excerpt summaries:
CTransition::transitional_insertat0x0050B6F0(pseudo-C:273137 onward) returnsOK_TSas soon ascontact_plane_valid != 0. Only an invalid contact reaches the ordinary StepDown tail, whose remaining gates are Contact,!sphere_path.step_down, a non-null check cell, and ObjectInfo.StepDown.- Its StepDown schedule is asymmetric by authored sphere count. For a one-sphere mover whose requested height exceeds the foot diameter, retail clamps the probe to half the foot radius and performs one probe. Otherwise a request within the diameter probes once; an over-diameter request on a two-sphere mover is halved and probes twice in sequence.
CTransition::edge_slideat0x0050B3D0(pseudo-C:273001-273090) runs!OnWalkable || !EdgeSliderestore-and-OK before its steep-contact CliffSlide branch. Any stored walkable polygon routes to PrecipiceSlide without a steepness test.CTransition::cliff_slideat0x0050A6D0(pseudo-C:272397 onward) crosses the supplied contact normal only withcollision_info.last_known_contact_plane.N. It has no remembered-walkable or world-up substitute. A default, invalid, parallel, or otherwise degenerate cross naturally returnsOK_TSthrough the retail normalization guard.
TransitionTypes.cs now follows that order exactly. AP-3, AP-4, AD-53, and
AD-54 are retired together. Slice 1B deliberately left the ordinary-tail
runPlacement: false choice for the following AP-5-specific slice and did not
alter TS-4's Path-6 steep-polygon shortcut. Slice 2A below closes AP-5.
RetailEdgeResponseOrderingTests pins every distinguishing branch: valid
steep-contact early return, the one/two-sphere probe schedule,
not-OnWalkable-before-CliffSlide, last-known-only source selection, degenerate
last-known handling, and stored-steep-walkable-to-Precipice routing. It also
runs multi-frame steep-roof and flat-roof-edge controls through both parsed
graph and prepared-flat collision traversal, requires exact trace parity, and
rejects a greater-than-15-tick frozen streak. The earlier dedicated
Ts4SteepRoofWedgeCaptureTests remains green, so retiring these four
compensations did not require weakening or deleting the TS-4 control.
Corrective review: edge_slide has two outputs
The first Slice 1B commit collapsed CTransition::edge_slide's function
return into its out TransitionState. That loses a material retail case:
the steep-contact branch writes the result of cliff_slide to the out state
but returns false independently. A degenerate/parallel CliffSlide therefore
writes OK_TS and still tells transitional_insert to continue its outer
retry. Branch 1 and the contact-without-remembered-walkable branch stop with
OK_TS; a COLLIDED_TS Precipice result stops; SLID_TS and ADJUSTED_TS
retain their ordinary retry handling.
The corrective implementation preserves this exact bool-plus-out-state seam.
Its end-to-end test drives an invalid-contact StepDown probe into a steep,
parallel-last-known CliffSlide, then proves a second outer object pass occurs
before success. The roof controls were also hardened: the flat-roof fixture
must first land and publish its persistent contact/walkable chronology, fails
its control arm when the roof is removed, and proves outward-X rejection plus
continued edge tangency. The steep-roof fixture now rejects non-finite or
oversized frame steps and signed-plane penetration until polygon exit. Parsed
graph and prepared-flat runs compare every ResolveResult field and every
persistent PhysicsBody field by raw float/double bits, including the ordered
walkable vertex payload.
9. Campaign P Slice 2A closeout — mandatory StepDown placement (2026-07-31)
The complete CTransition::step_down body at 0x0050B2A0
(pseudo-C:272946–272998) has no caller-controlled validation choice. It resets
walk_interp to 1, performs the transitional downward/support probe, applies
the EdgeSlide && !StepUp walkable-support gate, and then always:
- saves
sphere_path.insert_type; - installs
PLACEMENT_INSERT; - calls
CTransition::transitional_insert(this, 1); - restores the saved insert type; and
- returns true only for
OK_TS.
The walk_interp = 1 assignment occurs once at step_down entry. Retail does
not reset it again before the final Placement insertion: that insertion sees
and carries the exact interpolation value left by the support probe. Likewise,
CTransition::check_walkable (0x0050AFF0, pseudo-C:272811–272856) saves its
temporary check_pos and cell in stack locals. It does not overwrite
SPHEREPATH::backup_check_pos/backup_cell, because the enclosing edge-slide
still needs that outer failed candidate after the nested support probe.
This is the same tail for normal grounded contact maintenance, the
edge_slide current-position back-probe, and StepUp. The former acdream
runPlacement: false argument on the first two callers was therefore a real
behavioral divergence, not a caller-specific retail mode.
The historical justification for the bypass was a wall-slide candidate that
Placement rejected. The correct boundary is the Placement dispatcher, not
StepDown. Retail's BSP, sphere, and cylinder Placement branches are pure
occupancy tests and shave PhysicsGlobals.EPSILON from their effective reach.
The current graph and prepared-flat dispatchers already implement that rule:
an exactly tangent sphere remains valid while penetration beyond the retail
epsilon is rejected. With the response-order fixes from §8 in place, restoring
the mandatory placement tail does not reproduce the old wall stall.
RetailStepDownPlacementTests pins the mechanism end to end for one- and
two-sphere movers, including raw-bit walk_interp carry, nested
check_walkable backup preservation, ordinary contact maintenance, StepUp,
and a supported candidate that overlaps only during final Placement. It also
compares graph and prepared-flat Placement at exact wall tangency and
beyond-epsilon overlap. The full-engine floor-plus-wall replay drives ten
grounded maintenance frames for both sphere counts, requires one Placement for
every successful support maintenance, proves no freeze or penetration plus
tangential progress, and compares complete ResolveResult and persistent
PhysicsBody state by raw bits between parsed-graph and prepared-flat paths.
The #273, #271, #185, StepUp, transition-retry, and TS-4 controls remain
unchanged. AP-5 is retired; TS-4 is intentionally untouched.
10. Campaign P Slice 2B closeout — TS-4 retired (2026-07-31)
A fresh read of BSPTREE::find_collisions (0x0053A440) confirms the exact
Path-6 split. A primary/foot-sphere polygon hit transforms the polygon normal,
calls SPHEREPATH::set_collide, writes LandingZ, and returns ADJUSTED_TS
(0x0053A7B3..0x0053A7DC) regardless of steepness. Only when that sphere is
clear does a secondary/head-sphere hit write collision_normal and return
COLLIDED_TS (0x0053A793..0x0053A7A4). Neither branch writes
sliding_normal.
Both parsed-graph and prepared-flat Path-6 implementations now follow that
split exactly. The steepness branches, in-place tangent projection, and BSP-
layer SetSlidingNormal writes are deleted. Exact site tests compare the two
representations by raw bits and pin every mutated and preserved field:
- foot:
SetCollide, candidate backup, transformedStepUpNormal,WalkInterp=1,WalkableAllowance=LandingZ,Adjusted; - head:
CollisionNormal,Collided, with noSetCollidestate mutation; - both: a pre-existing sliding normal is preserved byte-for-byte.
The failed first removal was a test-harness lesson, not a retail exception.
Its gravity-only replay always passed isOnGround:false, manually zeroed
vertical velocity, and only copied part of the accepted contact state, so it
could not reproduce the live caller's next-frame chronology. The replacement
Ts4ProductionQuantumConformanceTests replay executes each already-airborne,
zero-root-motion 30 Hz Core collision quantum in the production order:
calc_acceleration, UpdatePhysicsInternal,
ResolveWithTransition, exact body/cell commit, then
PhysicsObjUpdate.CommitSetPositionTransition (the Core owner of retail's
Contact/OnWalkable replacement plus handle_all_collisions). It carries the
same body, cell, contact plane, walkable plane, sliding state, stationary-fall
counter, cached velocity, and transient flags for all 90 ticks. PositionManager
root composition, animation hooks, movement callbacks, and fresh-airborne
LeaveGround/HitGround edges are deliberately outside this already-airborne
dat-free fixture; it does not claim to replay those presentation/motion stages.
Parsed graph and prepared flat match by raw result/body bits for vertical, inward, tangential, and downhill roof motion plus a genuine positive-Z uphill jump. The foot-contact jump's apex precedes roof contact and every later candidate velocity remains non-positive in Z. A separate elevated head-only collision reaches the wall while Z velocity is still positive and pins the valid-normal, inward-dot retail 5%-elastic reflection without adding vertical velocity. Every direction rejects a half-second fixed point and signed-plane penetration, while exact terminal velocity, Contact/OnWalkable/Sliding bits, sliding normal, and contact plane are fixed by raw float bits. The older resolver-only wedge capture remains a deliberately weaker historical control and is restored to its original three-second bound. The former #116 D4 control remains active: its primary-sphere hit hard-stops frame one through SetCollide, then the accepted persistent normal permits the downward slide on frame two.
The complete historical #273/#271/#269/#265/#185/#137/#116/cellar/roof matrix and the full Core Release suite pass without a replacement compensation. TS-4 is retired.