diff --git a/AGENTS.md b/AGENTS.md index e12d6678..9f582297 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the appraisal and retained catalog-input corrections pass -3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips. +Release builds; the appraisal formula-media/geometry correction passes +3,555 App tests / 3 skips and 7,986 complete-solution tests / 5 skips. **Current work order:** the six-slice pre-M4 world-interaction completion program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite @@ -126,8 +126,9 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are user-accepted. Slice 3's first connected gate exposed and corrected the monster-response flag mismatch, non-retail shared-panel mounting, and missing inscription transaction. The creature and common-item report corrections are -user-accepted; favorite-spell press-time selection and right-click local -examination are the current visual gate. +user-accepted. Favorite-spell press-time selection and right-click local +examination are implemented; the current visual gate is its corrected modern +scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/CLAUDE.md b/CLAUDE.md index 79e8a0cd..f9a618b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the appraisal and retained catalog-input corrections pass -3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips. +Release builds; the appraisal formula-media/geometry correction passes +3,555 App tests / 3 skips and 7,986 complete-solution tests / 5 skips. **Current work order:** the six-slice pre-M4 world-interaction completion program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite @@ -124,8 +124,9 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are user-accepted. Slice 3's first connected gate exposed and corrected the monster-response flag mismatch, non-retail shared-panel mounting, and missing inscription transaction. The creature and common-item report corrections are -user-accepted; favorite-spell press-time selection and right-click local -examination are the current visual gate. +user-accepted. Favorite-spell press-time selection and right-click local +examination are implemented; the current visual gate is its corrected modern +scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c52d5625..6c2b24ec 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -166,6 +166,13 @@ component formula. It sends no Appraise request and takes no busy reference, so the status-bar magnifier remains object-only. Switching the shared floaty from a pending object appraisal to a spell releases exactly that appraisal transaction before showing the SpellPanel. +The formula-media correction now passes the component's DAT icon DID to +`GetSpellComponentIcon` instead of its WCID, so the authored cells render +their actual scarab/taper images. ACE characters with component enforcement +disabled show the modern scarab/prismatic formula rather than an inactive +legacy recipe (IA-21). A per-window authored-geometry revision resets the +obsolete saved 545-pixel examination height once to LayoutDesc's 310 x 400 +extent, preserving its position and all future retail-style user resizing. **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; @@ -196,6 +203,10 @@ dragging preserves that selection without activating the item. Left-pressing a favorite spell likewise changes its selection immediately. Right-clicking it opens the spell examination subview; selecting it and using the status-bar magnifier does not examine the spell. +The formula contains only its power scarab(s) and prismatic taper(s) in the +component-disabled ACE test profile, every component cell shows its DAT icon, +and the first corrected open is 310 x 400 even when the old profile saved a +545-pixel height. The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`, then its description after one retail paragraph break, without the bogus 255-item/255-container line. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index cfb43e4a..cb555fc7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -53,11 +53,12 @@ accepted-divergence entries (#96, #49, #50). | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port β€” keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical geometry and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from retail UI deep-dives, named client methods, and production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content tree while typed move/resize synchronization gives all registered toolbar/detail children the same persistent parent rectangle | Edge-case low-level input semantics can differ silently even though outer geometry, visibility, and restore-previous ownership match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them β€” dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case β€” terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟒/πŸ”΄ split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | -| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | +| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts with per-window authored-geometry revisions, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) β€” retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE β€” pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 β†’ the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) β€” the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`) | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | +| IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 5b793787..12239f26 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -44,8 +44,12 @@ selects without waiting for release; right-click opens the examination floaty's authored SpellPanel locally; and spell IDs never enter global object selection or the status-bar magnifier path. The view projects retail's name/school/mana/duration/range/description and authored appropriate-formula -component cells, including live missing-component state. Its connected visual -gate is pending. Slice 3's first connected gate exposed +component cells, including live missing-component state. The follow-up resolves +those cells through the component icon DID, shows the modern +scarab/prismatic formula for ACE characters whose component enforcement is +disabled (IA-21), and one-time migrates stale saved examination dimensions to +the authored 310 x 400 extent while preserving retail-style later resizing. +Its connected visual gate is pending. Slice 3's first connected gate exposed three deeper defects, now corrected: the response enum mislabeled ACE's creature bit `0x0100` and dropped monster packets while retaining the busy cursor; the imported 310 x 400 examination layout was incorrectly hosted as diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index ded8b6d4..3715bca4 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -112,8 +112,12 @@ Favorite spells now use the parallel retail catalog contract: MouseDown selects immediately, right-click opens the examination floaty's authored SpellPanel locally, and no spell ID enters global object selection or the status-bar magnifier command. Exact spell fields and the current appropriate -formula's authored component cells are automated; the connected visual gate -is pending. +formula's authored component cells are automated. The correction resolves +component icons by DAT DID, projects scarabs/prismatic tapers when ACE +disables component enforcement (IA-21), and resets obsolete saved dimensions +once to the authored 310 x 400 extent. The Release build with #228's 17 +tracked test warnings, 3,555 App tests / 3 skips, and 7,986 complete-solution +tests / 5 skips pass; the connected visual gate is pending. Slice 3's first connected gate found and corrected the real remaining defects: ACE creature flag `0x0100` was mislabeled as a weapon profile and left the appraisal busy reference held, retail's examination UI had been mounted as a diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 9490919b..5807d4d9 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -9,7 +9,11 @@ corrected and live-confirmed. The creature-page follow-up now has retail's ordered stat rows and animated private preview. The follow-up item report and authored 310 x 400 layout correction are implemented; their connected visual gate is pending. Favorite-spell press-time selection and right-click local -SpellPanel examination are implemented and await their connected visual gate. +SpellPanel examination are implemented. The follow-up now maps +component-disabled ACE characters to the modern scarab/prismatic formula, +resolves formula icons by their DAT icon DIDs, and migrates stale examination +dimensions once to the authored 310 x 400 extent. Their connected visual gate +is pending. **Milestone:** M4 prerequisite/preamble. **Architecture:** retained gameplay UI over shared selection, object, and interaction state. `GameWindow` remains a composition/callback shell. @@ -257,9 +261,15 @@ on left press, while the spell branch of SpellPanel locally on right-click. Spell IDs never enter `SelectionState` or the status-bar magnifier path, and no Appraise/busy transaction is invented. The view projects exact spell fields and the current appropriate formula -through authored component template `0x1000032E`; its connected visual gate is -pending. The 61-test focused App gate, Release solution build, 3,553 App tests -/ 3 skips, and 7,984 complete-solution tests / 5 skips pass. +through authored component template `0x1000032E`. Its corrective pass uses +each component descriptor's icon DID rather than its inventory WCID, applies +the modern scarab/prismatic formula when ACE disables component enforcement +(IA-21), and introduces per-window authored-geometry revisions so an obsolete +saved examination height resets once without discarding position or future +user resizing. The connected visual gate is pending. The 61-test focused App +gate passed before this correction. The corrected focused tests, Release build +with #228's 17 tracked test warnings, 3,555 App tests / 3 skips, and 7,986 +complete-solution tests / 5 skips pass. ## Slice 1 β€” spell-bar overflow arrows diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md index a591e40f..2376c5fa 100644 --- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md +++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md @@ -87,6 +87,37 @@ favorite-component events use WCIDs, resolved through DualEnumIDMap `0x27000002`. The magic-school focus map is retail enum category `0x28`, key `0x10000001`. +ACE integration policy (IA-21): when player BoolProperty `68` +(`SpellComponentsRequired`) is false, casting already bypasses component +preflight. In that server mode acdream also projects +`InqScarabOnlyFormula` in Spell examination, so an inactive account-randomized +legacy recipe is not exposed. With enforcement enabled, the focus/infusion +branch above remains byte-for-byte retail. + +### Spell-examination formula media and extent + +Retail: `SpellExamineUI::ExamineSpell` `0x004B6210`, +`gmFloatyExaminationUI::UpdateFromPlayerModule` `0x004D4250`, and LayoutDesc +`0x2100006B`. + +```text +for each SCID in GetAppropriateSpellFormula(spell): + component = SpellComponentTable[SCID] + texture = GetSpellComponentIcon(component.iconDID) + cell = AddItemFromTemplateList(0x1000032E) + cell.icon = texture + cell.missingOverlay = !ComponentTracker.ComponentIsOwned(component.WCID) + +initial floaty extent = LayoutDesc root extent = 310 x 400 pixels +restore user position/size from player UI state +``` + +The component icon lookup consumes the component's icon DID, not its WCID. +acdream's retained-layout persistence carries an authored-geometry revision: +when a shipped LayoutDesc extent correction invalidates an older saved size, +only width/height reset once; position, visibility, and later user resizing +remain persistent like retail. + ## Authoritative enchantment events Retail dispatchers: diff --git a/src/AcDream.App/Spells/SpellComponentRequirementService.cs b/src/AcDream.App/Spells/SpellComponentRequirementService.cs index e66e7f3b..6ce6ee06 100644 --- a/src/AcDream.App/Spells/SpellComponentRequirementService.cs +++ b/src/AcDream.App/Spells/SpellComponentRequirementService.cs @@ -74,6 +74,8 @@ public sealed class SpellComponentRequirementService /// Retail ClientMagicSystem::GetAppropriateSpellFormula /// (0x00567D50): an infused school or a directly carried school focus uses /// the scarab-only formula; otherwise the account-customized formula wins. + /// ACE characters with component enforcement disabled use the same modern + /// scarab/taper presentation instead of exposing an unusable legacy recipe. /// public IReadOnlyList GetAppropriateFormula(uint spellId) { @@ -105,6 +107,9 @@ public sealed class SpellComponentRequirementService ClientObject? player, uint playerGuid) { + bool componentsRequired = + player?.Properties.GetBool(SpellComponentsRequiredProperty, true) + ?? true; bool infused = SchoolInfusionProperty(formula.School) is uint propertyId && player?.Properties.GetInt(propertyId) > 0; bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue( @@ -112,7 +117,7 @@ public sealed class SpellComponentRequirementService && _objects.Objects.Any(item => item.ContainerId == playerGuid && item.WeenieClassId == packWcid); - return infused || carriesMagicPack + return !componentsRequired || infused || carriesMagicPack ? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds) : RetailSpellFormula.CustomizeForAccount( formula.ComponentIds, formula.FormulaVersion, _accountName()); diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 976cfb74..064643b7 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -783,7 +783,7 @@ public sealed class AppraisalUiController : IRetainedPanelController { SpellExamineComponent component = components[i]; UiElement cell = _spellComponentTemplates.Create( - _resolveComponentIcon(component.Descriptor.WeenieClassId), + _resolveComponentIcon(component.Descriptor.IconId), component.Owned); cell.LayoutPolicy = null; cell.Anchors = AnchorEdges.Left | AnchorEdges.Top; diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index 6c505e9f..d9b22a22 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -64,6 +64,13 @@ public static class RetailWindowFrame public bool DrawChromeCenter { get; init; } = true; public bool Visible { get; init; } = true; + /// + /// Increment when the authored default extent changes incompatibly. + /// Persistence preserves position and window state while resetting stale + /// saved dimensions to this mount's current DAT-authored extent. + /// + public int AuthoredGeometryRevision { get; init; } + /// /// Top-level desktop anchors. Most movable windows are unanchored; fixed /// retail HUD roots can preserve their authored screen-edge margins. @@ -173,7 +180,8 @@ public static class RetailWindowFrame outerFrame, content, options.Controller, - options.StateController); + options.StateController, + options.AuthoredGeometryRevision); } private static float ResolveConstraint( diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 7331e345..c1fb9aa5 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1045,6 +1045,7 @@ public sealed class RetailUiRuntime : IDisposable Top = root.Top, ContentWidth = root.Width, ContentHeight = root.Height, + AuthoredGeometryRevision = 1, Visible = false, ResizeX = true, ResizeY = true, diff --git a/src/AcDream.App/UI/RetailWindowHandle.cs b/src/AcDream.App/UI/RetailWindowHandle.cs index 15ad14db..fd7d12e6 100644 --- a/src/AcDream.App/UI/RetailWindowHandle.cs +++ b/src/AcDream.App/UI/RetailWindowHandle.cs @@ -20,7 +20,8 @@ public sealed class RetailWindowHandle UiElement outerFrame, UiElement contentRoot, IRetainedPanelController? controller, - IRetainedWindowStateController? stateController) + IRetainedWindowStateController? stateController, + int authoredGeometryRevision) { _owner = owner; Name = name; @@ -28,6 +29,9 @@ public sealed class RetailWindowHandle ContentRoot = contentRoot; Controller = controller; StateController = stateController; + AuthoredGeometryRevision = Math.Max(0, authoredGeometryRevision); + AuthoredWidth = outerFrame.Width; + AuthoredHeight = outerFrame.Height; _notifiedVisible = outerFrame.Visible; } @@ -36,6 +40,9 @@ public sealed class RetailWindowHandle public UiElement ContentRoot { get; } public IRetainedPanelController? Controller { get; private set; } public IRetainedWindowStateController? StateController { get; } + public int AuthoredGeometryRevision { get; } + public float AuthoredWidth { get; } + public float AuthoredHeight { get; } public bool IsRegistered => !_disposed; public bool IsVisible => OuterFrame.Visible; public bool IsLocked => _owner.IsLocked; diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index 90083c9e..9022c780 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -58,6 +58,9 @@ public sealed class RetailWindowLayoutPersistence : IDisposable UiWindowLayout? saved = _store.LoadWindowLayout( character, resolution, handle.Name, fallback); if (saved is not { } layout) continue; + layout = MigrateAuthoredGeometry( + layout, + AuthoredGeometry(handle, fallback)); Apply( handle, @@ -110,6 +113,10 @@ public sealed class RetailWindowLayoutPersistence : IDisposable UiWindowLayout? saved = _store.LoadNamedWindowLayout( profileName, handle.Name, Capture(handle)); if (saved is not { } layout) continue; + UiWindowLayout fallback = Capture(handle); + layout = MigrateAuthoredGeometry( + layout, + AuthoredGeometry(handle, fallback)); Apply( handle, layout, @@ -166,9 +173,34 @@ public sealed class RetailWindowLayoutPersistence : IDisposable state.PersistedHeight ?? handle.Height, handle.IsVisible, state.Collapsed, - state.Maximized); + state.Maximized, + handle.AuthoredGeometryRevision); } + private static UiWindowLayout MigrateAuthoredGeometry( + UiWindowLayout saved, + UiWindowLayout authored) + { + if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision) + return saved; + + return saved with + { + Width = authored.Width, + Height = authored.Height, + AuthoredGeometryRevision = authored.AuthoredGeometryRevision, + }; + } + + private static UiWindowLayout AuthoredGeometry( + RetailWindowHandle handle, + UiWindowLayout current) => current with + { + Width = handle.AuthoredWidth, + Height = handle.AuthoredHeight, + AuthoredGeometryRevision = handle.AuthoredGeometryRevision, + }; + private static void Apply( RetailWindowHandle handle, UiWindowLayout layout, diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index f5081027..8dd9d228 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -38,11 +38,13 @@ public sealed class RetailWindowManager : IDisposable UiElement outerFrame, UiElement? contentRoot = null, IRetainedPanelController? controller = null, - IRetainedWindowStateController? stateController = null) + IRetainedWindowStateController? stateController = null, + int authoredGeometryRevision = 0) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(outerFrame); + authoredGeometryRevision = Math.Max(0, authoredGeometryRevision); if (!ReferenceEquals(outerFrame.Parent, _root)) throw new InvalidOperationException( @@ -56,7 +58,8 @@ public sealed class RetailWindowManager : IDisposable if (ReferenceEquals(sameName.OuterFrame, outerFrame) && ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame) && ReferenceEquals(sameName.Controller, controller) - && ReferenceEquals(sameName.StateController, resolvedStateController)) + && ReferenceEquals(sameName.StateController, resolvedStateController) + && sameName.AuthoredGeometryRevision == authoredGeometryRevision) return sameName; Unregister(name); @@ -71,7 +74,8 @@ public sealed class RetailWindowManager : IDisposable outerFrame, contentRoot ?? outerFrame, controller, - resolvedStateController); + resolvedStateController, + authoredGeometryRevision); _byName.Add(name, handle); _byFrame.Add(outerFrame, handle); handle.NotifyInitialState(); diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 935e9c89..6fb21eb6 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -191,8 +191,15 @@ public sealed class UiHost : System.IDisposable UiElement window, UiElement? contentRoot = null, IRetainedPanelController? controller = null, - IRetainedWindowStateController? stateController = null) - => Root.RegisterWindow(name, window, contentRoot, controller, stateController); + IRetainedWindowStateController? stateController = null, + int authoredGeometryRevision = 0) + => Root.RegisterWindow( + name, + window, + contentRoot, + controller, + stateController, + authoredGeometryRevision); public bool UnregisterWindow(string name) => Root.UnregisterWindow(name); diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index b5afda8a..24c88b65 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -739,8 +739,15 @@ public sealed class UiRoot : UiElement UiElement window, UiElement? contentRoot = null, IRetainedPanelController? controller = null, - IRetainedWindowStateController? stateController = null) - => WindowManager.Register(name, window, contentRoot, controller, stateController); + IRetainedWindowStateController? stateController = null, + int authoredGeometryRevision = 0) + => WindowManager.Register( + name, + window, + contentRoot, + controller, + stateController, + authoredGeometryRevision); public bool UnregisterWindow(string name) => WindowManager.Unregister(name); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 6393abde..aaee6b81 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -367,7 +367,8 @@ public sealed class SettingsStore ReadNodeFloat(node, "height", fallback.Height), ReadNodeBool(node, "visible", fallback.Visible), ReadNodeBool(node, "collapsed", fallback.Collapsed), - ReadNodeBool(node, "maximized", fallback.Maximized)); + ReadNodeBool(node, "maximized", fallback.Maximized), + ReadNodeInt(node, "authoredGeometryRevision", 0)); } // Version-1 migration: radar stored only X/Y and had no resolution key. @@ -417,6 +418,7 @@ public sealed class SettingsStore ["visible"] = layout.Visible, ["collapsed"] = layout.Collapsed, ["maximized"] = layout.Maximized, + ["authoredGeometryRevision"] = layout.AuthoredGeometryRevision, }; root["version"] = CurrentSchemaVersion; WriteMutableRoot(root); @@ -449,7 +451,8 @@ public sealed class SettingsStore ReadNodeFloat(node, "height", fallback.Height), ReadNodeBool(node, "visible", fallback.Visible), ReadNodeBool(node, "collapsed", fallback.Collapsed), - ReadNodeBool(node, "maximized", fallback.Maximized)); + ReadNodeBool(node, "maximized", fallback.Maximized), + ReadNodeInt(node, "authoredGeometryRevision", 0)); } catch (Exception ex) { @@ -484,6 +487,7 @@ public sealed class SettingsStore ["visible"] = layout.Visible, ["collapsed"] = layout.Collapsed, ["maximized"] = layout.Maximized, + ["authoredGeometryRevision"] = layout.AuthoredGeometryRevision, }; private JsonObject LoadMutableRoot() @@ -518,6 +522,12 @@ public sealed class SettingsStore catch { return fallback; } } + private static int ReadNodeInt(JsonObject node, string key, int fallback) + { + try { return node[key]?.GetValue() ?? fallback; } + catch { return fallback; } + } + private static bool TryParseResolution(string key, out int width, out int height) { width = 0; diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs b/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs index 2e33566c..41bae4bb 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs @@ -3,7 +3,9 @@ namespace AcDream.UI.Abstractions.Panels.Settings; /// /// Complete persisted state for one retained window at one screen resolution. /// Position and size are outer-frame pixels; panel flags retain the small amount -/// of state that geometry alone cannot reproduce reliably. +/// of state that geometry alone cannot reproduce reliably. The authored revision +/// lets a window invalidate obsolete saved dimensions without discarding its +/// position, visibility, or later user resizing. /// public readonly record struct UiWindowLayout( float X, @@ -12,4 +14,5 @@ public readonly record struct UiWindowLayout( float Height, bool Visible, bool Collapsed, - bool Maximized); + bool Maximized, + int AuthoredGeometryRevision = 0); diff --git a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs b/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs index cf449c0b..5a6e0ac1 100644 --- a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs +++ b/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs @@ -37,15 +37,18 @@ public sealed class SpellComponentRequirementServiceTests } [Fact] - public void ServerDisabledComponents_BypassesInventoryPreflight() + public void ServerDisabledComponents_BypassesPreflightAndUsesModernFormula() { 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()) - .HasRequiredComponents(50u)); + SpellComponentRequirementService service = + Service(objects, [1u, 63u, 10u], new Dictionary()); + + Assert.True(service.HasRequiredComponents(50u)); + Assert.Equal(new uint[] { 1u, 0xBCu }, service.GetAppropriateFormula(50u)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index bb05f10d..491c3d73 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -717,6 +717,17 @@ public sealed class AppraisalUiControllerTests new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u), Owned: false), }; + var componentTemplate = new SpellExamineComponentTemplateFactory( + new ElementInfo + { + Id = SpellExamineComponentTemplateFactory.TemplateId, + Type = 3, + Width = 20, + Height = 20, + }, + NoTexture, + defaultFont: null); + var resolvedComponentIcons = new List(); int shown = 0; using AppraisalUiController controller = Bind( layout, @@ -730,8 +741,14 @@ public sealed class AppraisalUiControllerTests selection: selection, spellbook: spellbook, resolveSpellIcon: id => id + 1_000u, + resolveComponentIcon: did => + { + resolvedComponentIcons.Add(did); + return did + 2_000u; + }, spellComponents: _ => components, - magicSkill: _ => 200u)!; + magicSkill: _ => 200u, + spellComponentTemplates: componentTemplate)!; Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId)); Assert.Equal(1, interaction.BusyCount); @@ -784,6 +801,17 @@ public sealed class AppraisalUiControllerTests UiTextureElement icon = Assert.Single( iconHost.Children.OfType()); Assert.Equal(metadata.SpellId + 1_000u, icon.Texture); + Assert.Equal( + new uint[] { 0x06000010u, 0x06000011u }, + resolvedComponentIcons); + UiElement formula = layout.FindElement( + AppraisalUiController.SpellFormulaListId)!; + Assert.Equal( + new uint[] { 0x06000010u + 2_000u, 0x06000011u + 2_000u }, + formula.Children + .SelectMany(cell => cell.Children) + .OfType() + .Select(texture => texture.Texture)); } private static AppraisalUiController? Bind( diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index 2554decc..0370d04e 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -94,6 +94,87 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable Assert.True(state.Restored.Maximized); } + [Fact] + public void Restore_OlderAuthoredGeometryRevision_ResetsOnlySavedExtent() + { + var store = new SettingsStore(PathName); + store.SaveWindowLayout( + "Alice", + "800x600", + WindowNames.Examination, + new UiWindowLayout( + 44f, + 55f, + 310f, + 545f, + true, + false, + false, + AuthoredGeometryRevision: 0)); + + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = Mount( + root, + WindowNames.Examination, + authoredGeometryRevision: 1, + width: 310f, + height: 400f); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, + store, + () => "Alice", + () => (800, 600)); + + persistence.RestoreAll(); + + Assert.Equal((44f, 55f), (handle.Left, handle.Top)); + Assert.Equal((310f, 400f), (handle.Width, handle.Height)); + UiWindowLayout migrated = Assert.IsType( + store.LoadWindowLayout( + "Alice", + "800x600", + WindowNames.Examination, + default)); + Assert.Equal(1, migrated.AuthoredGeometryRevision); + } + + [Fact] + public void RestoreNamed_OlderRevisionUsesMountedAuthoredExtent_NotCurrentSize() + { + var store = new SettingsStore(PathName); + store.SaveNamedWindowLayout( + "legacy", + WindowNames.Examination, + new UiWindowLayout( + 44f, + 55f, + 310f, + 545f, + true, + false, + false, + AuthoredGeometryRevision: 0)); + + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = Mount( + root, + WindowNames.Examination, + authoredGeometryRevision: 1, + width: 310f, + height: 400f); + handle.ResizeTo(500f, 300f); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, + store, + () => "Alice", + () => (800, 600)); + + persistence.RestoreNamed("legacy"); + + Assert.Equal((44f, 55f), (handle.Left, handle.Top)); + Assert.Equal((310f, 400f), (handle.Width, handle.Height)); + } + [Fact] public void DefaultPreLoginKey_NeverOverwritesCharacterLayouts() { @@ -169,14 +250,17 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable private static RetailWindowHandle Mount( UiRoot root, string name, - IRetainedWindowStateController? state = null) + IRetainedWindowStateController? state = null, + int authoredGeometryRevision = 0, + float width = 200f, + float height = 100f) { var frame = new UiPanel { Left = 10f, Top = 20f, - Width = 200f, - Height = 100f, + Width = width, + Height = height, MinWidth = 100f, MinHeight = 80f, MaxWidth = 600f, @@ -185,7 +269,11 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable Resizable = true, }; root.AddChild(frame); - return root.RegisterWindow(name, frame, stateController: state); + return root.RegisterWindow( + name, + frame, + stateController: state, + authoredGeometryRevision: authoredGeometryRevision); } public void Dispose()