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:
parent
09612f9981
commit
0eab7497c1
19 changed files with 633 additions and 114 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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 1–2 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 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
||||||
|
|
|
||||||
|
|
@ -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 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **Missile/portal VFX campaign Steps 0–5 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 0–5 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 1–9 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 1–9 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
91
docs/research/2026-07-15-retail-spell-catalog-pseudocode.md
Normal file
91
docs/research/2026-07-15-retail-spell-catalog-pseudocode.md
Normal 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.
|
||||||
|
|
@ -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 2017–2019. 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`.
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
SpellMetadata spell = RetailSpellMetadataProjector.Project(
|
||||||
{
|
pair.Key, pair.Value, componentTable);
|
||||||
uint[] formula = pair.Value.Components.Take(8).ToArray();
|
metadata.Add(spell);
|
||||||
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
|
uint[] formula = spell.FormulaComponents.ToArray();
|
||||||
pair.Value.FormulaVersion,
|
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
|
||||||
formula,
|
spell.FormulaVersion,
|
||||||
(uint)pair.Value.School);
|
formula,
|
||||||
spellLevels[pair.Key] =
|
(uint)spell.SchoolId);
|
||||||
RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
|
spellLevels[pair.Key] = spell.Generation;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
140
src/AcDream.App/Spells/RetailSpellMetadataProjector.cs
Normal file
140
src/AcDream.App/Spells/RetailSpellMetadataProjector.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue