fix(spells): load complete retail DAT catalog

Replace the incomplete 3,956-row production CSV with one immutable projection of all 6,266 records in portal.dat. Preserve retail formula targeting, shared Spellbook/MagicRuntime metadata ownership, and fail startup when the required SpellTable is absent.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 21:17:13 +02:00
parent 09612f9981
commit 0eab7497c1
19 changed files with 633 additions and 114 deletions

View file

@ -8016,6 +8016,11 @@ The remaining trailer sections (options / shortcuts / hotbars / inventory / equi
**Commit:** `feat(spells): #11 SpellTable — hydrate metadata from spells.csv at startup` **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. **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.
**Superseded 2026-07-15:** Production now projects all 6,266 records from the
installed end-of-retail SpellTable DID `0x0E00000E`. The 3,956-row CSV remains
only as a historical fixture; AP-17 is retired. See
`docs/research/2026-07-15-retail-spell-catalog-pseudocode.md`.
--- ---
## #9 — [DONE 2026-04-25] Address-correction sweep on `acclient_function_map.md` ## #9 — [DONE 2026-04-25] Address-correction sweep on `acclient_function_map.md`

View file

@ -127,7 +127,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) | | AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
| AP-17 | Descriptive spell metadata and Family still come from a third-party CSV (3,956 rows, bad rows silently skipped); retail DAT now supplies decrypted formulas, school, exact icons, level filters, and component preflight | `src/AcDream.Core/Spells/SpellTable.cs:10`; `src/AcDream.App/Spells/MagicRuntime.cs` | CSV originally closed #11 and unblocked #6 stacking; M3 moved every cast-critical formula/icon/level decision to portal.dat but has not yet replaced descriptive/Family metadata | CSV↔DAT drift can still produce wrong stacking winners or panel name/description/beneficial classification; component choice and level/icon presentation no longer drift here | portal.dat SpellTable 0x0E00000E |
| ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 | | ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |

View file

@ -15,7 +15,9 @@ active enchantment, then recall through the DAT-driven portal presentation.
1. **Done (automated):** named-retail F.4/L.1d reconciliation, full live 1. **Done (automated):** named-retail F.4/L.1d reconciliation, full live
enchantment messages, account/focus-aware component formula preflight, enchantment messages, account/focus-aware component formula preflight,
retail target eligibility, authoritative cast request/completion ownership. retail target eligibility, authoritative cast request/completion ownership,
and the complete 6,266-record end-of-retail DAT spell catalog. The former
3,956-row CSV is no longer a production dependency.
2. **Done (automated):** authored Magic combat page, eight favorite tabs, 2. **Done (automated):** authored Magic combat page, eight favorite tabs,
equipped caster endowment, scoped retail keys, spellbook/component book, equipped caster endowment, scoped retail keys, spellbook/component book,
positive/negative active-enchantment panels, Helpful/Harmful indicator positive/negative active-enchantment panels, Helpful/Harmful indicator
@ -531,7 +533,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Steps 05 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary. - **Missile/portal VFX campaign Steps 05 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. - **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The favorite bar now ports retail horizontal empty-cell padding, shared UIItem 19 shortcut overlays, and the Cast button's three independently reflowed DAT face segments. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`. - **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. `MagicCatalog` now projects all 6,266 end-of-retail SpellTable records into the shared Core metadata table; the incomplete 3,956-row CSV is test/research-only and AP-17 is retired. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The favorite bar now ports retail horizontal empty-cell padding, shared UIItem 19 shortcut overlays, and the Cast button's three independently reflowed DAT face segments. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`, `docs/research/2026-07-15-retail-spell-catalog-pseudocode.md`.
- **M3 favorite-bar empty-state correction 2026-07-15; visual gate pending.** Shortcut-number arrays remain shared with the status toolbar, but the cell background is now resolved independently from the favorite ItemList's inherited `0x1000000E` cell prototype. LayoutDesc `0x21000010` selects prototype `0x10000341`, whose exact brown/gold `ItemSlot_Empty` surface is `0x06001A97`; the blue toolbar-style `0x060074CF` substitution is removed. The cross-layout resolver and controller presentation are pinned by live-DAT and retained-layout conformance tests. - **M3 favorite-bar empty-state correction 2026-07-15; visual gate pending.** Shortcut-number arrays remain shared with the status toolbar, but the cell background is now resolved independently from the favorite ItemList's inherited `0x1000000E` cell prototype. LayoutDesc `0x21000010` selects prototype `0x10000341`, whose exact brown/gold `ItemSlot_Empty` surface is `0x06001A97`; the blue toolbar-style `0x060074CF` substitution is removed. The cross-layout resolver and controller presentation are pinned by live-DAT and retained-layout conformance tests.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`. - **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.

View file

@ -659,6 +659,10 @@ server-authoritative `CM_Magic::Event_RemoveSpell (0x01A8)` response path.
The component-book follow-up now instantiates retail's category and component The component-book follow-up now instantiates retail's category and component
listbox templates from LayoutDesc `0x21000033`, preserving localized headings, listbox templates from LayoutDesc `0x21000033`, preserving localized headings,
row/selection textures, icon/name/count columns, and desired-value editing. row/selection textures, icon/name/count columns, and desired-value editing.
The spell metadata source is now the complete installed end-of-retail
SpellTable DID `0x0E00000E` (6,266 records). The former 3,956-row CSV is kept
only as a research fixture, so every spell ACE can teach resolves through the
same DAT-backed catalog used by formulas, casting, spellbook rows, and effects.
**Phases to ship:** **Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile - **F.4** — Spell cast state machine (buffs + recalls first, projectile

View file

@ -0,0 +1,91 @@
# Retail spell catalog projection
## Scope and oracles
The client behavior oracle remains the September 2013 named retail binary:
- `CSpellTable::UnPack @ 0x00597550`
- `CSpellTable::InqSpellBase @ 0x005670B0`
- `CSpellBase::UnPack @ 0x00597290`
- `CSpellBase::InqSpellFormula @ 0x00596EB0`
- `CSpellBase::InqTargetType @ 0x00597230`
- `CSpellBase::IsUntargeted @ 0x005974A0`
- `CSpellBase::InqSpellLevelByRoughHeuristic @ 0x00597260`
- `SpellFormula::GetTargetingType @ 0x005BC910`
- `SpellComponentTable::GetTargetTypeFromComponentID @ 0x005BBF50`
The installed end-of-retail `client_portal.dat` is the content oracle. Its
SpellTable DID `0x0E00000E` contains 6,266 spell records. The historical
`docs/research/data/spells.csv` contains only 3,956 records and omits 2,310
spell IDs which the server can legitimately teach a character. It is retained
only as a research fixture and is no longer copied into production output.
ACE's `SpellBase` and `SpellComponentsTable.GetSpellWords` ports were used as
independent schema/decryption and spell-word cross-checks (ACE commit
`650c5b75ae909957feaf58db320e46be16502653`). `DatCollection` remains the sole
DAT reader in acdream.
## Catalog projection
```text
LoadMagicCatalog(dats):
componentTable = dats.Get(0x0E00000F)
spellTable = dats.Get(0x0E00000E) or fail startup
for each (spellId, CSpellBase) in spellTable, preserving every record:
formula = the decrypted component list, at most eight SCIDs
formulaTarget = GetTargetingType(formula)
metadata[spellId] =
name, description, school, icon, category, flags
base mana/range, power, economy and component loss
formula version and decrypted components
meta-spell type and duration/degrade/portal fields
caster/target/fizzle effects and recovery fields
display order, non-component target type and mana modifier
spell words derived from Herb + Powder + lower-cased Potion
spellbook level from the rough power-component heuristic
untargeted = (formulaTarget == 0)
return one immutable SpellTable shared by Spellbook and MagicRuntime
```
Learned spell IDs remain server-authoritative. `PlayerDescription` and live
spell events populate `Spellbook`; the local DAT table supplies presentation
and formula metadata for those IDs. Installing metadata refreshes any learned
or enchantment state that arrived before Silk's `OnLoad`, and catalog
replacement is rejected after the one startup install.
## Formula target type versus server target field
These are deliberately separate concepts:
- Retail client target compatibility calls `CSpellBase::InqTargetType`, which
decrypts the formula and calls `SpellFormula::GetTargetingType`.
- `CSpellBase::_non_component_target_type` is also preserved from DAT. ACE uses
it for authoritative server-side target validation and redirect behavior.
Static disassembly of the matching retail executable confirms the native
vtable offset hidden by the pseudo-C. `GetTargetingType` starts with component
slot 4, scans slots 5 through 7 until the formula ends, and maps the final
populated component. It does not search for the first component with a known
target mapping.
```text
GetTargetingType(components[8]):
if component slot 4 is absent: return 0
last = 4
while last < 7 and components[last + 1] != 0:
last++
return GetTargetTypeFromComponentID(components[last])
GetTargetTypeFromComponentID(component):
0x31..0x38, 0x3C..0x3E, 0xBE => 0x00000010
0x39 => 0x00088B8F
0x3B => 0x10010000
otherwise => 0
```
The old CSV's exported target mask disagrees with this retail formula result
for 378 of its 3,956 shared spell IDs. Production therefore never overlays CSV
target values onto the DAT catalog.

View file

@ -1,29 +1,29 @@
# Game-data tables # Game-data tables
Reference data tables loaded by acdream at runtime or used as research Reference data tables used as research input and focused test fixtures.
input. Tracked in git so subagents + post-compaction sessions inherit Tracked in git so later sessions inherit them without round-tripping through
them without round-tripping through `refs/`. external repositories. Production spell metadata comes directly from the
installed retail DATs through `DatCollection`.
## Contents ## Contents
| File | Loader | Notes | | 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`. | | `spells.csv` | Legacy `SpellTable.LoadFromCsv` test/tool input only; not copied to production output. | Historical 3,956-row server-side export. The installed end-of-retail SpellTable contains 6,266 records, so this file must never be treated as a complete client catalog. |
## Format note — `spells.csv` ## Format note — `spells.csv`
RFC 4180 with header row. The `Description` column is quoted with embedded RFC 4180 with header row. The `Description` column is quoted with embedded
commas; everything else is plain. See `SpellTable` parser for the canonical commas; everything else is plain. See `SpellTable` for the legacy parser.
column ordering.
## Provenance ## Provenance
`spells.csv` came from a server-side database export of the retail `spells.csv` came from a server-side database export of the retail spell table,
spell table (likely a community-shared dump from circa 2017-2019 with likely a community-shared dump from circa 20172019. Spell IDs match the wire
~99% field coverage). Spell IDs match the wire opcodes used by ACE. values used by ACE, but the file omits 2,310 records present in the installed
The `Family` column is the AC retail spell-family enum used for buff end-of-retail DAT and some exported target masks differ from the retail
stacking. client's formula-derived result.
If the file ever needs updating from a fresher source, replace Do not update production behavior by replacing this file. The canonical
`spells.csv` in this directory; the `<None Update PreserveNewest>` runtime projection and its retail references are documented in
copy will sync it to bin output on next build. `docs/research/2026-07-15-retail-spell-catalog-pseudocode.md`.

View file

@ -46,11 +46,6 @@
<None Update="Rendering\Shaders\*.*"> <None Update="Rendering\Shaders\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </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>
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship <!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
next to the binary so MarkupDocument.Build can load them at runtime. --> next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml"> <None Include="UI\assets\**\*.xml">

View file

@ -803,12 +803,9 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Social.SquelchState Squelch = new(); public readonly AcDream.Core.Social.SquelchState Squelch = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; } public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
= System.Array.Empty<(uint Id, uint Amount)>(); = System.Array.Empty<(uint Id, uint Amount)>();
// Issue #11 — load static spell metadata from data/spells.csv at startup. // Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// Provides Family for buff stacking (issue #6) + names + icons + tooltips // installs that immutable table once DatCollection opens in OnLoad.
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/ public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!; public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ClientObjectTable Objects = new(); public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary> /// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
@ -838,6 +835,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Combat.CombatTargetController? _combatTargetController; private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController; private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.Spells.MagicRuntime? _magicRuntime; private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new(); private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport // Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625). // widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
@ -1175,7 +1173,7 @@ public sealed class GameWindow : IDisposable
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<AnimatedEntity>(() => _liveEntities); _animatedEntities = new LiveEntityAnimationRuntimeView<AnimatedEntity>(() => _liveEntities);
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities);
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's
// PhysicsEngine; the two helpers it also needs but that have callers // PhysicsEngine; the two helpers it also needs but that have callers
@ -1186,33 +1184,6 @@ public sealed class GameWindow : IDisposable
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
} }
/// <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() public void Run()
{ {
// A.5 T22.5: resolve quality preset BEFORE creating the window so // A.5 T22.5: resolve quality preset BEFORE creating the window so
@ -1506,6 +1477,9 @@ public sealed class GameWindow : IDisposable
_cameraController.ModeChanged += OnCameraModeChanged; _cameraController.ModeChanged += OnCameraModeChanged;
_dats = new DatCollection(_datDir, DatAccessType.Read); _dats = new DatCollection(_datDir, DatAccessType.Read);
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats); _animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats); _emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
@ -2092,8 +2066,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI; _uiHost.Root.UiLocked = _persistedGameplay.LockUI;
AcDream.App.Spells.MagicCatalog magicCatalog = AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
AcDream.App.Spells.MagicCatalog.Load(_dats!);
_itemInteractionController = new AcDream.App.UI.ItemInteractionController( _itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects, Objects,
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
@ -14648,6 +14621,7 @@ public sealed class GameWindow : IDisposable
_itemInteractionController?.Dispose(); _itemInteractionController?.Dispose();
_itemInteractionController = null; _itemInteractionController = null;
_magicRuntime = null; _magicRuntime = null;
_magicCatalog = null;
// Phase A.1: join the streamer worker thread before tearing down GL // Phase A.1: join the streamer worker thread before tearing down GL
// state. The worker may still be processing a load job that references // state. The worker may still be processing a load job that references

View file

@ -5,6 +5,7 @@ using AcDream.Core.Items;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.App.Spells; namespace AcDream.App.Spells;
@ -32,12 +33,14 @@ public sealed class MagicCatalog
private readonly IReadOnlyDictionary<uint, int> _spellLevels; private readonly IReadOnlyDictionary<uint, int> _spellLevels;
private MagicCatalog( private MagicCatalog(
CoreSpellTable spellTable,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components, IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions, IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
IReadOnlyDictionary<uint, uint> wcidByScid, IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool, IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
IReadOnlyDictionary<uint, int> spellLevels) IReadOnlyDictionary<uint, int> spellLevels)
{ {
SpellTable = spellTable;
Components = components; Components = components;
_formulaDefinitions = formulaDefinitions; _formulaDefinitions = formulaDefinitions;
_wcidByScid = wcidByScid; _wcidByScid = wcidByScid;
@ -45,6 +48,7 @@ public sealed class MagicCatalog
_spellLevels = spellLevels; _spellLevels = spellLevels;
} }
public CoreSpellTable SpellTable { get; }
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; } public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool IsComponentPack(uint weenieClassId) public bool IsComponentPack(uint weenieClassId)
@ -89,22 +93,24 @@ public sealed class MagicCatalog
} }
} }
var metadata = new List<SpellMetadata>();
var formulaDefinitions = new Dictionary<uint, SpellFormulaDefinition>(); var formulaDefinitions = new Dictionary<uint, SpellFormulaDefinition>();
var spellLevels = new Dictionary<uint, int>(); var spellLevels = new Dictionary<uint, int>();
DatReaderWriter.DBObjs.SpellTable? spellTable = DatReaderWriter.DBObjs.SpellTable spellTable =
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid); dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid)
if (spellTable is not null) ?? throw new InvalidOperationException(
{ $"Required retail SpellTable 0x{SpellTableDid:X8} is missing from portal.dat.");
foreach (var pair in spellTable.Spells) foreach (var pair in spellTable.Spells)
{ {
uint[] formula = pair.Value.Components.Take(8).ToArray(); SpellMetadata spell = RetailSpellMetadataProjector.Project(
pair.Key, pair.Value, componentTable);
metadata.Add(spell);
uint[] formula = spell.FormulaComponents.ToArray();
formulaDefinitions[pair.Key] = new SpellFormulaDefinition( formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
pair.Value.FormulaVersion, spell.FormulaVersion,
formula, formula,
(uint)pair.Value.School); (uint)spell.SchoolId);
spellLevels[pair.Key] = spellLevels[pair.Key] = spell.Generation;
RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
}
} }
var magicPackWcidBySchool = new Dictionary<uint, uint>(); var magicPackWcidBySchool = new Dictionary<uint, uint>();
@ -121,6 +127,7 @@ public sealed class MagicCatalog
} }
return new MagicCatalog( return new MagicCatalog(
CoreSpellTable.Create(metadata),
components, components,
formulaDefinitions, formulaDefinitions,
wcidByScid, wcidByScid,

View file

@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Spells;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using CoreMagicSchool = AcDream.Core.Spells.MagicSchool;
namespace AcDream.App.Spells;
/// <summary>
/// Projects portal.dat's complete retail <see cref="SpellBase"/> record into
/// the backend-neutral Core metadata consumed by spellbook and casting state.
/// </summary>
/// <remarks>
/// Schema/order: <c>CSpellBase::UnPack @ 0x00597290</c>.
/// School names: <c>CSpellBase::SchoolEnumToName @ 0x00597400</c>.
/// Target type: <c>SpellFormula::GetTargetingType @ 0x005BC910</c>.
/// </remarks>
internal static class RetailSpellMetadataProjector
{
public static SpellMetadata Project(
uint spellId,
SpellBase spell,
SpellComponentTable? componentTable)
{
ArgumentNullException.ThrowIfNull(spell);
uint[] formula = spell.Components.Take(8).ToArray();
uint flags = (uint)spell.Bitfield;
uint formulaTargetType = RetailSpellFormula.GetTargetingType(formula);
uint targetMask = formulaTargetType;
int level = RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
bool selfTargeted = (flags & (uint)SpellFlags.SelfTargeted) != 0u;
bool beneficial = (flags & (uint)SpellFlags.Beneficial) != 0u;
return new SpellMetadata(
spellId,
spell.Name.Value,
SchoolName(spell.School),
(uint)spell.Category,
spell.Icon,
BuildSpellWords(formula, componentTable),
checked((float)spell.Duration),
checked((int)spell.BaseMana),
(flags & (uint)SpellFlags.Reversed) != 0u,
(flags & (uint)SpellFlags.FellowshipSpell) != 0u,
spell.Description.Value,
unchecked((int)spell.DisplayOrder),
checked((int)spell.Power),
flags,
level,
(flags & (uint)SpellFlags.FastCast) != 0u,
!beneficial && !selfTargeted,
formulaTargetType == 0u,
Speed: 0f,
(uint)spell.CasterEffect,
(uint)spell.TargetEffect,
targetMask,
checked((int)spell.MetaSpellType))
{
SchoolId = ToCoreSchool(spell.School),
FormulaComponents = formula,
FormulaVersion = spell.FormulaVersion,
ComponentLoss = spell.ComponentLoss,
BaseRangeConstant = spell.BaseRangeConstant,
BaseRangeModifier = spell.BaseRangeMod,
SpellEconomyModifier = spell.SpellEconomyMod,
FizzleEffect = (uint)spell.FizzleEffect,
RecoveryInterval = spell.RecoveryInterval,
RecoveryAmount = spell.RecoveryAmount,
NonComponentTargetType = (uint)spell.NonComponentTargetType,
FormulaTargetType = formulaTargetType,
ManaModifier = spell.ManaMod,
DegradeModifier = spell.DegradeModifier,
DegradeLimit = spell.DegradeLimit,
PortalLifetime = spell.PortalLifetime,
};
}
private static string SchoolName(DatReaderWriter.Enums.MagicSchool school) => school switch
{
DatReaderWriter.Enums.MagicSchool.WarMagic => "War Magic",
DatReaderWriter.Enums.MagicSchool.LifeMagic => "Life Magic",
DatReaderWriter.Enums.MagicSchool.ItemEnchantment => "Item Enchantment",
DatReaderWriter.Enums.MagicSchool.CreatureEnchantment => "Creature Enchantment",
DatReaderWriter.Enums.MagicSchool.VoidMagic => "Void Magic",
_ => "None",
};
private static CoreMagicSchool ToCoreSchool(DatReaderWriter.Enums.MagicSchool school) => school switch
{
DatReaderWriter.Enums.MagicSchool.WarMagic => CoreMagicSchool.WarMagic,
DatReaderWriter.Enums.MagicSchool.LifeMagic => CoreMagicSchool.LifeMagic,
DatReaderWriter.Enums.MagicSchool.ItemEnchantment => CoreMagicSchool.ItemEnchantment,
DatReaderWriter.Enums.MagicSchool.CreatureEnchantment => CoreMagicSchool.CreatureEnchantment,
DatReaderWriter.Enums.MagicSchool.VoidMagic => CoreMagicSchool.VoidMagic,
_ => CoreMagicSchool.None,
};
/// <summary>
/// ACE's independently ported <c>SpellComponentsTable.GetSpellWords</c>:
/// Herb supplies word one; Powder + lower-cased Potion form word two.
/// </summary>
private static string BuildSpellWords(
IReadOnlyList<uint> formula,
SpellComponentTable? componentTable)
{
if (componentTable is null) return string.Empty;
string first = string.Empty;
string second = string.Empty;
string third = string.Empty;
foreach (uint componentId in formula)
{
if (!componentTable.Components.TryGetValue(componentId, out SpellComponentBase? component))
continue;
switch (component.Type)
{
case ComponentType.Herb:
first = component.Text.Value;
break;
case ComponentType.Powder:
second = component.Text.Value;
break;
case ComponentType.Potion:
third = component.Text.Value;
break;
}
}
string tail = second + third.ToLower(CultureInfo.InvariantCulture);
if (tail.Length != 0)
tail = char.ToUpperInvariant(tail[0]) + tail[1..];
return $"{first} {tail}".Trim();
}
}

View file

@ -419,13 +419,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private bool IsVisible(SpellMetadata metadata, uint filters) private bool IsVisible(SpellMetadata metadata, uint filters)
{ {
uint school = metadata.School switch uint school = metadata.SchoolId switch
{ {
"Creature Enchantment" => 0x0001u, MagicSchool.CreatureEnchantment => 0x0001u,
"Item Enchantment" => 0x0002u, MagicSchool.ItemEnchantment => 0x0002u,
"Life Magic" => 0x0004u, MagicSchool.LifeMagic => 0x0004u,
"War Magic" => 0x0008u, MagicSchool.WarMagic => 0x0008u,
"Void Magic" => 0x2000u, MagicSchool.VoidMagic => 0x2000u,
_ => 0u, _ => 0u,
}; };
int spellLevel = _spellLevel(metadata.SpellId); int spellLevel = _spellLevel(metadata.SpellId);

View file

@ -44,6 +44,37 @@ public static class RetailSpellFormula
}; };
} }
/// <summary>
/// Retail <c>SpellFormula::GetTargetingType @ 0x005BC910</c> followed by
/// <c>SpellComponentTable::GetTargetTypeFromComponentID @ 0x005BBF50</c>.
/// The native <c>SpellFormula</c> has a vtable before its eight components.
/// Retail starts at native offset index 5 (component slot 4), scans slots
/// 5 through 7 for the end of the formula, then maps the final populated
/// component. This deliberately does not search for the first recognized
/// target component.
/// </summary>
public static uint GetTargetingType(IReadOnlyList<uint> components)
{
ArgumentNullException.ThrowIfNull(components);
if (components.Count <= 4)
return 0u;
int last = 4;
while (last < 7 && last + 1 < components.Count && components[last + 1] != 0u)
last++;
return GetTargetTypeFromComponentId(components[last]);
}
/// <summary>Retail target-component ID to ItemType mask mapping.</summary>
public static uint GetTargetTypeFromComponentId(uint componentId) => componentId switch
{
0x31u or 0x32u or 0x33u or 0x34u or 0x35u or 0x36u
or 0x37u or 0x38u or 0x3Cu or 0x3Du or 0x3Eu or 0xBEu => 0x10u,
0x39u => 0x00088B8Fu,
0x3Bu => 0x10010000u,
_ => 0u,
};
/// <summary> /// <summary>
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050). /// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
/// Foci and the infused-magic augmentations replace the ordinary taper /// Foci and the infused-magic augmentations replace the ordinary taper

View file

@ -1,35 +1,17 @@
using System.Collections.Generic;
namespace AcDream.Core.Spells; namespace AcDream.Core.Spells;
/// <summary> /// <summary>
/// Per-spell static metadata loaded once at startup from /// Immutable client metadata for one retail spell. Production records are
/// <c>docs/research/data/spells.csv</c> via <see cref="SpellTable"/>. /// projected from portal.dat's SpellTable (0x0E00000E); the legacy CSV reader
/// One record per known spell id (3,956 entries in the retail dump). /// remains available only for focused fixtures and historical tooling.
///
/// <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> /// </summary>
/// <remarks>
/// The primary constructor preserves the former metadata surface while the
/// init-only properties retain the rest of retail's <c>CSpellBase</c> record.
/// Retail schema: <c>CSpellBase::UnPack @ 0x00597290</c>.
/// </remarks>
public sealed record SpellMetadata( public sealed record SpellMetadata(
uint SpellId, uint SpellId,
string Name, string Name,
@ -55,7 +37,24 @@ public sealed record SpellMetadata(
uint TargetMask, uint TargetMask,
int SpellType) int SpellType)
{ {
public bool IsSelfTargeted => (Flags & 0x00000008u) != 0; public MagicSchool SchoolId { get; init; }
public bool IsBeneficial => (Flags & 0x00000004u) != 0; public IReadOnlyList<uint> FormulaComponents { get; init; } = [];
public bool IsProjectile => (Flags & 0x00000100u) != 0; public uint FormulaVersion { get; init; }
public float ComponentLoss { get; init; }
public float BaseRangeConstant { get; init; }
public float BaseRangeModifier { get; init; }
public float SpellEconomyModifier { get; init; }
public uint FizzleEffect { get; init; }
public double RecoveryInterval { get; init; }
public float RecoveryAmount { get; init; }
public uint NonComponentTargetType { get; init; }
public uint FormulaTargetType { get; init; }
public uint ManaModifier { get; init; }
public float DegradeModifier { get; init; }
public float DegradeLimit { get; init; }
public double PortalLifetime { get; init; }
public bool IsSelfTargeted => (Flags & (uint)SpellFlags.SelfTargeted) != 0;
public bool IsBeneficial => (Flags & (uint)SpellFlags.Beneficial) != 0;
public bool IsProjectile => (Flags & (uint)SpellFlags.Projectile) != 0;
} }

View file

@ -6,10 +6,9 @@ using System.IO;
namespace AcDream.Core.Spells; namespace AcDream.Core.Spells;
/// <summary> /// <summary>
/// Loads + queries <see cref="SpellMetadata"/> from a CSV at startup. /// Immutable lookup of retail spell metadata. Production builds create this
/// Source: <c>docs/research/data/spells.csv</c> (RFC 4180-ish, 35 /// table from portal.dat through the App-layer <c>MagicCatalog</c>. The CSV
/// columns, 3,956 rows). Loaded once, used by panels + by /// parser below remains for historical tooling and small synthetic tests.
/// <c>EnchantmentMath</c> for buff stacking aggregation.
/// ///
/// <para> /// <para>
/// Hand-rolled CSV parser — the only complication is the /// Hand-rolled CSV parser — the only complication is the
@ -35,6 +34,21 @@ public sealed class SpellTable
_byId = byId; _byId = byId;
} }
/// <summary>Create a canonical immutable lookup from projected metadata.</summary>
public static SpellTable Create(IEnumerable<SpellMetadata> metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
var byId = new Dictionary<uint, SpellMetadata>();
foreach (SpellMetadata spell in metadata)
{
ArgumentNullException.ThrowIfNull(spell);
if (!byId.TryAdd(spell.SpellId, spell))
throw new ArgumentException(
$"Duplicate spell id 0x{spell.SpellId:X8}.", nameof(metadata));
}
return new SpellTable(byId);
}
/// <summary>Number of spells loaded.</summary> /// <summary>Number of spells loaded.</summary>
public int Count => _byId.Count; public int Count => _byId.Count;
@ -154,7 +168,10 @@ public sealed class SpellTable
spellId, name, school, family, iconId, words, duration, spellId, name, school, family, iconId, words, duration,
mana, isDebuff, isFellow, description, sortKey, difficulty, mana, isDebuff, isFellow, description, sortKey, difficulty,
flags, generation, fastWindup, offensive, untargeted, speed, flags, generation, fastWindup, offensive, untargeted, speed,
casterEffect, targetEffect, targetMask, spellType); casterEffect, targetEffect, targetMask, spellType)
{
SchoolId = ParseSchool(school),
};
} }
return new SpellTable(byId); return new SpellTable(byId);
@ -187,6 +204,16 @@ public sealed class SpellTable
return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase); return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase);
} }
private static MagicSchool ParseSchool(string school) => school switch
{
"War Magic" => MagicSchool.WarMagic,
"Life Magic" => MagicSchool.LifeMagic,
"Item Enchantment" => MagicSchool.ItemEnchantment,
"Creature Enchantment" => MagicSchool.CreatureEnchantment,
"Void Magic" => MagicSchool.VoidMagic,
_ => MagicSchool.None,
};
/// <summary> /// <summary>
/// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted /// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted
/// fields with embedded commas (the Description column). Embedded /// fields with embedded commas (the Description column). Embedded

View file

@ -23,7 +23,8 @@ public sealed class Spellbook
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8) private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
.Select(_ => new List<uint>()).ToArray(); .Select(_ => new List<uint>()).ToArray();
private readonly Dictionary<uint, uint> _desiredComponents = new(); private readonly Dictionary<uint, uint> _desiredComponents = new();
private readonly SpellTable _table; private SpellTable _table;
private bool _metadataInstalled;
private uint _spellbookFilters = 0x3FFFu; private uint _spellbookFilters = 0x3FFFu;
/// <summary> /// <summary>
@ -37,6 +38,7 @@ public sealed class Spellbook
public Spellbook(SpellTable? table = null) public Spellbook(SpellTable? table = null)
{ {
_table = table ?? SpellTable.Empty; _table = table ?? SpellTable.Empty;
_metadataInstalled = table is not null;
} }
/// <summary> /// <summary>
@ -51,6 +53,28 @@ public sealed class Spellbook
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary> /// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
public SpellTable Metadata => _table; public SpellTable Metadata => _table;
/// <summary>
/// Installs the production metadata table after DatCollection opens.
/// GameWindow constructs client state before Silk's OnLoad callback, while
/// portal.dat becomes available inside OnLoad. The table may be installed
/// once; replacing a populated catalog would invalidate active stacking and
/// presentation decisions.
/// </summary>
public void InstallMetadata(SpellTable table)
{
ArgumentNullException.ThrowIfNull(table);
if (_metadataInstalled)
{
if (ReferenceEquals(_table, table)) return;
throw new InvalidOperationException("Spell metadata is already installed.");
}
_table = table;
_metadataInstalled = true;
if (_learnedSpells.Count != 0) NotifySpellbookChanged();
if (_activeById.Count != 0) NotifyEnchantmentsChanged();
}
/// <summary> /// <summary>
/// Issue #6 — combined buff modifier for a vital stat. Aggregates /// Issue #6 — combined buff modifier for a vital stat. Aggregates
/// over <see cref="ActiveEnchantments"/> through /// over <see cref="ActiveEnchantments"/> through

View file

@ -0,0 +1,158 @@
using AcDream.App.Spells;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using DatMagicSchool = DatReaderWriter.Enums.MagicSchool;
using DatSpellCategory = DatReaderWriter.Enums.SpellCategory;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.App.Tests.Spells;
public sealed class RetailSpellMetadataProjectorTests
{
[Fact]
public void Project_PreservesRetailFieldsAndDerivesPresentationMetadata()
{
var components = new SpellComponentTable();
components.Components.Add(10u, Component(ComponentType.Herb, "Malar"));
components.Components.Add(11u, Component(ComponentType.Powder, "Caza"));
components.Components.Add(12u, Component(ComponentType.Potion, "El"));
var source = new SpellBase
{
Name = "Incantation of Test",
Description = "A projected retail spell.",
Components = [0xC1u, 10u, 11u, 12u, 63u, 0x31u],
School = DatMagicSchool.WarMagic,
Icon = 0x06001234u,
Category = (DatSpellCategory)77u,
Bitfield = SpellIndex.Resistable | SpellIndex.Projectile | SpellIndex.FastCast,
BaseMana = 50u,
BaseRangeConstant = 40f,
BaseRangeMod = 0.25f,
Power = 400u,
SpellEconomyMod = -1f,
FormulaVersion = 3u,
ComponentLoss = 0.2f,
MetaSpellType = SpellType.Projectile,
MetaSpellId = 5000u,
CasterEffect = (PlayScript)6u,
TargetEffect = (PlayScript)7u,
FizzleEffect = (PlayScript)8u,
RecoveryInterval = 1.5,
RecoveryAmount = 2.5f,
DisplayOrder = 1234u,
NonComponentTargetType = ItemType.Creature,
ManaMod = 14u,
};
SpellMetadata actual = RetailSpellMetadataProjector.Project(
5000u, source, components);
Assert.Equal("Incantation of Test", actual.Name);
Assert.Equal("A projected retail spell.", actual.Description);
Assert.Equal("War Magic", actual.School);
Assert.Equal(AcDream.Core.Spells.MagicSchool.WarMagic, actual.SchoolId);
Assert.Equal(77u, actual.Family);
Assert.Equal(0x06001234u, actual.IconId);
Assert.Equal("Malar Cazael", actual.SpellWords);
Assert.Equal(8, actual.Generation);
Assert.Equal(0x10u, actual.TargetMask);
Assert.False(actual.IsUntargeted);
Assert.True(actual.IsProjectile);
Assert.True(actual.IsFastWindup);
Assert.True(actual.IsOffensive);
Assert.Equal(source.Components, actual.FormulaComponents);
Assert.Equal(3u, actual.FormulaVersion);
Assert.Equal(14u, actual.ManaModifier);
Assert.Equal((uint)ItemType.Creature, actual.NonComponentTargetType);
Assert.Equal(0x10u, actual.FormulaTargetType);
}
[Fact]
public void Load_EndOfRetailDat_ResolvesEveryLateLearnedSpellMissingFromCsv()
{
string? datDir = ResolveDatDir();
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
Assert.Equal(6266, catalog.SpellTable.Count);
Assert.Equal(3956, legacy.Count);
uint[] lateSpellIds = catalog.SpellTable.SpellIds
.Where(id => !legacy.TryGet(id, out _))
.ToArray();
Assert.Equal(2310, lateSpellIds.Length);
var spellbook = new Spellbook(catalog.SpellTable);
foreach (uint spellId in lateSpellIds)
{
spellbook.OnSpellLearned(spellId);
Assert.True(spellbook.TryGetMetadata(spellId, out SpellMetadata metadata));
Assert.Equal(spellId, metadata.SpellId);
}
}
[Fact]
public void Load_EndOfRetailDat_UsesRetailFormulaTargetsInsteadOfLegacyExportMasks()
{
string? datDir = ResolveDatDir();
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
var mismatches = new List<string>();
foreach (uint spellId in legacy.SpellIds)
{
if (!legacy.TryGet(spellId, out SpellMetadata oldSpell)
|| !catalog.SpellTable.TryGet(spellId, out SpellMetadata datSpell))
continue;
if (oldSpell.TargetMask != datSpell.TargetMask)
mismatches.Add(
$"{spellId}:{oldSpell.Name} csv=0x{oldSpell.TargetMask:X8} " +
$"dat=0x{datSpell.TargetMask:X8} formulaTarget=0x{datSpell.FormulaTargetType:X8} " +
$"formula=[{string.Join(',', datSpell.FormulaComponents)}]");
}
Assert.Equal(378, mismatches.Count);
Assert.Contains(mismatches, value => value.StartsWith("35:Blood Drinker I "));
Assert.Contains(mismatches, value => value.Contains("csv=0x00000101 dat=0x00000010"));
}
private static SpellComponentBase Component(ComponentType type, string text) => new()
{
Type = type,
Text = text,
};
private static string? ResolveDatDir()
{
string? fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string fallback = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(fallback) ? fallback : null;
}
private static string? ResolveRepoFile(params string[] parts)
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
string candidate = Path.Combine([current.FullName, .. parts]);
if (File.Exists(candidate)) return candidate;
current = current.Parent;
}
return null;
}
}

View file

@ -16,6 +16,34 @@ public sealed class RetailSpellFormulaTests
=> Assert.Equal(expected, => Assert.Equal(expected,
RetailSpellFormula.InqSpellLevelByRoughHeuristic([component])); RetailSpellFormula.InqSpellLevelByRoughHeuristic([component]));
[Theory]
[InlineData(0x31u, 0x10u)]
[InlineData(0x39u, 0x00088B8Fu)]
[InlineData(0x3Bu, 0x10010000u)]
[InlineData(0xBEu, 0x10u)]
[InlineData(0x30u, 0u)]
public void TargetComponent_UsesRetailItemTypeMask(uint component, uint expected)
=> Assert.Equal(expected,
RetailSpellFormula.GetTargetTypeFromComponentId(component));
[Fact]
public void TargetingType_UsesTargetFormulaSlotsOnly()
{
Assert.Equal(0x10u,
RetailSpellFormula.GetTargetingType([1u, 2u, 3u, 4u, 5u, 0x31u]));
Assert.Equal(0u,
RetailSpellFormula.GetTargetingType([1u, 2u, 3u, 0x31u]));
}
[Fact]
public void TargetingType_MapsFinalPopulatedFormulaComponent()
{
Assert.Equal(0x10u,
RetailSpellFormula.GetTargetingType([1u, 2u, 3u, 4u, 0x39u, 0x31u]));
Assert.Equal(0x00088B8Fu,
RetailSpellFormula.GetTargetingType([1u, 2u, 3u, 4u, 0x39u, 0u, 0x31u]));
}
[Fact] [Fact]
public void CustomizeForAccount_IsDeterministic_AndOnlyChangesTapers() public void CustomizeForAccount_IsDeterministic_AndOnlyChangesTapers()
{ {

View file

@ -27,6 +27,17 @@ public sealed class SpellTableTests
Assert.False(SpellTable.Empty.TryGet(1u, out _)); Assert.False(SpellTable.Empty.TryGet(1u, out _));
} }
[Fact]
public void Create_RejectsDuplicateSpellIds()
{
SpellMetadata spell = new(
1u, "One", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0);
Assert.Throws<ArgumentException>(() => SpellTable.Create([spell, spell]));
}
[Fact] [Fact]
public void LoadFromReader_HeaderOnly_EmptyTable() public void LoadFromReader_HeaderOnly_EmptyTable()
{ {

View file

@ -5,6 +5,25 @@ namespace AcDream.Core.Tests.Spells;
public sealed class SpellbookTests public sealed class SpellbookTests
{ {
[Fact]
public void InstallMetadata_InstallsOnceAndRefreshesExistingLearnedState()
{
var book = new Spellbook();
book.OnSpellLearned(1u);
int changes = 0;
book.SpellbookChanged += () => changes++;
SpellTable table = SpellTable.Create([TestSpell(1u)]);
book.InstallMetadata(table);
Assert.Same(table, book.Metadata);
Assert.True(book.TryGetMetadata(1u, out _));
Assert.Equal(1, changes);
book.InstallMetadata(table);
Assert.Throws<InvalidOperationException>(
() => book.InstallMetadata(SpellTable.Empty));
}
[Fact] [Fact]
public void OnSpellLearned_FiresEvent_Idempotent() public void OnSpellLearned_FiresEvent_Idempotent()
{ {
@ -187,4 +206,9 @@ public sealed class SpellbookTests
StartTime: 10d, StartTime: 10d,
SpellCategory: category, SpellCategory: category,
PowerLevel: 7u); PowerLevel: 7u);
private static SpellMetadata TestSpell(uint spellId) => new(
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0);
} }