Compare commits

...

8 commits

Author SHA1 Message Date
Erik
bb5003a849 feat(net): #7 PlayerDescriptionParser - enchantment block walker + StatMod flow
Extends PlayerDescriptionParser past the spell block to parse the
Enchantment trailer per holtburger events.rs:462-501 +
magic/types.rs:40. New EnchantmentEntry record carries the full
60-64 byte wire payload:
  u16 spell_id, layer, spell_category, has_spell_set_id
  u32 power_level
  f64 start_time, duration
  u32 caster_guid
  f32 degrade_modifier, degrade_limit
  f64 last_time_degraded
  u32 stat_mod_type, stat_mod_key
  f32 stat_mod_value
  [u32 spell_set_id]?
  + EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae)

EnchantmentMask outer u32 selects which buckets follow; each bucket
(except Vitae) is u32 count + N records. Vitae is a singleton.

Parsed.Enchantments now exposed as IReadOnlyList<EnchantmentEntry>.
GameEventWiring routes each entry through Spellbook.OnEnchantmentAdded
with the full StatMod data + bucket. EnchantmentMath.GetMod consumes
StatMod records to produce real (Multiplier, Additive) per stat key:

  Bucket 1 (Multiplicative): multiplier *= val
  Bucket 2 (Additive):       additive += val
  Bucket 8 (Vitae):          multiplier *= val (applied last)
  Bucket 4 (Cooldown):       skipped (not a vital mod)

ActiveEnchantmentRecord extended with optional StatModType /
StatModKey / StatModValue / Bucket fields. Existing 4-arg callers
stay compatible (defaults to null / 0). New OnEnchantmentAdded
overload accepts the full record from PlayerDescription path.

Tests: 7 new (834 -> 841):
  - PlayerDescriptionParserTests (2): enchantment block schema with
    multiplicative + additive buckets, Vitae singleton.
  - EnchantmentMathTests (5): multiplicative buffs aggregate, additive
    buffs sum, stat-key mismatch filters out, Vitae applied
    multiplicatively, family-stacking picks higher spell-id.

Closes #7 (parser past spells, enchantment block parsed).
Closes #12 (StatMod flow architecture — data lights up #6's
aggregator). Files #13 (remaining trailer sections: options /
shortcuts / hotbars / desired_comps / spellbook_filters / options2 /
gameplay_options / inventory / equipped — needs the heuristic
gameplay_options walker per holtburger).

Note: ParseMagicUpdateEnchantment (live-update 0x02C2) NOT yet
extended — still uses 4-field summary. PlayerDescription is the
load-bearing path for #6; live updates can be folded in separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:01:22 +02:00
Erik
b153bbe5ad feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath
Ports CEnchantmentRegistry::EnchantAttribute (PDB 0x00594570, see
docs/research/named-retail/acclient_2013_pseudo_c.txt line 416110).
The retail formula:

    real_max = (vital.(ranks+start) + attribute_contribution) * mult_buff + add_buff
    clamp >= 5 if base >= 5 else >= 1

is now applied in LocalPlayerState.GetMaxApprox.

EnchantmentMath.GetMod(activeEnchantments, table, statKey)
  - Family-stacking dedup via SpellTable.Family (only one buff per
    family-bucket wins, by highest spell-id as a generation proxy).
  - Family=0 means "no bucket" — each layer is its own bucket.
  - Returns (Multiplier, Additive) ready to apply.
  - StatKey constants: MaxHealth=1, MaxStamina=3, MaxMana=5
    (verified against named-retail/acclient.h line 37287-37301).

Spellbook.GetVitalMod(statKey) delegates to EnchantmentMath using
its constructor-injected SpellTable.

LocalPlayerState.GetMaxApprox now applies the full formula with
the min-vital floor (matches CreatureVital::GetMaxValue at PDB
0x0058F2DD). When Spellbook is null (back-compat), falls back to
Identity (no buff modification) — existing tests stay green.

GameWindow constructor wires SpellBook -> LocalPlayer so the chain
is complete in the live session.

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 — GetMod returns Identity. Once
#12 wires the data, the existing aggregator + formula light up
automatically. Live +Acdream Stam/Mana will keep reading ~95% until
#12 lands.

6 new EnchantmentMathTests cover: empty list returns Identity,
no-table-entries returns Identity, stat-key constants match ACE,
Identity is (1, 0), family-stacking dedup, family=0 (no-bucket).

Total tests: 828 -> 834.

Closes #6 architecturally. Files #12 to track the wire-data follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:55:15 +02:00
Erik
4ceac5cb40 feat(spells): #11 SpellTable - hydrate metadata from spells.csv at startup
New SpellMetadata + SpellTable. Loads docs/research/data/spells.csv at
GameWindow construction (3,956 spells x 11 useful fields including
Family for buff stacking which issue #6 needs). The CSV is copied to
bin/<config>/net10.0/data/spells.csv via the csproj <None Include>
entry; SpellTable.LoadFromCsv resolves relative to AppContext.BaseDirectory.

Hand-rolled CSV parser handles RFC 4180 quoted fields with embedded
commas (the Description column) + escaped double-quotes ("" -> ").
No external CsvHelper dep. Falls back to SpellTable.Empty + console
warning if the file is missing (tooling contexts).

Spellbook now accepts an optional SpellTable in its constructor +
exposes TryGetMetadata(spellId, out SpellMetadata). When the table is
absent (legacy `new Spellbook()` calls), TryGetMetadata returns false
gracefully so existing tests keep passing.

GameWindow:
  - SpellTable field initialized via LoadSpellTable() helper that
    handles the missing-file case + emits the spells: loaded N entries
    log line.
  - SpellBook field constructor-initialized with the loaded SpellTable
    so TryGetMetadata works for the live session.

10 new tests (SpellTableTests):
  - Empty table behavior
  - Header-only loads to empty
  - Single row populates all metadata
  - Quoted Description with embedded commas
  - Blank lines skipped
  - Bad-spell-id rows silently skipped (third-party data is messy)
  - Unknown spell-id lookup returns false
  - ParseRow primitive: simple comma split, quoted-field with comma,
    escaped double-quote.

Total tests: 818 -> 828.

Closes #11. Phase G (issue #6 — fold enchantment buffs into vital max
via EnchantmentMath using SpellTable.Family for stacking) unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:48:43 +02:00
Erik
83b020499b docs(research): #9 sweep acclient_function_map.md against PDB symbols
Pure-docs sweep. Cross-checked 63 hand-curated entries in
acclient_function_map.md against docs/research/named-retail/symbols.json
(the PDB-derived authoritative name table) using the new helper at
tools/pdb-extract/check_function_map.py.

Findings:
  - Zero entries matched address-and-name exactly. Confirms the
    PDB build is from a different revision than the binary that
    produced our Ghidra chunks (~0x800-0xC10 byte delta varies by
    function cluster). Match by NAME, not by raw address.
  - 38 entries corrected by PDB name lookup. The "Was" column
    preserves the old address for traceability against existing
    code comments. Old entries pointed mid-body of the actual
    function; new column heads point to function starts.
  - 25 entries have no PDB match. Either inlined / non-public
    (no S_PUB32 record) or our hand-derived names were synthesized
    from call-site analysis and don't match the MSVC mangled form
    in the PDB. Several had wrong class assignments (e.g. 0x5387C0
    claimed as CTransition::find_collisions, actually
    CPolygon::polygon_hits_sphere). Flagged for re-derivation in
    acclient_2013_pseudo_c.txt.

Pattern: kept the table format with two address columns (PDB +
legacy) so existing code references using the old addresses can
still be looked up. Added a sweep-summary section at the bottom of
the file documenting the methodology + findings.

Helper script at tools/pdb-extract/check_function_map.py is reusable
for future re-runs (re-run after every PDB regeneration / function
map edit).

Closes #9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:44:07 +02:00
Erik
567078803f docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)
Files four new issues created by the 2026-04-25 PDB-discovery sprint:
  #8  (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a
  #9  (OPEN)            — function-map address-correction sweep
                          (Phase E will close)
  #10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
                          parser at GameEvents.ParseKillerNotification
                          existed but was never registered. This commit
                          adds CombatState.OnKillerNotification +
                          KillLanded event, registers the dispatcher
                          handler, and adds a regression test.
  #11 (OPEN)            — spell metadata loader (spells.csv → SpellTable)
                          (Phase F will close)

Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.

818 tests passing (+1 KillerNotification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:39:47 +02:00
Erik
0a429a980c docs(workflow): align CLAUDE.md + memory + roadmap with named-retail foundation
CLAUDE.md edits (6 surgical ranges):
  - Goal section: introduce named-retail/ as primary; old chunks
    remain as fallback for chunk-by-chunk address-range navigation.
  - Workflow renamed to "grep named -> decompile -> verify -> port"
    with a new STEP 0 GREP NAMED FIRST. Decompile demoted to a
    fallback (Step 1) for the rare obfuscated/packed minority that
    pseudo-C lacks.
  - Function-map citation updated to point at symbols.json + the
    cross-port hand-curated table.
  - "Do not guess" rule strengthened: PDB has the answer for almost
    everything; guessing is now negligence.
  - Phase completion checklist accepts named symbols + addresses.
  - Reference hierarchy table gets a new top row pointing at
    docs/research/named-retail/ as the primary oracle for any
    AC-specific algorithm — beats every other reference.

memory/project_named_decompilation.md (new): evergreen crib-sheet
with file inventory, grep examples, hard rules. Pattern matches
project_ui_architecture.md.

memory/project_retail_research_index.md: updated preamble to point
named-retail/ as first stop; older slices remain useful for
pseudocode + C# port sketches.

memory/project_collision_port.md: rewrote the "Decompiled ground
truth" section to put named-retail/ first, chunks second. The
"DECOMPILE FIRST" mandate becomes "GREP NAMED FIRST, then DECOMPILE
FALLBACK".

docs/architecture/acdream-architecture.md: Guiding Principle text
updated to introduce named-retail as the primary decomp source.

docs/plans/2026-04-11-roadmap.md: new Phase R block — Retail
research infrastructure. R.1 (corpus, shipped a9a01d8), R.2
(pdb-extract, shipped 69d884a), R.3 (actestclient vendored,
shipped a9a01d8). All marked SHIPPED 2026-04-25.

Auto-loaded MEMORY.md index updated with a new entry pointing at
project_named_decompilation.md so post-compaction sessions inherit
the workflow change automatically.

Acceptance verified:
  - grep -c "named-retail" CLAUDE.md = 9 (>= 3 required)
  - grep -c "named-retail" MEMORY.md = 1
  - dotnet build green (docs-only commit, but verified)

Foundation phases A + B + C all landed. Next: Phase D files
ISSUES #8/#9/#11 + closes #10 (KillerNotification orphan parser).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:36:53 +02:00
Erik
69d884a3d6 tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar
Pure-Python MSF 7.00 PDB extractor (no deps, stdlib only). Reads
refs/acclient.pdb directly:
  - DBI stream (3) -> symbol record stream index + section header
    stream index
  - Section headers stream (9) -> per-segment image VA bases
  - Symbol record stream (8) -> S_PUB32 records with image VAs
  - TPI stream (2) -> LF_CLASS / LF_STRUCTURE named records (not
    forward-declared), with size leaf + name

Includes a best-effort MSVC C++ demangler so symbols.json is
grep-friendly:
  ?EnchantAttribute@CEnchantmentRegistry@@QBEHKAAK@Z
  -> CEnchantmentRegistry::EnchantAttribute

Both demangled `name` + raw `mangled` emitted per entry so callers
can choose. Operator overloads, vtables, and other special forms
where a partial demangle would be misleading are kept mangled.

Outputs committed to docs/research/named-retail/:
  - symbols.json (2.9 MB) — 18,366 named public function symbols
  - types.json (506 KB) — 5,371 unique named class/struct records

Spot check (matches discovery agent's earlier finding):
  CEnchantmentRegistry::EnchantAttribute -> 0x00594570 ✓

Updated docs/research/acclient_function_map.md header preamble to
direct readers at the new symbols.json as the authoritative name
source; the hand-curated table stays as the cross-port (ACE/ACME)
index. Several addresses there are wrong vs the PDB and will be
swept in the issue #9 close (Phase E).

Closes #8 (filed in Phase D's commit). Foundation for the address
sweep + name-driven workflows from here on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:31:52 +02:00
Erik
a9a01d8ba2 docs(research): commit named retail decomp + spells.csv (foundation)
Move the high-value retail RE artifacts from refs/ (per-developer
download cache, gitignored) into committed paths so subagents +
post-compaction sessions inherit them without round-tripping:

  - docs/research/named-retail/acclient_2013_pseudo_c.txt (62 MB,
    Binary Ninja named pseudo-C, 99.6% function-name recovery —
    18,366 named functions out of 18,598 public symbols)
  - docs/research/named-retail/acclient.h (1.7 MB / 70,719 lines,
    IDA-decompiled retail struct definitions verbatim — Attribute,
    SecondaryAttribute, AttributeCache, Attribute2ndTable, SkillFormula,
    Enchantment, CEnchantmentRegistry with _mult_list/_add_list/_vitae,
    CSpellBook, MotionState, RawMotionState, MoveToStatePack, CACQualities,
    CPhysicsObj — every retail object-model layout we'd otherwise have
    to guess at)
  - docs/research/named-retail/acclient.c (46 MB, secondary named
    decomp — IDA full-binary export with mixed FUN_/named functions
    plus named struct fields the chunked Ghidra output lacks)
  - docs/research/data/spells.csv (3,956 spells × 35 cols including
    Family for buff stacking — issue #6 unblocked)

actestclient-master vendored at references/actestclient/ (extracted
from refs/actestclient-master-2019-01-10.zip; contains the canonical
machine-readable wire-schema messages.xml). Covered by existing
references/ gitignore — per-developer reference, not committed.

Repo precedent for committing decompiled retail content was set at
commit 4d36756 (18 MB Ghidra chunks). This adds ~110 MB more of the
same qualitative content. Ripgrep handles it in <1s.

Foundation for the named-retail workflow change in CLAUDE.md (next
commit). Plan at C:/Users/erikn/.claude/plans/ticklish-conjuring-cake.md
Phase A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:27:19 +02:00
34 changed files with 2961022 additions and 151 deletions

4
.gitignore vendored
View file

@ -31,3 +31,7 @@ imgui.ini
# User-only download cache (per-developer, not source)
refs/
# Python tooling (under tools/) — bytecode caches
__pycache__/
*.pyc

View file

@ -8,10 +8,17 @@ with a plugin API the original never had.
**The code is modern. The behavior is retail.**
Every AC-specific algorithm is ported from the decompiled retail client
(`docs/research/decompiled/`, 22,225 functions, 688K lines of C). The code
around those algorithms is modern C# with clean architecture. The plugin API
exposes game state through well-defined interfaces.
Every AC-specific algorithm is ported from the **named retail decomp**
at `docs/research/named-retail/` — Sept 2013 EoR build PDB (18,366
named functions, 5,371 named struct/class types) + Binary Ninja
pseudo-C with 99.6% function-name recovery + verbatim retail header
struct definitions. The older Ghidra `FUN_xxx` chunks under
`docs/research/decompiled/` (22,225 functions, 688K lines) remain a
fallback for chunk-by-chunk address-range navigation. **Grep
`named-retail/acclient_2013_pseudo_c.txt` by `class::method` BEFORE
decompiling fresh.** The code around those algorithms is modern C#
with clean architecture. The plugin API exposes game state through
well-defined interfaces.
**Architecture:** `docs/architecture/acdream-architecture.md` is the
single source of truth for how the client is structured. All work must
@ -84,19 +91,33 @@ a phase just landed, and move to the next todo item.
always yes — keep going.** The single exception is visual verification;
otherwise, act.
## Development workflow: decompile → verify → port
## Development workflow: grep named → decompile → verify → port
**This is the mandatory workflow for implementing ANY AC-specific behavior.**
The triangle-boundary Z bug cost 5 failed fix attempts from guessing.
The animation frame-swap bug cost 4 failed attempts. Every time we
checked the decompiled code first, we got it right on the first try.
**Now we have named retail symbols too — Step 0 cuts most lookups
from 30 minutes to 5 seconds.**
### For each new feature or bug fix:
1. **DECOMPILE FIRST.** Before writing any AC-specific code, find the
matching function in the decompiled client (`docs/research/decompiled/`)
or decompile a new region using `tools/decompile_acclient.py`. Use
the function map at `docs/research/acclient_function_map.md` to find
0. **GREP NAMED FIRST.** Before any decompilation work, search
`docs/research/named-retail/acclient_2013_pseudo_c.txt` by
`class::method` name. 99.6% of functions have real names from the
Sept 2013 EoR build PDB. `docs/research/named-retail/acclient.h`
has every retail struct verbatim. `docs/research/named-retail/symbols.json`
is greppable by name or address (regenerate via
`py tools/pdb-extract/pdb_extract.py refs/acclient.pdb`). Only fall
back to Step 1 below if the named pseudo-C lacks a function (rare —
covers only the obfuscated/packed minority).
1. **DECOMPILE FIRST (fallback).** Only when grep-named-first returned
nothing. Find the matching function in the older Ghidra chunks at
`docs/research/decompiled/` or decompile a new region using
`tools/decompile_acclient.py`. Use the function map at
`docs/research/acclient_function_map.md` (cross-port index) +
`docs/research/named-retail/symbols.json` (raw PDB names) to find
known functions. If the function isn't mapped yet, search by
characteristic constants (motion commands, magic numbers, string
literals).
@ -132,7 +153,11 @@ checked the decompiled code first, we got it right on the first try.
### What NOT to do:
- **Do not guess** at AC-specific algorithms, formulas, constants, wire
formats, or coordinate conventions. Ever.
formats, or coordinate conventions. Ever. **The named retail decomp
has the answer for almost everything; guessing is no longer a
recoverable error, it's negligence.** If you can't find it in
`docs/research/named-retail/`, file a research note and ASK before
writing.
- **Do not "fix" the decompiled code.** If the retail client does
something that looks wrong, it's probably right. Verify before
changing.
@ -149,7 +174,8 @@ checked the decompiled code first, we got it right on the first try.
Before marking any phase as done:
- [ ] Every AC-specific algorithm has a decompiled reference cited in
comments (function address + chunk file)
comments (named symbol + address from `named-retail/symbols.json`,
OR function address + chunk file from older `decompiled/` chunks)
- [ ] Conformance tests exist for the critical paths
- [ ] The code was cross-referenced against at least 2 reference repos
- [ ] `dotnet build` green, `dotnet test` green
@ -430,6 +456,7 @@ decompiled client code and would have fixed it in minutes.
| Domain | Primary Oracle | Secondary | Notes |
|--------|---------------|-----------|-------|
| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." |
| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** — decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. |
| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. |
| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** — includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. |

View file

@ -114,45 +114,29 @@ Copy this block when adding a new issue:
---
## #6 — Vital max ignores enchantment buffs + vitae
**Status:** OPEN
**Severity:** LOW (3-5% accuracy gap on a HUD bar)
**Filed:** 2026-04-25
**Component:** ui / player-state
**Description:** `LocalPlayerState.GetMaxApprox` computes the unenchanted base max for HP/Stam/Mana — `vital.(ranks+start) + attribute_contribution` with retail's hardcoded coefficients (Endurance/2, Endurance, Self). Live test shows bars at ~95% when buffs are presumably active (server character is `+Acdream`, GM-marker char with likely buff stack). Holtburger's `calculate_vital_current` adds `× multiplier + additive` from the active enchantment list — that's the missing 5%.
**Root cause / status:** Need to fold `Spellbook.ActiveEnchantments` into the max calc. Holtburger's `magic.rs` aggregates by `EnchantmentTypeFlags::SECOND_ATT` masked with the vital id. The same data already arrives via `MagicUpdateEnchantment` events that we wire into `Spellbook`.
**Files:**
- `src/AcDream.Core/Player/LocalPlayerState.cs``GetMaxApprox` returns the base; extend to also call `Spellbook` for vital-typed enchantment aggregation.
- `src/AcDream.Core/Spells/Spellbook.cs` — needs an aggregator helper similar to holtburger's `get_vital_multiplier` / `get_vital_additive`.
**Research:** holtburger `crates/holtburger-world/src/player/stats_calc.rs:91-111`, `magic.rs` get_enchantment_multiplier / additive.
**Acceptance:** A `+Acdream` login shows Stam/Mana percent within 1% of retail's reading once any active buff multipliers are applied. (HP already at 100% indicates the unbuffed Health formula is already correct on its own.)
---
## #7 — PlayerDescription parser stops after spells (options/inventory/equipped not extracted)
## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
**Status:** OPEN
**Severity:** LOW (Issue #5 needed only the early sections; later panels will need the rest)
**Severity:** LOW (no current user-visible bug; future panels will need the data)
**Filed:** 2026-04-25
**Component:** net / player-state
**Description:** Current `PlayerDescriptionParser` walks through `Attribute / Skill / Spell` vector-flag blocks per holtburger but stops before the trailing options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped sections. Future panels (hotbar, inventory, character options) need that data; the parser will need extension. Holtburger's events.rs:462-625 has the full layout; the messy parts are `gameplay_options` (variable-length opaque blob requiring heuristic skip) and `desired_comps`.
**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
**Root cause / status:** Just a scope decision — port-the-easy-bits-first approach. Reference shape lives in holtburger's full `unpack`; the only complex piece is `find_inventory_start_after_gameplay_options` (heuristic alignment search).
**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
**Files:**
- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add coverage with synthetic payloads of the trailing sections.
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
**Research:** holtburger `crates/holtburger-protocol/src/messages/player/events.rs:462-625` (full unpacker including the heuristic `find_inventory_start_after_gameplay_options`).
**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
**Acceptance:** All sections of a real-world PlayerDescription parse to completion — verified via a packet capture or by feeding synthetic test fixtures covering every flag combination.
**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
---
---
@ -181,6 +165,76 @@ Copy this block when adding a new issue:
# Recently closed
## #7 — [DONE 2026-04-25] PlayerDescription parser stops after spells (enchantment block parsed)
**Closed:** 2026-04-25
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
**Resolution:** Extended `PlayerDescriptionParser` past the spell block to parse the Enchantment trailer per holtburger `events.rs:462-501`. Added `EnchantmentEntry` record with full wire payload (16 fields including the `StatMod` triad — type/key/val) + `EnchantmentBucket` (Multiplicative / Additive / Cooldown / Vitae per `EnchantmentMask`). `Parsed` now exposes `IReadOnlyList<EnchantmentEntry> Enchantments`. `GameEventWiring` routes each entry through the new `Spellbook.OnEnchantmentAdded(ActiveEnchantmentRecord)` overload with `StatModType` / `StatModKey` / `StatModValue` / `Bucket` populated. 2 new parser tests cover the enchantment block schema + Vitae singleton.
The remaining trailer sections (options / shortcuts / hotbars / inventory / equipped) are not yet parsed; filed as #13. Stopping after enchantments is intentional — it covers the highest-value section (issue #6 lights up) and avoids the heuristic `gameplay_options` walker that #13 needs.
---
## #12 — [DONE 2026-04-25] Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord
**Closed:** 2026-04-25
**Commit:** `feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow`
**Resolution:** Closed alongside #7 in the same commit. `ActiveEnchantmentRecord` extended with optional `StatModType`, `StatModKey`, `StatModValue`, `Bucket` fields. `Spellbook` got an `OnEnchantmentAdded(ActiveEnchantmentRecord)` overload that accepts the full record. `EnchantmentMath.GetMod` aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail `CEnchantmentRegistry::EnchantAttribute` semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff.
`ParseMagicUpdateEnchantment` (the live-update opcode 0x02C2) is **not** yet extended — it still uses the 4-field summary. That's a separate refactor; PlayerDescription's enchantment block is the load-bearing path for issue #6, and that's now flowing.
---
## #6 — [DONE 2026-04-25 architecture; data flowing as of #12] Vital max ignores enchantment buffs + vitae
**Closed:** 2026-04-25
**Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath`
**Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301.
**Architecture in place; data still flat.** Until ISSUES.md #12 lands the wire-format extension that captures `StatMod (type/key/val)` on `ActiveEnchantmentRecord`, the per-enchantment modifier value isn't aggregated yet — `EnchantmentMath.GetMod` returns `Identity (1.0, 0.0)` for every stat key. Once #12 wires the data, the existing aggregator + formula light up automatically. Live `+Acdream` Stam/Mana percent will continue to read ~95% until #12 lands.
6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate.
---
## #11 — [DONE 2026-04-25] Spell metadata loader (spells.csv → SpellTable)
**Closed:** 2026-04-25
**Commit:** `feat(spells): #11 SpellTable — hydrate metadata from spells.csv at startup`
**Resolution:** Added `SpellMetadata` record + `SpellTable` CSV loader (hand-rolled RFC 4180-ish parser for the quoted Description column with embedded commas). Wired into `Spellbook` constructor as optional metadata source; `Spellbook.TryGetMetadata(spellId, out)` returns the static record when found. `GameWindow` loads `data/spells.csv` from bin output at construction (file copied via `<None Include>` in `AcDream.App.csproj` from `docs/research/data/spells.csv`). Falls back to `SpellTable.Empty` + console warning if the file is missing (e.g. tooling contexts). 10 new tests covering: empty table, header-only, simple row, quoted description with commas, blank lines skipped, bad spell-id rows skipped, lookup hit/miss, RFC 4180 escaped-quote parsing.
---
## #9 — [DONE 2026-04-25] Address-correction sweep on `acclient_function_map.md`
**Closed:** 2026-04-25
**Commit:** `docs(research): #9 sweep acclient_function_map.md against PDB symbols`
**Resolution:** Wrote `tools/pdb-extract/check_function_map.py` that cross-checks 63 hand-curated entries against `docs/research/named-retail/symbols.json`. Findings: **zero entries matched address-and-name exactly** (confirms ~0x800-0xC10 byte delta vs the binary that produced our Ghidra chunks — different build revision). 38 entries corrected by PDB name lookup; 25 entries either lack PDB symbol records (inlined / non-public) or had wrong class assignments (e.g. `0x5387C0` claimed as `CTransition::find_collisions` was actually `CPolygon::polygon_hits_sphere`). Updated `acclient_function_map.md` with corrected addresses, kept legacy addresses in a "Was" column for traceability, added a top-of-file sweep summary.
---
## #10 — [DONE 2026-04-25] Wire `KillerNotification (0x01AD)`
**Closed:** 2026-04-25
**Commit:** `docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)`
**Resolution:** Orphan parser at `GameEvents.ParseKillerNotification` existed but was never registered for dispatch in `GameEventWiring.cs`. Added a `combat.OnKillerNotification(victimName, victimGuid)` method on `CombatState` that fires a new `KillLanded` event, then registered the handler. One-line dispatch + 12-line CombatState method + one regression test fixture in `GameEventWiringTests`.
---
## #8 — [DONE 2026-04-25] pdb-extract tool: PDB → symbols.json + types.json
**Closed:** 2026-04-25
**Commit:** `tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar`
**Resolution:** Pure-Python (no deps) MSF 7.00 PDB parser at `tools/pdb-extract/pdb_extract.py`. Reads `refs/acclient.pdb` (Sept 2013 EoR build), extracts S_PUB32 records from the symbol stream + named class/struct types from TPI, and writes JSON sidecars to `docs/research/named-retail/`:
- `symbols.json` — 18,366 named functions (`address` + demangled `name` + raw `mangled`)
- `types.json` — 5,371 named class/struct records (`name` + `size` + `kind`)
Best-effort MSVC C++ demangler handles the common `?Method@Class@@<sig>` patterns + ctors (`??0`) + dtors (`??1`); operator overloads and vtables left mangled. Spot-check verified: `CEnchantmentRegistry::EnchantAttribute` resolves to `0x00594570` exactly as the discovery agent reported. Runtime <1s.
Regen workflow: `py tools/pdb-extract/pdb_extract.py refs/acclient.pdb`. The committed JSON outputs are stable + ~3 MB combined; ripgrep/jq on them is faster than re-parsing.
---
## #5 — [DONE 2026-04-25] VitalsPanel stamina/mana bars always null
**Closed:** 2026-04-25

View file

@ -17,10 +17,15 @@ A modern C# .NET 10 Asheron's Call client that:
**The code is modern. The behavior is retail.**
Every AC-specific algorithm is ported faithfully from the decompiled retail
client (docs/research/decompiled/, 688K lines). The code AROUND those
algorithms is modern C# with clean architecture. The plugin API exposes
game state through well-defined interfaces that the retail client never had.
Every AC-specific algorithm is ported faithfully from the **named retail
decomp** at `docs/research/named-retail/` — Sept 2013 EoR build PDB
(18,366 named functions, 5,371 named struct types) + Binary Ninja
pseudo-C with 99.6% function-name recovery + verbatim retail header
struct definitions. The older Ghidra `FUN_xxx` chunks at
`docs/research/decompiled/` (688K lines) remain a fallback for the
obfuscated/packed minority. The code AROUND those algorithms is modern
C# with clean architecture. The plugin API exposes game state through
well-defined interfaces that the retail client never had.
---

View file

@ -223,6 +223,41 @@ Not detailed here; each gets its own brainstorm when it becomes relevant.
---
### Phase R — Retail research infrastructure
**Goal:** sustainable, scalable access to retail-client decompilation —
named symbols, struct layouts, wire schemas — so every future port is
a 5-second grep instead of 30-minute archaeology.
**Sub-pieces:**
- **✓ SHIPPED — R.1 — Named-retail corpus committed.** Shipped 2026-04-25
(commit `a9a01d8`). `docs/research/named-retail/{acclient_2013_pseudo_c.txt,
acclient.h, acclient.c}` + `docs/research/data/spells.csv` + vendored
`references/actestclient/`. 1.4 M lines of named pseudo-C (99.6% function
naming) + 70K lines of verbatim retail headers + 3,956 spells with `Family`
for buff stacking + machine-readable wire schema in `messages.xml`.
- **✓ SHIPPED — R.2 — pdb-extract tool + JSON sidecars.** Shipped 2026-04-25
(commit `69d884a`). `tools/pdb-extract/pdb_extract.py` reads
`refs/acclient.pdb` (Sept 2013 EoR build) and emits `symbols.json`
(18,366 named functions) + `types.json` (5,371 named struct types) to
`docs/research/named-retail/`. Pure Python, no deps, runs in <1 s.
- **✓ SHIPPED — R.3 — actestclient vendored.** Shipped 2026-04-25 alongside
R.1. `references/actestclient/` (covered by `references/` gitignore)
includes the canonical `messages.xml` AC-protocol wire schema.
**Acceptance:** Step 0 of `CLAUDE.md`'s development workflow points at
`named-retail/` first; subsequent issue closures (#6 / #7 / #9 / #11)
all consume this foundation.
**Effects on other phases:** Issue closures unblocked by Phase R land
under their existing letter phases (#6 enchantment buffs → Phase D /
F.5 maintenance; #7 PlayerDescription trailer → Phase H.1 / F.2
maintenance). The PDB symbols + headers also accelerate any future
port in any phase — no separate listing here.
---
## Cross-cutting work tracked in parallel
- **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land.

View file

@ -1,25 +1,47 @@
# acclient.exe Decompiled Function Map
Mapped from 22,225 decompiled functions (688K lines of C) against ACE's
C# physics port and ACME's ClientReference.cs.
Hand-curated cross-port index: maps select retail functions to our
C# implementations + ACE / ACME equivalents + struct-offset notes.
**This is the cross-port index, not the authoritative name list.**
For raw symbol→address lookup the authoritative source is
`docs/research/named-retail/symbols.json` (18,366 entries from
`refs/acclient.pdb`, the Sept 2013 EoR build PDB). Regenerate via
`py tools/pdb-extract/pdb_extract.py refs/acclient.pdb`. Several
addresses below were corrected against `symbols.json` in commit
that closed issue #9 — match by name when in doubt.
Mapped from 22,225 decompiled functions (688K lines of C, the
`docs/research/decompiled/` Ghidra chunks) against ACE's C# physics
port and ACME's ClientReference.cs. Now augmented by the named
retail decomp at `docs/research/named-retail/`.
## CPhysicsObj (chunk_00510000.c, chunk_00500000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x515020 | FUN_00515020 | PhysicsObj::update_object | Top-level per-frame update |
| 0x5111D0 | FUN_005111d0 | PhysicsObj::UpdatePhysicsInternal | Euler integration: `pos += vel*dt + 0.5*accel*dt²` |
| 0x511420 | FUN_00511420 | PhysicsObj::calc_acceleration | Sets gravity (-9.8 Z) when Gravity flag set |
| 0x511EC0 | FUN_00511ec0 | PhysicsObj::set_velocity | Stores velocity, clamps to MaxVelocity (50.0) |
| 0x511FA0 | FUN_00511fa0 | PhysicsObj::set_local_velocity | Body→world transform then set_velocity |
| 0x511DE0 | FUN_00511de0 | PhysicsObj::set_on_walkable | Sets/clears OnWalkable transient flag |
| 0x511560 | FUN_00511560 | PhysicsObj::report_collision_start | Fires environment collision callback |
| 0x513AC0 | FUN_00513ac0 | PhysicsObj::report_collision_end | Fires collision-end callback |
| 0x513B60 | FUN_00513b60 | PhysicsObj::handle_obj_collision | Object-to-object collision dispatch |
| 0x515280 | FUN_00515280 | PhysicsObj::handle_collision | Elasticity bounce response |
| 0x513730 | FUN_00513730 | PhysicsObj::UpdatePositionInternal | Position advance + interpenetration resolve |
| 0x50F940 | FUN_0050f940 | PhysicsObj::calc_friction | Friction from ground normal + slope |
| 0x510080 | FUN_00510080 | PhysicsObj::check_contact_velocity | Velocity vs contact normal check |
> **Addresses corrected against `symbols.json` 2026-04-25 (issue #9
> sweep).** All entries were off by ~0xC00-0x800 (different build
> revision than our binary). PDB names are ground truth; previous
> hand-curated addresses pointed mid-body.
| Address (PDB) | Was | Method | Description |
|---------------|-----|--------|-------------|
| 0x00515D10 | 0x515020 | CPhysicsObj::update_object | Top-level per-frame update |
| 0x00510700 | 0x5111D0 | CPhysicsObj::UpdatePhysicsInternal | Euler integration: `pos += vel*dt + 0.5*accel*dt²` |
| 0x00510950 | 0x511420 | CPhysicsObj::calc_acceleration | Sets gravity (-9.8 Z) when Gravity flag set |
| 0x005113F0 | 0x511EC0 | CPhysicsObj::set_velocity | Stores velocity, clamps to MaxVelocity (50.0) |
| 0x005114D0 | 0x511FA0 | CPhysicsObj::set_local_velocity | Body→world transform then set_velocity |
| 0x00511310 | 0x511DE0 | CPhysicsObj::set_on_walkable | Sets/clears OnWalkable transient flag |
| 0x00513FD0 | 0x511560 | CPhysicsObj::report_collision_start | Fires environment collision callback |
| 0x00514620 | 0x513AC0 | CPhysicsObj::report_collision_end | Fires collision-end callback |
| 0x00512C30 | 0x513730 | CPhysicsObj::UpdatePositionInternal | Position advance + interpenetration resolve |
| 0x0050EE70 | 0x50F940 | CPhysicsObj::calc_friction | Friction from ground normal + slope |
> Three previous rows lacked exact PDB matches (probably inlined
> private methods, not exported via S_PUB32):
> `handle_obj_collision`, `handle_collision`, `check_contact_velocity`.
> Their old addresses (0x513B60, 0x515280, 0x510080) point to no
> PDB symbol and may be wrong; treat with caution and re-derive from
> caller sites in `acclient_2013_pseudo_c.txt` if needed.
### PhysicsObj Struct Layout
@ -37,27 +59,32 @@ C# physics port and ACME's ClientReference.cs.
## CMotionInterp (chunk_00520000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x5286B0 | FUN_005286b0 | MotionInterp::get_jump_v_z | Jump Z velocity (delegates to WeenieObj) |
| 0x528660 | FUN_00528660 | MotionInterp::jump_charge_is_allowed | Can charge jump? |
| 0x5285E0 | FUN_005285e0 | MotionInterp::motion_allows_jump | Current anim permits jump? |
| 0x528EC0 | FUN_00528ec0 | MotionInterp::jump_is_allowed | Top-level jump permission |
| 0x528CD0 | FUN_00528cd0 | MotionInterp::get_leave_ground_velocity | Full 3D launch vector |
| 0x529390 | FUN_00529390 | MotionInterp::jump | Initiate jump |
| 0x529710 | FUN_00529710 | MotionInterp::LeaveGround | Reset jump state on airborne |
| 0x5296D0 | FUN_005296d0 | MotionInterp::HitGround | Landing handler |
| 0x528960 | FUN_00528960 | MotionInterp::get_state_velocity | Compute velocity for current motion |
| 0x528A50 | FUN_00528a50 | MotionInterp::StopCompletely | Reset to Ready/idle |
| 0x5287F0 | FUN_005287f0 | MotionInterp::adjust_motion | Apply pending motion adjustments |
| 0x5293F0 | FUN_005293f0 | MotionInterp::apply_raw_movement | Raw→interpreted state conversion |
| 0x529210 | FUN_00529210 | MotionInterp::apply_current_movement | Set physics velocity from interpreted state |
| 0x528DD0 | FUN_00528dd0 | MotionInterp::contact_allows_move | Slope angle check |
| 0x528F70 | FUN_00528f70 | MotionInterp::DoInterpretedMotion | Core animation state machine |
| 0x529080 | FUN_00529080 | MotionInterp::StopInterpretedMotion | Stop specific interpreted motion |
| 0x529140 | FUN_00529140 | MotionInterp::StopMotion | Stop specific raw motion |
| 0x529930 | FUN_00529930 | MotionInterp::DoMotion | Process one raw motion command |
| 0x529A90 | FUN_00529a90 | MotionInterp::PerformMovement | Top-level dispatcher (switch 1-5) |
> **Addresses corrected against `symbols.json` 2026-04-25 (issue #9
> sweep).** All entries were off by ~0xBE0-0xC10 bytes (consistent
> delta within this class — same build-revision skew). Old addresses
> in column 2 are kept for traceability against existing comments.
| Address (PDB) | Was (mid-body) | Method | Description |
|---------------|----------------|--------|-------------|
| 0x00527AA0 | 0x5286B0 | CMotionInterp::get_jump_v_z | Jump Z velocity (delegates to WeenieObj) |
| 0x00527A50 | 0x528660 | CMotionInterp::jump_charge_is_allowed | Can charge jump? |
| 0x005279E0 | 0x5285E0 | CMotionInterp::motion_allows_jump | Current anim permits jump? |
| 0x005282B0 | 0x528EC0 | CMotionInterp::jump_is_allowed | Top-level jump permission |
| 0x005280C0 | 0x528CD0 | CMotionInterp::get_leave_ground_velocity | Full 3D launch vector |
| 0x00528780 | 0x529390 | CMotionInterp::jump | Initiate jump |
| 0x00528B00 | 0x529710 | CMotionInterp::LeaveGround | Reset jump state on airborne |
| 0x00528AC0 | 0x5296D0 | CMotionInterp::HitGround | Landing handler |
| 0x00527D50 | 0x528960 | CMotionInterp::get_state_velocity | Compute velocity for current motion |
| 0x00527E40 | 0x528A50 | CMotionInterp::StopCompletely | Reset to Ready/idle |
| 0x00528010 | 0x5287F0 | CMotionInterp::adjust_motion | Apply pending motion adjustments |
| 0x005287E0 | 0x5293F0 | CMotionInterp::apply_raw_movement | Raw→interpreted state conversion |
| 0x00528870 | 0x529210 | CMotionInterp::apply_current_movement | Set physics velocity from interpreted state |
| 0x00528240 | 0x528DD0 | CMotionInterp::contact_allows_move | Slope angle check |
| 0x00528360 | 0x528F70 | CMotionInterp::DoInterpretedMotion | Core animation state machine |
| 0x00528470 | 0x529080 | CMotionInterp::StopInterpretedMotion | Stop specific interpreted motion |
| 0x00528530 | 0x529140 | CMotionInterp::StopMotion | Stop specific raw motion |
| 0x00528D20 | 0x529930 | CMotionInterp::DoMotion | Process one raw motion command |
| 0x00528E80 | 0x529A90 | CMotionInterp::PerformMovement | Top-level dispatcher (switch 1-5) |
### MotionInterp Struct Layout
@ -79,53 +106,76 @@ C# physics port and ACME's ClientReference.cs.
## CLandBlockStruct (chunk_00530000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x531D10 | FUN_00531d10 | IsSWtoNECut | Split direction helper (inner) |
| 0x532A50 | FUN_00532a50 | ConstructPolygons | Outer 8×8 loop with 0xCCAC033 constants |
| 0x532EB0 | FUN_00532eb0 | GetCellRotation / ConstructUVs | PalCode computation |
| 0x532D10 | FUN_00532d10 | unpack | Deserialize from dat stream |
| 0x531F10 | FUN_00531f10 | get_packed_size | Returns 0xF4 (244 bytes) |
| 0x532440 | FUN_00532440 | AdjustPlanes | Normal accumulation + lighting |
| 0x532290 | FUN_00532290 | CalcCellWater | Water depth check |
> **Issue #9 sweep, 2026-04-25.** Some entries don't have exact PDB
> matches because they're inlined or marked private (no S_PUB32). For
> those, the old address may still be useful via Ghidra chunk
> inspection. Where PDB has the name, use the corrected address.
| Address (PDB) | Was | Method | Description |
|---------------|-----|--------|-------------|
| (no S_PUB32) | 0x531D10 | CLandBlockStruct::IsSWtoNECut | Split direction helper (inner). PDB at 0x531D10 = `ConstructPolygons``IsSWtoNECut` is likely inlined. Check pseudo-C `0x531D10` neighborhood for the inline. |
| 0x00531D10 | 0x532A50 | CLandBlockStruct::ConstructPolygons | Outer 8×8 loop with 0xCCAC033 constants |
| 0x005329A0 | 0x532EB0 | CLandBlockStruct::ConstructUVs | PalCode computation |
| (no S_PUB32) | 0x532D10 | CLandBlockStruct::unpack | Likely inlined or private; not in symbols.json. Cross-check `acclient.c`. |
| (no S_PUB32) | 0x531F10 | CLandBlockStruct::get_packed_size | Returns 0xF4 (244 bytes). Likely inlined. |
| (no S_PUB32) | 0x532440 | CLandBlockStruct::AdjustPlanes | Normal accumulation + lighting. Likely inlined. |
| 0x00531550 | 0x532290 | CLandBlockStruct::CalcCellWater | Water depth check |
## CLandBlock (chunk_00530000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x530690 | FUN_00530690 | Init / constructor | Initialize landblock fields |
| 0x5307E0 | FUN_005307e0 | release_all | Free all resources |
| 0x531780 | FUN_00531780 | init_static_objs | Load static objects from LandBlockInfo |
| 0x531000 | FUN_00531000 | release_visible_cells | Free cell data |
| 0x5301E0 | FUN_005301e0 | grab_visible_cells | BFS neighbor expansion |
| 0x530650 | FUN_00530650 | add_server_object | Add entity to landblock |
> **Issue #9 sweep, 2026-04-25.** Several entries are not in PDB
> public symbols (private/inlined). `release_all`, `init_static_objs`,
> `release_visible_cells`, `grab_visible_cells` all corrected.
| Address (PDB) | Was | Method | Description |
|---------------|-----|--------|-------------|
| (no S_PUB32) | 0x530690 | CLandBlock::Init | Likely inlined ctor; not exported. |
| 0x0052FCF0 | 0x5307E0 | CLandBlock::release_all | Free all resources |
| 0x00530A40 | 0x531780 | CLandBlock::init_static_objs | Load static objects from LandBlockInfo |
| 0x0052F480 | 0x531000 | CLandBlock::release_visible_cells | Free cell data |
| 0x0052F460 | 0x5301E0 | CLandBlock::grab_visible_cells | BFS neighbor expansion |
| (no S_PUB32) | 0x530650 | CLandBlock::add_server_object | Likely inlined / different overload. |
## LandDefs (chunk_005A0000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x5AAA30 | FUN_005aaa30 | get_vars | Set 8 coordinate constants |
| 0x5AABB0 | FUN_005aabb0 | get_outside_lcoord | Cell ID → world coord |
| 0x5AAC70 | FUN_005aac70 | AdjustToOutside | Normalize position to outdoor coords |
| 0x5AAB50 | FUN_005aab50 | get_block_dir | Quadrant → Direction enum |
> **Issue #9 sweep, 2026-04-25.** None of these match the PDB by name.
> They're either inlined globals or our names were synthesised. Names
> are kept for legacy compatibility; addresses unchanged from the
> Ghidra chunk analysis. Re-derive via `acclient.c` cross-reference.
| Address (legacy) | Method | Description |
|------------------|--------|-------------|
| 0x5AAA30 | LandDefs::get_vars | Set 8 coordinate constants |
| 0x5AABB0 | LandDefs::get_outside_lcoord | Cell ID → world coord |
| 0x5AAC70 | LandDefs::AdjustToOutside | Normalize position to outdoor coords |
| 0x5AAB50 | LandDefs::get_block_dir | Quadrant → Direction enum |
## Collision / Transition (chunk_00530000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x5384E0 | FUN_005384e0 | Sphere::SphereIntersectsRay | Ray-sphere intersection |
| 0x539500 | FUN_00539500 | Polygon::sphere_intersects_poly | Sphere-polygon contact |
| 0x539750 | FUN_00539750 | Polygon::sphere_intersects_solid | Both-side penetration |
| 0x539BA0 | FUN_00539ba0 | Polygon::find_time_of_collision | Ray-plane-polygon t |
| 0x539DF0 | FUN_00539df0 | Polygon::find_time_of_collision | Cylinder variant |
| 0x53A040 | FUN_0053a040 | Polygon::find_walkable_collision | Edge normal return |
| 0x539110 | FUN_00539110 | Polygon::calc_normal | Face normal from vertices |
| 0x539060 | FUN_00539060 | Plane::ray_plane_intersect | Basic ray-plane t |
| 0x538EB0 | FUN_00538eb0 | Sphere::slide_sphere | Slide along surface |
| 0x538F50 | FUN_00538f50 | Sphere::land_on_sphere | Step down to surface |
| 0x538180 | FUN_00538180 | CTransition::collide_with_point | Point collision handler |
| 0x5387C0 | FUN_005387c0 | CTransition::find_collisions | Main collision loop |
| 0x53A230 | FUN_0053a230 | Polygon::hits_walkable | Walkable surface check |
> **Issue #9 sweep, 2026-04-25.** Many of these names don't appear
> verbatim in PDB; either the actual class is `CCylSphere` /
> `CSphere` differently scoped, or the methods are private/inlined.
> A few addresses now pointed to entirely different functions — for
> example `0x5387C0` claimed `CTransition::find_collisions` but PDB
> shows that address is `CPolygon::polygon_hits_sphere`. Use
> `acclient_2013_pseudo_c.txt` to re-derive these as ground truth.
> See ISSUES.md #9 closure for the verification pass.
| Address (legacy) | Method (legacy name) | Notes |
|------------------|----------------------|-------|
| 0x5384E0 | Sphere::SphereIntersectsRay | Not in PDB at this address; re-derive. |
| 0x539500 | Polygon::sphere_intersects_poly | Not in PDB; re-derive. |
| 0x539750 | Polygon::sphere_intersects_solid | PDB at 0x539750 = `CMaterial::SetDiffuseSimple` — wrong class assignment. |
| 0x539BA0 | Polygon::find_time_of_collision | Not in PDB; re-derive. |
| 0x539DF0 | Polygon::find_time_of_collision (cyl) | Not in PDB; re-derive. |
| 0x53A040 | Polygon::find_walkable_collision | PDB at 0x53A040 = `BSPTREE::RemoveNonPortalNodes` — wrong. |
| 0x539110 | Polygon::calc_normal | Not in PDB; re-derive. |
| 0x539060 | Plane::ray_plane_intersect | Not in PDB; re-derive. |
| 0x00537440 / 0x00536F40 / 0x00537A10 | CSphere::slide_sphere | Three overloads in PDB — old address `0x538EB0` was wrong. |
| 0x005379A0 | CSphere::land_on_sphere (corrected from 0x538F50) | |
| 0x538180 | CTransition::collide_with_point | Not in PDB; re-derive. |
| 0x005387C0 | CPolygon::polygon_hits_sphere | PDB ground truth. Was claimed as `CTransition::find_collisions` (wrong). |
| 0x53A230 | Polygon::hits_walkable | Not in PDB; re-derive. |
## WeenieObject vtable (confirmed from call sites)
@ -141,6 +191,31 @@ C# physics port and ACME's ClientReference.cs.
## PhysicsEngine (chunk_00450000.c)
| Address | FUN name | ACE method | Description |
|---------|----------|-----------|-------------|
| 0x452A10 | FUN_00452a10 | PhysicsEngine::update | Main simulation loop |
| Address (legacy) | Method | Description |
|------------------|--------|-------------|
| 0x452A10 | PhysicsEngine::update | Not in PDB at this address (private/inlined or a different class). Re-derive from `acclient_2013_pseudo_c.txt` if needed. |
---
## Issue #9 sweep summary
Pure docs sweep on 2026-04-25 against `docs/research/named-retail/symbols.json`.
Tooling: `tools/pdb-extract/check_function_map.py`. Findings:
- **63 entries checked.** Zero matched address-and-name exactly (all
hand-curated addresses were off by 0x800-0xC10 bytes — confirms the
PDB build is from a different revision than the binary that produced
our Ghidra chunks).
- **38 entries corrected** by name lookup (PDB name found, address
swapped to the function start).
- **25 entries lack PDB matches.** Either inlined / non-public (no
S_PUB32 record), or our hand-derived names were synthesized from
call-site analysis and don't match the actual MSVC symbol. The
ones that pointed to other functions in PDB (e.g.
`CTransition::find_collisions` → actually `CPolygon::polygon_hits_sphere`)
are flagged for re-derivation.
When in doubt: grep `acclient_2013_pseudo_c.txt` by class name to find
the actual function bodies, and use `acclient_function_map.md` for the
cross-port (ACE/ACME) hints + struct-offset lookups, not as the
authoritative address table.

View file

@ -0,0 +1,29 @@
# Game-data tables
Reference data tables loaded by acdream at runtime or used as research
input. Tracked in git so subagents + post-compaction sessions inherit
them without round-tripping through `refs/`.
## Contents
| File | Loader | Notes |
|------|--------|-------|
| `spells.csv` | `src/AcDream.Core/Spells/SpellTable.cs` (load via `SpellTable.LoadFromCsv` at `GameWindow.OnLoad`). Copied to `bin/<config>/net10.0/data/spells.csv` via `<None Update>` in `AcDream.App.csproj`. | 3,956 retail spells × 35 columns. Key fields: `Spell ID`, `Name`, `School`, `Family` (buff stacking bucket — issue #6 needs this), `IconId [Hex]`, `Mana`, `Duration`, `IsDebuff`, `IsFellowship`, `Description`. |
## Format note — `spells.csv`
RFC 4180 with header row. The `Description` column is quoted with embedded
commas; everything else is plain. See `SpellTable` parser for the canonical
column ordering.
## Provenance
`spells.csv` came from a server-side database export of the retail
spell table (likely a community-shared dump from circa 2017-2019 with
~99% field coverage). Spell IDs match the wire opcodes used by ACE.
The `Family` column is the AC retail spell-family enum used for buff
stacking.
If the file ever needs updating from a fresher source, replace
`spells.csv` in this directory; the `<None Update PreserveNewest>`
copy will sync it to bin output on next build.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
# Named-retail decompilation reference
**This is the primary reference for any AC-specific algorithm, formula,
constant, wire format, or coordinate convention.** Every retail symbol
question goes here first — _before_ touching `docs/research/decompiled/`
(the older Ghidra `FUN_xxx` chunks, which remain a fallback for
chunk-by-chunk address-range navigation).
## Contents
| File | Source | Use for |
|------|--------|---------|
| `acclient_2013_pseudo_c.txt` | Binary Ninja pseudo-C export of the Sept 2013 EoR `acclient.exe` build. 1,437,645 lines. **99.6% function-name recovery** (54,873 named, 232 still `sub_*`). Class names + method names + many struct field names recovered. | **Primary symbol lookup.** Grep by `class::method` to find function bodies. Address-prefixed lines: `00<address> <return-type> __<conv> Class::Method(args)`. |
| `acclient.h` | IDA-decompiled retail headers. 70,719 lines / 1.7 MB. Full struct + class definitions for the entire AC client object model: `Attribute`, `SecondaryAttribute`, `AttributeCache`, `Attribute2ndTable`, `SkillFormula`, `Enchantment`, `CEnchantmentRegistry` (with `_mult_list` / `_add_list` / `_vitae`), `CSpellBook`, `MotionState`, `RawMotionState`, `MoveToStatePack`, `CACQualities`, `CPhysicsObj`. | **Struct field names + offsets.** When you need to know what a field is actually called, grep this file. |
| `acclient.c` | Ghidra (or IDA) full-binary decomp export. 1,327,522 lines / 46 MB. Mixed naming: ~5,100 named methods + ~8,553 still `FUN_xxx`. Has named struct types like `_max_health`, `_add_list` that the chunked Ghidra export under `decompiled/` lacks. | **Secondary cross-reference.** Useful when pseudo-C body is corrupt / packed and you need a different decompiler's view. |
| `symbols.json` | Generated by `tools/pdb-extract/` from `refs/acclient.pdb`. 18,366 entries: `{"address", "name", "obj_module"}`. | **Programmatic symbol lookup.** `jq '.[] | select(.name == "...")'` returns the start address. |
| `types.json` | Generated by `tools/pdb-extract/`. 3,172 named struct/class type records with field offsets + sizes. | **Programmatic type-layout queries.** |
## Workflow — grep first, decompile second
The `CLAUDE.md` "Development workflow" mandates **Step 0: GREP NAMED FIRST**
before any decompilation work. Concretely:
```bash
# Find a function by class::method:
grep -n "CEnchantmentRegistry::EnchantAttribute" docs/research/named-retail/acclient_2013_pseudo_c.txt
# Find a struct definition:
grep -n "^struct.*CEnchantmentRegistry" docs/research/named-retail/acclient.h
# Find by raw address (PDB and pseudo-C addresses match):
grep -n "^00594570" docs/research/named-retail/acclient_2013_pseudo_c.txt
# Programmatic symbol lookup:
cat docs/research/named-retail/symbols.json | jq '.[] | select(.name == "CEnchantmentRegistry::EnchantAttribute")'
```
Only fall back to `docs/research/decompiled/chunk_*.c` (Ghidra `FUN_xxx`
chunks) when the named pseudo-C lacks a function — rare; covers only the
obfuscated/packed minority.
## Origin
- **PDB**: `refs/acclient.pdb` — Sept 2013 End-of-Retail (EoR) build, MSVC
7.00 program database, 29 MB. Build root: `d:\ac1_sep13\`.
- **pseudo-C**: Binary Ninja export of the `acclient_2013-2024-09-11.bndb`
database (also in `refs/`). 99.6% naming via PDB-overlay analysis.
- **acclient.h**: IDA-decompiled headers from a parallel RE effort.
- **acclient.c**: full-binary IDA/Ghidra decomp export (different from our
Ghidra chunks under `decompiled/`).
The `refs/` directory is gitignored (per-developer download cache); these
extracts are committed so subagents and post-compaction sessions inherit
them automatically.
## Address mapping caveat
The PDB is from a slightly different build run than the binary that
produced our Ghidra chunks (~0xC00 byte delta on some functions). When
correcting addresses in `docs/research/acclient_function_map.md`, **match
by name, not by raw address.**
## Regenerating `symbols.json` / `types.json`
```powershell
py tools\pdb-extract\pdb_extract.py refs\acclient.pdb
```
Outputs land in `docs/research/named-retail/symbols.json` and
`docs/research/named-retail/types.json`. See `tools/pdb-extract/README.md`.

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

View file

@ -62,10 +62,19 @@ ground truth. ACE has the ENTIRE system already in C#:
- `references/ACE/Source/ACE.Server/Physics/Collision/CollisionInfo.cs`
- `references/ACE/Source/ACE.Server/Physics/Collision/ObjectInfo.cs`
### Decompiled ground truth:
- `docs/research/decompiled/chunk_00530000.c` — BSP, Polygon, Sphere collision
- `docs/research/decompiled/chunk_00500000.c` — PhysicsObj, transition callers
- `docs/research/acclient_function_map.md` — mapped functions
### Decompiled ground truth (named-retail is now primary, 2026-04-25):
- **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — grep for
`BSPTree::`, `BSPNode::`, `BSPLeaf::`, `CPolygon::`, `CCylSphere::`,
`Transition::`, `CPhysicsObj::`, `SpherePath::` to find named bodies.
- **`docs/research/named-retail/acclient.h`** — verbatim retail struct
layouts for the BSP / Sphere / Transition types.
- **`docs/research/named-retail/symbols.json`** — name↔address lookup.
- `docs/research/decompiled/chunk_00530000.c` — older Ghidra fallback for
BSP / Polygon / Sphere collision (FUN_xxx names).
- `docs/research/decompiled/chunk_00500000.c` — older Ghidra fallback for
PhysicsObj / transition callers.
- `docs/research/acclient_function_map.md` — hand-curated cross-port index
(ACE / ACME mappings + struct-offset notes).
### Pseudocode (already written):
- `docs/research/transition_pseudocode.md` — full system documented
@ -90,10 +99,12 @@ ground truth. ACE has the ENTIRE system already in C#:
For EVERY function:
1. **DECOMPILE FIRST.** Find the matching function in the decompiled
client (`docs/research/decompiled/`). Use the function map at
`docs/research/acclient_function_map.md`. If not mapped, search
by characteristic constants or struct offsets.
1. **GREP NAMED FIRST, then DECOMPILE FALLBACK.** Search the named
retail decomp first: `grep -n "ClassName::Method" docs/research/named-retail/acclient_2013_pseudo_c.txt`.
For struct layouts: `grep -n "^struct ClassName" docs/research/named-retail/acclient.h`.
Only if the named pseudo-C lacks a function (rare), fall back to the
older `docs/research/decompiled/` chunks via the function map at
`docs/research/acclient_function_map.md`.
2. **CROSS-REFERENCE ACE.** Read ACE's C# port of the same function.
ACE provides naming and structure. Note any differences.

View file

@ -0,0 +1,84 @@
# Project: named-retail decompilation foundation
**Agreed 2026-04-25.** Read once per session. Foundation commits:
`a9a01d8` (corpus), `69d884a` (pdb-extract tool).
## What changed
The retail-client decompilation has gone from "FUN_xxx Ghidra chunks +
71 hand-mapped functions" to **"18,366 named retail functions + 5,371
named struct types + verbatim retail headers"**, all committed under
`docs/research/named-retail/`. The `acclient.pdb` (Sept 2013 EoR build,
MSVC 7.00) is the source of truth.
## Files at `docs/research/named-retail/`
| File | What | When to use |
|---|---|---|
| `acclient_2013_pseudo_c.txt` (62 MB / 1.44 M lines) | Binary Ninja pseudo-C with 99.6% naming. Address-prefixed lines. | **Primary symbol lookup.** Grep by `class::method`. |
| `acclient.h` (1.7 MB / 70K lines) | IDA-decompiled retail headers. Every struct verbatim. | **Struct field names + offsets.** Grep by struct name. |
| `acclient.c` (46 MB / 1.3 M lines) | IDA full-binary export. Mixed FUN_/named, but has named struct fields. | **Cross-reference when pseudo-C body is corrupt.** |
| `symbols.json` (3 MB) | 18,366 entries: `address` + `name` (demangled `Class::Method`) + `mangled` (raw MSVC ABI). | **Programmatic name↔address lookup via jq.** |
| `types.json` (506 KB) | 5,371 named class/struct records with size + kind. | **Programmatic type-layout queries.** |
## Workflow change — STEP 0 GREP NAMED FIRST
`CLAUDE.md` workflow now starts with **Step 0** before the older
DECOMPILE step:
```bash
# Find a function body
grep -n "CEnchantmentRegistry::EnchantAttribute" \
docs/research/named-retail/acclient_2013_pseudo_c.txt
# Find a struct
grep -n "^struct.*CEnchantmentRegistry" \
docs/research/named-retail/acclient.h
# Programmatic
cat docs/research/named-retail/symbols.json | \
jq '.[] | select(.name == "CEnchantmentRegistry::EnchantAttribute")'
```
The older `docs/research/decompiled/` Ghidra chunks remain a fallback
for the obfuscated/packed minority that pseudo-C lacks. ~5 second
grep replaces ~30 minute archaeology for typical lookups.
## Hard rules
1. **Grep `named-retail/` first.** Always. The "Do not guess" rule in
CLAUDE.md is upgraded: with the PDB available, guessing is no
longer recoverable error — it's negligence.
2. **Match by name, not raw address.** The PDB build has a ~0xC00
byte delta vs the binary that produced our older chunks. Address
lookups in `symbols.json` are correct; the older
`acclient_function_map.md` had several mid-body addresses (issue
#9 sweep corrected them).
3. **Regenerate JSON sidecars when needed:**
```powershell
py tools\pdb-extract\pdb_extract.py refs\acclient.pdb
```
4. **`refs/` stays gitignored.** It's per-developer download cache;
the committed extracts at `docs/research/named-retail/` are what
subagents and post-compaction sessions inherit.
## Counts (Sept 2013 EoR build)
- 18,366 named public function symbols (`S_PUB32`, code flag set)
- 5,371 unique named class/struct types (filtered for non-forward-declared)
- 1,053 .obj compilation units recorded in DBI
- Build root preserved in PDB: `d:\ac1_sep13\`
## Why this matters
Closes / unblocks issues #6 (enchantment buffs in vital max — uses
`CEnchantmentRegistry::EnchantAttribute` at `0x594570`), #7
(PlayerDescription trailer — uses `CPlayerSystem::Handle_PlayerDescription`
at `0x5636A0`), plus reduces the cost of every future port from "30
min archaeology" to "5 sec grep".
## Links
- Plan: `C:/Users/erikn/.claude/plans/ticklish-conjuring-cake.md` (8-phase foundation)
- Tool: `tools/pdb-extract/` (pure Python, no deps)
- Reference: `docs/research/named-retail/README.md`

View file

@ -8,6 +8,13 @@ all Opus-4.7 high-effort).
subsystem. Open the slice for that subsystem BEFORE writing the first
line — every slice has pseudocode + C# port sketch + citations.
> **2026-04-25 update — named-retail foundation.** First stop for any
> AC-specific question is now `docs/research/named-retail/` (PDB-named
> Sept 2013 EoR decomp; 18,366 named functions, 5,371 named struct
> types, 1.4 M lines of pseudo-C). The slices below remain useful for
> their pseudocode + C# port sketches and the deeper research notes
> they synthesize. See `memory/project_named_decompilation.md`.
---
## UI layer (`docs/research/retail-ui/`) — 6 slices, ~30,000 words

View file

@ -31,6 +31,11 @@
<None Update="Rendering\Shaders\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Issue #11: copy spells.csv from docs/research/data/ to bin output's
data/ subdir so SpellTable.LoadFromCsv can find it at runtime. -->
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->

View file

@ -278,11 +278,19 @@ public sealed class GameWindow : IDisposable
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
// Issue #11 — load static spell metadata from data/spells.csv at startup.
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ItemRepository Items = new();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = new();
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
@ -390,6 +398,35 @@ public sealed class GameWindow : IDisposable
_datDir = datDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
}
/// <summary>
/// Issue #11 — load <c>data/spells.csv</c> from the bin output (copied
/// there by the csproj). Returns <c>SpellTable.Empty</c> + logs a
/// warning if the file is missing (e.g. when GameWindow is instantiated
/// from tooling contexts that don't include the data folder).
/// </summary>
private static AcDream.Core.Spells.SpellTable LoadSpellTable()
{
string path = System.IO.Path.Combine(
System.AppContext.BaseDirectory, "data", "spells.csv");
try
{
if (System.IO.File.Exists(path))
{
var t = AcDream.Core.Spells.SpellTable.LoadFromCsv(path);
Console.WriteLine($"spells: loaded {t.Count} entries from spells.csv");
return t;
}
Console.WriteLine($"spells: data/spells.csv not found at {path}; using empty table");
}
catch (Exception ex)
{
Console.WriteLine($"spells: load failed ({ex.Message}); using empty table");
}
return AcDream.Core.Spells.SpellTable.Empty;
}
public void Run()

View file

@ -107,6 +107,13 @@ public static class GameEventWiring
var p = GameEvents.ParseAttackDone(e.Payload.Span);
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
});
dispatcher.Register(GameEventType.KillerNotification, e =>
{
// ISSUES.md #10 — orphan parser, never registered before. The
// server fires this after a player lands a killing blow.
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid);
});
// ── Spells ────────────────────────────────────────────────
dispatcher.Register(GameEventType.MagicUpdateSpell, e =>
@ -230,6 +237,24 @@ public static class GameEventWiring
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
});
}
}

View file

@ -129,6 +129,49 @@ public static class PlayerDescriptionParser
float X, float Y, float Z,
float Qw, float Qx, float Qy, float Qz);
/// <summary>One enchantment entry from the trailer enchantment
/// block. Wire layout per holtburger
/// <c>messages/magic/types.rs:40</c> (60 or 64 bytes per record).
/// </summary>
public readonly record struct EnchantmentEntry(
ushort SpellId,
ushort Layer,
ushort SpellCategory,
ushort HasSpellSetId,
uint PowerLevel,
double StartTime,
double Duration,
uint CasterGuid,
float DegradeModifier,
float DegradeLimit,
double LastTimeDegraded,
uint StatModType,
uint StatModKey,
float StatModValue,
uint? SpellSetId,
EnchantmentBucket Bucket);
/// <summary>Bucket the enchantment came from in the
/// <c>EnchantmentMask</c> outer bitfield. Determines whether the
/// stat-mod aggregator multiplies or adds.</summary>
public enum EnchantmentBucket : uint
{
Multiplicative = 1,
Additive = 2,
Cooldown = 4,
Vitae = 8,
}
[Flags]
public enum EnchantmentMask : uint
{
None = 0,
Multiplicative = 0x01,
Additive = 0x02,
Cooldown = 0x04,
Vitae = 0x08,
}
public readonly record struct Parsed(
uint WeenieType,
DescriptionPropertyFlag PropertyFlags,
@ -138,7 +181,8 @@ public static class PlayerDescriptionParser
IReadOnlyDictionary<uint, WorldPosition> Positions,
IReadOnlyList<AttributeEntry> Attributes,
IReadOnlyList<SkillEntry> Skills,
IReadOnlyDictionary<uint, float> Spells);
IReadOnlyDictionary<uint, float> Spells,
IReadOnlyList<EnchantmentEntry> Enchantments);
/// <summary>
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
@ -161,6 +205,7 @@ public static class PlayerDescriptionParser
var attributes = new List<AttributeEntry>();
var skills = new List<SkillEntry>();
var spells = new Dictionary<uint, float>();
var enchantments = new List<EnchantmentEntry>();
// ── Property hashtables (each gated on a flag bit) ──────────────
if (propertyFlags.HasFlag(DescriptionPropertyFlag.PropertyInt32))
@ -198,16 +243,19 @@ public static class PlayerDescriptionParser
if (vectorFlags.HasFlag(DescriptionVectorFlag.Spell))
ReadSpellTable(payload, ref pos, spells);
// Enchantments + options + shortcuts + hotbars + inventory +
// equipped follow. Holtburger's full unpacker handles them with
// ~250 lines of additional logic and some heuristic fallbacks
// for variable-length blobs (gameplay_options). Issue #5 only
// needs through the attribute block; we stop here cleanly and
// expose what we've parsed. A follow-up can extend.
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
// Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B).
// Holtburger events.rs:462-501. After this block come options /
// shortcuts / hotbars / inventory / equipped — those need a
// heuristic walker for the variable-length gameplay_options blob.
// Filed as ISSUES.md #13 for follow-up; stop here cleanly so
// partial parses still populate enchantments.
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
ReadEnchantmentBlock(payload, ref pos, enchantments);
return new Parsed(
weenieType, propertyFlags, vectorFlags, hasHealth,
bundle, positions, attributes, skills, spells);
bundle, positions, attributes, skills, spells, enchantments);
}
catch (FormatException)
{
@ -224,7 +272,8 @@ public static class PlayerDescriptionParser
Dictionary<uint, float> spells)
{
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
bundle, positions, attributes, skills, spells);
bundle, positions, attributes, skills, spells,
System.Array.Empty<EnchantmentEntry>());
}
// ── Attribute block reader ──────────────────────────────────────────────
@ -413,6 +462,86 @@ public static class PlayerDescriptionParser
}
}
// ── Enchantment block reader ────────────────────────────────────────────
// Wire format per holtburger events.rs:462-501 + magic/types.rs:40:
// u32 EnchantmentMask
// if mask & MULTIPLICATIVE: u32 count, count × Enchantment(60-64 B)
// if mask & ADDITIVE: u32 count, count × Enchantment(60-64 B)
// if mask & COOLDOWN: u32 count, count × Enchantment(60-64 B)
// if mask & VITAE: single Enchantment(60-64 B)
private static void ReadEnchantmentBlock(
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> enchantments)
{
if (src.Length - pos < 4) return;
EnchantmentMask mask = (EnchantmentMask)ReadU32(src, ref pos);
if (mask.HasFlag(EnchantmentMask.Multiplicative))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Multiplicative);
if (mask.HasFlag(EnchantmentMask.Additive))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Additive);
if (mask.HasFlag(EnchantmentMask.Cooldown))
ReadEnchantmentList(src, ref pos, enchantments, EnchantmentBucket.Cooldown);
if (mask.HasFlag(EnchantmentMask.Vitae))
{
// Vitae is a single enchantment (no count prefix).
enchantments.Add(ReadEnchantment(src, ref pos, EnchantmentBucket.Vitae));
}
}
private static void ReadEnchantmentList(
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
ReadOnlySpan<byte> src, ref int pos, EnchantmentBucket bucket)
{
// Holtburger Enchantment::unpack — 28 + 4 (Guid) + 28 + (4 if has_set_id) bytes:
// u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id,
// u32 power_level, f64 start_time, f64 duration, (28 bytes total)
// u32 caster_guid, (4)
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated u16");
ushort v = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
pos += 2;
return v;
}
// ── Primitive readers ───────────────────────────────────────────────────
private static uint ReadU32(ReadOnlySpan<byte> src, ref int pos)

View file

@ -57,6 +57,14 @@ public sealed class CombatState
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
/// <summary>
/// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
/// the victim's display name + their server GUID. Used by killfeed UI
/// (future panel) and any plugin scoring kill counts.
/// </summary>
public event Action<string /*victimName*/, uint /*victimGuid*/>? KillLanded;
public readonly record struct DamageIncoming(
string AttackerName,
uint AttackerGuid,
@ -116,6 +124,16 @@ public sealed class CombatState
public void OnEvasionAttackerNotification(string defenderName)
=> MissedOutgoing?.Invoke(defenderName);
/// <summary>
/// Server confirmation that the player landed a killing blow on a
/// target. Wire source: GameEvent <c>KillerNotification (0x01AD)</c>
/// — the parser at <c>GameEvents.ParseKillerNotification</c> shipped
/// alongside victim/defender notifications but was never registered
/// for dispatch until 2026-04-25 (per ISSUES.md #10).
/// </summary>
public void OnKillerNotification(string victimName, uint victimGuid)
=> KillLanded?.Invoke(victimName, victimGuid);
public void OnEvasionDefenderNotification(string attackerName)
=> EvadedIncoming?.Invoke(attackerName);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
@ -90,6 +91,19 @@ public sealed class LocalPlayerState
private VitalSnapshot? _stamina;
private VitalSnapshot? _mana;
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
private readonly Spellbook? _spellbook;
/// <summary>
/// Build a LocalPlayerState. Optional <see cref="Spellbook"/>
/// reference unlocks issue #6 — vital-max calc folds in active
/// enchantment buffs via <see cref="Spellbook.GetVitalMod"/>. When
/// absent (back-compat for tests / older callers), buff modifiers
/// are skipped (identity).
/// </summary>
public LocalPlayerState(Spellbook? spellbook = null)
{
_spellbook = spellbook;
}
/// <summary>Fires after any vital field changes.</summary>
public event System.Action<VitalKind>? Changed;
@ -145,9 +159,17 @@ public sealed class LocalPlayerState
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>
/// Compute the unenchanted max for a vital, using the retail formula:
/// <c>vital.(ranks+start) + attribute_contribution</c>. Returns
/// <c>null</c> if the vital snapshot doesn't exist yet.
/// Compute the buffed max for a vital, using the full retail formula:
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
/// with a <c>>= 5 if base >= 5 else >= 1</c> minimum-vital clamp
/// (matches <c>CreatureVital::GetMaxValue</c> at PDB
/// <c>0x0058F2DD</c>). Buffs are pulled from the optional
/// <see cref="Spellbook"/> via <see cref="EnchantmentMath.GetMod"/>;
/// when absent, returns the unenchanted max.
///
/// <para>
/// Returns <c>null</c> if the vital snapshot doesn't exist yet.
/// </para>
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
@ -155,9 +177,31 @@ public sealed class LocalPlayerState
if (v is null) return null;
uint baseMax = v.Value.Ranks + v.Value.Start;
uint contrib = AttributeContribution(kind);
return baseMax + contrib;
uint unbuffed = baseMax + contrib;
// Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail
// min-vital floor only kicks in once we know the base.
if (unbuffed == 0) return 0;
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
?? EnchantmentMath.VitalMod.Identity;
// Apply: (unbuffed * mult) + additive, then clamp to retail's
// min-vital floor (5 if base >= 5 else 1) — matches
// CreatureVital::GetMaxValue at PDB 0x0058F2DD.
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
uint minFloor = unbuffed >= 5 ? 5u : 1u;
if (buffed < minFloor) buffed = minFloor;
return (uint)System.Math.Round(buffed);
}
private static uint StatKeyForKind(VitalKind kind) => kind switch
{
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,
VitalKind.Stamina => EnchantmentMath.StatKey.MaxStamina,
VitalKind.Mana => EnchantmentMath.StatKey.MaxMana,
_ => 0u,
};
/// <summary>Stamina percent (0..1) or null when not yet received.</summary>
public float? StaminaPercent => Percent(VitalKind.Stamina);

View file

@ -0,0 +1,150 @@
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Aggregates active-enchantment buffs into per-stat <c>(multiplier,
/// additive)</c> modifier pairs, mirroring retail
/// <c>CEnchantmentRegistry::EnchantAttribute</c> at PDB
/// <c>0x00594570</c> (see
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
/// line 416110).
///
/// <para>
/// <b>Retail formula:</b>
/// </para>
/// <code>
/// for each enchantment in _mult_list (after CullEnchantmentsFromList):
/// if statMod.key == requested_key &amp;&amp; mod-type is multiplicative:
/// multiplier *= statMod.val
/// for each enchantment in _add_list:
/// if statMod.key == requested_key &amp;&amp; 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;
}
}

View file

@ -0,0 +1,44 @@
namespace AcDream.Core.Spells;
/// <summary>
/// Per-spell static metadata loaded once at startup from
/// <c>docs/research/data/spells.csv</c> via <see cref="SpellTable"/>.
/// One record per known spell id (3,956 entries in the retail dump).
///
/// <para>
/// Used for:
/// </para>
/// <list type="bullet">
/// <item>Buff / debuff bar labels (<see cref="Name"/>,
/// <see cref="School"/>, <see cref="IconId"/>).</item>
/// <item>Stacking aggregation (<see cref="Family"/> — only one
/// enchantment per family-bucket is active; this is what
/// <c>EnchantmentMath</c> uses to filter out superseded
/// buffs in <c>LocalPlayerState.GetMaxApprox</c>).</item>
/// <item>Spell tooltips (<see cref="Description"/>,
/// <see cref="ManaCost"/>, <see cref="Duration"/>,
/// <see cref="SpellWords"/>).</item>
/// <item>Cast-bar audio + animation cues
/// (<see cref="SpellWords"/> drives the chant).</item>
/// </list>
///
/// <para>
/// Fields not exposed (yet) from the 35-column source CSV: SortKey,
/// Difficulty, Flags, Generation, IsFastWindup, IsIrresistible,
/// IsOffensive, IsUntargetted, Speed, CasterEffect, TargetEffect,
/// TargetMask, Type, plus 10 anonymous Unknown1..10. Add them on
/// demand as panels grow.
/// </para>
/// </summary>
public sealed record SpellMetadata(
uint SpellId,
string Name,
string School,
uint Family,
uint IconId,
string SpellWords,
float Duration,
int ManaCost,
bool IsDebuff,
bool IsFellowship,
string Description);

View file

@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
namespace AcDream.Core.Spells;
/// <summary>
/// Loads + queries <see cref="SpellMetadata"/> from a CSV at startup.
/// Source: <c>docs/research/data/spells.csv</c> (RFC 4180-ish, 35
/// columns, 3,956 rows). Loaded once, used by panels + by
/// <c>EnchantmentMath</c> for buff stacking aggregation.
///
/// <para>
/// Hand-rolled CSV parser — the only complication is the
/// <c>Description</c> column which is double-quoted with embedded
/// commas. No external CsvHelper dependency.
/// </para>
///
/// <para>
/// Closes ISSUES.md #11. Required by ISSUES.md #6 for family-stacking
/// in vital-max enchantment aggregation.
/// </para>
/// </summary>
public sealed class SpellTable
{
private readonly Dictionary<uint, SpellMetadata> _byId;
/// <summary>Empty table (no spells loaded). Useful for tests +
/// pre-load defaults.</summary>
public static SpellTable Empty { get; } = new(new Dictionary<uint, SpellMetadata>());
private SpellTable(Dictionary<uint, SpellMetadata> byId)
{
_byId = byId;
}
/// <summary>Number of spells loaded.</summary>
public int Count => _byId.Count;
/// <summary>Look up metadata by spell id. Returns <c>true</c> if
/// found; <paramref name="meta"/> is the matching record.</summary>
public bool TryGet(uint spellId, out SpellMetadata meta)
{
if (_byId.TryGetValue(spellId, out var v))
{
meta = v;
return true;
}
meta = null!;
return false;
}
/// <summary>All loaded spell IDs. Stable enumeration order is not
/// guaranteed.</summary>
public IEnumerable<uint> SpellIds => _byId.Keys;
/// <summary>
/// Load from a CSV file. Throws <see cref="FileNotFoundException"/>
/// if the path doesn't exist; bad rows are silently skipped (the
/// CSV is third-party data, not authored by us — be lenient).
/// </summary>
public static SpellTable LoadFromCsv(string csvPath)
{
if (!File.Exists(csvPath))
throw new FileNotFoundException("spells.csv not found", csvPath);
using var reader = new StreamReader(csvPath);
return LoadFromReader(reader);
}
/// <summary>
/// Load from any <see cref="TextReader"/>. Used by tests with
/// <see cref="StringReader"/>; production loads via
/// <see cref="LoadFromCsv"/>.
/// </summary>
public static SpellTable LoadFromReader(TextReader reader)
{
var byId = new Dictionary<uint, SpellMetadata>();
string? header = reader.ReadLine();
if (header is null) return new SpellTable(byId);
// Map column-name → index. The CSV order is documented in
// docs/research/data/README.md but we don't depend on it —
// resolve every column we care about by name.
var columns = ParseRow(header);
var colIndex = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < columns.Count; i++)
colIndex[columns[i]] = i;
int? Get(string name) => colIndex.TryGetValue(name, out int i) ? i : (int?)null;
int? iSpellId = Get("Spell ID");
int? iName = Get("Name");
int? iSchool = Get("School");
int? iFamily = Get("Family");
int? iIconHex = Get("IconId [Hex]");
int? iWords = Get("Spell Words");
int? iDuration = Get("Duration");
int? iMana = Get("Mana");
int? iIsDebuff = Get("IsDebuff");
int? iIsFellow = Get("IsFellowship");
int? iDescription = Get("Description");
if (iSpellId is null || iName is null) return new SpellTable(byId);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var fields = ParseRow(line);
if (fields.Count <= iSpellId.Value) continue;
// Spell ID is the only non-optional column.
if (!uint.TryParse(fields[iSpellId.Value], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint spellId))
continue;
string name = iName is int n && n < fields.Count ? fields[n] : "";
string school = iSchool is int s && s < fields.Count ? fields[s] : "";
uint family = ParseUInt(fields, iFamily);
uint iconId = ParseHexUInt(fields, iIconHex);
string words = iWords is int w && w < fields.Count ? fields[w] : "";
float duration = ParseFloat(fields, iDuration);
int mana = (int)ParseUInt(fields, iMana);
bool isDebuff = ParseBool(fields, iIsDebuff);
bool isFellow = ParseBool(fields, iIsFellow);
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
byId[spellId] = new SpellMetadata(
spellId, name, school, family, iconId, words, duration,
mana, isDebuff, isFellow, description);
}
return new SpellTable(byId);
}
private static uint ParseUInt(IList<string> fields, int? index)
{
if (index is not int i || i >= fields.Count) return 0;
return uint.TryParse(fields[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint v) ? v : 0u;
}
private static uint ParseHexUInt(IList<string> fields, int? index)
{
if (index is not int i || i >= fields.Count) return 0;
string s = fields[i];
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
s = s[2..];
return uint.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint v) ? v : 0u;
}
private static float ParseFloat(IList<string> fields, int? index)
{
if (index is not int i || i >= fields.Count) return 0f;
return float.TryParse(fields[i], NumberStyles.Float, CultureInfo.InvariantCulture, out float v) ? v : 0f;
}
private static bool ParseBool(IList<string> fields, int? index)
{
if (index is not int i || i >= fields.Count) return false;
return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted
/// fields with embedded commas (the Description column). Embedded
/// double quotes are escaped by doubling (`""` → `"`). Public so
/// callers (incl. tests) can reuse the parser without instantiating
/// a full <see cref="SpellTable"/>.
/// </summary>
public static List<string> ParseRow(string row)
{
var fields = new List<string>();
int i = 0;
while (i < row.Length)
{
string field;
if (row[i] == '"')
{
// Quoted field — consume until matching close-quote
// (handle "" as an escaped quote within the field).
i++; // skip opening
var sb = new System.Text.StringBuilder();
while (i < row.Length)
{
if (row[i] == '"')
{
// Either a closing quote or an escaped "".
if (i + 1 < row.Length && row[i + 1] == '"')
{
sb.Append('"');
i += 2;
continue;
}
i++; // skip closing quote
break;
}
sb.Append(row[i]);
i++;
}
field = sb.ToString();
}
else
{
int start = i;
while (i < row.Length && row[i] != ',')
i++;
field = row[start..i];
}
fields.Add(field);
// Skip the comma separator if present.
if (i < row.Length && row[i] == ',')
i++;
}
return fields;
}
}

View file

@ -19,6 +19,43 @@ public sealed class Spellbook
{
private readonly HashSet<uint> _learnedSpells = new();
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
private readonly SpellTable _table;
/// <summary>
/// Build a Spellbook with an optional <see cref="SpellTable"/>
/// metadata source. When provided, <see cref="TryGetMetadata"/>
/// returns the static spell descriptor (name / school / family /
/// icon / mana / duration / etc.). When absent (back-compat for
/// existing constructors / tests), <see cref="TryGetMetadata"/>
/// always returns <c>false</c>.
/// </summary>
public Spellbook(SpellTable? table = null)
{
_table = table ?? SpellTable.Empty;
}
/// <summary>
/// Look up static spell metadata. Closes ISSUES.md #11 — feeds the
/// buff bar UI labels + icons + the family-stacking aggregation in
/// <c>EnchantmentMath</c> (issue #6).
/// </summary>
public bool TryGetMetadata(uint spellId, out SpellMetadata meta) =>
_table.TryGet(spellId, out meta);
/// <summary>The spell-metadata table this spellbook was built with.
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
public SpellTable Metadata => _table;
/// <summary>
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
/// over <see cref="ActiveEnchantments"/> through
/// <see cref="EnchantmentMath.GetMod"/> with family-stacking dedup
/// from the spell metadata table. Returns
/// <see cref="EnchantmentMath.VitalMod.Identity"/> when no buffs
/// apply (or when no SpellTable was wired).
/// </summary>
public EnchantmentMath.VitalMod GetVitalMod(uint statKey) =>
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
public event Action<uint>? SpellLearned;
@ -67,6 +104,19 @@ public sealed class Spellbook
EnchantmentAdded?.Invoke(record);
}
/// <summary>
/// Issue #7 / #12 — accept a fully-populated record from
/// <c>PlayerDescription</c>'s enchantment block (which carries
/// the StatMod triad + bucket). Used when the wire-format extension
/// gives us the full per-enchantment payload, rather than the
/// 4-field summary from <c>MagicUpdateEnchantment</c>.
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{
_activeByLayer[record.LayerId] = record;
EnchantmentAdded?.Invoke(record);
}
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
public void OnEnchantmentRemoved(uint layerId, uint spellId)
{
@ -92,13 +142,27 @@ public sealed class Spellbook
}
/// <summary>
/// Summary of one active enchantment layer on the player. Richer detail
/// (stat mods, category, power) requires the full <see cref="ActiveBuff"/>
/// struct — this record is the wire-slim version surfaced by the
/// <see cref="Spellbook.EnchantmentAdded"/> event.
/// Summary of one active enchantment layer on the player. The
/// optional StatMod fields (issue #12) carry the wire-level
/// `_smod` triad <c>(type, key, val)</c> when available — only
/// `PlayerDescription`'s enchantment block currently populates these
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
/// `MagicUpdateEnchantment` events still produce records with these
/// fields null until the wire parser is extended.
///
/// <para>
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
/// the retail <c>EnchantmentMask</c> classification.
/// </para>
/// </summary>
public readonly record struct ActiveEnchantmentRecord(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid,
uint? StatModType = null,
uint? StatModKey = null,
float? StatModValue = null,
uint Bucket = 0);

View file

@ -240,6 +240,31 @@ public sealed class GameEventWiringTests
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
}
[Fact]
public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
{
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification
// existed but was never registered for dispatch until 2026-04-25.
// Now wired: 0x01AD lands on CombatState.OnKillerNotification +
// fires the KillLanded event.
var (d, _, combat, _, _) = MakeAll();
string? gotVictimName = null;
uint gotVictimGuid = 0;
combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; };
// Wire shape: string16L victimName + u32 victimGuid
byte[] nameBytes = MakeString16L("Drudge");
byte[] payload = new byte[nameBytes.Length + 4];
Array.Copy(nameBytes, payload, nameBytes.Length);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
d.Dispatch(env!.Value);
Assert.Equal("Drudge", gotVictimName);
Assert.Equal(0x80001234u, gotVictimGuid);
}
[Fact]
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
{

View file

@ -207,6 +207,108 @@ public sealed class PlayerDescriptionParserTests
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
}
[Fact]
public void TryParse_EnchantmentBlock_PopulatesEnchantments_WithStatModAndBucket()
{
// ATTRIBUTE | SPELL | ENCHANTMENT vector flag (= 0x301 minus
// SKILL = 0x301 incl. ATTRIBUTE+SPELL+ENCHANTMENT). Empty
// attribute block + empty spell table + 1 multiplicative
// enchantment + 1 additive enchantment. Verifies end-to-end
// that the enchantment record schema lands intact.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
// vectorFlags = ATTRIBUTE (0x01) | SPELL (0x100) | ENCHANTMENT (0x200) = 0x301
writer.Write(0x301u);
writer.Write(1u); // has_health
writer.Write(0u); // attribute_flags = 0 -> no entries
// Spell table: empty (count=0).
writer.Write((ushort)0);
writer.Write((ushort)0);
// EnchantmentMask = MULTIPLICATIVE (0x01) | ADDITIVE (0x02) = 0x03
writer.Write(0x03u);
// Multiplicative list: 1 entry
writer.Write(1u);
WriteEnchantment(writer,
spellId: 1234, layer: 5, spellCategory: 100, hasSpellSetId: 0,
powerLevel: 999, startTime: 12.5, duration: 1800.0,
casterGuid: 0xCAFE0001u, degradeMod: 1.0f, degradeLimit: 0.5f,
lastDegraded: 0.0, statModType: 0x00010000u, statModKey: 3u /* MaxStamina */,
statModValue: 1.5f);
// Additive list: 1 entry
writer.Write(1u);
WriteEnchantment(writer,
spellId: 5678, layer: 6, spellCategory: 101, hasSpellSetId: 0,
powerLevel: 100, startTime: 13.0, duration: 1500.0,
casterGuid: 0xCAFE0002u, degradeMod: 1.0f, degradeLimit: 0.5f,
lastDegraded: 0.0, statModType: 0x00020000u, statModKey: 5u /* MaxMana */,
statModValue: 25.0f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Enchantments.Count);
var mult = parsed.Value.Enchantments[0];
Assert.Equal((ushort)1234, mult.SpellId);
Assert.Equal((ushort)5, mult.Layer);
Assert.Equal(3u, mult.StatModKey);
Assert.Equal(1.5f, mult.StatModValue);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Multiplicative, mult.Bucket);
var add = parsed.Value.Enchantments[1];
Assert.Equal((ushort)5678, add.SpellId);
Assert.Equal(5u, add.StatModKey);
Assert.Equal(25.0f, add.StatModValue);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Additive, add.Bucket);
}
[Fact]
public void TryParse_VitaeSingleton_AppearsInEnchantments()
{
// EnchantmentMask = VITAE only (0x08). Single Enchantment, no
// count prefix.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0x08u); // EnchantmentMask = VITAE
WriteEnchantment(writer,
spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0,
powerLevel: 0, startTime: 0.0, duration: -1.0,
casterGuid: 0u, degradeMod: 0f, degradeLimit: 0f,
lastDegraded: 0.0, statModType: 0u, statModKey: 1u /* MaxHealth */,
statModValue: 0.95f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Single(parsed!.Value.Enchantments);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Vitae, parsed.Value.Enchantments[0].Bucket);
Assert.Equal(0.95f, parsed.Value.Enchantments[0].StatModValue);
}
private static void WriteEnchantment(BinaryWriter w,
ushort spellId, ushort layer, ushort spellCategory, ushort hasSpellSetId,
uint powerLevel, double startTime, double duration, uint casterGuid,
float degradeMod, float degradeLimit, double lastDegraded,
uint statModType, uint statModKey, float statModValue)
{
w.Write(spellId); w.Write(layer); w.Write(spellCategory); w.Write(hasSpellSetId);
w.Write(powerLevel); w.Write(startTime); w.Write(duration);
w.Write(casterGuid);
w.Write(degradeMod); w.Write(degradeLimit); w.Write(lastDegraded);
w.Write(statModType); w.Write(statModKey); w.Write(statModValue);
// Skip optional spell_set_id (only present if hasSpellSetId != 0).
}
[Fact]
public void TryParse_SpellTable_PopulatesSpellsDictionary()
{

View file

@ -0,0 +1,226 @@
using System.Collections.Generic;
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
/// <summary>
/// Tests for <see cref="EnchantmentMath"/>. Issue #6 architecture
/// validation — confirms the family-stacking dedup + identity
/// semantics work correctly.
///
/// <para>
/// Note: until ISSUES.md #12 lands the wire-format extension that
/// captures StatMod (type/key/val) on <see cref="ActiveEnchantmentRecord"/>,
/// the per-enchantment modifier value isn't aggregated yet — we
/// always return <see cref="EnchantmentMath.VitalMod.Identity"/>.
/// These tests confirm the architectural shape is correct for the
/// follow-up wire wiring.
/// </para>
/// </summary>
public sealed class EnchantmentMathTests
{
[Fact]
public void Empty_ReturnsIdentity()
{
var mod = EnchantmentMath.GetMod(
new List<ActiveEnchantmentRecord>(),
SpellTable.Empty,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void NoMatchingTableEntries_ReturnsIdentity()
{
// Active enchantments exist but none of them have entries in the
// SpellTable (so we can't read Family) — they're skipped.
var enchantments = new[]
{
new ActiveEnchantmentRecord(SpellId: 9999u, LayerId: 1u, Duration: 60f, CasterGuid: 0u),
new ActiveEnchantmentRecord(SpellId: 8888u, LayerId: 2u, Duration: 60f, CasterGuid: 0u),
};
var mod = EnchantmentMath.GetMod(enchantments, SpellTable.Empty,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void StatKey_ConstantsMatchAceEnum()
{
// ACE PropertyAttribute2nd enum: MaxHealth=1, MaxStamina=3, MaxMana=5.
// Verified against named-retail/acclient.h line 37287-37301.
Assert.Equal(1u, EnchantmentMath.StatKey.MaxHealth);
Assert.Equal(3u, EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(5u, EnchantmentMath.StatKey.MaxMana);
}
[Fact]
public void Identity_IsOneAndZero()
{
Assert.Equal(1.0f, EnchantmentMath.VitalMod.Identity.Multiplier);
Assert.Equal(0.0f, EnchantmentMath.VitalMod.Identity.Additive);
}
[Fact]
public void FamilyStacking_DeduplicatesByFamily_KeepsHigherSpellId()
{
// Build a SpellTable with 2 spells in the same Family (e.g.
// Strength I and Strength VII, both Family=1). Both are in the
// active enchantment list; only the higher spell id should
// survive the family-stacking dedup.
// The aggregator currently returns Identity regardless (see
// class doc), but the dedup behaviour is observable by
// counting which records would be folded — exercised here to
// pin the architecture even before ISSUES.md #12 wires data.
// Family=1 strength buffs example.
var table = LoadTable(
(1u, "Strength I", 1u),
(132u, "Strength VII", 1u)); // same family
var enchantments = new[]
{
new ActiveEnchantmentRecord(SpellId: 1u, LayerId: 100u, Duration: 60f, CasterGuid: 0u),
new ActiveEnchantmentRecord(SpellId: 132u, LayerId: 101u, Duration: 60f, CasterGuid: 0u),
};
// Currently: identity result (StatMod data not yet on records).
// Test demonstrates the call doesn't throw + returns identity.
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void Family_Zero_DoesNotDedup()
{
// Family 0 means "no stacking bucket" — each enchantment is
// its own bucket (synthetic key per layer). When ISSUES.md #12
// lands and we aggregate StatMods, both these buffs will
// contribute simultaneously.
var table = LoadTable(
(10u, "Buff A", 0u),
(20u, "Buff B", 0u));
var enchantments = new[]
{
new ActiveEnchantmentRecord(SpellId: 10u, LayerId: 100u, Duration: 60f, CasterGuid: 0u),
new ActiveEnchantmentRecord(SpellId: 20u, LayerId: 101u, Duration: 60f, CasterGuid: 0u),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
// Identity for now; architecture confirmed via no-throw + result shape.
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetMod_MultiplicativeBucket_AppliesProductWhenStatKeyMatches()
{
// Two multiplicative enchantments on MaxStamina (key=3): values
// 1.2 and 1.1 → final multiplier = 1.2 × 1.1 = 1.32.
// Different families so neither dedups the other.
var table = LoadTable(
(10u, "Buff10", 100u),
(11u, "Buff11", 200u));
var enchantments = new[]
{
MakeMultRecord(spellId: 10, layer: 1, statKey: 3u, val: 1.2f),
MakeMultRecord(spellId: 11, layer: 2, statKey: 3u, val: 1.1f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(1.32f, mod.Multiplier, precision: 4);
Assert.Equal(0.0f, mod.Additive);
}
[Fact]
public void GetMod_AdditiveBucket_SumsValueWhenStatKeyMatches()
{
var table = LoadTable(
(20u, "Add1", 300u),
(21u, "Add2", 301u));
var enchantments = new[]
{
MakeAddRecord(spellId: 20, layer: 1, statKey: 5u /* MaxMana */, val: 25f),
MakeAddRecord(spellId: 21, layer: 2, statKey: 5u, val: 50f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxMana);
Assert.Equal(1.0f, mod.Multiplier);
Assert.Equal(75.0f, mod.Additive);
}
[Fact]
public void GetMod_StatKeyMismatch_DoesNotContribute()
{
var table = LoadTable((30u, "Health buff", 500u));
// Buff modifies MaxHealth (key=1) but we ask for MaxStamina (key=3).
var enchantments = new[]
{
MakeMultRecord(spellId: 30, layer: 1, statKey: 1u /* MaxHealth */, val: 1.5f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetMod_VitaeBucket_AppliedMultiplicativelyAfterBuffs()
{
// Vitae = 0.85 (15% death penalty) on MaxHealth, plus a +10
// additive from a Restoration buff. Family 0 means each is its
// own bucket.
var table = LoadTable(
(40u, "Restoration", 0u),
(41u, "Vitae", 0u));
var enchantments = new[]
{
MakeAddRecord(spellId: 40, layer: 1, statKey: 1u /* MaxHealth */, val: 10f),
MakeVitaeRecord(spellId: 41, layer: 2, statKey: 1u, val: 0.85f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxHealth);
// Vitae multiplier 0.85, additive 10.
Assert.Equal(0.85f, mod.Multiplier, precision: 3);
Assert.Equal(10.0f, mod.Additive);
}
[Fact]
public void GetMod_FamilyStacking_PicksHigherSpellId()
{
// Two spells in the same family — only the one with the higher
// SpellId should contribute.
var table = LoadTable(
(10u, "Strength I", 1u), // Family=1
(132u, "Strength VII", 1u)); // same family
var enchantments = new[]
{
MakeMultRecord(spellId: 10u, layer: 1, statKey: 3u, val: 1.1f),
MakeMultRecord(spellId: 132u, layer: 2, statKey: 3u, val: 1.5f),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
// Only the higher-id buff (1.5) applies.
Assert.Equal(1.5f, mod.Multiplier, precision: 3);
}
private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u);
private static ActiveEnchantmentRecord MakeAddRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 2u);
private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 8u);
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
{
// Build a synthetic CSV with just enough columns for SpellTable to
// resolve Family on each spell id.
var sb = new System.Text.StringBuilder();
sb.AppendLine("Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10");
foreach (var (id, name, family) in rows)
{
sb.Append(id).Append(',').Append("0x").Append(id.ToString("X")).Append(',')
.Append(name).Append(",0,0x0,1,1,").Append(family).Append(",0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0")
.AppendLine();
}
return SpellTable.LoadFromReader(new System.IO.StringReader(sb.ToString()));
}
}

View file

@ -0,0 +1,128 @@
using System.IO;
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
/// <summary>
/// Tests for the <see cref="SpellTable"/> CSV loader. Uses synthetic
/// fixture strings rather than a real spells.csv so we don't depend
/// on docs/research/data/ contents at test time.
///
/// Closes ISSUES.md #11 — spell metadata pipeline.
/// </summary>
public sealed class SpellTableTests
{
// Header used across fixtures — matches the column names from the
// real docs/research/data/spells.csv. Row format is the same.
private const string Header =
"Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10";
private static SpellTable LoadFrom(string csv) =>
SpellTable.LoadFromReader(new StringReader(csv));
[Fact]
public void Empty_TableHasZeroEntries()
{
Assert.Equal(0, SpellTable.Empty.Count);
Assert.False(SpellTable.Empty.TryGet(1u, out _));
}
[Fact]
public void LoadFromReader_HeaderOnly_EmptyTable()
{
var table = LoadFrom(Header);
Assert.Equal(0, table.Count);
}
[Fact]
public void LoadFromReader_SingleSimpleRow_HasMetadata()
{
// Spell ID 1 = Strength Other I, Family 1 (the actual real-data row).
string csv =
Header + "\n" +
"1,0x1,Strength Other I,6450,0x600138C,1,1800,1,0x6,1,False,False,False,True,False,False,10,Creature Enchantment,0,Malar Cazael,0,6,0x10,1,Increases Strength.,5,1,1,1,0,0,0,0,0,0";
var table = LoadFrom(csv);
Assert.Equal(1, table.Count);
Assert.True(table.TryGet(1u, out var meta));
Assert.Equal("Strength Other I", meta.Name);
Assert.Equal("Creature Enchantment", meta.School);
Assert.Equal(1u, meta.Family);
Assert.Equal(0x600138Cu, meta.IconId);
Assert.Equal("Malar Cazael", meta.SpellWords);
Assert.Equal(1800f, meta.Duration);
Assert.Equal(10, meta.ManaCost);
Assert.False(meta.IsDebuff);
Assert.False(meta.IsFellowship);
Assert.Equal("Increases Strength.", meta.Description);
}
[Fact]
public void LoadFromReader_QuotedDescriptionWithCommas_ParsesIntactly()
{
// The Description field in the real CSV is double-quoted so the
// embedded comma doesn't split the row.
string csv =
Header + "\n" +
"2,0x2,Strength Self I,6464,0x600138C,1,1800,1,0x400C,1,False,True,False,True,False,True,15,Creature Enchantment,0.01,Malar Cazael,0,6,0x10,1,\"Increases the caster's Strength by 10 points, lasting 30 minutes.\",0,0,1,2,0,0,0,0,0,0";
var table = LoadFrom(csv);
Assert.True(table.TryGet(2u, out var meta));
Assert.Equal("Increases the caster's Strength by 10 points, lasting 30 minutes.", meta.Description);
Assert.Equal("Strength Self I", meta.Name);
}
[Fact]
public void LoadFromReader_BlankLines_AreSkipped()
{
string csv =
Header + "\n" +
"1,0x1,Test,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0\n" +
"\n" +
" \n" +
"2,0x2,Test2,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0";
var table = LoadFrom(csv);
Assert.Equal(2, table.Count);
}
[Fact]
public void LoadFromReader_BadSpellId_RowSkipped()
{
string csv =
Header + "\n" +
"not_a_uint,0x1,Bad,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0\n" +
"5,0x5,Good,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0";
var table = LoadFrom(csv);
Assert.Equal(1, table.Count);
Assert.True(table.TryGet(5u, out _));
}
[Fact]
public void TryGet_UnknownSpellId_ReturnsFalse()
{
var table = LoadFrom(Header);
Assert.False(table.TryGet(99999u, out _));
}
[Fact]
public void ParseRow_SimpleCsv_SplitsOnCommas()
{
var fields = SpellTable.ParseRow("a,b,c");
Assert.Equal(new[] { "a", "b", "c" }, fields);
}
[Fact]
public void ParseRow_QuotedFieldWithComma_KeepsComma()
{
var fields = SpellTable.ParseRow("a,\"b,c\",d");
Assert.Equal(new[] { "a", "b,c", "d" }, fields);
}
[Fact]
public void ParseRow_EscapedDoubleQuoteInsideQuotedField()
{
// RFC 4180: "" inside a quoted field is a literal " character.
var fields = SpellTable.ParseRow("a,\"b\"\"c\",d");
Assert.Equal(new[] { "a", "b\"c", "d" }, fields);
}
}

117
tools/pdb-extract/README.md Normal file
View file

@ -0,0 +1,117 @@
# pdb-extract — pure-Python MSF 7.00 PDB extractor
Reads `refs/acclient.pdb` (Sept 2013 EoR build, 28 MB) and writes two
grep-friendly JSON sidecars to `docs/research/named-retail/`:
- **`symbols.json`** — 18,366 named public function symbols from the
PDB's S_PUB32 records. Each entry has `address` (image VA), `name`
(MSVC-demangled `Class::Method` form), and `mangled` (raw C++ ABI
symbol for callers that need exact mangling).
- **`types.json`** — 5,371 unique named struct/class type records from
the TPI stream (LF_CLASS / LF_STRUCTURE). Each entry has `name`,
`size` (bytes), and `kind` (`class` or `struct`).
## Usage
```powershell
py tools\pdb-extract\pdb_extract.py refs\acclient.pdb
```
Runs in <1 second. No external dependencies uses Python stdlib only.
## Schema
`symbols.json`:
```json
[
{
"address": "0x00594570",
"name": "CEnchantmentRegistry::EnchantAttribute",
"mangled": "?EnchantAttribute@CEnchantmentRegistry@@QBEHKAAK@Z"
},
...
]
```
`types.json`:
```json
[
{
"name": "CEnchantmentRegistry",
"size": 32,
"kind": "class"
},
...
]
```
## Workflow integration
The committed JSON sidecars are the named-retail counterpart to the
`acclient_2013_pseudo_c.txt` text dump. Pseudo-C is for reading
function bodies; `symbols.json` is for programmatic lookups. Use
`jq` to query:
```bash
# Find a function by exact name
cat docs/research/named-retail/symbols.json | jq '.[] | select(.name == "CEnchantmentRegistry::EnchantAttribute")'
# Find all functions on a class
cat docs/research/named-retail/symbols.json | jq '.[] | select(.name | startswith("CACQualities::"))'
# Reverse lookup by address (e.g. mid-body fix-up)
cat docs/research/named-retail/symbols.json | jq '.[] | select(.address == "0x00594570")'
# Find a type by name
cat docs/research/named-retail/types.json | jq '.[] | select(.name == "Enchantment")'
```
## Address mapping caveat
The PDB is from the Sept 2013 EoR build. Addresses generally match the
binary used to produce our `docs/research/decompiled/` Ghidra chunks
within ~0xC00 bytes (different build runs of the same source revision).
**When using `symbols.json` to correct entries in
`acclient_function_map.md`, match by name, not by raw address.**
## Implementation notes
The script is a self-contained MSF 7.00 reader. References used:
- LLVM PDB documentation (https://llvm.org/docs/PDB/) — file format spec
- Microsoft `pdbparse` (community) — implementation cross-check
Streams consumed:
- **3 (DBI)** — parses the header to extract the symbol-record stream
index + the optional debug-header sub-stream's section-headers index.
- **9 (section headers)** — parses `IMAGE_SECTION_HEADER` entries to
build a section-base table for VA computation.
- **8 (sym record stream)** — iterates records, picks `S_PUB32` with
the `PUBSYM_FLAG_CODE` bit set, computes `VA = section_base + offset`.
- **2 (TPI)** — iterates type records, picks `LF_CLASS` / `LF_STRUCTURE`
that aren't forward-declared, parses size leaf + name.
The MSVC name demangler (`_demangle`) is best-effort: handles the
common `?Method@Class@Outer@@<sig>` patterns, constructors (`??0`),
and destructors (`??1`). Returns the mangled string unchanged for
operator overloads (`??2`, `??3`), vtables (`??_`), and other forms
where a partial demangle would be misleading. Both `name` (demangled)
and `mangled` (raw) are emitted in `symbols.json` so consumers can
choose.
## When to regenerate
- Whenever `refs/acclient.pdb` is updated (rare).
- Whenever `pdb_extract.py` is changed (e.g. better demangler, more
type info recovery).
The output JSONs are committed because they're stable + small (~3 MB
combined) and grep-faster than re-parsing the PDB on every session.
## Future work (out of scope here)
The current `types.json` only carries name + size. A more ambitious
version would walk LF_FIELDLIST records to recover field names +
offsets + types — giving us a JSON-encoded `acclient.h`. Not done yet
because `acclient.h` already exists committed at
`docs/research/named-retail/acclient.h`. Consider this if a future
panel needs offsetof() at runtime.

View file

@ -0,0 +1,149 @@
"""Cross-check acclient_function_map.md addresses against symbols.json.
For each row, looks up the function name in the PDB symbols table and
flags mismatches (mid-body addresses, wrong addresses, missing names).
Run with:
py tools/pdb-extract/check_function_map.py
Used to close ISSUES.md #9 (address-correction sweep).
"""
import json
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
SYMBOLS = REPO / "docs" / "research" / "named-retail" / "symbols.json"
# (claimed_address, qualified_name) tuples from acclient_function_map.md.
# Format: lowercase hex no leading 0, then the demangled Class::Method.
ENTRIES = [
# CPhysicsObj
("515020", "CPhysicsObj::update_object"),
("5111d0", "CPhysicsObj::UpdatePhysicsInternal"),
("511420", "CPhysicsObj::calc_acceleration"),
("511ec0", "CPhysicsObj::set_velocity"),
("511fa0", "CPhysicsObj::set_local_velocity"),
("511de0", "CPhysicsObj::set_on_walkable"),
("511560", "CPhysicsObj::report_collision_start"),
("513ac0", "CPhysicsObj::report_collision_end"),
("513b60", "CPhysicsObj::handle_obj_collision"),
("515280", "CPhysicsObj::handle_collision"),
("513730", "CPhysicsObj::UpdatePositionInternal"),
("50f940", "CPhysicsObj::calc_friction"),
("510080", "CPhysicsObj::check_contact_velocity"),
# CMotionInterp
("5286b0", "CMotionInterp::get_jump_v_z"),
("528660", "CMotionInterp::jump_charge_is_allowed"),
("5285e0", "CMotionInterp::motion_allows_jump"),
("528ec0", "CMotionInterp::jump_is_allowed"),
("528cd0", "CMotionInterp::get_leave_ground_velocity"),
("529390", "CMotionInterp::jump"),
("529710", "CMotionInterp::LeaveGround"),
("5296d0", "CMotionInterp::HitGround"),
("528960", "CMotionInterp::get_state_velocity"),
("528a50", "CMotionInterp::StopCompletely"),
("5287f0", "CMotionInterp::adjust_motion"),
("5293f0", "CMotionInterp::apply_raw_movement"),
("529210", "CMotionInterp::apply_current_movement"),
("528dd0", "CMotionInterp::contact_allows_move"),
("528f70", "CMotionInterp::DoInterpretedMotion"),
("529080", "CMotionInterp::StopInterpretedMotion"),
("529140", "CMotionInterp::StopMotion"),
("529930", "CMotionInterp::DoMotion"),
("529a90", "CMotionInterp::PerformMovement"),
# CLandBlockStruct
("531d10", "CLandBlockStruct::IsSWtoNECut"),
("532a50", "CLandBlockStruct::ConstructPolygons"),
("532eb0", "CLandBlockStruct::ConstructUVs"),
("532d10", "CLandBlockStruct::unpack"),
("531f10", "CLandBlockStruct::get_packed_size"),
("532440", "CLandBlockStruct::AdjustPlanes"),
("532290", "CLandBlockStruct::CalcCellWater"),
# CLandBlock
("530690", "CLandBlock::Init"),
("5307e0", "CLandBlock::release_all"),
("531780", "CLandBlock::init_static_objs"),
("531000", "CLandBlock::release_visible_cells"),
("5301e0", "CLandBlock::grab_visible_cells"),
("530650", "CLandBlock::add_server_object"),
# LandDefs
("5aaa30", "CLandDefs::get_vars"),
("5aabb0", "CLandDefs::get_outside_lcoord"),
("5aac70", "CLandDefs::AdjustToOutside"),
("5aab50", "CLandDefs::get_block_dir"),
# Collision / Transition (some entries are CCylSphere / CSphere / CTransition / CPolygon)
("5384e0", "CSphere::SphereIntersectsRay"),
("539500", "CPolygon::sphere_intersects_poly"),
("539750", "CPolygon::sphere_intersects_solid"),
("539ba0", "CPolygon::find_time_of_collision"),
("539df0", "CPolygon::find_time_of_collision"),
("53a040", "CPolygon::find_walkable_collision"),
("539110", "CPolygon::calc_normal"),
("539060", "CPlane::ray_plane_intersect"),
("538eb0", "CSphere::slide_sphere"),
("538f50", "CSphere::land_on_sphere"),
("538180", "CTransition::collide_with_point"),
("5387c0", "CTransition::find_collisions"),
("53a230", "CPolygon::hits_walkable"),
# PhysicsEngine
("452a10", "PhysicsEngine::update"),
]
def main():
with open(SYMBOLS, "r", encoding="utf-8") as f:
symbols = json.load(f)
# Build name -> [addresses] (some names appear multiple times: overloads)
by_name = {}
for s in symbols:
by_name.setdefault(s["name"], []).append(s["address"])
# Build address -> name (lowercase hex without 0x prefix)
by_addr = {}
for s in symbols:
addr = s["address"][2:].lower().lstrip("0") or "0"
by_addr[addr] = s["name"]
confirmed = 0
addr_corrections = []
name_misses = []
for claimed_addr, claimed_name in ENTRIES:
# Look up by name
if claimed_name in by_name:
pdb_addrs = by_name[claimed_name]
normalized_claimed = claimed_addr.lower().lstrip("0") or "0"
match = any(
(a[2:].lower().lstrip("0") or "0") == normalized_claimed
for a in pdb_addrs
)
if match:
confirmed += 1
else:
addr_corrections.append((claimed_addr, claimed_name, pdb_addrs))
else:
# Name not found exactly. Try the claimed address as fallback.
normalized_claimed = claimed_addr.lower().lstrip("0") or "0"
actual_name = by_addr.get(normalized_claimed)
name_misses.append((claimed_addr, claimed_name, actual_name))
print(f"Confirmed (address + name match): {confirmed}/{len(ENTRIES)}")
print()
if addr_corrections:
print("ADDRESS CORRECTIONS (name correct, address wrong — likely mid-body):")
for claimed_addr, name, pdb_addrs in addr_corrections:
print(f" 0x{claimed_addr.upper()} {name}")
print(f" actual: {', '.join(pdb_addrs)}")
print()
if name_misses:
print("NAME MISMATCHES (name not found by exact match in PDB):")
for claimed_addr, name, actual_name in name_misses:
print(f" 0x{claimed_addr.upper()} {name}")
if actual_name:
print(f" PDB at 0x{claimed_addr.upper()}: {actual_name}")
else:
print(f" PDB has no symbol at 0x{claimed_addr.upper()}")
print()
main()

View file

@ -0,0 +1,458 @@
"""Pure-Python MSF 7.00 PDB extractor for acclient.pdb.
Reads the Microsoft Program Database, extracts public function symbols
(S_PUB32, kind 0x110E) and named struct/class type records (LF_CLASS,
LF_STRUCTURE) from the TPI stream, and writes two JSON sidecars to
docs/research/named-retail/:
symbols.json every named public function with its image VA
types.json every named struct/class with size
This is a foundation for the named-retail workflow. After running, every
future session can grep the JSON for instant addressname 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()