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>
50 KiB
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
UpdateObjectInternalchain is currently spread across: §4 integrate (:820-877,_body.UpdatePhysicsInternal(tickDt)integratingPositionin-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): singleVelocityfield (playsm_velocityVector),calc_acceleration/UpdatePhysicsInternal/calc_friction/set_velocityports present. NoCachedVelocity.TransientStateFlags= Contact/OnWalkable/Sliding/Active only (no 0x10/0x20/0x40).UpdatePhysicsInternalhas a divergence: the small-velocity-zero at:503-504is gated onOnWalkable(retail is ungated).ResolveResult(src/AcDream.Core/Physics/ResolveResult.cs:21):readonly record structwithPosition, CellId, IsOnGround, CollisionNormalValid, CollisionNormal, Ok. Rich state (ContactPlane, SlidingNormal) flows back through the passedbodyside-effect (PhysicsEngine.cs:999-1015seed-in,:1056-1106write-out).FramesStationaryFallis 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.FramesStationaryFallfield at:245(dead). All other retail COLLISIONINFO fields have counterparts.- Apparatus:
PhysicsResolveCapture(src/AcDream.Core/Physics/PhysicsResolveCapture.cs), toggled byACDREAM_CAPTURE_RESOLVE, IsPlayer-filtered atPhysicsEngine.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}.
#!/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
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
[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):
[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):
/// <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):
// 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
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; themovedsignal 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.
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.GravityMoverhelper
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.
// 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
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(ResolveWithTransitionseed-in~:999, write-out~:1056-1106) -
Test:
tests/AcDream.Core.Tests/Physics/ResolveFsfRoundTripTests.cs(create) -
Step 1: Write failing test
[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):
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:
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
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
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
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
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):
// ── 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+ aVelocity.Z <= 0gate to decide landing and to zeroVelocity.Z. The rebuild derives grounded state from_body.ContactPlaneValid/OnWalkable(set byResolveWithTransition's writeback), notIsOnGround, and no longer zerosVelocity.Zby hand —handle_all_collisions(fsf) +calc_acceleration(grounded → accel 0) own the settle. KeepjustLandedfeeding whatever downstream (§6 outbound, animation) consumed it. Preserve the HitGround-on-land / LeaveGround-on-depart edges exactly; only their trigger changes fromIsOnGround && v.z<=0toOnWalkabletransitions.
- 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
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.jsonlcovering 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.
[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
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 viahandle_all_collisions). AD-25 still covers the remote-DR sweep inGameWindow.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.
CachedVelocityis 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'sHasFrozenBit/ObjectHasGravityderivation 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:
- Crowd glide/land (the #182 symptom): jump into a monster crowd — does the player land/glide across the tops and shuffle out, like retail?
- 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):
UpdateObjectInternalchain → Task 6 (consolidated in the controller) + the retail-pseudocode appendix cited in code.UpdatePositionInternal/calc_acceleration→ already present inPhysicsBody; the OnWalkable-gate divergence fixed in Task 2.calc_frictionexplicitly 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/ResolveWithTransitionreused 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.