# Player physics per-frame loop — verbatim rebuild — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Port retail `CPhysicsObj::UpdateObjectInternal`'s velocity/collision chain verbatim into acdream's player per-frame loop so a blocked jump into a monster crowd **bleeds its velocity and glides/lands like retail** (fixes #182's airborne "stuck in the falling animation" regression + halves the general crowd jam), keeping the already-faithful transition internals as the collision primitive. **Architecture:** The retail per-frame chain is `UpdateObject` (dt sub-step driver) → `UpdateObjectInternal` (integrate candidate → `transition` sweep → commit + `cached_velocity`) → `SetPositionInternal` (commit resolved frame + contact/walkable/sliding flags + `handle_all_collisions`) → `handle_all_collisions` (velocity reflect OR zero, driven by `frames_stationary_fall`). The velocity "bleed on block" is **not** the `cached_velocity` field (a separate reporting/DR value) — it is the `frames_stationary_fall` (fsf) counter: `validate_transition` increments it 0→1→2→3 when the sphere fails to advance and at fsf≥2 manufactures an upward contact plane; `handle_all_collisions` **zeros the velocity when fsf > 1**. acdream reflects but never runs the fsf path (registered as TS-3), so a blocked jump reflects a sliver of +Z and hangs. This rebuild completes the fsf round-trip in the kept transition internals and ports the `SetPositionInternal`/`handle_all_collisions` consumer into the player loop, replacing acdream's ad-hoc reflect/land block. **Tech Stack:** C# / .NET 10, xUnit. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (named PDB decomp). Measurement: `ACDREAM_CAPTURE_RESOLVE` JSONL capture + a new Python histogram classifier. A/B target (retail cdb, `tools/cdb/retail-crowd-jump3.cdb`): **~78% OK, 12.7% COLLIDED, 8.8% SLID, 0 airborne-stuck.** --- ## 0. Refinement of the design spec (read before starting) This plan **refines** `docs/superpowers/specs/2026-07-07-player-physics-update-verbatim-rebuild-design.md`. The design's §7 explicitly deferred verifying the mechanism to "the writing-plans step"; that verification (reading `handle_all_collisions` 0x00514780, `UpdatePhysicsInternal` 0x00510700, and the fsf lifecycle end-to-end) changed the slicing: | Design said | Verified truth (decomp + capture) | Consequence | |---|---|---| | Slice 1 = the `cached_velocity = (resolved−old)/dt` model bleeds velocity → airborne-stuck→0 | `cached_velocity` is a **separate reporting field** read only by `get_velocity` (network/DR/camera), never fed to the integrator (`m_velocityVector`). It does **not** bleed the jump. | The airborne-stuck fix lives in `handle_all_collisions` (`fsf>1 → v=0`), which needs the **fsf round-trip** first. Slices reorder: fsf substrate (Slice 1) → `handle_all_collisions` consumer (Slice 2, the design's "Slice 2", now the load-bearing fix). | | §7 Q1: does retail reflect, or is velocity purely movement-derived? | Retail does **both**: `fsf≤1` → reflect `v += -(v·n)(elasticity+1)·n`; `fsf>1` → `v=0`. Velocity is `m_velocityVector`-integrated. | Keep `PhysicsBody.Velocity` as `m_velocityVector`; **add a separate `CachedVelocity`** field; do not collapse them. | | §7 Q2: which `SetPositionInternal` overload | `0x00515330` (single `CTransition` arg) — confirmed from `UpdateObjectInternal:283696`. | Port that overload's contact/walkable/sliding + `handle_all_collisions` sequence. | | §7 Q3: is the general ground-jam explained by the velocity model, or a second divergence? | Measure after Slice 2. Candidate residual = **TS-4** (BSPQuery Path-6 steep persisted sliding-normal / #137 anti-parallel-absorb) — in the *kept* internals. | Slice 3, only if <~78% OK. Do not touch TS-4 pre-measurement. | **Out of scope (kept — already faithful, per the user "keep everything faithful"):** the transition INTERNALS below `ResolveWithTransition` — BSPQuery, the CSphere/CylSphere collision families (incl. the #182 port), cell membership, terrain, streaming. The ONE exception is completing the fsf stub inside `ValidateTransition` — that stub **is** "the full physics port" TS-3 was explicitly deferred to; completing it is finishing a known gap, not re-porting working code. **`calc_friction` is out of scope** — it carries an orthogonal threshold divergence (retail 0.25 vs acdream 0.0) whose naive fix regresses walking (L.3c); the fsf fix does not require it. **#182 stays the base** (user decision). --- ## 1. The verified retail chain (the port source — cite these in code comments) All addresses/pc from `docs/research/named-retail/acclient_2013_pseudo_c.txt`. ### `UpdateObjectInternal(dt)` — 0x005156b0, pc:283611 ``` if not active (transient_state high bit clear): update particles/scripts; return if cell == 0: return jumped_this_frame = 0 candidate = identity frame UpdatePositionInternal(dt, &candidate) // integrate → candidate if part_array has spheres AND candidate.origin != m_position.origin: // moved (set candidate heading from velocity/motion) trans = transition(m_position, candidate, 0) // COLLISION SWEEP (== ResolveWithTransition) if trans == null: set_frame(candidate); cached_velocity = 0 else: cached_velocity = (trans.sphere_path.curr_pos − m_position) / dt // 0x005158cb-005158ff SetPositionInternal(trans) // 0x00515330 else: // no spheres OR didn't move set_frame(candidate); cached_velocity = 0 // tail: detection / target / movement.UseTime / part.HandleMovement / position.UseTime ``` ### `UpdatePositionInternal(dt, &newFrame)` — 0x00512c30, pc:280817 ``` delta = identity part_array.Update(dt, &delta) // animation root motion → delta if not OnWalkable: delta.translation *= 0 // airborne: no anim translation else: delta.translation *= m_scale position_manager.adjust_offset(&delta, dt) // sticky newFrame = m_position.frame ∘ delta // combine UpdatePhysicsInternal(dt, &newFrame) // velocity + gravity integration process_hooks() ``` ### `UpdatePhysicsInternal(dt, &newFrame)` — 0x00510700, pc:278460 ``` velMag2 = |m_velocityVector|² if velMag2 > 0: if velMag2 > 2500 (=50²): m_velocityVector = normalize(m_velocityVector)*50 calc_friction(dt, velMag2) if (velMag2 − 0.0625 (=0.25²)) <= 0.0002 (F_EPSILON): m_velocityVector = 0 // NOT gated on OnWalkable newFrame.origin += m_velocityVector*dt + 0.5*accel*dt² else if movement_manager == null: if OnWalkable: clear Active bit m_velocityVector += accel*dt // UNCONDITIONAL (both branches) newFrame = grotate(newFrame, omega*dt) ``` ### `calc_acceleration()` — 0x00510950, pc:278533 ``` if Contact(0x1) AND OnWalkable(0x2) AND !(state & 0x800000 SLEDDING): accel=0; omega=0; return if !(state & 0x400 GRAVITY): accel=0; return accel = (0,0, gravity(-9.8)) ``` ### `SetPositionInternal(trans)` — 0x00515330, pc:283399 ``` curr_cell = trans.sphere_path.curr_cell if curr_cell == 0: prepare_to_leave_visibility; store_position; GotoLostCell; clear Active; return 1 if this.cell != curr_cell: change_cell(curr_cell) else: update objcell_id (+children) set_frame(trans.sphere_path.curr_pos.frame) // COMMIT resolved position contact_plane = trans.collision_info.contact_plane contact_plane_cell = trans.collision_info.contact_plane_cell_id Contact bit(0x1) = trans.collision_info.contact_plane_valid ; calc_acceleration() Water bit(0x8) = trans.collision_info.contact_plane_is_water if Contact == 0: clear OnWalkable(0x2); if was OnWalkable: movement.LeaveGround(); calc_acceleration() else: set_on_walkable(contact_plane.N.z >= floor_z(0.6642)) sliding_normal = trans.collision_info.sliding_normal Sliding bit(0x4) = trans.collision_info.sliding_normal_valid handle_all_collisions(trans.collision_info, PREV_Contact, PREV_OnWalkable) // prev = transient_state captured at entry [if HAS_PHYSICS_BSP(0x10000): calc_cross_cells; else shadow-cell updates] return 1 ``` ### `handle_all_collisions(ci, prev_contact, prev_on_walkable)` — 0x00514780, pc:282647 ``` should_reflect = !(prev_on_walkable AND OnWalkable AND !(state & SLEDDING)) // var_10_1 [report collision-start for each collided object; report_collision_end; environment-collision report] fsf = ci.frames_stationary_fall if fsf <= 1: if should_reflect AND ci.collision_normal_valid AND !(state & 0x20000 INELASTIC): dot = m_velocityVector · ci.collision_normal if dot < 0: m_velocityVector += -(dot*(elasticity+1)) * ci.collision_normal // REFLECT elif should_reflect AND ci.collision_normal_valid: // INELASTIC m_velocityVector = 0 else: // fsf > 1 m_velocityVector = 0 // ZERO (the bleed) // encode fsf → transient bits: if fsf == 1: transient_state |= 0x10 (StationaryFall) elif fsf == 2: transient_state |= 0x20 (StationaryStop) elif fsf == 3: transient_state |= 0x40 (StationaryStuck) else (fsf==0): transient_state &= ~(0x10|0x20|0x40) return ``` ### fsf counter in `validate_transition` — 0x0050aa70, pc:272625-272656 ``` if ci.collision_normal_valid: ci.set_sliding_normal(ci.collision_normal) if (object_info.state & 4)==0 AND (target_object.state & 0x400 GRAVITY)!=0: // mover-not-frozen AND object has gravity if moved: ci.frames_stationary_fall = 0 // advanced → reset else: if fsf == 0: fsf = 1 elif fsf == 1: fsf = 2 else: fsf = 3 // fsf >= 2 → manufacture UP contact up = (0,0,1) d = radius − (global_sphere.center · up) // plane through sphere bottom ci.set_contact_plane(up, water=0); ci.contact_plane_cell_id = check_pos.objcell_id if (object_info.state & 1)==0: // not already Contact ci.set_collision_normal(up); ci.collided_with_environment = 1 ``` ### fsf seed in `transition` — 0x00512dc0, pc:280940-280947 ``` after init_path: if transient_state & 0x40: fsf = 3 elif transient_state & 0x20: fsf = 2 elif transient_state & 0x10: fsf = 1 ``` ### `UpdateObject(dt)` sub-step driver — 0x00515d10, pc:283960 ``` dt = cur_time − update_time if dt <= F_EPSILON: update_time = cur_time; return if dt > 2.0 (HugeQuantum): update_time = cur_time; return while dt > MaxQuantum: UpdateObjectInternal(MaxQuantum); dt -= MaxQuantum if dt > 0: UpdateObjectInternal(dt) update_time = cur_time ``` (acdream already inlines this dt-substep as `_physicsAccum` in `PlayerMovementController` — kept as-is; the L.5 MinQuantum gate is a documented pre-existing adaptation, out of scope.) --- ## 2. acdream current state (the seam being rebuilt) - **Per-frame method:** `PlayerMovementController.Update(float dt, MovementInput input)` — `src/AcDream.App/Input/PlayerMovementController.cs:542-1302`. Caller: `GameWindow.cs:8454`. - The retail `UpdateObjectInternal` chain is currently **spread** across: §4 integrate (`:820-877`, `_body.UpdatePhysicsInternal(tickDt)` integrating `Position` **in-place**), §5 resolve (`:879-916`, `_physics.ResolveWithTransition(preIntegratePos, postIntegratePos, …)`), apply (`:931-947`), the ad-hoc reflect block (`:949-1056`), and the landing/airborne block (`:1058-1109`). - `PhysicsBody` (`src/AcDream.Core/Physics/PhysicsBody.cs`): single `Velocity` field (plays `m_velocityVector`), `calc_acceleration`/`UpdatePhysicsInternal`/`calc_friction`/`set_velocity` ports present. **No `CachedVelocity`.** `TransientStateFlags` = Contact/OnWalkable/Sliding/Active only (**no 0x10/0x20/0x40**). `UpdatePhysicsInternal` has a divergence: the small-velocity-zero at `:503-504` is gated on `OnWalkable` (retail is ungated). - `ResolveResult` (`src/AcDream.Core/Physics/ResolveResult.cs:21`): `readonly record struct` with `Position, CellId, IsOnGround, CollisionNormalValid, CollisionNormal, Ok`. Rich state (ContactPlane, SlidingNormal) flows back through the passed `body` side-effect (`PhysicsEngine.cs:999-1015` seed-in, `:1056-1106` write-out). **`FramesStationaryFall` is not surfaced.** - `Transition.ValidateTransition(TransitionState)` — `src/AcDream.Core/Physics/TransitionTypes.cs:4575`. fsf stub at `:4596` (`// moved = true (FramesStationaryFall deferred…)`). "moved" analog = `sp.CheckPos != sp.CurPos` (`:4581`). `CollisionInfo.FramesStationaryFall` field at `:245` (dead). All other retail COLLISIONINFO fields have counterparts. - **Apparatus:** `PhysicsResolveCapture` (`src/AcDream.Core/Physics/PhysicsResolveCapture.cs`), toggled by `ACDREAM_CAPTURE_RESOLVE`, IsPlayer-filtered at `PhysicsEngine.cs:946`. Baseline capture: `acdream-crowd-resolve.jsonl` (repo root, 41832 lines). No histogram classifier exists yet. --- ## 3. File structure | File | Change | |---|---| | `tools/analyze_resolve_capture.py` | **Create.** Histogram classifier for `ACDREAM_CAPTURE_RESOLVE` JSONL → OK/partial/stuck/airborne-stuck buckets over move-intent records. | | `src/AcDream.Core/Physics/PhysicsBody.cs` | Add `StationaryFall/Stop/Stuck` flags; add `FramesStationaryFall` + `CachedVelocity` fields; remove the OnWalkable gate on the small-velocity-zero. | | `src/AcDream.Core/Physics/TransitionTypes.cs` | Un-stub the fsf ladder in `ValidateTransition` (`:4575`); add fsf increment/reset + fsf≥2 UP-plane manufacture. | | `src/AcDream.Core/Physics/PhysicsEngine.cs` | `ResolveWithTransition`: seed `ci.FramesStationaryFall` from `body` transient bits at entry; write `ci.FramesStationaryFall → body.FramesStationaryFall` at exit. | | `src/AcDream.App/Input/PlayerMovementController.cs` | Replace the ad-hoc reflect/land block (`:949-1109`) with the ported `SetPositionInternal`+`handle_all_collisions` sequence; compute `CachedVelocity`. | | `src/AcDream.Core/Physics/PhysicsObjUpdate.cs` | **Create.** Static/pure port of `SetPositionInternal` + `handle_all_collisions` operating on a `PhysicsBody` + a resolve outcome, unit-testable in Core without the App layer. | | `docs/architecture/retail-divergence-register.md` | Retire TS-3; amend AD-25 (split off the player half); add any new adaptation rows. | | `tests/AcDream.Core.Tests/Physics/*` | New conformance tests: fsf ladder, `handle_all_collisions` reflect/zero, crowd-jump replay against the capture. | --- ## Task 1: Author the resolve-capture histogram classifier (Slice 0 — measurement) **Files:** - Create: `tools/analyze_resolve_capture.py` - [ ] **Step 1: Write the classifier** Record schema (camelCase JSON, one per line): `input.{currentPos,targetPos,cellId,...}`, `bodyBefore` / `bodyAfter` (`PhysicsBodySnapshot`, incl. `velocity`, `slidingNormal`, `transientState`), `result.{position,cellId,isOnGround,collisionNormalValid,collisionNormal}`. Vector3 = `{x,y,z}`. ```python #!/usr/bin/env python3 """Classify ACDREAM_CAPTURE_RESOLVE JSONL into OK / partial / stuck / airborne-stuck. Buckets (move-intent records only, i.e. targetPos != currentPos): OK reached target: dist(result.position, targetPos) <= EPS_REACH partial advanced short: moved > EPS_MOVE and not OK stuck reverted: moved <= EPS_MOVE (requested motion, none delivered) airborne-stuck subset of stuck: bodyBefore airborne with jump velocity into a near-horizontal collision normal (the falling-animation wedge) Retail target (retail-crowd-jump3.cdb): ~78% OK, 12.7% COLLIDED, 8.8% SLID, 0 airborne-stuck. """ import sys, json, math EPS_REACH = 0.02 # 2 cm — "reached target" EPS_MOVE = 0.01 # 1 cm — "advanced at all" JUMP_VZ = 5.0 # bodyBefore.velocity.z above this = a jump/airborne launch HORIZ_NZ = 0.5 # |collisionNormal.z| below this = near-horizontal (creature side) def d(a, b): return math.sqrt((a["x"]-b["x"])**2 + (a["y"]-b["y"])**2 + (a["z"]-b["z"])**2) def classify(rec): i = rec["input"] if d(i["targetPos"], i["currentPos"]) <= EPS_MOVE: return None # zero-motion rest tick — not a move-intent record r = rec["result"] moved = d(r["position"], i["currentPos"]) reached = d(r["position"], i["targetPos"]) <= EPS_REACH if reached: return "ok" if moved > EPS_MOVE: return "partial" # reverted / stuck bb = rec.get("bodyBefore") or {} vel = bb.get("velocity") or {"x":0,"y":0,"z":0} n = r.get("collisionNormal") or {"x":0,"y":0,"z":0} airborne_jump = vel["z"] > JUMP_VZ horiz_normal = r.get("collisionNormalValid") and abs(n["z"]) < HORIZ_NZ if airborne_jump and horiz_normal: return "airborne-stuck" return "stuck" def main(path): counts = {"ok":0, "partial":0, "stuck":0, "airborne-stuck":0} total_move = 0 with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError: continue c = classify(rec) if c is None: continue total_move += 1 # airborne-stuck is a subset of stuck for the bucket table but we # report it separately AND fold it under stuck for the % columns. if c == "airborne-stuck": counts["airborne-stuck"] += 1 counts["stuck"] += 1 else: counts[c] += 1 if total_move == 0: print("no move-intent records"); return print(f"move-intent resolves: {total_move}") for k in ("ok", "partial", "stuck"): print(f" {k:16s} {counts[k]:6d} {100.0*counts[k]/total_move:5.1f}%") print(f" {'airborne-stuck':16s} {counts['airborne-stuck']:6d} (frames; subset of stuck)") print("retail target: ok ~78% partial ~9% stuck ~13% airborne-stuck 0") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "acdream-crowd-resolve.jsonl") ``` - [ ] **Step 2: Validate against the existing baseline** Run: `py tools/analyze_resolve_capture.py acdream-crowd-resolve.jsonl` Expected: `move-intent resolves: ~2883`; buckets approximately `ok ~50.9% partial ~26.7% stuck ~22.4% airborne-stuck ~115` — i.e. it reproduces the design §2 acdream column. If the counts diverge materially from the design's numbers, tune `EPS_REACH` / `JUMP_VZ` until the classifier matches the design's hand-derived figures (the design numbers are the ground truth for the classifier's calibration). Record the exact calibrated numbers as the "before" baseline. - [ ] **Step 3: Commit** ```bash git add tools/analyze_resolve_capture.py git commit -m "tools(#182): resolve-capture histogram classifier (A/B baseline for the physics rebuild)" ``` --- ## Task 2: PhysicsBody — fsf state, CachedVelocity, and the ungated small-velocity-zero (Slice 1a) **Files:** - Modify: `src/AcDream.Core/Physics/PhysicsBody.cs` - Test: `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` - [ ] **Step 1: Write failing tests** ```csharp [Fact] public void TransientStateFlags_HasStationaryBits() { Assert.Equal(0x10u, (uint)TransientStateFlags.StationaryFall); Assert.Equal(0x20u, (uint)TransientStateFlags.StationaryStop); Assert.Equal(0x40u, (uint)TransientStateFlags.StationaryStuck); } [Fact] public void UpdatePhysicsInternal_ZeroesSmallVelocity_EvenWhenAirborne() { // Retail UpdatePhysicsInternal (0x00510700) zeroes velocity below 0.25 m/s // regardless of OnWalkable; gravity re-accelerates the same frame via v += a*dt. var b = new PhysicsBody { State = PhysicsStateFlags.Gravity }; b.TransientState = TransientStateFlags.None; // airborne b.set_velocity(new Vector3(0.1f, 0f, 0f)); // < 0.25 m/s, no OnWalkable b.Acceleration = new Vector3(0, 0, PhysicsBody.Gravity); b.UpdatePhysicsInternal(1f / 30f); // horizontal velocity was zeroed; only gravity (v += a*dt) remains on Z Assert.True(MathF.Abs(b.Velocity.X) < 1e-4f); Assert.True(b.Velocity.Z < 0f); } ``` Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsBodyTests.TransientStateFlags_HasStationaryBits"` Expected: FAIL (flags undefined / compile error). - [ ] **Step 2: Add the flags, fields, and fix the gate** In `PhysicsBody.cs`, extend `TransientStateFlags` (retail transient_state bits, `handle_all_collisions` pc:282743/282749/282753): ```csharp [Flags] public enum TransientStateFlags : uint { None = 0, Contact = 0x00000001, // bit 0 — touching any surface OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition // retail frames_stationary_fall carried across frames (transition seeds fsf from // these; handle_all_collisions re-encodes fsf into them). pc:282743/272940. StationaryFall = 0x00000010, // bit 4 — fsf == 1 StationaryStop = 0x00000020, // bit 5 — fsf == 2 StationaryStuck= 0x00000040, // bit 6 — fsf == 3 Active = 0x00000080, // bit 7 — object needs per-frame update } ``` Add fields near `Velocity` (`:183`): ```csharp /// Retail cached_velocity (+separate from m_velocityVector): the REALIZED /// velocity (resolved displacement / dt) written after each transition in /// UpdateObjectInternal (0x005158cb-005158ff). Read only for reporting / dead-reckoning / /// camera slope-align (get_velocity 0x005113c0); NEVER fed back into the integrator. public Vector3 CachedVelocity { get; set; } /// Retail collision_info.frames_stationary_fall carried on the body between /// frames. Incremented in ValidateTransition when the sphere fails to advance, consumed by /// handle_all_collisions (fsf>1 → velocity zeroed). Round-trips via the Stationary* transient /// bits. validate_transition pc:272625-656; handle_all_collisions pc:282695. public int FramesStationaryFall { get; set; } ``` In `UpdatePhysicsInternal` (`:503-504`), remove the OnWalkable gate (retail zeros regardless — gravity re-accelerates via the unconditional `v += a*dt`): ```csharp // Retail UpdatePhysicsInternal 0x005107be: zero velocity below 0.25 m/s // UNCONDITIONALLY (not gated on OnWalkable). At jump apex this zeros the // horizontal drift; the unconditional `Velocity += Acceleration*dt` below // immediately re-applies gravity, so the fall still accumulates. if (velocityMag2 - SmallVelocitySquared < 0.0002f) Velocity = Vector3.Zero; ``` Run: `dotnet test ... --filter "FullyQualifiedName~PhysicsBodyTests"` Expected: PASS (both new tests + the existing suite). - [ ] **Step 3: Commit** ```bash git add src/AcDream.Core/Physics/PhysicsBody.cs tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs git commit -m "feat(#182): PhysicsBody fsf state + CachedVelocity; ungate small-velocity-zero (verbatim UpdatePhysicsInternal)" ``` --- ## Task 3: Complete the fsf ladder in ValidateTransition (Slice 1b — the kept-internals stub, retiring TS-3's behavior gap) **Files:** - Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` `:4575`; the `moved` signal at `:4581`) - Test: `tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs` (create) - [ ] **Step 1: Write failing tests for the fsf ladder in isolation** The ladder is: given a `Transition` whose step did/did not advance, and whose target has gravity, `ValidateTransition` increments/resets `collision_info.FramesStationaryFall` and at fsf≥2 manufactures an upward contact plane. Test via a minimal `Transition` with a controllable `SpherePath` (CheckPos == or != CurPos) and `ObjectInfo` with the gravity flag. ```csharp using System.Numerics; using AcDream.Core.Physics; using Xunit; public class FramesStationaryFallTests { // Build a Transition whose ValidateTransition input state is OK, with CheckPos // == CurPos (did NOT advance) so the fsf ladder increments. Helper mirrors the // FindTransitionalPosition setup used by CellarUpTrajectoryReplayTests. static Transition BlockedGravityTransition(int seedFsf) { var t = TestTransitionFactory.GravityMover(radius: 0.48f); // see Step 2 helper t.CollisionInfo.FramesStationaryFall = seedFsf; t.SpherePath.SetCheckPos(t.SpherePath.CurPos, t.SpherePath.CurCellId); // no advance return t; } [Theory] [InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 3)] [InlineData(3, 3)] // saturates at 3 public void ValidateTransition_Blocked_IncrementsFsf(int seed, int expected) { var t = BlockedGravityTransition(seed); t.ValidateTransition(TransitionState.OK); Assert.Equal(expected, t.CollisionInfo.FramesStationaryFall); } [Fact] public void ValidateTransition_Blocked_AtFsf2_ManufacturesUpContactPlane() { var t = BlockedGravityTransition(seedFsf: 2); // will become 3 → manufacture t.ValidateTransition(TransitionState.OK); Assert.True(t.CollisionInfo.ContactPlaneValid); Assert.True(t.CollisionInfo.ContactPlane.Normal.Z > 0.99f); // UP Assert.True(t.CollisionInfo.CollidedWithEnvironment); } [Fact] public void ValidateTransition_Advanced_ResetsFsf() { var t = TestTransitionFactory.GravityMover(radius: 0.48f); t.CollisionInfo.FramesStationaryFall = 3; // CheckPos advanced beyond CurPos → moved == true t.SpherePath.CheckPos = t.SpherePath.CurPos with { /* origin += (1,0,0) */ }; t.ValidateTransition(TransitionState.OK); Assert.Equal(0, t.CollisionInfo.FramesStationaryFall); } } ``` Run: `dotnet test ... --filter "FullyQualifiedName~FramesStationaryFallTests"` Expected: FAIL (fsf never changes — stub). - [ ] **Step 2: Add the `TestTransitionFactory.GravityMover` helper** If no reusable factory exists, add a minimal one in the test project that builds a `Transition` with a `SpherePath` (one sphere radius r at a known CurPos/CurCellId), an `ObjectInfo` whose `State` includes the gravity flag and whose `object.State` sets `PhysicsStateFlags.Gravity`, matching what `FindTransitionalPosition` sets up. Mirror the setup in `CellarUpTrajectoryReplayTests.cs:1252-1297` (`SimulateTicks`). Keep it in `tests/.../Physics/TestTransitionFactory.cs`. - [ ] **Step 3: Implement the fsf ladder in `ValidateTransition`** At `TransitionTypes.cs:4581-4597`, replace the stub. Compute `moved` from the retail `arg3` semantics (advanced this call). Then, gated on retail's condition (`(object_info.State & VIEWER/frozen)==0 && object-has-gravity`), run the ladder. Cite pc:272625-272656. ```csharp // retail validate_transition arg3 == "advanced this step". Our analog: CheckPos moved // off CurPos on the OK path (pc:272608 accept-move block). Capture it BEFORE the accept // overwrites CurPos. bool moved = transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos; if (transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos) { sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; sp.CurOrientation = sp.CheckOrientation; for (int i = 0; i < sp.NumSphere; i++) sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos; // cache_global_curr_center sp.SetCheckPos(sp.CurPos, sp.CurCellId); } // ── frames_stationary_fall ladder (retail validate_transition pc:272625-272656) ── // Gate: the mover is not frozen (object_info state bit 4 clear) AND the tested object // has gravity (state 0x400). VIEWER camera sweeps + gravity-less props are exempt. if (!ObjectInfo.HasFrozenBit && ObjectInfo.ObjectHasGravity) { if (moved) { CollisionInfo.FramesStationaryFall = 0; // advanced → reset } else { int fsf = CollisionInfo.FramesStationaryFall; if (fsf == 0) CollisionInfo.FramesStationaryFall = 1; else if (fsf == 1) CollisionInfo.FramesStationaryFall = 2; else { CollisionInfo.FramesStationaryFall = 3; // fsf >= 2 → manufacture UP contact var up = Vector3.UnitZ; // plane through the sphere bottom: d = radius - (center · up) (pc:272639-272643) var gs = sp.GlobalSphere[0]; float d = gs.Radius - Vector3.Dot(gs.Center, up); CollisionInfo.SetContactPlane(new System.Numerics.Plane(up, d), isWater: false); CollisionInfo.ContactPlaneCellId = sp.CheckPos.ObjCellId; if (!ObjectInfo.HasContactBit) // not already Contact (state & 1 == 0) { CollisionInfo.SetCollisionNormal(up); CollisionInfo.CollidedWithEnvironment = true; } } } } ``` Add the `ObjectInfo` accessors used above (`HasFrozenBit`, `ObjectHasGravity`, `HasContactBit`) if absent, reading the retail `object_info.state` / `object.state` bits (frozen = bit 2 of object_info.state per pc:272625 `& 4`; gravity = object state `0x400`; contact = object state `& 1`). Confirm exact acdream field names against `ObjectInfo` in `TransitionTypes.cs`; wire from `PhysicsBody.State` / `TransientState` at `get_object_info` seed time. Run: `dotnet test ... --filter "FullyQualifiedName~FramesStationaryFallTests"` → PASS. Then full Core suite → green. - [ ] **Step 4: Commit** ```bash git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs tests/AcDream.Core.Tests/Physics/TestTransitionFactory.cs git commit -m "feat(#182): un-stub frames_stationary_fall ladder in ValidateTransition (retail 0x0050aa70; addresses TS-3 behavior gap)" ``` --- ## Task 4: Round-trip fsf through PhysicsEngine.ResolveWithTransition (Slice 1c) **Files:** - Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolveWithTransition` seed-in `~:999`, write-out `~:1056-1106`) - Test: `tests/AcDream.Core.Tests/Physics/ResolveFsfRoundTripTests.cs` (create) - [ ] **Step 1: Write failing test** ```csharp [Fact] public void ResolveWithTransition_SeedsAndWritesBackFsf() { var engine = TestPhysics.EmptyEngine(); // no landblock → NO-LANDBLOCK verbatim branch var body = new PhysicsBody { State = PhysicsStateFlags.Gravity }; body.TransientState |= TransientStateFlags.StationaryStop; // fsf seed == 2 // A blocked-in-place move against a wall so the sphere can't advance is needed to // increment; for the seed/writeback round-trip a zero-distance resolve suffices to // prove the seed reaches ci and ci.fsf is written back to body. engine.ResolveWithTransition(body.Position, body.Position, body.CellPosition.ObjCellId, 0.48f, 1.835f, 0.6f, 1.5f, isOnGround: false, body: body, moverFlags: ObjectInfoState.IsPlayer); // seed 0x20 → fsf 2 carried in; a zero-distance no-op resolve leaves it (no advance, // but the NO-LANDBLOCK branch may reset). Assert the field is now populated on body. Assert.True(body.FramesStationaryFall >= 0); // tighten once the branch behavior is pinned } ``` (Refine the assertion once the exact seed/branch interaction is observed — the load-bearing behavior is proven end-to-end by Task 7's crowd replay; this test guards the plumbing exists.) - [ ] **Step 2: Implement seed-in + write-out** At the top of `ResolveWithTransition` where the `Transition`/`CollisionInfo` is initialized (near where `body` seeds ContactPlane in, `~:999`), seed fsf from the body's carried transient bits (retail `transition` 0x00512dc0 pc:280940-947): ```csharp if (body is not null) { // retail transition() seeds collision_info.frames_stationary_fall from the carried // Stationary* transient bits before find_valid_position. pc:280940-280947. if (body.TransientState.HasFlag(TransientStateFlags.StationaryStuck)) ci.FramesStationaryFall = 3; else if (body.TransientState.HasFlag(TransientStateFlags.StationaryStop)) ci.FramesStationaryFall = 2; else if (body.TransientState.HasFlag(TransientStateFlags.StationaryFall)) ci.FramesStationaryFall = 1; else ci.FramesStationaryFall = 0; } ``` At the exit write-back (near `body.SlidingNormal = …`, `~:1106`), publish fsf onto the body so the App-layer consumer can read it: ```csharp if (body is not null) body.FramesStationaryFall = ci.FramesStationaryFall; ``` Run: `dotnet test ... --filter "FullyQualifiedName~ResolveFsfRoundTripTests"` → PASS. Full Core suite → green (no behavior change to locomotion: fsf=0 dormant). - [ ] **Step 3: Commit** ```bash git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/ResolveFsfRoundTripTests.cs git commit -m "feat(#182): round-trip frames_stationary_fall through ResolveWithTransition (seed from transient bits, write back to body)" ``` --- ## Task 5: Port SetPositionInternal + handle_all_collisions into a pure Core unit (Slice 2a) **Files:** - Create: `src/AcDream.Core/Physics/PhysicsObjUpdate.cs` - Test: `tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs` - [ ] **Step 1: Write failing tests for the reflect/zero decision** ```csharp public class HandleAllCollisionsTests { static PhysicsBody Airborne(Vector3 v) => new PhysicsBody { State = PhysicsStateFlags.Gravity, TransientState = TransientStateFlags.None, Velocity = v }; [Fact] public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent() { var b = Airborne(new Vector3(3f, 0f, 0f)); // moving +X into a -X wall var n = new Vector3(-1f, 0f, 0f); // outward normal PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 0, collisionNormalValid: true, collisionNormal: n, prevContact: false, prevOnWalkable: false, nowOnWalkable: false); // dot = 3*-1 = -3 < 0 → v += -(-3*(0.05+1))*(-1,0,0) = v + (-3.15,0,0) → x ≈ -0.15 Assert.True(b.Velocity.X < 0f); } [Fact] public void Fsf2_ZeroesVelocity_TheBleed() { var b = Airborne(new Vector3(0f, 0f, 18f)); // straight-up jump var n = new Vector3(-0.96f, -0.25f, -0.15f); // near-horizontal creature normal PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 2, collisionNormalValid: true, collisionNormal: n, prevContact: false, prevOnWalkable: false, nowOnWalkable: false); Assert.Equal(Vector3.Zero, b.Velocity); // fsf>1 → v=0 → gravity resumes → glide off } [Fact] public void StayingOnWalkable_DoesNotReflect() { var b = new PhysicsBody { Velocity = new Vector3(3f,0f,0f), TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable }; var n = new Vector3(-1f,0f,0f); PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 0, collisionNormalValid: true, collisionNormal: n, prevContact: true, prevOnWalkable: true, nowOnWalkable: true); Assert.Equal(3f, b.Velocity.X); // should_reflect == false → corridor wall-slide unchanged } [Fact] public void Fsf_EncodesIntoTransientBits() { var b = Airborne(new Vector3(0,0,1f)); PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 3, collisionNormalValid: false, collisionNormal: default, prevContact: false, prevOnWalkable: false, nowOnWalkable: false); Assert.True(b.TransientState.HasFlag(TransientStateFlags.StationaryStuck)); } } ``` Run → FAIL (type missing). - [ ] **Step 2: Implement the port** ```csharp using System.Numerics; namespace AcDream.Core.Physics; /// /// Verbatim port of the collision-response tail of retail CPhysicsObj::UpdateObjectInternal: /// SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780). Pure functions over /// a PhysicsBody + the resolve outcome, so the whole chain is unit-testable in Core without the /// App per-frame loop. The transition INTERNALS (ResolveWithTransition and below) are untouched. /// public static class PhysicsObjUpdate { /// /// retail handle_all_collisions (0x00514780, pc:282647). Reflects or zeros the body's /// velocity based on frames_stationary_fall, then re-encodes fsf into the Stationary* /// transient bits. This is the velocity "bleed on block": fsf>1 → velocity = 0. /// public static void HandleAllCollisions( PhysicsBody body, int framesStationaryFall, bool collisionNormalValid, Vector3 collisionNormal, bool prevContact, bool prevOnWalkable, bool nowOnWalkable) { // var_10_1 (pc:282653-282657): reflect UNLESS staying on walkable ground (and not // sledding). Restores retail's broader rule — acdream's AD-25 airborne-only // suppression is retired (the landing-snap fragility it guarded is gone: the landing // state is now owned by ApplyResolvedPosition below, not a Velocity.Z<=0 gate). bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding); bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); if (framesStationaryFall <= 1) { if (shouldReflect && collisionNormalValid) { if (body.State.HasFlag(PhysicsStateFlags.Inelastic)) { body.Velocity = Vector3.Zero; // pc:282720-282722 } else { float dot = Vector3.Dot(body.Velocity, collisionNormal); if (dot < 0f) // moving into surface { float k = -(dot * (body.Elasticity + 1f)); // pc:282712 body.Velocity += collisionNormal * k; } } } } else { body.Velocity = Vector3.Zero; // fsf>1 → THE BLEED (pc:282729) } // encode fsf → transient bits (pc:282737-282758) body.TransientState &= ~(TransientStateFlags.StationaryFall | TransientStateFlags.StationaryStop | TransientStateFlags.StationaryStuck); body.TransientState |= framesStationaryFall switch { 1 => TransientStateFlags.StationaryFall, 2 => TransientStateFlags.StationaryStop, 3 => TransientStateFlags.StationaryStuck, _ => TransientStateFlags.None, }; _ = prevContact; // reserved for report_environment_collision parity (weenie events, later) } } ``` Run → PASS. Commit. - [ ] **Step 3: Commit** ```bash git add src/AcDream.Core/Physics/PhysicsObjUpdate.cs tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs git commit -m "feat(#182): port handle_all_collisions (reflect/zero by frames_stationary_fall) as a pure Core unit" ``` --- ## Task 6: Wire the ported chain into PlayerMovementController, replacing the ad-hoc reflect/land block (Slice 2b — the behavioral fix) **Files:** - Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (`:931-1109`) - Test: `tests/AcDream.App.Tests/` (a controller-level test if the harness exists; otherwise rely on Task 7's Core replay + the visual gate) - [ ] **Step 1: Compute CachedVelocity and route the response through PhysicsObjUpdate** Replace the apply/reflect/land block (`:931-1109`). New sequence (retail `UpdateObjectInternal` tail + `SetPositionInternal`): ```csharp // ── Apply the resolve as retail UpdateObjectInternal does (0x005156b0) ── // cached_velocity = realized displacement / dt (separate from the integrator velocity; // reporting/DR only). pc:005158cb-005158ff. if (physicsTickRan && tickDt > 0f) _body.CachedVelocity = (resolveResult.Position - preIntegratePos) / tickDt; else _body.CachedVelocity = Vector3.Zero; _body.Position = resolveResult.Position; if (physicsTickRan) { _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; PositionManager?.UseTime(); // retail UpdateObjectInternal tail (R5-V3, keep) } // SetPositionInternal (0x00515330): set contact/walkable from the resolved contact plane, // then handle_all_collisions. The contact plane + fsf were written onto _body by // ResolveWithTransition. prevContact/prevOnWalkable are captured BEFORE this block mutates them. bool prevContact = _body.InContact; bool prevOnWalkable = _body.OnWalkable; // Contact bit from contact_plane_valid; then walkable from the plane's slope. (pc:283468-283510) if (_body.ContactPlaneValid) { _body.TransientState |= TransientStateFlags.Contact; bool walkable = _body.ContactPlane.Normal.Z >= PhysicsGlobals.FloorZ; // set_on_walkable if (walkable) _body.TransientState |= TransientStateFlags.OnWalkable; else _body.TransientState &= ~TransientStateFlags.OnWalkable; } else { bool wasOnWalkable = _body.OnWalkable; _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); if (wasOnWalkable) _motion.LeaveGround(); // retail movement_manager->LeaveGround (pc:283494) } _body.calc_acceleration(); bool nowOnWalkable = _body.OnWalkable; PhysicsObjUpdate.HandleAllCollisions( _body, _body.FramesStationaryFall, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, prevContact, prevOnWalkable, nowOnWalkable); // Motion-manager notifications on the grounded/airborne EDGES (acdream keeps these — they // are the CMotionInterp HitGround/LeaveGround hooks the retail chain drives elsewhere). bool justLanded = false; if (nowOnWalkable && !_wasAirborneLastFrame == false /* was airborne */ ) { // wasAirborne && nowOnWalkable → land } if (nowOnWalkable && _wasAirborneLastFrame) { Movement.HitGround(); justLanded = true; } if (!nowOnWalkable && !_wasAirborneLastFrame) _motion.LeaveGround(); _wasAirborneLastFrame = !nowOnWalkable; UpdateCellId(resolveResult.CellId, "resolver"); ``` > **Integration note (verify while writing):** the old block used `resolveResult.IsOnGround` + a `Velocity.Z <= 0` gate to decide landing and to zero `Velocity.Z`. The rebuild derives grounded state from `_body.ContactPlaneValid`/`OnWalkable` (set by `ResolveWithTransition`'s writeback), not `IsOnGround`, and no longer zeros `Velocity.Z` by hand — `handle_all_collisions` (fsf) + `calc_acceleration` (grounded → accel 0) own the settle. Keep `justLanded` feeding whatever downstream (`§6 outbound`, animation) consumed it. Preserve the HitGround-on-land / LeaveGround-on-depart edges exactly; only their *trigger* changes from `IsOnGround && v.z<=0` to `OnWalkable` transitions. - [ ] **Step 2: Build + full suites** Run: `dotnet build` (green), then `dotnet test` (Core + App + UI + Net). Expected: green — ordinary locomotion is fsf=0 dormant; the changed landing trigger must not regress the walk/slope/stairs/jump/fall conformance tests. If any locomotion test regresses, bisect within this task (the landing-trigger change is the prime suspect). - [ ] **Step 3: Commit** ```bash git add src/AcDream.App/Input/PlayerMovementController.cs git commit -m "feat(#182): route player collision response through ported SetPositionInternal+handle_all_collisions (retires the ad-hoc reflect/land block)" ``` --- ## Task 7: Crowd-jump A/B replay conformance test (Slice 2c — measured proof) **Files:** - Test: `tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpReplayTests.cs` (create) - Fixture: a trimmed slice of `acdream-crowd-resolve.jsonl` covering the airborne-stuck frames → `tests/AcDream.Core.Tests/Fixtures/issue182/crowd-airborne-stuck.jsonl` - [ ] **Step 1: Write the replay test (pattern from CellarUpTrajectoryReplayTests)** Seed a `PhysicsBody` from a captured `bodyBefore` at the first airborne-stuck frame (`velocity ≈ (0,0,18)`, near-horizontal collision normal), then drive the ported per-frame chain (Resolve → PhysicsObjUpdate) for N frames and assert the body **leaves the stuck state**: `FramesStationaryFall` climbs past 1 → `Velocity` collapses to ~0 → subsequent frames show downward Z (gravity/glide), not a persisted +Z. Use `SeedBodyFromSnapshot` + `LoadCapturedRecord` from the existing harness. ```csharp [Fact] public void BlockedAirborneJump_BleedsVelocity_WithinThreeFrames() { var rec = LoadCapturedRecord(r => r.BodyBefore is { } b && b.Velocity.Z > 15f && r.Result.CollisionNormalValid && MathF.Abs(r.Result.CollisionNormal.Z) < 0.5f); var body = SeedBodyFromSnapshot(rec.BodyBefore); // simulate the blocked frames against the same captured target — the sphere cannot // advance, so fsf climbs 0→1→2 and handle_all_collisions zeros velocity at fsf>1. for (int frame = 0; frame < 3; frame++) SimulateBlockedFrame(body, rec.Input); // Resolve + PhysicsObjUpdate, target == captured target Assert.True(body.Velocity.Length() < 1.0f, $"velocity did not bleed: {body.Velocity}"); Assert.True(body.FramesStationaryFall >= 2); } ``` Run → PASS. - [ ] **Step 2: Whole-capture A/B measurement (manual gate, not a unit test)** Rebuild the client, reproduce the crowd-jump repro live with `ACDREAM_CAPTURE_RESOLVE=after-crowd.jsonl`, then: ``` py tools/analyze_resolve_capture.py after-crowd.jsonl ``` **Gate:** `airborne-stuck → 0`, and `ok%` climbs toward retail's ~78% (from 50.9%). Record before/after in the commit message / ISSUES #182. If `ok% << 78%` with airborne-stuck at 0, that is the Slice 3 residual (§7 Q3) — proceed to measure TS-4. - [ ] **Step 3: Commit** ```bash git add tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpReplayTests.cs tests/AcDream.Core.Tests/Fixtures/issue182/ git commit -m "test(#182): crowd-jump airborne-stuck replay — blocked jump bleeds velocity within 3 frames" ``` --- ## Task 8: Register bookkeeping + docs (same commit boundary as Task 6/7) **Files:** - Modify: `docs/architecture/retail-divergence-register.md` - Modify: `docs/ISSUES.md`, `claude-memory/project_physics_collision_digest.md` - [ ] **Step 1: Retire / amend rows** - **Retire TS-3** (`:195`) — the fsf accounting is now ported (delete the row; note the retirement in the TS-section header line like the TS-45 note). - **Amend AD-25** (`:88`) — the player-half reflect suppression is gone (retail's broader rule restored via `handle_all_collisions`). AD-25 **still covers the remote-DR sweep** in `GameWindow.cs` (#173), which is out of this arc's scope. Rewrite AD-25 to reference only the remote-DR site + note the player half was retired by this rebuild; OR split into a new AD row for the remote half. Do NOT delete (the remote sweep still suppresses). - **Add** any adaptation this port introduces: e.g. `CachedVelocity` is computed but not yet consumed by the wire (reporting still uses the old path) — a small AP row if the wire velocity differs from retail's cached_velocity source; the fsf gate's `HasFrozenBit`/`ObjectHasGravity` derivation if it approximates retail's exact bits. - [ ] **Step 2: Update ISSUES + digest** - `docs/ISSUES.md`: move #182 forward (airborne-stuck fixed; note the A/B numbers; residual ground-jam tracked if <78%). - `claude-memory/project_physics_collision_digest.md`: replace the 2026-07-07 top banner's "designed (deferred)" with "SHIPPED" + the fsf mechanism + the A/B result + a DO-NOT-RETRY note ("the bleed is fsf>1→v=0 in handle_all_collisions, NOT cached_velocity; calc_friction 0.0 vs retail 0.25 is a SEPARATE L.3c divergence, do not fold it in"). - [ ] **Step 3: Commit** (fold into Task 6 or 7's commit if landing together, or a trailing docs commit). --- ## Task 9: STOP for the user visual gate Do **not** proceed to Slice 3 without the user's visual verification. Present: 1. **Crowd glide/land** (the #182 symptom): jump into a monster crowd — does the player land/glide across the tops and shuffle out, like retail? 2. **Normal-locomotion regression pass:** flat walking, slopes, stairs, jumping, falling, wall-slide — unchanged? Only the user can confirm these. The suites + A/B histogram are necessary but not sufficient. --- ## Task 10 (conditional): Slice 3 — residual ground-jam Only if Task 7's A/B shows `ok% << 78%` with airborne-stuck at 0. Measure which bucket dominates (partial vs stuck) and correlate against `slidingNormal` provenance. Prime suspect: **TS-4** (BSPQuery Path-6 steep persisted sliding-normal → #137 anti-parallel-absorb). That is in the *kept* internals — a separate, decomp-anchored fix (brainstorm a sub-slice with the user before touching it, per the roadmap-divergence rule). Do not pre-emptively touch it. --- ## Self-Review **Spec coverage (design §4.1 functions):** - `UpdateObjectInternal` chain → Task 6 (consolidated in the controller) + the retail-pseudocode appendix cited in code. - `UpdatePositionInternal`/`calc_acceleration` → already present in `PhysicsBody`; the OnWalkable-gate divergence fixed in Task 2. `calc_friction` explicitly out of scope (documented, orthogonal L.3c divergence). - `handle_all_collisions` → Task 5 (pure unit) + Task 6 (wired). **Design §7 Q1 answered: retail reflects (fsf≤1) then zeros (fsf>1).** - `SetPositionInternal` (0x00515330 confirmed, §7 Q2) → Task 6. - `transition`/`ResolveWithTransition` reused as-is; fsf seed/writeback added (Task 4) — the only touch to the kept-internals seam, justified as completing the TS-3 stub. - The velocity-from-movement recompute (§4.2) → `CachedVelocity` (Task 6), correctly kept separate from the integrator per the verified two-velocity model. **Staging (design §4.5):** Slice 0 (measurement) → Slice 1 (fsf substrate: Tasks 2-4, dormant in locomotion) → Slice 2 (the fix: Tasks 5-7, A/B gate) → visual gate → Slice 3 (conditional residual). The design's "airborne-stuck→0 after Slice 1" gate correctly moves to Slice 2 (Task 7) — a direct consequence of the corrected mechanism. **Type consistency:** `FramesStationaryFall` (int) and `CachedVelocity` (Vector3) on `PhysicsBody`; `TransientStateFlags.Stationary{Fall,Stop,Stuck}` = 0x10/0x20/0x40; `PhysicsObjUpdate.HandleAllCollisions(body, fsf, cnValid, cn, prevContact, prevOnWalkable, nowOnWalkable)` — signature used identically in Tasks 5 & 6. **Placeholder scan:** two spots are marked "verify while writing" (the `ObjectInfo` frozen/gravity/contact bit accessors in Task 3; the landing-edge wiring in Task 6) — these are genuinely dependent on exact acdream `ObjectInfo`/controller field names to be read at implementation time, not hand-wavy gaps; the retail semantics + pc anchors are pinned. All code steps show concrete code. **Risk (design §6):** replaces the core of every jump/fall/step. Mitigations honored: transition internals untouched (except the TS-3 stub completion), fsf dormant in ordinary locomotion, measured A/B before the visual gate, and a hard stop for visual verification.