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>
This commit is contained in:
parent
80881ed6f1
commit
8bb8b20411
2 changed files with 1002 additions and 0 deletions
|
|
@ -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
|
||||
/// <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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue