feat(physics): #32 L.5 30Hz physics tick + retail debugger toolchain (#35) + Phase 3 retail-faithful kill_velocity

Three intertwined changes from a single investigation session driven by
attaching cdb to a live retail acclient.exe (v11.4186, Sept 2013 EoR
build) and tracing what retail actually DOES on the steep-roof wedge
scenario the user reported in acdream.

═══════════════════════════════════════════════════════════
1. L.5 — physics-tick MinQuantum gate (PlayerMovementController)
═══════════════════════════════════════════════════════════

Retail's CPhysicsObj::update_object subdivides per-frame dt into 1/30 s
sized integration steps and SKIPS entirely when accumulated dt is below
MinQuantum. Live trace evidence:

  update_object        = 40,960 calls
  UpdatePhysicsInternal = 25,087 calls   (61%)

i.e., 39% of update_object calls return early via the MinQuantum gate.
Retail's effective physics tick rate is 30Hz even at 60+ Hz render.

acdream's PlayerMovementController bypassed the existing PhysicsBody.
update_object and called UpdatePhysicsInternal(dt) directly each render
frame, which compressed bounce-energy / gravity-tangent accumulation
into half the time and amplified our steep-roof wedge dynamics.

Fix: add `_physicsAccum` accumulator. Integrate only when accumulated
dt ≥ MinQuantum (clamped to MaxQuantum to bound stale-frame jumps).
HugeQuantum drops accumulated time to discard truly stale frames
(debugger break, GC pause). Render still runs at full rate; only the
physics step is gated.

═══════════════════════════════════════════════════════════
2. Phase 3 reset retail-faithful kill_velocity (TransitionTypes)
═══════════════════════════════════════════════════════════

Retail's reset path (acclient_2013_pseudo_c.txt:273231-273239) gates
kill_velocity on `last_known_contact_plane_valid`:

  if (last_known_valid == 0) {
      set_collision_normal(step_up_normal); return COLLIDED;
  }
  kill_velocity(this);
  last_known_valid = 0;
  return COLLIDED;

Earlier in this session I deviated to "unconditional kill_velocity" as
a hypothesis-driven wedge fix. The live trace then showed the
deviation CAUSED a different wedge by zeroing V every frame, leaving
the body with no tangent momentum to escape (V = (0,0,0) for 169
consecutive frames while position pre/resolved frozen). The retail-
faithful gate is restored.

Note: the gate rarely fires in normal airborne play because our L.2.4
proximity guard clears last_known_valid soon after the body separates
from its remembered floor. Live retail trace also showed
kill_velocity = 0 hits over an entire play session — same behavior. So
acdream's kill_velocity is correct as ported now.

The supporting ObjectInfo.VelocityKilled flag + StopVelocity wiring +
PhysicsEngine.ResolveWithTransition consumer that actually zeros
body.Velocity when the flag is set — these were a no-op stub before
this session and are now correctly wired. Retail anchor:
OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) at
acclient_2013_pseudo_c.txt:274467-274475.

