From 8bb8b20411bff29bb74e83f38b5b56f4d94987b9 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 7 Jul 2026 13:37:15 +0200 Subject: [PATCH] tools+plan(#182): resolve-capture histogram classifier + verbatim player-physics rebuild plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...-player-physics-update-verbatim-rebuild.md | 908 ++++++++++++++++++ tools/analyze_resolve_capture.py | 94 ++ 2 files changed, 1002 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md create mode 100644 tools/analyze_resolve_capture.py 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")