diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 70c04b0a..ca2ed2b7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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` **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 `` 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` diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b1358c2b..a90f708e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.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-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-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-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 | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 50a686f6..d66ede28 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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 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, equipped caster endowment, scoped retail keys, spellbook/component book, 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. - **✓ 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. -- **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. - **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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 369622f3..78f821d1 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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 listbox templates from LayoutDesc `0x21000033`, preserving localized headings, 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:** - **F.4** — Spell cast state machine (buffs + recalls first, projectile diff --git a/docs/research/2026-07-15-retail-spell-catalog-pseudocode.md b/docs/research/2026-07-15-retail-spell-catalog-pseudocode.md new file mode 100644 index 00000000..65f3266f --- /dev/null +++ b/docs/research/2026-07-15-retail-spell-catalog-pseudocode.md @@ -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. diff --git a/docs/research/data/README.md b/docs/research/data/README.md index 5a324775..dae25778 100644 --- a/docs/research/data/README.md +++ b/docs/research/data/README.md @@ -1,29 +1,29 @@ # Game-data tables -Reference data tables loaded by acdream at runtime or used as research -input. Tracked in git so subagents + post-compaction sessions inherit -them without round-tripping through `refs/`. +Reference data tables used as research input and focused test fixtures. +Tracked in git so later sessions inherit them without round-tripping through +external repositories. Production spell metadata comes directly from the +installed retail DATs through `DatCollection`. ## Contents | File | Loader | Notes | |------|--------|-------| -| `spells.csv` | `src/AcDream.Core/Spells/SpellTable.cs` (load via `SpellTable.LoadFromCsv` at `GameWindow.OnLoad`). Copied to `bin//net10.0/data/spells.csv` via `` 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` RFC 4180 with header row. The `Description` column is quoted with embedded -commas; everything else is plain. See `SpellTable` parser for the canonical -column ordering. +commas; everything else is plain. See `SpellTable` for the legacy parser. ## Provenance -`spells.csv` came from a server-side database export of the retail -spell table (likely a community-shared dump from circa 2017-2019 with -~99% field coverage). Spell IDs match the wire opcodes used by ACE. -The `Family` column is the AC retail spell-family enum used for buff -stacking. +`spells.csv` came from a server-side database export of the retail spell table, +likely a community-shared dump from circa 2017–2019. Spell IDs match the wire +values used by ACE, but the file omits 2,310 records present in the installed +end-of-retail DAT and some exported target masks differ from the retail +client's formula-derived result. -If the file ever needs updating from a fresher source, replace -`spells.csv` in this directory; the `` -copy will sync it to bin output on next build. +Do not update production behavior by replacing this file. The canonical +runtime projection and its retail references are documented in +`docs/research/2026-07-15-retail-spell-catalog-pseudocode.md`. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index c07ae6c3..aef6a729 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -46,11 +46,6 @@ PreserveNewest - - - PreserveNewest - diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index eec3235c..1d3d0a48 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -803,12 +803,9 @@ public sealed class GameWindow : IDisposable public readonly AcDream.Core.Social.SquelchState Squelch = new(); public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; } = System.Array.Empty<(uint Id, uint Amount)>(); - // Issue #11 — load static spell metadata from data/spells.csv at startup. - // Provides Family for buff stacking (issue #6) + names + icons + tooltips - // for the future Spellbook panel. The CSV is copied to bin//net10.0/data/ - // by the csproj entry. Loads silently to - // SpellTable.Empty if the file is missing (e.g. tooling contexts). - public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable(); + // Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog + // installs that immutable table once DatCollection opens in OnLoad. + public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata; public readonly AcDream.Core.Spells.Spellbook SpellBook = null!; public readonly AcDream.Core.Items.ClientObjectTable Objects = new(); /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). @@ -838,6 +835,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Combat.CombatTargetController? _combatTargetController; private AcDream.App.UI.ItemInteractionController? _itemInteractionController; private AcDream.App.Spells.MagicRuntime? _magicRuntime; + private AcDream.App.Spells.MagicCatalog? _magicCatalog; 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 // 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; _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); - SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); + SpellBook = new AcDream.Core.Spells.Spellbook(); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's // PhysicsEngine; the two helpers it also needs but that have callers @@ -1186,33 +1184,6 @@ public sealed class GameWindow : IDisposable _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); } - /// - /// Issue #11 — load data/spells.csv from the bin output (copied - /// there by the csproj). Returns SpellTable.Empty + logs a - /// warning if the file is missing (e.g. when GameWindow is instantiated - /// from tooling contexts that don't include the data folder). - /// - 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() { // A.5 T22.5: resolve quality preset BEFORE creating the window so @@ -1506,6 +1477,9 @@ public sealed class GameWindow : IDisposable _cameraController.ModeChanged += OnCameraModeChanged; _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); _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); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost.Root.UiLocked = _persistedGameplay.LockUI; - AcDream.App.Spells.MagicCatalog magicCatalog = - AcDream.App.Spells.MagicCatalog.Load(_dats!); + AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; _itemInteractionController = new AcDream.App.UI.ItemInteractionController( Objects, playerGuid: () => _playerServerGuid, @@ -14648,6 +14621,7 @@ public sealed class GameWindow : IDisposable _itemInteractionController?.Dispose(); _itemInteractionController = null; _magicRuntime = null; + _magicCatalog = null; // Phase A.1: join the streamer worker thread before tearing down GL // state. The worker may still be processing a load job that references diff --git a/src/AcDream.App/Spells/MagicRuntime.cs b/src/AcDream.App/Spells/MagicRuntime.cs index d47c1da6..e92dada3 100644 --- a/src/AcDream.App/Spells/MagicRuntime.cs +++ b/src/AcDream.App/Spells/MagicRuntime.cs @@ -5,6 +5,7 @@ using AcDream.Core.Items; using AcDream.Core.Spells; using DatReaderWriter; using DatReaderWriter.DBObjs; +using CoreSpellTable = AcDream.Core.Spells.SpellTable; namespace AcDream.App.Spells; @@ -32,12 +33,14 @@ public sealed class MagicCatalog private readonly IReadOnlyDictionary _spellLevels; private MagicCatalog( + CoreSpellTable spellTable, IReadOnlyDictionary components, IReadOnlyDictionary formulaDefinitions, IReadOnlyDictionary wcidByScid, IReadOnlyDictionary magicPackWcidBySchool, IReadOnlyDictionary spellLevels) { + SpellTable = spellTable; Components = components; _formulaDefinitions = formulaDefinitions; _wcidByScid = wcidByScid; @@ -45,6 +48,7 @@ public sealed class MagicCatalog _spellLevels = spellLevels; } + public CoreSpellTable SpellTable { get; } public IReadOnlyDictionary Components { get; } public bool IsComponentPack(uint weenieClassId) @@ -89,22 +93,24 @@ public sealed class MagicCatalog } } + var metadata = new List(); var formulaDefinitions = new Dictionary(); var spellLevels = new Dictionary(); - DatReaderWriter.DBObjs.SpellTable? spellTable = - dats.Get(SpellTableDid); - if (spellTable is not null) + DatReaderWriter.DBObjs.SpellTable spellTable = + dats.Get(SpellTableDid) + ?? 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(); - formulaDefinitions[pair.Key] = new SpellFormulaDefinition( - pair.Value.FormulaVersion, - formula, - (uint)pair.Value.School); - spellLevels[pair.Key] = - RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula); - } + SpellMetadata spell = RetailSpellMetadataProjector.Project( + pair.Key, pair.Value, componentTable); + metadata.Add(spell); + uint[] formula = spell.FormulaComponents.ToArray(); + formulaDefinitions[pair.Key] = new SpellFormulaDefinition( + spell.FormulaVersion, + formula, + (uint)spell.SchoolId); + spellLevels[pair.Key] = spell.Generation; } var magicPackWcidBySchool = new Dictionary(); @@ -121,6 +127,7 @@ public sealed class MagicCatalog } return new MagicCatalog( + CoreSpellTable.Create(metadata), components, formulaDefinitions, wcidByScid, diff --git a/src/AcDream.App/Spells/RetailSpellMetadataProjector.cs b/src/AcDream.App/Spells/RetailSpellMetadataProjector.cs new file mode 100644 index 00000000..3e342460 --- /dev/null +++ b/src/AcDream.App/Spells/RetailSpellMetadataProjector.cs @@ -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; + +/// +/// Projects portal.dat's complete retail record into +/// the backend-neutral Core metadata consumed by spellbook and casting state. +/// +/// +/// Schema/order: CSpellBase::UnPack @ 0x00597290. +/// School names: CSpellBase::SchoolEnumToName @ 0x00597400. +/// Target type: SpellFormula::GetTargetingType @ 0x005BC910. +/// +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, + }; + + /// + /// ACE's independently ported SpellComponentsTable.GetSpellWords: + /// Herb supplies word one; Powder + lower-cased Potion form word two. + /// + private static string BuildSpellWords( + IReadOnlyList 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(); + } +} diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs index 1dbfc5d8..b5470b2a 100644 --- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs +++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs @@ -419,13 +419,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController private bool IsVisible(SpellMetadata metadata, uint filters) { - uint school = metadata.School switch + uint school = metadata.SchoolId switch { - "Creature Enchantment" => 0x0001u, - "Item Enchantment" => 0x0002u, - "Life Magic" => 0x0004u, - "War Magic" => 0x0008u, - "Void Magic" => 0x2000u, + MagicSchool.CreatureEnchantment => 0x0001u, + MagicSchool.ItemEnchantment => 0x0002u, + MagicSchool.LifeMagic => 0x0004u, + MagicSchool.WarMagic => 0x0008u, + MagicSchool.VoidMagic => 0x2000u, _ => 0u, }; int spellLevel = _spellLevel(metadata.SpellId); diff --git a/src/AcDream.Core/Spells/RetailSpellFormula.cs b/src/AcDream.Core/Spells/RetailSpellFormula.cs index 138c8518..6cbb24c5 100644 --- a/src/AcDream.Core/Spells/RetailSpellFormula.cs +++ b/src/AcDream.Core/Spells/RetailSpellFormula.cs @@ -44,6 +44,37 @@ public static class RetailSpellFormula }; } + /// + /// Retail SpellFormula::GetTargetingType @ 0x005BC910 followed by + /// SpellComponentTable::GetTargetTypeFromComponentID @ 0x005BBF50. + /// The native SpellFormula 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. + /// + public static uint GetTargetingType(IReadOnlyList 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]); + } + + /// Retail target-component ID to ItemType mask mapping. + 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, + }; + /// /// Retail CSpellBase::InqScarabOnlyFormula (0x00597050). /// Foci and the infused-magic augmentations replace the ordinary taper diff --git a/src/AcDream.Core/Spells/SpellMetadata.cs b/src/AcDream.Core/Spells/SpellMetadata.cs index ca238876..b8887578 100644 --- a/src/AcDream.Core/Spells/SpellMetadata.cs +++ b/src/AcDream.Core/Spells/SpellMetadata.cs @@ -1,35 +1,17 @@ +using System.Collections.Generic; + namespace AcDream.Core.Spells; /// -/// Per-spell static metadata loaded once at startup from -/// docs/research/data/spells.csv via . -/// One record per known spell id (3,956 entries in the retail dump). -/// -/// -/// Used for: -/// -/// -/// Buff / debuff bar labels (, -/// , ). -/// Stacking aggregation ( — only one -/// enchantment per family-bucket is active; this is what -/// EnchantmentMath uses to filter out superseded -/// buffs in LocalPlayerState.GetMaxApprox). -/// Spell tooltips (, -/// , , -/// ). -/// Cast-bar audio + animation cues -/// ( drives the chant). -/// -/// -/// -/// 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. -/// +/// Immutable client metadata for one retail spell. Production records are +/// projected from portal.dat's SpellTable (0x0E00000E); the legacy CSV reader +/// remains available only for focused fixtures and historical tooling. /// +/// +/// The primary constructor preserves the former metadata surface while the +/// init-only properties retain the rest of retail's CSpellBase record. +/// Retail schema: CSpellBase::UnPack @ 0x00597290. +/// public sealed record SpellMetadata( uint SpellId, string Name, @@ -55,7 +37,24 @@ public sealed record SpellMetadata( uint TargetMask, int SpellType) { - public bool IsSelfTargeted => (Flags & 0x00000008u) != 0; - public bool IsBeneficial => (Flags & 0x00000004u) != 0; - public bool IsProjectile => (Flags & 0x00000100u) != 0; + public MagicSchool SchoolId { get; init; } + public IReadOnlyList FormulaComponents { get; init; } = []; + 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; } diff --git a/src/AcDream.Core/Spells/SpellTable.cs b/src/AcDream.Core/Spells/SpellTable.cs index 638984ce..d4a497f9 100644 --- a/src/AcDream.Core/Spells/SpellTable.cs +++ b/src/AcDream.Core/Spells/SpellTable.cs @@ -6,10 +6,9 @@ using System.IO; namespace AcDream.Core.Spells; /// -/// Loads + queries from a CSV at startup. -/// Source: docs/research/data/spells.csv (RFC 4180-ish, 35 -/// columns, 3,956 rows). Loaded once, used by panels + by -/// EnchantmentMath for buff stacking aggregation. +/// Immutable lookup of retail spell metadata. Production builds create this +/// table from portal.dat through the App-layer MagicCatalog. The CSV +/// parser below remains for historical tooling and small synthetic tests. /// /// /// Hand-rolled CSV parser — the only complication is the @@ -35,6 +34,21 @@ public sealed class SpellTable _byId = byId; } + /// Create a canonical immutable lookup from projected metadata. + public static SpellTable Create(IEnumerable metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + var byId = new Dictionary(); + 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); + } + /// Number of spells loaded. public int Count => _byId.Count; @@ -154,7 +168,10 @@ public sealed class SpellTable spellId, name, school, family, iconId, words, duration, mana, isDebuff, isFellow, description, sortKey, difficulty, flags, generation, fastWindup, offensive, untargeted, speed, - casterEffect, targetEffect, targetMask, spellType); + casterEffect, targetEffect, targetMask, spellType) + { + SchoolId = ParseSchool(school), + }; } return new SpellTable(byId); @@ -187,6 +204,16 @@ public sealed class SpellTable 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, + }; + /// /// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted /// fields with embedded commas (the Description column). Embedded diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index b2e57c29..6600126c 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -23,7 +23,8 @@ public sealed class Spellbook private readonly List[] _favoriteSpells = Enumerable.Range(0, 8) .Select(_ => new List()).ToArray(); private readonly Dictionary _desiredComponents = new(); - private readonly SpellTable _table; + private SpellTable _table; + private bool _metadataInstalled; private uint _spellbookFilters = 0x3FFFu; /// @@ -37,6 +38,7 @@ public sealed class Spellbook public Spellbook(SpellTable? table = null) { _table = table ?? SpellTable.Empty; + _metadataInstalled = table is not null; } /// @@ -51,6 +53,28 @@ public sealed class Spellbook /// Returns if none was provided. public SpellTable Metadata => _table; + /// + /// 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. + /// + 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(); + } + /// /// Issue #6 — combined buff modifier for a vital stat. Aggregates /// over through diff --git a/tests/AcDream.App.Tests/Spells/RetailSpellMetadataProjectorTests.cs b/tests/AcDream.App.Tests/Spells/RetailSpellMetadataProjectorTests.cs new file mode 100644 index 00000000..25c921c9 --- /dev/null +++ b/tests/AcDream.App.Tests/Spells/RetailSpellMetadataProjectorTests.cs @@ -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(); + 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; + } +} diff --git a/tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs b/tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs index 9007ffed..391a2528 100644 --- a/tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs +++ b/tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs @@ -16,6 +16,34 @@ public sealed class RetailSpellFormulaTests => Assert.Equal(expected, 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] public void CustomizeForAccount_IsDeterministic_AndOnlyChangesTapers() { diff --git a/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs b/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs index f0ccd117..02b474f8 100644 --- a/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs +++ b/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs @@ -27,6 +27,17 @@ public sealed class SpellTableTests 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(() => SpellTable.Create([spell, spell])); + } + [Fact] public void LoadFromReader_HeaderOnly_EmptyTable() { diff --git a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs index ce887cdf..513745eb 100644 --- a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs +++ b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs @@ -5,6 +5,25 @@ namespace AcDream.Core.Tests.Spells; 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( + () => book.InstallMetadata(SpellTable.Empty)); + } + [Fact] public void OnSpellLearned_FiresEvent_Idempotent() { @@ -187,4 +206,9 @@ public sealed class SpellbookTests StartTime: 10d, SpellCategory: category, 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); }