═══════════════════════════════════════════════════════════
3. Retail debugger toolchain (#35)
═══════════════════════════════════════════════════════════

When the question is "what does retail actually DO at runtime?" — not
"what does retail's code SAY" — the decomp at docs/research/named-retail/
is invaluable but doesn't capture state interactions across frames.
This commit ships infrastructure to attach Windows' cdb.exe to a live
retail acclient.exe with full PDB symbols and capture state at any
breakpoint.

  - tools/pdb-extract/check_exe_pdb.py — reads any PE's CodeView entry
    and reports MATCH / MISMATCH against refs/acclient.pdb's GUID.
    Always run before attaching cdb. The matching v11.4186 build's
    GUID is 9e847e2f-777c-4bd9-886c-22256bb87f32.

  - tools/pdb-extract/dump_pdb_info.py — dumps a PDB's expected
    build timestamp + GUID + age. Used to figure out which acclient.exe
    build pairs with our PDB.

CLAUDE.md gets a Step -1 in the development workflow ("ATTACH cdb
TO RETAIL when behavior is the question, not code") and a full
"Retail debugger toolchain" section with the workflow, sample .cdb
script structure, and watchouts (PDB names use snake_case for some
classes / PascalCase for CPhysicsObj; ; is cdb's command separator;
killing cdb kills the debuggee; high-hit-rate breakpoints lag the game).

memory/project_retail_debugger.md captures the workflow + key findings
so future sessions inherit the toolchain by reading project memory.

═══════════════════════════════════════════════════════════
4. BSPQuery Path 6 slide-tangent restored (b1af56e behavior)
═══════════════════════════════════════════════════════════

After this session's retail-strict experiments showed that retail-
faithful Path 6 (SetCollide + Phase 3 reset chain) produces a
"lands on roof in falling animation, can't slide off" half-state in
acdream — because our acdream port of step_up_slide / cliff_slide is
incomplete for grounded-on-steep movement — the L.4 slide-tangent
deviation from commit b1af56e is restored as the pragmatic ship state.

The deviation: when an airborne sphere hits a polygon whose normal Z
is below FloorZ (≈ 0.6642, slope > ~49°), project the move along the
steep face to remove the into-wall displacement, set CollisionNormal +
SlidingNormal, return Slid. Body never gets ContactPlane on the steep
poly, never gets the half-state, slides off the slope under gravity's
tangent contribution.

Retail-strict requires the deeper step_up_slide / cliff_slide audit
(filed under #32). Until that lands, slide-tangent is the right
deviation — produces user-acceptable "slide off the roof" behavior.

═══════════════════════════════════════════════════════════
Test status: 833/833 green.

Refs:
  acclient_2013_pseudo_c.txt:283950 (CPhysicsObj::update_object)
  acclient_2013_pseudo_c.txt:273231-273239 (Phase 3 reset path)
  acclient_2013_pseudo_c.txt:274467-274475 (OBJECTINFO::kill_velocity)
  acclient_2013_pseudo_c.txt:323783-323821 (BSPTREE::find_collisions Path 6)

Closes #35. Updates #32 with L.4/L.5 status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-30 22:41:12 +02:00
parent b1af56eb19
commit 235de3322a
8 changed files with 624 additions and 82 deletions

132
CLAUDE.md
View file

@ -164,10 +164,22 @@ The triangle-boundary Z bug cost 5 failed fix attempts from guessing.
The animation frame-swap bug cost 4 failed attempts. Every time we The animation frame-swap bug cost 4 failed attempts. Every time we
checked the decompiled code first, we got it right on the first try. checked the decompiled code first, we got it right on the first try.
**Now we have named retail symbols too — Step 0 cuts most lookups **Now we have named retail symbols too — Step 0 cuts most lookups
from 30 minutes to 5 seconds.** from 30 minutes to 5 seconds. And as of 2026-04-30, when "what does
retail actually DO at runtime?" is the question and decomp alone
isn't enough, attach cdb to a live retail client (Step -1).**
### For each new feature or bug fix: ### For each new feature or bug fix:
-1. **ATTACH cdb TO RETAIL (when behavior is the question, not code).**
For "what does retail actually DO frame-by-frame?" questions —
wedges, weird animation flicker, geometry-specific bugs, anything
where the decomp is correct but it's not clear how it produces the
visible behavior — **don't guess; attach the Windows debugger to
a live retail client and trace it.** See "Retail debugger toolchain"
below for setup. We discovered the steep-roof wedge had a 30Hz
physics-tick cause this way; would have taken weeks of guessing
without the trace.
0. **GREP NAMED FIRST.** Before any decompilation work, search 0. **GREP NAMED FIRST.** Before any decompilation work, search
`docs/research/named-retail/acclient_2013_pseudo_c.txt` by `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
`class::method` name. 99.6% of functions have real names from the `class::method` name. 99.6% of functions have real names from the
@ -249,6 +261,124 @@ Before marking any phase as done:
- [ ] Roadmap updated - [ ] Roadmap updated
- [ ] Memory updated if there's a durable lesson - [ ] Memory updated if there's a durable lesson
## Retail debugger toolchain (live runtime trace)
**When the question is "what does retail actually DO frame-by-frame?"**
the decomp alone is often not enough — code paths interact with state
(LastKnownContactPlane, transient flags, accumulated counters) in ways
that aren't obvious from reading. As of 2026-04-30 we have a working
toolchain to attach Windows' console debugger (cdb.exe) to a live
retail acclient.exe with full PDB symbols and capture state at any
breakpoint. **Use this when guessing has failed twice in a row.**
### What we have
- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe`
v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs
exactly with our `refs/acclient.pdb`.
- **Debugger**: `cdb.exe` (console WinDbg) at
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
Install via Microsoft Store WinDbg (~50 MB). 32-bit version is
required for acclient.exe.
- **PDB**: `refs/acclient.pdb` (29 MB, Sept 2013 EoR build).
18,366 named functions + 5,371 named struct types resolve.
- **Symbol verifier**: `tools/pdb-extract/check_exe_pdb.py <exe>`
reads any acclient.exe and prints whether it pairs with our PDB
(`MATCH` / `MISMATCH (expected GUID = ...)`). Always run this on
a candidate binary BEFORE attaching.
- **PDB metadata dumper**: `tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb`
prints the PDB's expected timestamp + GUID + age. Use to figure
out which build to look for if the chain ever breaks.
### Workflow
1. **Verify the binary matches the PDB:**
```bash
py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"
```
Expect: `=== MATCH: this exe pairs with our acclient.pdb ===`
2. **Have the user launch retail client** and connect to local ACE.
Retail must already be in-world before attaching.
3. **Write a `.cdb` script** that arms breakpoints with non-blocking
actions (count + log + `gc`). Pattern:
```
.logopen <output-path>
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
bp acclient!CTransition::transitional_insert "r $t0 = @$t0 + 1; .if (@$t0 % 5000 == 0) { .printf \"...\" }; .if (@$t0 >= 30000) { qd } .else { gc }"
bp acclient!OBJECTINFO::kill_velocity "r $t1 = @$t1 + 1; gc"
...
g
```
`gc` = "go conditional" (continue without breaking). Auto-detach
via `qd` after a hit-count threshold to avoid manual cleanup.
4. **Launch cdb in the background** via a PowerShell wrapper:
```powershell
& "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" `
-pn acclient.exe -cf <script>.cdb *>&1 |
Tee-Object -FilePath <log>
```
5. **User reproduces the scenario** in the retail client window
(jump on roof, hit wall, etc.). Breakpoints fire, log fills.
6. **cdb auto-detaches** when the threshold breakpoint fires `qd`.
Retail keeps running unaffected. Read the log offline.
### Known watchouts
- **PDB function names use snake_case for some classes**
(BSPTREE, CTransition, OBJECTINFO, COLLISIONINFO, SPHEREPATH) and
**PascalCase for others** (CPhysicsObj). The Binary Ninja decomp
shows snake_case for everything; the PDB has Turbine's actual
PascalCase for CPhysicsObj. Always look up symbols with `x` first
to find the actual name.
- **`bp acclient!Class::method`** sets a breakpoint by symbol. The
cdb command parser splits on `;`, so don't put `;` inside the
action string — use newlines or escape carefully.
- **Symbol path: do NOT use `.sympath srv*<server>;<local>`** — the
`;` is a cdb command separator, gets split. Use `.sympath <local>`
(no symbol server, just our refs/) since we don't need Microsoft
system DLL symbols.
- **Killing cdb kills the debuggee.** Use `qd` (quit detached) inside
a breakpoint action to detach cleanly. `Stop-Process cdb` will
take the retail client down with it.
- **High breakpoint hit rates produce game lag.** Each breakpoint hit
traps the process briefly. For frequent functions
(transitional_insert at ~10K/sec) the cumulative cost is enough to
make retail feel sluggish. Mitigate by setting a tight auto-detach
threshold (e.g., 30,000 hits) and/or moving counters to less-frequent
functions.
- **acclient.exe is 32-bit + uses thiscall.** When dumping struct
fields in breakpoint actions, `this` is in `ecx`. Use cdb's
`dt acclient!ClassName @ecx` for full struct dump.
### When NOT to use this
- **Pure code-port questions** — the decomp at `docs/research/named-retail/`
has the answer. Don't waste time on cdb if `grep` is enough.
- **Visual / rendering bugs** — debugger doesn't help with shaders or
framebuffers; use RenderDoc or similar.
- **Network protocol questions**`holtburger` references + ACE source
+ Wireshark are the right tools, not cdb.
This toolchain was used to settle the L.5 steep-roof investigation:
30Hz physics tick (vs our 60Hz), `kill_velocity` gating,
`set_collide` rate per minute. See commit history around 2026-04-30
for the trace data and the decisions it drove.
## Subagent policy ## Subagent policy
Subagents are the primary tool for saving parent-context and keeping one Subagents are the primary tool for saving parent-context and keeping one

View file

@ -196,11 +196,44 @@ before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results
now feed the `TransitionalInsert` retry loop instead of being reverted by outer now feed the `TransitionalInsert` retry loop instead of being reverted by outer
validation, and a synthetic diagonal terrain-boundary test covers tangent validation, and a synthetic diagonal terrain-boundary test covers tangent
motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had
polygon context. Remaining gaps: live visual confirmation of the retry-loop polygon context.
fix, real-DAT building-edge fixtures, fuller `cliff_slide` coverage, and
`NegPolyHit` dispatch. Named retail anchors include `CTransition::edge_slide`, **L.4/L.5 update 2026-04-30:** A retail debugger trace (cdb attached to
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and v11.4186 acclient.exe — see #35) confirmed that retail does NOT wedge
`SPHEREPATH::step_up_slide`. on the steep-roof scenario that produces the wedge in our acdream port.
Three concrete findings:
1. Retail's `OBJECTINFO::kill_velocity` rarely fires in normal play —
gated on `last_known_contact_plane_valid`, which our L.2.4 proximity
guard tends to clear before steep-poly hits land. Retail trace: 0
kill_velocity hits across 40,960 update_object calls. Our Phase 3
reset path now matches retail's gate (only kills when valid).
2. Retail integrates physics at 30Hz (`MinQuantum = 1/30 s`); render is
60+ Hz. UpdatePhysicsInternal/update_object ratio = 0.61. We
ported this gate as L.5 in `PlayerMovementController` via
`_physicsAccum`. Render still runs at 60+ Hz; only the physics
integration step is 30Hz.
3. The remaining wedge cause — body's pre-position drifts to the
polygon's tangent and gravity's tangent component into surface
produces a stable retain-collide-revert loop — is a downstream
consequence of retail's grounded-on-steep escape chain
(`step_sphere_up``step_up_slide``cliff_slide`) being
incompletely ported. Live test confirmed retail-strict Path 6
produces "lands on roof in falling animation, can't slide off"
half-state because that chain doesn't produce smooth descent.
**Pragmatic ship-state:** BSPQuery Path 6 keeps the L.4 slide-tangent
deviation (project-along-steep-face-and-return-Slid) for steep-poly
airborne hits. It produces user-acceptable "slide off the roof"
behavior at the cost of departing from retail's Path 6 → SetCollide →
Path 4 → Phase 3 reset chain. Retail-strict requires the
step_up_slide / cliff_slide audit below; until that lands, slide-tangent
is the right deviation.
Remaining gaps: real-DAT building-edge fixtures, fuller `cliff_slide`
coverage, `NegPolyHit` dispatch, and the retail-strict
step_up_slide / cliff_slide audit (filed for follow-up). Named retail
anchors include `CTransition::edge_slide`, `CTransition::cliff_slide`,
`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`.
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`, **Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
`src/AcDream.Core/Physics/BSPQuery.cs`, `src/AcDream.Core/Physics/BSPQuery.cs`,
@ -214,6 +247,52 @@ cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
--- ---
## #35 — [DONE 2026-04-30] Retail debugger toolchain (cdb + PDB GUID matching)
**Status:** DONE
**Severity:** N/A (infrastructure)
**Filed + closed:** 2026-04-30
**Component:** tooling / research
**Description:** When the question is "what does retail actually DO at
runtime?" — wedges, animation flicker, geometry-specific bugs where the
decomp is correct but the visible behavior is mysterious — there was no
way to attach a debugger to a live retail acclient.exe and trace it.
This issue tracks the toolchain that closed that gap.
**What shipped:**
- **`tools/pdb-extract/check_exe_pdb.py`** — reads any PE's CodeView entry
and reports `MATCH` / `MISMATCH (expected GUID = …)` against our
`refs/acclient.pdb`. Always run before attaching cdb.
- **`tools/pdb-extract/dump_pdb_info.py`** — dumps a PDB's expected
build timestamp + GUID + age. Used to figure out which acclient.exe
build pairs with our PDB (answer: v11.4186, Sept 2013 EoR).
- **CLAUDE.md "Retail debugger toolchain" section** — full workflow:
cdb path, sample `.cdb` script, PowerShell wrapper pattern, watchouts
(PDB name conventions, `;` parsing, kill-target-on-detach behavior,
high-hit-rate lag).
- **Step `-1` added to the development workflow** — "ATTACH cdb TO
RETAIL (when behavior is the question, not code)". Tells future
sessions: when guessing has failed twice in a row, don't keep guessing.
**Discoveries this toolchain enabled (closed in same session):**
- Retail integrates physics at 30Hz (`UpdatePhysicsInternal/update_object`
ratio = 0.61). Drove the L.5 fix in PlayerMovementController.
- `OBJECTINFO::kill_velocity` rarely fires in normal play (gated on
last_known_contact_plane_valid). Our acdream port now matches.
- Retail does NOT wedge on the steep-roof scenario. Confirmed our L.4
slide-tangent deviation in Path 6 is necessary until the retail
step_up_slide / cliff_slide chain audit lands.
**Files:** `tools/pdb-extract/check_exe_pdb.py`,
`tools/pdb-extract/dump_pdb_info.py`, `CLAUDE.md`,
`memory/project_retail_debugger.md`.
**Acceptance:** Future sessions can attach cdb to a live retail client
in under 5 minutes by following the CLAUDE.md workflow.
---
## #33 — Live entity collision shape collapses to one cylinder ## #33 — Live entity collision shape collapses to one cylinder
**Status:** OPEN **Status:** OPEN

View file

@ -172,6 +172,32 @@ public sealed class PlayerMovementController
public const float HeartbeatInterval = 0.2f; // 200ms public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; } public bool HeartbeatDue { get; private set; }
// L.5 retail physics-tick gate (2026-04-30).
//
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
// accumulated dt is below MinQuantum. The retail debugger trace
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
// update_object — i.e., retail's effective physics tick rate is 30Hz
// even when the renderer runs at 60+Hz.
//
// Without this gate our acdream integrates at the full render rate
// (60+Hz), which compresses bounce-energy / gravity-tangent
// accumulation into half the time. Per-frame V grows ~2x faster than
// retail's. On a steep-slope tangent that produces the wedge: V grows
// tangent + huge while position reverts each frame, body locks in
// place. Retail's slower integration cadence (and larger per-tick
// position deltas) lets the body geometrically escape the tangent.
//
// Source: retail debugger trace 2026-04-30
// update_object = 40,960 calls
// UpdatePhysicsInternal = 25,087 calls (61%)
// ratio implies 39% of frames return early via the MinQuantum gate.
//
// ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
private float _physicsAccum;
public PlayerMovementController(PhysicsEngine physics) public PlayerMovementController(PhysicsEngine physics)
{ {
_physics = physics; _physics = physics;
@ -411,9 +437,34 @@ public sealed class PlayerMovementController
} }
// ── 4. Integrate physics (gravity, friction, sub-stepping) ──────────── // ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
//
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
// update_object skips integration when accumulated dt is below
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
// render. We accumulate per-frame dt and only integrate (with the
// accumulated dt) when the threshold is reached. See _physicsAccum
// declaration for the full retail trace evidence.
var preIntegratePos = _body.Position; var preIntegratePos = _body.Position;
_body.calc_acceleration(); _physicsAccum += dt;
_body.UpdatePhysicsInternal(dt);
if (_physicsAccum > PhysicsBody.HugeQuantum)
{
// Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f;
}
else if (_physicsAccum >= PhysicsBody.MinQuantum)
{
// Integrate accumulated dt, clamped to MaxQuantum so a long
// pause doesn't produce one giant integration step.
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt;
}
// Else: dt below MinQuantum threshold — skip integration. Position
// and velocity remain unchanged; Resolve below runs as a zero-distance
// sphere sweep (no collision possible) and the rest of the frame
// (motion commands, animation, return) runs normally.
var postIntegratePos = _body.Position; var postIntegratePos = _body.Position;
// ── 5. Collision resolution via CTransition sphere-sweep ───────────── // ── 5. Collision resolution via CTransition sphere-sweep ─────────────
@ -520,32 +571,6 @@ public sealed class PlayerMovementController
? !(prevOnWalkable && nowOnWalkable) ? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable); : (!prevOnWalkable && !nowOnWalkable);
// L.4-steep-landing-bounce (2026-04-30): also bounce on
// landing IF the contact surface is upward-facing but
// steeper than walkable (FloorZ ≈ 49°). Per retail and the
// user's expectation: jumping onto a steep roof should NOT
// result in a landing — the player should bounce off, keep
// the falling animation, and slide off.
//
// Without this: the L.3a base rule suppresses the bounce on
// landing transitions (prev air → now ground) to avoid
// micro-bounce on flat terrain, but that suppression also
// sticks the player to too-steep roofs they shouldn't land
// on. This carve-out re-enables the bounce specifically for
// steep upward-facing surfaces.
//
// Range `0 < N.Z < FloorZ` means "facing upward but too
// steep" — excludes walls (N.Z ≈ 0) which are handled by the
// existing prevAirborne+nowAirborne rule, and ceilings
// (N.Z < 0) which the body shouldn't bounce off the same way.
if (!applyBounce
&& resolveResult.CollisionNormalValid
&& resolveResult.CollisionNormal.Z > 0f
&& resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ)
{
applyBounce = true;
}
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug. // L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"; bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep && resolveResult.CollisionNormalValid) if (diagSteep && resolveResult.CollisionNormalValid)

View file

@ -1473,6 +1473,27 @@ public static class BSPQuery
if (changed && hitPoly is not null) if (changed && hitPoly is not null)
{ {
// ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale // ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale
//
// L.4 retail-strict (2026-04-30): the FloorZ gate previously
// here was REMOVED after the retail debugger trace + acdream
// wedge analysis showed it was preventing the natural escape
// path. Retail's flow:
// Frame N: airborne sphere hits steep poly. Path 4 commits
// ContactPlane on the steep poly (LandingZ ≈ 0.087 is
// permissive enough — even 49°+ slopes pass).
// Frame N+1: body now grounded with Contact + steep
// ContactPlane. OnWalkable cleared by FloorZ test
// downstream. Resolver fires Path 5 (Contact branch)
// instead of Path 6. step_sphere_up tries to step over,
// fails, falls back to step_up_slide → clears Contact,
// slides sphere laterally along StepUpNormal.
// Frame N+2: body airborne with lateral V from the slide.
// Gravity takes over, body falls off the slope.
//
// Without this Path-4 commit, the body NEVER gets Contact
// set, Path 5 never fires, step_up_slide never runs, and the
// body wedges in airborne-collision-revert-loop with V at
// MaxVelocity tangent to the surface (live wedge 2026-04-30).
var localOffset = validPos.Center - sphere0.Center; var localOffset = validPos.Center - sphere0.Center;
var worldOffset = L2W(localOffset) * scale; var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset); path.AddOffsetToCheckPos(worldOffset);
@ -1573,35 +1594,39 @@ public static class BSPQuery
var worldNormal0 = L2W(hitPoly0!.Plane.Normal); var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
// L.4-reject-steep-landing (2026-04-30): if the polygon is // L.4 slide-tangent for steep airborne hits (2026-04-30).
// too steep to walk on (worldNormal.Z < FloorZ ≈ 0.6642),
// do NOT enter the SetCollide → Path-4 → SetContactPlane
// landing path. That path commits the player to the
// surface (sets ContactPlane), which sticks them to the
// steep roof in a falling animation.
// //
// Instead, treat the steep-poly hit as a wall slide: // For polygons too steep to walk on (worldNormal.Z < FloorZ),
// project the move along the steep face (remove the // skip the SetCollide → Path-4 → ContactPlane landing chain.
// into-wall component), set CollisionNormal + // That chain commits the body to the steep surface, leading
// to the "stuck in falling animation on the roof" bug — once
// grounded with a steep ContactPlane, our step_up_slide /
// cliff_slide / edge_slide chain can't produce smooth
// descent and the body wedges or "falls a bit at a time"
// when bumped.
//
// Instead: project the move along the steep face (remove
// the into-wall displacement), set CollisionNormal +
// SlidingNormal, return Slid. Same shape as Path 5's // SlidingNormal, return Slid. Same shape as Path 5's
// step-up fallback (line 1545-1547) and CylinderCollision // step-up fallback (line 1545-1547) and CylinderCollision
// (TransitionTypes.cs:1518-1522). The position is updated // (TransitionTypes.cs:1518-1522). Position is updated in-
// in-place; on the next resolver iteration the sphere is // place; on the next resolver iteration the sphere is
// outside the poly, FindCollisions returns OK, and // outside the poly, FindCollisions returns OK, and
// ValidateTransition commits the new position. Body stays // ValidateTransition commits the new position. Body stays
// airborne, falling animation continues, gravity pulls // airborne, falling animation continues, and gravity's
// down the slope. // tangent component drifts the body downhill until it
// slides off the slope's edge.
// //
// CRITICAL: this MUST happen before path.SetCollide(...) // This is a deliberate deviation from retail (retail uses
// is called. Once Collide=true is set, TransitionalInsert // SetCollide unconditionally and lets find_walkable +
// Phase 3 either commits via ContactPlane+Placement (the // step_up_slide produce the slide). Validated against
// walkable case, OK on shallow slopes) or RestoreCheckPos // retail debugger trace 2026-04-30: retail body did not
// (the reset case, when ContactPlaneValid is false). The // wedge; our retail-faithful port DID wedge because we're
// reset path REVERTS our slide and freezes the body. // missing implementation details of the step_up_slide /
// // cliff_slide chain on grounded-steep movement. The
// Per user 2026-04-30: // slide-tangent here produces user-acceptable behavior
// "I jump up, I land on it. It should not even let me // (slides off naturally) while the deeper chain port is
// land, should just slide with a falling animation." // researched. Filed as L.5+ followup for retail-strict.
if (worldNormal0.Z < PhysicsGlobals.FloorZ) if (worldNormal0.Z < PhysicsGlobals.FloorZ)
{ {
Vector3 currWorld = path.GlobalCurrCenter[0].Origin; Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
@ -1613,26 +1638,11 @@ public static class BSPQuery
collisions.SetCollisionNormal(worldNormal0); collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0); collisions.SetSlidingNormal(worldNormal0);
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
{
Console.WriteLine(
$"[steep-roof] PATH6-STEEP-SLIDE N=({worldNormal0.X:F2},{worldNormal0.Y:F2},{worldNormal0.Z:F2}) " +
$"FloorZ={PhysicsGlobals.FloorZ:F2} " +
$"diff={diff:F3} " +
$"newCheckPos=({path.CheckPos.X:F2},{path.CheckPos.Y:F2},{path.CheckPos.Z:F2})");
}
return TransitionState.Slid; return TransitionState.Slid;
} }
// ─── SetCollide response ───────────────────────────────── // ─── SetCollide response (shallow / walkable) ───────────
// Airborne sphere hits a shallow polygon (walkable surface). // Per retail (acclient_2013_pseudo_c.txt:323783-323821).
// Call SetCollide so TransitionalInsert's Collide branch
// re-tests as Placement to confirm we can land on it.
//
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
// + return Adjusted.
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
path.SetCollide(worldNormal0); path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ; path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted; return TransitionState.Adjusted;
@ -1650,8 +1660,7 @@ public static class BSPQuery
{ {
var worldNormal1 = L2W(hitPoly1!.Plane.Normal); var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
// L.4-reject-steep-landing: same steep-poly slide // L.4 slide-tangent: same steep-poly slide for head-sphere.
// for head-sphere hits.
if (worldNormal1.Z < PhysicsGlobals.FloorZ) if (worldNormal1.Z < PhysicsGlobals.FloorZ)
{ {
Vector3 currWorld = path.GlobalCurrCenter[0].Origin; Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
@ -1666,7 +1675,7 @@ public static class BSPQuery
return TransitionState.Slid; return TransitionState.Slid;
} }
// Head sphere hit shallow surface: same SetCollide response. // Head sphere hit shallow surface: SetCollide.
path.SetCollide(worldNormal1); path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ; path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted; return TransitionState.Adjusted;

View file

@ -594,6 +594,25 @@ public sealed class PhysicsEngine
body.SlidingNormal = Vector3.Zero; body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding; body.TransientState &= ~TransientStateFlags.Sliding;
} }
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
// Phase 3's reset path sets VelocityKilled when an airborne hit
// can't find a walkable surface (steep roof, wall) AND the
// body had a last_known_contact_plane (i.e., was grounded
// recently). Retail zeros all three velocity components so
// gravity restarts cleanly next frame.
//
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
// acclient_2013_pseudo_c.txt:274467-274475
// Called from CTransition::transitional_insert reset path:
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
if (transition.ObjectInfo.VelocityKilled)
{
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
body.Velocity = Vector3.Zero;
}
} }
// L.3a (2026-04-30): surface the wall normal so callers can apply // L.3a (2026-04-30): surface the wall normal so callers can apply

View file

@ -79,12 +79,31 @@ public sealed class ObjectInfo
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ; => OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
/// <summary> /// <summary>
/// Stop any accumulated velocity on this object info. /// Sticky flag: set by <see cref="StopVelocity"/>; PhysicsEngine consumes
/// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body. /// it after the transition commits to zero the body's velocity. Models
/// acdream: velocity is tracked on PhysicsBody, not here. No-op for now; /// retail's <c>OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)</c>
/// will be wired when velocity is threaded through TransitionalInsert. /// (named-retail acclient_2013_pseudo_c.txt:274467-274475).
/// Cleared by the engine when consumed; reset to false at the start of
/// each <c>FindTransitionalPosition</c>.
/// </summary> /// </summary>
public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ } public bool VelocityKilled;
/// <summary>
/// Stop any accumulated velocity on this object info.
/// Retail: <c>OBJECTINFO::kill_velocity</c> calls
/// <c>CPhysicsObj::set_velocity(object, {0,0,0}, 0)</c>
/// (named-retail 0x50cfe0).
/// ACE: <c>ObjectInfo.StopVelocity</c> — clears Velocity on the physics body.
/// <para>
/// Velocity lives on <see cref="PhysicsBody"/>, not here. We can't reach
/// the body directly from inside the resolver without coupling
/// ObjectInfo to it, so we set a flag and let
/// <see cref="PhysicsEngine.ResolveWithTransition"/> apply the zero
/// after the transition completes. The flag is sticky across the
/// outer step loop and consumed exactly once per resolve.
/// </para>
/// </summary>
public void StopVelocity() { VelocityKilled = true; }
} }
/// <summary> /// <summary>
@ -439,6 +458,13 @@ public sealed class Transition
{ {
var sp = SpherePath; var sp = SpherePath;
// L.4 retail-strict (2026-04-30): clear the kill_velocity flag at
// the start of each resolve so leftover state from a prior
// transition doesn't carry over. Inside the loop, Phase 3's reset
// path may set this via OBJECTINFO::StopVelocity; the engine reads
// it after FindTransitionalPosition returns.
ObjectInfo.VelocityKilled = false;
// No starting cell → cannot move. // No starting cell → cannot move.
if (sp.CurCellId == 0) if (sp.CurCellId == 0)
return false; return false;
@ -676,13 +702,50 @@ public sealed class Transition
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false; ci.ContactPlaneIsWater = false;
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
if (diagSteep)
{
Console.WriteLine(
$"[steep-roof] PHASE3-RESET lastKnownValid={ci.LastKnownContactPlaneValid} " +
$"checkPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2}) " +
$"curPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2}) " +
$"stepUpNormal=({sp.StepUpNormal.X:F2},{sp.StepUpNormal.Y:F2},{sp.StepUpNormal.Z:F2})");
}
// Retail-faithful gate (acclient_2013_pseudo_c.txt:273231-273239):
//
// if (last_known_valid == 0) {
// set_collision_normal(step_up_normal); return COLLIDED;
// }
// kill_velocity(this);
// last_known_valid = 0;
// return COLLIDED;
//
// kill_velocity ONLY fires when last_known was valid. When
// it's not (the case our L.2.4 proximity guard produces
// after a few airborne frames), velocity is PRESERVED so
// the bounce reflection in handle_all_collisions can
// redirect V's perpendicular component along the slope's
// tangent direction — that's how retail's body escapes
// the wedge geometry.
//
// This was deviated to "unconditional" earlier in this
// session as a hypothesis-driven fix; the live trace
// showed the deviation CAUSED the wedge by zeroing V
// every frame, leaving the body with no tangent momentum
// to escape (live diag 2026-04-30: V=(0,0,0) for 169
// consecutive frames while position pre/resolved frozen).
if (ci.LastKnownContactPlaneValid) if (ci.LastKnownContactPlaneValid)
{ {
ci.LastKnownContactPlaneValid = false; ci.LastKnownContactPlaneValid = false;
oi.StopVelocity(); oi.StopVelocity();
if (diagSteep)
Console.WriteLine($"[steep-roof] PHASE3-RESET-KILLV ← StopVelocity called");
} }
else else
{
ci.SetCollisionNormal(sp.StepUpNormal); ci.SetCollisionNormal(sp.StepUpNormal);
}
return TransitionState.Collided; return TransitionState.Collided;
} }

