diff --git a/docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md b/docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md
new file mode 100644
index 00000000..a599e0f5
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md
@@ -0,0 +1,908 @@
+# 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.
diff --git a/tools/analyze_resolve_capture.py b/tools/analyze_resolve_capture.py
new file mode 100644
index 00000000..c369b290
--- /dev/null
+++ b/tools/analyze_resolve_capture.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""Classify ACDREAM_CAPTURE_RESOLVE JSONL into OK / partial / stuck / airborne-stuck.
+
+The A/B measurement instrument for the #182 verbatim player-physics rebuild
+(docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md).
+
+Each JSONL record (PhysicsResolveCapture.ResolveCaptureRecord) has:
+ input.{currentPos,targetPos,cellId,...}, bodyBefore/bodyAfter (PhysicsBodySnapshot
+ incl. velocity, slidingNormal, transientState), result.{position,cellId,isOnGround,
+ collisionNormalValid,collisionNormal}. Vector3 = {x,y,z}.
+
+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 (retail SLID)
+ stuck reverted: moved <= EPS_MOVE (retail COLLIDED-revert)
+ airborne-stuck subset of stuck: bodyBefore airborne w/ jump velocity into a
+ near-horizontal collision normal (the falling-animation wedge)
+
+Retail target (tools/cdb/retail-crowd-jump3.cdb):
+ ~78% OK, 12.7% COLLIDED (~stuck), 8.8% SLID (~partial), 0 airborne-stuck.
+
+Usage: py tools/analyze_resolve_capture.py [capture.jsonl]
+"""
+import sys
+import json
+import math
+
+EPS_REACH = 0.02 # 2 cm — "reached target"
+EPS_MOVE = 0.01 # 1 cm — "advanced at all"
+
+
+def dist(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 dist(i["targetPos"], i["currentPos"]) <= EPS_MOVE:
+ return None # zero-motion rest tick — not a move-intent record
+ r = rec["result"]
+ moved = dist(r["position"], i["currentPos"])
+ reached = dist(r["position"], i["targetPos"]) <= EPS_REACH
+ if reached:
+ return "ok"
+ if moved > EPS_MOVE:
+ return "partial"
+ # reverted / stuck. airborne-stuck = a stuck frame while AIRBORNE (OnWalkable bit clear):
+ # the "stuck in the falling animation" wedge. The near-horizontal creature normal is the
+ # cause and the jump velocity the symptom, but the sliding normal flips frame-to-frame so
+ # neither is reliable every frame — the airborne (not-grounded) revert is the stable marker.
+ bb = rec.get("bodyBefore") or {}
+ on_walkable = bb.get("transientState", 0) & 0x2 # TransientStateFlags.OnWalkable
+ if not on_walkable:
+ return "airborne-stuck"
+ return "stuck"
+
+
+def main(path):
+ counts = {"ok": 0, "partial": 0, "stuck": 0, "airborne-stuck": 0}
+ total_move = 0
+ total_records = 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
+ total_records += 1
+ c = classify(rec)
+ if c is None:
+ continue
+ total_move += 1
+ if c == "airborne-stuck":
+ counts["airborne-stuck"] += 1
+ counts["stuck"] += 1 # airborne-stuck is a subset of stuck for the % columns
+ else:
+ counts[c] += 1
+
+ print(f"total records: {total_records}")
+ 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")