Compare commits
No commits in common. "bb5003a8492fded552e4c0a3fe05576b0f88d571" and "7da2a027d43aa48eef0e835eb1738ab79a1cb7c9" have entirely different histories.
bb5003a849
...
7da2a027d4
34 changed files with 153 additions and 2961024 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -31,7 +31,3 @@ 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,17 +8,10 @@ with a plugin API the original never had.
|
|||
|
||||
**The code is modern. The behavior is retail.**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**Architecture:** `docs/architecture/acdream-architecture.md` is the
|
||||
single source of truth for how the client is structured. All work must
|
||||
|
|
@ -91,33 +84,19 @@ 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: grep named → decompile → verify → port
|
||||
## Development workflow: 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:
|
||||
|
||||
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
|
||||
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
|
||||
known functions. If the function isn't mapped yet, search by
|
||||
characteristic constants (motion commands, magic numbers, string
|
||||
literals).
|
||||
|
|
@ -153,11 +132,7 @@ from 30 minutes to 5 seconds.**
|
|||
### What NOT to do:
|
||||
|
||||
- **Do not guess** at AC-specific algorithms, formulas, constants, wire
|
||||
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.
|
||||
formats, or coordinate conventions. Ever.
|
||||
- **Do not "fix" the decompiled code.** If the retail client does
|
||||
something that looks wrong, it's probably right. Verify before
|
||||
changing.
|
||||
|
|
@ -174,8 +149,7 @@ from 30 minutes to 5 seconds.**
|
|||
Before marking any phase as done:
|
||||
|
||||
- [ ] Every AC-specific algorithm has a decompiled reference cited in
|
||||
comments (named symbol + address from `named-retail/symbols.json`,
|
||||
OR function address + chunk file from older `decompiled/` chunks)
|
||||
comments (function address + chunk file)
|
||||
- [ ] Conformance tests exist for the critical paths
|
||||
- [ ] The code was cross-referenced against at least 2 reference repos
|
||||
- [ ] `dotnet build` green, `dotnet test` green
|
||||
|
|
@ -456,7 +430,6 @@ 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. |
|
||||
|
|
|
|||
110
docs/ISSUES.md
110
docs/ISSUES.md
|
|
@ -114,29 +114,45 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
|
||||
## #6 — Vital max ignores enchantment buffs + vitae
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (no current user-visible bug; future panels will need the data)
|
||||
**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)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (Issue #5 needed only the early sections; later panels will need the rest)
|
||||
**Filed:** 2026-04-25
|
||||
**Component:** net / player-state
|
||||
|
||||
**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.
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
**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).
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
|
||||
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
|
||||
- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
|
||||
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add coverage with synthetic payloads of the trailing sections.
|
||||
|
||||
**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
|
||||
**Research:** holtburger `crates/holtburger-protocol/src/messages/player/events.rs:462-625` (full unpacker including the heuristic `find_inventory_start_after_gameplay_options`).
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -165,76 +181,6 @@ 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,15 +17,10 @@ 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 **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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -223,41 +223,6 @@ 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,47 +1,25 @@
|
|||
# acclient.exe Decompiled Function Map
|
||||
|
||||
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/`.
|
||||
Mapped from 22,225 decompiled functions (688K lines of C) against ACE's
|
||||
C# physics port and ACME's ClientReference.cs.
|
||||
|
||||
## CPhysicsObj (chunk_00510000.c, chunk_00500000.c)
|
||||
|
||||
> **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.
|
||||
| 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 |
|
||||
|
||||
### PhysicsObj Struct Layout
|
||||
|
||||
|
|
@ -59,32 +37,27 @@ retail decomp at `docs/research/named-retail/`.
|
|||
|
||||
## CMotionInterp (chunk_00520000.c)
|
||||
|
||||
> **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) |
|
||||
| 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) |
|
||||
|
||||
### MotionInterp Struct Layout
|
||||
|
||||
|
|
@ -106,76 +79,53 @@ retail decomp at `docs/research/named-retail/`.
|
|||
|
||||
## CLandBlockStruct (chunk_00530000.c)
|
||||
|
||||
> **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 |
|
||||
| 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 |
|
||||
|
||||
## CLandBlock (chunk_00530000.c)
|
||||
|
||||
> **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. |
|
||||
| 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 |
|
||||
|
||||
## LandDefs (chunk_005A0000.c)
|
||||
|
||||
> **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 |
|
||||
| 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 |
|
||||
|
||||
## Collision / Transition (chunk_00530000.c)
|
||||
|
||||
> **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. |
|
||||
| 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 |
|
||||
|
||||
## WeenieObject vtable (confirmed from call sites)
|
||||
|
||||
|
|
@ -191,31 +141,6 @@ retail decomp at `docs/research/named-retail/`.
|
|||
|
||||
## PhysicsEngine (chunk_00450000.c)
|
||||
|
||||
| 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.
|
||||
| Address | FUN name | ACE method | Description |
|
||||
|---------|----------|-----------|-------------|
|
||||
| 0x452A10 | FUN_00452a10 | PhysicsEngine::update | Main simulation loop |
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# 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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,70 +0,0 @@
|
|||
# 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`.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -62,19 +62,10 @@ 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 (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).
|
||||
### 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
|
||||
|
||||
### Pseudocode (already written):
|
||||
- `docs/research/transition_pseudocode.md` — full system documented
|
||||
|
|
@ -99,12 +90,10 @@ ground truth. ACE has the ENTIRE system already in C#:
|
|||
|
||||
For EVERY function:
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
2. **CROSS-REFERENCE ACE.** Read ACE's C# port of the same function.
|
||||
ACE provides naming and structure. Note any differences.
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
# 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,13 +8,6 @@ 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,11 +31,6 @@
|
|||
<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,19 +278,11 @@ 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();
|
||||
// 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.Spells.Spellbook SpellBook = new();
|
||||
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.
|
||||
// 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!;
|
||||
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = new();
|
||||
|
||||
// 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.
|
||||
|
|
@ -398,35 +390,6 @@ 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,13 +107,6 @@ 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 =>
|
||||
|
|
@ -237,24 +230,6 @@ 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,49 +129,6 @@ 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,
|
||||
|
|
@ -181,8 +138,7 @@ public static class PlayerDescriptionParser
|
|||
IReadOnlyDictionary<uint, WorldPosition> Positions,
|
||||
IReadOnlyList<AttributeEntry> Attributes,
|
||||
IReadOnlyList<SkillEntry> Skills,
|
||||
IReadOnlyDictionary<uint, float> Spells,
|
||||
IReadOnlyList<EnchantmentEntry> Enchantments);
|
||||
IReadOnlyDictionary<uint, float> Spells);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
|
||||
|
|
@ -205,7 +161,6 @@ 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))
|
||||
|
|
@ -243,19 +198,16 @@ public static class PlayerDescriptionParser
|
|||
if (vectorFlags.HasFlag(DescriptionVectorFlag.Spell))
|
||||
ReadSpellTable(payload, ref pos, spells);
|
||||
|
||||
// ── 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);
|
||||
// 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.
|
||||
|
||||
return new Parsed(
|
||||
weenieType, propertyFlags, vectorFlags, hasHealth,
|
||||
bundle, positions, attributes, skills, spells, enchantments);
|
||||
bundle, positions, attributes, skills, spells);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
|
|
@ -272,8 +224,7 @@ public static class PlayerDescriptionParser
|
|||
Dictionary<uint, float> spells)
|
||||
{
|
||||
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
|
||||
bundle, positions, attributes, skills, spells,
|
||||
System.Array.Empty<EnchantmentEntry>());
|
||||
bundle, positions, attributes, skills, spells);
|
||||
}
|
||||
|
||||
// ── Attribute block reader ──────────────────────────────────────────────
|
||||
|
|
@ -462,86 +413,6 @@ 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,14 +57,6 @@ 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,
|
||||
|
|
@ -124,16 +116,6 @@ 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,5 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Player;
|
||||
|
||||
|
|
@ -91,19 +90,6 @@ 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;
|
||||
|
|
@ -159,17 +145,9 @@ public sealed class LocalPlayerState
|
|||
_attrs.TryGetValue(kind, out var a) ? a : null;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public uint? GetMaxApprox(VitalKind kind)
|
||||
{
|
||||
|
|
@ -177,31 +155,9 @@ public sealed class LocalPlayerState
|
|||
if (v is null) return null;
|
||||
uint baseMax = v.Value.Ranks + v.Value.Start;
|
||||
uint contrib = AttributeContribution(kind);
|
||||
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);
|
||||
return baseMax + contrib;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
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,43 +19,6 @@ 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;
|
||||
|
|
@ -104,19 +67,6 @@ 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)
|
||||
{
|
||||
|
|
@ -142,27 +92,13 @@ public sealed class Spellbook
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 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>
|
||||
public readonly record struct ActiveEnchantmentRecord(
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
uint CasterGuid,
|
||||
uint? StatModType = null,
|
||||
uint? StatModKey = null,
|
||||
float? StatModValue = null,
|
||||
uint Bucket = 0);
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
uint CasterGuid);
|
||||
|
|
|
|||
|
|
@ -240,31 +240,6 @@ 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,108 +207,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,458 +0,0 @@
|
|||
"""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