feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -108,12 +108,12 @@ movement queries.
## Current state
**Currently working toward: M3 — Cast a spell. M2 — Kill a drudge LANDED 2026-07-15**:
the live melee/missile loop covers weapon switching, target-facing, attack/damage/death, loot,
inventory, and item giving. The M2/M3 projectile + DAT-effect foundation is automated through
Step 9; single-client missiles/protection effects are user-gated. **Next:** reconcile F.4/L.1d's
cast lifecycle against that foundation, then F.5 spellbook + active enchantments. **Carried:** final
two-client portal/observer VFX gate, #145-residual, #116, R6/TS-42, and Track MP0. Detail below.
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent,
component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component
book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect
foundation. **Next:** connected single-client magic UI/casting visual gate, then the final two-client
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0.
Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
For canonical state, read in this order:
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone

View file

@ -106,12 +106,12 @@ movement queries.
## Current state
**Currently working toward: M3 — Cast a spell. M2 — Kill a drudge LANDED 2026-07-15**:
the live melee/missile loop covers weapon switching, target-facing, attack/damage/death, loot,
inventory, and item giving. The M2/M3 projectile + DAT-effect foundation is automated through
Step 9; single-client missiles/protection effects are user-gated. **Next:** reconcile F.4/L.1d's
cast lifecycle against that foundation, then F.5 spellbook + active enchantments. **Carried:** final
two-client portal/observer VFX gate, #145-residual, #116, R6/TS-42, and Track MP0. Detail below.
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent,
component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component
book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect
foundation. **Next:** connected single-client magic UI/casting visual gate, then the final two-client
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0.
Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
For canonical state, read in this order:
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone

View file

@ -5136,49 +5136,52 @@ responds as if the user clicked the item.
## #L.2 — Spellbook favorites panel
**Status:** OPEN
**Status:** DONE 2026-07-15
**Severity:** MEDIUM
**Filed:** 2026-04-26 (deferred from Phase K)
**Component:** ui / magic
**Description:** In `MagicCombat` scope, 1-9 should fire
`UseSpellSlot_1..9` (distinct from hotbar). Requires a small UI to
pin favorite spells + a spellbook tab nav. Cross-references issue
#L.3 (combat-mode dispatch).
**Resolution:** The authored `gmSpellcastingUI` page inside combat layout
`0x21000073` now exposes all eight server-persisted favorite tabs, equipped
caster endowment, spell selection/name, drag-remove/drop-add favorite edits,
cast button, and `UseSpellSlot_1..9`. Cast intent goes through the single
`SpellCastingController`; ACE remains authoritative for turning, motion,
components, mana, fizzle, impact, and completion.
The same slice also mounts the authored Helpful/Harmful effect indicators
(`0x21000071`, buttons `0x100000F5/F6`) and routes their panel IDs 4/5 through
the shared retail `gmPanelUI` lifecycle, so closed effect lists have an exact
reopen path and replace the current main panel instead of becoming orphaned.
---
## #L.3 — Combat-mode tracking + scope-aware Insert/PgUp/Delete/End/PgDn dispatch
**Status:** OPEN
**Status:** DONE 2026-07-15
**Severity:** MEDIUM
**Filed:** 2026-04-26 (deferred from Phase K)
**Component:** input / combat
**Description:** Insert/PgUp/Delete/End/PgDn mean different things in
melee / missile / magic combat modes (per retail keymap MeleeCombat /
MissileCombat / MagicCombat blocks). Phase K has the bindings and the
scope stack; what's missing: `CombatState.CurrentMode` field +
listener for the server-side `SetCombatMode` packet (likely 0x0053 or
similar — confirm against ACE source). When mode arrives, push the
appropriate scope; when leaving combat, pop.
**Resolution:** Every binding now persists its retail `InputScope`; schema 4
migrates older files. `CombatState.CurrentMode` selects the Melee/Missile/Magic
shadow scope, held actions release atomically on any scope/binding transition,
and reentrant callbacks cannot replay a stale held-chord snapshot. Settings
conflict checks and rebinds preserve the scoped identity.
---
## #L.4 — F-key panels: Allegiance / Fellowship / Skills / Attributes / World / SpellComponents
**Status:** OPEN
**Status:** PARTIAL — magic/character/inventory surfaces shipped
**Severity:** LOW
**Filed:** 2026-04-26 (deferred from Phase K)
**Component:** ui
**Description:** Retail F3-F6, F8-F12 toggle UI panels for various
character data. Phase K has the bindings (`ToggleAllegiancePanel`,
`ToggleFellowshipPanel`, `ToggleSpellbookPanel`,
`ToggleSpellComponentsPanel`, `ToggleAttributesPanel`,
`ToggleSkillsPanel`, `ToggleWorldPanel`, `ToggleInventoryPanel`); the
panels themselves don't exist. Each is its own design feature.
Inventory (F12) is the most-requested.
**Current state:** Retained Inventory, Skills, Attributes, Spellbook, and
SpellComponents panels now exist and their F-key actions route through the
window manager. The spell/component book is the authored `0x21000034` tree,
with learned-spell filters, exact DAT spell/component icons, component
categories, selection, scrolling, and server-persisted desired amounts.
Allegiance, Fellowship, and World remain separate open panel features.
---

View file

@ -128,6 +128,23 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the
retained tick/draw/restore/dispose paths. Panel-specific construction must not
move back into `GameWindow.OnLoad`.
Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
desired/enchantment state projection; Core.Net owns exact manifest and live
message parsing; App `SpellCastingController` owns validated cast intent; and
the retained spell bar, spell/component book, and effects controllers only
project that state and invoke supplied actions. ACE remains authoritative after
the targeted/untargeted request for turning, animation, mana/components,
fizzle, impact, damage, and completion.
The shared LayoutDesc importer resolves inherited controller trees with retail
`LayoutDesc::InqFullDesc`/`ElementDesc::Incorporate` child identity semantics;
panel controllers never reconstruct missing inherited widgets by hand.
`RetailPanelUiController` is the single retained owner of retail
`gmPanelUI::RecvNotice_SetPanelVisibility` semantics: one active main-panel
child, optional DAT-property-driven deferred restoration, and visibility
reconciliation for persistence. Toolbar buttons and the authored Helpful/
Harmful effects indicators both send panel IDs into this owner; neither owns a
parallel window-lifecycle map.
---
## Project Structure (current + target)

View file

@ -127,7 +127,7 @@ 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 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| 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 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
@ -210,7 +210,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic combat plus M3 spellbook/component/effects/favorite-spell surfaces now cover the active melee/missile/magic loops; later waves land each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI outside the current combat/magic/inventory/character loops | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-15. **M3 active.** M2's connected demo loop is complete: melee and missile weapon switching, target-facing, combat animation and damage/death presentation, loot/inventory, stack operations, and giving an item to a world target have all passed user gates. The projectile/PhysicsScript/particle/portal foundation is implemented through its Step 9 automated hardening gate; single-client missile and protection visuals pass, while the final two-client portal-out/materialization observer gate remains open. The immediate work order is an F.4/L.1d retail-conformance audit against that shipped foundation, implementation of the remaining cast lifecycle, then F.5 spellbook and active-enchantment UI.
**Status:** Living document. Updated 2026-07-15. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, authored effect-indicator buttons, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -11,17 +11,19 @@
turn/windup/release/projectile/impact lifecycle, self-cast a buff and see its
active enchantment, then recall through the DAT-driven portal presentation.
**Next concrete work, in order:**
**Current gate order:**
1. Reconcile current E.5 wire, M2/M3 projectile/VFX, motion, and enchantment
state against named-retail F.4/L.1d; record only the proven remaining gaps.
2. Port and conformance-test those cast-state and animation lifecycle gaps,
including facing, release, interruption/fizzle, recoil, and authoritative
completion.
3. Ship F.5's retained spellbook and active-enchantment surfaces over the
existing shared state/command seam.
4. Run the pending two-client portal-out/materialization observer visual gate
before M3 closes.
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.
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
buttons, and the retail one-active-main-panel lifecycle.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
complete retained magic UI interaction checklist.
4. **Pending user visual gate:** two-client portal-out/materialization observer
presentation before M3 closes.
Opportunistic UI/physics fixes are no longer the work-order driver. They enter
the active slice only when they block this demo or represent a severe regression.
@ -527,13 +529,14 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Steps 05 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
- **✓ 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, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
- **✓ D.5.3 — Toolbar selected-object display (issue #141) SHIPPED.** Exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, health/name, and paperdoll self/upper-item selection all passed live by 2026-07-11. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
- **☐ D.5.5+ — Remaining core panels.** Inventory, equipment/paperdoll, spellbook/component book, and active-effects surfaces are shipped through the retained window manager. Vendor, trade, salvage, tinkering, and the remaining social/world panels stay open. Research drop: `docs/research/2026-06-16-*`; current magic research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; jump power bar IMPLEMENTED 2026-07-13; live-world visual gates pending.** The radar is retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular face (`0x06004CC1`) with orbiting N/E/S/W tokens, a fixed bright-green player marker, and coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — no 1.18 factor or scrolling strip. The separate `gmFloatyPowerBarUI` (`LayoutDesc 0x21000072`) now appears only while jump is pending and clips the authored JumpMode fill left-to-right from the movement controller's shared 01 charge. Both live in `AcDream.App/UI` behind focused controllers; `GameWindow` supplies state delegates only. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, `docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md`, and the named `gmRadarUI`/`gmPowerbarUI` decomp.
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.

View file

@ -634,6 +634,18 @@ protection effects pass the single-client visual gate. M3 begins with a focused
named-retail gap audit of F.4/L.1d—not a rewrite of this foundation—then fills
the spellbook/active-enchantment UI and completes the two-client portal gate.
**Implementation update (2026-07-15):** The automated F.4/L.1d audit and port
are complete. Cast requests now use retail component-formula selection,
target compatibility, shared busy/completion ownership, and exact
targeted/untargeted messages. Live enchantment add/remove/dispel/purge state is
fully parsed with layer identity and client-clock normalization. The authored
Magic combat page, eight favorite tabs, caster endowment, spellbook/component
book, scoped keys, positive/negative effects windows, and the authored
Helpful/Harmful indicators are mounted in the retained runtime. Main-panel
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
active only for the connected single-client magic
UI/casting visual gate and the final two-client portal observer gate.
**Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
spells second).

View file

@ -0,0 +1,277 @@
# Retail magic UI and casting pseudocode
This note is the implementation oracle for the M3 cast lifecycle and retained
spell UI. Retail addresses refer to the September 2013 EoR named client.
Wire layouts were cross-checked against ACE and the existing holtburger-derived
`PlayerDescriptionParser`.
## Cast request ownership
Retail: `ClientMagicSystem::CastSpell` `0x00568040` and
`ClientMagicSystem::FreeHandsAndCastSpell` `0x00566EF0`.
```text
CastSpell(spellId, useSelectedTarget):
spell = SpellTable[spellId]
if components are required:
formula = GetAppropriateSpellFormula(spell)
if any required component is absent:
display "You do not have all of this spell's components."
return
if spell targets self:
target = local player id (or untargeted when no SmartBox exists)
FreeHandsAndCastSpell(spellId, target)
return
if spell has no target type:
MaybeStopCompletely()
send CastUntargetedSpell(spellId)
increment UI busy count
return
target = selected object
if target == 0:
display "You must select a suitable target."
return
if the selected GUID no longer resolves to an object:
return silently
if ObjectCompatibleWithSpell(target, spellId):
FreeHandsAndCastSpell(spellId, target)
reselect target
FreeHandsAndCastSpell(spellId, target):
MaybeStopCompletely()
if target == 0: send CastUntargetedSpell(spellId)
else: send CastTargetedSpell(target, spellId)
increment UI busy count
```
The request is sent immediately. ACE remains authoritative for turning,
casting motion, component consumption, mana use, fizzle, impact, damage, and
enchantment state. The client does not invent a parallel gameplay outcome.
### Formula selection and target compatibility
Retail: `ClientMagicSystem::GetAppropriateSpellFormula` `0x00567D50`,
`CSpellBase::InqScarabOnlyFormula` `0x00597050`,
`ACCWeenieObject::MagicPackIsOwned` `0x0058CB80`, and
`ClientMagicSystem::ObjectCompatibleWithSpellTargetType` `0x00567230`.
```text
GetAppropriateSpellFormula(spell):
schoolFocusWcid = SchoolOfMagic2WCID(spell.school)
infusedProperty = [War 0x129, Life 0x128, Item 0x127,
Creature 0x126, Void 0x148][school]
if infusedProperty > 0 OR a directly carried side-pack has schoolFocusWcid:
retain every power component in formula order
strongest = highest retained component power
append prismatic taper SCID 0xBC:
power 1 => 1; power 2 => 2; power 3/4/7 => 3;
power 5/6/8/9/10 => 4
return result
return account-customized formula
ObjectCompatibleWithSpellTargetType(target, targetMask):
special = targetMask & 0x8107
reject self when special == 0
reject stacks
require (targetMask & target.type) != 0 OR special != 0
require target.IsPlayer OR target.publicBitfield has BF_ATTACKABLE
require target.petOwner == 0
```
The final Player/Attackable and pet gates apply after both the ordinary type
match and the special-mask bypass. Formula entries are SCIDs; inventory and
favorite-component events use WCIDs, resolved through DualEnumIDMap
`0x27000002`. The magic-school focus map is retail enum category `0x28`, key
`0x10000001`.
## Authoritative enchantment events
Retail dispatchers:
- `CM_Magic::DispatchUI_UpdateEnchantment` `0x006A32F0`
- `CM_Magic::DispatchUI_RemoveEnchantment` `0x006A2FB0`
- `CM_Magic::DispatchUI_DispelEnchantment` `0x006A2F20`
```text
0x02C2 UpdateEnchantment:
unpack one Enchantment record
0x02C4 UpdateMultipleEnchantments:
u32 count
repeat count times: unpack Enchantment record
Enchantment record (60 or 64 bytes):
u16 spellId
u16 layer
u16 spellCategory
u16 hasSpellSetId
u32 powerLevel
f64 startTime
f64 duration
u32 casterGuid
f32 degradeModifier
f32 degradeLimit
f64 lastDegraded
u32 statModType
u32 statModKey
f32 statModValue
if hasSpellSetId: u32 spellSetId
0x02C3 RemoveEnchantment:
u16 spellId
u16 layer
0x02C7 DispelEnchantment:
u16 spellId
u16 layer
0x02C5 / 0x02C8 Remove/DispelMultipleEnchantments:
u32 count
repeat count times: u16 spellId, u16 layer
0x02C6 PurgeEnchantments:
no payload; clear the authoritative active set
```
## Magic combat page (spell bar)
Retail: `gmSpellcastingUI::PostInit` `0x004C5D00`,
`SpellCastSubMenu::Init` `0x004C5A90`, and `gmSpellcastingUI::Cast`
`0x004C6050`.
The spell bar is the authored Magic page inside combat layout `0x21000073`.
It is not an advanced-combat placeholder.
```text
combat basic page 0x1000005C
spellcasting page 0x10000061
spellcasting content 0x100000A2
spell name text 0x1000048B
spellcasting background 0x100000A0
cast button 0x100000B2
endowment icon 0x100000B1
favorite tab buttons 0x100000A3..0x100000A9, 0x100005C2
favorite list groups 0x100000AA..0x100000B0, 0x100005C3
item list within each group 0x100000B6
```
There are eight favorite tabs. Each list is authoritative character state.
Selecting a spell updates its name and current spell. Cast invokes
`ClientMagicSystem::CastSpell(selectedSpell, true)`. PageUp/PageDown and the
retail spell input actions change tab/selection; the cast-current action casts
the current selection. Favorite edits are sent to ACE.
Outbound character events (`CM_Character`):
```text
0x01E3 AddSpellFavorite(spellId:u32, position:i32, tab:i32)
0x01E4 RemoveSpellFavorite(spellId:u32, tab:i32)
0x0286 SpellbookFilterEvent(filter:u32)
0x0224 SetDesiredComponentLevel(componentDid:u32, amount:i32)
```
## Spellbook and component book
Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and
`gmSpellComponentUI::PostInit` `0x0048A190`.
Both pages are authored in layout `0x21000034` (300 by 600 pixels):
```text
window root 0x100002A8
spellbook tab 0x100002A9
component tab 0x100002AA
close button 0x100002AB
spellbook page 0x100002AC (base 0x10000294 / 0x21000032)
component page 0x100002AD (base 0x100002A7 / 0x21000033)
spell list 0x10000295
school buttons 0x10000298..0x1000029B, 0x100005C0
level buttons 1..7 0x1000029C..0x100002A2
level button 8 0x1000054E
component item list 0x10000464
component scrollbar 0x10000465
```
Spellbook rows come only from the authoritative learned-spell set. Retail
sorts by `CSpellBase::_display_order`; school and level buttons apply the
server-persisted filter bitfield. Component rows use the installed component
table and the server-persisted desired purchase amounts.
## Active enchantment panels
Retail: `gmEffectsUI::PostInit` `0x004B7560` and
`gmEffectsUI::RebuildList` `0x004B8350`.
```text
layout 0x2100001B
positive root 0x1000011F (inherits 0x10000122)
negative root 0x10000121 (inherits 0x10000122)
shared effects template 0x10000122
effect list 0x10000123
information text 0x10000126
effect UI type property 0x1000000C
```
The `0x10000184`/`0x10000185` values used by `gmPanelUI` are panel-slot
identities, not roots in effects LayoutDesc `0x2100001B`. The two authored
effects roots carry their title/close overrides and inherit the list,
scrollbar, information area, and remaining chrome from `0x10000122`.
Retail `gmPanelUI::SetupChildren` (`0x004BC9E0`) registers those children as
panel IDs 4 and 5. `RecvNotice_SetPanelVisibility` (`0x004BC6F0`) owns one
active child: showing either effect panel hides the previous main panel, while
closing the active child hides the shared host. DAT bool property `0x10000049`
selects the exceptional save-and-restore-previous behavior. The resolved
Helpful/positive root sets it, so closing Helpful restores the preceding
ordinary panel. Harmful/negative does not set it and leaves the host empty.
The reopen path is not the main toolbar. Retail
`gmUIElement_EffectsIndicator::PostInit` (`0x004E6930`) reads effect type 1 or
2 from property `0x1000000C`, and `Update` (`0x004E6A50`) ghosts the button
when no matching enchantment exists. The floating indicators layout is
`0x21000071`; Helpful button `0x100000F5` opens panel 4 and Harmful button
`0x100000F6` opens panel 5. The docked equivalent is layout `0x21000015`.
Retail `LayoutDesc::InqFullDesc` (`0x0069A520`) resolves the base first and
then calls `ElementDesc::Incorporate` (`0x0069B5A0`). Child tables are merged
by element identity: base-only children remain, same-identity children are
incorporated recursively, and derived-only children are appended with their
read order shifted by the retained base-only count.
When visible, each panel rebuilds from the authoritative active-enchantment
snapshot and refreshes each token's duration at most once per second.
`CEnchantmentRegistry::GetEnchantmentsInEffect` (`0x00594540`) merges only the
multiplicative and additive registries, so Vitae and cooldown records neither
appear in these panels nor enable an indicator. Each incoming entry is dueled
against the current winner for its spell category (`Duel @ 0x005942B0`): higher
power wins, with later start time breaking an equal-power tie. Positive and
negative panels then test the winning spell's Beneficial flag and sort
alphabetically.
Live `AddEnchantmentToList @ 0x00593DF0` inserts a new identity at the bucket
head, while an existing identity refreshes in place; bulk UnPack retains its
received head-to-tail order. Since an exact power/start tie keeps the first
duel incumbent, the newest incrementally added identity wins an exact tie.
The Helpful/Harmful indicators intentionally use a different projection.
`CountSpellsInList @ 0x00593CD0` feeds the registry's two indicator counters by
visiting every raw mult/add node before any category duel. Therefore opposite-
sign spells in the same category can enable both indicators while only the duel
winner appears in the effects list.
Remaining duration is `(startTime + duration) - current game time`. Selecting
an active token stores its spell ID, so every layer of that spell highlights
together and shows the shared name/description; clicking either layer again
clears the selection.
## Portal presentation boundary
Recall and portal presentation remains DAT-driven. `/ls` starts the retail
recall motion (`0x10000153`); its animation hooks/PES chain supplies the cloud.
Remote disappearance and materialization use authoritative Hidden/UnHide state
and typed physics scripts. No generic teleport effect, guessed color, or local
teleport outcome is added by the spell UI work.

View file

@ -11,6 +11,10 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
private readonly AcDream.App.RuntimeOptions _options;
@ -833,6 +837,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
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).
@ -1289,6 +1294,8 @@ public sealed class GameWindow : IDisposable
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 /
// 0x00458310): the callback submits a filtered horizontal
@ -2085,11 +2092,8 @@ 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;
var spellComponentTable =
// DBObj file id is 0x0E00000F. Retail's IsComponentPack
// passes enum key 0x10000001 to DBObj::GetByEnum; that key is
// NOT a portal.dat file id (using it here reads unrelated bytes).
_dats!.Get<DatReaderWriter.DBObjs.SpellComponentTable>(0x0E00000Fu);
AcDream.App.Spells.MagicCatalog magicCatalog =
AcDream.App.Spells.MagicCatalog.Load(_dats!);
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
@ -2110,7 +2114,7 @@ public sealed class GameWindow : IDisposable
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp,
sendSplitToWorld: (item, amount) =>
_liveSession?.SendStackableSplitTo3D(item, amount),
@ -2138,6 +2142,24 @@ public sealed class GameWindow : IDisposable
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
magicCatalog,
SpellBook,
Objects,
selectedObject: () => _selection.SelectedObjectId,
localPlayerId: () => _playerServerGuid,
// SpellFormula::Randomize hashes the canonical account spelling
// delivered by CharacterList.
accountName: () => _liveSession?.Characters?.AccountName
?? _options.LiveUser
?? string.Empty,
stopCompletely: PreparePlayerForAttackRequest,
sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
@ -2228,6 +2250,31 @@ public sealed class GameWindow : IDisposable
_combatAttackController,
() => _persistedGameplay,
SetRetailCombatGameplay),
Magic: new AcDream.App.UI.MagicRuntimeBindings(
SpellBook,
_magicRuntime.Casting,
Objects,
() => _playerServerGuid,
magicCatalog.Components,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
iconComposer.GetSpellIcon,
iconComposer.GetSpellComponentIcon,
_selection,
magicCatalog.GetSpellLevel,
guid => _selection.Select(
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
UseItemByGuid,
(tab, position, spellId) =>
_liveSession?.SendAddSpellFavorite(spellId, position, tab),
(tab, spellId) =>
_liveSession?.SendRemoveSpellFavorite(spellId, tab),
filters => _liveSession?.SendSpellbookFilter(filters),
(componentId, amount) =>
_liveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
Fps: new AcDream.App.UI.FpsRuntimeBindings(
@ -2763,6 +2810,9 @@ public sealed class GameWindow : IDisposable
// them down before dropping the canonical GUID/timestamp snapshots.
_equippedChildRenderer?.Clear();
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
_itemInteractionController?.ClearBusy();
_selection.Reset();
_pendingPostArrivalAction = null;
try
@ -2932,7 +2982,8 @@ public sealed class GameWindow : IDisposable
onCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1);
options1,
clientTime: ClientTimerNow);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -12423,6 +12474,17 @@ public sealed class GameWindow : IDisposable
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
/// this method handles transitional Press/Release events only.
/// </summary>
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
{
_inputDispatcher?.SetCombatScope(mode switch
{
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
_ => null,
});
}
private void OnInputAction(
AcDream.UI.Abstractions.Input.InputAction action,
AcDream.UI.Abstractions.Input.ActivationType activation)
@ -14584,6 +14646,7 @@ public sealed class GameWindow : IDisposable
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
_magicRuntime = 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
@ -14632,6 +14695,7 @@ public sealed class GameWindow : IDisposable
_debugFont?.Dispose();
_frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring
_dats?.Dispose();
Combat.CombatModeChanged -= SetInputCombatScope;
_input?.Dispose();
_gl?.Dispose();
}

View file

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Spells;
/// <summary>Immutable presentation metadata for one retail spell component WCID.</summary>
public sealed record SpellComponentDescriptor(
uint WeenieClassId,
string Name,
uint Category,
uint IconId);
/// <summary>
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
/// rough spell-level calculation one independently testable ownership seam.
/// </summary>
public sealed class MagicCatalog
{
private const uint SpellTableDid = 0x0E00000Eu;
private const uint SpellComponentTableDid = 0x0E00000Fu;
private const uint ComponentIdMapDid = 0x27000002u;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulaDefinitions;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
private MagicCatalog(
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
IReadOnlyDictionary<uint, int> spellLevels)
{
Components = components;
_formulaDefinitions = formulaDefinitions;
_wcidByScid = wcidByScid;
_magicPackWcidBySchool = magicPackWcidBySchool;
_spellLevels = spellLevels;
}
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
public bool IsComponentPack(uint weenieClassId)
=> Components.ContainsKey(weenieClassId);
public int GetSpellLevel(uint spellId)
=> _spellLevels.TryGetValue(spellId, out int level) ? level : 0;
public SpellComponentRequirementService CreateRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName)
=> new(
objects,
playerGuid,
accountName,
_formulaDefinitions,
_wcidByScid,
_magicPackWcidBySchool);
public static MagicCatalog Load(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
var components = new Dictionary<uint, SpellComponentDescriptor>();
var wcidByScid = new Dictionary<uint, uint>();
SpellComponentTable? componentTable = dats.Get<SpellComponentTable>(SpellComponentTableDid);
DualEnumIDMap? componentIds = dats.Get<DualEnumIDMap>(ComponentIdMapDid);
if (componentTable is not null && componentIds is not null)
{
foreach (var pair in componentTable.Components)
{
if (!componentIds.ClientEnumToID.TryGetValue(pair.Key, out uint wcid))
continue;
wcidByScid[pair.Key] = wcid;
components[wcid] = new SpellComponentDescriptor(
wcid,
pair.Value.Name.Value,
pair.Value.Category,
pair.Value.Icon.DataId);
}
}
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)
{
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);
}
}
var magicPackWcidBySchool = new Dictionary<uint, uint>();
// Retail IsComponentPack resolves enum key 0x10000001 in category 0x28;
// that key is not itself a portal.dat file id.
uint magicPackMapDid = AcDream.App.UI.RetailDataIdResolver.Resolve(
dats, 0x10000001u, 0x28u);
if (magicPackMapDid != 0u
&& dats.Portal.TryGet<EnumIDMap>(magicPackMapDid, out EnumIDMap? magicPackMap)
&& magicPackMap is not null)
{
foreach (var pair in magicPackMap.ClientEnumToID)
magicPackWcidBySchool[pair.Key] = pair.Value;
}
return new MagicCatalog(
components,
formulaDefinitions,
wcidByScid,
magicPackWcidBySchool,
spellLevels);
}
}
/// <summary>
/// App-layer owner for the live retail magic request path. The server owns
/// turning, motion, fizzle, mana/component consumption, effects, and results.
/// </summary>
public sealed class MagicRuntime
{
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
{
Catalog = catalog;
Casting = casting;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
public static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Func<string> accountName,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Action incrementBusy,
Func<bool> canSend)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(displayMessage);
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
objects, localPlayerId, accountName);
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
{
if (objects.Get(target) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
localPlayerId(), targetObject, spell);
if (showMessage && !result.Allowed && result.Message is { } message)
displayMessage(message);
return result.Allowed;
}
var casting = new SpellCastingController(
spellbook,
selectedObject,
localPlayerId,
stopCompletely,
sendUntargeted,
sendTargeted,
displayMessage,
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
hasRequiredComponents: requirements.HasRequiredComponents,
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
return new MagicRuntime(catalog, casting);
}
public void Reset() => Casting.Reset();
}

View file

@ -0,0 +1,45 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public readonly record struct SpellTargetPolicyResult(bool Allowed, string? Message)
{
public static SpellTargetPolicyResult Accept { get; } = new(true, null);
}
/// <summary>
/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
/// (0x00567230). The caller owns target lookup and message presentation.
/// </summary>
public static class RetailSpellTargetPolicy
{
private const uint SpecialTargetMask = 0x00008107u;
public static SpellTargetPolicyResult Evaluate(
uint localPlayerId,
ClientObject target,
SpellMetadata spell)
{
uint mask = spell.TargetMask;
uint special = mask & SpecialTargetMask;
if (target.ObjectId == localPlayerId && special == 0u)
return new(false, "You cannot cast this spell upon yourself.");
if (target.StackSize > 1)
return new(false, "Cannot cast spell on a stack of items.");
if (((uint)target.Type & mask) == 0u && special == 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
// Retail joins the ordinary type match and the 0x8107 special-mask
// bypass before these two gates. IsPlayer is PWD bit 3; otherwise the
// object must carry BF_ATTACKABLE. Pets are never legal spell targets.
var flags = (PublicWeenieFlags)target.PublicWeenieBitfield.GetValueOrDefault();
bool playerOrAttackable = (flags
& (PublicWeenieFlags.Player | PublicWeenieFlags.Attackable)) != 0;
if (!playerOrAttackable || target.PetOwnerId != 0u)
return new(false, $"This spell cannot be cast on {target.Name}.");
return SpellTargetPolicyResult.Accept;
}
}

View file

@ -0,0 +1,157 @@
using System;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
/// <summary>
/// App-layer owner for retail cast intent. It validates the client-owned
/// selection rules, stops movement, and emits exactly one targeted or
/// untargeted request. ACE owns every gameplay result after that boundary.
/// </summary>
/// <remarks>
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
/// does not consume mana/components or start local effects.
/// </remarks>
public sealed class SpellCastingController
{
private readonly Spellbook _spellbook;
private readonly Func<uint?> _selectedObject;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
private readonly Func<uint, bool> _hasRequiredComponents;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public SpellCastingController(
Spellbook spellbook,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Func<uint, SpellMetadata, bool>? targetCompatible = null,
Func<uint, bool>? hasRequiredComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null,
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
_targetCompatible = targetCompatible ?? ((_, _) => true);
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
_incrementBusy = incrementBusy ?? (() => { });
_canSend = canSend ?? (() => true);
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
public bool IsTargetReady(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selectedObject() is uint target and not 0u
&& _targetCompatibleSilent(target, spell);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_displayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_hasRequiredComponents(spellId))
{
_displayMessage("You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _localPlayerId();
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selectedObject();
untargeted = false;
if (target is null or 0)
{
_displayMessage("You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_targetCompatible(target.Value, spell))
return CastRequestResult.IncompatibleTarget;
}
if (!_canSend())
{
_displayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_stopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_sendUntargeted(spellId);
else
_sendTargeted(target!.Value, spellId);
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
// busy reference only after Event_Cast has been emitted. The
// matching server UseDone event decrements it.
_incrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
return CastRequestResult.Sent;
}
public void Reset()
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public sealed record SpellFormulaDefinition(
uint FormulaVersion,
IReadOnlyList<uint> ComponentIds,
uint School = 0u);
/// <summary>
/// Client-side retail component preflight for ClientMagicSystem::CastSpell
/// (0x00568040). Spell formula entries are SCIDs; carried inventory objects and
/// SetDesiredComponent use WCIDs, so all comparisons cross the DAT enum map.
/// </summary>
public sealed class SpellComponentRequirementService
{
public const uint SpellComponentsRequiredProperty = 68u;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<string> _accountName;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulas;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
public SpellComponentRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulas,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint>? magicPackWcidBySchool = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_accountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
_formulas = formulas ?? throw new ArgumentNullException(nameof(formulas));
_wcidByScid = wcidByScid ?? throw new ArgumentNullException(nameof(wcidByScid));
_magicPackWcidBySchool = magicPackWcidBySchool
?? new Dictionary<uint, uint>();
}
public bool HasRequiredComponents(uint spellId)
{
uint playerGuid = _playerGuid();
ClientObject? player = _objects.Get(playerGuid);
if (player is not null
&& !player.Properties.GetBool(SpellComponentsRequiredProperty, true))
return true;
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return false;
HashSet<uint> ownedWcids = _objects.Objects
.Where(item => IsOwnedByPlayer(item, playerGuid))
.Select(item => item.WeenieClassId)
.ToHashSet();
IReadOnlyList<uint> components = GetAppropriateFormula(
formula, player, playerGuid);
foreach (uint scid in components)
{
if (scid == 0u) continue;
if (!_wcidByScid.TryGetValue(scid, out uint wcid)
|| !ownedWcids.Contains(wcid))
return false;
}
return true;
}
/// <summary>
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
/// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins.
/// </summary>
private IReadOnlyList<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,
uint playerGuid)
{
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
&& player?.Properties.GetInt(propertyId) > 0;
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
formula.School, out uint packWcid)
&& _objects.Objects.Any(item =>
item.ContainerId == playerGuid
&& item.WeenieClassId == packWcid);
return infused || carriesMagicPack
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
: RetailSpellFormula.CustomizeForAccount(
formula.ComponentIds, formula.FormulaVersion, _accountName());
}
private static uint? SchoolInfusionProperty(uint school) => school switch
{
1u => 0x129u, // AugmentationInfusedWarMagic
2u => 0x128u, // AugmentationInfusedLifeMagic
3u => 0x127u, // AugmentationInfusedItemMagic
4u => 0x126u, // AugmentationInfusedCreatureMagic
5u => 0x148u, // AugmentationInfusedVoidMagic
_ => null,
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0u) return false;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid)
return true;
if (current.ContainerId == 0u
|| _objects.Get(current.ContainerId) is not { } parent)
return false;
current = parent;
}
return false;
}
}

View file

@ -4,6 +4,7 @@ using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Textures;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -35,6 +36,8 @@ public sealed class IconComposer
private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
private readonly Dictionary<uint, uint> _spellIcons = new();
private readonly Dictionary<uint, uint> _componentIcons = new();
private sealed record ComposedIcon(byte[] Rgba, int Width, int Height, uint Texture);
@ -298,4 +301,71 @@ public sealed class IconComposer
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellIcon (0x00567550): power-level
/// backing, spell art, reversed/normal recolor, then self/fellow overlay.
/// </summary>
public uint GetSpellIcon(uint spellId)
{
if (_spellIcons.TryGetValue(spellId, out uint cached)) return cached;
DatReaderWriter.DBObjs.SpellTable? table =
_dats.Get<DatReaderWriter.DBObjs.SpellTable>(0x0E00000Eu);
if (table is null || !table.Spells.TryGetValue(spellId, out var spell)) return 0u;
uint power = spell.Components.Count == 0
? 0u
: RetailSpellFormula.DeterminePowerLevelOfComponent(spell.Components[0]);
uint powerBacking = RetailDataIdResolver.Resolve(_dats, power, 0x10000006u);
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, powerBacking);
AddLayer(layers, spell.Icon);
if (layers.Count == 0) return 0u;
var composed = Compose(layers);
uint tintIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.Reversed) != 0
? 1u : 2u;
uint tintDid = RetailDataIdResolver.Resolve(_dats, tintIndex, 0x10000007u);
if (TryDecode(tintDid, out DecodedTexture tint))
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
tint.Rgba8, tint.Width, tint.Height);
uint overlayIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.FellowshipSpell) != 0
? 4u
: (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.SelfTargeted) != 0
? 3u : 0u;
if (overlayIndex != 0u)
{
uint overlayDid = RetailDataIdResolver.Resolve(_dats, overlayIndex, 0x10000007u);
if (TryDecode(overlayDid, out DecodedTexture overlay))
composed = Compose([
(composed.rgba, composed.w, composed.h),
(overlay.Rgba8, overlay.Width, overlay.Height)]);
}
uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
_spellIcons[spellId] = texture;
return texture;
}
/// <summary>
/// Retail ClientMagicSystem::CompositeSpellComponentIcon (0x00567720).
/// Components use their raw DAT art with pure white replaced by black.
/// </summary>
public uint GetSpellComponentIcon(uint iconId)
{
if (iconId == 0u) return 0u;
if (_componentIcons.TryGetValue(iconId, out uint cached)) return cached;
if (!TryDecode(iconId, out DecodedTexture icon)) return 0u;
byte[] rgba = (byte[])icon.Rgba8.Clone();
for (int i = 0; i + 3 < rgba.Length; i += 4)
{
if (rgba[i] != 255 || rgba[i + 1] != 255 || rgba[i + 2] != 255 || rgba[i + 3] != 255)
continue;
rgba[i] = rgba[i + 1] = rgba[i + 2] = 0;
}
uint texture = _cache.UploadRgba8(rgba, icon.Width, icon.Height, nearest: true);
_componentIcons[iconId] = texture;
return texture;
}
}

View file

@ -125,6 +125,18 @@ public sealed class ItemInteractionController : IDisposable
public bool CanMakeInventoryRequest =>
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
/// corresponding UseDone releases it. Retail spell casts complete through
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
/// </summary>
public void IncrementBusyCount()
{
_busyCount++;
StateChanged?.Invoke();
}
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
@ -514,6 +526,14 @@ public sealed class ItemInteractionController : IDisposable
StateChanged?.Invoke();
}
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
public void ClearBusy()
{
if (_busyCount == 0) return;
_busyCount = 0;
StateChanged?.Invoke();
}
private bool ConsumeUseThrottle()
{
long now = _nowMs();

View file

@ -20,7 +20,8 @@ public sealed class CombatUiController : IRetainedPanelController
{
public const uint LayoutId = 0x21000073u;
public const uint BasicPanelId = 0x1000005Cu;
public const uint AdvancedPanelId = 0x10000061u;
public const uint SpellcastingPanelId = 0x10000061u;
public const uint AdvancedPanelId = SpellcastingPanelId;
public const uint PowerControlId = 0x1000004Fu;
public const uint SpeedLabelId = 0x10000051u;
public const uint PowerLabelId = 0x10000052u;
@ -36,7 +37,7 @@ public sealed class CombatUiController : IRetainedPanelController
private readonly UiElement _root;
private readonly UiElement _basicPanel;
private readonly UiElement _advancedPanel;
private readonly UiElement _spellcastingPanel;
private readonly UiScrollbar _powerControl;
private readonly UiButton _high;
private readonly UiButton _medium;
@ -54,7 +55,7 @@ public sealed class CombatUiController : IRetainedPanelController
private CombatUiController(
ImportedLayout layout,
UiElement basicPanel,
UiElement advancedPanel,
UiElement spellcastingPanel,
UiScrollbar powerControl,
UiButton high,
UiButton medium,
@ -71,7 +72,7 @@ public sealed class CombatUiController : IRetainedPanelController
{
_root = layout.Root;
_basicPanel = basicPanel;
_advancedPanel = advancedPanel;
_spellcastingPanel = spellcastingPanel;
_powerControl = powerControl;
_high = high;
_medium = medium;
@ -85,11 +86,10 @@ public sealed class CombatUiController : IRetainedPanelController
_setGameplay = setGameplay;
_setWindowVisible = setWindowVisible;
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
// basic page. The advanced page belongs to the separate advanced-
// combat flow and stays hidden in the basic panel.
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
// physical-attack controls and gmSpellcastingUI's favorite-spell bar.
_basicPanel.Visible = true;
_advancedPanel.Visible = false;
_spellcastingPanel.Visible = false;
_powerControl.SetScalarPosition(_attacks.DesiredPower);
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
@ -140,7 +140,7 @@ public sealed class CombatUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(setWindowVisible);
if (layout.FindElement(BasicPanelId) is not { } basic
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|| layout.FindElement(SpellcastingPanelId) is not { } spellcasting
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|| layout.FindElement(HighButtonId) is not UiButton high
|| layout.FindElement(MediumButtonId) is not UiButton medium
@ -151,7 +151,7 @@ public sealed class CombatUiController : IRetainedPanelController
return null;
return new CombatUiController(
layout, basic, advanced, power, high, medium, low,
layout, basic, spellcasting, power, high, medium, low,
repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
}
@ -166,7 +166,9 @@ public sealed class CombatUiController : IRetainedPanelController
private void OnCombatModeChanged(CombatMode mode)
{
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
bool visible = mode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic;
_basicPanel.Visible = mode is CombatMode.Melee or CombatMode.Missile;
_spellcastingPanel.Visible = mode == CombatMode.Magic;
if (_root is IUiDatStateful stateful)
{
if (mode == CombatMode.Melee)

View file

@ -77,6 +77,8 @@ public static class DatWidgetFactory
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
EffectsIndicatorController.RetailClassId => BuildButton(
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf

View file

@ -0,0 +1,86 @@
using System;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
/// reflect whether matching enchantments exist and send panel visibility notices
/// for gmPanelUI slots 4 and 5.
/// </summary>
public sealed class EffectsIndicatorController : IRetainedPanelController
{
public const uint LayoutId = 0x21000071u;
public const uint RetailClassId = 0x10000002u;
public const uint HelpfulButtonId = 0x100000F5u;
public const uint HarmfulButtonId = 0x100000F6u;
private readonly Spellbook _spellbook;
private readonly UiButton _helpful;
private readonly UiButton _harmful;
private readonly Action<uint> _togglePanel;
private bool _disposed;
private EffectsIndicatorController(
Spellbook spellbook,
UiButton helpful,
UiButton harmful,
Action<uint> togglePanel)
{
_spellbook = spellbook;
_helpful = helpful;
_harmful = harmful;
_togglePanel = togglePanel;
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
_spellbook.EnchantmentsChanged += Update;
Update();
}
public static EffectsIndicatorController? Bind(
ImportedLayout layout,
Spellbook spellbook,
Action<uint> togglePanel)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(togglePanel);
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
return null;
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
}
private void Update()
{
bool helpful = HasMatchingEffect(beneficial: true);
bool harmful = HasMatchingEffect(beneficial: false);
_helpful.TrySetRetailState(helpful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
_harmful.TrySetRetailState(harmful
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
private bool HasMatchingEffect(bool beneficial)
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
// by spell category first.
record.Bucket is 1u or 2u
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
&& metadata.IsBeneficial == beneficial);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Update;
_helpful.OnClick = null;
_harmful.OnClick = null;
}
}

View file

@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>Retail gmEffectsUI positive/negative instance binding.</summary>
public sealed class EffectsUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100001Bu;
// gmPanelUI's 0x10000184/185 are panel-slot IDs, not roots in this LayoutDesc.
// The authored positive/negative gmEffectsUI instances inherit template 0x10000122.
public const uint PositiveRootId = 0x1000011Fu;
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint InfoTextId = 0x10000126u;
private readonly Spellbook _spellbook;
private readonly bool _positive;
private readonly Func<double> _serverTime;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
private bool _disposed;
internal uint? SelectedSpellId => _selectedSpellId;
private EffectsUiController(
ImportedLayout layout,
Spellbook spellbook,
bool positive,
Func<double> serverTime,
Func<uint, uint> resolveSpellIcon,
UiItemList list,
Action? close)
{
_spellbook = spellbook;
_positive = positive;
_serverTime = serverTime;
_resolveSpellIcon = resolveSpellIcon;
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = Math.Max(1f, _list.Width);
_list.CellHeight = 32f;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
}
public static EffectsUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
bool positive,
Func<double> serverTime,
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
Func<uint, uint> resolveSpellIcon,
Action? close = null)
{
UiElement? host = layout.FindElement(ListId);
if (host is null) return null;
UiItemList list;
if (host is UiItemList itemList)
list = itemList;
else
{
list = new UiItemList(spriteResolve)
{
Width = host.Width,
Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
host.AddChild(list);
}
return new EffectsUiController(
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
}
public void Tick()
{
double now = _serverTime();
if (!double.IsFinite(now)) return;
if (double.IsFinite(_lastDurationUpdate)
&& now >= _lastDurationUpdate
&& now - _lastDurationUpdate < 1.0)
return;
_lastDurationUpdate = now;
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
row.Detail = FormatRemaining(enchantment, now);
}
private void Rebuild()
{
_lastDurationUpdate = double.NaN;
_rows.Clear();
ActiveEnchantmentRecord[] enchantments = VisibleEnchantments().ToArray();
using (_list.DeferLayout())
{
_list.Flush();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
{
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
uint identity = enchantment.Identity;
var row = new UiCatalogSlot
{
EntryId = enchantment.SpellId,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
Detail = FormatRemaining(enchantment, _serverTime()),
ShowLabel = true,
SpriteResolve = _list.SpriteResolve,
};
row.Clicked = () => Select(enchantment.SpellId);
_rows[identity] = row;
_list.AddItem(row);
}
}
if (_selectedSpellId is uint selected
&& !_rows.Values.Any(row => row.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
}
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
=> _spellbook.EnchantmentsInEffectSnapshot
.Where(record =>
{
if (!_spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata))
return false;
return metadata.IsBeneficial == _positive;
})
.OrderBy(record => _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
? metadata.Name : record.SpellId.ToString(CultureInfo.InvariantCulture),
StringComparer.OrdinalIgnoreCase);
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
{
if (enchantment.Duration < 0) return "Permanent";
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
if (!double.IsFinite(remaining)) return "--:--";
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
TimeSpan time = TimeSpan.FromSeconds(remaining);
return time.TotalHours >= 1
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{time.Minutes}:{time.Seconds:00}";
}
private void Select(uint spellId)
{
// Retail gmEffectsUI::SetSelectedSpell @ 0x004B8290 stores the
// token's spell stat, not its enchantment layer identity.
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
SyncSelection();
}
private void SyncSelection()
{
foreach (UiCatalogSlot row in _rows.Values)
row.Selected = row.EntryId == _selectedSpellId;
}
private void ConfigureInfo()
{
if (_info is null) return;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return Array.Empty<UiText.Line>();
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return Array.Empty<UiText.Line>();
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
};
}
public void OnShown() => Rebuild();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
}
}

View file

@ -131,8 +131,9 @@ public sealed class ElementInfo
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
/// Resolved child elements. The importer ports retail child-table incorporation:
/// base-only children remain, same-ID children merge recursively, and derived-only
/// children append after the retained inherited entries.
/// </summary>
public List<ElementInfo> Children = new();
@ -264,7 +265,9 @@ public static class ElementReader
/// base entries are the default; derived entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
/// <see cref="ElementInfo.Children"/>: are not combined by this scalar merge helper;
/// <see cref="LayoutImporter"/> separately ports retail's recursive child-table
/// incorporation.
/// </description></item>
/// </list>
/// </para>
@ -306,10 +309,9 @@ public static class ElementReader
FontColor = derived.FontColor ?? base_.FontColor,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe because derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after).
// This helper merges one element snapshot only. LayoutImporter separately
// incorporates the child tables after the scalar/state merge.
// Defensive copy prevents later mutation of either input.
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.

View file

@ -295,11 +295,9 @@ public static class LayoutImporter
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
/// (close button / title), elements with their own children, and childless style
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
/// <summary>True when a pure-container inheritor needs the mounted-base Z-layer
/// correction. Child inheritance itself is unconditional and follows retail
/// <c>ElementDesc::Incorporate</c> (0x0069B5A0).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
@ -316,7 +314,7 @@ public static class LayoutImporter
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
ElementInfo? baseInfo = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
@ -327,32 +325,23 @@ public static class LayoutImporter
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture for the sub-window mount
}
}
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
// Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base,
// then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table:
// base-only children remain, same-ID children incorporate recursively, and
// derived-only children append after the retained base entries.
IncorporateChildren(dats, result, baseInfo?.Children, d);
// A pure-container sub-window mount needs one additional layer correction.
// Child-table incorporation has already happened above for every inheritor.
if (baseInfo is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count))
{
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
// inert for media-bearing inheritors (close button/title) and childless style prototypes
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
{
result.Children.AddRange(baseChildren);
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
@ -372,6 +361,57 @@ public static class LayoutImporter
return result;
}
private static void IncorporateChildren(
DatCollection dats,
ElementInfo result,
IReadOnlyList<ElementInfo>? baseChildren,
ElementDesc derived)
{
baseChildren ??= Array.Empty<ElementInfo>();
var baseById = baseChildren.ToDictionary(child => child.Id);
int retainedBaseCount = baseChildren.Count(child =>
!derived.Children.ContainsKey(child.Id));
foreach (ElementInfo baseChild in baseChildren)
{
bool hasOverlay = derived.Children.TryGetValue(
baseChild.Id, out ElementDesc? overlay);
ElementInfo child = hasOverlay
? IncorporateResolvedChild(dats, baseChild, overlay!)
: baseChild;
// Base-only descendants retain the base layout's design parent size;
// UiLayoutPolicy then performs retail's base-to-derived parent resize.
if (hasOverlay)
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Every new child receives a fresh base-chain set. Retail offsets its read order
// by the count of inherited children not replaced by a same-ID overlay.
foreach (var pair in derived.Children)
{
if (baseById.ContainsKey(pair.Key)) continue;
ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>());
child.ReadOrder += checked((uint)retainedBaseCount);
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
}
private static ElementInfo IncorporateResolvedChild(
DatCollection dats,
ElementInfo baseChild,
ElementDesc derivedChild)
{
// ElementDesc::Incorporate consumes the partial child directly. It does not
// independently re-resolve that child's BaseElement when the inherited table
// already contains the same identity.
ElementInfo self = ToInfo(derivedChild);
ElementInfo result = ElementReader.Merge(baseChild, self);
IncorporateChildren(dats, result, baseChild.Children, derivedChild);
return result;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not

View file

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-window port of retail <c>gmPanelUI::RecvNotice_SetPanelVisibility</c>
/// at <c>0x004BC6F0</c>. The original owns one active child panel and an optional
/// deferred child; this owner applies the same lifecycle to registered retained
/// windows without making the toolbar authoritative for non-toolbar panels.
/// </summary>
public sealed class RetailPanelUiController
{
public const uint RestorePreviousPropertyId = 0x10000049u;
private readonly Func<string, bool> _isVisible;
private readonly Func<string, bool> _show;
private readonly Func<string, bool> _hide;
private readonly Dictionary<uint, PanelEntry> _byPanel = new();
private readonly Dictionary<string, uint> _byWindow = new(StringComparer.Ordinal);
private uint? _activePanel;
private uint? _deferredPanel;
private bool _applying;
public RetailPanelUiController(
Func<string, bool> isVisible,
Func<string, bool> show,
Func<string, bool> hide)
{
_isVisible = isVisible ?? throw new ArgumentNullException(nameof(isVisible));
_show = show ?? throw new ArgumentNullException(nameof(show));
_hide = hide ?? throw new ArgumentNullException(nameof(hide));
}
public uint? ActivePanelId => _activePanel;
/// <param name="restorePrevious">
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
/// panel only when the newly shown panel has this property and the previous
/// panel does not.
/// </param>
public void Register(uint panelId, string windowName, bool restorePrevious = false)
{
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
throw new InvalidOperationException(
$"Panel {panelId} or retained window '{windowName}' is already registered.");
_byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious));
_byWindow.Add(windowName, panelId);
if (_isVisible(windowName))
ObserveWindowVisibility(windowName, visible: true);
}
/// <summary>
/// Registers a panel directly from its fully inherited retail root so the
/// authored deferred-panel behavior cannot be dropped at the mount seam.
/// </summary>
public void Register(uint panelId, string windowName, ElementInfo authoredRoot)
{
ArgumentNullException.ThrowIfNull(authoredRoot);
Register(
panelId,
windowName,
authoredRoot.TryGetEffectiveBool(
RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
}
public bool IsPanelVisible(uint panelId)
=> _byPanel.TryGetValue(panelId, out PanelEntry entry)
&& _isVisible(entry.WindowName);
public bool TogglePanel(uint panelId)
{
bool visible = !IsPanelVisible(panelId);
return SetPanelVisibility(panelId, visible) && visible;
}
public bool SetPanelVisibility(uint panelId, bool visible)
{
if (!_byPanel.TryGetValue(panelId, out PanelEntry requested)) return false;
_applying = true;
try
{
if (visible)
{
if (_activePanel == panelId)
return _show(requested.WindowName);
uint? previousId = _activePanel;
PanelEntry? previous = previousId is uint id
&& _byPanel.TryGetValue(id, out PanelEntry found)
&& _isVisible(found.WindowName)
? found
: null;
_deferredPanel = requested.RestorePrevious
&& previous is { RestorePrevious: false }
? previousId
: null;
_activePanel = panelId;
if (previous is not null)
_hide(previous.Value.WindowName);
return _show(requested.WindowName);
}
if (_activePanel != panelId)
{
if (_deferredPanel == panelId) _deferredPanel = null;
return _hide(requested.WindowName);
}
bool hidden = _hide(requested.WindowName);
_activePanel = null;
if (_deferredPanel is not uint deferredId
|| !_byPanel.TryGetValue(deferredId, out PanelEntry deferred))
return hidden;
_deferredPanel = null;
_activePanel = deferredId;
return _show(deferred.WindowName) || hidden;
}
finally
{
_applying = false;
}
}
/// <summary>
/// Feeds visibility changes made by persistence or another retained-window
/// owner back through the canonical panel lifecycle.
/// </summary>
public void ObserveWindowVisibility(string windowName, bool visible)
{
if (_applying || !_byWindow.TryGetValue(windowName, out uint panelId)) return;
SetPanelVisibility(panelId, visible);
}
private readonly record struct PanelEntry(string WindowName, bool RestorePrevious);
}

View file

@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
public enum SpellbookWindowPage { Spells, Components }
/// <summary>
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
/// </summary>
public sealed class SpellbookWindowController : IRetainedPanelController
{
public const uint LayoutId = 0x21000034u;
public const uint RootId = 0x100002A8u;
public const uint SpellTabId = 0x100002A9u;
public const uint ComponentTabId = 0x100002AAu;
public const uint CloseId = 0x100002ABu;
public const uint SpellPageId = 0x100002ACu;
public const uint ComponentPageId = 0x100002ADu;
public const uint SpellListId = 0x10000295u;
public const uint ComponentListId = 0x10000464u;
public const uint ComponentScrollbarId = 0x10000465u;
private static readonly (uint Id, uint Mask)[] FilterButtons =
[
(0x10000298u, 0x0001u), (0x10000299u, 0x0002u),
(0x1000029Au, 0x0004u), (0x1000029Bu, 0x0008u),
(0x100005C0u, 0x2000u),
(0x1000029Cu, 0x0010u), (0x1000029Du, 0x0020u),
(0x1000029Eu, 0x0040u), (0x1000029Fu, 0x0080u),
(0x100002A0u, 0x0100u), (0x100002A1u, 0x0200u),
(0x100002A2u, 0x0400u), (0x1000054Eu, 0x0800u),
];
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, int> _spellLevel;
private readonly Action<uint> _selectObject;
private readonly Action<uint> _addFavorite;
private readonly Action<uint> _sendFilter;
private readonly Action<uint, uint> _setDesiredComponent;
private readonly Action _close;
private readonly UiElement _spellPage;
private readonly UiElement _componentPage;
private readonly UiButton _spellTab;
private readonly UiButton _componentTab;
private readonly UiButton _closeButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private uint? _selectedSpell;
private bool _spellsDirty;
private bool _componentsDirty;
private bool _componentEditActive;
private bool _disposed;
private SpellbookWindowController(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close,
UiElement spellPage,
UiElement componentPage,
UiButton spellTab,
UiButton componentTab,
UiButton closeButton,
UiItemList spellList,
UiItemList componentList)
{
_spellbook = spellbook;
_objects = objects;
_playerGuid = playerGuid;
_components = components;
_resolveSpellIcon = resolveSpellIcon;
_resolveComponentIcon = resolveComponentIcon;
_spellLevel = spellLevel;
_selectObject = selectObject;
_addFavorite = addFavorite;
_sendFilter = sendFilter;
_setDesiredComponent = setDesiredComponent;
_close = close;
_spellPage = spellPage;
_componentPage = componentPage;
_spellTab = spellTab;
_componentTab = componentTab;
_closeButton = closeButton;
_spellList = spellList;
_componentList = componentList;
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
closeButton.OnClick = close;
foreach ((uint id, uint mask) in FilterButtons)
{
if (layout.FindElement(id) is not UiButton button) continue;
uint filterMask = mask;
button.OnClick = () => ToggleFilter(filterMask);
_filters.Add((button, mask));
}
ConfigureSpellList();
ConfigureComponentList(layout);
_spellbook.SpellbookChanged += OnSpellbookChanged;
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
ShowPage(SpellbookWindowPage.Spells);
RebuildAll();
}
public SpellbookWindowPage CurrentPage { get; private set; }
public static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Func<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
Action<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint, uint> setDesiredComponent,
Action close)
{
if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton
|| layout.FindElement(SpellListId) is not UiItemList spellList)
return null;
UiElement? componentHost = layout.FindElement(ComponentListId);
if (componentHost is null) return null;
UiItemList componentList;
if (componentHost is UiItemList itemList)
componentList = itemList;
else
{
componentList = new UiItemList(spellList.SpriteResolve)
{
Width = componentHost.Width,
Height = componentHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
};
componentHost.AddChild(componentList);
}
return new SpellbookWindowController(
layout, spellbook, objects, playerGuid, components,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
spellList, componentList);
}
public void ShowPage(SpellbookWindowPage page)
{
CurrentPage = page;
_spellPage.Visible = page == SpellbookWindowPage.Spells;
_componentPage.Visible = page == SpellbookWindowPage.Components;
_spellTab.Selected = page == SpellbookWindowPage.Spells;
_componentTab.Selected = page == SpellbookWindowPage.Components;
}
private void ConfigureSpellList()
{
_spellList.Columns = 6;
_spellList.CellWidth = 32f;
_spellList.CellHeight = 32f;
}
private void ConfigureComponentList(ImportedLayout layout)
{
_componentList.Columns = 1;
_componentList.CellWidth = Math.Max(1f, _componentList.Width);
_componentList.CellHeight = 32f;
if (layout.FindElement(ComponentScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = _componentList.Scroll;
}
private void ToggleFilter(uint mask)
{
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
}
private void RebuildAll()
{
RebuildSpells();
RebuildComponents();
_spellsDirty = false;
_componentsDirty = false;
}
private void RebuildSpells()
{
uint effective = _spellbook.SpellbookFilters;
foreach ((UiButton button, uint mask) in _filters)
button.Selected = (effective & mask) != 0;
SpellMetadata[] spells = _spellbook.LearnedSpells
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
.OrderBy(metadata => metadata!.SortKey)
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
.Cast<SpellMetadata>()
.ToArray();
using (_spellList.DeferLayout())
{
_spellList.Flush();
foreach (SpellMetadata metadata in spells)
{
uint spellId = metadata.SpellId;
var slot = new UiCatalogSlot
{
EntryId = spellId,
CatalogIconTexture = _resolveSpellIcon(spellId),
Label = metadata.Name,
SpriteResolve = _spellList.SpriteResolve,
};
slot.Clicked = () => SelectSpell(spellId);
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
// gmSpellcastingUI tab chooses the server favorite-list destination.
slot.DoubleClicked = () => { SelectSpell(spellId); _addFavorite(spellId); };
_spellList.AddItem(slot);
}
}
SyncSpellSelection();
}
private void RebuildComponents()
{
uint player = _playerGuid();
var packs = _objects.Objects
.Where(item => (item.IsComponentPack || _components.ContainsKey(item.WeenieClassId))
&& IsOwnedByPlayer(item, player))
.GroupBy(item => item.WeenieClassId)
.Select(group => new
{
ComponentId = group.Key,
First = group.First(),
Quantity = group.Sum(item => Math.Max(1, item.StackSize)),
})
.OrderBy(group => _components.TryGetValue(group.ComponentId, out var descriptor) ? descriptor.Category : uint.MaxValue)
.ThenBy(group => group.First.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
float width = Math.Max(96f, _componentList.Width);
_componentList.CellWidth = width;
using (_componentList.DeferLayout())
{
_componentList.Flush();
uint? category = null;
foreach (var packGroup in packs)
{
ClientObject pack = packGroup.First;
uint componentId = packGroup.ComponentId;
_components.TryGetValue(componentId, out SpellComponentDescriptor? descriptor);
uint nextCategory = descriptor?.Category ?? 8u;
if (category != nextCategory)
{
category = nextCategory;
_componentList.AddItem(new UiCatalogSlot
{
EntryId = uint.MaxValue,
Label = ComponentCategoryName(nextCategory),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
});
}
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
var row = new UiCatalogSlot
{
EntryId = componentId,
CatalogIconTexture = _resolveComponentIcon(
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
ShowLabel = true,
SpriteResolve = _componentList.SpriteResolve,
};
var desiredField = new UiField
{
Left = width - 47f,
Top = 4f,
Width = 43f,
Height = 24f,
MaxCharacters = 4,
ClearOnSubmit = false,
RecordHistory = false,
SelectAllOnFocus = true,
RightAligned = true,
CharacterFilter = char.IsAsciiDigit,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
};
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
desiredField.OnFocusGained = () => _componentEditActive = true;
uint committed = desired;
void Commit(string value)
{
if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)
|| parsed > 5000u)
{
desiredField.SetText(committed.ToString(CultureInfo.InvariantCulture));
return;
}
if (parsed == committed) return;
committed = parsed;
_spellbook.SetDesiredComponent(componentId, parsed);
_setDesiredComponent(componentId, parsed);
}
desiredField.OnSubmit = Commit;
desiredField.OnFocusLost = value =>
{
Commit(value);
_componentEditActive = false;
};
row.AddChild(desiredField);
row.Clicked = () => _selectObject(pack.ObjectId);
_componentList.AddItem(row);
}
}
}
private static string ComponentCategoryName(uint category) => category switch
{
0u => "Scarabs",
1u => "Herbs",
2u => "Powdered Gems",
3u => "Alchemical Substances",
4u => "Talismans",
5u => "Tapers",
6u => "Prismatic Components",
_ => "Other Components",
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid) return true;
if (current.ContainerId == 0 || _objects.Get(current.ContainerId) is not { } parent) return false;
current = parent;
}
return false;
}
private bool IsVisible(SpellMetadata metadata, uint filters)
{
uint school = metadata.School switch
{
"Creature Enchantment" => 0x0001u,
"Item Enchantment" => 0x0002u,
"Life Magic" => 0x0004u,
"War Magic" => 0x0008u,
"Void Magic" => 0x2000u,
_ => 0u,
};
int spellLevel = _spellLevel(metadata.SpellId);
if (spellLevel is < 1 or > 8) return false;
uint level = 0x10u << (spellLevel - 1);
return school != 0 && (filters & school) != 0 && (filters & level) != 0;
}
private void SelectSpell(uint spellId)
{
_selectedSpell = spellId;
SyncSpellSelection();
}
private void SyncSpellSelection()
{
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
if (_spellList.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selectedSpell;
}
public void Tick()
{
if (_spellsDirty)
{
_spellsDirty = false;
RebuildSpells();
}
if (_componentsDirty && !_componentEditActive)
{
_componentsDirty = false;
RebuildComponents();
}
}
private void OnSpellbookChanged() => _spellsDirty = true;
private void OnDesiredComponentsChanged() => _componentsDirty = true;
private void OnObjectChanged(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectRemoved(ClientObject item)
{
if (IsComponent(item)) _componentsDirty = true;
}
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
private bool IsComponent(ClientObject item)
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
public void OnShown() => RebuildAll();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_spellbook.DesiredComponentsChanged -= OnDesiredComponentsChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_spellTab.OnClick = null;
_componentTab.OnClick = null;
_closeButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null;
}
}

View file

@ -0,0 +1,472 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Spells;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Core.Selection;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
/// request through <see cref="SpellCastingController"/>.
/// </summary>
public sealed class SpellcastingUiController : IRetainedPanelController
{
public const uint PageId = 0x10000061u;
public const uint SpellNameId = 0x1000048Bu;
public const uint EndowmentId = 0x100000B1u;
public const uint CastButtonId = 0x100000B2u;
public const uint FavoriteListId = 0x100000B6u;
private static readonly uint[] TabIds =
[
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
];
private static readonly uint[] GroupIds =
[
0x100000AAu, 0x100000ABu, 0x100000ACu, 0x100000ADu,
0x100000AEu, 0x100000AFu, 0x100000B0u, 0x100005C3u,
];
private readonly Spellbook _spellbook;
private readonly SpellCastingController _casting;
private readonly SelectionState _selection;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
private readonly Action<uint> _useItem;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
private readonly UiButton _cast;
private readonly UiText? _spellName;
private readonly UiElement _endowmentHost;
private readonly UiCatalogSlot _endowmentSlot;
private int _activeTab;
private readonly uint?[] _selected = new uint?[8];
private readonly bool[] _endowmentSelected = new bool[8];
private uint _endowmentItemId;
private uint _endowmentSpellId;
private bool _disposed;
private bool _favoritesDirty;
private bool _endowmentDirty;
private SpellcastingUiController(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
UiElement[] tabs,
UiElement[] groups,
UiItemList?[] lists,
UiButton cast,
UiElement endowmentHost)
{
_spellbook = spellbook;
_casting = casting;
_selection = selection;
_objects = objects;
_playerGuid = playerGuid;
_resolveSpellIcon = resolveSpellIcon;
_resolveItemDragIcon = resolveItemDragIcon;
_useItem = useItem;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_tabs = tabs;
_groups = groups;
_lists = lists;
_cast = cast;
_spellName = layout.FindElement(SpellNameId) as UiText;
_endowmentHost = endowmentHost;
foreach (UiElement child in _endowmentHost.Children) child.Visible = false;
_endowmentSlot = new UiCatalogSlot
{
Left = 0f,
Top = 0f,
Width = endowmentHost.Width,
Height = endowmentHost.Height,
SpriteResolve = lists.FirstOrDefault(list => list is not null)?.SpriteResolve,
};
_endowmentSlot.Clicked = SelectEndowment;
_endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); };
_endowmentHost.AddChild(_endowmentSlot);
for (int i = 0; i < tabs.Length; i++)
{
int index = i;
SetClick(tabs[i], () => SelectTab(index));
}
_cast.OnClick = CastSelected;
ConfigureSpellName();
_spellbook.SpellbookChanged += OnSpellbookChanged;
_selection.Changed += OnSelectionChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectRemoved += OnObjectChanged;
_objects.Cleared += OnObjectsCleared;
UpdateEndowment();
SelectTab(0);
Rebuild();
}
public int ActiveTab => _activeTab;
public static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
SpellCastingController casting,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
return null;
var tabs = new UiElement[8];
var groups = new UiElement[8];
var lists = new UiItemList?[8];
for (int i = 0; i < 8; i++)
{
if (layout.FindElement(TabIds[i]) is not { } tab
|| layout.FindElement(GroupIds[i]) is not { } group)
return null;
tabs[i] = tab;
groups[i] = group;
lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault();
}
return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost);
}
public void AddFavorite(uint spellId)
{
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (spells.Contains(spellId))
{
SelectSpell(spellId);
return;
}
int position = spells.Count;
_addFavorite?.Invoke(_activeTab, position, spellId);
_spellbook.SetFavorite(_activeTab, position, spellId);
SelectSpell(spellId);
}
public bool Handle(InputAction action)
{
if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9)
{
int index = (int)action - (int)InputAction.UseSpellSlot_1;
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (index < spells.Count)
{
SelectSpell(spells[index]);
CastSelected();
}
return true;
}
switch (action)
{
case InputAction.CombatPrevSpellTab: SelectTab((_activeTab + 7) % 8); return true;
case InputAction.CombatNextSpellTab: SelectTab((_activeTab + 1) % 8); return true;
case InputAction.CombatFirstSpellTab: SelectTab(0); return true;
case InputAction.CombatLastSpellTab: SelectTab(7); return true;
case InputAction.CombatPrevSpell: MoveSelection(-1, false); return true;
case InputAction.CombatNextSpell: MoveSelection(1, false); return true;
case InputAction.CombatFirstSpell: MoveSelection(0, true); return true;
case InputAction.CombatLastSpell: MoveSelection(-1, true); return true;
case InputAction.CombatCastCurrentSpell: CastSelected(); return true;
default: return false;
}
}
private void SelectTab(int tab)
{
_activeTab = Math.Clamp(tab, 0, 7);
for (int i = 0; i < 8; i++)
{
SetSelected(_tabs[i], i == _activeTab);
_groups[i].Visible = i == _activeTab;
}
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(_activeTab);
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
_selected[_activeTab] = null;
else if (_selected[_activeTab] is not uint selected || !favorites.Contains(selected))
{
_selected[_activeTab] = favorites.Count == 0 ? null : favorites[0];
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
}
SyncSelection();
UpdateCastAvailability();
}
private void MoveSelection(int delta, bool edge)
{
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
int count = spells.Count + (_endowmentItemId != 0u ? 1 : 0);
if (count == 0) return;
int current = _endowmentSelected[_activeTab]
? 0
: (_selected[_activeTab] is uint id ? spells.IndexOf(id) : -1)
+ (_endowmentItemId != 0u ? 1 : 0);
int next = edge ? (delta < 0 ? count - 1 : 0) : (current + delta + count) % count;
if (_endowmentItemId != 0u && next == 0) SelectEndowment();
else SelectSpell(spells[next - (_endowmentItemId != 0u ? 1 : 0)]);
}
private void SelectSpell(uint spellId)
{
_endowmentSelected[_activeTab] = false;
_selected[_activeTab] = spellId;
SyncSelection();
UpdateCastAvailability();
}
private void CastSelected()
{
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
_useItem(_endowmentItemId);
else if (_selected[_activeTab] is uint spellId)
_casting.Cast(spellId);
}
private void SelectEndowment()
{
if (_endowmentItemId == 0u) return;
_endowmentSelected[_activeTab] = true;
_selected[_activeTab] = null;
SyncSelection();
UpdateCastAvailability();
}
private void Rebuild()
{
for (int tab = 0; tab < 8; tab++)
{
UiItemList? list = _lists[tab];
if (list is null) continue;
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
using (list.DeferLayout())
{
list.Flush();
list.Columns = 9;
list.CellWidth = 32f;
list.CellHeight = 32f;
foreach (uint spellId in favorites)
{
uint id = spellId;
int position = list.GetNumUIItems();
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
var slot = new UiCatalogSlot
{
EntryId = id,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
Label = metadata?.Name ?? $"Spell {id}",
SpriteResolve = list.SpriteResolve,
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
Dropped = payload =>
{
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, tab, position);
},
};
slot.Clicked = () => SelectSpell(id);
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
list.AddItem(slot);
}
}
}
SelectTab(_activeTab);
}
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
private void EndFavoriteDrag(SpellFavoriteDragPayload payload)
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId);
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
{
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}
private void OnSpellbookChanged() => _favoritesDirty = true;
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
private void OnObjectsCleared() => _endowmentDirty = true;
public void Tick()
{
if (_endowmentDirty)
{
_endowmentDirty = false;
UpdateEndowment();
SelectTab(_activeTab);
}
if (_favoritesDirty)
{
_favoritesDirty = false;
Rebuild();
}
}
private void SyncSelection()
{
for (int tab = 0; tab < 8; tab++)
{
UiItemList? list = _lists[tab];
if (list is null) continue;
for (int i = 0; i < list.GetNumUIItems(); i++)
if (list.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selected[tab];
_endowmentSlot.Selected = _endowmentItemId != 0u
&& _endowmentSelected[_activeTab];
}
}
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
private void UpdateCastAvailability()
=> _cast.Enabled = _endowmentSelected[_activeTab]
? _endowmentItemId != 0u
: _selected[_activeTab] is uint spellId
&& _casting.IsTargetReady(spellId);
private void ConfigureSpellName()
{
if (_spellName is null) return;
_spellName.OneLine = true;
_spellName.Centered = true;
_spellName.Padding = 0;
_spellName.LinesProvider = () =>
{
uint? chosenSpell = _endowmentSelected[_activeTab]
? (_endowmentSpellId == 0u ? null : _endowmentSpellId)
: _selected[_activeTab];
string name = chosenSpell is uint spellId
&& _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)
? metadata.Name : string.Empty;
return [new UiText.Line(name, _spellName.DefaultColor)];
};
}
private void UpdateEndowment()
{
ClientObject? endowment = _objects.GetEquippedBy(_playerGuid())
.FirstOrDefault(item =>
(item.CurrentlyEquippedLocation & EquipMask.Held) != 0
&& (item.Type & ItemType.Caster) != 0
&& item.SpellId.GetValueOrDefault() != 0u);
_endowmentItemId = endowment?.ObjectId ?? 0u;
_endowmentSpellId = endowment?.SpellId ?? 0u;
_endowmentHost.Visible = endowment is not null;
_endowmentSlot.EntryId = _endowmentItemId;
_endowmentSlot.CatalogIconTexture = _endowmentSpellId == 0u
? 0u : _resolveSpellIcon(_endowmentSpellId);
_endowmentSlot.CatalogOverlayTexture = endowment is null
? 0u : _resolveItemDragIcon(endowment);
string spellName = _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata metadata)
? metadata.Name : $"Spell {_endowmentSpellId}";
_endowmentSlot.Label = endowment is null
? string.Empty : $"{endowment.GetAppropriateName()} ({spellName})";
for (int tab = 0; tab < 8; tab++)
{
if (_endowmentItemId != 0u && _selected[tab] is null)
_endowmentSelected[tab] = true;
else if (_endowmentItemId == 0u && _endowmentSelected[tab])
_endowmentSelected[tab] = false;
}
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement nested in Descendants(child)) yield return nested;
}
}
public void OnShown() => SyncSelection();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_selection.Changed -= OnSelectionChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
foreach (UiElement tab in _tabs) SetClick(tab, null);
_cast.OnClick = null;
}
private static void SetClick(UiElement element, Action? action)
{
element.ClickThrough = action is null;
switch (element)
{
case UiButton button: button.OnClick = action; break;
case UiText text: text.OnClick = action; break;
case UiDatElement dat: dat.OnClick = action; break;
}
}
private static void SetSelected(UiElement element, bool selected)
{
if (element is UiButton button)
button.Selected = selected;
else if (element is UiText text)
text.DefaultColor = selected
? new System.Numerics.Vector4(1f, 1f, 1f, 1f)
: new System.Numerics.Vector4(0.65f, 0.65f, 0.65f, 1f);
}
}
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
internal static class FavoriteListExtensions
{
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
{
for (int i = 0; i < values.Count; i++) if (values[i] == value) return i;
return -1;
}
}

View file

@ -1,24 +1,36 @@
namespace AcDream.App.UI;
/// <summary>
/// Retail toolbar panel ids carried by DAT property <c>0x10000029</c>.
/// Retail reference: <c>gmToolbarUI::PostInit @ 0x004BEA80</c> discovers the
/// seven buttons and reads this property from each one.
/// Only panels mounted in the retained runtime are mapped; other authored
/// toolbar buttons remain present but ghosted until their panel ships.
/// Retail <c>gmPanelUI</c> panel ids carried by DAT property <c>0x10000029</c>.
/// The toolbar exposes only a subset of these ids; Helpful/Harmful effects use
/// the authored <c>gmUIElement_EffectsIndicator</c> buttons instead.
/// </summary>
public static class RetailPanelCatalog
{
public const uint PositiveEffects = 4u;
public const uint NegativeEffects = 5u;
public const uint Inventory = 7u;
public const uint Character = 11u;
public const uint Magic = 13u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(PositiveEffects, WindowNames.PositiveEffects),
(NegativeEffects, WindowNames.NegativeEffects),
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =
{
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
};
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
public static IReadOnlyList<(uint PanelId, string WindowName)> ToolbarPanels => Toolbar;
public static bool TryGetWindowName(uint panelId, out string windowName)
{

View file

@ -3,12 +3,14 @@ using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
@ -43,6 +45,26 @@ public sealed record CombatRuntimeBindings(
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record MagicRuntimeBindings(
Spellbook Spellbook,
SpellCastingController Casting,
ClientObjectTable Objects,
Func<uint> PlayerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<uint, uint> ResolveSpellIcon,
Func<uint, uint> ResolveComponentIcon,
SelectionState Selection,
Func<uint, int> SpellLevel,
Action<uint> SelectObject,
Action<uint> UseItem,
Action<int, int, uint> AddFavorite,
Action<int, uint> RemoveFavorite,
Action<uint> SendSpellbookFilter,
Action<uint, uint> SetDesiredComponent,
Func<double> ServerTime);
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
public sealed record FpsRuntimeBindings(
@ -116,6 +138,7 @@ public sealed record RetailUiRuntimeBindings(
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
CombatRuntimeBindings Combat,
MagicRuntimeBindings Magic,
JumpPowerbarRuntimeBindings JumpPowerbar,
FpsRuntimeBindings Fps,
ToolbarRuntimeBindings Toolbar,
@ -139,6 +162,7 @@ public sealed class RetailUiRuntime : IDisposable
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
private readonly RetailPanelUiController _panelUi;
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
@ -147,12 +171,19 @@ public sealed class RetailUiRuntime : IDisposable
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
_panelUi = new RetailPanelUiController(
bindings.Host.IsWindowVisible,
bindings.Host.ShowWindow,
bindings.Host.HideWindow);
MountFpsDisplay();
MountVitals();
MountRadar();
MountChat();
MountToolbar();
MountCombat();
MountSpellbook();
MountEffects();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountCharacter();
@ -192,6 +223,11 @@ public sealed class RetailUiRuntime : IDisposable
public ToolbarController? ToolbarController { get; private set; }
public ToolbarInputController? ToolbarInputController { get; private set; }
public CombatUiController? CombatUiController { get; private set; }
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public EffectsIndicatorController? EffectsIndicatorController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; }
public RetailFpsController? FpsController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
@ -216,6 +252,10 @@ public sealed class RetailUiRuntime : IDisposable
public void Tick(double deltaSeconds)
{
FpsController?.Tick();
SpellbookWindowController?.Tick();
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
DialogFactory?.Tick();
@ -226,7 +266,33 @@ public sealed class RetailUiRuntime : IDisposable
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize);
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
=> ToolbarInputController?.Handle(action) == true;
{
if (SpellcastingUiController?.Handle(action) == true)
return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
{
OpenSpellbook(SpellbookWindowPage.Spells);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
{
OpenSpellbook(SpellbookWindowPage.Components);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
private void OpenSpellbook(SpellbookWindowPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
if (visible && SpellbookWindowController?.CurrentPage == page)
CloseWindow(WindowNames.Spellbook);
else
{
SpellbookWindowController?.ShowPage(page);
_panelUi.SetPanelVisibility(RetailPanelCatalog.Magic, visible: true);
}
}
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
=> _gameplayConfirmationController?.HandleRequest(request) == true;
@ -258,15 +324,22 @@ public sealed class RetailUiRuntime : IDisposable
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
public bool ToggleWindow(string name)
=> Host.ToggleWindow(name);
=> RetailPanelCatalog.TryGetPanelId(name, out uint panelId)
? _panelUi.TogglePanel(panelId)
: Host.ToggleWindow(name);
public void CloseWindow(string name)
=> Host.HideWindow(name);
{
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
_panelUi.SetPanelVisibility(panelId, visible: false);
else
Host.HideWindow(name);
}
public void SyncToolbarWindowButtons()
{
if (ToolbarController is null) return;
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
foreach (var (panelId, windowName) in RetailPanelCatalog.ToolbarPanels)
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
}
@ -284,6 +357,7 @@ public sealed class RetailUiRuntime : IDisposable
private void OnWindowVisibilityChanged(string windowName, bool visible)
{
_panelUi.ObserveWindowVisibility(windowName, visible);
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
}
@ -583,7 +657,28 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
SpellcastingUiController? spellcasting = Layout.SpellcastingUiController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Casting,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.ResolveSpellIcon,
item => _bindings.Magic.ResolveDragIcon(
item.Type, item.IconId, item.IconUnderlayId,
item.IconOverlayId, item.Effects),
_bindings.Magic.UseItem,
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite);
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
CombatUiController = controller;
SpellcastingUiController = spellcasting;
IRetainedPanelController controllerOwner = spellcasting is null
? controller
: new RetainedPanelControllerGroup(controller, spellcasting);
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
@ -601,10 +696,190 @@ public sealed class RetailUiRuntime : IDisposable
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
Controller = controllerOwner,
});
controller.SyncVisibility();
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
Console.WriteLine(spellcasting is null
? "[M2] retail combat from LayoutDesc 0x21000073; magic binding unavailable."
: "[M3] retail combat + spell bar from LayoutDesc 0x21000073.");
}
private void MountSpellbook()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
SpellbookWindowController.LayoutId,
SpellbookWindowController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
return;
}
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
layout,
_bindings.Magic.Spellbook,
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.Components,
_bindings.Magic.ResolveSpellIcon,
_bindings.Magic.ResolveComponentIcon,
_bindings.Magic.SpellLevel,
_bindings.Magic.SelectObject,
spellId => SpellcastingUiController?.AddFavorite(spellId),
_bindings.Magic.SendSpellbookFilter,
_bindings.Magic.SetDesiredComponent,
() => CloseWindow(WindowNames.Spellbook));
if (controller is null)
{
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
return;
}
SpellbookWindowController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Spellbook,
Chrome = RetailWindowChrome.Imported,
Left = 18f,
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.Register(RetailPanelCatalog.Magic, WindowNames.Spellbook);
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
}
private void MountEffects()
{
MountEffectsInstance(positive: true);
MountEffectsInstance(positive: false);
}
private void MountEffectsInstance(bool positive)
{
uint rootId = positive ? EffectsUiController.PositiveRootId : EffectsUiController.NegativeRootId;
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
EffectsUiController.LayoutId,
rootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine($"[M3] effects: root 0x{rootId:X8} not found.");
return;
}
EffectsUiController? controller = Layout.EffectsUiController.Bind(
layout,
_bindings.Magic.Spellbook,
positive,
_bindings.Magic.ServerTime,
_bindings.Assets.ResolveSprite,
_bindings.Magic.ResolveSpellIcon,
close: () => CloseWindow(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects));
if (controller is null)
{
Console.WriteLine($"[M3] effects: list missing under root 0x{rootId:X8}.");
return;
}
if (positive) PositiveEffectsController = controller;
else NegativeEffectsController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, Host.Root.Width - root.Width - 12f),
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.Register(
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects,
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
rootInfo);
}
private void MountIndicators()
{
ImportedLayout? layout = Import(EffectsIndicatorController.LayoutId);
if (layout is null)
{
Console.WriteLine("[M3] effects indicators: LayoutDesc 0x21000071 not found.");
return;
}
EffectsIndicatorController? controller = Layout.EffectsIndicatorController.Bind(
layout,
_bindings.Magic.Spellbook,
panelId => _panelUi.TogglePanel(panelId));
if (controller is null)
{
Console.WriteLine("[M3] effects indicators: authored Helpful/Harmful buttons missing.");
return;
}
EffectsIndicatorController = controller;
RetailWindowFrame.Mount(
Host.Root,
layout.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Indicators,
Chrome = RetailWindowChrome.Imported,
Left = 10f,
Top = 96f,
Visible = true,
Resizable = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
Console.WriteLine("[M3] retail Helpful/Harmful effect indicators from LayoutDesc 0x21000071.");
}
private void MountJumpPowerbar()
@ -825,6 +1100,7 @@ public sealed class RetailUiRuntime : IDisposable
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
});
_panelUi.Register(RetailPanelCatalog.Character, WindowNames.Character);
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
@ -906,6 +1182,7 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root.Height - root.Top),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
_panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
uint contents, sideBag, mainPack;
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;

View file

@ -0,0 +1,97 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Icon-list cell for non-weenie catalog entries such as spells and components.
/// It reuses the retail UIItemList geometry without pretending a spell id is an
/// object GUID and without participating in item drag/drop.
/// </summary>
public sealed class UiCatalogSlot : UiItemSlot
{
public uint EntryId { get; set; }
public uint CatalogIconTexture { get; set; }
/// <summary>Optional second 32x32 layer (retail spell endowment item icon).</summary>
public uint CatalogOverlayTexture { get; set; }
public string Label { get; set; } = string.Empty;
public string? Detail { get; set; }
public bool ShowLabel { get; init; }
public new Action? Clicked { get; set; }
public new Action? DoubleClicked { get; set; }
public object? CatalogDragPayload { get; init; }
public Action<object>? DragBegan { get; init; }
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
public override object? GetDragPayload() => CatalogDragPayload;
public override (uint tex, int w, int h)? GetDragGhost() =>
CatalogDragPayload is not null && CatalogIconTexture != 0u
? (CatalogIconTexture, 32, 32)
: null;
internal override void SetDragSourceActive(bool active, object? payload)
{
if (!active && payload is not null) DragEnded?.Invoke(payload);
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
return true;
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;
case UiEventType.DragEnter:
case UiEventType.DragOver:
return true;
case UiEventType.DropReleased:
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
return true;
default:
return false;
}
}
protected override void OnDraw(UiRenderContext ctx)
{
float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
if (CatalogIconTexture != 0)
ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (texture, _, _) = SpriteResolve(EmptySprite);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
}
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
{
var (texture, _, _) = SpriteResolve(SelectedSprite);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (ShowLabel)
{
ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
if (!string.IsNullOrWhiteSpace(Detail))
ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
}
}
}

View file

@ -61,6 +61,7 @@ public sealed class UiField : UiElement
private int _caret;
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
public string Text => _text;
public bool IsFocused => _focused;
public int CaretPos => _caret;
private readonly List<string> _history = new();

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// pre-composited icon texture (set by the controller). Holds the bound weenie
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
/// </summary>
public sealed class UiItemSlot : UiElement
public class UiItemSlot : UiElement
{
public UiItemSlot() { ClickThrough = false; }

View file

@ -22,6 +22,9 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiText : UiElement
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
public override bool HandlesClick => OnClick is not null || base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
@ -360,6 +363,11 @@ public sealed class UiText : UiElement
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null)
{
OnClick();
return true;
}
switch (e.Type)
{
case UiEventType.Scroll:

View file

@ -12,4 +12,8 @@ public static class WindowNames
public const string Radar = "radar";
public const string Combat = "combat";
public const string JumpPowerbar = "jump-powerbar";
public const string Spellbook = "spellbook";
public const string Indicators = "indicators";
public const string PositiveEffects = "effects-positive";
public const string NegativeEffects = "effects-negative";
}

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -77,13 +78,15 @@ public static class GameEventWiring
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
clientTime ??= static () => 0d;
// ── Chat ──────────────────────────────────────────────────
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
@ -283,21 +286,44 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
{
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentAdded(
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
});
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
if (entries is not null)
{
double receivedAt = clientTime();
spellbook.OnEnchantmentsAdded(entries.Select(entry =>
ToActiveEnchantment(entry, receivedAt)));
}
});
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
{
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
_ => spellbook.OnPurgeBadEnchantments());
// ── Inventory ─────────────────────────────────────────────
dispatcher.Register(GameEventType.WieldObject, e =>
@ -407,8 +433,8 @@ public static class GameEventWiring
// spellbook arrives via PlayerDescription (0x0013), which uses
// a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
// The appraised spellbook belongs to that item. The local player's
// learned spell manifest arrives only in PlayerDescription.
});
// ── Player ────────────────────────────────────────────────
@ -441,6 +467,17 @@ public static class GameEventWiring
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
onDesiredComponents?.Invoke(p.Value.DesiredComps);
double receivedAt = clientTime();
ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments
.Select(entry => ToActiveEnchantment(entry, receivedAt))
.ToArray();
spellbook.ReplaceManifest(
p.Value.Spells,
enchantments,
p.Value.HotbarSpells,
p.Value.DesiredComps,
p.Value.SpellbookFilters);
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
@ -508,9 +545,6 @@ public static class GameEventWiring
}
}
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// K-fix7 (2026-04-26): push Run + Jump skill values to the
// PlayerMovementController so the runRate / jump-arc formulas
// use the SERVER's authoritative skill instead of our
@ -566,21 +600,6 @@ public static class GameEventWiring
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
// source. Record existence (+ equip slot); CreateObject fills the
// actual weenie data via ObjectTableWiring. (Previously this seeded
@ -630,4 +649,46 @@ public static class GameEventWiring
onShortcuts?.Invoke(p.Value.Shortcuts);
});
}
private static ActiveEnchantmentRecord ToActiveEnchantment(
PlayerDescriptionParser.EnchantmentEntry enchantment,
double receivedAt) => new(
SpellId: enchantment.SpellId,
LayerId: enchantment.Layer,
Duration: enchantment.Duration,
CasterGuid: enchantment.CasterGuid,
StatModType: enchantment.StatModType,
StatModKey: enchantment.StatModKey,
StatModValue: enchantment.StatModValue,
Bucket: enchantment.Bucket == 0
? ClassifyLiveEnchantmentBucket(enchantment.StatModType)
: (uint)enchantment.Bucket,
// Retail Enchantment::UnPack (0x005CB040) converts both relative
// wire timestamps to the monotonic client Timer domain at receipt.
StartTime: receivedAt + enchantment.StartTime,
SpellCategory: enchantment.SpellCategory,
PowerLevel: enchantment.PowerLevel,
DegradeModifier: enchantment.DegradeModifier,
DegradeLimit: enchantment.DegradeLimit,
LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded,
SpellSetId: enchantment.SpellSetId);
/// <summary>
/// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer
/// EnchantmentMask bucket. Retail reconstructs the registry list from the
/// StatMod type flags; this is the same ordering used by ACE's
/// EnchantmentRegistry.BuildCategories.
/// </summary>
private static uint ClassifyLiveEnchantmentBucket(uint statModType)
{
const uint Multiplicative = 0x00004000u;
const uint Additive = 0x00008000u;
const uint Vitae = 0x00800000u;
const uint Cooldown = 0x01000000u;
if ((statModType & Vitae) != 0) return 4u;
if ((statModType & Cooldown) != 0) return 8u;
if ((statModType & Multiplicative) != 0) return 1u;
if ((statModType & Additive) != 0) return 2u;
return 0u;
}
}

View file

@ -33,6 +33,9 @@ public static class ClientCommandRequests
public const uint DisplayConsentOpcode = 0x0217u;
public const uint RemoveConsentOpcode = 0x0218u;
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
public const uint AddSpellFavoriteOpcode = 0x01E3u;
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
public const uint SpellbookFilterOpcode = 0x0286u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
@ -177,6 +180,31 @@ public static class ClientCommandRequests
return body;
}
// CM_Character::Event_AddSpellFavorite @ 0x006A0F70.
public static byte[] BuildAddSpellFavorite(
uint sequence, uint spellId, int position, int tabIndex)
{
byte[] body = CreateBody(sequence, AddSpellFavoriteOpcode, 12);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), position);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(20), tabIndex);
return body;
}
// CM_Character::Event_RemoveSpellFavorite @ 0x006A1890.
public static byte[] BuildRemoveSpellFavorite(
uint sequence, uint spellId, int tabIndex)
{
byte[] body = CreateBody(sequence, RemoveSpellFavoriteOpcode, 8);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), tabIndex);
return body;
}
// CM_Character::Event_SpellbookFilterEvent @ 0x006A1A30.
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -207,6 +207,9 @@ public static class CreateObject
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
// AMMO_NONE is the explicit wire value zero; null means absent.
ushort? AmmoType = null,
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member.
uint? SpellId = null,
// Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
@ -862,6 +865,7 @@ public static class CreateObject
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -1011,6 +1015,7 @@ public static class CreateObject
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
@ -1113,6 +1118,7 @@ public static class CreateObject
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
Physics: physics);
}
catch

View file

@ -0,0 +1,115 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail <c>Enchantment::UnPack</c> reader shared by PlayerDescription and
/// the 0x02C2/0x02C4 magic update events. The record is 60 bytes, plus the
/// optional four-byte spell-set id.
/// </summary>
/// <remarks>
/// Retail reference: <c>CM_Magic::DispatchUI_UpdateEnchantment</c>
/// (0x006A32F0). Layout cross-checked against ACE
/// <c>Network/Structure/Enchantment.cs</c>.
/// </remarks>
public static class EnchantmentWireReader
{
public static PlayerDescriptionParser.EnchantmentEntry Read(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 60)
throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(source, ref position);
ushort layer = ReadU16(source, ref position);
ushort spellCategory = ReadU16(source, ref position);
ushort hasSpellSetId = ReadU16(source, ref position);
uint powerLevel = ReadU32(source, ref position);
double startTime = ReadF64(source, ref position);
double duration = ReadF64(source, ref position);
uint casterGuid = ReadU32(source, ref position);
float degradeModifier = ReadF32(source, ref position);
float degradeLimit = ReadF32(source, ref position);
double lastDegraded = ReadF64(source, ref position);
uint statModType = ReadU32(source, ref position);
uint statModKey = ReadU32(source, ref position);
float statModValue = ReadF32(source, ref position);
uint? spellSetId = hasSpellSetId != 0 ? ReadU32(source, ref position) : null;
if (!double.IsFinite(startTime)
|| !double.IsFinite(duration)
|| !float.IsFinite(degradeModifier)
|| !float.IsFinite(degradeLimit)
|| !double.IsFinite(lastDegraded)
|| !float.IsFinite(statModValue))
{
throw new FormatException("non-finite enchantment value");
}
return new PlayerDescriptionParser.EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry> ReadList(
ReadOnlySpan<byte> source,
ref int position,
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
{
if (source.Length - position < 4)
throw new FormatException("truncated enchantment list count");
uint count = ReadU32(source, ref position);
if (count > 0x4000)
throw new FormatException("unreasonable enchantment list count");
var result = new List<PlayerDescriptionParser.EnchantmentEntry>((int)count);
for (uint i = 0; i < count; i++)
result.Add(Read(source, ref position, bucket));
return result;
}
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 2);
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(position, 2));
position += 2;
return value;
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static float ReadF32(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 4);
float value = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(position, 4));
position += 4;
return value;
}
private static double ReadF64(ReadOnlySpan<byte> source, ref int position)
{
Require(source, position, 8);
double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(position, 8));
position += 8;
return value;
}
private static void Require(ReadOnlySpan<byte> source, int position, int count)
{
if (position < 0 || source.Length - position < count)
throw new FormatException("truncated enchantment record");
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Net.Messages;
@ -271,14 +272,19 @@ public static class GameEvents
/// <summary>
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
/// </summary>
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
{
public uint Packed => SpellId | ((uint)Layer << 16);
}
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
if (payload.Length < 4) return null;
return new MagicRemoveEnchantment(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
BinaryPrimitives.ReadUInt16LittleEndian(payload),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
}
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
@ -294,26 +300,20 @@ public static class GameEvents
/// stat mods. We expose the first few fields that drive the enchant
/// bar UI; the rest is available via the raw payload view.
/// </summary>
public readonly record struct EnchantmentSummary(
uint SpellId,
uint LayerId,
float Duration,
uint CasterGuid);
public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan<byte> payload)
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
ReadOnlySpan<byte> payload)
{
// Layout (ACE Enchantment.Pack):
// u32 spellId
// u32 layerId
// f32 duration
// u32 casterGuid
// ... (stat mods, category, power, etc.)
if (payload.Length < 16) return null;
return new EnchantmentSummary(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
int position = 0;
try { return EnchantmentWireReader.Read(payload, ref position); }
catch (FormatException) { return null; }
}
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
{
int position = 0;
try { return EnchantmentWireReader.ReadList(payload, ref position); }
catch (FormatException) { return null; }
}
/// <summary>
@ -323,6 +323,23 @@ public static class GameEvents
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
=> ParseMagicRemoveEnchantment(payload);
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
var result = new LayeredSpellId[count];
for (int i = 0; i < result.Length; i++)
{
int offset = 4 + i * 4;
result[i] = new LayeredSpellId(
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
}
return result;
}
// ── Appraise / identify ─────────────────────────────────────────────────
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>

View file

@ -328,7 +328,9 @@ public static class PlayerDescriptionParser
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
uint options1 = 0;
uint options2 = 0;
uint spellbookFilters = 0;
// Retail CPlayerModule ctor (0x005D5245) enables every school and
// level filter before any optional PlayerDescription override.
uint spellbookFilters = 0x3FFFu;
List<ShortcutEntry> shortcuts = new();
List<IReadOnlyList<uint>> hotbarSpells = new();
List<(uint, uint)> desiredComps = new();
@ -687,10 +689,7 @@ public static class PlayerDescriptionParser
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
EnchantmentBucket bucket)
{
uint count = ReadU32(src, ref pos);
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
for (int i = 0; i < count; i++)
dest.Add(ReadEnchantment(src, ref pos, bucket));
dest.AddRange(EnchantmentWireReader.ReadList(src, ref pos, bucket));
}
private static EnchantmentEntry ReadEnchantment(
@ -703,29 +702,7 @@ public static class PlayerDescriptionParser
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
ushort spellId = ReadU16(src, ref pos);
ushort layer = ReadU16(src, ref pos);
ushort spellCategory = ReadU16(src, ref pos);
ushort hasSpellSetId = ReadU16(src, ref pos);
uint powerLevel = ReadU32(src, ref pos);
double startTime = ReadF64(src, ref pos);
double duration = ReadF64(src, ref pos);
uint casterGuid = ReadU32(src, ref pos);
float degradeModifier= ReadF32(src, ref pos);
float degradeLimit = ReadF32(src, ref pos);
double lastDegraded = ReadF64(src, ref pos);
uint statModType = ReadU32(src, ref pos);
uint statModKey = ReadU32(src, ref pos);
float statModValue = ReadF32(src, ref pos);
uint? spellSetId = null;
if (hasSpellSetId != 0)
spellSetId = ReadU32(src, ref pos);
return new EnchantmentEntry(
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
startTime, duration, casterGuid, degradeModifier, degradeLimit,
lastDegraded, statModType, statModKey, statModValue, spellSetId,
bucket);
return EnchantmentWireReader.Read(src, ref pos, bucket);
}
/// <summary>Strict inventory + equipped block reader. Returns true if

View file

@ -138,5 +138,6 @@ public static class ObjectTableWiring
CombatUse: s.CombatUse,
PluralName: s.PluralName,
PetOwnerId: s.PetOwnerId,
AmmoType: s.AmmoType);
AmmoType: s.AmmoType,
SpellId: s.SpellId);
}

View file

@ -131,6 +131,7 @@ public sealed class WorldSession : IDisposable
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
PhysicsSpawnData? Physics = null);
/// <summary>
@ -191,6 +192,7 @@ public sealed class WorldSession : IDisposable
PluralName: parsed.PluralName,
PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType,
SpellId: parsed.SpellId,
Physics: parsed.Physics);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
@ -1451,6 +1453,45 @@ public sealed class WorldSession : IDisposable
seq, componentId: 0u, amount: uint.MaxValue));
}
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
seq, componentId, amount));
}
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
seq, spellId, position, tabIndex));
}
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
seq, spellId, tabIndex));
}
public void SendSpellbookFilter(uint filters)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
}
public void SendCastUntargetedSpell(uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
}
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
}
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{

View file

@ -191,6 +191,8 @@ public sealed class ClientObject
public byte? CombatUse { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._ammoType</c>; zero is <c>AMMO_NONE</c>.</summary>
public ushort? AmmoType { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
public uint? SpellId { get; set; }
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
public int TradeState { get; set; }
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
@ -258,7 +260,8 @@ public readonly record struct WeenieData(
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null);
ushort? AmmoType = null,
uint? SpellId = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -700,6 +700,7 @@ public sealed class ClientObjectTable
if (d.PetOwnerId is { } petOwnerId) obj.PetOwnerId = petOwnerId;
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
if (d.SpellId is { } spellId) obj.SpellId = spellId;
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Pure port of retail's ordinary in-effect registry projection:
/// <c>CEnchantmentRegistry::GetEnchantmentsInEffect @ 0x00594540</c>,
/// <c>GetEnchantmentsInEffectFromList @ 0x00594430</c>,
/// <c>Duel @ 0x005942B0</c>, and <c>Enchantment::Duel @ 0x005CACE0</c>.
/// </summary>
public static class EnchantmentRegistryProjection
{
/// <summary>
/// Merges multiplicative then additive records and retains one winner per
/// spell category. Higher power wins; equal power is replaced only by a
/// later start time. Vitae and cooldown registries do not participate.
/// </summary>
public static IReadOnlyList<ActiveEnchantmentRecord> GetEnchantmentsInEffect(
IEnumerable<ActiveEnchantmentRecord> enchantments)
{
ArgumentNullException.ThrowIfNull(enchantments);
ActiveEnchantmentRecord[] source = enchantments as ActiveEnchantmentRecord[]
?? [.. enchantments];
var result = new List<ActiveEnchantmentRecord>();
DuelList(source, bucket: 1u, result);
DuelList(source, bucket: 2u, result);
return result;
}
private static void DuelList(
IReadOnlyList<ActiveEnchantmentRecord> source,
uint bucket,
List<ActiveEnchantmentRecord> result)
{
for (int sourceIndex = 0; sourceIndex < source.Count; sourceIndex++)
{
ActiveEnchantmentRecord candidate = source[sourceIndex];
if (candidate.Bucket != bucket) continue;
int incumbentIndex = result.FindIndex(existing =>
existing.SpellCategory == candidate.SpellCategory);
if (incumbentIndex >= 0)
{
ActiveEnchantmentRecord incumbent = result[incumbentIndex];
if (!CandidateWins(incumbent, candidate))
continue;
// Retail removes the losing node, then appends the winner.
result.RemoveAt(incumbentIndex);
}
result.Add(candidate);
}
}
private static bool CandidateWins(
ActiveEnchantmentRecord incumbent,
ActiveEnchantmentRecord candidate)
{
// Retail Enchantment::_power_level is signed int even though the wire
// field is read as four raw bytes.
int incumbentPower = unchecked((int)incumbent.PowerLevel);
int candidatePower = unchecked((int)candidate.PowerLevel);
return candidatePower > incumbentPower
|| (candidatePower == incumbentPower
&& candidate.StartTime > incumbent.StartTime);
}
}

View file

@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AcDream.Core.Spells;
/// <summary>
/// Pure retail spell-formula helpers. The formula is an eight-entry list of
/// spell-component enum IDs (SCIDs), not inventory weenie class IDs.
/// </summary>
public static class RetailSpellFormula
{
private const uint LowestTaperId = 63u;
private const uint PrismaticTaperId = 0xBCu;
/// <summary>
/// Retail MagicSystem::DeterminePowerLevelOfComponent (0x005BD240).
/// </summary>
public static uint DeterminePowerLevelOfComponent(uint componentId) => componentId switch
{
>= 1u and <= 6u => componentId,
0x6Eu => 7u,
0x70u => 8u,
0xC0u => 9u,
0xC1u => 10u,
_ => 0u,
};
/// <summary>
/// Retail CSpellBase::InqSpellLevelByRoughHeuristic (0x00597260).
/// This is the I-VIII level used by gmSpellbookUI's level filters.
/// </summary>
public static int InqSpellLevelByRoughHeuristic(IReadOnlyList<uint> components)
{
uint power = components.Count == 0
? 0u
: DeterminePowerLevelOfComponent(components[0]);
return power switch
{
< 7u => (int)power,
>= 9u => (int)power - 2,
_ => (int)power - 1,
};
}
/// <summary>
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
/// Foci and the infused-magic augmentations replace the ordinary taper
/// formula with its power components plus one to four prismatic tapers.
/// </summary>
public static IReadOnlyList<uint> InqScarabOnlyFormula(
IReadOnlyList<uint> components)
{
ArgumentNullException.ThrowIfNull(components);
var result = new List<uint>(8);
uint strongestPower = 0u;
foreach (uint component in components.Take(8))
{
if (component == 0u)
break;
if (!IsPowerComponent(component))
continue;
result.Add(component);
strongestPower = Math.Max(
strongestPower,
DeterminePowerLevelOfComponent(component));
}
int taperCount = strongestPower switch
{
1u => 1,
2u => 2,
3u or 4u or 7u => 3,
5u or 6u or 8u or 9u or 10u => 4,
_ => 0,
};
while (taperCount-- > 0 && result.Count < 8)
result.Add(PrismaticTaperId);
return result;
}
private static bool IsPowerComponent(uint component) => component is
>= 1u and <= 6u or 0x6Eu or 0x6Fu or 0x70u or 0xC0u or 0xC1u;
/// <summary>
/// Retail PString's CP-1252 hash used by SpellFormula::RandomizeForName.
/// </summary>
public static uint ComputeNameHash(string value)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
long result = 0;
foreach (byte raw in Encoding.GetEncoding(1252).GetBytes(value ?? string.Empty))
{
sbyte character = unchecked((sbyte)raw);
result = character + (result << 4);
if ((result & 0xF0000000L) != 0)
result = (result ^ ((result & 0xF0000000L) >> 24)) & 0x0FFFFFFF;
}
return unchecked((uint)result);
}
/// <summary>
/// Retail SpellFormula::RandomizeForName (0x005BD050), cross-checked with
/// ACE.DatLoader SpellTable.GetSpellFormula. Only taper SCIDs change.
/// </summary>
public static IReadOnlyList<uint> CustomizeForAccount(
IReadOnlyList<uint> components,
uint formulaVersion,
string accountName)
{
uint[] result = components.Take(8).ToArray();
return formulaVersion switch
{
1u => RandomizeVersion1(result, accountName),
2u => RandomizeVersion2(result, accountName),
3u => RandomizeVersion3(result, accountName),
_ => result,
};
}
private static IReadOnlyList<uint> RandomizeVersion1(uint[] c, string accountName)
{
int count = c.Count(value => value != 0);
if (count < 5) return c;
uint seed = ComputeNameHash(accountName) % 0x13D573u;
uint scarab = c[0];
int herbIndex = count > 5 ? 2 : 1;
uint herb = c[herbIndex];
int powderIndex = herbIndex + 1 + (count > 6 ? 1 : 0);
if (powderIndex + 1 >= c.Length) return c;
uint powder = c[powderIndex];
uint potion = c[powderIndex + 1];
int talismanIndex = powderIndex + 2 + (count > 7 ? 1 : 0);
if (talismanIndex >= c.Length) return c;
uint talisman = c[talismanIndex];
if (count > 5)
c[1] = unchecked(powder + 2u * herb + potion + talisman + scarab) % 12u + LowestTaperId;
if (count > 6)
{
uint denominator = unchecked(scarab + powder + potion);
if (denominator != 0)
c[3] = unchecked((scarab + herb + talisman + 2u * (powder + potion))
* (seed / denominator)) % 12u + LowestTaperId;
}
if (count > 7)
{
uint denominator = unchecked(talisman + scarab);
if (denominator != 0)
c[6] = unchecked((powder + 2u * talisman + potion + herb + scarab)
* (seed / denominator)) % 12u + LowestTaperId;
}
return c;
}
private static IReadOnlyList<uint> RandomizeVersion2(uint[] c, string accountName)
{
if (c.Length < 8) return c;
uint seed = ComputeNameHash(accountName) % 0x13D573u;
uint p1 = c[0], c4 = c[4], x = c[5], a = c[7];
c[3] = unchecked(a + 3u * p1 + 2u * c4 * x + c[2] + c[1]) % 12u + LowestTaperId;
uint denominator = unchecked(c[1] * a + 2u * c4);
if (denominator != 0)
c[6] = unchecked((a + 3u * p1 * c[2] + 2u * x + c4)
* (seed / denominator)) % 12u + LowestTaperId;
return c;
}
private static IReadOnlyList<uint> RandomizeVersion3(uint[] c, string accountName)
{
if (c.Length < 7) return c;
uint hash = ComputeNameHash(accountName);
uint h0 = unchecked(hash % 0x13D573u + c[0]) % 12u;
uint h1 = unchecked(hash % 0x4AEFDu + c[1]) % 12u;
uint h2 = unchecked(hash % 0x96A7Fu + c[2]) % 12u;
uint h4 = unchecked(hash % 0x100A03u + c[4]) % 12u;
uint h5 = unchecked(hash % 0xEB2EFu + c[5]) % 12u;
uint h7 = unchecked(hash % 0x121E7Du + (c.Length > 7 ? c[7] : 0u)) % 12u;
c[3] = unchecked(h0 + h1 + h2 + h4 + h5 + h2 * h5 + h0 * h1 + h7 * (h4 + 1u))
% 12u + LowestTaperId;
c[6] = unchecked(h0 + h1 + h2 + h4 + hash % 0x65039u % 12u
+ h7 * (h4 * (h0 * h1 * h2 * h5 + 7u) + 1u)
+ h5 + 5u * h0 * h1 + 11u * h2 * h5) % 12u + LowestTaperId;
return c;
}
}

View file

@ -41,4 +41,21 @@ public sealed record SpellMetadata(
int ManaCost,
bool IsDebuff,
bool IsFellowship,
string Description);
string Description,
int SortKey,
int Difficulty,
uint Flags,
int Generation,
bool IsFastWindup,
bool IsOffensive,
bool IsUntargeted,
float Speed,
uint CasterEffect,
uint TargetEffect,
uint TargetMask,
int SpellType)
{
public bool IsSelfTargeted => (Flags & 0x00000008u) != 0;
public bool IsBeneficial => (Flags & 0x00000004u) != 0;
public bool IsProjectile => (Flags & 0x00000100u) != 0;
}

View file

@ -18,11 +18,9 @@ public enum MagicSchool : uint
None = 0,
WarMagic = 1,
LifeMagic = 2,
CreatureEnchantment = 3,
ItemEnchantment = 4,
PortalMagic = 5,
// VoidMagic added in later retail revisions; uses LifeMagic skill.
VoidMagic = 6,
ItemEnchantment = 3,
CreatureEnchantment = 4,
VoidMagic = 5,
}
/// <summary>
@ -55,14 +53,23 @@ public enum SpellCategory : uint
public enum SpellFlags : uint
{
None = 0,
Beneficial = 0x00000001,
Resistable = 0x00000002,
Projectile = 0x00000004,
EnchantmentDispel = 0x00000008,
PurgeOnReset = 0x00000010,
Resistable = 0x00000001,
PKSensitive = 0x00000002,
Beneficial = 0x00000004,
SelfTargeted = 0x00000008,
Reversed = 0x00000010,
NotIndoors = 0x00000020,
Melee = 0x00000040,
Missile = 0x00000080,
NotOutdoors = 0x00000040,
NotResearchable = 0x00000080,
Projectile = 0x00000100,
CreatureSpell = 0x00000200,
ExcludedFromItemDescriptions = 0x00000400,
IgnoresManaConversion = 0x00000800,
NonTrackingProjectile = 0x00001000,
FellowshipSpell = 0x00002000,
FastCast = 0x00004000,
IndoorLongRange = 0x00008000,
DamageOverTime = 0x00010000,
// more flags in r01 §1
}

View file

@ -101,6 +101,18 @@ public sealed class SpellTable
int? iIsDebuff = Get("IsDebuff");
int? iIsFellow = Get("IsFellowship");
int? iDescription = Get("Description");
int? iSortKey = Get("SortKey");
int? iDifficulty = Get("Difficulty");
int? iFlags = Get("Flags [Hex]");
int? iGeneration = Get("Generation");
int? iFastWindup = Get("IsFastWindup");
int? iOffensive = Get("IsOffensive");
int? iUntargeted = Get("IsUntargetted");
int? iSpeed = Get("Speed");
int? iCasterFx = Get("CasterEffect");
int? iTargetFx = Get("TargetEffect");
int? iTargetMask = Get("TargetMask [Hex]");
int? iSpellType = Get("Type");
if (iSpellId is null || iName is null) return new SpellTable(byId);
@ -125,10 +137,24 @@ public sealed class SpellTable
bool isDebuff = ParseBool(fields, iIsDebuff);
bool isFellow = ParseBool(fields, iIsFellow);
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
int sortKey = (int)ParseUInt(fields, iSortKey);
int difficulty = (int)ParseUInt(fields, iDifficulty);
uint flags = ParseHexUInt(fields, iFlags);
int generation = (int)ParseUInt(fields, iGeneration);
bool fastWindup = ParseBool(fields, iFastWindup);
bool offensive = ParseBool(fields, iOffensive);
bool untargeted = ParseBool(fields, iUntargeted);
float speed = ParseFloat(fields, iSpeed);
uint casterEffect = ParseUInt(fields, iCasterFx);
uint targetEffect = ParseUInt(fields, iTargetFx);
uint targetMask = ParseHexUInt(fields, iTargetMask);
int spellType = (int)ParseUInt(fields, iSpellType);
byId[spellId] = new SpellMetadata(
spellId, name, school, family, iconId, words, duration,
mana, isDebuff, isFellow, description);
mana, isDebuff, isFellow, description, sortKey, difficulty,
flags, generation, fastWindup, offensive, untargeted, speed,
casterEffect, targetEffect, targetMask, spellType);
}
return new SpellTable(byId);

View file

@ -1,6 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.Core.Spells;
@ -17,9 +17,14 @@ namespace AcDream.Core.Spells;
/// </summary>
public sealed class Spellbook
{
private readonly HashSet<uint> _learnedSpells = new();
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
private readonly Dictionary<uint, float> _learnedSpells = new();
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
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 uint _spellbookFilters = 0x3FFFu;
/// <summary>
/// Build a Spellbook with an optional <see cref="SpellTable"/>
@ -69,39 +74,98 @@ public sealed class Spellbook
/// <summary>Fires when an enchantment is removed (expired / dispelled).</summary>
public event Action<ActiveEnchantmentRecord>? EnchantmentRemoved;
/// <summary>Fires when any manifest-backed magic UI state changes.</summary>
public event Action? StateChanged;
/// <summary>Fires when learned spells, favorites, or spellbook filters change.</summary>
public event Action? SpellbookChanged;
/// <summary>Fires when the active enchantment set changes.</summary>
public event Action? EnchantmentsChanged;
/// <summary>Fires when the desired spell-component levels change.</summary>
public event Action? DesiredComponentsChanged;
/// <summary>All currently learned spell ids.</summary>
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells;
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells.Keys;
/// <summary>Stable learned-spell snapshot, including server spell power.</summary>
public IReadOnlyList<LearnedSpellRecord> LearnedSpellSnapshot => _learnedSpells
.Select(pair => new LearnedSpellRecord(pair.Key, pair.Value))
.OrderBy(spell => spell.SpellId)
.ToArray();
/// <summary>All currently-active enchantments.</summary>
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeByLayer.Values;
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeById.Values;
public IReadOnlyList<ActiveEnchantmentRecord> ActiveEnchantmentSnapshot =>
_activeById.Values.OrderBy(record => record.Identity).ToArray();
/// <summary>
/// Retail ordinary-effect projection after mult/add filtering and the
/// spell-category duel. This is the source for effects-list rows; retail's
/// indicator counts scan the raw mult/add records before this projection.
/// </summary>
public IReadOnlyList<ActiveEnchantmentRecord> EnchantmentsInEffectSnapshot
{
get
{
var ordered = new List<ActiveEnchantmentRecord>();
AppendBucket(bucket: 1u, ordered);
AppendBucket(bucket: 2u, ordered);
return EnchantmentRegistryProjection.GetEnchantmentsInEffect(ordered);
}
}
public IReadOnlyList<uint> GetFavorites(int tabIndex)
{
if ((uint)tabIndex >= _favoriteSpells.Length) return Array.Empty<uint>();
return _favoriteSpells[tabIndex].ToArray();
}
public IReadOnlyDictionary<uint, uint> DesiredComponents => _desiredComponents;
public uint SpellbookFilters => _spellbookFilters;
public int LearnedCount => _learnedSpells.Count;
public int ActiveCount => _activeByLayer.Count;
public int ActiveCount => _activeById.Count;
public bool Knows(uint spellId) => _learnedSpells.Contains(spellId);
public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId);
// ── Inbound handlers ─────────────────────────────────────────────────────
/// <summary>0x02C1 MagicUpdateSpell: learn a spell.</summary>
public void OnSpellLearned(uint spellId)
public void OnSpellLearned(uint spellId, float power = 0f)
{
if (_learnedSpells.TryAdd(spellId, power))
{
if (_learnedSpells.Add(spellId))
SpellLearned?.Invoke(spellId);
NotifySpellbookChanged();
}
else if (_learnedSpells[spellId] != power)
{
_learnedSpells[spellId] = power;
NotifySpellbookChanged();
}
}
/// <summary>0x01A8 MagicRemoveSpell: forget a spell.</summary>
public void OnSpellForgotten(uint spellId)
{
if (_learnedSpells.Remove(spellId))
{
SpellForgotten?.Invoke(spellId);
NotifySpellbookChanged();
}
}
/// <summary>0x02C2 MagicUpdateEnchantment: enchantment added / refreshed.</summary>
public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid)
{
var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid);
_activeByLayer[layerId] = record;
UpsertLiveEnchantment(record);
EnchantmentAdded?.Invoke(record);
NotifyEnchantmentsChanged();
}
/// <summary>
@ -113,56 +177,305 @@ public sealed class Spellbook
/// </summary>
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
{
_activeByLayer[record.LayerId] = record;
UpsertLiveEnchantment(record);
EnchantmentAdded?.Invoke(record);
NotifyEnchantmentsChanged();
}
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
public void OnEnchantmentRemoved(uint layerId, uint spellId)
{
if (_activeByLayer.TryRemove(layerId, out var record))
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layerId);
if (RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record))
{
EnchantmentRemoved?.Invoke(record);
else
EnchantmentRemoved?.Invoke(new ActiveEnchantmentRecord(spellId, layerId, 0f, 0));
NotifyEnchantmentsChanged();
}
}
/// <summary>0x02C6 MagicPurgeEnchantments: clear all active buffs.</summary>
public void OnEnchantmentsAdded(IEnumerable<ActiveEnchantmentRecord> records)
{
foreach (ActiveEnchantmentRecord record in records)
{
UpsertLiveEnchantment(record);
EnchantmentAdded?.Invoke(record);
}
NotifyEnchantmentsChanged();
}
public void OnEnchantmentsRemoved(IEnumerable<(uint SpellId, uint Layer)> spells)
{
bool changed = false;
foreach ((uint spellId, uint layer) in spells)
{
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layer);
if (!RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) continue;
changed = true;
EnchantmentRemoved?.Invoke(record);
}
if (changed) NotifyEnchantmentsChanged();
}
/// <summary>
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
/// and permanent enchantments live in separate registries and survive.
/// </summary>
public void OnPurgeAll()
{
foreach (var rec in _activeByLayer.Values)
ActiveEnchantmentRecord[] removed = _activeById.Values
.Where(IsPurgeable)
.ToArray();
foreach (ActiveEnchantmentRecord record in removed)
RemoveActiveEnchantment(record.Identity, out _);
foreach (var rec in removed)
EnchantmentRemoved?.Invoke(rec);
_activeByLayer.Clear();
if (removed.Length != 0) NotifyEnchantmentsChanged();
}
public void OnPurgeBadEnchantments()
{
// Retail PurgeBadEnchantments (0x00594E00) tests the StatMod
// Beneficial bit directly; spell-table labels do not participate.
const uint BeneficialStatMod = 0x02000000u;
ActiveEnchantmentRecord[] removed = _activeById.Values
.Where(record => IsPurgeable(record)
&& (record.StatModType.GetValueOrDefault() & BeneficialStatMod) == 0)
.ToArray();
foreach (ActiveEnchantmentRecord record in removed)
RemoveActiveEnchantment(record.Identity, out _);
foreach (ActiveEnchantmentRecord record in removed)
EnchantmentRemoved?.Invoke(record);
if (removed.Length != 0) NotifyEnchantmentsChanged();
}
private static bool IsPurgeable(ActiveEnchantmentRecord record) =>
(record.Bucket is 1u or 2u) && record.Duration != -1f;
/// <summary>
/// PlayerDescription is a complete character manifest. Replace every
/// magic collection atomically so relogging or switching characters cannot
/// retain state from the prior snapshot.
/// </summary>
public void ReplaceManifest(
IReadOnlyDictionary<uint, float> learnedSpells,
IEnumerable<ActiveEnchantmentRecord> enchantments,
IReadOnlyList<IReadOnlyList<uint>> favorites,
IReadOnlyList<(uint Id, uint Amount)> desiredComponents,
uint spellbookFilters)
{
_learnedSpells.Clear();
foreach ((uint spellId, float power) in learnedSpells)
_learnedSpells[spellId] = power;
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
UpsertManifestEnchantment(enchantment);
for (int i = 0; i < _favoriteSpells.Length; i++)
{
_favoriteSpells[i].Clear();
if (i < favorites.Count)
_favoriteSpells[i].AddRange(favorites[i]);
}
_desiredComponents.Clear();
foreach ((uint id, uint amount) in desiredComponents)
_desiredComponents[id] = amount;
_spellbookFilters = spellbookFilters;
SpellbookChanged?.Invoke();
EnchantmentsChanged?.Invoke();
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
public void SetFavorite(int tabIndex, int position, uint spellId)
{
if ((uint)tabIndex >= _favoriteSpells.Length || position < 0) return;
List<uint> tab = _favoriteSpells[tabIndex];
if (position > tab.Count) position = tab.Count;
tab.Remove(spellId);
tab.Insert(Math.Min(position, tab.Count), spellId);
NotifySpellbookChanged();
}
public void RemoveFavorite(int tabIndex, uint spellId)
{
if ((uint)tabIndex >= _favoriteSpells.Length) return;
if (_favoriteSpells[tabIndex].Remove(spellId)) NotifySpellbookChanged();
}
public void SetSpellbookFilters(uint filters)
{
if (_spellbookFilters == filters) return;
_spellbookFilters = filters;
NotifySpellbookChanged();
}
public void SetDesiredComponent(uint componentId, uint amount)
{
uint current = _desiredComponents.TryGetValue(componentId, out uint existing)
? existing : 0u;
if (current == amount) return;
if (amount == 0) _desiredComponents.Remove(componentId);
else _desiredComponents[componentId] = amount;
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
public void Clear()
{
_learnedSpells.Clear();
_activeByLayer.Clear();
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
_desiredComponents.Clear();
_spellbookFilters = 0x3FFFu;
SpellbookChanged?.Invoke();
EnchantmentsChanged?.Invoke();
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
private void NotifySpellbookChanged()
{
SpellbookChanged?.Invoke();
StateChanged?.Invoke();
}
private void NotifyEnchantmentsChanged()
{
EnchantmentsChanged?.Invoke();
StateChanged?.Invoke();
}
private void AppendBucket(uint bucket, List<ActiveEnchantmentRecord> destination)
{
if (!_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order)) return;
foreach (uint identity in order)
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord record)
&& record.Bucket == bucket)
destination.Add(record);
}
/// <summary>
/// Retail <c>AddEnchantmentToList @ 0x00593DF0</c> inserts a new live
/// mult/add record at the list head. Refreshing an existing identity keeps
/// its node position; moving buckets creates a new head node.
/// </summary>
private void UpsertLiveEnchantment(ActiveEnchantmentRecord record)
{
uint identity = record.Identity;
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
{
_activeById[identity] = record;
if (previous.Bucket == record.Bucket)
{
EnsureOrdered(identity, record.Bucket, insertAtHead: true);
return;
}
RemoveFromOrder(previous);
}
else
{
_activeById.Add(identity, record);
}
GetBucketOrder(record.Bucket).Insert(0, identity);
}
/// <summary>Bulk UnPack preserves the received head-to-tail list order.</summary>
private void UpsertManifestEnchantment(ActiveEnchantmentRecord record)
{
uint identity = record.Identity;
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
{
_activeById[identity] = record;
if (previous.Bucket == record.Bucket)
{
EnsureOrdered(identity, record.Bucket, insertAtHead: false);
return;
}
RemoveFromOrder(previous);
}
else
{
_activeById.Add(identity, record);
}
GetBucketOrder(record.Bucket).Add(identity);
}
private bool RemoveActiveEnchantment(
uint identity,
out ActiveEnchantmentRecord record)
{
if (!_activeById.Remove(identity, out record)) return false;
RemoveFromOrder(record);
return true;
}
private void EnsureOrdered(uint identity, uint bucket, bool insertAtHead)
{
List<uint> order = GetBucketOrder(bucket);
if (order.Contains(identity)) return;
if (insertAtHead) order.Insert(0, identity);
else order.Add(identity);
}
private List<uint> GetBucketOrder(uint bucket)
{
if (_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order))
return order;
order = new List<uint>();
_enchantmentOrderByBucket.Add(bucket, order);
return order;
}
private void RemoveFromOrder(ActiveEnchantmentRecord record)
{
if (!_enchantmentOrderByBucket.TryGetValue(record.Bucket, out List<uint>? order))
return;
order.Remove(record.Identity);
if (order.Count == 0)
_enchantmentOrderByBucket.Remove(record.Bucket);
}
}
public readonly record struct LearnedSpellRecord(uint SpellId, float Power);
/// <summary>
/// Summary of one active enchantment layer on the player. The
/// optional StatMod fields (issue #12) carry the wire-level
/// `_smod` triad <c>(type, key, val)</c> when available — only
/// `PlayerDescription`'s enchantment block currently populates these
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
/// `MagicUpdateEnchantment` events still produce records with these
/// fields null until the wire parser is extended.
/// `_smod` triad <c>(type, key, val)</c>. Both the complete
/// `PlayerDescription` snapshot and live MagicUpdateEnchantment events
/// populate the same record shape.
///
/// <para>
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
/// (<c>0x02</c>), vitae (<c>0x04</c>), or cooldown (<c>0x08</c>) per
/// the retail <c>EnchantmentMask</c> classification.
/// </para>
/// </summary>
public readonly record struct ActiveEnchantmentRecord(
uint SpellId,
uint LayerId,
float Duration,
double Duration,
uint CasterGuid,
uint? StatModType = null,
uint? StatModKey = null,
float? StatModValue = null,
uint Bucket = 0);
uint Bucket = 0,
double StartTime = 0,
uint SpellCategory = 0,
uint PowerLevel = 0,
float DegradeModifier = 0,
float DegradeLimit = 0,
double LastTimeDegraded = 0,
uint? SpellSetId = null)
{
public uint Identity => MakeIdentity(SpellId, LayerId);
public static uint MakeIdentity(uint spellId, uint layerId) =>
(spellId & 0xFFFFu) | ((layerId & 0xFFFFu) << 16);
}

View file

@ -13,4 +13,5 @@ namespace AcDream.UI.Abstractions.Input;
public readonly record struct Binding(
KeyChord Chord,
InputAction Action,
ActivationType Activation = ActivationType.Press);
ActivationType Activation = ActivationType.Press,
InputScope Scope = InputScope.Game);

View file

@ -36,6 +36,7 @@ public sealed class InputDispatcher
private readonly IMouseSource _mouse;
private KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new();
private InputScope? _combatScope;
private readonly HashSet<KeyChord> _heldHoldChords = new();
// Double-click detection. _lastMouseDownButton == null means no recent press.
@ -72,7 +73,33 @@ public sealed class InputDispatcher
}
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek();
public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat
? combat
: _scopes.Peek();
/// <summary>Set the mode-dependent combat layer that shadows normal game chords.</summary>
public void SetCombatScope(InputScope? scope)
{
if (scope is not null && scope is not (
InputScope.MeleeCombat or InputScope.MissileCombat or InputScope.MagicCombat))
throw new ArgumentOutOfRangeException(nameof(scope));
if (_combatScope == scope) return;
ReleaseHeldHoldBindings();
_combatScope = scope;
}
private Binding? FindActive(KeyChord chord, ActivationType activation)
{
foreach (InputScope scope in _scopes)
{
if (scope == InputScope.Game && _combatScope is { } combat
&& _bindings.Find(chord, activation, combat) is { } combatBinding)
return combatBinding;
if (_bindings.Find(chord, activation, scope) is { } binding)
return binding;
}
return null;
}
/// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary>
public bool IsCapturing => _captureCallback is not null;
@ -115,15 +142,18 @@ public sealed class InputDispatcher
/// </summary>
public void SetBindings(KeyBindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_heldHoldChords.Clear();
ArgumentNullException.ThrowIfNull(bindings);
ReleaseHeldHoldBindings();
_bindings = bindings;
}
/// <summary>
/// Per-frame "is this action's chord currently held" query. Walks every
/// binding for the given action; returns true if any of them has its
/// chord currently held in the underlying keyboard/mouse state AND the
/// modifier mask matches.
/// modifier mask matches. Scope precedence is resolved through the same
/// <see cref="FindActive"/> path as transition dispatch, so a combat binding
/// on a chord shadows the normal Game action during held polling too.
///
/// <para>
/// Used by per-frame movement polling (<c>MovementInput.Forward</c>
@ -149,11 +179,28 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureKeyboard) return false;
foreach (var b in _bindings.ForAction(action))
{
if (IsChordHeld(b.Chord)) return true;
if (!IsChordHeld(b.Chord)) continue;
Binding? active = FindActiveHeld(b.Chord, b.Activation);
if (active?.Action == action) return true;
}
return false;
}
private Binding? FindActiveHeld(KeyChord candidate, ActivationType activation)
{
// IsChordHeld deliberately lets a bare movement chord coexist with the
// retail Shift-to-walk modifier. Resolve an explicitly bound current
// modifier chord first so a higher combat scope can still shadow it;
// only fall back to the authored bare chord when Shift has no binding.
var actual = candidate with { Modifiers = _keyboard.CurrentModifiers };
Binding? active = FindActive(actual, activation);
if (active is not null) return active;
return candidate.Modifiers == ModifierMask.None
&& _keyboard.CurrentModifiers == ModifierMask.Shift
? FindActive(candidate, activation)
: null;
}
/// <summary>True iff the given chord's primary key is currently down on
/// the appropriate device AND the keyboard's current modifier mask
/// matches the chord's required modifier mask. Match semantics:
@ -208,7 +255,11 @@ public sealed class InputDispatcher
};
/// <summary>Push a scope onto the active stack. Top wins.</summary>
public void PushScope(InputScope scope) => _scopes.Push(scope);
public void PushScope(InputScope scope)
{
ReleaseHeldHoldBindings();
_scopes.Push(scope);
}
/// <summary>Pop the topmost scope. <paramref name="expected"/> is the
/// scope the caller believes is on top; mismatch throws to catch
@ -218,9 +269,22 @@ public sealed class InputDispatcher
if (_scopes.Peek() != expected)
throw new InvalidOperationException(
$"PopScope expected {expected} but top is {_scopes.Peek()}");
ReleaseHeldHoldBindings();
_scopes.Pop();
}
private void ReleaseHeldHoldBindings()
{
if (_heldHoldChords.Count == 0) return;
var releases = new List<Binding>(_heldHoldChords.Count);
foreach (KeyChord chord in _heldHoldChords)
if (FindActive(chord, ActivationType.Hold) is { } binding)
releases.Add(binding);
_heldHoldChords.Clear();
foreach (Binding binding in releases)
Fired?.Invoke(binding.Action, ActivationType.Release);
}
/// <summary>
/// Per-frame tick. Re-fires <see cref="ActivationType.Hold"/>
/// activations for every chord that's currently held. Call once per
@ -238,7 +302,12 @@ public sealed class InputDispatcher
for (int i = 0; i < snapshot.Length; i++)
{
var chord = snapshot[i];
var hold = _bindings.Find(chord, ActivationType.Hold);
// A preceding Hold callback may have changed scope or bindings.
// Those transitions synchronously release and clear every held
// chord; never dispatch a stale snapshot entry afterward.
if (!_heldHoldChords.Contains(chord))
continue;
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
Fired?.Invoke(hold.Value.Action, ActivationType.Hold);
}
@ -273,10 +342,10 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0);
var press = _bindings.Find(chord, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
// Emit a Press transition so subscribers can latch state, then
@ -305,7 +374,7 @@ public sealed class InputDispatcher
// mid-press.
var chord = new KeyChord(key, mods, Device: 0);
var release = _bindings.Find(chord, ActivationType.Release);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
// Any matching Hold binding gets a Release transition. Walk the
@ -320,7 +389,7 @@ public sealed class InputDispatcher
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}
@ -330,10 +399,10 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var press = _bindings.Find(chord, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
@ -348,7 +417,7 @@ public sealed class InputDispatcher
if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{
var dbl = _bindings.Find(chord, ActivationType.DoubleClick);
var dbl = FindActive(chord, ActivationType.DoubleClick);
if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick);
_lastMouseDownButton = null; // consumed; require fresh pair for next
}
@ -363,7 +432,7 @@ public sealed class InputDispatcher
{
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var release = _bindings.Find(chord, ActivationType.Release);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
var keyForLookup = MouseButtonToKey(button);
@ -376,7 +445,7 @@ public sealed class InputDispatcher
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}

View file

@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary>
public sealed class KeyBindings
{
private const int CurrentSchemaVersion = 3;
private const int CurrentSchemaVersion = 4;
private readonly List<Binding> _bindings = new();
@ -47,11 +47,22 @@ public sealed class KeyBindings
/// activation type matches the requested phase. Null if none.
/// </summary>
public Binding? Find(KeyChord chord, ActivationType activation)
{
for (int i = 0; i < _bindings.Count; i++)
{
Binding binding = _bindings[i];
if (binding.Chord == chord && binding.Activation == activation)
return binding;
}
return null;
}
public Binding? Find(KeyChord chord, ActivationType activation, InputScope scope)
{
for (int i = 0; i < _bindings.Count; i++)
{
var b = _bindings[i];
if (b.Chord == chord && b.Activation == activation) return b;
if (b.Chord == chord && b.Activation == activation && b.Scope == scope) return b;
}
return null;
}
@ -242,37 +253,37 @@ public sealed class KeyBindings
// ── Combat (mode-dependent — dormant in K, lights up in Phase L) ──
b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat));
// Melee mode (active when MeleeCombat scope pushed).
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower, Scope: InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower, Scope: InputScope.MeleeCombat));
// Retail HandleCombatAction consumes key-down AND key-up for attack
// height actions: down starts charging, up ends the request. Hold is
// our binding representation for that transition pair.
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat));
// Missile + Magic + Spell-tab — same chords; resolved by scope at
// runtime per InputDispatcher's stack lookup. Add the bindings;
// subscribers arrive in Phase L when CombatState.CurrentMode is
// wired.
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell, Scope: InputScope.MagicCombat));
for (int i = 1; i <= 9; i++)
{
var k = (Key)((int)Key.Number0 + i);
var action = (InputAction)((int)InputAction.UseSpellSlot_1 + i - 1);
b.Add(new(new KeyChord(k, ModifierMask.None), action));
b.Add(new(new KeyChord(k, ModifierMask.None), action, Scope: InputScope.MagicCombat));
}
// ── Emotes ──────────────────────────────────────────────
@ -424,7 +435,17 @@ public sealed class KeyBindings
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
activation = MigrateCombatAttackActivation(version, action, activation);
loaded.Add(new(chord, action, activation));
InputScope scope = defaults.ForAction(action)
.Select(binding => binding.Scope)
.DefaultIfEmpty(InputScope.Game)
.First();
if (bindingEl.TryGetProperty("scope", out var scopeEl)
&& scopeEl.ValueKind == JsonValueKind.String
&& Enum.TryParse(scopeEl.GetString(), out InputScope parsedScope))
{
scope = parsedScope;
}
loaded.Add(new(chord, action, activation, scope));
}
}
}
@ -480,6 +501,8 @@ public sealed class KeyBindings
entry["device"] = (int)binding.Chord.Device;
if (binding.Activation != ActivationType.Press)
entry["activation"] = binding.Activation.ToString();
if (binding.Scope != InputScope.Game)
entry["scope"] = binding.Scope.ToString();
list.Add(entry);
}

View file

@ -253,7 +253,10 @@ public sealed class SettingsVM
// captured chord + same activation type, but on a DIFFERENT
// action. (Same-action bindings are fine — that's already in
// _draft for this action and gets removed when we apply.)
var existing = _draft.Find(chord, RebindOriginal!.Value.Activation);
var existing = _draft.Find(
chord,
RebindOriginal!.Value.Activation,
RebindOriginal.Value.Scope);
if (existing is not null && existing.Value.Action != RebindInProgress!.Value)
{
PendingConflict = new ConflictPrompt(
@ -293,7 +296,11 @@ public sealed class SettingsVM
private void ApplyRebind(KeyChord chord)
{
_draft.Remove(RebindOriginal!.Value);
_draft.Add(new Binding(chord, RebindInProgress!.Value, RebindOriginal.Value.Activation));
_draft.Add(new Binding(
chord,
RebindInProgress!.Value,
RebindOriginal.Value.Activation,
RebindOriginal.Value.Scope));
RebindInProgress = null;
RebindOriginal = null;
}

View file

@ -0,0 +1,115 @@
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class RetailSpellTargetPolicyTests
{
[Fact]
public void RejectsStackedTarget()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject { ObjectId = 2u, Name = "Scarabs", Type = ItemType.SpellComponents, StackSize = 2 },
Spell(targetMask: (uint)ItemType.SpellComponents));
Assert.False(result.Allowed);
Assert.Contains("stack", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void RejectsSelfWithoutRetailSpecialTargetBits()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject { ObjectId = 1u, Name = "Self", Type = ItemType.Creature },
Spell(targetMask: (uint)ItemType.Creature));
Assert.False(result.Allowed);
Assert.Contains("yourself", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AcceptsSelfWithSpecialTargetBits()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject
{
ObjectId = 1u,
Name = "Self",
Type = ItemType.Creature,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Player,
},
Spell(targetMask: 0x8101u));
Assert.True(result.Allowed);
}
[Theory]
[InlineData(PublicWeenieFlags.None, 0u, false)]
[InlineData(PublicWeenieFlags.Attackable, 0u, true)]
[InlineData(PublicWeenieFlags.Player, 0u, true)]
[InlineData(PublicWeenieFlags.Attackable, 99u, false)]
public void SpecialTargetBits_RequirePlayerOrAttackableNonPet(
PublicWeenieFlags flags,
uint petOwner,
bool expected)
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject
{
ObjectId = 2u,
Name = "Target",
Type = ItemType.Creature,
PublicWeenieBitfield = (uint)flags,
PetOwnerId = petOwner,
},
Spell(targetMask: 0x8101u));
Assert.Equal(expected, result.Allowed);
}
[Fact]
public void RejectsTypeMaskMismatchWithObjectName()
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject { ObjectId = 2u, Name = "Sword", Type = ItemType.MeleeWeapon },
Spell(targetMask: (uint)ItemType.Creature));
Assert.False(result.Allowed);
Assert.Contains("Sword", result.Message);
}
[Theory]
[InlineData(PublicWeenieFlags.Attackable, 0u, true)]
[InlineData(PublicWeenieFlags.None, 0u, false)]
[InlineData(PublicWeenieFlags.Player, 0u, true)]
[InlineData(PublicWeenieFlags.Attackable, 9u, false)]
public void DirectTypeMatch_StillRequiresPlayerOrAttackableNonPet(
PublicWeenieFlags flags,
uint petOwner,
bool expected)
{
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
1u,
new ClientObject
{
ObjectId = 2u,
Name = "Target",
Type = ItemType.Misc,
PublicWeenieBitfield = (uint)flags,
PetOwnerId = petOwner,
},
Spell(targetMask: (uint)ItemType.Misc));
Assert.Equal(expected, result.Allowed);
}
private static SpellMetadata Spell(uint targetMask) => new(
1, "Test", "War Magic", 1, 0, "", 0, 0, false, false, "",
0, 0, 0, 1, false, false, false, 0, 0, 0, targetMask, 0);
}

View file

@ -0,0 +1,119 @@
using AcDream.App.Spells;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class SpellCastingControllerTests
{
[Fact]
public void Cast_SelfTargeted_SendsLocalPlayerAsTarget()
{
Spellbook book = MakeBook(flags: 0x8, untargeted: true, targetMask: 0x10);
uint target = 0;
var controller = MakeController(book, selected: 99, player: 42,
sendTargeted: (id, _) => target = id);
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
Assert.Equal(42u, target);
}
[Fact]
public void Cast_TargetedWithoutSelection_DoesNotSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: false, targetMask: 0x10);
int sends = 0;
string message = "";
var controller = MakeController(book, selected: null, player: 42,
sendTargeted: (_, _) => sends++, display: value => message = value);
Assert.Equal(CastRequestResult.NoTarget, controller.Cast(1));
Assert.Equal(0, sends);
Assert.Contains("select", message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Cast_Untargeted_StopsThenSendsThenIncrementsBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
var order = new List<string>();
var controller = MakeController(book, selected: null, player: 42,
stop: () => order.Add("stop"),
sendUntargeted: _ => order.Add("send"),
incrementBusy: () => order.Add("busy"));
Assert.Equal(CastRequestResult.Sent, controller.Cast(1));
Assert.Equal(["stop", "send", "busy"], order);
}
[Fact]
public void Cast_MissingComponents_DoesNotIncrementBusyOrSend()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int sends = 0, busy = 0;
var controller = MakeController(book, selected: null, player: 42,
sendUntargeted: _ => sends++, hasComponents: _ => false,
incrementBusy: () => busy++);
Assert.Equal(CastRequestResult.MissingComponents, controller.Cast(1));
Assert.Equal(0, sends);
Assert.Equal(0, busy);
}
[Fact]
public void Cast_Disconnected_DoesNotIncrementBusy()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int busy = 0;
var controller = MakeController(book, selected: null, player: 42,
incrementBusy: () => busy++, canSend: () => false);
Assert.Equal(CastRequestResult.Unavailable, controller.Cast(1));
Assert.Equal(0, busy);
}
[Fact]
public void Cast_SendThrows_DoesNotIncrementBusyAndRethrows()
{
Spellbook book = MakeBook(flags: 0, untargeted: true, targetMask: 0);
int increments = 0;
var controller = MakeController(book, selected: null, player: 42,
sendUntargeted: _ => throw new InvalidOperationException("wire"),
incrementBusy: () => increments++);
Assert.Throws<InvalidOperationException>(() => controller.Cast(1));
Assert.Equal(0, increments);
Assert.Null(controller.LastRequestedSpellId);
}
private static SpellCastingController MakeController(
Spellbook book,
uint? selected,
uint player,
Action? stop = null,
Action<uint>? sendUntargeted = null,
Action<uint, uint>? sendTargeted = null,
Action<string>? display = null,
Func<uint, bool>? hasComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null) => new(
book,
() => selected,
() => player,
stop ?? (() => { }),
sendUntargeted ?? (_ => { }),
sendTargeted ?? ((_, _) => { }),
display ?? (_ => { }),
hasRequiredComponents: hasComponents,
incrementBusy: incrementBusy,
canSend: canSend);
private static Spellbook MakeBook(uint flags, bool untargeted, uint targetMask)
{
const string header = "Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]";
string row = $"1,Test,0x{flags:X},{untargeted},0x{targetMask:X}";
SpellTable table = SpellTable.LoadFromReader(new StringReader($"{header}\n{row}"));
var book = new Spellbook(table);
book.OnSpellLearned(1);
return book;
}
}

View file

@ -0,0 +1,180 @@
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class SpellComponentRequirementServiceTests
{
[Fact]
public void RequiresEveryFormulaScidMappedToOwnedWcid()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 100u,
ContainerId = 1u,
IsComponentPack = true,
});
var service = Service(objects, [10u, 11u], new Dictionary<uint, uint>
{
[10u] = 100u,
[11u] = 101u,
});
Assert.False(service.HasRequiredComponents(50u));
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 1u,
IsComponentPack = true,
});
Assert.True(service.HasRequiredComponents(50u));
}
[Fact]
public void ServerDisabledComponents_BypassesInventoryPreflight()
{
var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" };
player.Properties.Bools[SpellComponentRequirementService.SpellComponentsRequiredProperty] = false;
objects.AddOrUpdate(player);
Assert.True(Service(objects, [10u], new Dictionary<uint, uint>())
.HasRequiredComponents(50u));
}
[Fact]
public void PersonalizedFormula_UsesExactCanonicalAccountSpelling()
{
const string canonical = "CanonicalAccount";
const string loginSpelling = "canonicalaccount";
uint[] formula = [6u, 63u, 10u, 64u, 20u, 30u, 65u, 40u];
IReadOnlyList<uint> canonicalFormula = RetailSpellFormula.CustomizeForAccount(
formula, 3u, canonical);
IReadOnlyList<uint> loginFormula = RetailSpellFormula.CustomizeForAccount(
formula, 3u, loginSpelling);
Assert.NotEqual(canonicalFormula, loginFormula);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var map = canonicalFormula.Where(scid => scid != 0u)
.Distinct()
.ToDictionary(scid => scid, scid => scid + 1000u);
foreach (uint wcid in map.Values)
objects.AddOrUpdate(new ClientObject
{
ObjectId = wcid + 10000u,
WeenieClassId = wcid,
ContainerId = 1u,
});
string account = canonical;
var service = new SpellComponentRequirementService(
objects, () => 1u, () => account,
new Dictionary<uint, SpellFormulaDefinition> { [50u] = new(3u, formula) },
map);
Assert.True(service.HasRequiredComponents(50u));
account = loginSpelling;
Assert.False(service.HasRequiredComponents(50u));
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void InfusedSchoolOrDirectlyCarriedFocus_UsesScarabOnlyFormula(
bool infused,
bool carriesFocus)
{
var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" };
if (infused)
player.Properties.Ints[0x129u] = 1;
objects.AddOrUpdate(player);
if (carriesFocus)
{
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 900u,
ContainerId = 1u,
Type = ItemType.Container,
});
}
// War I scarab-only formula is SCID 1 + one prismatic taper (0xBC).
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 1u,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 4u,
WeenieClassId = 188u,
ContainerId = 1u,
});
var service = new SpellComponentRequirementService(
objects, () => 1u, () => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, [1u, 63u, 10u], School: 1u),
},
new Dictionary<uint, uint>
{
[1u] = 101u,
[0xBCu] = 188u,
},
new Dictionary<uint, uint> { [1u] = 900u });
Assert.True(service.HasRequiredComponents(50u));
}
[Fact]
public void FocusNestedInsidePack_DoesNotCountAsRetailMagicPackOwnership()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 700u,
ContainerId = 1u,
Type = ItemType.Container,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 900u,
ContainerId = 2u,
Type = ItemType.Container,
});
var service = new SpellComponentRequirementService(
objects, () => 1u, () => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, [1u, 63u], School: 1u),
},
new Dictionary<uint, uint>(),
new Dictionary<uint, uint> { [1u] = 900u });
Assert.False(service.HasRequiredComponents(50u));
}
private static SpellComponentRequirementService Service(
ClientObjectTable objects,
IReadOnlyList<uint> components,
IReadOnlyDictionary<uint, uint> map) => new(
objects,
() => 1u,
() => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, components),
},
map);
}

View file

@ -11,12 +11,12 @@ public sealed class CombatUiControllerTests
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void CombatMode_ShowsOnlyTargetedModes_AndSelectsMediumByDefault()
public void CombatMode_ShowsPhysicalAndMagicPages_AndSelectsMediumByDefault()
{
double now = 0d;
var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => now, []);
var (layout, _, _, power, high, medium, low) = BuildLayout();
var (layout, basic, spellcasting, power, high, medium, low) = BuildLayout();
var visibility = new List<bool>();
GameplaySettings gameplay = GameplaySettings.Default;
using var controller = CombatUiController.Bind(
@ -27,7 +27,9 @@ public sealed class CombatUiControllerTests
combat.SetCombatMode(CombatMode.Melee);
combat.SetCombatMode(CombatMode.Magic);
Assert.Equal([false, true, false], visibility);
Assert.Equal([false, true, true], visibility);
Assert.False(basic.Visible);
Assert.True(spellcasting.Visible);
Assert.False(high.Selected);
Assert.True(medium.Selected);
Assert.False(low.Selected);

View file

@ -0,0 +1,102 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class EffectsIndicatorControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void AuthoredFixture_BuildsRetailIndicatorButtons()
{
ElementInfo info = FixtureLoader.LoadIndicatorsInfos();
Assert.Equal(0x10000610u, info.Id);
Assert.Equal(150f, info.Width);
Assert.Equal(30f, info.Height);
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
Assert.IsType<UiButton>(layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
}
[Fact]
public void Enchantments_GhostAndEnableMatchingIndicator_ThenClickCorrectPanel()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n43,Bane,0x0\n"));
var spellbook = new Spellbook(table);
ImportedLayout layout = BuildSyntheticLayout();
var toggled = new List<uint>();
using EffectsIndicatorController controller = EffectsIndicatorController.Bind(
layout, spellbook, toggled.Add)!;
UiButton helpful = Assert.IsType<UiButton>(
layout.FindElement(EffectsIndicatorController.HelpfulButtonId));
UiButton harmful = Assert.IsType<UiButton>(
layout.FindElement(EffectsIndicatorController.HarmfulButtonId));
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 10u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 11u, 60f, 2u, Bucket: 8u, SpellCategory: 43u));
Assert.False(helpful.Enabled);
Assert.False(harmful.Enabled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 99u, PowerLevel: 1u));
Assert.True(helpful.Enabled);
Assert.False(harmful.Enabled);
helpful.OnEvent(new UiEvent(0, helpful, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.PositiveEffects], toggled);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
43u, 1u, 60f, 2u, Bucket: 2u, SpellCategory: 99u, PowerLevel: 7u));
// The harmful spell wins the shared category in the effects list, but
// retail's raw registry counters keep both indicator types enabled.
Assert.True(helpful.Enabled);
Assert.True(harmful.Enabled);
harmful.OnEvent(new UiEvent(0, harmful, UiEventType.Click));
Assert.Equal(
[RetailPanelCatalog.PositiveEffects, RetailPanelCatalog.NegativeEffects],
toggled);
spellbook.OnEnchantmentRemoved(1u, 42u);
Assert.False(helpful.Enabled);
Assert.True(harmful.Enabled);
}
private static ImportedLayout BuildSyntheticLayout()
{
var root = new ElementInfo { Id = 1u, Type = 3u, Width = 150f, Height = 30f };
var helpful = ButtonInfo(EffectsIndicatorController.HelpfulButtonId);
var harmful = ButtonInfo(EffectsIndicatorController.HarmfulButtonId);
return LayoutImporter.BuildFromInfos(root, [helpful, harmful], NoTex, null);
}
private static ElementInfo ButtonInfo(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = EffectsIndicatorController.RetailClassId,
Width = 20f,
Height = 20f,
DefaultStateId = UiButtonStateMachine.Normal,
};
info.States[UiButtonStateMachine.Normal] = new UiStateInfo
{
Id = UiButtonStateMachine.Normal,
Name = "Normal",
};
info.States[UiButtonStateMachine.Ghosted] = new UiStateInfo
{
Id = UiButtonStateMachine.Ghosted,
Name = "Ghosted",
};
return info;
}
}

View file

@ -0,0 +1,198 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class EffectsUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void Rebuild_DoesNotAutoSelectFirstEffect_AndClearsRemovedSelection()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n"));
var spellbook = new Spellbook(table);
var root = new UiPanel { Width = 160f, Height = 100f };
var list = new UiItemList(NoTex) { Width = 160f, Height = 64f };
var info = new UiText { Width = 160f, Height = 32f };
root.AddChild(list);
root.AddChild(info);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[EffectsUiController.ListId] = list,
[EffectsUiController.InfoTextId] = info,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id)!;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 2u, 60f, 1u, Bucket: 4u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 3u, 60f, 1u, Bucket: 8u, SpellCategory: 42u));
Assert.Null(controller.SelectedSpellId);
Assert.Equal(1, list.GetNumUIItems());
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
Assert.Equal(42u, controller.SelectedSpellId);
spellbook.OnEnchantmentRemoved(1u, 42u);
Assert.Null(controller.SelectedSpellId);
}
[Fact]
public void DuplicateLayersOfSameSpell_ShareRetailSelection()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n"));
var spellbook = new Spellbook(table);
var root = new UiPanel { Width = 160f, Height = 100f };
var list = new UiItemList(NoTex) { Width = 160f, Height = 64f };
root.AddChild(list);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[EffectsUiController.ListId] = list,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id)!;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 100u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 2u, 60f, 2u, Bucket: 2u, SpellCategory: 101u));
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
first.OnEvent(new UiEvent(0, first, UiEventType.Click));
Assert.Equal(42u, controller.SelectedSpellId);
Assert.True(first.Selected);
Assert.True(second.Selected);
second.OnEvent(new UiEvent(0, second, UiEventType.Click));
Assert.Null(controller.SelectedSpellId);
Assert.False(first.Selected);
Assert.False(second.Selected);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AuthoredEffectsFixture_InheritsListAndInfo_AndBinds(bool positive)
{
ElementInfo info = positive
? FixtureLoader.LoadPositiveEffectsInfos()
: FixtureLoader.LoadNegativeEffectsInfos();
uint expectedRoot = positive
? EffectsUiController.PositiveRootId
: EffectsUiController.NegativeRootId;
Assert.Equal(expectedRoot, info.Id);
Assert.Equal(0x1000001Bu, info.Type);
Assert.Equal(300f, info.Width);
Assert.Equal(362f, info.Height);
Assert.True(info.TryGetEffectiveProperty(0x1000000Cu, out UiPropertyValue effectType));
Assert.Equal(UiPropertyKind.Enum, effectType.Kind);
Assert.Equal(positive ? 1u : 2u, effectType.UnsignedValue);
Assert.Equal(
positive,
info.TryGetEffectiveBool(0x10000049u, out bool restorePrevious)
&& restorePrevious);
ElementInfo[] children = info.Children.OrderBy(child => child.ReadOrder).ToArray();
Assert.Equal(
[0x100000FCu, 0x10000120u, 0x10000123u, 0x10000124u, 0x10000125u],
children.Select(child => child.Id));
Assert.Equal([1u, 2u, 3u, 4u, 5u], children.Select(child => child.ReadOrder));
Assert.Equal(children.Length, children.Select(child => child.Id).Distinct().Count());
ElementInfo listInfo = Assert.Single(children, child => child.Id == EffectsUiController.ListId);
Assert.Equal(5u, listInfo.Type);
Assert.Equal(300f, listInfo.Width);
Assert.Equal(249f, listInfo.Height);
Assert.Equal(12u, FindInfo(info, EffectsUiController.InfoTextId)?.Type);
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
var spellbook = new Spellbook();
int closes = 0;
using EffectsUiController? controller = EffectsUiController.Bind(
layout, spellbook, positive, () => 0d, NoTex, id => id, () => closes++);
Assert.NotNull(controller);
Assert.NotNull(layout.FindElement(EffectsUiController.ListId));
Assert.IsType<UiText>(layout.FindElement(EffectsUiController.InfoTextId));
Assert.Equal(expectedRoot, layout.Root.DatElementId);
UiButton close = Assert.IsType<UiButton>(layout.FindElement(EffectsUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
uint panelId = positive
? RetailPanelCatalog.PositiveEffects
: RetailPanelCatalog.NegativeEffects;
Assert.True(RetailPanelCatalog.TryGetWindowName(panelId, out string windowName));
Assert.Equal(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
windowName);
Assert.DoesNotContain(
RetailPanelCatalog.ToolbarPanels,
entry => entry.PanelId == panelId);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AuthoredRestorePreviousProperty_DrivesRetailPanelLifecycle(bool positive)
{
ElementInfo info = positive
? FixtureLoader.LoadPositiveEffectsInfos()
: FixtureLoader.LoadNegativeEffectsInfos();
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
[WindowNames.Inventory] = false,
[positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects] = false,
};
var panelUi = new RetailPanelUiController(
name => visible[name],
name =>
{
visible[name] = true;
return true;
},
name =>
{
visible[name] = false;
return true;
});
uint effectsPanel = positive
? RetailPanelCatalog.PositiveEffects
: RetailPanelCatalog.NegativeEffects;
string effectsWindow = positive
? WindowNames.PositiveEffects
: WindowNames.NegativeEffects;
panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
panelUi.Register(
effectsPanel,
effectsWindow,
info);
panelUi.SetPanelVisibility(RetailPanelCatalog.Inventory, visible: true);
panelUi.SetPanelVisibility(effectsPanel, visible: true);
panelUi.SetPanelVisibility(effectsPanel, visible: false);
Assert.Equal(positive, visible[WindowNames.Inventory]);
Assert.False(visible[effectsWindow]);
Assert.Equal(
positive ? RetailPanelCatalog.Inventory : null,
panelUi.ActivePanelId);
}
private static ElementInfo? FindInfo(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (FindInfo(child, id) is { } found)
return found;
return null;
}
}

View file

@ -113,6 +113,24 @@ public static class FixtureLoader
public static ElementInfo LoadFpsDisplayInfos()
=> LoadInfos("smartbox_fps_2100000F.json");
public static ImportedLayout LoadPositiveEffects()
=> LayoutImporter.Build(LoadPositiveEffectsInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadPositiveEffectsInfos()
=> LoadInfos("effects_positive_2100001B.json");
public static ImportedLayout LoadNegativeEffects()
=> LayoutImporter.Build(LoadNegativeEffectsInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadNegativeEffectsInfos()
=> LoadInfos("effects_negative_2100001B.json");
public static ImportedLayout LoadIndicators()
=> LayoutImporter.Build(LoadIndicatorsInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadIndicatorsInfos()
=> LoadInfos("indicators_21000071.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -22,6 +22,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(EffectsIndicatorController.LayoutId, "indicators_21000071.json"),
(0x21000072u, "powerbar_21000072.json"),
(0x21000073u, "combat_21000073.json"),
(0x21000074u, "radar_21000074.json"),
@ -73,7 +74,7 @@ public sealed class RetailLayoutFixtureGenerator
Assert.Equal(0x4000001Au, fps!.FontDid);
var fpsStrings = new DatStringResolver(dats);
Assert.Equal("FPS: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 0));
Assert.Equal("\nDEG: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 1));
Assert.Equal(@"\nDEG: ", fpsStrings.Resolve(0x23000001u, 0x0DCFFF73u, 1));
var fpsJson = JsonSerializer.Serialize(fps, new JsonSerializerOptions
{
IncludeFields = true,
@ -83,6 +84,23 @@ public sealed class RetailLayoutFixtureGenerator
Path.Combine(FixtureDirectory(), $"smartbox_fps_{smartboxLayoutDid:X8}.json"),
fpsJson);
foreach ((uint rootId, string fileName) in new[]
{
(EffectsUiController.PositiveRootId, "effects_positive_2100001B.json"),
(EffectsUiController.NegativeRootId, "effects_negative_2100001B.json"),
})
{
ElementInfo? effects = LayoutImporter.ImportInfos(
dats, EffectsUiController.LayoutId, rootId);
Assert.NotNull(effects);
var effectsJson = JsonSerializer.Serialize(effects, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
}
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);

View file

@ -0,0 +1,90 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailPanelUiControllerTests
{
[Fact]
public void ShowingPanel_HidesCurrent_AndClosingLeavesHostEmpty()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["inventory"] = false,
["helpful"] = false,
};
var controller = Create(visible);
controller.Register(7u, "inventory");
controller.Register(4u, "helpful");
Assert.True(controller.SetPanelVisibility(7u, true));
Assert.True(visible["inventory"]);
Assert.Equal(7u, controller.ActivePanelId);
Assert.True(controller.SetPanelVisibility(4u, true));
Assert.False(visible["inventory"]);
Assert.True(visible["helpful"]);
Assert.Equal(4u, controller.ActivePanelId);
Assert.False(controller.TogglePanel(4u));
Assert.False(visible["helpful"]);
Assert.Null(controller.ActivePanelId);
}
[Fact]
public void RestorePreviousProperty_ReopensOnlyNonRestoringPreviousPanel()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["ordinary"] = false,
["transient"] = false,
};
var controller = Create(visible);
controller.Register(1u, "ordinary", restorePrevious: false);
controller.Register(2u, "transient", restorePrevious: true);
controller.SetPanelVisibility(1u, true);
controller.SetPanelVisibility(2u, true);
Assert.False(visible["ordinary"]);
Assert.True(visible["transient"]);
controller.SetPanelVisibility(2u, false);
Assert.True(visible["ordinary"]);
Assert.False(visible["transient"]);
Assert.Equal(1u, controller.ActivePanelId);
}
[Fact]
public void ObservedPersistenceShow_UsesSameExclusiveLifecycle()
{
var visible = new Dictionary<string, bool>(StringComparer.Ordinal)
{
["first"] = false,
["second"] = false,
};
var controller = Create(visible);
controller.Register(1u, "first");
controller.Register(2u, "second");
controller.SetPanelVisibility(1u, true);
visible["second"] = true;
controller.ObserveWindowVisibility("second", visible: true);
Assert.False(visible["first"]);
Assert.True(visible["second"]);
Assert.Equal(2u, controller.ActivePanelId);
}
private static RetailPanelUiController Create(Dictionary<string, bool> visible)
=> new(
name => visible[name],
name =>
{
visible[name] = true;
return true;
},
name =>
{
visible[name] = false;
return true;
});
}

View file

@ -0,0 +1,173 @@
using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellcastingUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var used = new List<uint>();
using SpellcastingUiController? controller = Bind(
layout, spellbook, objects, used.Add);
Assert.True(controller is not null, DescribeBinding(layout));
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Orb",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
IconId = 0x06001234u,
});
controller.Tick();
UiElement host = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(SpellcastingUiController.EndowmentId));
Assert.True(host.Visible);
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
Assert.Equal(2u, slot.EntryId);
Assert.Equal(2670u, slot.CatalogIconTexture);
Assert.Equal(2u, slot.CatalogOverlayTexture);
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId));
cast.OnEvent(new UiEvent(0, cast, UiEventType.Click));
Assert.Equal([2u], used);
}
[Fact]
public void FavoriteDrop_IgnoresForeignInventoryPayload()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController? controller = Bind(
layout, spellbook, objects, _ => { });
Assert.True(controller is not null, DescribeBinding(layout));
controller.Tick();
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
var foreign = new ItemDragPayload(9u, ItemDragSource.Inventory, 0, new UiItemSlot(), null);
bool handled = slot.OnEvent(new UiEvent(
0, slot, UiEventType.DropReleased, Payload: foreign));
Assert.True(handled);
Assert.Equal([42u], spellbook.GetFavorites(0));
}
[Fact]
public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { })!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiItemSlot before = list.GetItem(0)!;
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Rock" });
controller.Tick();
Assert.Same(before, list.GetItem(0));
}
[Fact]
public void ObjectTableClear_RemovesEquippedEndowmentAndCannotUseStaleGuid()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
Name = "Orb",
Type = ItemType.Caster,
WielderId = 1u,
CurrentlyEquippedLocation = EquipMask.Held,
SpellId = 2670u,
});
var used = new List<uint>();
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, used.Add)!;
UiElement host = layout.FindElement(SpellcastingUiController.EndowmentId)!;
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(host.Children[^1]);
slot.OnEvent(new UiEvent(0, slot, UiEventType.Click));
objects.Clear();
controller.Tick();
Assert.False(host.Visible);
Assert.Equal(0u, slot.EntryId);
slot.OnEvent(new UiEvent(0, slot, UiEventType.DoubleClick));
Assert.Empty(used);
}
private static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Action<uint> useItem)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
return SpellcastingUiController.Bind(
layout, spellbook, casting, objects, () => 1u,
spellId => spellId,
item => item.ObjectId,
useItem,
new SelectionState(),
(_, _, _) => { },
(_, _) => { });
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement nested in Descendants(child)) yield return nested;
}
}
private static string DescribeBinding(ImportedLayout layout)
{
uint[] ids =
[
SpellcastingUiController.CastButtonId, SpellcastingUiController.EndowmentId,
0x100000A3u, 0x100000AAu, 0x100005C2u, 0x100005C3u,
];
return string.Join(", ", ids.Select(id =>
$"{id:X8}={layout.FindElement(id)?.GetType().Name ?? "missing"}"));
}
}

View file

@ -1097,7 +1097,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741825,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -3320,7 +3320,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741824,
"HJustify": 1,
"HJustify": 0,
"VJustify": 2,
"FontColor": {
"X": 0.8,
@ -6248,7 +6248,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741824,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -2035,7 +2035,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741825,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -19338,7 +19338,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -20278,7 +20278,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -20859,7 +20859,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
@ -21469,7 +21469,7 @@
},
"DefaultStateId": 0,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -287,6 +287,382 @@
"StateCursors": {},
"DefaultStateName": "",
"Children": [
{
"Id": 268435977,
"Type": 3,
"X": 0,
"Y": 13,
"Width": 4,
"Height": 53,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 2,
"Bottom": 1,
"ReadOrder": 6,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687166,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687166,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435978,
"Type": 3,
"X": 74,
"Y": 13,
"Width": 4,
"Height": 53,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 2,
"Top": 1,
"Right": 1,
"Bottom": 1,
"ReadOrder": 7,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687166,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687166,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435979,
"Type": 3,
"X": 13,
"Y": 73,
"Width": 52,
"Height": 6,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 2,
"Right": 1,
"Bottom": 1,
"ReadOrder": 8,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687165,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687165,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436145,
"Type": 3,
"X": 0,
"Y": 0,
"Width": 13,
"Height": 13,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 2,
"Bottom": 2,
"ReadOrder": 1,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687161,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687161,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436146,
"Type": 3,
"X": 65,
"Y": 0,
"Width": 13,
"Height": 13,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 2,
"Top": 1,
"Right": 1,
"Bottom": 2,
"ReadOrder": 2,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687162,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687162,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436147,
"Type": 3,
"X": 0,
"Y": 65,
"Width": 13,
"Height": 14,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 2,
"Right": 2,
"Bottom": 1,
"ReadOrder": 3,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687163,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687163,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436148,
"Type": 3,
"X": 65,
"Y": 65,
"Width": 13,
"Height": 14,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 2,
"Top": 2,
"Right": 1,
"Bottom": 1,
"ReadOrder": 4,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687164,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687164,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436149,
"Type": 3,
"X": 13,
"Y": 0,
"Width": 52,
"Height": 6,
"OriginalParentWidth": 78,
"OriginalParentHeight": 79,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 2,
"ReadOrder": 5,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": {
"File": 100687165,
"DrawMode": 3
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100687165,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268436271,
"Type": 3,
@ -301,7 +677,7 @@
"Top": 2,
"Right": 3,
"Bottom": 1,
"ReadOrder": 2,
"ReadOrder": 10,
"ZLevel": 0,
"States": {
"4294967295": {
@ -2069,7 +2445,7 @@
"Top": 1,
"Right": 1,
"Bottom": 1,
"ReadOrder": 1,
"ReadOrder": 9,
"ZLevel": 0,
"States": {
"4294967295": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8940,7 +8940,7 @@
},
"DefaultStateId": 1,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -8151,7 +8151,7 @@
},
"DefaultStateId": 1,
"FontDid": 1073741826,
"HJustify": 1,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,

View file

@ -55,6 +55,17 @@ public class UiItemSlotTests
Assert.True(fired);
}
[Fact]
public void CatalogSlot_keeps_catalog_identity_separate_from_object_guid()
{
var slot = new UiCatalogSlot { EntryId = 1234u, CatalogIconTexture = 99u };
Assert.Equal(1234u, slot.EntryId);
Assert.Equal(0u, slot.ItemId);
Assert.False(slot.IsDragSource);
Assert.Null(slot.GetDragPayload());
}
// ── Shortcut number tests ────────────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).

View file

@ -460,8 +460,8 @@ public sealed class GameEventWiringTests
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
{
var (d, _, _, book, _) = MakeAll();
book.OnEnchantmentAdded(1, 1, 100f, 0);
book.OnEnchantmentAdded(2, 2, 100f, 0);
book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2));
Assert.Equal(2, book.ActiveCount);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty<byte>()));
@ -470,6 +470,32 @@ public sealed class GameEventWiringTests
Assert.Equal(0, book.ActiveCount);
}
[Fact]
public void WireAll_MagicUpdateEnchantment_ConvertsRelativeTimesAndClassifiesBucket()
{
var dispatcher = new GameEventDispatcher();
var book = new Spellbook();
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
book,
new ChatLog(),
clientTime: () => 100d);
byte[] payload = BuildEnchantment(
spellId: 42, layer: 3, duration: 60, caster: 0xBEEF,
startTime: 10, lastDegraded: 2, statModType: 0x00008000u);
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.MagicUpdateEnchantment, payload))!.Value);
ActiveEnchantmentRecord record = Assert.Single(book.ActiveEnchantmentSnapshot);
Assert.Equal(110d, record.StartTime);
Assert.Equal(102d, record.LastTimeDegraded);
Assert.Equal(2u, record.Bucket);
Assert.Equal(60d, record.Duration);
}
[Fact]
public void WireAll_WeenieError_RoutesToChatLog()
{
@ -1064,4 +1090,27 @@ public sealed class GameEventWiringTests
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
}
private static byte[] BuildEnchantment(
ushort spellId,
ushort layer,
double duration,
uint caster,
double startTime,
double lastDegraded,
uint statModType)
{
byte[] payload = new byte[60];
int offset = 0;
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster);
WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded);
WriteU32(statModType); WriteU32(7); WriteF32(1.25f);
return payload;
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
}
}

View file

@ -42,37 +42,92 @@ public sealed class CastSpellTests
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u);
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x123403E1u);
var parsed = GameEvents.ParseMagicUpdateSpell(payload);
Assert.Equal(0x123403E1u, parsed);
}
[Fact]
public void ParseMagicUpdateEnchantment_RoundTrip()
{
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); // spellId
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 7u); // layerId
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(8), 300.0f); // duration
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0xBEEFu); // caster
byte[] payload = BuildEnchantment(spellId: 42, layer: 7, duration: 300, caster: 0xBEEF);
var parsed = GameEvents.ParseMagicUpdateEnchantment(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.SpellId);
Assert.Equal(7u, parsed.Value.LayerId);
Assert.Equal(300f, parsed.Value.Duration, 4);
Assert.Equal((ushort)42, parsed!.Value.SpellId);
Assert.Equal((ushort)7, parsed.Value.Layer);
Assert.Equal(300d, parsed.Value.Duration, 4);
Assert.Equal(0xBEEFu, parsed.Value.CasterGuid);
Assert.Equal(0x20u, parsed.Value.StatModType);
Assert.Equal(7u, parsed.Value.StatModKey);
Assert.Equal(1.25f, parsed.Value.StatModValue);
}
[Fact]
public void ParseMagicRemoveEnchantment_RoundTrip()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); // layerId
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); // spellId
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt16LittleEndian(payload, 42);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2), 7);
var parsed = GameEvents.ParseMagicRemoveEnchantment(payload);
Assert.NotNull(parsed);
Assert.Equal(7u, parsed!.Value.LayerId);
Assert.Equal(42u, parsed.Value.SpellId);
Assert.Equal((ushort)7, parsed!.Value.Layer);
Assert.Equal((ushort)42, parsed.Value.SpellId);
}
[Fact]
public void ParseMagicUpdateMultipleEnchantments_ReadsEveryRecord()
{
byte[] first = BuildEnchantment(42, 1, 60, 0xAA);
byte[] second = BuildEnchantment(43, 2, 120, 0xBB);
byte[] payload = new byte[4 + first.Length + second.Length];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 2);
first.CopyTo(payload, 4);
second.CopyTo(payload, 4 + first.Length);
var parsed = GameEvents.ParseMagicUpdateMultipleEnchantments(payload);
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Count);
Assert.Equal((ushort)42, parsed[0].SpellId);
Assert.Equal((ushort)43, parsed[1].SpellId);
}
[Fact]
public void ParseMagicUpdateEnchantment_RejectsTruncatedOptionalSpellSetId()
{
byte[] payload = BuildEnchantment(42, 1, 60, 0xAA);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6), 1);
Assert.Null(GameEvents.ParseMagicUpdateEnchantment(payload));
}
[Fact]
public void ParseMagicLayeredSpellList_RejectsTruncation()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 2);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(4), 42);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(6), 1);
Assert.Null(GameEvents.ParseMagicLayeredSpellList(payload));
}
private static byte[] BuildEnchantment(
ushort spellId, ushort layer, double duration, uint caster)
{
byte[] payload = new byte[60];
int offset = 0;
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
WriteU32(8); WriteF64(10); WriteF64(duration); WriteU32(caster);
WriteF32(0.1f); WriteF32(-1f); WriteF64(0);
WriteU32(0x20); WriteU32(7); WriteF32(1.25f);
return payload;
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; }
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
}
}

View file

@ -147,6 +147,31 @@ public sealed class ClientCommandRequestsTests
Assert.Equal(25u, Read(body, 16));
}
[Fact]
public void AddSpellFavorite_MatchesRetailThreeIntegerPayload()
{
byte[] body = ClientCommandRequests.BuildAddSpellFavorite(9u, 42u, 3, 7);
Assert.Equal(24, body.Length);
Assert.Equal(ClientCommandRequests.AddSpellFavoriteOpcode, Read(body, 8));
Assert.Equal(42u, Read(body, 12));
Assert.Equal(3u, Read(body, 16));
Assert.Equal(7u, Read(body, 20));
}
[Fact]
public void RemoveFavoriteAndFilter_MatchRetailPayloads()
{
byte[] remove = ClientCommandRequests.BuildRemoveSpellFavorite(2u, 42u, 6);
byte[] filter = ClientCommandRequests.BuildSpellbookFilter(3u, 0xA5u);
Assert.Equal(ClientCommandRequests.RemoveSpellFavoriteOpcode, Read(remove, 8));
Assert.Equal(42u, Read(remove, 12));
Assert.Equal(6u, Read(remove, 16));
Assert.Equal(ClientCommandRequests.SpellbookFilterOpcode, Read(filter, 8));
Assert.Equal(0xA5u, Read(filter, 12));
}
[Fact]
public void LegacyFriendsCommand_IsAControlMessageWithoutGameActionEnvelope()
{

View file

@ -628,6 +628,22 @@ public sealed class CreateObjectTests
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
}
[Fact]
public void WeenieHeader_spellId_isPreservedForCasterEndowment()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000025u,
name: "Orb",
itemType: (uint)ItemType.Caster,
weenieFlags: 0x00400000u,
spellId: 0x0A6E);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x0A6Eu, parsed.Value.SpellId);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
@ -673,7 +689,8 @@ public sealed class CreateObjectTests
uint materialType = 0,
uint cooldownId = 0,
double cooldownDuration = 0,
uint petOwnerId = 0)
uint petOwnerId = 0,
ushort spellId = 0)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -760,7 +777,7 @@ public sealed class CreateObjectTests
bytes.AddRange(tmp.ToArray());
}
if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, 0); // Spell u16
if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, spellId); // Spell u16
if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
// HouseRestrictions (0x04000000): not parameterized (zero entries).
// Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.

View file

@ -237,7 +237,8 @@ public sealed class GameEventDispatcherTests
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x123403E1u);
var parsed = GameEvents.ParseMagicUpdateSpell(payload);
Assert.Equal(0x123403E1u, parsed);
}
}

View file

@ -379,7 +379,7 @@ public sealed class PlayerDescriptionParserTests
writer.Write(0u); // option_flags = None — no further sections
writer.Write(0xDEADBEEFu); // options1 sentinel
// No more bytes — spellbook_filters is optional (defaults to 0).
// No more bytes — spellbook_filters is optional (retail defaults all on).
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
@ -392,7 +392,7 @@ public sealed class PlayerDescriptionParserTests
// pre-existing regression guard if they accidentally consume into
// the wrong field's wire bytes.
Assert.Equal(0u, parsed.Value.Options2);
Assert.Equal(0u, parsed.Value.SpellbookFilters);
Assert.Equal(0x3FFFu, parsed.Value.SpellbookFilters);
Assert.Empty(parsed.Value.HotbarSpells);
Assert.Empty(parsed.Value.DesiredComps);
Assert.True(parsed.Value.GameplayOptions.IsEmpty);

View file

@ -0,0 +1,91 @@
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
public sealed class EnchantmentRegistryProjectionTests
{
[Fact]
public void GetEnchantmentsInEffect_ExcludesVitaeAndCooldown()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 1u),
Enchantment(2u, 4u, category: 2u),
Enchantment(3u, 8u, category: 3u),
Enchantment(4u, 2u, category: 4u),
]);
Assert.Equal([1u, 4u], result.Select(record => record.SpellId));
}
[Fact]
public void GetEnchantmentsInEffect_CategoryDuel_PrefersHigherPower()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 12u, power: 1u),
Enchantment(2u, 1u, category: 12u, power: 7u),
]);
Assert.Equal(2u, Assert.Single(result).SpellId);
}
[Fact]
public void GetEnchantmentsInEffect_PowerComparisonUsesRetailSignedInt()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 12u, power: 1u),
Enchantment(2u, 1u, category: 12u, power: uint.MaxValue),
]);
Assert.Equal(1u, Assert.Single(result).SpellId);
}
[Fact]
public void GetEnchantmentsInEffect_EqualPower_PrefersLaterStartAndKeepsExactTie()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 12u, power: 7u, start: 10d),
Enchantment(2u, 1u, category: 12u, power: 7u, start: 20d),
Enchantment(3u, 2u, category: 12u, power: 7u, start: 20d),
]);
Assert.Equal(2u, Assert.Single(result).SpellId);
}
[Fact]
public void GetEnchantmentsInEffect_ReplacingWinner_AppendsInRetailOrder()
{
IReadOnlyList<ActiveEnchantmentRecord> result =
EnchantmentRegistryProjection.GetEnchantmentsInEffect(
[
Enchantment(1u, 1u, category: 10u, power: 1u),
Enchantment(2u, 1u, category: 20u, power: 1u),
Enchantment(3u, 2u, category: 10u, power: 2u),
]);
Assert.Equal([2u, 3u], result.Select(record => record.SpellId));
}
private static ActiveEnchantmentRecord Enchantment(
uint spellId,
uint bucket,
uint category,
uint power = 1u,
double start = 0d)
=> new(
SpellId: spellId,
LayerId: spellId,
Duration: 60d,
CasterGuid: 1u,
Bucket: bucket,
StartTime: start,
SpellCategory: category,
PowerLevel: power);
}

View file

@ -0,0 +1,59 @@
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
public sealed class RetailSpellFormulaTests
{
[Theory]
[InlineData(1u, 1)]
[InlineData(6u, 6)]
[InlineData(0x6Eu, 6)]
[InlineData(0x70u, 7)]
[InlineData(0xC0u, 7)]
[InlineData(0xC1u, 8)]
[InlineData(7u, 0)]
public void RoughHeuristic_UsesFormulaPowerComponent(uint component, int expected)
=> Assert.Equal(expected,
RetailSpellFormula.InqSpellLevelByRoughHeuristic([component]));
[Fact]
public void CustomizeForAccount_IsDeterministic_AndOnlyChangesTapers()
{
uint[] formula = [6u, 63u, 10u, 64u, 20u, 30u, 65u, 40u];
IReadOnlyList<uint> first = RetailSpellFormula.CustomizeForAccount(
formula, 3u, "testaccount");
IReadOnlyList<uint> second = RetailSpellFormula.CustomizeForAccount(
formula, 3u, "testaccount");
Assert.Equal(first, second);
Assert.Equal(formula[0], first[0]);
Assert.Equal(formula[1], first[1]);
Assert.Equal(formula[2], first[2]);
Assert.InRange(first[3], 63u, 74u);
Assert.Equal(formula[4], first[4]);
Assert.Equal(formula[5], first[5]);
Assert.InRange(first[6], 63u, 74u);
Assert.Equal(formula[7], first[7]);
}
[Theory]
[InlineData(1u, 1)]
[InlineData(2u, 2)]
[InlineData(3u, 3)]
[InlineData(0x6Eu, 3)]
[InlineData(0x70u, 4)]
[InlineData(0xC1u, 4)]
public void ScarabOnlyFormula_KeepsPowerAndAppendsRetailTaperCount(
uint powerComponent,
int expectedTapers)
{
IReadOnlyList<uint> result = RetailSpellFormula.InqScarabOnlyFormula(
[powerComponent, 63u, 10u, 64u]);
Assert.Equal(powerComponent, result[0]);
Assert.Equal(expectedTapers, result.Count(component => component == 0xBCu));
Assert.DoesNotContain(63u, result);
Assert.DoesNotContain(10u, result);
}
}

View file

@ -61,6 +61,72 @@ public sealed class SpellbookTests
Assert.Equal(600f, e.Duration, 4);
}
[Fact]
public void OnEnchantmentAdded_SameLayerDifferentSpell_RemainsDistinct()
{
var book = new Spellbook();
book.OnEnchantmentAdded(42, 7, 300f, 0);
book.OnEnchantmentAdded(43, 7, 600f, 0);
Assert.Equal(2, book.ActiveCount);
}
[Fact]
public void LiveEnchantments_NewIdentityInsertsAtRetailListHead()
{
var book = new Spellbook();
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
book.OnEnchantmentAdded(Effect(2u, layer: 2u, category: 12u));
Assert.Equal(2u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void LiveEnchantment_ExistingIdentityRefreshKeepsListPosition()
{
var book = new Spellbook();
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
book.OnEnchantmentAdded(Effect(2u, layer: 2u, category: 12u));
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
Assert.Equal(2u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void ManifestEnchantments_PreserveReceivedListOrder()
{
var book = new Spellbook();
book.ReplaceManifest(
new Dictionary<uint, float>(),
[Effect(1u, layer: 1u, category: 12u), Effect(2u, layer: 2u, category: 12u)],
[],
[],
0x3FFFu);
Assert.Equal(1u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void ReplaceManifest_ReplacesFavoritesFiltersAndDesiredComponents()
{
var book = new Spellbook();
book.OnSpellLearned(999);
book.ReplaceManifest(
new Dictionary<uint, float> { [42] = 123f },
[new ActiveEnchantmentRecord(50, 2, 60, 1)],
[new uint[] { 42, 43 }],
[(100u, 25u)],
0xA5u);
Assert.False(book.Knows(999));
Assert.True(book.Knows(42));
Assert.Equal(new uint[] { 42, 43 }, book.GetFavorites(0));
Assert.Equal(25u, book.DesiredComponents[100]);
Assert.Equal(0xA5u, book.SpellbookFilters);
Assert.Equal(1, book.ActiveCount);
}
[Fact]
public void OnEnchantmentRemoved_FiresEvent()
{
@ -76,18 +142,49 @@ public sealed class SpellbookTests
}
[Fact]
public void OnPurgeAll_RemovesAllEnchantments_FiresPerRecord()
public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments()
{
var book = new Spellbook();
book.OnEnchantmentAdded(1, 1, 100f, 0);
book.OnEnchantmentAdded(2, 2, 100f, 0);
book.OnEnchantmentAdded(3, 3, 100f, 0);
book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2));
book.OnEnchantmentAdded(new(3, 3, -1, 0, Bucket: 2));
book.OnEnchantmentAdded(new(4, 4, 100, 0, Bucket: 4));
book.OnEnchantmentAdded(new(5, 5, 100, 0, Bucket: 8));
int removals = 0;
book.EnchantmentRemoved += _ => removals++;
book.OnPurgeAll();
Assert.Equal(3, removals);
Assert.Equal(0, book.ActiveCount);
Assert.Equal(2, removals);
Assert.Equal(3, book.ActiveCount);
Assert.Equal([3u, 4u, 5u],
book.ActiveEnchantmentSnapshot.Select(record => record.SpellId).Order().ToArray());
}
[Fact]
public void OnPurgeBad_UsesStatModBeneficialFlag_AndPreservesPermanent()
{
const uint Beneficial = 0x02000000u;
var book = new Spellbook();
book.OnEnchantmentAdded(new(1, 1, 100, 0, StatModType: 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, StatModType: Beneficial, Bucket: 2));
book.OnEnchantmentAdded(new(3, 3, -1, 0, StatModType: 0, Bucket: 2));
book.OnEnchantmentAdded(new(4, 4, 100, 0, StatModType: 0, Bucket: 8));
book.OnPurgeBadEnchantments();
Assert.Equal([2u, 3u, 4u],
book.ActiveEnchantmentSnapshot.Select(record => record.SpellId).Order().ToArray());
}
private static ActiveEnchantmentRecord Effect(uint spellId, uint layer, uint category)
=> new(
SpellId: spellId,
LayerId: layer,
Duration: 60d,
CasterGuid: 1u,
Bucket: 1u,
StartTime: 10d,
SpellCategory: category,
PowerLevel: 7u);
}

View file

@ -38,6 +38,47 @@ public class InputDispatcherIsActionHeldTests
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_CombatBindingOnSameChord_ShadowsGamePolling()
{
var (dispatcher, kb, _, bindings) = Build();
var chord = new KeyChord(Key.W, ModifierMask.None);
bindings.Add(new Binding(chord, InputAction.MovementForward));
bindings.Add(new Binding(
chord,
InputAction.CombatCastCurrentSpell,
Scope: InputScope.MagicCombat));
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.True(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
dispatcher.SetCombatScope(null);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.False(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
}
[Fact]
public void IsActionHeld_ExplicitShiftCombatChord_ShadowsBareMovementFallback()
{
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.Shift),
InputAction.CombatCastCurrentSpell,
Scope: InputScope.MagicCombat));
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.W, ModifierMask.Shift);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.True(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
}
[Fact]
public void IsActionHeld_returns_false_when_no_binding_for_action()
{

View file

@ -82,6 +82,44 @@ public class InputDispatcherTests
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void Magic_combat_scope_shadows_game_binding_for_same_chord()
{
var (dispatcher, kb, _, bindings, fired) = Build();
var chord = new KeyChord(Key.Number1, ModifierMask.None);
bindings.Add(new Binding(chord, InputAction.UseQuickSlot_1));
bindings.Add(new Binding(chord, InputAction.UseSpellSlot_1, Scope: InputScope.MagicCombat));
kb.EmitKeyDown(Key.Number1, ModifierMask.None);
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.Number1, ModifierMask.None);
Assert.Equal(
[(InputAction.UseQuickSlot_1, ActivationType.Press),
(InputAction.UseSpellSlot_1, ActivationType.Press)],
fired);
}
[Fact]
public void Changing_combat_scope_releases_hold_resolved_in_previous_scope()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.Delete, ModifierMask.None),
InputAction.CombatLowAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
dispatcher.SetCombatScope(InputScope.MeleeCombat);
kb.EmitKeyDown(Key.Delete, ModifierMask.None);
dispatcher.SetCombatScope(InputScope.MagicCombat);
Assert.Equal(
[(InputAction.CombatLowAttack, ActivationType.Press),
(InputAction.CombatLowAttack, ActivationType.Release)],
fired);
}
[Fact]
public void PushScope_changes_ActiveScope()
{
@ -149,6 +187,40 @@ public class InputDispatcherTests
Assert.Empty(fired); // no longer held
}
[Fact]
public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.Delete, ModifierMask.None),
InputAction.CombatLowAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
bindings.Add(new Binding(
new KeyChord(Key.End, ModifierMask.None),
InputAction.CombatHighAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
dispatcher.SetCombatScope(InputScope.MeleeCombat);
kb.EmitKeyDown(Key.Delete, ModifierMask.None);
kb.EmitKeyDown(Key.End, ModifierMask.None);
fired.Clear();
bool changed = false;
dispatcher.Fired += (_, activation) =>
{
if (activation != ActivationType.Hold || changed)
return;
changed = true;
dispatcher.SetCombatScope(InputScope.MagicCombat);
};
dispatcher.Tick();
Assert.Single(fired, entry => entry.Item2 == ActivationType.Hold);
Assert.Equal(2, fired.Count(entry => entry.Item2 == ActivationType.Release));
}
[Fact]
public void Release_binding_fires_only_on_KeyUp()
{

View file

@ -49,7 +49,8 @@ public class KeyBindingsJsonTests
Assert.Contains(loaded.All, x =>
x.Chord == b.Chord
&& x.Action == b.Action
&& x.Activation == b.Activation);
&& x.Activation == b.Activation
&& x.Scope == b.Scope);
}
}
finally

View file

@ -57,6 +57,20 @@ public class KeyBindingsTests
Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
}
[Fact]
public void RetailDefaults_resolve_shared_number_key_by_scope()
{
var bindings = KeyBindings.RetailDefaults();
var chord = new KeyChord(Key.Number1, ModifierMask.None);
Assert.Equal(
InputAction.UseQuickSlot_1,
bindings.Find(chord, ActivationType.Press, InputScope.Game)?.Action);
Assert.Equal(
InputAction.UseSpellSlot_1,
bindings.Find(chord, ActivationType.Press, InputScope.MagicCombat)?.Action);
}
[Fact]
public void ForAction_returns_all_chords_bound_to_action()
{