acdream/tools/pdb-extract
Erik 235de3322a 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>
2026-04-30 22:41:12 +02:00
..
check_exe_pdb.py feat(physics): #32 L.5 30Hz physics tick + retail debugger toolchain (#35) + Phase 3 retail-faithful kill_velocity 2026-04-30 22:41:12 +02:00
check_function_map.py docs(research): #9 sweep acclient_function_map.md against PDB symbols 2026-04-25 17:44:07 +02:00
dump_pdb_info.py feat(physics): #32 L.5 30Hz physics tick + retail debugger toolchain (#35) + Phase 3 retail-faithful kill_velocity 2026-04-30 22:41:12 +02:00
pdb_extract.py tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar 2026-04-25 17:31:52 +02:00
README.md tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar 2026-04-25 17:31:52 +02:00

pdb-extract — pure-Python MSF 7.00 PDB extractor

Reads refs/acclient.pdb (Sept 2013 EoR build, 28 MB) and writes two grep-friendly JSON sidecars to docs/research/named-retail/:

  • symbols.json — 18,366 named public function symbols from the PDB's S_PUB32 records. Each entry has address (image VA), name (MSVC-demangled Class::Method form), and mangled (raw C++ ABI symbol for callers that need exact mangling).
  • types.json — 5,371 unique named struct/class type records from the TPI stream (LF_CLASS / LF_STRUCTURE). Each entry has name, size (bytes), and kind (class or struct).

Usage

py tools\pdb-extract\pdb_extract.py refs\acclient.pdb

Runs in <1 second. No external dependencies — uses Python stdlib only.

Schema

symbols.json:

[
  {
    "address": "0x00594570",
    "name": "CEnchantmentRegistry::EnchantAttribute",
    "mangled": "?EnchantAttribute@CEnchantmentRegistry@@QBEHKAAK@Z"
  },
  ...
]

types.json:

[
  {
    "name": "CEnchantmentRegistry",
    "size": 32,
    "kind": "class"
  },
  ...
]

Workflow integration

The committed JSON sidecars are the named-retail counterpart to the acclient_2013_pseudo_c.txt text dump. Pseudo-C is for reading function bodies; symbols.json is for programmatic lookups. Use jq to query:

# Find a function by exact name
cat docs/research/named-retail/symbols.json | jq '.[] | select(.name == "CEnchantmentRegistry::EnchantAttribute")'

# Find all functions on a class
cat docs/research/named-retail/symbols.json | jq '.[] | select(.name | startswith("CACQualities::"))'

# Reverse lookup by address (e.g. mid-body fix-up)
cat docs/research/named-retail/symbols.json | jq '.[] | select(.address == "0x00594570")'

# Find a type by name
cat docs/research/named-retail/types.json | jq '.[] | select(.name == "Enchantment")'

Address mapping caveat

The PDB is from the Sept 2013 EoR build. Addresses generally match the binary used to produce our docs/research/decompiled/ Ghidra chunks within ~0xC00 bytes (different build runs of the same source revision). When using symbols.json to correct entries in acclient_function_map.md, match by name, not by raw address.

Implementation notes

The script is a self-contained MSF 7.00 reader. References used:

  • LLVM PDB documentation (https://llvm.org/docs/PDB/) — file format spec
  • Microsoft pdbparse (community) — implementation cross-check

Streams consumed:

  • 3 (DBI) — parses the header to extract the symbol-record stream index + the optional debug-header sub-stream's section-headers index.
  • 9 (section headers) — parses IMAGE_SECTION_HEADER entries to build a section-base table for VA computation.
  • 8 (sym record stream) — iterates records, picks S_PUB32 with the PUBSYM_FLAG_CODE bit set, computes VA = section_base + offset.
  • 2 (TPI) — iterates type records, picks LF_CLASS / LF_STRUCTURE that aren't forward-declared, parses size leaf + name.

The MSVC name demangler (_demangle) is best-effort: handles the common ?Method@Class@Outer@@<sig> patterns, constructors (??0), and destructors (??1). Returns the mangled string unchanged for operator overloads (??2, ??3), vtables (??_), and other forms where a partial demangle would be misleading. Both name (demangled) and mangled (raw) are emitted in symbols.json so consumers can choose.

When to regenerate

  • Whenever refs/acclient.pdb is updated (rare).
  • Whenever pdb_extract.py is changed (e.g. better demangler, more type info recovery).

The output JSONs are committed because they're stable + small (~3 MB combined) and grep-faster than re-parsing the PDB on every session.

Future work (out of scope here)

The current types.json only carries name + size. A more ambitious version would walk LF_FIELDLIST records to recover field names + offsets + types — giving us a JSON-encoded acclient.h. Not done yet because acclient.h already exists committed at docs/research/named-retail/acclient.h. Consider this if a future panel needs offsetof() at runtime.