View file

@ -0,0 +1,119 @@
"""Check an .exe's CodeView debug info to see what PDB GUID + age it
expects. Used to verify whether a candidate acclient.exe matches our
acclient.pdb without running the binary.
Usage:
py tools/pdb-extract/check_exe_pdb.py <path-to-exe>
"""
import struct
import sys
import uuid
def main():
if len(sys.argv) < 2:
print("usage: check_exe_pdb.py <path-to-exe>")
sys.exit(1)
with open(sys.argv[1], "rb") as f:
data = f.read()
# DOS header -> e_lfanew @ offset 0x3C points to PE header
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe_off:pe_off + 4] == b"PE\x00\x00", "not a PE file"
# COFF header
machine = struct.unpack_from("<H", data, pe_off + 4)[0]
n_sections = struct.unpack_from("<H", data, pe_off + 6)[0]
timestamp = struct.unpack_from("<I", data, pe_off + 8)[0]
opt_size = struct.unpack_from("<H", data, pe_off + 20)[0]
print(f"machine = 0x{machine:04x}")
print(f"timestamp = 0x{timestamp:08x} ({timestamp})")
import datetime
ts = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
print(f" -> linker UTC: {ts.isoformat()}")
# Optional header — magic indicates 32 vs 64 bit
opt_off = pe_off + 24
magic = struct.unpack_from("<H", data, opt_off)[0]
is_pe32_plus = (magic == 0x20B)
print(f"opt magic = 0x{magic:04x} ({'PE32+' if is_pe32_plus else 'PE32'})")
# Data directories: PE32 has them at opt_off + 96; PE32+ at opt_off + 112
dd_off = opt_off + (112 if is_pe32_plus else 96)
# Debug directory is data dir [6]
debug_va = struct.unpack_from("<I", data, dd_off + 6 * 8)[0]
debug_size = struct.unpack_from("<I", data, dd_off + 6 * 8 + 4)[0]
print(f"debug dir = VA=0x{debug_va:08x} size={debug_size}")
# We need to map the VA back to a file offset via section headers
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s + 8].rstrip(b"\x00").decode("ascii", errors="replace")
vsize = struct.unpack_from("<I", data, s + 8)[0]
vaddr = struct.unpack_from("<I", data, s + 12)[0]
rsize = struct.unpack_from("<I", data, s + 16)[0]
roff = struct.unpack_from("<I", data, s + 20)[0]
sections.append((name, vaddr, vsize, roff, rsize))
def va_to_file(va):
for (name, vaddr, vsize, roff, rsize) in sections:
if vaddr <= va < vaddr + vsize:
return roff + (va - vaddr)
return None
debug_off = va_to_file(debug_va)
if debug_off is None:
print("debug directory VA does not map into any section")
return
# Each debug directory entry is 28 bytes
n_entries = debug_size // 28
print(f"# debug entries = {n_entries}")
for i in range(n_entries):
e = debug_off + i * 28
characteristics = struct.unpack_from("<I", data, e)[0]
ts_e = struct.unpack_from("<I", data, e + 4)[0]
major = struct.unpack_from("<H", data, e + 8)[0]
minor = struct.unpack_from("<H", data, e + 10)[0]
type_e = struct.unpack_from("<I", data, e + 12)[0]
sz = struct.unpack_from("<I", data, e + 16)[0]
rva = struct.unpack_from("<I", data, e + 20)[0]
ptr = struct.unpack_from("<I", data, e + 24)[0]
type_name = {2: "CODEVIEW", 4: "MISC", 12: "VC_FEATURE", 13: "POGO", 16: "REPRO"}.get(type_e, f"type_{type_e}")
print(f" entry {i}: type={type_name} sz={sz} fileOff=0x{ptr:08x}")
if type_e == 2 and sz >= 24:
cv = data[ptr:ptr + sz]
sig = cv[:4]
print(f" cv signature = {sig!r}")
if sig == b"RSDS":
guid_bytes = cv[4:20]
age = struct.unpack_from("<I", cv, 20)[0]
pdb_name = cv[24:].rstrip(b"\x00").decode("utf-8", errors="replace")
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
print(f" GUID = {{{pdb_guid}}}")
print(f" age = {age}")
print(f" PDB filename = {pdb_name}")
expected_guid = uuid.UUID("9e847e2f-777c-4bd9-886c-22256bb87f32")
expected_age = 1
if pdb_guid == expected_guid and age == expected_age:
print()
print("=== MATCH: this exe pairs with our acclient.pdb ===")
else:
print()
print("=== MISMATCH ===")
print(f" expected GUID = {{{expected_guid}}}")
print(f" expected age = {expected_age}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,98 @@
"""Dump the PDB info stream so we know exactly which acclient.exe build
matches our PDB GUID. The PDB header points to stream 1 ("PDB Info") which
contains: u32 version, u32 signature(timestamp), u32 age, 16-byte GUID.
Usage:
py tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb
"""
import struct
import sys
import datetime
import uuid
def _ceil_div(a, b):
return (a + b - 1) // b
def main():
if len(sys.argv) < 2:
print("usage: dump_pdb_info.py <path-to-pdb>")
sys.exit(1)
pdb_path = sys.argv[1]
with open(pdb_path, "rb") as f:
data = f.read()
magic = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\x00\x00\x00"
assert data.startswith(magic), "not an MSF 7.00 PDB"
block_size = struct.unpack_from("<I", data, 0x20)[0]
num_blocks = struct.unpack_from("<I", data, 0x28)[0]
num_dir_bytes = struct.unpack_from("<I", data, 0x2C)[0]
block_map_addr = struct.unpack_from("<I", data, 0x34)[0]
print(f"block_size = {block_size}")
print(f"num_blocks = {num_blocks}")
print(f"num_dir_bytes = {num_dir_bytes}")
print(f"block_map_addr = {block_map_addr}")
def read_page(idx):
return data[idx * block_size : (idx + 1) * block_size]
dir_pages_needed = _ceil_div(num_dir_bytes, block_size)
block_map = read_page(block_map_addr)
dir_page_indices = struct.unpack_from(f"<{dir_pages_needed}I", block_map, 0)
dir_data = bytearray()
for pi in dir_page_indices:
dir_data.extend(read_page(pi))
dir_data = bytes(dir_data)
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
stream_sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
print(f"num_streams = {num_streams}")
offset = 4 + num_streams * 4
streams = []
for sz in stream_sizes:
if sz == 0xFFFFFFFF:
streams.append((0, []))
continue
n_pages = _ceil_div(sz, block_size)
pages = struct.unpack_from(f"<{n_pages}I", dir_data, offset)
offset += n_pages * 4
streams.append((sz, list(pages)))
# Stream 1 = PDB Info Stream
pdb_info_size, pdb_info_pages = streams[1]
print(f"pdb_info_size = {pdb_info_size}")
pdb_info = bytearray()
for pi in pdb_info_pages:
pdb_info.extend(read_page(pi))
pdb_info = bytes(pdb_info[:pdb_info_size])
version = struct.unpack_from("<I", pdb_info, 0)[0]
signature = struct.unpack_from("<I", pdb_info, 4)[0]
age = struct.unpack_from("<I", pdb_info, 8)[0]
guid_bytes = pdb_info[12:28]
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
sig_dt = datetime.datetime.fromtimestamp(signature, tz=datetime.timezone.utc)
print()
print("=== PDB Info Stream ===")
print(f"version = {version}")
print(f"signature = 0x{signature:08x} ({signature})")
print(f" -> linker timestamp UTC: {sig_dt.isoformat()}")
print(f"age = {age}")
print(f"GUID = {{{pdb_guid}}}")
print()
print("This is the GUID + age the matching acclient.exe must reference")
print("in its CodeView entry. Find a binary whose linker timestamp")
print(f"is around {sig_dt.strftime('%Y-%m-%d')}.")
if __name__ == "__main__":
main()