acdream/docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md
Erik 8bb8b20411 tools+plan(#182): resolve-capture histogram classifier + verbatim player-physics rebuild plan
Slice 0 of the #182 verbatim rebuild. The classifier reproduces the design
baseline off acdream-crowd-resolve.jsonl (2883 move-intent resolves:
52.8% OK / 25.1% partial / 22.1% stuck / 107 airborne-stuck) — the A/B
'before' the rebuild measures against (retail target ~78% OK, 0 airborne-stuck).

The plan refines the design spec's §7: the airborne-stuck bleed is the
frames_stationary_fall counter (validate_transition increments; handle_all_collisions
zeros velocity at fsf>1), NOT the cached_velocity field (a separate reporting value).
Slices reorder accordingly; calc_friction (retail 0.25 vs acdream 0.0) is an
orthogonal L.3c divergence kept out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:37:15 +02:00

908 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = (resolvedold)/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
/// <summary>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.</summary>
public Vector3 CachedVelocity { get; set; }
/// <summary>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.</summary>
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;
/// <summary>
/// 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.
/// </summary>
public static class PhysicsObjUpdate
{
/// <summary>
/// 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.
/// </summary>
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.