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:
parent
b1af56eb19
commit
235de3322a
8 changed files with 624 additions and 82 deletions
132
CLAUDE.md
132
CLAUDE.md
|
|
@ -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
|
||||
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
|
||||
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:
|
||||
|
||||
-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
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` by
|
||||
`class::method` name. 99.6% of functions have real names from the
|
||||
|
|
@ -249,6 +261,124 @@ Before marking any phase as done:
|
|||
- [ ] Roadmap updated
|
||||
- [ ] 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
|
||||
|
||||
Subagents are the primary tool for saving parent-context and keeping one
|
||||
|
|
|
|||
|
|
@ -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
|
||||
validation, and a synthetic diagonal terrain-boundary test covers tangent
|
||||
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
|
||||
fix, real-DAT building-edge fixtures, fuller `cliff_slide` coverage, and
|
||||
`NegPolyHit` dispatch. Named retail anchors include `CTransition::edge_slide`,
|
||||
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
|
||||
`SPHEREPATH::step_up_slide`.
|
||||
polygon context.
|
||||
|
||||
**L.4/L.5 update 2026-04-30:** A retail debugger trace (cdb attached to
|
||||
v11.4186 acclient.exe — see #35) confirmed that retail does NOT wedge
|
||||
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`,
|
||||
`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
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -172,6 +172,32 @@ public sealed class PlayerMovementController
|
|||
public const float HeartbeatInterval = 0.2f; // 200ms
|
||||
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)
|
||||
{
|
||||
_physics = physics;
|
||||
|
|
@ -411,9 +437,34 @@ public sealed class PlayerMovementController
|
|||
}
|
||||
|
||||
// ── 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;
|
||||
_body.calc_acceleration();
|
||||
_body.UpdatePhysicsInternal(dt);
|
||||
_physicsAccum += 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;
|
||||
|
||||
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
||||
|
|
@ -520,32 +571,6 @@ public sealed class PlayerMovementController
|
|||
? !(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.
|
||||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||||
if (diagSteep && resolveResult.CollisionNormalValid)
|
||||
|
|
|
|||
|
|
@ -1473,6 +1473,27 @@ public static class BSPQuery
|
|||
if (changed && hitPoly is not null)
|
||||
{
|
||||
// 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 worldOffset = L2W(localOffset) * scale;
|
||||
path.AddOffsetToCheckPos(worldOffset);
|
||||
|
|
@ -1573,35 +1594,39 @@ public static class BSPQuery
|
|||
|
||||
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
|
||||
|
||||
// L.4-reject-steep-landing (2026-04-30): if the polygon is
|
||||
// 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.
|
||||
// L.4 slide-tangent for steep airborne hits (2026-04-30).
|
||||
//
|
||||
// Instead, treat the steep-poly hit as a wall slide:
|
||||
// project the move along the steep face (remove the
|
||||
// into-wall component), set CollisionNormal +
|
||||
// For polygons too steep to walk on (worldNormal.Z < FloorZ),
|
||||
// skip the SetCollide → Path-4 → ContactPlane landing chain.
|
||||
// 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
|
||||
// step-up fallback (line 1545-1547) and CylinderCollision
|
||||
// (TransitionTypes.cs:1518-1522). The position is updated
|
||||
// in-place; on the next resolver iteration the sphere is
|
||||
// (TransitionTypes.cs:1518-1522). Position is updated in-
|
||||
// place; on the next resolver iteration the sphere is
|
||||
// outside the poly, FindCollisions returns OK, and
|
||||
// ValidateTransition commits the new position. Body stays
|
||||
// airborne, falling animation continues, gravity pulls
|
||||
// down the slope.
|
||||
// airborne, falling animation continues, and gravity's
|
||||
// tangent component drifts the body downhill until it
|
||||
// slides off the slope's edge.
|
||||
//
|
||||
// CRITICAL: this MUST happen before path.SetCollide(...)
|
||||
// is called. Once Collide=true is set, TransitionalInsert
|
||||
// Phase 3 either commits via ContactPlane+Placement (the
|
||||
// walkable case, OK on shallow slopes) or RestoreCheckPos
|
||||
// (the reset case, when ContactPlaneValid is false). The
|
||||
// reset path REVERTS our slide and freezes the body.
|
||||
//
|
||||
// Per user 2026-04-30:
|
||||
// "I jump up, I land on it. It should not even let me
|
||||
// land, should just slide with a falling animation."
|
||||
// This is a deliberate deviation from retail (retail uses
|
||||
// SetCollide unconditionally and lets find_walkable +
|
||||
// step_up_slide produce the slide). Validated against
|
||||
// retail debugger trace 2026-04-30: retail body did not
|
||||
// wedge; our retail-faithful port DID wedge because we're
|
||||
// missing implementation details of the step_up_slide /
|
||||
// cliff_slide chain on grounded-steep movement. The
|
||||
// slide-tangent here produces user-acceptable behavior
|
||||
// (slides off naturally) while the deeper chain port is
|
||||
// researched. Filed as L.5+ followup for retail-strict.
|
||||
if (worldNormal0.Z < PhysicsGlobals.FloorZ)
|
||||
{
|
||||
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
|
||||
|
|
@ -1613,26 +1638,11 @@ public static class BSPQuery
|
|||
|
||||
collisions.SetCollisionNormal(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;
|
||||
}
|
||||
|
||||
// ─── SetCollide response ─────────────────────────────────
|
||||
// Airborne sphere hits a shallow polygon (walkable surface).
|
||||
// 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.
|
||||
// ─── SetCollide response (shallow / walkable) ───────────
|
||||
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
|
||||
path.SetCollide(worldNormal0);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
return TransitionState.Adjusted;
|
||||
|
|
@ -1650,8 +1660,7 @@ public static class BSPQuery
|
|||
{
|
||||
var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
|
||||
|
||||
// L.4-reject-steep-landing: same steep-poly slide
|
||||
// for head-sphere hits.
|
||||
// L.4 slide-tangent: same steep-poly slide for head-sphere.
|
||||
if (worldNormal1.Z < PhysicsGlobals.FloorZ)
|
||||
{
|
||||
Vector3 currWorld = path.GlobalCurrCenter[0].Origin;
|
||||
|
|
@ -1666,7 +1675,7 @@ public static class BSPQuery
|
|||
return TransitionState.Slid;
|
||||
}
|
||||
|
||||
// Head sphere hit shallow surface: same SetCollide response.
|
||||
// Head sphere hit shallow surface: SetCollide.
|
||||
path.SetCollide(worldNormal1);
|
||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||
return TransitionState.Adjusted;
|
||||
|
|
|
|||
|
|
@ -594,6 +594,25 @@ public sealed class PhysicsEngine
|
|||
body.SlidingNormal = Vector3.Zero;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -79,12 +79,31 @@ public sealed class ObjectInfo
|
|||
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
|
||||
|
||||
/// <summary>
|
||||
/// Stop any accumulated velocity on this object info.
|
||||
/// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body.
|
||||
/// acdream: velocity is tracked on PhysicsBody, not here. No-op for now;
|
||||
/// will be wired when velocity is threaded through TransitionalInsert.
|
||||
/// Sticky flag: set by <see cref="StopVelocity"/>; PhysicsEngine consumes
|
||||
/// it after the transition commits to zero the body's velocity. Models
|
||||
/// retail's <c>OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)</c>
|
||||
/// (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>
|
||||
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>
|
||||
|
|
@ -439,6 +458,13 @@ public sealed class Transition
|
|||
{
|
||||
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.
|
||||
if (sp.CurCellId == 0)
|
||||
return false;
|
||||
|
|
@ -676,13 +702,50 @@ public sealed class Transition
|
|||
ci.ContactPlaneValid = 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)
|
||||
{
|
||||
ci.LastKnownContactPlaneValid = false;
|
||||
oi.StopVelocity();
|
||||
if (diagSteep)
|
||||
Console.WriteLine($"[steep-roof] PHASE3-RESET-KILLV ← StopVelocity called");
|
||||
}
|
||||
else
|
||||
{
|
||||
ci.SetCollisionNormal(sp.StepUpNormal);
|
||||
}
|
||||
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
|
|
|||
119
tools/pdb-extract/check_exe_pdb.py
Normal file
119
tools/pdb-extract/check_exe_pdb.py
Normal 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()
|
||||
98
tools/pdb-extract/dump_pdb_info.py
Normal file
98
tools/pdb-extract/dump_pdb_info.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue