tools(render): OH retail capture scripts for the S3/S4 answer key

Recon script, three capture templates (walk / parts / alpha-depth),
runner, and operator README under tools/walk-oracle/oh/. FW0 line formats
are kept byte-identical; new EC/OC/PD/DM/AM/FL/PM/PC line kinds are
documented. Auto-detach uses the fall-through + top-level qd recipe; qd
never appears inside a breakpoint action. Lead-resolved: DBObj::m_DID is
+0x28 (header layout, first base of CGfxObj and CObjCell, matches the
proven FW0 cell reads); the three D3DPolyRender alpha functions are static
cdecl with stack arguments, uint8 arguments read with by().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 18:09:11 +02:00
parent 13fc7d8349
commit ebaa41df1a
5 changed files with 716 additions and 0 deletions

View file

@ -0,0 +1,291 @@
# OH1 walk-oracle capture kit (2026-09-02)
Draft cdb capture scripts for Campaign OVERHAUL's OH1 evidence gap: the four
OH1 contract docs
(`docs/research/2026-09-01-overhaul/oh1-retail-world-contract.md`,
`oh1-built-mesh-view-contract.md`, `oh1-alpha-list-contract.md`,
`oh1-depth-lifecycle.md`) establish retail's built-mesh/view/stamp, alpha
FIFO, and depth-lifecycle contracts from the named decomp plus live Ghidra
arbitration, but they explicitly say (`oh1-retail-world-contract.md` s10):
"Existing FW0 captures cover only frame/pose/DrawInside/DrawCells/
landscape/building entry. Full semantic equality requires a bounded richer
retail capture before the owning later slices close." This kit is that
richer capture: it extends the proven FW0 F/P/LS/BLD/DI/DC per-frame shape
with three new families (cell-shell/object-cell stamps, part/mesh
admission, and the alpha-FIFO/depth-counter state machine) without changing
any existing line format.
**These scripts are DRAFTS pending the recon pass below. Do not treat any
offset as final until `oh-recon.cdb`'s `dt` output confirms it against a
live attach.** See "Open questions" at the end before running a real
session.
## Files
| File | Purpose |
|---|---|
| `oh-recon.cdb` | No breakpoints. `dt`/`x`/`uf`/`dd` dumps that confirm every offset the three capture scripts assume. Run this FIRST, once per session. |
| `oh-capture-walk.cdb.template` | FW0's F/P/LS/BLD/DI/DC lines, byte-identical, plus new `EC`/`OC` lines (cell-shell and object-cell draw-stamp events). 5 frames. |
| `oh-capture-parts.cdb.template` | F/P lines plus new `PD`/`DM` lines (per-part and per-mesh admission/GfxObj identity). The high-hit-rate script. 3 frames. |
| `oh-capture-alpha-depth.cdb.template` | F/P lines plus new `AM`/`FL`/`PM`/`PC` lines (the two alpha FIFO lists and the persistent portal-mask depth counter). 5 frames. |
| `oh-run-capture.ps1` | Runner: `-Script -Log -Frames`. Verifies the live acclient.exe pairs with `refs/acclient.pdb` (aborts on mismatch), substitutes `<LOG>`/`<FRAMES>`, launches `cdb -pn acclient.exe -cf <script>` in the background. |
## Prerequisites
1. `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe` installed
(Microsoft Store WinDbg).
2. The retail client running the **2013 v11.4186 EoR build** (PDB GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`), in-world, connected to local ACE
on `127.0.0.1:9000`. The runner checks this for you and aborts on
mismatch, but confirm before starting — a mismatched attach silently
corrupts every offset in these scripts.
3. `docs/research/named-retail/acclient.h` and `acclient_2013_pseudo_c.txt`
available (already in the repo) if you need to cross-check a line
number cited in a script's comments.
## The four poses
Reused verbatim from `docs/research/2026-08-30-fw-walk-oracle/README.md`'s
trace table — these are the exact in-world spots already used for the FW0
oracle fixtures, so a human who captured those poses before can return to
the same spots from memory; there is no additional GPS/coordinate record
beyond these descriptions in this repo.
| Pose | Description | Root shape |
|---|---|---|
| `holtburg-doorway-still` | Standing in a cottage doorway, still | INTERIOR (`DI a9b4013f`) |
| `terrace-edge` | Waterfall ledge (the #456 acceptance pose), still | OUTDOOR |
| `cathedral-arrival` | Cathedral portal-in arrival, still | INTERIOR (`DI f4180106`) |
| `foundry-deep` | Bottom of the Holtburg foundry, still | INTERIOR (`DI a9b40176`) |
All four are **still** poses (no movement during capture) — this kit only
targets the still-fixture family, matching FW0's own "still fixtures gate
first" precedent (pose-application timing sensitivity in moving fixtures is
a separate, harder problem this kit does not attempt).
## Operator procedure
**Budget: one hour total** for all four poses. Each pose's three scripts
(walk, parts, alpha-depth) run back to back and take well under a minute of
actual capture time each; the time goes to repositioning, verifying detach,
and the occasional retry.
### 0. Once per session
Run recon first, before touching any pose:
```powershell
cd tools\walk-oracle\oh
.\oh-run-capture.ps1 -Script oh-recon.cdb -Log C:\path\to\oh-recon.log
```
Wait for it to finish (recon has no breakpoints, so it runs to completion
in well under a second once attached — watch for the `oh-recon.log.console`
file to stop growing, or just wait ~5 seconds). Read `oh-recon.log` and:
- confirm every `dt` offset matches what the capture scripts' comments
assume (see each template's own "OFFSETS (VERIFY-IN-RECON)" block);
- read the `dt acclient!DBObj` output and confirm `m_DID` is at `+0x28`
(the lead derived this from the header on 2026-09-02 and the parts
template is pre-filled with it; if `dt` disagrees, fix the two `0x28`
reads in `oh-capture-parts.cdb.template` before running it);
- glance at the three `uf` disassembly dumps
(`AddMeshToAlphaList`/`DrawPortalPolyInternal`/`FlushAlphaList`): the
named pseudo-C declares all three as plain static functions with no
receiver and no non-default convention (`acclient_2013_pseudo_c.txt`
424920, 424490, 425941), i.e. `__cdecl` with arguments at `[esp+4]`
upward; the templates read `uint8` arguments with `by()` (one byte) so
the untouched upper bytes of a pushed byte cannot pollute the value.
Only if the prologue shows `ecx`/`edx` being consumed as arguments does
the template need changing.
If either open question resolves DIFFERENTLY than assumed, fix the
template before capturing — do not capture first and "adjust the parser
later"; a wrong stack offset produces plausible-looking garbage, not an
obvious crash.
### 1. Per pose
For each of the four poses, in order (doorway-still, terrace-edge,
cathedral-arrival, foundry-deep — matches the FW0 capture order, no
particular reason to change it):
1. Position the retail character/camera at the pose (see table above) and
hold still.
2. Run the walk capture:
```powershell
.\oh-run-capture.ps1 -Script oh-capture-walk.cdb.template `
-Log C:\path\to\<pose>.walk.log -Frames 5
```
3. **Wait for detach** before moving or running the next script. The
runner returns immediately (it launches cdb in the background), so
watch for completion instead of assuming it's done:
```powershell
while (Get-Process cdb -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 500 }
```
or simply tail the log and watch it stop growing at `F 5`:
```powershell
Get-Content C:\path\to\<pose>.walk.log -Tail 20 -Wait
```
4. Run the parts capture (stay in the same pose):
```powershell
.\oh-run-capture.ps1 -Script oh-capture-parts.cdb.template `
-Log C:\path\to\<pose>.parts.log -Frames 3
```
Wait for detach the same way.
5. Run the alpha-depth capture (stay in the same pose):
```powershell
.\oh-run-capture.ps1 -Script oh-capture-alpha-depth.cdb.template `
-Log C:\path\to\<pose>.alphadepth.log -Frames 5
```
Wait for detach.
6. Move to the next pose and repeat.
Do not run two of these scripts against the same live process
simultaneously — cdb's `-pn` attach is exclusive, and the second launch
will simply fail to attach rather than queue.
## Expected line counts
For `oh-capture-walk.cdb.template`'s F/P/LS/BLD/DI/DC lines, real per-frame
counts from the ORIGINAL FW0 40-frame captures of these exact four poses
give a concrete baseline (frame 40 in every FW0 log is truncated by the
auto-detach race, so these are per-COMPLETE-frame averages over frames
1-39; this kit's own 5-frame captures will end at a different, cleaner
boundary since they use the fall-through recipe instead of FW0's
non-firing `qd`, but the per-frame magnitude should still hold):
| Pose | DI/frame | DC/frame | LS/frame | BLD/frame |
|---|---:|---:|---:|---:|
| `holtburg-doorway-still` | 1 | ~6 | 1 | ~13 |
| `terrace-edge` | 0 (outdoor root) | ~2 | 1 | ~14 |
| `cathedral-arrival` | 1 | ~3 | 1 | ~10 |
| `foundry-deep` | 1 | 1 | 1 | ~12 |
`EC`/`OC` (new in the walk script) have **no prior baseline** — this is
their first capture. Expect a similar order of magnitude to `DC`/`BLD`
(shell/object-cell draws roughly track the cell/building roster each frame
walks); a flat **zero** `EC` or `OC` count for an interior pose is the real
red flag, not an exact mismatch against the table above.
`PD`/`DM` (parts script) and `AM`/`FL`/`PM`/`PC` (alpha-depth script) also
have **no prior baseline**:
- `PD`/`DM` are per-resident-part, not per-cell/building — expect
hundreds to low thousands of lines across 3 frames in a town pose
(doorway-still, terrace-edge, cathedral-arrival) and noticeably fewer in
the sparser foundry-deep interior. This is exactly why the parts script
defaults to 3 frames instead of 5.
- `FL` is bounded by known call sites
(`oh1-alpha-list-contract.md` s7): expect roughly
(buildings-in-view-this-frame) + 1 (top-level `SmartBox::RenderNormalMode`
flush) + (0 or 1, depending on whether that frame's `PView::DrawCells`
had an outside view) per frame — so order 10-16 for the town poses,
fewer for foundry-deep.
- `AM` and `PM` depend entirely on how much transparent/portal content is
in view at that specific pose (falls, torches, doorway apertures) and
could legitimately be zero for a pose with no transparent surfaces in
frame — a zero count here is not automatically a bug.
## If retail lags or ACE disconnects
The parts script is the risky one: it traps on every part draw (hundreds
per frame), so expect retail to freeze for a few seconds during its three
frames. Run it last for each pose, and if ACE drops the session once, use
`-Frames 2` on the retry rather than abandoning the pose.
Per `claude-memory/project_retail_debugger.md`'s "High hit rates lag the
game" watchout: `PD`/`DM` in the parts script are the highest-risk
breakpoints in this kit (fired once per resident part draw, potentially
thousands of times per frame in a dense town pose). If you observe visible
stutter, input lag, or an ACE disconnect during a capture:
1. Let the current capture finish or auto-detach if possible — do not
force-kill cdb (see CLAUDE.md: `Stop-Process -Force` on cdb takes the
attached retail client down with it via `TerminateProcess`).
2. Re-run with a SHORTER frame count for the offending script:
- `oh-capture-walk.cdb.template`: try `-Frames 3` instead of 5.
- `oh-capture-parts.cdb.template`: try `-Frames 1` instead of 3 — a
single frame is still enough to sanity-check the `PD`/`DM` line
format and the `<GFXOBJ_DID_OFF>` offset.
- `oh-capture-alpha-depth.cdb.template`: try `-Frames 3` instead of 5.
3. If ACE actually disconnected, follow CLAUDE.md's "Logout-before-
reconnect" guidance before relaunching retail — a hard-killed session
can stay logged in on ACE's side for several minutes.
4. If a specific pose keeps lagging even at the reduced frame count,
capture that pose's walk/alpha-depth scripts (cheap) and skip its parts
capture (expensive) rather than spending the whole hour on one pose.
## New line-kind prefixes (glossary)
All existing FW0 prefixes (`F`, `P`, `LS`, `BLD`, `DI`, `DC`) are
byte-identical to
`docs/research/2026-08-30-fw-walk-oracle/README.md`'s "Trace format"
section — not repeated here.
| Prefix | Script | Source breakpoint | Meaning |
|---|---|---|---|
| `EC` | walk | `RenderDeviceD3D::DrawEnvCell` @`0x0059F170` | A built EnvCell shell's draw-stamp fired this frame; `<cellid>` = the cell's `m_DID`. |
| `OC` | walk | `RenderDeviceD3D::DrawObjCellForDummies` @`0x005A0760` | An object-cell's shadow-part list was walked this frame; `<cellid>` = the cell's `m_DID`. |
| `PD` | parts | `CPhysicsPart::Draw` @`0x0050D7A0` | A physics part's draw call, with its degrade-resolved GfxObj identity and (best-effort) containing cell. |
| `DM` | parts | `RenderDeviceD3D::DrawMeshInternal` @`0x0059F360` | The actual admission/stamp/submit call for one GfxObj part, with its GfxObj identity directly from the argument (no degrade-level resolution needed). |
| `AM` | alpha-depth | `D3DPolyRender::AddMeshToAlphaList` @`0x0059C230` | One subset appended to the CLIP or ALPHA FIFO list. |
| `FL` | alpha-depth | `D3DPolyRender::FlushAlphaList` @`0x0059D2E0` | A flush-barrier call, with the return address to identify the caller site. |
| `PM` | alpha-depth | `D3DPolyRender::DrawPortalPolyInternal` @`0x0059BC90` | A far-Z building punch or true-depth exit-seal attempt, with the counter value before this call's own possible increment. |
| `PC` | alpha-depth | `PView::DrawCells` @`0x005A4840` | The persistent `portalsDrawnCount`/`forceClear` state sampled at this eligible call's entry. |
## Open questions a human must settle before a real session
1. **GfxObj DataID offset — RESOLVED 2026-09-02 (lead).** `DBObj : Interface`
lays out `vfptr@0`, `m_dataCategory@4`, `m_bLoaded@8`, the 8-byte
`m_timeStamp@0x10`, `m_pNext@0x18`, `m_pLast@0x1C`, `m_pMaintainer@0x20`,
`m_numLinks@0x24`, `m_DID@0x28`. `DBObj` is the first base of both
`CGfxObj` and `CObjCell` (via `SerializeUsingPackDBObj`), and `+0x28` is
exactly the `m_DID` offset every FW0 capture already read on cells. The
parts template is pre-filled with `0x28`; recon's `dt acclient!DBObj`
re-confirms it.
2. **Calling convention — RESOLVED 2026-09-02 (lead) from the named
pseudo-C signatures.** `AddMeshToAlphaList(MeshBuffer*, int32, CSurface*,
uint8, uint8, uint8)` (424920), `DrawPortalPolyInternal(CPolygon*, uint8)`
(424490), and `FlushAlphaList(float)` (425941) are static with the
default convention: no receiver, arguments at `[esp+4]` upward. The
`AM` line now also prints the `CSurface*` at `[esp+0xC]`, and every
`uint8` argument is read with `by()`. The recon `uf` dumps remain as a
sanity check.
3. **`CPhysicsPart+0x14` (`deg_level`).** Derived only from
`acclient.h:31151-31173`'s field order (confirmed consistent with its
neighbors `draw_state+0x1C` and `gfxobj+0x20`, both of which OH1
independently Ghidra-cited), but never independently Ghidra-checked
itself anywhere in the OH1 evidence docs. Low risk, still worth a glance
at `oh-recon.cdb`'s `dt acclient!CPhysicsPart` output.
4. **`DrawObjCell` vs. `DrawObjCellForDummies` cell-argument location.**
The task that produced this kit flagged this exact question as an
example of the class of ambiguity to watch for. It turned out NOT to be
ambiguous once the pseudo-C signatures were read directly
(`acclient_2013_pseudo_c.txt:429177,429198`): both take the cell
identity as a plain stack argument at `[esp+4]`, and
`oh-capture-walk.cdb.template` uses `DrawObjCellForDummies` (the
function `PView::DrawCells` step 10 actually calls, per
`oh1-depth-lifecycle.md`'s exact frame ordering) rather than the inner
`DrawObjCell`/`DrawPartCell` helpers. Recorded here as a "resolved, but
worth a second pair of eyes" item rather than a true open question,
since it was resolved by reading text rather than by a live `dt`/`uf`.
5. **The `PD`/`DM` lines' `cell=` field can be stale.** It is set by a
silent `RenderDeviceD3D::DrawPartCell` hook and simply carries forward
whatever cell it last saw — it can be wrong for the first part drawn
after a building's forced portal-only pass (`DrawBuilding`'s
`Draw(part, 1)` call) and before that frame's first ordinary object-cell
walk. Treat it as advisory, not authoritative; a real port needs the
actual call-stack correlation this draft does not attempt.
6. **`oh-capture-alpha-depth.cdb.template` does not instrument the exact
mid-`PView::DrawCells` PCs** (`0x5A4885` stamp increment, `0x5A488C`
counter read, `0x5A489E` reset, `0x5A48A9` conditional clear) that
`oh1-depth-lifecycle.md`'s own Ghidra arbitration table cites. It
samples the global `portalsDrawnCount`/`forceClear` memory cells at
`PView::DrawCells`' function ENTRY instead (the `PC` line), which is
sufficient to reconstruct before/after values across consecutive
eligible calls (per that same doc's "Consecutive-call example" table)
without guessing which register holds the value at an arbitrary
mid-function instruction — but it means this kit does not directly
observe the mid-frame stamp increment itself. If a later slice needs
that specific event, it needs its own recon pass over those four PCs.

View file

@ -0,0 +1,108 @@
* OH1 walk-oracle capture: ALPHA + DEPTH LIFECYCLE (2026-09-02). Captures
* the two retail FIFO alpha lists (oh1-alpha-list-contract.md) and the
* persistent portalsDrawnCount depth state machine (oh1-depth-lifecycle.md)
* at call-entry granularity. Frame count default 5 (substitute <FRAMES>).
*
* SCOPE NOTE: oh1-depth-lifecycle.md's exact state machine touches specific
* PCs INSIDE PView::DrawCells (0x5A4885 mid-frame stamp increment, 0x5A488C
* counter read, 0x5A489E counter reset, 0x5A48A9 conditional clear -- see
* that doc's "Ghidra branch arbitration table"). This script does NOT set
* breakpoints at those interior addresses: OH1's own report supplies the PC
* ranges but this task did not re-disassemble them, so the exact register
* holding the counter value at each of those specific mid-function PCs is
* unconfirmed here -- guessing a register at an arbitrary mid-function
* instruction (as opposed to a function ENTRY, where the calling convention
* fixes argument locations) is a materially different and riskier kind of
* guess than anything else in this directory. Instead this script samples
* the GLOBAL portalsDrawnCount memory cell (0x008719B4, confirmed by OH1's
* Ghidra xref set as the ONLY three accesses to that address in the whole
* binary) at two safe, function-ENTRY breakpoints: PView::DrawCells itself
* (the "PC" line, giving counterBefore for that eligible call -- which,
* per oh1-depth-lifecycle.md's own "Consecutive-call example" table, IS the
* same value as counterAfter for the previous eligible call, so sampling
* only at entry is sufficient to reconstruct the sequence offline) and
* every DrawPortalPolyInternal attempt (the "PM" line, giving the
* pre-this-call's-own-increment counter value for each individual mask
* attempt). This is a deliberate, documented scope reduction, not an
* oversight -- see the README's open questions.
*
* AUTO-DETACH: same fall-through + top-level qd recipe as the other two
* scripts in this directory. Never qd inside a bp action.
*
* LINE FORMATS:
* F <n> -- unchanged from FW0
* P <cellid> <ox> <oy> <oz> <qw> <qx> <qy> <qz> -- unchanged from FW0
* AM mesh=<ptr> surf=<n> new=<0|1> clip=<0|1> listSel=<n> -- NEW.
* D3DPolyRender::AddMeshToAlphaList @0x0059C230 entry. mesh/surf are
* arg1 (MeshBuffer*) / arg2 (surfaceNum); new = arg4 (bNewObject);
* clip = arg5 (overrideClipmapSetting); listSel = arg6 (0 selects the
* ALPHA list, nonzero selects the CLIP list -- confirmed directly from
* the decompiled body at acclient_2013_pseudo_c.txt:424925-424982, which
* branches on "arg6 == 0" to the alphaedMeshCountAlpha/List* globals vs.
* the alphaedMeshCountClip/List* globals). See the calling-convention
* note below before trusting these stack offsets.
* FL thresh=<hex> ret=<hex> -- NEW.
* D3DPolyRender::FlushAlphaList @0x0059D2E0 entry. thresh = arg1's raw
* float bits (decode offline; oh1-alpha-list-contract.md ss7 lists the
* five call sites and their literal threshold constants -- 0f/0f/0f/0f
* from DrawBuilding/PView::DrawCells/SmartBox::RenderNormalMode/
* CreatureMode::Render, 0.75f from DrawBlock's emergency-capacity
* drain); ret = the return address on the stack at function entry
* (poi(@esp), i.e. [esp] itself before the callee's own push ebp),
* which a human can map back to one of those five sites offline via
* `ln` or the oh1-alpha-list-contract.md ss7 table.
* PM poly=<ptr> mode=<0|1> counterBefore=<hex> -- NEW.
* D3DPolyRender::DrawPortalPolyInternal @0x0059BC90 entry. poly = arg1
* (CPolygon*); mode = arg2 (0 = true-depth / exit-seal or mode-3
* fallback, nonzero = far-Z / building punch -- oh1-depth-lifecycle.md
* "Far-Z punches and true-depth exit seals" table); counterBefore is
* the GLOBAL portalsDrawnCount sampled BEFORE this specific call's own
* local-boundary-reject-then-maybe-increment logic runs. Far-mode
* (mode!=0) attempts NEVER change the counter; true-mode (mode==0)
* attempts increment it by 1 UNLESS the whole-polygon local x/y==+/-12
* boundary reject fires first (which this script cannot observe --
* only the audited CPU clip in a real port could).
* PC ov=<n> counter=<hex> fc=<0|1> -- NEW.
* PView::DrawCells @0x005A4840 entry (SAME breakpoint address as FW0's
* DC line, but this script does NOT also emit a DC line -- the task
* spec for this script only asked for the alpha/depth family, not the
* cell-shell roster; see oh-capture-walk.cdb.template for DC). ov =
* outside_view.view_count (PView+0x38); counter = portalsDrawnCount
* sampled at this call's entry (the "previous eligible call's" final
* value, per oh1-depth-lifecycle.md); fc = the forceClear global
* (0x008ED824, expected 0 -- OH1 found no write site for it in this
* binary, so a nonzero read here would itself be a notable finding).
*
* CALLING-CONVENTION NOTE (OPEN QUESTION -- the second big one, see README):
* AddMeshToAlphaList, FlushAlphaList, and DrawPortalPolyInternal all show
* NO "this" receiver in the named pseudo-C signatures (unlike
* DrawBuilding/DrawEnvCell/DrawPartCell/DrawMeshInternal, which explicitly
* annotate "this @ ecx"). This script ASSUMES that absence means a plain
* stack-argument layout starting immediately at esp+4 for arg1 (i.e.
* either true __cdecl, or __thiscall with an entirely UNUSED ecx receiver
* that does not consume a stack slot either way) -- NOT that some visible
* argument is silently arg2 of an unshown receiver. oh-recon.cdb includes
* `uf` disassembly dumps of all three functions specifically so a human
* can read the prologue and confirm (or refute) this before trusting the
* stack offsets below. If `uf` shows the first instructions touching
* [esp+4] before referencing ecx for anything other than a dead/unused
* load, the assumption holds.
*
* BEFORE USE: replace <FRAMES> (plain decimal digits) and <LOG> (output
* path). <VPOS> is pre-resolved to Render::viewer_pos @0x0081ef00, same as
* the other two scripts in this directory.
.logopen <LOG>
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
bp 00453aa0 "r $t0 = @$t0 + 1; .printf \"F %d\\n\", @$t0; .printf \"P %08x %08x %08x %08x %08x %08x %08x %08x\\n\", poi(0081ef00+4), poi(0081ef00+0x3c), poi(0081ef00+0x40), poi(0081ef00+0x44), poi(0081ef00+8), poi(0081ef00+0xc), poi(0081ef00+0x10), poi(0081ef00+0x14); .if (@$t0 < 0n<FRAMES>) { gc }"
bp 005a4840 ".printf \"PC ov=%d counter=%04x fc=%d\\n\", poi(@ecx+0x38), wo(008719b4), poi(008ed824); gc"
bp 0059c230 ".printf \"AM mesh=%08x surf=%d csurf=%08x new=%d clip=%d listSel=%d\n\", poi(@esp+4), poi(@esp+8), poi(@esp+0xc), by(@esp+0x10), by(@esp+0x14), by(@esp+0x18); gc"
bp 0059d2e0 ".printf \"FL thresh=%08x ret=%08x\\n\", poi(@esp+4), poi(@esp); gc"
bp 0059bc90 ".printf \"PM poly=%08x mode=%d counterBefore=%04x\n\", poi(@esp+4), by(@esp+8), wo(008719b4); gc"
g
.echo ===DETACHING===
qd
.logclose

View file

@ -0,0 +1,111 @@
* OH1 walk-oracle capture: PARTS (2026-09-02). The high-hit-rate script --
* CPhysicsPart::Draw and RenderDeviceD3D::DrawMeshInternal fire far more
* often per frame than DrawCells/DrawEnvCell/DrawBuilding (once per resident
* part vs. once per cell/building). Frame count default 3 (substitute
* <FRAMES>) to keep the trap cost bounded -- see
* claude-memory/project_retail_debugger.md's "High hit rates lag the game"
* watchout. Every bp action here is ONE .printf plus a handful of poi reads,
* per the task's own instruction; no .while loops, no nested nested calls.
*
* AUTO-DETACH: same fall-through + top-level qd recipe as
* oh-capture-walk.cdb.template / tools/cdb/pview-spiral2.cdb. Never qd
* inside a bp action.
*
* LINE FORMATS:
* F <n> -- unchanged from FW0
* P <cellid> <ox> <oy> <oz> <qw> <qx> <qy> <qz> -- unchanged from FW0
* PD gfx=<ptr> did=<hex> force=<0|1> cell=<hex> -- NEW.
* CPhysicsPart::Draw @0x0050D7A0 entry. gfx = the resolved
* CGfxObj* for the part's current degrade level (this->gfxobj[deg_level]);
* did = that GfxObj's DataID (see OPEN QUESTION below -- offset
* unconfirmed, placeholder token); force = the arg2 force flag (0 =
* ordinary CShadowPart::draw call, 1 = DrawBuilding's forced
* portal-only pass); cell = the LAST cell DID a DrawPartCell breakpoint
* saw (best-effort call-context correlation -- see note below, this can
* be STALE for the first part drawn after a building's force pass and
* before the frame's first ordinary object cell).
* DM gfx=<ptr> did=<hex> force=<0|1> bound=<0|1|2> cell=<hex> -- NEW.
* RenderDeviceD3D::DrawMeshInternal @0x0059F360 entry. gfx/did are read
* DIRECTLY from arg2 (no degrade-level resolution needed -- this is the
* already-selected CGfxObj*, cheaper/more reliable than the PD line's
* read); force = arg3; bound = arg4 (BoundingType: 0=OUTSIDE,
* 1=PARTIALLY_INSIDE, 2=ENTIRELY_INSIDE per
* oh1-built-mesh-view-contract.md's viewconeCheck table); cell = same
* best-effort DrawPartCell correlation as PD.
* There is no separate printed line for RenderDeviceD3D::DrawPartCell -- it
* is instrumented SILENTLY (register store only, no .printf) purely to
* supply the "cell" field above. DrawPartCell's own arg2 IS the cell DID
* directly (acclient_2013_pseudo_c.txt:429177 -- DrawObjCell calls
* "DrawPartCell(this, arg2->m_DID.id, &arg2->vtable, 0)"), so this is a
* clean 1-instruction, zero-.printf hook cheap enough to run every part
* draw's containing cell-walk turn.
*
* OFFSETS (VERIFY-IN-RECON -- see oh-recon.cdb's dt list):
* CPhysicsPart+0x14 = deg_level, +0x1C = draw_state, +0x20 = gfxobj,
* +0x24 = gfxobj_scale, +0x78 = draw_pos, +0xC0 = material, +0xC4 =
* surfaces, +0xDC = m_current_render_frame_num, +0xE0 = physobj.
* +0x1C/+0x20/+0xC0/+0xC4/+0xDC/+0xE0 are TRIPLE-confirmed: OH1's own
* Ghidra table (oh1-built-mesh-view-contract.md "View state"/"Built
* content and stamps") cites them independently, AND they fall out of
* acclient.h:31151-31173's field order (CYpt float @0x00, viewer_heading
* Vector3 @0x04, degrades ptr @0x10, deg_level uint @0x14, deg_mode int
* @0x18, draw_state int @0x1C, gfxobj ptr @0x20, ...). +0x14 (deg_level)
* is ONLY the acclient.h-order derivation -- OH1 never independently
* Ghidra-cited that specific field, though its immediate neighbors
* (+0x1C, +0x20) are both confirmed, which is reassuring but not proof.
* Confirm with `dt acclient!CPhysicsPart` in oh-recon.cdb before trusting
* the PD line's degrade-level GfxObj resolution.
* CGfxObj+0x6C = constructed_mesh, +0x70 = use_built_mesh, +0x90 =
* drawing_sphere, +0x94 = drawing_bsp -- same triple-confirmation
* (OH1 table + acclient.h:31712-31731 field order, matched exactly).
* Not directly read by this script, listed for completeness/cross-check.
* RenderDeviceD3D::DrawMeshInternal(this@ecx, CGfxObj* arg2, uint8 arg3,
* BoundingType arg4) -- arg2 @ esp+4, arg3 @ esp+8, arg4 @ esp+0xC.
* Signature: acclient_2013_pseudo_c.txt:427965 ("this @ ecx" annotation,
* same shape as the already-proven DrawBuilding/DrawEnvCell hooks).
* CPhysicsPart::Draw(this, int32 arg2) -- plain __thiscall, this=ecx,
* arg2 (force) @ esp+4. Signature: acclient_2013_pseudo_c.txt:274964.
* RenderDeviceD3D::DrawPartCell(this@ecx, uint32 arg2, CPartCell* arg3,
* uint32 arg4) -- arg2 (cell DID) @ esp+4. Signature:
* acclient_2013_pseudo_c.txt:429198.
*
* RESOLVED 2026-09-02 (lead): DBObj::m_DID is at +0x28. DBObj : Interface lays out vfptr@0, m_dataCategory@4, m_bLoaded@8, m_timeStamp(8-byte)@0x10, m_pNext@0x18, m_pLast@0x1C, m_pMaintainer@0x20, m_numLinks@0x24, m_DID@0x28; DBObj is the FIRST base of CGfxObj and of CObjCell (via SerializeUsingPackDBObj), and CEnvCell+0x28 is the offset every FW0 capture already proved. oh-recon.cdb's dt acclient!DBObj re-confirms. Original note kept below for provenance:
* GfxObj DataID offset. CGfxObj : DBObj (acclient.h:31712), and DBObj's
* own m_DID field (acclient.h:27579) sits behind an "Interface" vtable
* base, a `long double` timestamp, and several pointers whose EXACT
* compiler-inserted padding this research could not hand-derive with
* confidence (x86 MSVC alignment of the `long double`/double member under
* DBObj's own __declspec(align(4)) is genuinely ambiguous from the header
* text alone). DO NOT trust the placeholder below. Replace
* 0x28 with the real hex offset oh-recon.cdb's
* `dt acclient!CGfxObj` / `dt acclient!DBObj` output reports for m_DID
* before running this script for real. There is no runner-substituted
* default for this token (oh-run-capture.ps1 only fills <LOG>/<FRAMES>) --
* hand-edit the two `0x28` occurrences below.
*
* CALLING-CONVENTION NOTE: CPhysicsPart::Draw and DrawMeshInternal both use
* the proven "this@ecx (or plain thiscall), remaining args on the stack
* starting at esp+4" shape already exercised by the FW0 BLD/DI hooks, so
* this script's stack reads carry the SAME confidence as those. This is
* NOT true of the alpha-list functions in oh-capture-alpha-depth.cdb.template
* -- see that file's own calling-convention note.
*
* BEFORE USE: replace <FRAMES> (plain decimal digits), <LOG> (output path),
* and BOTH occurrences of 0x28 (see OPEN QUESTION above).
* <VPOS> is pre-resolved to Render::viewer_pos @0x0081ef00, same as
* oh-capture-walk.cdb.template.
.logopen <LOG>
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t5 = 0
bp 00453aa0 "r $t0 = @$t0 + 1; .printf \"F %d\\n\", @$t0; .printf \"P %08x %08x %08x %08x %08x %08x %08x %08x\\n\", poi(0081ef00+4), poi(0081ef00+0x3c), poi(0081ef00+0x40), poi(0081ef00+0x44), poi(0081ef00+8), poi(0081ef00+0xc), poi(0081ef00+0x10), poi(0081ef00+0x14); .if (@$t0 < 0n<FRAMES>) { gc }"
bp 005a07a0 "r $t5 = poi(@esp+4); gc"
bp 0050d7a0 "r $t6 = poi(poi(@ecx+0x20) + poi(@ecx+0x14)*4); .printf \"PD gfx=%08x did=%08x force=%d cell=%08x\\n\", @$t6, poi(@$t6+0x28), poi(@esp+4), @$t5; gc"
bp 0059f360 "r $t7 = poi(@esp+4); .printf \"DM gfx=%08x did=%08x force=%d bound=%d cell=%08x\\n\", @$t7, poi(@$t7+0x28), by(@esp+8), poi(@esp+0xc), @$t5; gc"
g
.echo ===DETACHING===
qd
.logclose

View file

@ -0,0 +1,75 @@
* OH1 walk-oracle capture: WALK (2026-09-02). Adds the cell-shell/object-cell
* stamp events (EC/OC) to the exact proven FW0 F/P/LS/BLD/DI/DC per-frame
* shape. Frame count default 5 (substitute <FRAMES> via
* oh-run-capture.ps1 -Frames, or by hand).
*
* AUTO-DETACH: this file deliberately does NOT reuse FW0's own
* ".if (@$t0 >= 0nN) { qd } .else { gc }" shape -- that puts `qd` inside a
* CONDITIONAL bp action, which the project's own retail-debugger toolchain
* memory documents as NEVER FIRING (qd/q/qq inside a bp action is silently
* ignored; re-confirmed the hard way on issue #177, see
* claude-memory/project_retail_debugger.md). Instead this uses the proven
* tools/cdb/pview-spiral2.cdb recipe: the frame-marker bp's action calls
* `gc` while below threshold and simply omits `gc` on the terminal hit (so
* the action "falls through" and the whole process genuinely stops); control
* then returns to the script's single top-level `g`, and a TOP-LEVEL `qd`
* (not inside any bp action) detaches cleanly. Every other bp in this file
* always `gc`s unconditionally -- only the frame marker decides when to
* stop, exactly like every other capture script in this directory.
*
* LINE FORMATS:
* F <n> -- unchanged from FW0
* P <cellid> <ox> <oy> <oz> <qw> <qx> <qy> <qz> -- unchanged from FW0
* LS -- unchanged from FW0
* BLD <cellid> -- unchanged from FW0
* DI <cellid> -- unchanged from FW0
* DC pv=<PView*> ov=<n> n=<n>: <ids...> -- unchanged from FW0
* EC <cellid> -- NEW. RenderDeviceD3D::DrawEnvCell
* @0x0059F170 entry; id is the
* CEnvCell's m_DID (arg2+0x28).
* OC <cellid> -- NEW. RenderDeviceD3D::
* DrawObjCellForDummies
* @0x005A0760 entry; id is the
* CObjCell's m_DID (arg2+0x28).
* EC/OC together let a human line up "which cell shells/object lists did
* PView::DrawCells' reverse loops (steps 9-10 in oh1-depth-lifecycle.md)
* actually stamp-draw this frame" against the DC line's flood-cell roster.
*
* OFFSETS (VERIFY-IN-RECON -- see oh-recon.cdb, all in this file's dt list):
* CEnvCell+0x28 / CObjCell+0x28 = m_DID -- PROVEN, not a guess: this is
* the exact offset the existing FW0 DI/DC/BLD lines already read in
* production captures (docs/research/2026-08-30-fw-walk-oracle/*.log),
* and CEnvCell : CObjCell (acclient.h:32072) shares the base-class
* layout, so DrawObjCellForDummies's CObjCell* argument reads the same
* way. Still listed in oh-recon.cdb's dt for a fresh re-confirmation.
* RenderDeviceD3D::DrawEnvCell(this@ecx, CEnvCell* arg2) -- arg2 @ esp+4.
* Signature: acclient_2013_pseudo_c.txt:427885. Same "this@ecx, one
* stack arg" shape as the already-proven DrawBuilding/DrawInside hooks.
* RenderDeviceD3D::DrawObjCellForDummies(this, CObjCell* arg2) -- plain
* __thiscall, arg2 @ esp+4. Signature: acclient_2013_pseudo_c.txt:429177.
*
* BEFORE USE: replace <FRAMES> (plain decimal digits, e.g. "5" -- the
* template already supplies the "0n" decimal-radix prefix) and <LOG>
* (output path). oh-run-capture.ps1 -Frames does this substitution.
* <VPOS> is pre-resolved to Render::viewer_pos @0x0081ef00 (the address the
* FW0 posed captures already used successfully -- see
* claude-memory/project_frame_walk_campaign.md 2026-08-30 evening entry and
* tools/walk-oracle/fw0-capture-pose.cdb.template's own <VPOS> note). Re-run
* `x acclient!*viewer_pos*` in oh-recon.cdb if this session's binary differs.
.logopen <LOG>
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
bp 00453aa0 "r $t0 = @$t0 + 1; .printf \"F %d\\n\", @$t0; .printf \"P %08x %08x %08x %08x %08x %08x %08x %08x\\n\", poi(0081ef00+4), poi(0081ef00+0x3c), poi(0081ef00+0x40), poi(0081ef00+0x44), poi(0081ef00+8), poi(0081ef00+0xc), poi(0081ef00+0x10), poi(0081ef00+0x14); .if (@$t0 < 0n<FRAMES>) { gc }"
bp 005a4840 "r $t2 = poi(@ecx+0x60); .printf \"DC pv=%08x ov=%d n=%d:\", @ecx, poi(@ecx+0x38), @$t2; r $t3 = poi(@ecx+0x50); r $t4 = 0; .while (@$t4 < @$t2) { r $t5 = poi(@$t3 + @$t4*4); .printf \" %08x\", poi(@$t5+0x28); r $t4 = @$t4 + 1 }; .printf \"\\n\"; gc"
bp 005a5860 ".printf \"DI %08x\\n\", poi(poi(@esp+4)+0x28); gc"
bp 00506330 ".printf \"LS\\n\"; gc"
bp 0059f2a0 ".printf \"BLD %08x\\n\", poi(poi(@esp+4)+0x4c); gc"
bp 0059f170 ".printf \"EC %08x\\n\", poi(poi(@esp+4)+0x28); gc"
bp 005a0760 ".printf \"OC %08x\\n\", poi(poi(@esp+4)+0x28); gc"
g
.echo ===DETACHING===
qd
.logclose

View file

@ -0,0 +1,131 @@
# OH1 walk-oracle capture runner (2026-09-02).
#
# Substitutes <LOG> and the <FRAMES> frame-count token into one of this
# directory's oh-capture-*.cdb.template files (or runs oh-recon.cdb
# unmodified, since it has no <FRAMES> token to fill), verifies the LIVE
# retail acclient.exe process actually pairs with our named-retail PDB
# (aborting on mismatch -- see claude-memory/project_retail_debugger.md and
# CLAUDE.md's "Retail debugger toolchain" section for why this matters: a
# 2015 C:\Turbine\Asheron's Call\acclient.exe build does NOT pair with
# refs/acclient.pdb, only the 2013 v11.4186 build does), then launches cdb
# in the BACKGROUND (non-blocking -- the operator needs the foreground to
# drive the retail client during the capture) with output redirected to a
# companion ".console" file next to -Log.
#
# NOTE: this script does NOT resolve every token some templates need.
# oh-capture-parts.cdb.template has two <GFXOBJ_DID_OFF> occurrences that
# are NOT substituted here (see that file's own OPEN QUESTION comment and
# the README) -- hand-edit those before pointing -Script at that template
# for a real session.
#
# Usage:
# .\oh-run-capture.ps1 -Script oh-recon.cdb -Log C:\path\to\oh-recon.log
# .\oh-run-capture.ps1 -Script oh-capture-walk.cdb.template `
# -Log C:\path\to\holtburg-doorway-still.walk.log -Frames 5
#
# Then, in another shell (this script returns immediately):
# Get-Content C:\path\to\holtburg-doorway-still.walk.log -Tail 50 -Wait
param(
[Parameter(Mandatory = $true)]
[string]$Script,
[Parameter(Mandatory = $true)]
[string]$Log,
[Parameter(Mandatory = $false)]
[ValidateRange(1, 200)]
[int]$Frames = 5
)
$ErrorActionPreference = "Stop"
$cdbExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe"
if (-not (Test-Path -LiteralPath $cdbExe)) {
throw "cdb.exe was not found at '$cdbExe'. Install Microsoft Store WinDbg (~50 MB)."
}
$symbolPath = "C:\Users\erikn\source\repos\acdream\refs"
if (-not (Test-Path -LiteralPath $symbolPath)) {
throw "Symbol directory '$symbolPath' does not exist. Every template in this " +
"directory hardcodes '.sympath $symbolPath' -- fix that path or create the directory."
}
$scriptDir = $PSScriptRoot
$scriptPath = if ([System.IO.Path]::IsPathRooted($Script)) { $Script } else { Join-Path $scriptDir $Script }
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "Capture script '$scriptPath' does not exist."
}
# --- verify the LIVE acclient.exe pairs with refs/acclient.pdb ---
$retail = Get-CimInstance Win32_Process -Filter "Name = 'acclient.exe'" |
Select-Object -First 1
if ($null -eq $retail) {
throw "No live retail acclient.exe process was found. Launch retail and get " +
"in-world before running a capture (see CLAUDE.md's Retail debugger " +
"toolchain section step 2)."
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
$checkScript = Join-Path $repoRoot "tools\pdb-extract\check_exe_pdb.py"
if (-not (Test-Path -LiteralPath $checkScript)) {
throw "PDB pairing checker not found at '$checkScript'."
}
$pairing = & py $checkScript $retail.ExecutablePath 2>&1 | Out-String
if ($pairing -notmatch "=== MATCH:") {
throw @"
The live retail executable at '$($retail.ExecutablePath)' does NOT pair with
refs/acclient.pdb. Do not attach -- symbol offsets will be silently wrong.
The matching binary is documented in claude-memory/project_retail_debugger.md
as v11.4186 (Sept 2013 EoR build, PDB GUID
9e847e2f-777c-4bd9-886c-22256bb87f32); a 2015 C:\Turbine\Asheron's
Call\acclient.exe build will MISMATCH.
$pairing
"@
}
Write-Host "PDB pairing OK: $($retail.ExecutablePath) (PID $($retail.ProcessId))"
# --- substitute <LOG> and <FRAMES>, write a temp copy ---
$logPath = if ([System.IO.Path]::IsPathRooted($Log)) { $Log } else { Join-Path (Get-Location) $Log }
$logDir = Split-Path -Parent $logPath
if ($logDir -and -not (Test-Path -LiteralPath $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$content = Get-Content -LiteralPath $scriptPath -Raw
$content = $content.Replace('<LOG>', $logPath)
$content = $content.Replace('<FRAMES>', $Frames.ToString([System.Globalization.CultureInfo]::InvariantCulture))
if ($content -match '<GFXOBJ_DID_OFF>') {
Write-Warning ("'$scriptPath' still contains an unresolved <GFXOBJ_DID_OFF> " +
"token -- this runner does not fill it in. Hand-edit the template with " +
"the offset oh-recon.cdb's 'dt acclient!CGfxObj' reports for the " +
"inherited DBObj::m_DID field before proceeding, or the capture will " +
"produce a garbage 'did=' field on every PD/DM line.")
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempScript = Join-Path $env:TEMP "oh-capture-$timestamp.cdb"
Set-Content -LiteralPath $tempScript -Value $content -Encoding ASCII
# --- launch cdb in the background ---
$consoleLog = "$logPath.console"
Write-Host "Attaching cdb to acclient.exe PID $($retail.ProcessId)."
Write-Host "Trace log: $logPath"
Write-Host "Console log: $consoleLog"
Write-Host "Temp script: $tempScript (not auto-deleted -- cdb needs it while running)"
Write-Host "Frame threshold: $Frames"
Write-Host "Tail the trace live with: Get-Content '$logPath' -Tail 50 -Wait"
$proc = Start-Process -FilePath $cdbExe `
-ArgumentList @("-pn", "acclient.exe", "-cf", $tempScript) `
-RedirectStandardOutput $consoleLog `
-RedirectStandardError "$consoleLog.err" `
-WindowStyle Hidden `
-PassThru
Write-Host "cdb launched in the background, PID $($proc.Id). This script returns now --"
Write-Host "drive the retail client into the target pose and hold still until the"
Write-Host "capture auto-detaches (frame marker hits the $Frames-frame threshold)."