Compare commits
8 commits
7da2a027d4
...
bb5003a849
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb5003a849 | ||
|
|
b153bbe5ad | ||
|
|
4ceac5cb40 | ||
|
|
83b020499b | ||
|
|
567078803f | ||
|
|
0a429a980c | ||
|
|
69d884a3d6 | ||
|
|
a9a01d8ba2 |
34 changed files with 2961022 additions and 151 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -31,3 +31,7 @@ imgui.ini
|
|||
|
||||
# User-only download cache (per-developer, not source)
|
||||
refs/
|
||||
|
||||
# Python tooling (under tools/) — bytecode caches
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -8,10 +8,17 @@ with a plugin API the original never had.
|
|||
|
||||
**The code is modern. The behavior is retail.**
|
||||
|
||||
Every AC-specific algorithm is ported from the decompiled retail client
|
||||
(`docs/research/decompiled/`, 22,225 functions, 688K lines of C). The code
|
||||
around those algorithms is modern C# with clean architecture. The plugin API
|
||||
exposes game state through well-defined interfaces.
|
||||
Every AC-specific algorithm is ported from the **named retail decomp**
|
||||
at `docs/research/named-retail/` — Sept 2013 EoR build PDB (18,366
|
||||
named functions, 5,371 named struct/class types) + Binary Ninja
|
||||
pseudo-C with 99.6% function-name recovery + verbatim retail header
|
||||
struct definitions. The older Ghidra `FUN_xxx` chunks under
|
||||
`docs/research/decompiled/` (22,225 functions, 688K lines) remain a
|
||||
fallback for chunk-by-chunk address-range navigation. **Grep
|
||||
`named-retail/acclient_2013_pseudo_c.txt` by `class::method` BEFORE
|
||||
decompiling fresh.** The code around those algorithms is modern C#
|
||||
with clean architecture. The plugin API exposes game state through
|
||||
well-defined interfaces.
|
||||
|
||||
**Architecture:** `docs/architecture/acdream-architecture.md` is the
|
||||
single source of truth for how the client is structured. All work must
|
||||
|
|
@ -84,19 +91,33 @@ a phase just landed, and move to the next todo item.
|
|||
always yes — keep going.** The single exception is visual verification;
|
||||
otherwise, act.
|
||||
|
||||
## Development workflow: decompile → verify → port
|
||||
## Development workflow: grep named → decompile → verify → port
|
||||
|
||||
**This is the mandatory workflow for implementing ANY AC-specific behavior.**
|
||||
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.**
|
||||
|
||||
### For each new feature or bug fix:
|
||||
|
||||
1. **DECOMPILE FIRST.** Before writing any AC-specific code, find the
|
||||
matching function in the decompiled client (`docs/research/decompiled/`)
|
||||
or decompile a new region using `tools/decompile_acclient.py`. Use
|
||||
the function map at `docs/research/acclient_function_map.md` to find
|
||||
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
|
||||
Sept 2013 EoR build PDB. `docs/research/named-retail/acclient.h`
|
||||
has every retail struct verbatim. `docs/research/named-retail/symbols.json`
|
||||
is greppable by name or address (regenerate via
|
||||
`py tools/pdb-extract/pdb_extract.py refs/acclient.pdb`). Only fall
|
||||
back to Step 1 below if the named pseudo-C lacks a function (rare —
|
||||
covers only the obfuscated/packed minority).
|
||||
|
||||
1. **DECOMPILE FIRST (fallback).** Only when grep-named-first returned
|
||||
nothing. Find the matching function in the older Ghidra chunks at
|
||||
`docs/research/decompiled/` or decompile a new region using
|
||||
`tools/decompile_acclient.py`. Use the function map at
|
||||
`docs/research/acclient_function_map.md` (cross-port index) +
|
||||
`docs/research/named-retail/symbols.json` (raw PDB names) to find
|
||||
known functions. If the function isn't mapped yet, search by
|
||||
characteristic constants (motion commands, magic numbers, string
|
||||
literals).
|
||||
|
|
@ -132,7 +153,11 @@ checked the decompiled code first, we got it right on the first try.
|
|||
### What NOT to do:
|
||||
|
||||
- **Do not guess** at AC-specific algorithms, formulas, constants, wire
|
||||
formats, or coordinate conventions. Ever.
|
||||
formats, or coordinate conventions. Ever. **The named retail decomp
|
||||
has the answer for almost everything; guessing is no longer a
|
||||
recoverable error, it's negligence.** If you can't find it in
|
||||
`docs/research/named-retail/`, file a research note and ASK before
|
||||
writing.
|
||||
- **Do not "fix" the decompiled code.** If the retail client does
|
||||
something that looks wrong, it's probably right. Verify before
|
||||
changing.
|
||||
|
|
@ -149,7 +174,8 @@ checked the decompiled code first, we got it right on the first try.
|
|||
Before marking any phase as done:
|
||||
|
||||
- [ ] Every AC-specific algorithm has a decompiled reference cited in
|
||||
comments (function address + chunk file)
|
||||
comments (named symbol + address from `named-retail/symbols.json`,
|
||||
OR function address + chunk file from older `decompiled/` chunks)
|
||||
- [ ] Conformance tests exist for the critical paths
|
||||
- [ ] The code was cross-referenced against at least 2 reference repos
|
||||
- [ ] `dotnet build` green, `dotnet test` green
|
||||
|
|
@ -430,6 +456,7 @@ decompiled client code and would have fixed it in minutes.
|
|||
|
||||
| Domain | Primary Oracle | Secondary | Notes |
|
||||
|--------|---------------|-----------|-------|
|
||||
| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." |
|
||||
| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** — decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. |
|
||||
| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. |
|
||||
| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** — includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. |
|
||||
|
|
|
|||
106
docs/ISSUES.md
106
docs/ISSUES.md
|
|
@ -114,45 +114,29 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #6 — Vital max ignores enchantment buffs + vitae
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (3-5% accuracy gap on a HUD bar)
|
||||
**Filed:** 2026-04-25
|
||||
**Component:** ui / player-state
|
||||
|
||||
**Description:** `LocalPlayerState.GetMaxApprox` computes the unenchanted base max for HP/Stam/Mana — `vital.(ranks+start) + attribute_contribution` with retail's hardcoded coefficients (Endurance/2, Endurance, Self). Live test shows bars at ~95% when buffs are presumably active (server character is `+Acdream`, GM-marker char with likely buff stack). Holtburger's `calculate_vital_current` adds `× multiplier + additive` from the active enchantment list — that's the missing 5%.
|
||||
|
||||
**Root cause / status:** Need to fold `Spellbook.ActiveEnchantments` into the max calc. Holtburger's `magic.rs` aggregates by `EnchantmentTypeFlags::SECOND_ATT` masked with the vital id. The same data already arrives via `MagicUpdateEnchantment` events that we wire into `Spellbook`.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.Core/Player/LocalPlayerState.cs` — `GetMaxApprox` returns the base; extend to also call `Spellbook` for vital-typed enchantment aggregation.
|
||||
- `src/AcDream.Core/Spells/Spellbook.cs` — needs an aggregator helper similar to holtburger's `get_vital_multiplier` / `get_vital_additive`.
|
||||
|
||||
**Research:** holtburger `crates/holtburger-world/src/player/stats_calc.rs:91-111`, `magic.rs` get_enchantment_multiplier / additive.
|
||||
|
||||
**Acceptance:** A `+Acdream` login shows Stam/Mana percent within 1% of retail's reading once any active buff multipliers are applied. (HP already at 100% indicates the unbuffed Health formula is already correct on its own.)
|
||||
|
||||
---
|
||||
|
||||
## #7 — PlayerDescription parser stops after spells (options/inventory/equipped not extracted)
|
||||
## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (Issue #5 needed only the early sections; later panels will need the rest)
|
||||
**Severity:** LOW (no current user-visible bug; future panels will need the data)
|
||||
**Filed:** 2026-04-25
|
||||
**Component:** net / player-state
|
||||
|
||||
**Description:** Current `PlayerDescriptionParser` walks through `Attribute / Skill / Spell` vector-flag blocks per holtburger but stops before the trailing options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped sections. Future panels (hotbar, inventory, character options) need that data; the parser will need extension. Holtburger's events.rs:462-625 has the full layout; the messy parts are `gameplay_options` (variable-length opaque blob requiring heuristic skip) and `desired_comps`.
|
||||
**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
|
||||
|
||||
**Root cause / status:** Just a scope decision — port-the-easy-bits-first approach. Reference shape lives in holtburger's full `unpack`; the only complex piece is `find_inventory_start_after_gameplay_options` (heuristic alignment search).
|
||||
**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
|
||||
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add coverage with synthetic payloads of the trailing sections.
|
||||
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
|
||||
- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
|
||||
|
||||
**Research:** holtburger `crates/holtburger-protocol/src/messages/player/events.rs:462-625` (full unpacker including the heuristic `find_inventory_start_after_gameplay_options`).
|
||||
**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
|
||||
|
||||
**Acceptance:** All sections of a real-world PlayerDescription parse to completion — verified via a packet capture or by feeding synthetic test fixtures covering every flag combination.
|
||||
**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -181,6 +165,76 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Recently closed
|
||||
|
||||
## #7 — [DONE 2026-04-25] PlayerDescription parser stops after spells (enchantment block parsed)
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
|
||||
**Resolution:** Extended `PlayerDescriptionParser` past the spell block to parse the Enchantment trailer per holtburger `events.rs:462-501`. Added `EnchantmentEntry` record with full wire payload (16 fields including the `StatMod` triad — type/key/val) + `EnchantmentBucket` (Multiplicative / Additive / Cooldown / Vitae per `EnchantmentMask`). `Parsed` now exposes `IReadOnlyList<EnchantmentEntry> Enchantments`. `GameEventWiring` routes each entry through the new `Spellbook.OnEnchantmentAdded(ActiveEnchantmentRecord)` overload with `StatModType` / `StatModKey` / `StatModValue` / `Bucket` populated. 2 new parser tests cover the enchantment block schema + Vitae singleton.
|
||||
|
||||
The remaining trailer sections (options / shortcuts / hotbars / inventory / equipped) are not yet parsed; filed as #13. Stopping after enchantments is intentional — it covers the highest-value section (issue #6 lights up) and avoids the heuristic `gameplay_options` walker that #13 needs.
|
||||
|
||||
---
|
||||
|
||||
## #12 — [DONE 2026-04-25] Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
|
||||
**Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff.
|
||||
|
||||
`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing.
|
||||
|
||||
---
|
||||
|
||||
## #6 — [DONE 2026-04-25 architecture; data flowing as of #12] Vital max ignores enchantment buffs + vitae
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath`
|
||||
**Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301.
|
||||
|
||||
**Architecture in place; data still flat.** Until ISSUES.md #12 lands the wire-format extension that captures `StatMod (type/key/val)` on `ActiveEnchantmentRecord`, the per-enchantment modifier value isn't aggregated yet — `EnchantmentMath.GetMod` returns `Identity (1.0, 0.0)` for every stat key. Once #12 wires the data, the existing aggregator + formula light up automatically. Live `+Acdream` Stam/Mana percent will continue to read ~95% until #12 lands.
|
||||
|
||||
6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate.
|
||||
|
||||
---
|
||||
|
||||
## #11 — [DONE 2026-04-25] Spell metadata loader (spells.csv → SpellTable)
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `feat(spells): #11 SpellTable — hydrate metadata from spells.csv at startup`
|
||||
**Resolution:** Added `SpellMetadata` record + `SpellTable` CSV loader (hand-rolled RFC 4180-ish parser for the quoted Description column with embedded commas). Wired into `Spellbook` constructor as optional metadata source; `Spellbook.TryGetMetadata(spellId, out)` returns the static record when found. `GameWindow` loads `data/spells.csv` from bin output at construction (file copied via `<None Include>` in `AcDream.App.csproj` from `docs/research/data/spells.csv`). Falls back to `SpellTable.Empty` + console warning if the file is missing (e.g. tooling contexts). 10 new tests covering: empty table, header-only, simple row, quoted description with commas, blank lines skipped, bad spell-id rows skipped, lookup hit/miss, RFC 4180 escaped-quote parsing.
|
||||
|
||||
---
|
||||
|
||||
## #9 — [DONE 2026-04-25] Address-correction sweep on `acclient_function_map.md`
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `docs(research): #9 sweep acclient_function_map.md against PDB symbols`
|
||||
**Resolution:** Wrote `tools/pdb-extract/check_function_map.py` that cross-checks 63 hand-curated entries against `docs/research/named-retail/symbols.json`. Findings: **zero entries matched address-and-name exactly** (confirms ~0x800-0xC10 byte delta vs the binary that produced our Ghidra chunks — different build revision). 38 entries corrected by PDB name lookup; 25 entries either lack PDB symbol records (inlined / non-public) or had wrong class assignments (e.g. `0x5387C0` claimed as `CTransition::find_collisions` was actually `CPolygon::polygon_hits_sphere`). Updated `acclient_function_map.md` with corrected addresses, kept legacy addresses in a "Was" column for traceability, added a top-of-file sweep summary.
|
||||
|
||||
---
|
||||
|
||||
## #10 — [DONE 2026-04-25] Wire `KillerNotification (0x01AD)`
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)`
|
||||
**Resolution:** Orphan parser at `GameEvents.ParseKillerNotification` existed but was never registered for dispatch in `GameEventWiring.cs`. Added a `combat.OnKillerNotification(victimName, victimGuid)` method on `CombatState` that fires a new `KillLanded` event, then registered the handler. One-line dispatch + 12-line CombatState method + one regression test fixture in `GameEventWiringTests`.
|
||||
|
||||
---
|
||||
|
||||
## #8 — [DONE 2026-04-25] pdb-extract tool: PDB → symbols.json + types.json
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar`
|
||||
**Resolution:** Pure-Python (no deps) MSF 7.00 PDB parser at `tools/pdb-extract/pdb_extract.py`. Reads `refs/acclient.pdb` (Sept 2013 EoR build), extracts S_PUB32 records from the symbol stream + named class/struct types from TPI, and writes JSON sidecars to `docs/research/named-retail/`:
|
||||
- `symbols.json` — 18,366 named functions (`address` + demangled `name` + raw `mangled`)
|
||||
- `types.json` — 5,371 named class/struct records (`name` + `size` + `kind`)
|
||||
|
||||
Best-effort MSVC C++ demangler handles the common `?Method@Class@@<sig>` patterns + ctors (`??0`) + dtors (`??1`); operator overloads and vtables left mangled. Spot-check verified: `CEnchantmentRegistry::EnchantAttribute` resolves to `0x00594570` exactly as the discovery agent reported. Runtime <1s.
|
||||
|
||||
Regen workflow: `py tools/pdb-extract/pdb_extract.py refs/acclient.pdb`. The committed JSON outputs are stable + ~3 MB combined; ripgrep/jq on them is faster than re-parsing.
|
||||
|
||||
---
|
||||
|
||||
## #5 — [DONE 2026-04-25] VitalsPanel stamina/mana bars always null
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
|
|
|
|||
|
|
@ -17,10 +17,15 @@ A modern C# .NET 10 Asheron's Call client that:
|
|||
|
||||
**The code is modern. The behavior is retail.**
|
||||
|
||||
Every AC-specific algorithm is ported faithfully from the decompiled retail
|
||||
client (docs/research/decompiled/, 688K lines). The code AROUND those
|
||||
algorithms is modern C# with clean architecture. The plugin API exposes
|
||||
game state through well-defined interfaces that the retail client never had.
|
||||
Every AC-specific algorithm is ported faithfully from the **named retail
|
||||
decomp** at `docs/research/named-retail/` — Sept 2013 EoR build PDB
|
||||
(18,366 named functions, 5,371 named struct types) + Binary Ninja
|
||||
pseudo-C with 99.6% function-name recovery + verbatim retail header
|
||||
struct definitions. The older Ghidra `FUN_xxx` chunks at
|
||||
`docs/research/decompiled/` (688K lines) remain a fallback for the
|
||||
obfuscated/packed minority. The code AROUND those algorithms is modern
|
||||
C# with clean architecture. The plugin API exposes game state through
|
||||
well-defined interfaces that the retail client never had.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,41 @@ Not detailed here; each gets its own brainstorm when it becomes relevant.
|
|||
|
||||
---
|
||||
|
||||
### Phase R — Retail research infrastructure
|
||||
|
||||
**Goal:** sustainable, scalable access to retail-client decompilation —
|
||||
named symbols, struct layouts, wire schemas — so every future port is
|
||||
a 5-second grep instead of 30-minute archaeology.
|
||||
|
||||
**Sub-pieces:**
|
||||
|
||||
- **✓ SHIPPED — R.1 — Named-retail corpus committed.** Shipped 2026-04-25
|
||||
(commit `a9a01d8`). `docs/research/named-retail/{acclient_2013_pseudo_c.txt,
|
||||
acclient.h, acclient.c}` + `docs/research/data/spells.csv` + vendored
|
||||
`references/actestclient/`. 1.4 M lines of named pseudo-C (99.6% function
|
||||
naming) + 70K lines of verbatim retail headers + 3,956 spells with `Family`
|
||||
for buff stacking + machine-readable wire schema in `messages.xml`.
|
||||
- **✓ SHIPPED — R.2 — pdb-extract tool + JSON sidecars.** Shipped 2026-04-25
|
||||
(commit `69d884a`). `tools/pdb-extract/pdb_extract.py` reads
|
||||
`refs/acclient.pdb` (Sept 2013 EoR build) and emits `symbols.json`
|
||||
(18,366 named functions) + `types.json` (5,371 named struct types) to
|
||||
`docs/research/named-retail/`. Pure Python, no deps, runs in <1 s.
|
||||
- **✓ SHIPPED — R.3 — actestclient vendored.** Shipped 2026-04-25 alongside
|
||||
R.1. `references/actestclient/` (covered by `references/` gitignore)
|
||||
includes the canonical `messages.xml` AC-protocol wire schema.
|
||||
|
||||
**Acceptance:** Step 0 of `CLAUDE.md`'s development workflow points at
|
||||
`named-retail/` first; subsequent issue closures (#6 / #7 / #9 / #11)
|
||||
all consume this foundation.
|
||||
|
||||
**Effects on other phases:** Issue closures unblocked by Phase R land
|
||||
under their existing letter phases (#6 enchantment buffs → Phase D /
|
||||
F.5 maintenance; #7 PlayerDescription trailer → Phase H.1 / F.2
|
||||
maintenance). The PDB symbols + headers also accelerate any future
|
||||
port in any phase — no separate listing here.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting work tracked in parallel
|
||||
|
||||
- **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land.
|
||||
|
|
|
|||
|
|
@ -1,25 +1,47 @@
|
|||
# acclient.exe Decompiled Function Map
|
||||
|
||||
Mapped from 22,225 decompiled functions (688K lines of C) against ACE's
|
||||
C# physics port and ACME's ClientReference.cs.
|
||||
Hand-curated cross-port index: maps select retail functions to our
|
||||
C# implementations + ACE / ACME equivalents + struct-offset notes.
|
||||
**This is the cross-port index, not the authoritative name list.**
|
||||
|
||||
For raw symbol→address lookup the authoritative source is
|
||||
`docs/research/named-retail/symbols.json` (18,366 entries from
|
||||
`refs/acclient.pdb`, the Sept 2013 EoR build PDB). Regenerate via
|
||||
`py tools/pdb-extract/pdb_extract.py refs/acclient.pdb`. Several
|
||||
addresses below were corrected against `symbols.json` in commit
|
||||
that closed issue #9 — match by name when in doubt.
|
||||
|
||||
Mapped from 22,225 decompiled functions (688K lines of C, the
|
||||
`docs/research/decompiled/` Ghidra chunks) against ACE's C# physics
|
||||
port and ACME's ClientReference.cs. Now augmented by the named
|
||||
retail decomp at `docs/research/named-retail/`.
|
||||
|
||||
## CPhysicsObj (chunk_00510000.c, chunk_00500000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x515020 | FUN_00515020 | PhysicsObj::update_object | Top-level per-frame update |
|
||||
| 0x5111D0 | FUN_005111d0 | PhysicsObj::UpdatePhysicsInternal | Euler integration: `pos += vel*dt + 0.5*accel*dt²` |
|
||||
| 0x511420 | FUN_00511420 | PhysicsObj::calc_acceleration | Sets gravity (-9.8 Z) when Gravity flag set |
|
||||
| 0x511EC0 | FUN_00511ec0 | PhysicsObj::set_velocity | Stores velocity, clamps to MaxVelocity (50.0) |
|
||||
| 0x511FA0 | FUN_00511fa0 | PhysicsObj::set_local_velocity | Body→world transform then set_velocity |
|
||||
| 0x511DE0 | FUN_00511de0 | PhysicsObj::set_on_walkable | Sets/clears OnWalkable transient flag |
|
||||
| 0x511560 | FUN_00511560 | PhysicsObj::report_collision_start | Fires environment collision callback |
|
||||
| 0x513AC0 | FUN_00513ac0 | PhysicsObj::report_collision_end | Fires collision-end callback |
|
||||
| 0x513B60 | FUN_00513b60 | PhysicsObj::handle_obj_collision | Object-to-object collision dispatch |
|
||||
| 0x515280 | FUN_00515280 | PhysicsObj::handle_collision | Elasticity bounce response |
|
||||
| 0x513730 | FUN_00513730 | PhysicsObj::UpdatePositionInternal | Position advance + interpenetration resolve |
|
||||
| 0x50F940 | FUN_0050f940 | PhysicsObj::calc_friction | Friction from ground normal + slope |
|
||||
| 0x510080 | FUN_00510080 | PhysicsObj::check_contact_velocity | Velocity vs contact normal check |
|
||||
> **Addresses corrected against `symbols.json` 2026-04-25 (issue #9
|
||||
> sweep).** All entries were off by ~0xC00-0x800 (different build
|
||||
> revision than our binary). PDB names are ground truth; previous
|
||||
> hand-curated addresses pointed mid-body.
|
||||
|
||||
| Address (PDB) | Was | Method | Description |
|
||||
|---------------|-----|--------|-------------|
|
||||
| 0x00515D10 | 0x515020 | CPhysicsObj::update_object | Top-level per-frame update |
|
||||
| 0x00510700 | 0x5111D0 | CPhysicsObj::UpdatePhysicsInternal | Euler integration: `pos += vel*dt + 0.5*accel*dt²` |
|
||||
| 0x00510950 | 0x511420 | CPhysicsObj::calc_acceleration | Sets gravity (-9.8 Z) when Gravity flag set |
|
||||
| 0x005113F0 | 0x511EC0 | CPhysicsObj::set_velocity | Stores velocity, clamps to MaxVelocity (50.0) |
|
||||
| 0x005114D0 | 0x511FA0 | CPhysicsObj::set_local_velocity | Body→world transform then set_velocity |
|
||||
| 0x00511310 | 0x511DE0 | CPhysicsObj::set_on_walkable | Sets/clears OnWalkable transient flag |
|
||||
| 0x00513FD0 | 0x511560 | CPhysicsObj::report_collision_start | Fires environment collision callback |
|
||||
| 0x00514620 | 0x513AC0 | CPhysicsObj::report_collision_end | Fires collision-end callback |
|
||||
| 0x00512C30 | 0x513730 | CPhysicsObj::UpdatePositionInternal | Position advance + interpenetration resolve |
|
||||
| 0x0050EE70 | 0x50F940 | CPhysicsObj::calc_friction | Friction from ground normal + slope |
|
||||
|
||||
> Three previous rows lacked exact PDB matches (probably inlined
|
||||
> private methods, not exported via S_PUB32):
|
||||
> `handle_obj_collision`, `handle_collision`, `check_contact_velocity`.
|
||||
> Their old addresses (0x513B60, 0x515280, 0x510080) point to no
|
||||
> PDB symbol and may be wrong; treat with caution and re-derive from
|
||||
> caller sites in `acclient_2013_pseudo_c.txt` if needed.
|
||||
|
||||
### PhysicsObj Struct Layout
|
||||
|
||||
|
|
@ -37,27 +59,32 @@ C# physics port and ACME's ClientReference.cs.
|
|||
|
||||
## CMotionInterp (chunk_00520000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x5286B0 | FUN_005286b0 | MotionInterp::get_jump_v_z | Jump Z velocity (delegates to WeenieObj) |
|
||||
| 0x528660 | FUN_00528660 | MotionInterp::jump_charge_is_allowed | Can charge jump? |
|
||||
| 0x5285E0 | FUN_005285e0 | MotionInterp::motion_allows_jump | Current anim permits jump? |
|
||||
| 0x528EC0 | FUN_00528ec0 | MotionInterp::jump_is_allowed | Top-level jump permission |
|
||||
| 0x528CD0 | FUN_00528cd0 | MotionInterp::get_leave_ground_velocity | Full 3D launch vector |
|
||||
| 0x529390 | FUN_00529390 | MotionInterp::jump | Initiate jump |
|
||||
| 0x529710 | FUN_00529710 | MotionInterp::LeaveGround | Reset jump state on airborne |
|
||||
| 0x5296D0 | FUN_005296d0 | MotionInterp::HitGround | Landing handler |
|
||||
| 0x528960 | FUN_00528960 | MotionInterp::get_state_velocity | Compute velocity for current motion |
|
||||
| 0x528A50 | FUN_00528a50 | MotionInterp::StopCompletely | Reset to Ready/idle |
|
||||
| 0x5287F0 | FUN_005287f0 | MotionInterp::adjust_motion | Apply pending motion adjustments |
|
||||
| 0x5293F0 | FUN_005293f0 | MotionInterp::apply_raw_movement | Raw→interpreted state conversion |
|
||||
| 0x529210 | FUN_00529210 | MotionInterp::apply_current_movement | Set physics velocity from interpreted state |
|
||||
| 0x528DD0 | FUN_00528dd0 | MotionInterp::contact_allows_move | Slope angle check |
|
||||
| 0x528F70 | FUN_00528f70 | MotionInterp::DoInterpretedMotion | Core animation state machine |
|
||||
| 0x529080 | FUN_00529080 | MotionInterp::StopInterpretedMotion | Stop specific interpreted motion |
|
||||
| 0x529140 | FUN_00529140 | MotionInterp::StopMotion | Stop specific raw motion |
|
||||
| 0x529930 | FUN_00529930 | MotionInterp::DoMotion | Process one raw motion command |
|
||||
| 0x529A90 | FUN_00529a90 | MotionInterp::PerformMovement | Top-level dispatcher (switch 1-5) |
|
||||
> **Addresses corrected against `symbols.json` 2026-04-25 (issue #9
|
||||
> sweep).** All entries were off by ~0xBE0-0xC10 bytes (consistent
|
||||
> delta within this class — same build-revision skew). Old addresses
|
||||
> in column 2 are kept for traceability against existing comments.
|
||||
|
||||
| Address (PDB) | Was (mid-body) | Method | Description |
|
||||
|---------------|----------------|--------|-------------|
|
||||
| 0x00527AA0 | 0x5286B0 | CMotionInterp::get_jump_v_z | Jump Z velocity (delegates to WeenieObj) |
|
||||
| 0x00527A50 | 0x528660 | CMotionInterp::jump_charge_is_allowed | Can charge jump? |
|
||||
| 0x005279E0 | 0x5285E0 | CMotionInterp::motion_allows_jump | Current anim permits jump? |
|
||||
| 0x005282B0 | 0x528EC0 | CMotionInterp::jump_is_allowed | Top-level jump permission |
|
||||
| 0x005280C0 | 0x528CD0 | CMotionInterp::get_leave_ground_velocity | Full 3D launch vector |
|
||||
| 0x00528780 | 0x529390 | CMotionInterp::jump | Initiate jump |
|
||||
| 0x00528B00 | 0x529710 | CMotionInterp::LeaveGround | Reset jump state on airborne |
|
||||
| 0x00528AC0 | 0x5296D0 | CMotionInterp::HitGround | Landing handler |
|
||||
| 0x00527D50 | 0x528960 | CMotionInterp::get_state_velocity | Compute velocity for current motion |
|
||||
| 0x00527E40 | 0x528A50 | CMotionInterp::StopCompletely | Reset to Ready/idle |
|
||||
| 0x00528010 | 0x5287F0 | CMotionInterp::adjust_motion | Apply pending motion adjustments |
|
||||
| 0x005287E0 | 0x5293F0 | CMotionInterp::apply_raw_movement | Raw→interpreted state conversion |
|
||||
| 0x00528870 | 0x529210 | CMotionInterp::apply_current_movement | Set physics velocity from interpreted state |
|
||||
| 0x00528240 | 0x528DD0 | CMotionInterp::contact_allows_move | Slope angle check |
|
||||
| 0x00528360 | 0x528F70 | CMotionInterp::DoInterpretedMotion | Core animation state machine |
|
||||
| 0x00528470 | 0x529080 | CMotionInterp::StopInterpretedMotion | Stop specific interpreted motion |
|
||||
| 0x00528530 | 0x529140 | CMotionInterp::StopMotion | Stop specific raw motion |
|
||||
| 0x00528D20 | 0x529930 | CMotionInterp::DoMotion | Process one raw motion command |
|
||||
| 0x00528E80 | 0x529A90 | CMotionInterp::PerformMovement | Top-level dispatcher (switch 1-5) |
|
||||
|
||||
### MotionInterp Struct Layout
|
||||
|
||||
|
|
@ -79,53 +106,76 @@ C# physics port and ACME's ClientReference.cs.
|
|||
|
||||
## CLandBlockStruct (chunk_00530000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x531D10 | FUN_00531d10 | IsSWtoNECut | Split direction helper (inner) |
|
||||
| 0x532A50 | FUN_00532a50 | ConstructPolygons | Outer 8×8 loop with 0xCCAC033 constants |
|
||||
| 0x532EB0 | FUN_00532eb0 | GetCellRotation / ConstructUVs | PalCode computation |
|
||||
| 0x532D10 | FUN_00532d10 | unpack | Deserialize from dat stream |
|
||||
| 0x531F10 | FUN_00531f10 | get_packed_size | Returns 0xF4 (244 bytes) |
|
||||
| 0x532440 | FUN_00532440 | AdjustPlanes | Normal accumulation + lighting |
|
||||
| 0x532290 | FUN_00532290 | CalcCellWater | Water depth check |
|
||||
> **Issue #9 sweep, 2026-04-25.** Some entries don't have exact PDB
|
||||
> matches because they're inlined or marked private (no S_PUB32). For
|
||||
> those, the old address may still be useful via Ghidra chunk
|
||||
> inspection. Where PDB has the name, use the corrected address.
|
||||
|
||||
| Address (PDB) | Was | Method | Description |
|
||||
|---------------|-----|--------|-------------|
|
||||
| (no S_PUB32) | 0x531D10 | CLandBlockStruct::IsSWtoNECut | Split direction helper (inner). PDB at 0x531D10 = `ConstructPolygons` — `IsSWtoNECut` is likely inlined. Check pseudo-C `0x531D10` neighborhood for the inline. |
|
||||
| 0x00531D10 | 0x532A50 | CLandBlockStruct::ConstructPolygons | Outer 8×8 loop with 0xCCAC033 constants |
|
||||
| 0x005329A0 | 0x532EB0 | CLandBlockStruct::ConstructUVs | PalCode computation |
|
||||
| (no S_PUB32) | 0x532D10 | CLandBlockStruct::unpack | Likely inlined or private; not in symbols.json. Cross-check `acclient.c`. |
|
||||
| (no S_PUB32) | 0x531F10 | CLandBlockStruct::get_packed_size | Returns 0xF4 (244 bytes). Likely inlined. |
|
||||
| (no S_PUB32) | 0x532440 | CLandBlockStruct::AdjustPlanes | Normal accumulation + lighting. Likely inlined. |
|
||||
| 0x00531550 | 0x532290 | CLandBlockStruct::CalcCellWater | Water depth check |
|
||||
|
||||
## CLandBlock (chunk_00530000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x530690 | FUN_00530690 | Init / constructor | Initialize landblock fields |
|
||||
| 0x5307E0 | FUN_005307e0 | release_all | Free all resources |
|
||||
| 0x531780 | FUN_00531780 | init_static_objs | Load static objects from LandBlockInfo |
|
||||
| 0x531000 | FUN_00531000 | release_visible_cells | Free cell data |
|
||||
| 0x5301E0 | FUN_005301e0 | grab_visible_cells | BFS neighbor expansion |
|
||||
| 0x530650 | FUN_00530650 | add_server_object | Add entity to landblock |
|
||||
> **Issue #9 sweep, 2026-04-25.** Several entries are not in PDB
|
||||
> public symbols (private/inlined). `release_all`, `init_static_objs`,
|
||||
> `release_visible_cells`, `grab_visible_cells` all corrected.
|
||||
|
||||
| Address (PDB) | Was | Method | Description |
|
||||
|---------------|-----|--------|-------------|
|
||||
| (no S_PUB32) | 0x530690 | CLandBlock::Init | Likely inlined ctor; not exported. |
|
||||
| 0x0052FCF0 | 0x5307E0 | CLandBlock::release_all | Free all resources |
|
||||
| 0x00530A40 | 0x531780 | CLandBlock::init_static_objs | Load static objects from LandBlockInfo |
|
||||
| 0x0052F480 | 0x531000 | CLandBlock::release_visible_cells | Free cell data |
|
||||
| 0x0052F460 | 0x5301E0 | CLandBlock::grab_visible_cells | BFS neighbor expansion |
|
||||
| (no S_PUB32) | 0x530650 | CLandBlock::add_server_object | Likely inlined / different overload. |
|
||||
|
||||
## LandDefs (chunk_005A0000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x5AAA30 | FUN_005aaa30 | get_vars | Set 8 coordinate constants |
|
||||
| 0x5AABB0 | FUN_005aabb0 | get_outside_lcoord | Cell ID → world coord |
|
||||
| 0x5AAC70 | FUN_005aac70 | AdjustToOutside | Normalize position to outdoor coords |
|
||||
| 0x5AAB50 | FUN_005aab50 | get_block_dir | Quadrant → Direction enum |
|
||||
> **Issue #9 sweep, 2026-04-25.** None of these match the PDB by name.
|
||||
> They're either inlined globals or our names were synthesised. Names
|
||||
> are kept for legacy compatibility; addresses unchanged from the
|
||||
> Ghidra chunk analysis. Re-derive via `acclient.c` cross-reference.
|
||||
|
||||
| Address (legacy) | Method | Description |
|
||||
|------------------|--------|-------------|
|
||||
| 0x5AAA30 | LandDefs::get_vars | Set 8 coordinate constants |
|
||||
| 0x5AABB0 | LandDefs::get_outside_lcoord | Cell ID → world coord |
|
||||
| 0x5AAC70 | LandDefs::AdjustToOutside | Normalize position to outdoor coords |
|
||||
| 0x5AAB50 | LandDefs::get_block_dir | Quadrant → Direction enum |
|
||||
|
||||
## Collision / Transition (chunk_00530000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x5384E0 | FUN_005384e0 | Sphere::SphereIntersectsRay | Ray-sphere intersection |
|
||||
| 0x539500 | FUN_00539500 | Polygon::sphere_intersects_poly | Sphere-polygon contact |
|
||||
| 0x539750 | FUN_00539750 | Polygon::sphere_intersects_solid | Both-side penetration |
|
||||
| 0x539BA0 | FUN_00539ba0 | Polygon::find_time_of_collision | Ray-plane-polygon t |
|
||||
| 0x539DF0 | FUN_00539df0 | Polygon::find_time_of_collision | Cylinder variant |
|
||||
| 0x53A040 | FUN_0053a040 | Polygon::find_walkable_collision | Edge normal return |
|
||||
| 0x539110 | FUN_00539110 | Polygon::calc_normal | Face normal from vertices |
|
||||
| 0x539060 | FUN_00539060 | Plane::ray_plane_intersect | Basic ray-plane t |
|
||||
| 0x538EB0 | FUN_00538eb0 | Sphere::slide_sphere | Slide along surface |
|
||||
| 0x538F50 | FUN_00538f50 | Sphere::land_on_sphere | Step down to surface |
|
||||
| 0x538180 | FUN_00538180 | CTransition::collide_with_point | Point collision handler |
|
||||
| 0x5387C0 | FUN_005387c0 | CTransition::find_collisions | Main collision loop |
|
||||
| 0x53A230 | FUN_0053a230 | Polygon::hits_walkable | Walkable surface check |
|
||||
> **Issue #9 sweep, 2026-04-25.** Many of these names don't appear
|
||||
> verbatim in PDB; either the actual class is `CCylSphere` /
|
||||
> `CSphere` differently scoped, or the methods are private/inlined.
|
||||
> A few addresses now pointed to entirely different functions — for
|
||||
> example `0x5387C0` claimed `CTransition::find_collisions` but PDB
|
||||
> shows that address is `CPolygon::polygon_hits_sphere`. Use
|
||||
> `acclient_2013_pseudo_c.txt` to re-derive these as ground truth.
|
||||
> See ISSUES.md #9 closure for the verification pass.
|
||||
|
||||
| Address (legacy) | Method (legacy name) | Notes |
|
||||
|------------------|----------------------|-------|
|
||||
| 0x5384E0 | Sphere::SphereIntersectsRay | Not in PDB at this address; re-derive. |
|
||||
| 0x539500 | Polygon::sphere_intersects_poly | Not in PDB; re-derive. |
|
||||
| 0x539750 | Polygon::sphere_intersects_solid | PDB at 0x539750 = `CMaterial::SetDiffuseSimple` — wrong class assignment. |
|
||||
| 0x539BA0 | Polygon::find_time_of_collision | Not in PDB; re-derive. |
|
||||
| 0x539DF0 | Polygon::find_time_of_collision (cyl) | Not in PDB; re-derive. |
|
||||
| 0x53A040 | Polygon::find_walkable_collision | PDB at 0x53A040 = `BSPTREE::RemoveNonPortalNodes` — wrong. |
|
||||
| 0x539110 | Polygon::calc_normal | Not in PDB; re-derive. |
|
||||
| 0x539060 | Plane::ray_plane_intersect | Not in PDB; re-derive. |
|
||||
| 0x00537440 / 0x00536F40 / 0x00537A10 | CSphere::slide_sphere | Three overloads in PDB — old address `0x538EB0` was wrong. |
|
||||
| 0x005379A0 | CSphere::land_on_sphere (corrected from 0x538F50) | |
|
||||
| 0x538180 | CTransition::collide_with_point | Not in PDB; re-derive. |
|
||||
| 0x005387C0 | CPolygon::polygon_hits_sphere | PDB ground truth. Was claimed as `CTransition::find_collisions` (wrong). |
|
||||
| 0x53A230 | Polygon::hits_walkable | Not in PDB; re-derive. |
|
||||
|
||||
## WeenieObject vtable (confirmed from call sites)
|
||||
|
||||
|
|
@ -141,6 +191,31 @@ C# physics port and ACME's ClientReference.cs.
|
|||
|
||||
## PhysicsEngine (chunk_00450000.c)
|
||||
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x452A10 | FUN_00452a10 | PhysicsEngine::update | Main simulation loop |
|
||||
| Address (legacy) | Method | Description |
|
||||
|------------------|--------|-------------|
|
||||
| 0x452A10 | PhysicsEngine::update | Not in PDB at this address (private/inlined or a different class). Re-derive from `acclient_2013_pseudo_c.txt` if needed. |
|
||||
|
||||
---
|
||||
|
||||
## Issue #9 sweep summary
|
||||
|
||||
Pure docs sweep on 2026-04-25 against `docs/research/named-retail/symbols.json`.
|
||||
Tooling: `tools/pdb-extract/check_function_map.py`. Findings:
|
||||
|
||||
- **63 entries checked.** Zero matched address-and-name exactly (all
|
||||
hand-curated addresses were off by 0x800-0xC10 bytes — confirms the
|
||||
PDB build is from a different revision than the binary that produced
|
||||
our Ghidra chunks).
|
||||
- **38 entries corrected** by name lookup (PDB name found, address
|
||||
swapped to the function start).
|
||||
- **25 entries lack PDB matches.** Either inlined / non-public (no
|
||||
S_PUB32 record), or our hand-derived names were synthesized from
|
||||
call-site analysis and don't match the actual MSVC symbol. The
|
||||
ones that pointed to other functions in PDB (e.g.
|
||||
`CTransition::find_collisions` → actually `CPolygon::polygon_hits_sphere`)
|
||||
are flagged for re-derivation.
|
||||
|
||||
When in doubt: grep `acclient_2013_pseudo_c.txt` by class name to find
|
||||
the actual function bodies, and use `acclient_function_map.md` for the
|
||||
cross-port (ACE/ACME) hints + struct-offset lookups, not as the
|
||||
authoritative address table.
|
||||
|
|
|
|||
29
docs/research/data/README.md
Normal file
29
docs/research/data/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Game-data tables
|
||||
|
||||
Reference data tables loaded by acdream at runtime or used as research
|
||||
input. Tracked in git so subagents + post-compaction sessions inherit
|
||||
them without round-tripping through `refs/`.
|
||||
|
||||
## Contents
|
||||
|
||||
| File | Loader | Notes |
|
||||
|------|--------|-------|
|
||||
| `spells.csv` | `src/AcDream.Core/Spells/SpellTable.cs` (load via `SpellTable.LoadFromCsv` at `GameWindow.OnLoad`). Copied to `bin/<config>/net10.0/data/spells.csv` via `<None Update>` in `AcDream.App.csproj`. | 3,956 retail spells × 35 columns. Key fields: `Spell ID`, `Name`, `School`, `Family` (buff stacking bucket — issue #6 needs this), `IconId [Hex]`, `Mana`, `Duration`, `IsDebuff`, `IsFellowship`, `Description`. |
|
||||
|
||||
## Format note — `spells.csv`
|
||||
|
||||
RFC 4180 with header row. The `Description` column is quoted with embedded
|
||||
commas; everything else is plain. See `SpellTable` parser for the canonical
|
||||
column ordering.
|
||||
|
||||
## Provenance
|
||||
|
||||
`spells.csv` came from a server-side database export of the retail
|
||||
spell table (likely a community-shared dump from circa 2017-2019 with
|
||||
~99% field coverage). Spell IDs match the wire opcodes used by ACE.
|
||||
The `Family` column is the AC retail spell-family enum used for buff
|
||||
stacking.
|
||||
|
||||
If the file ever needs updating from a fresher source, replace
|
||||
`spells.csv` in this directory; the `<None Update PreserveNewest>`
|
||||
copy will sync it to bin output on next build.
|
||||
3957
docs/research/data/spells.csv
Normal file
3957
docs/research/data/spells.csv
Normal file
File diff suppressed because it is too large
Load diff
70
docs/research/named-retail/README.md
Normal file
70
docs/research/named-retail/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Named-retail decompilation reference
|
||||
|
||||
**This is the primary reference for any AC-specific algorithm, formula,
|
||||
constant, wire format, or coordinate convention.** Every retail symbol
|
||||
question goes here first — _before_ touching `docs/research/decompiled/`
|
||||
(the older Ghidra `FUN_xxx` chunks, which remain a fallback for
|
||||
chunk-by-chunk address-range navigation).
|
||||
|
||||
## Contents
|
||||
|
||||
| File | Source | Use for |
|
||||
|------|--------|---------|
|
||||
| `acclient_2013_pseudo_c.txt` | Binary Ninja pseudo-C export of the Sept 2013 EoR `acclient.exe` build. 1,437,645 lines. **99.6% function-name recovery** (54,873 named, 232 still `sub_*`). Class names + method names + many struct field names recovered. | **Primary symbol lookup.** Grep by `class::method` to find function bodies. Address-prefixed lines: `00<address> <return-type> __<conv> Class::Method(args)`. |
|
||||
| `acclient.h` | IDA-decompiled retail headers. 70,719 lines / 1.7 MB. Full struct + class definitions for the entire AC client object model: `Attribute`, `SecondaryAttribute`, `AttributeCache`, `Attribute2ndTable`, `SkillFormula`, `Enchantment`, `CEnchantmentRegistry` (with `_mult_list` / `_add_list` / `_vitae`), `CSpellBook`, `MotionState`, `RawMotionState`, `MoveToStatePack`, `CACQualities`, `CPhysicsObj`. | **Struct field names + offsets.** When you need to know what a field is actually called, grep this file. |
|
||||
| `acclient.c` | Ghidra (or IDA) full-binary decomp export. 1,327,522 lines / 46 MB. Mixed naming: ~5,100 named methods + ~8,553 still `FUN_xxx`. Has named struct types like `_max_health`, `_add_list` that the chunked Ghidra export under `decompiled/` lacks. | **Secondary cross-reference.** Useful when pseudo-C body is corrupt / packed and you need a different decompiler's view. |
|
||||
| `symbols.json` | Generated by `tools/pdb-extract/` from `refs/acclient.pdb`. 18,366 entries: `{"address", "name", "obj_module"}`. | **Programmatic symbol lookup.** `jq '.[] | select(.name == "...")'` returns the start address. |
|
||||
| `types.json` | Generated by `tools/pdb-extract/`. 3,172 named struct/class type records with field offsets + sizes. | **Programmatic type-layout queries.** |
|
||||
|
||||
## Workflow — grep first, decompile second
|
||||
|
||||
The `CLAUDE.md` "Development workflow" mandates **Step 0: GREP NAMED FIRST**
|
||||
before any decompilation work. Concretely:
|
||||
|
||||
```bash
|
||||
# Find a function by class::method:
|
||||
grep -n "CEnchantmentRegistry::EnchantAttribute" docs/research/named-retail/acclient_2013_pseudo_c.txt
|
||||
|
||||
# Find a struct definition:
|
||||
grep -n "^struct.*CEnchantmentRegistry" docs/research/named-retail/acclient.h
|
||||
|
||||
# Find by raw address (PDB and pseudo-C addresses match):
|
||||
grep -n "^00594570" docs/research/named-retail/acclient_2013_pseudo_c.txt
|
||||
|
||||
# Programmatic symbol lookup:
|
||||
cat docs/research/named-retail/symbols.json | jq '.[] | select(.name == "CEnchantmentRegistry::EnchantAttribute")'
|
||||
```
|
||||
|
||||
Only fall back to `docs/research/decompiled/chunk_*.c` (Ghidra `FUN_xxx`
|
||||
chunks) when the named pseudo-C lacks a function — rare; covers only the
|
||||
obfuscated/packed minority.
|
||||
|
||||
## Origin
|
||||
|
||||
- **PDB**: `refs/acclient.pdb` — Sept 2013 End-of-Retail (EoR) build, MSVC
|
||||
7.00 program database, 29 MB. Build root: `d:\ac1_sep13\`.
|
||||
- **pseudo-C**: Binary Ninja export of the `acclient_2013-2024-09-11.bndb`
|
||||
database (also in `refs/`). 99.6% naming via PDB-overlay analysis.
|
||||
- **acclient.h**: IDA-decompiled headers from a parallel RE effort.
|
||||
- **acclient.c**: full-binary IDA/Ghidra decomp export (different from our
|
||||
Ghidra chunks under `decompiled/`).
|
||||
|
||||
The `refs/` directory is gitignored (per-developer download cache); these
|
||||
extracts are committed so subagents and post-compaction sessions inherit
|
||||
them automatically.
|
||||
|
||||
## Address mapping caveat
|
||||
|
||||
The PDB is from a slightly different build run than the binary that
|
||||
produced our Ghidra chunks (~0xC00 byte delta on some functions). When
|
||||
correcting addresses in `docs/research/acclient_function_map.md`, **match
|
||||
by name, not by raw address.**
|
||||
|
||||
## Regenerating `symbols.json` / `types.json`
|
||||
|
||||
```powershell
|
||||
py tools\pdb-extract\pdb_extract.py refs\acclient.pdb
|
||||
```
|
||||
|
||||
Outputs land in `docs/research/named-retail/symbols.json` and
|
||||
`docs/research/named-retail/types.json`. See `tools/pdb-extract/README.md`.
|
||||
1327522
docs/research/named-retail/acclient.c
Normal file
1327522
docs/research/named-retail/acclient.c
Normal file
File diff suppressed because it is too large
Load diff
70719
docs/research/named-retail/acclient.h
Normal file
70719
docs/research/named-retail/acclient.h
Normal file
File diff suppressed because it is too large
Load diff
1437645
docs/research/named-retail/acclient_2013_pseudo_c.txt
Normal file
1437645
docs/research/named-retail/acclient_2013_pseudo_c.txt
Normal file
File diff suppressed because it is too large
Load diff
91832
docs/research/named-retail/symbols.json
Normal file
91832
docs/research/named-retail/symbols.json
Normal file
File diff suppressed because it is too large
Load diff
26857
docs/research/named-retail/types.json
Normal file
26857
docs/research/named-retail/types.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -62,10 +62,19 @@ ground truth. ACE has the ENTIRE system already in C#:
|
|||
- `references/ACE/Source/ACE.Server/Physics/Collision/CollisionInfo.cs`
|
||||
- `references/ACE/Source/ACE.Server/Physics/Collision/ObjectInfo.cs`
|
||||
|
||||
### Decompiled ground truth:
|
||||
- `docs/research/decompiled/chunk_00530000.c` — BSP, Polygon, Sphere collision
|
||||
- `docs/research/decompiled/chunk_00500000.c` — PhysicsObj, transition callers
|
||||
- `docs/research/acclient_function_map.md` — mapped functions
|
||||
### Decompiled ground truth (named-retail is now primary, 2026-04-25):
|
||||
- **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — grep for
|
||||
`BSPTree::`, `BSPNode::`, `BSPLeaf::`, `CPolygon::`, `CCylSphere::`,
|
||||
`Transition::`, `CPhysicsObj::`, `SpherePath::` to find named bodies.
|
||||
- **`docs/research/named-retail/acclient.h`** — verbatim retail struct
|
||||
layouts for the BSP / Sphere / Transition types.
|
||||
- **`docs/research/named-retail/symbols.json`** — name↔address lookup.
|
||||
- `docs/research/decompiled/chunk_00530000.c` — older Ghidra fallback for
|
||||
BSP / Polygon / Sphere collision (FUN_xxx names).
|
||||
- `docs/research/decompiled/chunk_00500000.c` — older Ghidra fallback for
|
||||
PhysicsObj / transition callers.
|
||||
- `docs/research/acclient_function_map.md` — hand-curated cross-port index
|
||||
(ACE / ACME mappings + struct-offset notes).
|
||||
|
||||
### Pseudocode (already written):
|
||||
- `docs/research/transition_pseudocode.md` — full system documented
|
||||
|
|
@ -90,10 +99,12 @@ ground truth. ACE has the ENTIRE system already in C#:
|
|||
|
||||
For EVERY function:
|
||||
|
||||
1. **DECOMPILE FIRST.** Find the matching function in the decompiled
|
||||
client (`docs/research/decompiled/`). Use the function map at
|
||||
`docs/research/acclient_function_map.md`. If not mapped, search
|
||||
by characteristic constants or struct offsets.
|
||||
1. **GREP NAMED FIRST, then DECOMPILE FALLBACK.** Search the named
|
||||
retail decomp first: `grep -n "ClassName::Method" docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||
For struct layouts: `grep -n "^struct ClassName" docs/research/named-retail/acclient.h`.
|
||||
Only if the named pseudo-C lacks a function (rare), fall back to the
|
||||
older `docs/research/decompiled/` chunks via the function map at
|
||||
`docs/research/acclient_function_map.md`.
|
||||
|
||||
2. **CROSS-REFERENCE ACE.** Read ACE's C# port of the same function.
|
||||
ACE provides naming and structure. Note any differences.
|
||||
|
|
|
|||
84
memory/project_named_decompilation.md
Normal file
84
memory/project_named_decompilation.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Project: named-retail decompilation foundation
|
||||
|
||||
**Agreed 2026-04-25.** Read once per session. Foundation commits:
|
||||
`a9a01d8` (corpus), `69d884a` (pdb-extract tool).
|
||||
|
||||
## What changed
|
||||
|
||||
The retail-client decompilation has gone from "FUN_xxx Ghidra chunks +
|
||||
71 hand-mapped functions" to **"18,366 named retail functions + 5,371
|
||||
named struct types + verbatim retail headers"**, all committed under
|
||||
`docs/research/named-retail/`. The `acclient.pdb` (Sept 2013 EoR build,
|
||||
MSVC 7.00) is the source of truth.
|
||||
|
||||
## Files at `docs/research/named-retail/`
|
||||
|
||||
| File | What | When to use |
|
||||
|---|---|---|
|
||||
| `acclient_2013_pseudo_c.txt` (62 MB / 1.44 M lines) | Binary Ninja pseudo-C with 99.6% naming. Address-prefixed lines. | **Primary symbol lookup.** Grep by `class::method`. |
|
||||
| `acclient.h` (1.7 MB / 70K lines) | IDA-decompiled retail headers. Every struct verbatim. | **Struct field names + offsets.** Grep by struct name. |
|
||||
| `acclient.c` (46 MB / 1.3 M lines) | IDA full-binary export. Mixed FUN_/named, but has named struct fields. | **Cross-reference when pseudo-C body is corrupt.** |
|
||||
| `symbols.json` (3 MB) | 18,366 entries: `address` + `name` (demangled `Class::Method`) + `mangled` (raw MSVC ABI). | **Programmatic name↔address lookup via jq.** |
|
||||
| `types.json` (506 KB) | 5,371 named class/struct records with size + kind. | **Programmatic type-layout queries.** |
|
||||
|
||||
## Workflow change — STEP 0 GREP NAMED FIRST
|
||||
|
||||
`CLAUDE.md` workflow now starts with **Step 0** before the older
|
||||
DECOMPILE step:
|
||||
|
||||
```bash
|
||||
# Find a function body
|
||||
grep -n "CEnchantmentRegistry::EnchantAttribute" \
|
||||
docs/research/named-retail/acclient_2013_pseudo_c.txt
|
||||
|
||||
# Find a struct
|
||||
grep -n "^struct.*CEnchantmentRegistry" \
|
||||
docs/research/named-retail/acclient.h
|
||||
|
||||
# Programmatic
|
||||
cat docs/research/named-retail/symbols.json | \
|
||||
jq '.[] | select(.name == "CEnchantmentRegistry::EnchantAttribute")'
|
||||
```
|
||||
|
||||
The older `docs/research/decompiled/` Ghidra chunks remain a fallback
|
||||
for the obfuscated/packed minority that pseudo-C lacks. ~5 second
|
||||
grep replaces ~30 minute archaeology for typical lookups.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Grep `named-retail/` first.** Always. The "Do not guess" rule in
|
||||
CLAUDE.md is upgraded: with the PDB available, guessing is no
|
||||
longer recoverable error — it's negligence.
|
||||
2. **Match by name, not raw address.** The PDB build has a ~0xC00
|
||||
byte delta vs the binary that produced our older chunks. Address
|
||||
lookups in `symbols.json` are correct; the older
|
||||
`acclient_function_map.md` had several mid-body addresses (issue
|
||||
#9 sweep corrected them).
|
||||
3. **Regenerate JSON sidecars when needed:**
|
||||
```powershell
|
||||
py tools\pdb-extract\pdb_extract.py refs\acclient.pdb
|
||||
```
|
||||
4. **`refs/` stays gitignored.** It's per-developer download cache;
|
||||
the committed extracts at `docs/research/named-retail/` are what
|
||||
subagents and post-compaction sessions inherit.
|
||||
|
||||
## Counts (Sept 2013 EoR build)
|
||||
|
||||
- 18,366 named public function symbols (`S_PUB32`, code flag set)
|
||||
- 5,371 unique named class/struct types (filtered for non-forward-declared)
|
||||
- 1,053 .obj compilation units recorded in DBI
|
||||
- Build root preserved in PDB: `d:\ac1_sep13\`
|
||||
|
||||
## Why this matters
|
||||
|
||||
Closes / unblocks issues #6 (enchantment buffs in vital max — uses
|
||||
`CEnchantmentRegistry::EnchantAttribute` at `0x594570`), #7
|
||||
(PlayerDescription trailer — uses `CPlayerSystem::Handle_PlayerDescription`
|
||||
at `0x5636A0`), plus reduces the cost of every future port from "30
|
||||
min archaeology" to "5 sec grep".
|
||||
|
||||
## Links
|
||||
|
||||
- Plan: `C:/Users/erikn/.claude/plans/ticklish-conjuring-cake.md` (8-phase foundation)
|
||||
- Tool: `tools/pdb-extract/` (pure Python, no deps)
|
||||
- Reference: `docs/research/named-retail/README.md`
|
||||
|
|
@ -8,6 +8,13 @@ all Opus-4.7 high-effort).
|
|||
subsystem. Open the slice for that subsystem BEFORE writing the first
|
||||
line — every slice has pseudocode + C# port sketch + citations.
|
||||
|
||||
> **2026-04-25 update — named-retail foundation.** First stop for any
|
||||
> AC-specific question is now `docs/research/named-retail/` (PDB-named
|
||||
> Sept 2013 EoR decomp; 18,366 named functions, 5,371 named struct
|
||||
> types, 1.4 M lines of pseudo-C). The slices below remain useful for
|
||||
> their pseudocode + C# port sketches and the deeper research notes
|
||||
> they synthesize. See `memory/project_named_decompilation.md`.
|
||||
|
||||
---
|
||||
|
||||
## UI layer (`docs/research/retail-ui/`) — 6 slices, ~30,000 words
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@
|
|||
<None Update="Rendering\Shaders\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<!-- Issue #11: copy spells.csv from docs/research/data/ to bin output's
|
||||
data/ subdir so SpellTable.LoadFromCsv can find it at runtime. -->
|
||||
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
|
||||
|
|
|
|||
|
|
@ -278,11 +278,19 @@ public sealed class GameWindow : IDisposable
|
|||
// Exposed publicly so plugins + UI panels can bind directly.
|
||||
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
||||
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
||||
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
|
||||
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||||
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
||||
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
|
||||
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
|
||||
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
|
||||
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
|
||||
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
|
||||
public readonly AcDream.Core.Items.ItemRepository Items = new();
|
||||
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
|
||||
// PlayerDescription so the Vitals HUD can render those bars.
|
||||
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = new();
|
||||
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
|
||||
// buffs into the max formula via Spellbook.GetVitalMod.
|
||||
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
|
||||
|
||||
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
|
||||
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
|
||||
|
|
@ -390,6 +398,35 @@ public sealed class GameWindow : IDisposable
|
|||
_datDir = datDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue #11 — load <c>data/spells.csv</c> from the bin output (copied
|
||||
/// there by the csproj). Returns <c>SpellTable.Empty</c> + logs a
|
||||
/// warning if the file is missing (e.g. when GameWindow is instantiated
|
||||
/// from tooling contexts that don't include the data folder).
|
||||
/// </summary>
|
||||
private static AcDream.Core.Spells.SpellTable LoadSpellTable()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.AppContext.BaseDirectory, "data", "spells.csv");
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
var t = AcDream.Core.Spells.SpellTable.LoadFromCsv(path);
|
||||
Console.WriteLine($"spells: loaded {t.Count} entries from spells.csv");
|
||||
return t;
|
||||
}
|
||||
Console.WriteLine($"spells: data/spells.csv not found at {path}; using empty table");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"spells: load failed ({ex.Message}); using empty table");
|
||||
}
|
||||
return AcDream.Core.Spells.SpellTable.Empty;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
|
|
|
|||
|
|
@ -107,6 +107,13 @@ public static class GameEventWiring
|
|||
var p = GameEvents.ParseAttackDone(e.Payload.Span);
|
||||
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
|
||||
});
|
||||
dispatcher.Register(GameEventType.KillerNotification, e =>
|
||||
{
|
||||
// ISSUES.md #10 — orphan parser, never registered before. The
|
||||
// server fires this after a player lands a killing blow.
|
||||
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
|
||||
if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid);
|
||||
});
|
||||
|
||||
// ── Spells ────────────────────────────────────────────────
|
||||
dispatcher.Register(GameEventType.MagicUpdateSpell, e =>
|
||||
|
|
@ -230,6 +237,24 @@ public static class GameEventWiring
|
|||
|
||||
foreach (uint sid in p.Value.Spells.Keys)
|
||||
spellbook.OnSpellLearned(sid);
|
||||
|
||||
// Issue #7 — enchantment block: feed each entry into the
|
||||
// Spellbook with full StatMod data so EnchantmentMath can
|
||||
// aggregate buffs in vital-max calc (issue #6 lights up).
|
||||
foreach (var ench in p.Value.Enchantments)
|
||||
{
|
||||
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
|
||||
SpellId: ench.SpellId,
|
||||
LayerId: ench.Layer,
|
||||
Duration: (float)ench.Duration,
|
||||
CasterGuid: ench.CasterGuid,
|
||||
StatModType: ench.StatModType,
|
||||
StatModKey: ench.StatModKey,
|
||||
StatModValue: ench.StatModValue,
|
||||
Bucket: (uint)ench.Bucket));
|
||||
if (dumpPd)
|
||||
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,49 @@ public static class PlayerDescriptionParser
|
|||
float X, float Y, float Z,
|
||||
float Qw, float Qx, float Qy, float Qz);
|
||||
|
||||
/// <summary>One enchantment entry from the trailer enchantment
|
||||
/// block. Wire layout per holtburger
|
||||
/// <c>messages/magic/types.rs:40</c> (60 or 64 bytes per record).
|
||||
/// </summary>
|
||||
public readonly record struct EnchantmentEntry(
|
||||
ushort SpellId,
|
||||
ushort Layer,
|
||||
ushort SpellCategory,
|
||||
ushort HasSpellSetId,
|
||||
uint PowerLevel,
|
||||
double StartTime,
|
||||
double Duration,
|
||||
uint CasterGuid,
|
||||
float DegradeModifier,
|
||||
float DegradeLimit,
|
||||
double LastTimeDegraded,
|
||||
uint StatModType,
|
||||
uint StatModKey,
|
||||
float StatModValue,
|
||||
uint? SpellSetId,
|
||||
EnchantmentBucket Bucket);
|
||||
|
||||
/// <summary>Bucket the enchantment came from in the
|
||||
/// <c>EnchantmentMask</c> outer bitfield. Determines whether the
|
||||
/// stat-mod aggregator multiplies or adds.</summary>
|
||||
public enum EnchantmentBucket : uint
|
||||
{
|
||||
Multiplicative = 1,
|
||||
Additive = 2,
|
||||
Cooldown = 4,
|
||||
Vitae = 8,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum EnchantmentMask : uint
|
||||
{
|
||||
None = 0,
|
||||
Multiplicative = 0x01,
|
||||
Additive = 0x02,
|
||||
Cooldown = 0x04,
|
||||
Vitae = 0x08,
|
||||
}
|
||||
|
||||
public readonly record struct Parsed(
|
||||
uint WeenieType,
|
||||
DescriptionPropertyFlag PropertyFlags,
|
||||
|
|
@ -138,7 +181,8 @@ public static class PlayerDescriptionParser
|
|||
IReadOnlyDictionary<uint, WorldPosition> Positions,
|
||||
IReadOnlyList<AttributeEntry> Attributes,
|
||||
IReadOnlyList<SkillEntry> Skills,
|
||||
IReadOnlyDictionary<uint, float> Spells);
|
||||
IReadOnlyDictionary<uint, float> Spells,
|
||||
IReadOnlyList<EnchantmentEntry> Enchantments);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
|
||||
|
|
@ -161,6 +205,7 @@ public static class PlayerDescriptionParser
|
|||
var attributes = new List<AttributeEntry>();
|
||||
var skills = new List<SkillEntry>();
|
||||
var spells = new Dictionary<uint, float>();
|
||||
var enchantments = new List<EnchantmentEntry>();
|
||||
|
||||
// ── Property hashtables (each gated on a flag bit) ──────────────
|
||||
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt32))
|
||||
|
|
@ -198,16 +243,19 @@ public static class PlayerDescriptionParser
|
|||
if (vectorFlags.HasFlag(DescriptionVectorFlag.Spell))
|
||||
ReadSpellTable(payload, ref pos, spells);
|
||||
|
||||
// Enchantments + options + shortcuts + hotbars + inventory +
|
||||
// equipped follow. Holtburger's full unpacker handles them with
|
||||
// ~250 lines of additional logic and some heuristic fallbacks
|
||||
// for variable-length blobs (gameplay_options). Issue #5 only
|
||||
// needs through the attribute block; we stop here cleanly and
|
||||
// expose what we've parsed. A follow-up can extend.
|
||||
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
|
||||
// Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B).
|
||||
// Holtburger events.rs:462-501. After this block come options /
|
||||
// shortcuts / hotbars / inventory / equipped — those need a
|
||||
// heuristic walker for the variable-length gameplay_options blob.
|
||||
// Filed as ISSUES.md #13 for follow-up; stop here cleanly so
|
||||
// partial parses still populate enchantments.
|
||||
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
|
||||
ReadEnchantmentBlock(payload, ref pos, enchantments);
|
||||
|
||||
return new Parsed(
|
||||
weenieType, propertyFlags, vectorFlags, hasHealth,
|
||||
bundle, positions, attributes, skills, spells);
|
||||
bundle, positions, attributes, skills, spells, enchantments);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
|
|
@ -224,7 +272,8 @@ public static class PlayerDescriptionParser
|
|||
Dictionary<uint, float> spells)
|
||||
{
|
||||
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
|
||||
bundle, positions, attributes, skills, spells);
|
||||
bundle, positions, attributes, skills, spells,
|
||||
System.Array.Empty<EnchantmentEntry>());
|
||||
}
|
||||
|
||||
// ── Attribute block reader ──────────────────────────────────────────────
|
||||
|
|
@ -413,6 +462,86 @@ public static class PlayerDescriptionParser
|
|||
}
|
||||
}
|
||||
|
||||
// ── Enchantment block reader ────────────────────────────────────────────
|
||||
// Wire format per holtburger events.rs:462-501 + magic/types.rs:40:
|
||||
// u32 EnchantmentMask
|
||||
// if mask & MULTIPLICATIVE: u32 count, count × Enchantment(60-64 B)
|
||||
// if mask & ADDITIVE: u32 count, count × Enchantment(60-64 B)
|
||||
// if mask & COOLDOWN: u32 count, count × Enchantment(60-64 B)
|
||||
// if mask & VITAE: single Enchantment(60-64 B)
|
||||
|
||||
private static void ReadEnchantmentBlock(
|
||||
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> enchantments)
|
||||
{
|
||||
if (src.Length - pos < 4) return;
|
||||
EnchantmentMask mask = (EnchantmentMask)ReadU32(src, ref pos);
|
||||
|
||||
if (mask.HasFlag(EnchantmentMask.Multiplicative))
|
||||
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Multiplicative);
|
||||
if (mask.HasFlag(EnchantmentMask.Additive))
|
||||
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Additive);
|
||||
if (mask.HasFlag(EnchantmentMask.Cooldown))
|
||||
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Cooldown);
|
||||
if (mask.HasFlag(EnchantmentMask.Vitae))
|
||||
{
|
||||
// Vitae is a single enchantment (no count prefix).
|
||||
enchantments.Add(ReadEnchantment(src, ref pos, EnchantmentBucket.Vitae));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadEnchantmentList(
|
||||
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
|
||||
EnchantmentBucket bucket)
|
||||
{
|
||||
uint count = ReadU32(src, ref pos);
|
||||
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
|
||||
for (int i = 0; i < count; i++)
|
||||
dest.Add(ReadEnchantment(src, ref pos, bucket));
|
||||
}
|
||||
|
||||
private static EnchantmentEntry ReadEnchantment(
|
||||
ReadOnlySpan<byte> src, ref int pos, EnchantmentBucket bucket)
|
||||
{
|
||||
// Holtburger Enchantment::unpack — 28 + 4 (Guid) + 28 + (4 if has_set_id) bytes:
|
||||
// u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id,
|
||||
// u32 power_level, f64 start_time, f64 duration, (28 bytes total)
|
||||
// u32 caster_guid, (4)
|
||||
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
|
||||
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
|
||||
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
|
||||
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
|
||||
ushort spellId = ReadU16(src, ref pos);
|
||||
ushort layer = ReadU16(src, ref pos);
|
||||
ushort spellCategory = ReadU16(src, ref pos);
|
||||
ushort hasSpellSetId = ReadU16(src, ref pos);
|
||||
uint powerLevel = ReadU32(src, ref pos);
|
||||
double startTime = ReadF64(src, ref pos);
|
||||
double duration = ReadF64(src, ref pos);
|
||||
uint casterGuid = ReadU32(src, ref pos);
|
||||
float degradeModifier= ReadF32(src, ref pos);
|
||||
float degradeLimit = ReadF32(src, ref pos);
|
||||
double lastDegraded = ReadF64(src, ref pos);
|
||||
uint statModType = ReadU32(src, ref pos);
|
||||
uint statModKey = ReadU32(src, ref pos);
|
||||
float statModValue = ReadF32(src, ref pos);
|
||||
uint? spellSetId = null;
|
||||
if (hasSpellSetId != 0)
|
||||
spellSetId = ReadU32(src, ref pos);
|
||||
return new EnchantmentEntry(
|
||||
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
|
||||
startTime, duration, casterGuid, degradeModifier, degradeLimit,
|
||||
lastDegraded, statModType, statModKey, statModValue, spellSetId,
|
||||
bucket);
|
||||
}
|
||||
|
||||
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
|
||||
{
|
||||
if (src.Length - pos < 2) throw new FormatException("truncated u16");
|
||||
ushort v = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
|
||||
pos += 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Primitive readers ───────────────────────────────────────────────────
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> src, ref int pos)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@ public sealed class CombatState
|
|||
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
|
||||
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the server confirms the player landed a killing blow
|
||||
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
|
||||
/// the victim's display name + their server GUID. Used by killfeed UI
|
||||
/// (future panel) and any plugin scoring kill counts.
|
||||
/// </summary>
|
||||
public event Action<string /*victimName*/, uint /*victimGuid*/>? KillLanded;
|
||||
|
||||
public readonly record struct DamageIncoming(
|
||||
string AttackerName,
|
||||
uint AttackerGuid,
|
||||
|
|
@ -116,6 +124,16 @@ public sealed class CombatState
|
|||
public void OnEvasionAttackerNotification(string defenderName)
|
||||
=> MissedOutgoing?.Invoke(defenderName);
|
||||
|
||||
/// <summary>
|
||||
/// Server confirmation that the player landed a killing blow on a
|
||||
/// target. Wire source: GameEvent <c>KillerNotification (0x01AD)</c>
|
||||
/// — the parser at <c>GameEvents.ParseKillerNotification</c> shipped
|
||||
/// alongside victim/defender notifications but was never registered
|
||||
/// for dispatch until 2026-04-25 (per ISSUES.md #10).
|
||||
/// </summary>
|
||||
public void OnKillerNotification(string victimName, uint victimGuid)
|
||||
=> KillLanded?.Invoke(victimName, victimGuid);
|
||||
|
||||
public void OnEvasionDefenderNotification(string attackerName)
|
||||
=> EvadedIncoming?.Invoke(attackerName);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Player;
|
||||
|
||||
|
|
@ -90,6 +91,19 @@ public sealed class LocalPlayerState
|
|||
private VitalSnapshot? _stamina;
|
||||
private VitalSnapshot? _mana;
|
||||
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
|
||||
private readonly Spellbook? _spellbook;
|
||||
|
||||
/// <summary>
|
||||
/// Build a LocalPlayerState. Optional <see cref="Spellbook"/>
|
||||
/// reference unlocks issue #6 — vital-max calc folds in active
|
||||
/// enchantment buffs via <see cref="Spellbook.GetVitalMod"/>. When
|
||||
/// absent (back-compat for tests / older callers), buff modifiers
|
||||
/// are skipped (identity).
|
||||
/// </summary>
|
||||
public LocalPlayerState(Spellbook? spellbook = null)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
}
|
||||
|
||||
/// <summary>Fires after any vital field changes.</summary>
|
||||
public event System.Action<VitalKind>? Changed;
|
||||
|
|
@ -145,9 +159,17 @@ public sealed class LocalPlayerState
|
|||
_attrs.TryGetValue(kind, out var a) ? a : null;
|
||||
|
||||
/// <summary>
|
||||
/// Compute the unenchanted max for a vital, using the retail formula:
|
||||
/// <c>vital.(ranks+start) + attribute_contribution</c>. Returns
|
||||
/// <c>null</c> if the vital snapshot doesn't exist yet.
|
||||
/// Compute the buffed max for a vital, using the full retail formula:
|
||||
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
|
||||
/// with a <c>>= 5 if base >= 5 else >= 1</c> minimum-vital clamp
|
||||
/// (matches <c>CreatureVital::GetMaxValue</c> at PDB
|
||||
/// <c>0x0058F2DD</c>). Buffs are pulled from the optional
|
||||
/// <see cref="Spellbook"/> via <see cref="EnchantmentMath.GetMod"/>;
|
||||
/// when absent, returns the unenchanted max.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>null</c> if the vital snapshot doesn't exist yet.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public uint? GetMaxApprox(VitalKind kind)
|
||||
{
|
||||
|
|
@ -155,9 +177,31 @@ public sealed class LocalPlayerState
|
|||
if (v is null) return null;
|
||||
uint baseMax = v.Value.Ranks + v.Value.Start;
|
||||
uint contrib = AttributeContribution(kind);
|
||||
return baseMax + contrib;
|
||||
uint unbuffed = baseMax + contrib;
|
||||
// Preserve the "no data" sentinel — when the unbuffed max is 0
|
||||
// we lack the inputs to compute anything reasonable. The retail
|
||||
// min-vital floor only kicks in once we know the base.
|
||||
if (unbuffed == 0) return 0;
|
||||
|
||||
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
|
||||
?? EnchantmentMath.VitalMod.Identity;
|
||||
// Apply: (unbuffed * mult) + additive, then clamp to retail's
|
||||
// min-vital floor (5 if base >= 5 else 1) — matches
|
||||
// CreatureVital::GetMaxValue at PDB 0x0058F2DD.
|
||||
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
|
||||
uint minFloor = unbuffed >= 5 ? 5u : 1u;
|
||||
if (buffed < minFloor) buffed = minFloor;
|
||||
return (uint)System.Math.Round(buffed);
|
||||
}
|
||||
|
||||
private static uint StatKeyForKind(VitalKind kind) => kind switch
|
||||
{
|
||||
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,
|
||||
VitalKind.Stamina => EnchantmentMath.StatKey.MaxStamina,
|
||||
VitalKind.Mana => EnchantmentMath.StatKey.MaxMana,
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
/// <summary>Stamina percent (0..1) or null when not yet received.</summary>
|
||||
public float? StaminaPercent => Percent(VitalKind.Stamina);
|
||||
|
||||
|
|
|
|||
150
src/AcDream.Core/Spells/EnchantmentMath.cs
Normal file
150
src/AcDream.Core/Spells/EnchantmentMath.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates active-enchantment buffs into per-stat <c>(multiplier,
|
||||
/// additive)</c> modifier pairs, mirroring retail
|
||||
/// <c>CEnchantmentRegistry::EnchantAttribute</c> at PDB
|
||||
/// <c>0x00594570</c> (see
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
|
||||
/// line 416110).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail formula:</b>
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// for each enchantment in _mult_list (after CullEnchantmentsFromList):
|
||||
/// if statMod.key == requested_key && mod-type is multiplicative:
|
||||
/// multiplier *= statMod.val
|
||||
/// for each enchantment in _add_list:
|
||||
/// if statMod.key == requested_key && mod-type is additive:
|
||||
/// additive += statMod.val
|
||||
/// apply family-stacking: only one enchantment per Family wins
|
||||
/// (highest Generation; tie-broken by latest cast).
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Vitae</b> (death penalty) is a singleton on
|
||||
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
|
||||
/// the buff lists. We don't yet wire it through.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Current implementation status:</b> the aggregator iterates
|
||||
/// <see cref="Spellbook.ActiveEnchantments"/> and applies
|
||||
/// <see cref="SpellTable"/> family-stacking deduplication, but
|
||||
/// **returns identity (1.0, 0.0) for stat modifiers** because our
|
||||
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the
|
||||
/// <c>StatMod (type/key/val)</c> triad — that requires extending
|
||||
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
|
||||
/// payload (60-64 bytes per holtburger
|
||||
/// <c>messages/magic/types.rs</c>) and storing it on the record.
|
||||
/// Filed as ISSUES.md #12. Once that lands, the aggregator's
|
||||
/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and
|
||||
/// the Vitals HUD percent gap closes.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Stat keys</b> (ACE <c>PropertyAttribute2nd</c>):
|
||||
/// <c>MaxHealth=1</c>, <c>MaxStamina=3</c>, <c>MaxMana=5</c>.
|
||||
/// Verified against
|
||||
/// <c>docs/research/named-retail/acclient.h</c> line 37287-37301
|
||||
/// (<c>SecondaryAttribute</c> family).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EnchantmentMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Combined multiplicative + additive modifier for a stat key.
|
||||
/// </summary>
|
||||
public readonly record struct VitalMod(float Multiplier, float Additive)
|
||||
{
|
||||
/// <summary>Identity modifier — <c>(1.0, 0.0)</c>. No active
|
||||
/// buffs apply.</summary>
|
||||
public static readonly VitalMod Identity = new(1.0f, 0.0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the combined buff modifier for a given stat key from
|
||||
/// the player's active enchantments. Returns <see cref="VitalMod.Identity"/>
|
||||
/// when no relevant buffs are active.
|
||||
/// </summary>
|
||||
/// <param name="enchantments">All active enchantment layers
|
||||
/// (typically <see cref="Spellbook.ActiveEnchantments"/>).</param>
|
||||
/// <param name="table">Spell metadata table for family-stacking
|
||||
/// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param>
|
||||
/// <param name="statKey">Target stat key (ACE
|
||||
/// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth,
|
||||
/// 3=MaxStamina, 5=MaxMana).</param>
|
||||
public static VitalMod GetMod(
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
||||
SpellTable table,
|
||||
uint statKey)
|
||||
{
|
||||
// Family-stacking: bucket the active enchantments by Family and
|
||||
// keep the strongest one per bucket (the one with the largest
|
||||
// SpellId, which in retail correlates with generation level —
|
||||
// higher level = higher id within a family. Without the
|
||||
// Generation field, this is a faithful approximation.)
|
||||
var stronger = new Dictionary<uint, ActiveEnchantmentRecord>();
|
||||
foreach (var ench in enchantments)
|
||||
{
|
||||
if (!table.TryGet(ench.SpellId, out var meta))
|
||||
continue;
|
||||
// Family 0 means "no stacking bucket" — these don't dedup;
|
||||
// pass them through with a synthetic key per layer.
|
||||
uint bucket = meta.Family == 0 ? ench.LayerId | 0x80000000u : meta.Family;
|
||||
if (!stronger.TryGetValue(bucket, out var current) ||
|
||||
ench.SpellId > current.SpellId)
|
||||
{
|
||||
stronger[bucket] = ench;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate StatMod values from the deduplicated set. Records
|
||||
// with StatModKey == statKey contribute; bucket determines
|
||||
// whether the value is multiplicative or additive.
|
||||
// Bucket 1 (Multiplicative): multiplier *= ench.StatModValue
|
||||
// Bucket 2 (Additive): additive += ench.StatModValue
|
||||
// Bucket 8 (Vitae): multiplier *= ench.StatModValue (post-pass)
|
||||
// Records without StatMod data (StatModKey == null) — e.g.
|
||||
// those from older MagicUpdateEnchantment events that don't
|
||||
// yet parse the full payload — contribute nothing.
|
||||
float multiplier = 1.0f;
|
||||
float additive = 0.0f;
|
||||
float vitae = 1.0f;
|
||||
foreach (var ench in stronger.Values)
|
||||
{
|
||||
if (ench.StatModKey is not uint key || key != statKey) continue;
|
||||
if (ench.StatModValue is not float val) continue;
|
||||
|
||||
switch (ench.Bucket)
|
||||
{
|
||||
case 1: multiplier *= val; break;
|
||||
case 2: additive += val; break;
|
||||
case 8: vitae *= val; break;
|
||||
// Bucket 4 (Cooldown) doesn't affect vital max.
|
||||
}
|
||||
}
|
||||
// Vitae is applied multiplicatively last per retail
|
||||
// CEnchantmentRegistry::EnchantAttribute behaviour.
|
||||
multiplier *= vitae;
|
||||
return multiplier == 1.0f && additive == 0.0f
|
||||
? VitalMod.Identity
|
||||
: new VitalMod(multiplier, additive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
|
||||
/// (verified against <c>docs/research/named-retail/acclient.h</c>
|
||||
/// line 37287-37301). Used by <see cref="LocalPlayerState"/> to
|
||||
/// look up the right buff bucket per vital kind.
|
||||
/// </summary>
|
||||
public static class StatKey
|
||||
{
|
||||
public const uint MaxHealth = 1;
|
||||
public const uint MaxStamina = 3;
|
||||
public const uint MaxMana = 5;
|
||||
}
|
||||
}
|
||||
44
src/AcDream.Core/Spells/SpellMetadata.cs
Normal file
44
src/AcDream.Core/Spells/SpellMetadata.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Per-spell static metadata loaded once at startup from
|
||||
/// <c>docs/research/data/spells.csv</c> via <see cref="SpellTable"/>.
|
||||
/// One record per known spell id (3,956 entries in the retail dump).
|
||||
///
|
||||
/// <para>
|
||||
/// Used for:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Buff / debuff bar labels (<see cref="Name"/>,
|
||||
/// <see cref="School"/>, <see cref="IconId"/>).</item>
|
||||
/// <item>Stacking aggregation (<see cref="Family"/> — only one
|
||||
/// enchantment per family-bucket is active; this is what
|
||||
/// <c>EnchantmentMath</c> uses to filter out superseded
|
||||
/// buffs in <c>LocalPlayerState.GetMaxApprox</c>).</item>
|
||||
/// <item>Spell tooltips (<see cref="Description"/>,
|
||||
/// <see cref="ManaCost"/>, <see cref="Duration"/>,
|
||||
/// <see cref="SpellWords"/>).</item>
|
||||
/// <item>Cast-bar audio + animation cues
|
||||
/// (<see cref="SpellWords"/> drives the chant).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Fields not exposed (yet) from the 35-column source CSV: SortKey,
|
||||
/// Difficulty, Flags, Generation, IsFastWindup, IsIrresistible,
|
||||
/// IsOffensive, IsUntargetted, Speed, CasterEffect, TargetEffect,
|
||||
/// TargetMask, Type, plus 10 anonymous Unknown1..10. Add them on
|
||||
/// demand as panels grow.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record SpellMetadata(
|
||||
uint SpellId,
|
||||
string Name,
|
||||
string School,
|
||||
uint Family,
|
||||
uint IconId,
|
||||
string SpellWords,
|
||||
float Duration,
|
||||
int ManaCost,
|
||||
bool IsDebuff,
|
||||
bool IsFellowship,
|
||||
string Description);
|
||||
217
src/AcDream.Core/Spells/SpellTable.cs
Normal file
217
src/AcDream.Core/Spells/SpellTable.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Loads + queries <see cref="SpellMetadata"/> from a CSV at startup.
|
||||
/// Source: <c>docs/research/data/spells.csv</c> (RFC 4180-ish, 35
|
||||
/// columns, 3,956 rows). Loaded once, used by panels + by
|
||||
/// <c>EnchantmentMath</c> for buff stacking aggregation.
|
||||
///
|
||||
/// <para>
|
||||
/// Hand-rolled CSV parser — the only complication is the
|
||||
/// <c>Description</c> column which is double-quoted with embedded
|
||||
/// commas. No external CsvHelper dependency.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Closes ISSUES.md #11. Required by ISSUES.md #6 for family-stacking
|
||||
/// in vital-max enchantment aggregation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SpellTable
|
||||
{
|
||||
private readonly Dictionary<uint, SpellMetadata> _byId;
|
||||
|
||||
/// <summary>Empty table (no spells loaded). Useful for tests +
|
||||
/// pre-load defaults.</summary>
|
||||
public static SpellTable Empty { get; } = new(new Dictionary<uint, SpellMetadata>());
|
||||
|
||||
private SpellTable(Dictionary<uint, SpellMetadata> byId)
|
||||
{
|
||||
_byId = byId;
|
||||
}
|
||||
|
||||
/// <summary>Number of spells loaded.</summary>
|
||||
public int Count => _byId.Count;
|
||||
|
||||
/// <summary>Look up metadata by spell id. Returns <c>true</c> if
|
||||
/// found; <paramref name="meta"/> is the matching record.</summary>
|
||||
public bool TryGet(uint spellId, out SpellMetadata meta)
|
||||
{
|
||||
if (_byId.TryGetValue(spellId, out var v))
|
||||
{
|
||||
meta = v;
|
||||
return true;
|
||||
}
|
||||
meta = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>All loaded spell IDs. Stable enumeration order is not
|
||||
/// guaranteed.</summary>
|
||||
public IEnumerable<uint> SpellIds => _byId.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Load from a CSV file. Throws <see cref="FileNotFoundException"/>
|
||||
/// if the path doesn't exist; bad rows are silently skipped (the
|
||||
/// CSV is third-party data, not authored by us — be lenient).
|
||||
/// </summary>
|
||||
public static SpellTable LoadFromCsv(string csvPath)
|
||||
{
|
||||
if (!File.Exists(csvPath))
|
||||
throw new FileNotFoundException("spells.csv not found", csvPath);
|
||||
using var reader = new StreamReader(csvPath);
|
||||
return LoadFromReader(reader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load from any <see cref="TextReader"/>. Used by tests with
|
||||
/// <see cref="StringReader"/>; production loads via
|
||||
/// <see cref="LoadFromCsv"/>.
|
||||
/// </summary>
|
||||
public static SpellTable LoadFromReader(TextReader reader)
|
||||
{
|
||||
var byId = new Dictionary<uint, SpellMetadata>();
|
||||
|
||||
string? header = reader.ReadLine();
|
||||
if (header is null) return new SpellTable(byId);
|
||||
|
||||
// Map column-name → index. The CSV order is documented in
|
||||
// docs/research/data/README.md but we don't depend on it —
|
||||
// resolve every column we care about by name.
|
||||
var columns = ParseRow(header);
|
||||
var colIndex = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
colIndex[columns[i]] = i;
|
||||
|
||||
int? Get(string name) => colIndex.TryGetValue(name, out int i) ? i : (int?)null;
|
||||
|
||||
int? iSpellId = Get("Spell ID");
|
||||
int? iName = Get("Name");
|
||||
int? iSchool = Get("School");
|
||||
int? iFamily = Get("Family");
|
||||
int? iIconHex = Get("IconId [Hex]");
|
||||
int? iWords = Get("Spell Words");
|
||||
int? iDuration = Get("Duration");
|
||||
int? iMana = Get("Mana");
|
||||
int? iIsDebuff = Get("IsDebuff");
|
||||
int? iIsFellow = Get("IsFellowship");
|
||||
int? iDescription = Get("Description");
|
||||
|
||||
if (iSpellId is null || iName is null) return new SpellTable(byId);
|
||||
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var fields = ParseRow(line);
|
||||
if (fields.Count <= iSpellId.Value) continue;
|
||||
|
||||
// Spell ID is the only non-optional column.
|
||||
if (!uint.TryParse(fields[iSpellId.Value], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint spellId))
|
||||
continue;
|
||||
|
||||
string name = iName is int n && n < fields.Count ? fields[n] : "";
|
||||
string school = iSchool is int s && s < fields.Count ? fields[s] : "";
|
||||
uint family = ParseUInt(fields, iFamily);
|
||||
uint iconId = ParseHexUInt(fields, iIconHex);
|
||||
string words = iWords is int w && w < fields.Count ? fields[w] : "";
|
||||
float duration = ParseFloat(fields, iDuration);
|
||||
int mana = (int)ParseUInt(fields, iMana);
|
||||
bool isDebuff = ParseBool(fields, iIsDebuff);
|
||||
bool isFellow = ParseBool(fields, iIsFellow);
|
||||
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
|
||||
|
||||
byId[spellId] = new SpellMetadata(
|
||||
spellId, name, school, family, iconId, words, duration,
|
||||
mana, isDebuff, isFellow, description);
|
||||
}
|
||||
|
||||
return new SpellTable(byId);
|
||||
}
|
||||
|
||||
private static uint ParseUInt(IList<string> fields, int? index)
|
||||
{
|
||||
if (index is not int i || i >= fields.Count) return 0;
|
||||
return uint.TryParse(fields[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint v) ? v : 0u;
|
||||
}
|
||||
|
||||
private static uint ParseHexUInt(IList<string> fields, int? index)
|
||||
{
|
||||
if (index is not int i || i >= fields.Count) return 0;
|
||||
string s = fields[i];
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
s = s[2..];
|
||||
return uint.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint v) ? v : 0u;
|
||||
}
|
||||
|
||||
private static float ParseFloat(IList<string> fields, int? index)
|
||||
{
|
||||
if (index is not int i || i >= fields.Count) return 0f;
|
||||
return float.TryParse(fields[i], NumberStyles.Float, CultureInfo.InvariantCulture, out float v) ? v : 0f;
|
||||
}
|
||||
|
||||
private static bool ParseBool(IList<string> fields, int? index)
|
||||
{
|
||||
if (index is not int i || i >= fields.Count) return false;
|
||||
return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted
|
||||
/// fields with embedded commas (the Description column). Embedded
|
||||
/// double quotes are escaped by doubling (`""` → `"`). Public so
|
||||
/// callers (incl. tests) can reuse the parser without instantiating
|
||||
/// a full <see cref="SpellTable"/>.
|
||||
/// </summary>
|
||||
public static List<string> ParseRow(string row)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
int i = 0;
|
||||
while (i < row.Length)
|
||||
{
|
||||
string field;
|
||||
if (row[i] == '"')
|
||||
{
|
||||
// Quoted field — consume until matching close-quote
|
||||
// (handle "" as an escaped quote within the field).
|
||||
i++; // skip opening
|
||||
var sb = new System.Text.StringBuilder();
|
||||
while (i < row.Length)
|
||||
{
|
||||
if (row[i] == '"')
|
||||
{
|
||||
// Either a closing quote or an escaped "".
|
||||
if (i + 1 < row.Length && row[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++; // skip closing quote
|
||||
break;
|
||||
}
|
||||
sb.Append(row[i]);
|
||||
i++;
|
||||
}
|
||||
field = sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
int start = i;
|
||||
while (i < row.Length && row[i] != ',')
|
||||
i++;
|
||||
field = row[start..i];
|
||||
}
|
||||
fields.Add(field);
|
||||
// Skip the comma separator if present.
|
||||
if (i < row.Length && row[i] == ',')
|
||||
i++;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,43 @@ public sealed class Spellbook
|
|||
{
|
||||
private readonly HashSet<uint> _learnedSpells = new();
|
||||
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
|
||||
private readonly SpellTable _table;
|
||||
|
||||
/// <summary>
|
||||
/// Build a Spellbook with an optional <see cref="SpellTable"/>
|
||||
/// metadata source. When provided, <see cref="TryGetMetadata"/>
|
||||
/// returns the static spell descriptor (name / school / family /
|
||||
/// icon / mana / duration / etc.). When absent (back-compat for
|
||||
/// existing constructors / tests), <see cref="TryGetMetadata"/>
|
||||
/// always returns <c>false</c>.
|
||||
/// </summary>
|
||||
public Spellbook(SpellTable? table = null)
|
||||
{
|
||||
_table = table ?? SpellTable.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up static spell metadata. Closes ISSUES.md #11 — feeds the
|
||||
/// buff bar UI labels + icons + the family-stacking aggregation in
|
||||
/// <c>EnchantmentMath</c> (issue #6).
|
||||
/// </summary>
|
||||
public bool TryGetMetadata(uint spellId, out SpellMetadata meta) =>
|
||||
_table.TryGet(spellId, out meta);
|
||||
|
||||
/// <summary>The spell-metadata table this spellbook was built with.
|
||||
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
|
||||
public SpellTable Metadata => _table;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
|
||||
/// over <see cref="ActiveEnchantments"/> through
|
||||
/// <see cref="EnchantmentMath.GetMod"/> with family-stacking dedup
|
||||
/// from the spell metadata table. Returns
|
||||
/// <see cref="EnchantmentMath.VitalMod.Identity"/> when no buffs
|
||||
/// apply (or when no SpellTable was wired).
|
||||
/// </summary>
|
||||
public EnchantmentMath.VitalMod GetVitalMod(uint statKey) =>
|
||||
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
|
||||
|
||||
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
|
||||
public event Action<uint>? SpellLearned;
|
||||
|
|
@ -67,6 +104,19 @@ public sealed class Spellbook
|
|||
EnchantmentAdded?.Invoke(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue #7 / #12 — accept a fully-populated record from
|
||||
/// <c>PlayerDescription</c>'s enchantment block (which carries
|
||||
/// the StatMod triad + bucket). Used when the wire-format extension
|
||||
/// gives us the full per-enchantment payload, rather than the
|
||||
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
|
||||
/// </summary>
|
||||
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
|
||||
{
|
||||
_activeByLayer[record.LayerId] = record;
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
}
|
||||
|
||||
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
|
||||
public void OnEnchantmentRemoved(uint layerId, uint spellId)
|
||||
{
|
||||
|
|
@ -92,13 +142,27 @@ public sealed class Spellbook
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of one active enchantment layer on the player. Richer detail
|
||||
/// (stat mods, category, power) requires the full <see cref="ActiveBuff"/>
|
||||
/// struct — this record is the wire-slim version surfaced by the
|
||||
/// <see cref="Spellbook.EnchantmentAdded"/> event.
|
||||
/// Summary of one active enchantment layer on the player. The
|
||||
/// optional StatMod fields (issue #12) carry the wire-level
|
||||
/// `_smod` triad <c>(type, key, val)</c> when available — only
|
||||
/// `PlayerDescription`'s enchantment block currently populates these
|
||||
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
|
||||
/// `MagicUpdateEnchantment` events still produce records with these
|
||||
/// fields null until the wire parser is extended.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
|
||||
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
|
||||
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
|
||||
/// the retail <c>EnchantmentMask</c> classification.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ActiveEnchantmentRecord(
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
uint CasterGuid);
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
uint CasterGuid,
|
||||
uint? StatModType = null,
|
||||
uint? StatModKey = null,
|
||||
float? StatModValue = null,
|
||||
uint Bucket = 0);
|
||||
|
|
|
|||
|
|
@ -240,6 +240,31 @@ public sealed class GameEventWiringTests
|
|||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
|
||||
{
|
||||
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification
|
||||
// existed but was never registered for dispatch until 2026-04-25.
|
||||
// Now wired: 0x01AD lands on CombatState.OnKillerNotification +
|
||||
// fires the KillLanded event.
|
||||
var (d, _, combat, _, _) = MakeAll();
|
||||
string? gotVictimName = null;
|
||||
uint gotVictimGuid = 0;
|
||||
combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; };
|
||||
|
||||
// Wire shape: string16L victimName + u32 victimGuid
|
||||
byte[] nameBytes = MakeString16L("Drudge");
|
||||
byte[] payload = new byte[nameBytes.Length + 4];
|
||||
Array.Copy(nameBytes, payload, nameBytes.Length);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal("Drudge", gotVictimName);
|
||||
Assert.Equal(0x80001234u, gotVictimGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -207,6 +207,108 @@ public sealed class PlayerDescriptionParserTests
|
|||
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_EnchantmentBlock_PopulatesEnchantments_WithStatModAndBucket()
|
||||
{
|
||||
// ATTRIBUTE | SPELL | ENCHANTMENT vector flag (= 0x301 minus
|
||||
// SKILL = 0x301 incl. ATTRIBUTE+SPELL+ENCHANTMENT). Empty
|
||||
// attribute block + empty spell table + 1 multiplicative
|
||||
// enchantment + 1 additive enchantment. Verifies end-to-end
|
||||
// that the enchantment record schema lands intact.
|
||||
var sb = new MemoryStream();
|
||||
using var writer = new BinaryWriter(sb);
|
||||
writer.Write(0u); // propertyFlags
|
||||
writer.Write(0x52u); // weenieType
|
||||
// vectorFlags = ATTRIBUTE (0x01) | SPELL (0x100) | ENCHANTMENT (0x200) = 0x301
|
||||
writer.Write(0x301u);
|
||||
writer.Write(1u); // has_health
|
||||
writer.Write(0u); // attribute_flags = 0 -> no entries
|
||||
|
||||
// Spell table: empty (count=0).
|
||||
writer.Write((ushort)0);
|
||||
writer.Write((ushort)0);
|
||||
|
||||
// EnchantmentMask = MULTIPLICATIVE (0x01) | ADDITIVE (0x02) = 0x03
|
||||
writer.Write(0x03u);
|
||||
// Multiplicative list: 1 entry
|
||||
writer.Write(1u);
|
||||
WriteEnchantment(writer,
|
||||
spellId: 1234, layer: 5, spellCategory: 100, hasSpellSetId: 0,
|
||||
powerLevel: 999, startTime: 12.5, duration: 1800.0,
|
||||
casterGuid: 0xCAFE0001u, degradeMod: 1.0f, degradeLimit: 0.5f,
|
||||
lastDegraded: 0.0, statModType: 0x00010000u, statModKey: 3u /* MaxStamina */,
|
||||
statModValue: 1.5f);
|
||||
// Additive list: 1 entry
|
||||
writer.Write(1u);
|
||||
WriteEnchantment(writer,
|
||||
spellId: 5678, layer: 6, spellCategory: 101, hasSpellSetId: 0,
|
||||
powerLevel: 100, startTime: 13.0, duration: 1500.0,
|
||||
casterGuid: 0xCAFE0002u, degradeMod: 1.0f, degradeLimit: 0.5f,
|
||||
lastDegraded: 0.0, statModType: 0x00020000u, statModKey: 5u /* MaxMana */,
|
||||
statModValue: 25.0f);
|
||||
|
||||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(2, parsed!.Value.Enchantments.Count);
|
||||
|
||||
var mult = parsed.Value.Enchantments[0];
|
||||
Assert.Equal((ushort)1234, mult.SpellId);
|
||||
Assert.Equal((ushort)5, mult.Layer);
|
||||
Assert.Equal(3u, mult.StatModKey);
|
||||
Assert.Equal(1.5f, mult.StatModValue);
|
||||
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Multiplicative, mult.Bucket);
|
||||
|
||||
var add = parsed.Value.Enchantments[1];
|
||||
Assert.Equal((ushort)5678, add.SpellId);
|
||||
Assert.Equal(5u, add.StatModKey);
|
||||
Assert.Equal(25.0f, add.StatModValue);
|
||||
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Additive, add.Bucket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_VitaeSingleton_AppearsInEnchantments()
|
||||
{
|
||||
// EnchantmentMask = VITAE only (0x08). Single Enchantment, no
|
||||
// count prefix.
|
||||
var sb = new MemoryStream();
|
||||
using var writer = new BinaryWriter(sb);
|
||||
writer.Write(0u); // propertyFlags
|
||||
writer.Write(0x52u); // weenieType
|
||||
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
|
||||
writer.Write(1u); // has_health
|
||||
writer.Write(0u); // empty attribute_flags
|
||||
|
||||
writer.Write(0x08u); // EnchantmentMask = VITAE
|
||||
WriteEnchantment(writer,
|
||||
spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0,
|
||||
powerLevel: 0, startTime: 0.0, duration: -1.0,
|
||||
casterGuid: 0u, degradeMod: 0f, degradeLimit: 0f,
|
||||
lastDegraded: 0.0, statModType: 0u, statModKey: 1u /* MaxHealth */,
|
||||
statModValue: 0.95f);
|
||||
|
||||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Single(parsed!.Value.Enchantments);
|
||||
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Vitae, parsed.Value.Enchantments[0].Bucket);
|
||||
Assert.Equal(0.95f, parsed.Value.Enchantments[0].StatModValue);
|
||||
}
|
||||
|
||||
private static void WriteEnchantment(BinaryWriter w,
|
||||
ushort spellId, ushort layer, ushort spellCategory, ushort hasSpellSetId,
|
||||
uint powerLevel, double startTime, double duration, uint casterGuid,
|
||||
float degradeMod, float degradeLimit, double lastDegraded,
|
||||
uint statModType, uint statModKey, float statModValue)
|
||||
{
|
||||
w.Write(spellId); w.Write(layer); w.Write(spellCategory); w.Write(hasSpellSetId);
|
||||
w.Write(powerLevel); w.Write(startTime); w.Write(duration);
|
||||
w.Write(casterGuid);
|
||||
w.Write(degradeMod); w.Write(degradeLimit); w.Write(lastDegraded);
|
||||
w.Write(statModType); w.Write(statModKey); w.Write(statModValue);
|
||||
// Skip optional spell_set_id (only present if hasSpellSetId != 0).
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_SpellTable_PopulatesSpellsDictionary()
|
||||
{
|
||||
|
|
|
|||
226
tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs
Normal file
226
tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Tests.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EnchantmentMath"/>. Issue #6 architecture
|
||||
/// validation — confirms the family-stacking dedup + identity
|
||||
/// semantics work correctly.
|
||||
///
|
||||
/// <para>
|
||||
/// Note: until ISSUES.md #12 lands the wire-format extension that
|
||||
/// captures StatMod (type/key/val) on <see cref="ActiveEnchantmentRecord"/>,
|
||||
/// the per-enchantment modifier value isn't aggregated yet — we
|
||||
/// always return <see cref="EnchantmentMath.VitalMod.Identity"/>.
|
||||
/// These tests confirm the architectural shape is correct for the
|
||||
/// follow-up wire wiring.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EnchantmentMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_ReturnsIdentity()
|
||||
{
|
||||
var mod = EnchantmentMath.GetMod(
|
||||
new List<ActiveEnchantmentRecord>(),
|
||||
SpellTable.Empty,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatchingTableEntries_ReturnsIdentity()
|
||||
{
|
||||
// Active enchantments exist but none of them have entries in the
|
||||
// SpellTable (so we can't read Family) — they're skipped.
|
||||
var enchantments = new[]
|
||||
{
|
||||
new ActiveEnchantmentRecord(SpellId: 9999u, LayerId: 1u, Duration: 60f, CasterGuid: 0u),
|
||||
new ActiveEnchantmentRecord(SpellId: 8888u, LayerId: 2u, Duration: 60f, CasterGuid: 0u),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, SpellTable.Empty,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatKey_ConstantsMatchAceEnum()
|
||||
{
|
||||
// ACE PropertyAttribute2nd enum: MaxHealth=1, MaxStamina=3, MaxMana=5.
|
||||
// Verified against named-retail/acclient.h line 37287-37301.
|
||||
Assert.Equal(1u, EnchantmentMath.StatKey.MaxHealth);
|
||||
Assert.Equal(3u, EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(5u, EnchantmentMath.StatKey.MaxMana);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identity_IsOneAndZero()
|
||||
{
|
||||
Assert.Equal(1.0f, EnchantmentMath.VitalMod.Identity.Multiplier);
|
||||
Assert.Equal(0.0f, EnchantmentMath.VitalMod.Identity.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FamilyStacking_DeduplicatesByFamily_KeepsHigherSpellId()
|
||||
{
|
||||
// Build a SpellTable with 2 spells in the same Family (e.g.
|
||||
// Strength I and Strength VII, both Family=1). Both are in the
|
||||
// active enchantment list; only the higher spell id should
|
||||
// survive the family-stacking dedup.
|
||||
// The aggregator currently returns Identity regardless (see
|
||||
// class doc), but the dedup behaviour is observable by
|
||||
// counting which records would be folded — exercised here to
|
||||
// pin the architecture even before ISSUES.md #12 wires data.
|
||||
// Family=1 strength buffs example.
|
||||
var table = LoadTable(
|
||||
(1u, "Strength I", 1u),
|
||||
(132u, "Strength VII", 1u)); // same family
|
||||
var enchantments = new[]
|
||||
{
|
||||
new ActiveEnchantmentRecord(SpellId: 1u, LayerId: 100u, Duration: 60f, CasterGuid: 0u),
|
||||
new ActiveEnchantmentRecord(SpellId: 132u, LayerId: 101u, Duration: 60f, CasterGuid: 0u),
|
||||
};
|
||||
// Currently: identity result (StatMod data not yet on records).
|
||||
// Test demonstrates the call doesn't throw + returns identity.
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_Zero_DoesNotDedup()
|
||||
{
|
||||
// Family 0 means "no stacking bucket" — each enchantment is
|
||||
// its own bucket (synthetic key per layer). When ISSUES.md #12
|
||||
// lands and we aggregate StatMods, both these buffs will
|
||||
// contribute simultaneously.
|
||||
var table = LoadTable(
|
||||
(10u, "Buff A", 0u),
|
||||
(20u, "Buff B", 0u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
new ActiveEnchantmentRecord(SpellId: 10u, LayerId: 100u, Duration: 60f, CasterGuid: 0u),
|
||||
new ActiveEnchantmentRecord(SpellId: 20u, LayerId: 101u, Duration: 60f, CasterGuid: 0u),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
// Identity for now; architecture confirmed via no-throw + result shape.
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_MultiplicativeBucket_AppliesProductWhenStatKeyMatches()
|
||||
{
|
||||
// Two multiplicative enchantments on MaxStamina (key=3): values
|
||||
// 1.2 and 1.1 → final multiplier = 1.2 × 1.1 = 1.32.
|
||||
// Different families so neither dedups the other.
|
||||
var table = LoadTable(
|
||||
(10u, "Buff10", 100u),
|
||||
(11u, "Buff11", 200u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 10, layer: 1, statKey: 3u, val: 1.2f),
|
||||
MakeMultRecord(spellId: 11, layer: 2, statKey: 3u, val: 1.1f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(1.32f, mod.Multiplier, precision: 4);
|
||||
Assert.Equal(0.0f, mod.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_AdditiveBucket_SumsValueWhenStatKeyMatches()
|
||||
{
|
||||
var table = LoadTable(
|
||||
(20u, "Add1", 300u),
|
||||
(21u, "Add2", 301u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeAddRecord(spellId: 20, layer: 1, statKey: 5u /* MaxMana */, val: 25f),
|
||||
MakeAddRecord(spellId: 21, layer: 2, statKey: 5u, val: 50f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxMana);
|
||||
Assert.Equal(1.0f, mod.Multiplier);
|
||||
Assert.Equal(75.0f, mod.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_StatKeyMismatch_DoesNotContribute()
|
||||
{
|
||||
var table = LoadTable((30u, "Health buff", 500u));
|
||||
// Buff modifies MaxHealth (key=1) but we ask for MaxStamina (key=3).
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 30, layer: 1, statKey: 1u /* MaxHealth */, val: 1.5f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_VitaeBucket_AppliedMultiplicativelyAfterBuffs()
|
||||
{
|
||||
// Vitae = 0.85 (15% death penalty) on MaxHealth, plus a +10
|
||||
// additive from a Restoration buff. Family 0 means each is its
|
||||
// own bucket.
|
||||
var table = LoadTable(
|
||||
(40u, "Restoration", 0u),
|
||||
(41u, "Vitae", 0u));
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeAddRecord(spellId: 40, layer: 1, statKey: 1u /* MaxHealth */, val: 10f),
|
||||
MakeVitaeRecord(spellId: 41, layer: 2, statKey: 1u, val: 0.85f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxHealth);
|
||||
// Vitae multiplier 0.85, additive 10.
|
||||
Assert.Equal(0.85f, mod.Multiplier, precision: 3);
|
||||
Assert.Equal(10.0f, mod.Additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMod_FamilyStacking_PicksHigherSpellId()
|
||||
{
|
||||
// Two spells in the same family — only the one with the higher
|
||||
// SpellId should contribute.
|
||||
var table = LoadTable(
|
||||
(10u, "Strength I", 1u), // Family=1
|
||||
(132u, "Strength VII", 1u)); // same family
|
||||
var enchantments = new[]
|
||||
{
|
||||
MakeMultRecord(spellId: 10u, layer: 1, statKey: 3u, val: 1.1f),
|
||||
MakeMultRecord(spellId: 132u, layer: 2, statKey: 3u, val: 1.5f),
|
||||
};
|
||||
var mod = EnchantmentMath.GetMod(enchantments, table,
|
||||
EnchantmentMath.StatKey.MaxStamina);
|
||||
// Only the higher-id buff (1.5) applies.
|
||||
Assert.Equal(1.5f, mod.Multiplier, precision: 3);
|
||||
}
|
||||
|
||||
private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u);
|
||||
|
||||
private static ActiveEnchantmentRecord MakeAddRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 2u);
|
||||
|
||||
private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) =>
|
||||
new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 8u);
|
||||
|
||||
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
|
||||
{
|
||||
// Build a synthetic CSV with just enough columns for SpellTable to
|
||||
// resolve Family on each spell id.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10");
|
||||
foreach (var (id, name, family) in rows)
|
||||
{
|
||||
sb.Append(id).Append(',').Append("0x").Append(id.ToString("X")).Append(',')
|
||||
.Append(name).Append(",0,0x0,1,1,").Append(family).Append(",0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0")
|
||||
.AppendLine();
|
||||
}
|
||||
return SpellTable.LoadFromReader(new System.IO.StringReader(sb.ToString()));
|
||||
}
|
||||
}
|
||||
128
tests/AcDream.Core.Tests/Spells/SpellTableTests.cs
Normal file
128
tests/AcDream.Core.Tests/Spells/SpellTableTests.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using System.IO;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Tests.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="SpellTable"/> CSV loader. Uses synthetic
|
||||
/// fixture strings rather than a real spells.csv so we don't depend
|
||||
/// on docs/research/data/ contents at test time.
|
||||
///
|
||||
/// Closes ISSUES.md #11 — spell metadata pipeline.
|
||||
/// </summary>
|
||||
public sealed class SpellTableTests
|
||||
{
|
||||
// Header used across fixtures — matches the column names from the
|
||||
// real docs/research/data/spells.csv. Row format is the same.
|
||||
private const string Header =
|
||||
"Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10";
|
||||
|
||||
private static SpellTable LoadFrom(string csv) =>
|
||||
SpellTable.LoadFromReader(new StringReader(csv));
|
||||
|
||||
[Fact]
|
||||
public void Empty_TableHasZeroEntries()
|
||||
{
|
||||
Assert.Equal(0, SpellTable.Empty.Count);
|
||||
Assert.False(SpellTable.Empty.TryGet(1u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromReader_HeaderOnly_EmptyTable()
|
||||
{
|
||||
var table = LoadFrom(Header);
|
||||
Assert.Equal(0, table.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromReader_SingleSimpleRow_HasMetadata()
|
||||
{
|
||||
// Spell ID 1 = Strength Other I, Family 1 (the actual real-data row).
|
||||
string csv =
|
||||
Header + "\n" +
|
||||
"1,0x1,Strength Other I,6450,0x600138C,1,1800,1,0x6,1,False,False,False,True,False,False,10,Creature Enchantment,0,Malar Cazael,0,6,0x10,1,Increases Strength.,5,1,1,1,0,0,0,0,0,0";
|
||||
var table = LoadFrom(csv);
|
||||
|
||||
Assert.Equal(1, table.Count);
|
||||
Assert.True(table.TryGet(1u, out var meta));
|
||||
Assert.Equal("Strength Other I", meta.Name);
|
||||
Assert.Equal("Creature Enchantment", meta.School);
|
||||
Assert.Equal(1u, meta.Family);
|
||||
Assert.Equal(0x600138Cu, meta.IconId);
|
||||
Assert.Equal("Malar Cazael", meta.SpellWords);
|
||||
Assert.Equal(1800f, meta.Duration);
|
||||
Assert.Equal(10, meta.ManaCost);
|
||||
Assert.False(meta.IsDebuff);
|
||||
Assert.False(meta.IsFellowship);
|
||||
Assert.Equal("Increases Strength.", meta.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromReader_QuotedDescriptionWithCommas_ParsesIntactly()
|
||||
{
|
||||
// The Description field in the real CSV is double-quoted so the
|
||||
// embedded comma doesn't split the row.
|
||||
string csv =
|
||||
Header + "\n" +
|
||||
"2,0x2,Strength Self I,6464,0x600138C,1,1800,1,0x400C,1,False,True,False,True,False,True,15,Creature Enchantment,0.01,Malar Cazael,0,6,0x10,1,\"Increases the caster's Strength by 10 points, lasting 30 minutes.\",0,0,1,2,0,0,0,0,0,0";
|
||||
var table = LoadFrom(csv);
|
||||
|
||||
Assert.True(table.TryGet(2u, out var meta));
|
||||
Assert.Equal("Increases the caster's Strength by 10 points, lasting 30 minutes.", meta.Description);
|
||||
Assert.Equal("Strength Self I", meta.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromReader_BlankLines_AreSkipped()
|
||||
{
|
||||
string csv =
|
||||
Header + "\n" +
|
||||
"1,0x1,Test,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0\n" +
|
||||
"\n" +
|
||||
" \n" +
|
||||
"2,0x2,Test2,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0";
|
||||
var table = LoadFrom(csv);
|
||||
Assert.Equal(2, table.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromReader_BadSpellId_RowSkipped()
|
||||
{
|
||||
string csv =
|
||||
Header + "\n" +
|
||||
"not_a_uint,0x1,Bad,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0\n" +
|
||||
"5,0x5,Good,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0";
|
||||
var table = LoadFrom(csv);
|
||||
Assert.Equal(1, table.Count);
|
||||
Assert.True(table.TryGet(5u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_UnknownSpellId_ReturnsFalse()
|
||||
{
|
||||
var table = LoadFrom(Header);
|
||||
Assert.False(table.TryGet(99999u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRow_SimpleCsv_SplitsOnCommas()
|
||||
{
|
||||
var fields = SpellTable.ParseRow("a,b,c");
|
||||
Assert.Equal(new[] { "a", "b", "c" }, fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRow_QuotedFieldWithComma_KeepsComma()
|
||||
{
|
||||
var fields = SpellTable.ParseRow("a,\"b,c\",d");
|
||||
Assert.Equal(new[] { "a", "b,c", "d" }, fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRow_EscapedDoubleQuoteInsideQuotedField()
|
||||
{
|
||||
// RFC 4180: "" inside a quoted field is a literal " character.
|
||||
var fields = SpellTable.ParseRow("a,\"b\"\"c\",d");
|
||||
Assert.Equal(new[] { "a", "b\"c", "d" }, fields);
|
||||
}
|
||||
}
|
||||
117
tools/pdb-extract/README.md
Normal file
117
tools/pdb-extract/README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# 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
|
||||
|
||||
```powershell
|
||||
py tools\pdb-extract\pdb_extract.py refs\acclient.pdb
|
||||
```
|
||||
|
||||
Runs in <1 second. No external dependencies — uses Python stdlib only.
|
||||
|
||||
## Schema
|
||||
|
||||
`symbols.json`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"address": "0x00594570",
|
||||
"name": "CEnchantmentRegistry::EnchantAttribute",
|
||||
"mangled": "?EnchantAttribute@CEnchantmentRegistry@@QBEHKAAK@Z"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
`types.json`:
|
||||
```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:
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
149
tools/pdb-extract/check_function_map.py
Normal file
149
tools/pdb-extract/check_function_map.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Cross-check acclient_function_map.md addresses against symbols.json.
|
||||
|
||||
For each row, looks up the function name in the PDB symbols table and
|
||||
flags mismatches (mid-body addresses, wrong addresses, missing names).
|
||||
|
||||
Run with:
|
||||
py tools/pdb-extract/check_function_map.py
|
||||
|
||||
Used to close ISSUES.md #9 (address-correction sweep).
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent.parent
|
||||
SYMBOLS = REPO / "docs" / "research" / "named-retail" / "symbols.json"
|
||||
|
||||
# (claimed_address, qualified_name) tuples from acclient_function_map.md.
|
||||
# Format: lowercase hex no leading 0, then the demangled Class::Method.
|
||||
ENTRIES = [
|
||||
# CPhysicsObj
|
||||
("515020", "CPhysicsObj::update_object"),
|
||||
("5111d0", "CPhysicsObj::UpdatePhysicsInternal"),
|
||||
("511420", "CPhysicsObj::calc_acceleration"),
|
||||
("511ec0", "CPhysicsObj::set_velocity"),
|
||||
("511fa0", "CPhysicsObj::set_local_velocity"),
|
||||
("511de0", "CPhysicsObj::set_on_walkable"),
|
||||
("511560", "CPhysicsObj::report_collision_start"),
|
||||
("513ac0", "CPhysicsObj::report_collision_end"),
|
||||
("513b60", "CPhysicsObj::handle_obj_collision"),
|
||||
("515280", "CPhysicsObj::handle_collision"),
|
||||
("513730", "CPhysicsObj::UpdatePositionInternal"),
|
||||
("50f940", "CPhysicsObj::calc_friction"),
|
||||
("510080", "CPhysicsObj::check_contact_velocity"),
|
||||
# CMotionInterp
|
||||
("5286b0", "CMotionInterp::get_jump_v_z"),
|
||||
("528660", "CMotionInterp::jump_charge_is_allowed"),
|
||||
("5285e0", "CMotionInterp::motion_allows_jump"),
|
||||
("528ec0", "CMotionInterp::jump_is_allowed"),
|
||||
("528cd0", "CMotionInterp::get_leave_ground_velocity"),
|
||||
("529390", "CMotionInterp::jump"),
|
||||
("529710", "CMotionInterp::LeaveGround"),
|
||||
("5296d0", "CMotionInterp::HitGround"),
|
||||
("528960", "CMotionInterp::get_state_velocity"),
|
||||
("528a50", "CMotionInterp::StopCompletely"),
|
||||
("5287f0", "CMotionInterp::adjust_motion"),
|
||||
("5293f0", "CMotionInterp::apply_raw_movement"),
|
||||
("529210", "CMotionInterp::apply_current_movement"),
|
||||
("528dd0", "CMotionInterp::contact_allows_move"),
|
||||
("528f70", "CMotionInterp::DoInterpretedMotion"),
|
||||
("529080", "CMotionInterp::StopInterpretedMotion"),
|
||||
("529140", "CMotionInterp::StopMotion"),
|
||||
("529930", "CMotionInterp::DoMotion"),
|
||||
("529a90", "CMotionInterp::PerformMovement"),
|
||||
# CLandBlockStruct
|
||||
("531d10", "CLandBlockStruct::IsSWtoNECut"),
|
||||
("532a50", "CLandBlockStruct::ConstructPolygons"),
|
||||
("532eb0", "CLandBlockStruct::ConstructUVs"),
|
||||
("532d10", "CLandBlockStruct::unpack"),
|
||||
("531f10", "CLandBlockStruct::get_packed_size"),
|
||||
("532440", "CLandBlockStruct::AdjustPlanes"),
|
||||
("532290", "CLandBlockStruct::CalcCellWater"),
|
||||
# CLandBlock
|
||||
("530690", "CLandBlock::Init"),
|
||||
("5307e0", "CLandBlock::release_all"),
|
||||
("531780", "CLandBlock::init_static_objs"),
|
||||
("531000", "CLandBlock::release_visible_cells"),
|
||||
("5301e0", "CLandBlock::grab_visible_cells"),
|
||||
("530650", "CLandBlock::add_server_object"),
|
||||
# LandDefs
|
||||
("5aaa30", "CLandDefs::get_vars"),
|
||||
("5aabb0", "CLandDefs::get_outside_lcoord"),
|
||||
("5aac70", "CLandDefs::AdjustToOutside"),
|
||||
("5aab50", "CLandDefs::get_block_dir"),
|
||||
# Collision / Transition (some entries are CCylSphere / CSphere / CTransition / CPolygon)
|
||||
("5384e0", "CSphere::SphereIntersectsRay"),
|
||||
("539500", "CPolygon::sphere_intersects_poly"),
|
||||
("539750", "CPolygon::sphere_intersects_solid"),
|
||||
("539ba0", "CPolygon::find_time_of_collision"),
|
||||
("539df0", "CPolygon::find_time_of_collision"),
|
||||
("53a040", "CPolygon::find_walkable_collision"),
|
||||
("539110", "CPolygon::calc_normal"),
|
||||
("539060", "CPlane::ray_plane_intersect"),
|
||||
("538eb0", "CSphere::slide_sphere"),
|
||||
("538f50", "CSphere::land_on_sphere"),
|
||||
("538180", "CTransition::collide_with_point"),
|
||||
("5387c0", "CTransition::find_collisions"),
|
||||
("53a230", "CPolygon::hits_walkable"),
|
||||
# PhysicsEngine
|
||||
("452a10", "PhysicsEngine::update"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
with open(SYMBOLS, "r", encoding="utf-8") as f:
|
||||
symbols = json.load(f)
|
||||
|
||||
# Build name -> [addresses] (some names appear multiple times: overloads)
|
||||
by_name = {}
|
||||
for s in symbols:
|
||||
by_name.setdefault(s["name"], []).append(s["address"])
|
||||
|
||||
# Build address -> name (lowercase hex without 0x prefix)
|
||||
by_addr = {}
|
||||
for s in symbols:
|
||||
addr = s["address"][2:].lower().lstrip("0") or "0"
|
||||
by_addr[addr] = s["name"]
|
||||
|
||||
confirmed = 0
|
||||
addr_corrections = []
|
||||
name_misses = []
|
||||
for claimed_addr, claimed_name in ENTRIES:
|
||||
# Look up by name
|
||||
if claimed_name in by_name:
|
||||
pdb_addrs = by_name[claimed_name]
|
||||
normalized_claimed = claimed_addr.lower().lstrip("0") or "0"
|
||||
match = any(
|
||||
(a[2:].lower().lstrip("0") or "0") == normalized_claimed
|
||||
for a in pdb_addrs
|
||||
)
|
||||
if match:
|
||||
confirmed += 1
|
||||
else:
|
||||
addr_corrections.append((claimed_addr, claimed_name, pdb_addrs))
|
||||
else:
|
||||
# Name not found exactly. Try the claimed address as fallback.
|
||||
normalized_claimed = claimed_addr.lower().lstrip("0") or "0"
|
||||
actual_name = by_addr.get(normalized_claimed)
|
||||
name_misses.append((claimed_addr, claimed_name, actual_name))
|
||||
|
||||
print(f"Confirmed (address + name match): {confirmed}/{len(ENTRIES)}")
|
||||
print()
|
||||
if addr_corrections:
|
||||
print("ADDRESS CORRECTIONS (name correct, address wrong — likely mid-body):")
|
||||
for claimed_addr, name, pdb_addrs in addr_corrections:
|
||||
print(f" 0x{claimed_addr.upper()} {name}")
|
||||
print(f" actual: {', '.join(pdb_addrs)}")
|
||||
print()
|
||||
if name_misses:
|
||||
print("NAME MISMATCHES (name not found by exact match in PDB):")
|
||||
for claimed_addr, name, actual_name in name_misses:
|
||||
print(f" 0x{claimed_addr.upper()} {name}")
|
||||
if actual_name:
|
||||
print(f" PDB at 0x{claimed_addr.upper()}: {actual_name}")
|
||||
else:
|
||||
print(f" PDB has no symbol at 0x{claimed_addr.upper()}")
|
||||
print()
|
||||
|
||||
|
||||
main()
|
||||
458
tools/pdb-extract/pdb_extract.py
Normal file
458
tools/pdb-extract/pdb_extract.py
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
"""Pure-Python MSF 7.00 PDB extractor for acclient.pdb.
|
||||
|
||||
Reads the Microsoft Program Database, extracts public function symbols
|
||||
(S_PUB32, kind 0x110E) and named struct/class type records (LF_CLASS,
|
||||
LF_STRUCTURE) from the TPI stream, and writes two JSON sidecars to
|
||||
docs/research/named-retail/:
|
||||
|
||||
symbols.json — every named public function with its image VA
|
||||
types.json — every named struct/class with size
|
||||
|
||||
This is a foundation for the named-retail workflow. After running, every
|
||||
future session can grep the JSON for instant address↔name lookups.
|
||||
|
||||
Run with:
|
||||
py tools/pdb-extract/pdb_extract.py refs/acclient.pdb
|
||||
|
||||
References:
|
||||
https://llvm.org/docs/PDB/MsfFile.html — MSF container layout
|
||||
https://llvm.org/docs/PDB/PublicStream.html — symbol record format
|
||||
https://llvm.org/docs/PDB/TpiStream.html — type-info stream layout
|
||||
https://llvm.org/docs/PDB/DbiStream.html — DBI header + sections
|
||||
|
||||
No external dependencies — uses stdlib `struct` + `json` only.
|
||||
|
||||
Wire constants (PDB7 / MSF 7.00):
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── MSF / PDB7 constants ───────────────────────────────────────────────────
|
||||
|
||||
MSF_MAGIC = b"Microsoft C/C++ MSF 7.00\r\n\x1aDS\0\0\0"
|
||||
SUPERBLOCK_SIZE = 56 # u32 ×6 + magic; MSF 7.00 layout
|
||||
|
||||
# Stream indices (fixed by spec)
|
||||
STREAM_PDB_INFO = 1
|
||||
STREAM_TPI = 2
|
||||
STREAM_DBI = 3
|
||||
STREAM_IPI = 4
|
||||
|
||||
# Symbol record kinds (subset)
|
||||
S_PUB32 = 0x110E # Public symbol (32-bit)
|
||||
|
||||
# Type record kinds (subset)
|
||||
LF_CLASS = 0x1504
|
||||
LF_STRUCTURE = 0x1505
|
||||
LF_UNION = 0x1506
|
||||
LF_ENUM = 0x1507
|
||||
|
||||
# DBI machine type (image-base + section table source)
|
||||
SECTION_HEADERS_DEFAULT_BASE = 0x00400000 # acclient.exe image base
|
||||
|
||||
# CV public-symbol flag bits
|
||||
PUBSYM_FLAG_CODE = 0x00000002
|
||||
|
||||
|
||||
# ── MSF reader ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Msf:
|
||||
"""In-memory wrapper over a PDB file. Loads the superblock + stream
|
||||
directory, exposes per-stream byte buffers reconstructed from page
|
||||
chains."""
|
||||
|
||||
def __init__(self, path):
|
||||
with open(path, "rb") as f:
|
||||
self._data = f.read()
|
||||
|
||||
if not self._data.startswith(MSF_MAGIC):
|
||||
raise ValueError(f"not an MSF 7.00 file: {path}")
|
||||
|
||||
# Superblock fields (after the 32-byte magic).
|
||||
(self.block_size,
|
||||
self.free_block_map,
|
||||
self.num_blocks,
|
||||
self.num_dir_bytes,
|
||||
_reserved,
|
||||
self.block_map_addr) = struct.unpack_from("<6I", self._data, 32)
|
||||
|
||||
if self.block_size not in (512, 1024, 2048, 4096):
|
||||
raise ValueError(f"unexpected block size: {self.block_size}")
|
||||
|
||||
# Stream directory: read the directory-block-map first (a list
|
||||
# of page indices that themselves spell out the directory's
|
||||
# page list). Then read the directory pages.
|
||||
dir_pages_needed = _ceil_div(self.num_dir_bytes, self.block_size)
|
||||
block_map_pages_needed = _ceil_div(dir_pages_needed * 4, self.block_size)
|
||||
# The block_map_addr is the page index of the FIRST page in the
|
||||
# block-map. The block-map's pages are stored sequentially.
|
||||
# Wait, actually it's a single page index pointing to one page
|
||||
# full of u32 page indices. If there are >block_size/4 pages in
|
||||
# the directory, this overflows; for typical small PDBs it
|
||||
# doesn't. acclient.pdb's 119 KB directory at 4 KB pages = 30
|
||||
# pages -> 30 u32s = 120 bytes, fits in one page. OK.
|
||||
block_map = self._read_page(self.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(self._read_page(pi))
|
||||
dir_data = bytes(dir_data[:self.num_dir_bytes])
|
||||
|
||||
# Directory layout:
|
||||
# u32 NumStreams
|
||||
# u32 StreamSizes[NumStreams]
|
||||
# for i in 0..NumStreams: u32 StreamPageIndices[ceil(size_i / block_size)]
|
||||
num_streams = struct.unpack_from("<I", dir_data, 0)[0]
|
||||
sizes = struct.unpack_from(f"<{num_streams}I", dir_data, 4)
|
||||
# 0xFFFFFFFF is the "deleted stream" sentinel — treat as size 0.
|
||||
sizes = tuple(0 if s == 0xFFFFFFFF else s for s in sizes)
|
||||
|
||||
offset = 4 + 4 * num_streams
|
||||
streams = []
|
||||
for size in sizes:
|
||||
pages_needed = _ceil_div(size, self.block_size)
|
||||
indices = struct.unpack_from(
|
||||
f"<{pages_needed}I", dir_data, offset)
|
||||
offset += 4 * pages_needed
|
||||
streams.append((size, indices))
|
||||
self.streams = streams
|
||||
|
||||
def _read_page(self, page_index):
|
||||
start = page_index * self.block_size
|
||||
return self._data[start:start + self.block_size]
|
||||
|
||||
def stream(self, idx):
|
||||
"""Return the raw bytes of stream `idx` (concatenated pages,
|
||||
truncated to declared size)."""
|
||||
size, pages = self.streams[idx]
|
||||
buf = bytearray()
|
||||
for p in pages:
|
||||
buf.extend(self._read_page(p))
|
||||
return bytes(buf[:size])
|
||||
|
||||
|
||||
def _ceil_div(a, b):
|
||||
return (a + b - 1) // b
|
||||
|
||||
|
||||
# ── DBI parser (just enough for: section headers + symbol stream index) ────
|
||||
|
||||
def _parse_dbi(dbi_bytes):
|
||||
"""Pull the section-header-stream index + symbol-record-stream index
|
||||
out of the DBI header. Returns (sym_stream_idx, section_hdr_stream_idx).
|
||||
"""
|
||||
# DBI header (first 64 bytes)
|
||||
if len(dbi_bytes) < 64:
|
||||
raise ValueError("DBI stream too short")
|
||||
(version_sig, version_hdr, age,
|
||||
gsi_stream, build_no,
|
||||
psgsi_stream, pdb_dll_ver,
|
||||
sym_record_stream, pdb_dll_rbld,
|
||||
mod_info_size, section_contrib_size, section_map_size,
|
||||
source_info_size, type_server_map_size, mfc_type_server_idx,
|
||||
opt_dbg_hdr_size, ec_substream_size,
|
||||
flags, machine, padding) = struct.unpack_from(
|
||||
"<iIIHHHHHHiiiiiIiiHHI", dbi_bytes, 0)
|
||||
|
||||
# The optional debug header sub-stream is at the end of the DBI.
|
||||
# Layout offset = 64 + sum of all preceding sub-stream sizes.
|
||||
base = 64
|
||||
offsets = {
|
||||
"mod_info": base,
|
||||
"section_contrib": base + mod_info_size,
|
||||
"section_map": base + mod_info_size + section_contrib_size,
|
||||
"source_info": base + mod_info_size + section_contrib_size + section_map_size,
|
||||
"type_server_map": base + mod_info_size + section_contrib_size + section_map_size + source_info_size,
|
||||
"ec_substream": base + mod_info_size + section_contrib_size + section_map_size + source_info_size + type_server_map_size,
|
||||
"opt_dbg_header": base + mod_info_size + section_contrib_size + section_map_size + source_info_size + type_server_map_size + ec_substream_size,
|
||||
}
|
||||
|
||||
# The optional debug header is an array of u16 stream indices; the
|
||||
# one at index 5 is the section-headers stream (per LLVM docs).
|
||||
opt_off = offsets["opt_dbg_header"]
|
||||
opt_count = opt_dbg_hdr_size // 2
|
||||
opt_streams = struct.unpack_from(
|
||||
f"<{opt_count}H", dbi_bytes, opt_off)
|
||||
# Index 5 = original section headers stream.
|
||||
section_hdr_stream = opt_streams[5] if opt_count > 5 else 0xFFFF
|
||||
|
||||
return sym_record_stream, section_hdr_stream
|
||||
|
||||
|
||||
# ── Public symbols extractor (S_PUB32 from sym-record stream) ──────────────
|
||||
|
||||
def _demangle(mangled):
|
||||
"""Best-effort demangle of MSVC C++ symbol names to Class::Method form.
|
||||
|
||||
PDB public symbols use the MSVC ABI mangling. A full demangler would
|
||||
parse arg types + calling conventions; we only need the readable
|
||||
qualified name for grep workflows.
|
||||
|
||||
Examples:
|
||||
"?EnchantAttribute@CACQualities@@IBEHKAAK@Z" -> "CACQualities::EnchantAttribute"
|
||||
"??0CEnchantmentRegistry@@QAE@XZ" -> "CEnchantmentRegistry::CEnchantmentRegistry"
|
||||
"??1Foo@@QAE@XZ" -> "Foo::~Foo"
|
||||
"_someThing" -> "_someThing" (C-style, kept as-is)
|
||||
"?GlobalFunc@@..." -> "GlobalFunc" (no class)
|
||||
"""
|
||||
if not mangled or not mangled.startswith("?"):
|
||||
return mangled # C-linkage symbol, return as-is
|
||||
# Strip the leading '?' (or '??' for ctors/dtors)
|
||||
if mangled.startswith("??0"): # constructor
|
||||
rest = mangled[3:]
|
||||
ctor_dtor = "ctor"
|
||||
elif mangled.startswith("??1"): # destructor
|
||||
rest = mangled[3:]
|
||||
ctor_dtor = "dtor"
|
||||
elif mangled.startswith("??_"): # vtable / vbtable / etc — leave as-is
|
||||
return mangled
|
||||
else:
|
||||
rest = mangled[1:]
|
||||
ctor_dtor = None
|
||||
|
||||
# `Name@Class@Outer@@<sig>` -> split on '@@' to drop the signature suffix
|
||||
sep = rest.find("@@")
|
||||
if sep < 0:
|
||||
return mangled # not a recognised pattern
|
||||
qualified = rest[:sep]
|
||||
parts = qualified.split("@")
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
return mangled
|
||||
|
||||
if ctor_dtor:
|
||||
# parts[0] is the class name (the only part); ctor name = class name.
|
||||
cls = parts[0]
|
||||
outer = "::".join(reversed(parts[1:]))
|
||||
full = f"{outer}::{cls}" if outer else cls
|
||||
if ctor_dtor == "dtor":
|
||||
return f"{full}::~{cls}"
|
||||
return f"{full}::{cls}"
|
||||
|
||||
# Method: parts[0] is the function name, parts[1:] are nested classes
|
||||
# (innermost first -> reverse for outer::inner::method order).
|
||||
method = parts[0]
|
||||
classes = list(reversed(parts[1:]))
|
||||
if classes:
|
||||
return "::".join(classes) + "::" + method
|
||||
return method
|
||||
|
||||
|
||||
def _extract_pub32(sym_bytes, section_bases):
|
||||
"""Iterate S_PUB32 records, compute image VA, return list of dicts.
|
||||
|
||||
sym_bytes: raw sym-record-stream bytes.
|
||||
section_bases: list of section base VAs (1-indexed via segment field).
|
||||
"""
|
||||
pos = 0
|
||||
end = len(sym_bytes)
|
||||
out = []
|
||||
while pos + 4 <= end:
|
||||
rec_len, kind = struct.unpack_from("<HH", sym_bytes, pos)
|
||||
if rec_len == 0:
|
||||
break
|
||||
rec_end = pos + 2 + rec_len # rec_len excludes its own u16
|
||||
if kind == S_PUB32:
|
||||
# body: u32 flags, u32 offset, u16 seg, char name[]
|
||||
flags, offset, seg = struct.unpack_from("<IIH", sym_bytes, pos + 4)
|
||||
name_start = pos + 14
|
||||
zero = sym_bytes.index(b"\0", name_start, rec_end)
|
||||
mangled = sym_bytes[name_start:zero].decode("ascii", errors="replace")
|
||||
if (flags & PUBSYM_FLAG_CODE) and seg >= 1 and (seg - 1) < len(section_bases):
|
||||
va = section_bases[seg - 1] + offset
|
||||
out.append({
|
||||
"address": f"0x{va:08X}",
|
||||
"name": _demangle(mangled),
|
||||
"mangled": mangled,
|
||||
"flags": flags,
|
||||
})
|
||||
pos = rec_end
|
||||
# Records align to 4 bytes
|
||||
if pos % 4:
|
||||
pos += 4 - (pos % 4)
|
||||
return out
|
||||
|
||||
|
||||
# ── Section-header stream parser (40 bytes per IMAGE_SECTION_HEADER) ───────
|
||||
|
||||
def _parse_section_headers(sec_bytes, image_base=SECTION_HEADERS_DEFAULT_BASE):
|
||||
"""Each entry is a 40-byte IMAGE_SECTION_HEADER. Returns list of
|
||||
section-VA bases (so symbol[seg-1] + offset = VA)."""
|
||||
bases = []
|
||||
SECTION_SIZE = 40
|
||||
offset = 0
|
||||
while offset + SECTION_SIZE <= len(sec_bytes):
|
||||
# Layout: char Name[8], u32 VirtSize, u32 VirtAddress, ...
|
||||
virt_size, virt_addr = struct.unpack_from("<II", sec_bytes, offset + 8)
|
||||
if virt_addr == 0 and offset > 0:
|
||||
# Padding / empty trailing entry — stop.
|
||||
break
|
||||
bases.append(image_base + virt_addr)
|
||||
offset += SECTION_SIZE
|
||||
return bases
|
||||
|
||||
|
||||
# ── TPI parser — minimal pass to extract LF_CLASS/STRUCTURE names + sizes ──
|
||||
|
||||
def _extract_named_types(tpi_bytes):
|
||||
"""Walk the TPI stream's type record array and yield named class /
|
||||
struct / union / enum records with their declared size. Skips
|
||||
forward-declared (incomplete) records."""
|
||||
if len(tpi_bytes) < 56:
|
||||
return []
|
||||
# TPI header (56 bytes)
|
||||
(version, header_size,
|
||||
ti_min, ti_max,
|
||||
gpi_size,
|
||||
gpi_substream_offset,
|
||||
hash_aux_idx,
|
||||
hash_key_size, num_hash_buckets,
|
||||
hash_value_off, hash_value_len,
|
||||
ti_off_off, ti_off_len,
|
||||
hash_adj_off, hash_adj_len) = struct.unpack_from(
|
||||
"<IIIIIIHHIIIIIII", tpi_bytes, 0)
|
||||
pos = header_size # records start right after the header
|
||||
end = pos + gpi_size
|
||||
if end > len(tpi_bytes):
|
||||
end = len(tpi_bytes)
|
||||
|
||||
out = []
|
||||
while pos + 4 <= end:
|
||||
rec_len, kind = struct.unpack_from("<HH", tpi_bytes, pos)
|
||||
if rec_len == 0:
|
||||
break
|
||||
rec_end = pos + 2 + rec_len
|
||||
if kind in (LF_CLASS, LF_STRUCTURE):
|
||||
# body layout (LLVM type-records.h):
|
||||
# u16 count, u16 props, u32 fieldList, u32 derived,
|
||||
# u32 vshape, then varint16 size, then null-term string.
|
||||
try:
|
||||
count, props, field_list, derived, vshape = struct.unpack_from(
|
||||
"<HHIII", tpi_bytes, pos + 4)
|
||||
# varint16 size: <0x8000 = u16 inline; ≥0x8000 = follow-on u16/u32.
|
||||
size_pos = pos + 4 + 16
|
||||
size_word = tpi_bytes[size_pos] | (tpi_bytes[size_pos + 1] << 8)
|
||||
if size_word < 0x8000:
|
||||
size_val = size_word
|
||||
name_start = size_pos + 2
|
||||
else:
|
||||
# Numeric leaves. 0x8002=u16, 0x8003=u32, etc. Skip.
|
||||
if size_word == 0x8002:
|
||||
size_val = struct.unpack_from("<H", tpi_bytes, size_pos + 2)[0]
|
||||
name_start = size_pos + 4
|
||||
elif size_word == 0x8003:
|
||||
size_val = struct.unpack_from("<I", tpi_bytes, size_pos + 2)[0]
|
||||
name_start = size_pos + 6
|
||||
else:
|
||||
# Unknown numeric leaf — skip.
|
||||
pos = rec_end
|
||||
if pos % 4: pos += 4 - (pos % 4)
|
||||
continue
|
||||
# Name is null-terminated ASCII.
|
||||
if name_start < rec_end:
|
||||
zero = tpi_bytes.find(b"\0", name_start, rec_end)
|
||||
if zero > name_start:
|
||||
name = tpi_bytes[name_start:zero].decode("ascii", errors="replace")
|
||||
# Skip forward-decls: bit 7 of `props` (0x80).
|
||||
is_fwdref = (props & 0x80) != 0
|
||||
if not is_fwdref and name and not name.startswith("<unnamed"):
|
||||
out.append({
|
||||
"name": name,
|
||||
"size": size_val,
|
||||
"kind": "class" if kind == LF_CLASS else "struct",
|
||||
})
|
||||
except (struct.error, IndexError, ValueError):
|
||||
pass
|
||||
|
||||
pos = rec_end
|
||||
if pos % 4:
|
||||
pos += 4 - (pos % 4)
|
||||
return out
|
||||
|
||||
|
||||
# ── Driver ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_repo_root():
|
||||
"""tools/pdb-extract/ -> repo root is two directories up."""
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _main():
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: py pdb_extract.py <path-to-acclient.pdb>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
pdb_path = Path(sys.argv[1])
|
||||
if not pdb_path.exists():
|
||||
print(f"not found: {pdb_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
print(f"loading {pdb_path} ({pdb_path.stat().st_size / 1024 / 1024:.1f} MB)...")
|
||||
msf = Msf(str(pdb_path))
|
||||
print(f" block size: {msf.block_size}, streams: {len(msf.streams)}")
|
||||
|
||||
# 1. DBI -> find sym-record + section-header stream indices
|
||||
dbi_bytes = msf.stream(STREAM_DBI)
|
||||
sym_stream_idx, sec_hdr_stream_idx = _parse_dbi(dbi_bytes)
|
||||
print(f" sym stream: {sym_stream_idx}, section-hdr stream: {sec_hdr_stream_idx}")
|
||||
|
||||
# 2. Section headers -> segment bases
|
||||
sec_bytes = msf.stream(sec_hdr_stream_idx)
|
||||
section_bases = _parse_section_headers(sec_bytes)
|
||||
print(f" sections: {len(section_bases)} (text base = 0x{section_bases[0]:08X})")
|
||||
|
||||
# 3. Symbol records -> S_PUB32 entries with image VAs
|
||||
sym_bytes = msf.stream(sym_stream_idx)
|
||||
print(f" sym record stream: {len(sym_bytes) / 1024:.1f} KB")
|
||||
symbols = _extract_pub32(sym_bytes, section_bases)
|
||||
print(f" extracted {len(symbols)} public function symbols")
|
||||
|
||||
# 4. TPI -> named types
|
||||
tpi_bytes = msf.stream(STREAM_TPI)
|
||||
print(f" TPI stream: {len(tpi_bytes) / 1024:.1f} KB")
|
||||
types = _extract_named_types(tpi_bytes)
|
||||
# Dedup by name (templates/forward-decl spam can produce duplicates)
|
||||
seen = set()
|
||||
unique_types = []
|
||||
for t in types:
|
||||
if t["name"] not in seen:
|
||||
seen.add(t["name"])
|
||||
unique_types.append(t)
|
||||
print(f" extracted {len(unique_types)} unique named types ({len(types)} total records)")
|
||||
|
||||
# 5. Write outputs
|
||||
repo = _resolve_repo_root()
|
||||
out_dir = repo / "docs" / "research" / "named-retail"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sym_out = out_dir / "symbols.json"
|
||||
type_out = out_dir / "types.json"
|
||||
|
||||
with open(sym_out, "w", encoding="utf-8") as f:
|
||||
# Keep address + demangled name + raw mangled name (for callers
|
||||
# that need the C++ ABI form). Strip flags as not useful for grep.
|
||||
compact = [
|
||||
{"address": s["address"], "name": s["name"], "mangled": s["mangled"]}
|
||||
for s in symbols
|
||||
]
|
||||
json.dump(compact, f, indent=2)
|
||||
with open(type_out, "w", encoding="utf-8") as f:
|
||||
json.dump(unique_types, f, indent=2)
|
||||
|
||||
print(f"\nwrote {sym_out} ({sym_out.stat().st_size / 1024:.1f} KB)")
|
||||
print(f"wrote {type_out} ({type_out.stat().st_size / 1024:.1f} KB)")
|
||||
# Spot check: CEnchantmentRegistry::EnchantAttribute should be at 0x594570 per discovery agent.
|
||||
target = "CEnchantmentRegistry::EnchantAttribute"
|
||||
for s in symbols:
|
||||
if s["name"] == target:
|
||||
print(f"\nspot check: {target} -> {s['address']} (expected 0x00594570)")
|
||||
break
|
||||
else:
|
||||
print(f"\nspot check: {target} NOT FOUND in symbols (PDB lookup mismatch?)")
|
||||
|
||||
|
||||
_main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue