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:
parent
7b7ffcd278
commit
07be994d97
84 changed files with 17822 additions and 1051 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
||||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
|
||||
- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||
- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
|
||||
- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, 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 0–1 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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
197
src/AcDream.App/Spells/MagicRuntime.cs
Normal file
197
src/AcDream.App/Spells/MagicRuntime.cs
Normal 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();
|
||||
}
|
||||
45
src/AcDream.App/Spells/RetailSpellTargetPolicy.cs
Normal file
45
src/AcDream.App/Spells/RetailSpellTargetPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
157
src/AcDream.App/Spells/SpellCastingController.cs
Normal file
157
src/AcDream.App/Spells/SpellCastingController.cs
Normal 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,
|
||||
}
|
||||
121
src/AcDream.App/Spells/SpellComponentRequirementService.cs
Normal file
121
src/AcDream.App/Spells/SpellComponentRequirementService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
Normal file
86
src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
src/AcDream.App/UI/Layout/RetailPanelUiController.cs
Normal file
144
src/AcDream.App/UI/Layout/RetailPanelUiController.cs
Normal 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);
|
||||
}
|
||||
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal file
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
472
src/AcDream.App/UI/Layout/SpellcastingUiController.cs
Normal file
472
src/AcDream.App/UI/Layout/SpellcastingUiController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
97
src/AcDream.App/UI/UiCatalogSlot.cs
Normal file
97
src/AcDream.App/UI/UiCatalogSlot.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
115
src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs
Normal file
115
src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
67
src/AcDream.Core/Spells/EnchantmentRegistryProjection.cs
Normal file
67
src/AcDream.Core/Spells/EnchantmentRegistryProjection.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
190
src/AcDream.Core/Spells/RetailSpellFormula.cs
Normal file
190
src/AcDream.Core/Spells/RetailSpellFormula.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
115
tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
Normal file
115
tests/AcDream.App.Tests/Spells/RetailSpellTargetPolicyTests.cs
Normal 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);
|
||||
}
|
||||
119
tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs
Normal file
119
tests/AcDream.App.Tests/Spells/SpellCastingControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
198
tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
Normal file
198
tests/AcDream.App.Tests/UI/Layout/EffectsUiControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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"}"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
2719
tests/AcDream.App.Tests/UI/Layout/fixtures/indicators_21000071.json
Normal file
2719
tests/AcDream.App.Tests/UI/Layout/fixtures/indicators_21000071.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8940,7 +8940,7 @@
|
|||
},
|
||||
"DefaultStateId": 1,
|
||||
"FontDid": 1073741826,
|
||||
"HJustify": 1,
|
||||
"HJustify": 0,
|
||||
"VJustify": 1,
|
||||
"FontColor": {
|
||||
"X": 1,
|
||||
|
|
|
|||
|
|
@ -8151,7 +8151,7 @@
|
|||
},
|
||||
"DefaultStateId": 1,
|
||||
"FontDid": 1073741826,
|
||||
"HJustify": 1,
|
||||
"HJustify": 0,
|
||||
"VJustify": 1,
|
||||
"FontColor": {
|
||||
"X": 1,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
59
tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs
Normal file
59
tests/AcDream.Core.Tests/Spells/RetailSpellFormulaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue