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`
|
||||
**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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
|
||||
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/<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`
|
||||
|
||||
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 `<None Update PreserveNewest>`
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -46,11 +46,6 @@
|
|||
<None Update="Rendering\Shaders\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<!-- Issue #11: copy spells.csv from docs/research/data/ to bin output's
|
||||
data/ subdir so SpellTable.LoadFromCsv can find it at runtime. -->
|
||||
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
|
||||
next to the binary so MarkupDocument.Build can load them at runtime. -->
|
||||
<None Include="UI\assets\**\*.xml">
|
||||
|
|
|
|||
|
|
@ -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/<config>/net10.0/data/
|
||||
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
|
||||
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
|
||||
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
|
||||
// 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();
|
||||
/// <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.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<AnimatedEntity>(() => _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);
|
||||
// #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);
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<uint, int> _spellLevels;
|
||||
|
||||
private MagicCatalog(
|
||||
CoreSpellTable spellTable,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
|
||||
IReadOnlyDictionary<uint, uint> wcidByScid,
|
||||
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
|
||||
IReadOnlyDictionary<uint, int> 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<uint, SpellComponentDescriptor> Components { get; }
|
||||
|
||||
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 spellLevels = new Dictionary<uint, int>();
|
||||
DatReaderWriter.DBObjs.SpellTable? spellTable =
|
||||
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid);
|
||||
if (spellTable is not null)
|
||||
DatReaderWriter.DBObjs.SpellTable spellTable =
|
||||
dats.Get<DatReaderWriter.DBObjs.SpellTable>(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<uint, uint>();
|
||||
|
|
@ -121,6 +127,7 @@ public sealed class MagicCatalog
|
|||
}
|
||||
|
||||
return new MagicCatalog(
|
||||
CoreSpellTable.Create(metadata),
|
||||
components,
|
||||
formulaDefinitions,
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
|
||||
/// Foci and the infused-magic augmentations replace the ordinary taper
|
||||
|
|
|
|||
|
|
@ -1,35 +1,17 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Per-spell static metadata loaded once at startup from
|
||||
/// <c>docs/research/data/spells.csv</c> via <see cref="SpellTable"/>.
|
||||
/// One record per known spell id (3,956 entries in the retail dump).
|
||||
///
|
||||
/// <para>
|
||||
/// Used for:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Buff / debuff bar labels (<see cref="Name"/>,
|
||||
/// <see cref="School"/>, <see cref="IconId"/>).</item>
|
||||
/// <item>Stacking aggregation (<see cref="Family"/> — only one
|
||||
/// enchantment per family-bucket is active; this is what
|
||||
/// <c>EnchantmentMath</c> uses to filter out superseded
|
||||
/// buffs in <c>LocalPlayerState.GetMaxApprox</c>).</item>
|
||||
/// <item>Spell tooltips (<see cref="Description"/>,
|
||||
/// <see cref="ManaCost"/>, <see cref="Duration"/>,
|
||||
/// <see cref="SpellWords"/>).</item>
|
||||
/// <item>Cast-bar audio + animation cues
|
||||
/// (<see cref="SpellWords"/> drives the chant).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Fields not exposed (yet) from the 35-column source CSV: SortKey,
|
||||
/// Difficulty, Flags, Generation, IsFastWindup, IsIrresistible,
|
||||
/// IsOffensive, IsUntargetted, Speed, CasterEffect, TargetEffect,
|
||||
/// TargetMask, Type, plus 10 anonymous Unknown1..10. Add them on
|
||||
/// demand as panels grow.
|
||||
/// </para>
|
||||
/// 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.
|
||||
/// </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(
|
||||
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<uint> 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ using System.IO;
|
|||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Loads + queries <see cref="SpellMetadata"/> from a CSV at startup.
|
||||
/// Source: <c>docs/research/data/spells.csv</c> (RFC 4180-ish, 35
|
||||
/// columns, 3,956 rows). Loaded once, used by panels + by
|
||||
/// <c>EnchantmentMath</c> for buff stacking aggregation.
|
||||
/// Immutable lookup of retail spell metadata. Production builds create this
|
||||
/// table from portal.dat through the App-layer <c>MagicCatalog</c>. The CSV
|
||||
/// parser below remains for historical tooling and small synthetic tests.
|
||||
///
|
||||
/// <para>
|
||||
/// Hand-rolled CSV parser — the only complication is the
|
||||
|
|
@ -35,6 +34,21 @@ public sealed class SpellTable
|
|||
_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>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted
|
||||
/// 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)
|
||||
.Select(_ => new List<uint>()).ToArray();
|
||||
private readonly Dictionary<uint, uint> _desiredComponents = new();
|
||||
private readonly SpellTable _table;
|
||||
private SpellTable _table;
|
||||
private bool _metadataInstalled;
|
||||
private uint _spellbookFilters = 0x3FFFu;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -37,6 +38,7 @@ public sealed class Spellbook
|
|||
public Spellbook(SpellTable? table = null)
|
||||
{
|
||||
_table = table ?? SpellTable.Empty;
|
||||
_metadataInstalled = table is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -51,6 +53,28 @@ public sealed class Spellbook
|
|||
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
|
||||
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>
|
||||
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
|
||||
/// 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,
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<ArgumentException>(() => SpellTable.Create([spell, spell]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFromReader_HeaderOnly_EmptyTable()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(
|
||||
() => 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue