fix(ui): restore modern spell formula presentation

Resolve spell-examination component cells through their DAT icon DIDs, project scarab and prismatic-taper formulas when ACE disables component enforcement, and version authored window geometry so stale examination sizes reset once without losing user layout behavior.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 07:08:47 +02:00
parent 3e31b0ac70
commit d4ce64f56b
22 changed files with 307 additions and 41 deletions

View file

@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal and retained catalog-input corrections pass Release builds; the appraisal formula-media/geometry correction passes
3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips. 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 **Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite 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 user-accepted. Slice 3's first connected gate exposed and corrected the
monster-response flag mismatch, non-retail shared-panel mounting, and missing monster-response flag mismatch, non-retail shared-panel mounting, and missing
inscription transaction. The creature and common-item report corrections are inscription transaction. The creature and common-item report corrections are
user-accepted; favorite-spell press-time selection and right-click local user-accepted. Favorite-spell press-time selection and right-click local
examination are the current visual gate. 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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal and retained catalog-input corrections pass Release builds; the appraisal formula-media/geometry correction passes
3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips. 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 **Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite 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 user-accepted. Slice 3's first connected gate exposed and corrected the
monster-response flag mismatch, non-retail shared-panel mounting, and missing monster-response flag mismatch, non-retail shared-panel mounting, and missing
inscription transaction. The creature and common-item report corrections are inscription transaction. The creature and common-item report corrections are
user-accepted; favorite-spell press-time selection and right-click local user-accepted. Favorite-spell press-time selection and right-click local
examination are the current visual gate. 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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -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 so the status-bar magnifier remains object-only. Switching the shared floaty
from a pending object appraisal to a spell releases exactly that appraisal from a pending object appraisal to a spell releases exactly that appraisal
transaction before showing the SpellPanel. 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`; **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.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. Left-pressing a favorite spell likewise changes its selection immediately.
Right-clicking it opens the spell examination subview; selecting it and using Right-clicking it opens the spell examination subview; selecting it and using
the status-bar magnifier does not examine the spell. 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`, The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
then its description after one retail paragraph break, without the bogus then its description after one retail paragraph break, without the bogus
255-item/255-container line. 255-item/255-container line.

View file

@ -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-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-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-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-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-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-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-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` |
--- ---

View file

@ -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 floaty's authored SpellPanel locally; and spell IDs never enter global object
selection or the status-bar magnifier path. The view projects retail's selection or the status-bar magnifier path. The view projects retail's
name/school/mana/duration/range/description and authored appropriate-formula name/school/mana/duration/range/description and authored appropriate-formula
component cells, including live missing-component state. Its connected visual component cells, including live missing-component state. The follow-up resolves
gate is pending. Slice 3's first connected gate exposed 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 three deeper defects, now corrected: the response enum mislabeled ACE's
creature bit `0x0100` and dropped monster packets while retaining the busy creature bit `0x0100` and dropped monster packets while retaining the busy
cursor; the imported 310 x 400 examination layout was incorrectly hosted as cursor; the imported 310 x 400 examination layout was incorrectly hosted as

View file

@ -112,8 +112,12 @@ Favorite spells now use the parallel retail catalog contract: MouseDown
selects immediately, right-click opens the examination floaty's authored selects immediately, right-click opens the examination floaty's authored
SpellPanel locally, and no spell ID enters global object selection or the SpellPanel locally, and no spell ID enters global object selection or the
status-bar magnifier command. Exact spell fields and the current appropriate status-bar magnifier command. Exact spell fields and the current appropriate
formula's authored component cells are automated; the connected visual gate formula's authored component cells are automated. The correction resolves
is pending. 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: 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 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 appraisal busy reference held, retail's examination UI had been mounted as a

View file

@ -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 ordered stat rows and animated private preview. The follow-up item report and
authored 310 x 400 layout correction are implemented; their connected visual authored 310 x 400 layout correction are implemented; their connected visual
gate is pending. Favorite-spell press-time selection and right-click local 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. **Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and **Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell. 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 SpellPanel locally on right-click. Spell IDs never enter `SelectionState` or
the status-bar magnifier path, and no Appraise/busy transaction is invented. the status-bar magnifier path, and no Appraise/busy transaction is invented.
The view projects exact spell fields and the current appropriate formula The view projects exact spell fields and the current appropriate formula
through authored component template `0x1000032E`; its connected visual gate is through authored component template `0x1000032E`. Its corrective pass uses
pending. The 61-test focused App gate, Release solution build, 3,553 App tests each component descriptor's icon DID rather than its inventory WCID, applies
/ 3 skips, and 7,984 complete-solution tests / 5 skips pass. 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 ## Slice 1 — spell-bar overflow arrows

View file

@ -87,6 +87,37 @@ favorite-component events use WCIDs, resolved through DualEnumIDMap
`0x27000002`. The magic-school focus map is retail enum category `0x28`, key `0x27000002`. The magic-school focus map is retail enum category `0x28`, key
`0x10000001`. `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 ## Authoritative enchantment events
Retail dispatchers: Retail dispatchers:

View file

@ -74,6 +74,8 @@ public sealed class SpellComponentRequirementService
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c> /// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
/// (0x00567D50): an infused school or a directly carried school focus uses /// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins. /// 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.
/// </summary> /// </summary>
public IReadOnlyList<uint> GetAppropriateFormula(uint spellId) public IReadOnlyList<uint> GetAppropriateFormula(uint spellId)
{ {
@ -105,6 +107,9 @@ public sealed class SpellComponentRequirementService
ClientObject? player, ClientObject? player,
uint playerGuid) uint playerGuid)
{ {
bool componentsRequired =
player?.Properties.GetBool(SpellComponentsRequiredProperty, true)
?? true;
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
&& player?.Properties.GetInt(propertyId) > 0; && player?.Properties.GetInt(propertyId) > 0;
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue( bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
@ -112,7 +117,7 @@ public sealed class SpellComponentRequirementService
&& _objects.Objects.Any(item => && _objects.Objects.Any(item =>
item.ContainerId == playerGuid item.ContainerId == playerGuid
&& item.WeenieClassId == packWcid); && item.WeenieClassId == packWcid);
return infused || carriesMagicPack return !componentsRequired || infused || carriesMagicPack
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds) ? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
: RetailSpellFormula.CustomizeForAccount( : RetailSpellFormula.CustomizeForAccount(
formula.ComponentIds, formula.FormulaVersion, _accountName()); formula.ComponentIds, formula.FormulaVersion, _accountName());

View file

@ -783,7 +783,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
{ {
SpellExamineComponent component = components[i]; SpellExamineComponent component = components[i];
UiElement cell = _spellComponentTemplates.Create( UiElement cell = _spellComponentTemplates.Create(
_resolveComponentIcon(component.Descriptor.WeenieClassId), _resolveComponentIcon(component.Descriptor.IconId),
component.Owned); component.Owned);
cell.LayoutPolicy = null; cell.LayoutPolicy = null;
cell.Anchors = AnchorEdges.Left | AnchorEdges.Top; cell.Anchors = AnchorEdges.Left | AnchorEdges.Top;

View file

@ -64,6 +64,13 @@ public static class RetailWindowFrame
public bool DrawChromeCenter { get; init; } = true; public bool DrawChromeCenter { get; init; } = true;
public bool Visible { get; init; } = true; public bool Visible { get; init; } = true;
/// <summary>
/// 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.
/// </summary>
public int AuthoredGeometryRevision { get; init; }
/// <summary> /// <summary>
/// Top-level desktop anchors. Most movable windows are unanchored; fixed /// Top-level desktop anchors. Most movable windows are unanchored; fixed
/// retail HUD roots can preserve their authored screen-edge margins. /// retail HUD roots can preserve their authored screen-edge margins.
@ -173,7 +180,8 @@ public static class RetailWindowFrame
outerFrame, outerFrame,
content, content,
options.Controller, options.Controller,
options.StateController); options.StateController,
options.AuthoredGeometryRevision);
} }
private static float ResolveConstraint( private static float ResolveConstraint(

View file

@ -1045,6 +1045,7 @@ public sealed class RetailUiRuntime : IDisposable
Top = root.Top, Top = root.Top,
ContentWidth = root.Width, ContentWidth = root.Width,
ContentHeight = root.Height, ContentHeight = root.Height,
AuthoredGeometryRevision = 1,
Visible = false, Visible = false,
ResizeX = true, ResizeX = true,
ResizeY = true, ResizeY = true,

View file

@ -20,7 +20,8 @@ public sealed class RetailWindowHandle
UiElement outerFrame, UiElement outerFrame,
UiElement contentRoot, UiElement contentRoot,
IRetainedPanelController? controller, IRetainedPanelController? controller,
IRetainedWindowStateController? stateController) IRetainedWindowStateController? stateController,
int authoredGeometryRevision)
{ {
_owner = owner; _owner = owner;
Name = name; Name = name;
@ -28,6 +29,9 @@ public sealed class RetailWindowHandle
ContentRoot = contentRoot; ContentRoot = contentRoot;
Controller = controller; Controller = controller;
StateController = stateController; StateController = stateController;
AuthoredGeometryRevision = Math.Max(0, authoredGeometryRevision);
AuthoredWidth = outerFrame.Width;
AuthoredHeight = outerFrame.Height;
_notifiedVisible = outerFrame.Visible; _notifiedVisible = outerFrame.Visible;
} }
@ -36,6 +40,9 @@ public sealed class RetailWindowHandle
public UiElement ContentRoot { get; } public UiElement ContentRoot { get; }
public IRetainedPanelController? Controller { get; private set; } public IRetainedPanelController? Controller { get; private set; }
public IRetainedWindowStateController? StateController { get; } public IRetainedWindowStateController? StateController { get; }
public int AuthoredGeometryRevision { get; }
public float AuthoredWidth { get; }
public float AuthoredHeight { get; }
public bool IsRegistered => !_disposed; public bool IsRegistered => !_disposed;
public bool IsVisible => OuterFrame.Visible; public bool IsVisible => OuterFrame.Visible;
public bool IsLocked => _owner.IsLocked; public bool IsLocked => _owner.IsLocked;

View file

@ -58,6 +58,9 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
UiWindowLayout? saved = _store.LoadWindowLayout( UiWindowLayout? saved = _store.LoadWindowLayout(
character, resolution, handle.Name, fallback); character, resolution, handle.Name, fallback);
if (saved is not { } layout) continue; if (saved is not { } layout) continue;
layout = MigrateAuthoredGeometry(
layout,
AuthoredGeometry(handle, fallback));
Apply( Apply(
handle, handle,
@ -110,6 +113,10 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
UiWindowLayout? saved = _store.LoadNamedWindowLayout( UiWindowLayout? saved = _store.LoadNamedWindowLayout(
profileName, handle.Name, Capture(handle)); profileName, handle.Name, Capture(handle));
if (saved is not { } layout) continue; if (saved is not { } layout) continue;
UiWindowLayout fallback = Capture(handle);
layout = MigrateAuthoredGeometry(
layout,
AuthoredGeometry(handle, fallback));
Apply( Apply(
handle, handle,
layout, layout,
@ -166,9 +173,34 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
state.PersistedHeight ?? handle.Height, state.PersistedHeight ?? handle.Height,
handle.IsVisible, handle.IsVisible,
state.Collapsed, 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( private static void Apply(
RetailWindowHandle handle, RetailWindowHandle handle,
UiWindowLayout layout, UiWindowLayout layout,

View file

@ -38,11 +38,13 @@ public sealed class RetailWindowManager : IDisposable
UiElement outerFrame, UiElement outerFrame,
UiElement? contentRoot = null, UiElement? contentRoot = null,
IRetainedPanelController? controller = null, IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null) IRetainedWindowStateController? stateController = null,
int authoredGeometryRevision = 0)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(outerFrame); ArgumentNullException.ThrowIfNull(outerFrame);
authoredGeometryRevision = Math.Max(0, authoredGeometryRevision);
if (!ReferenceEquals(outerFrame.Parent, _root)) if (!ReferenceEquals(outerFrame.Parent, _root))
throw new InvalidOperationException( throw new InvalidOperationException(
@ -56,7 +58,8 @@ public sealed class RetailWindowManager : IDisposable
if (ReferenceEquals(sameName.OuterFrame, outerFrame) if (ReferenceEquals(sameName.OuterFrame, outerFrame)
&& ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame) && ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame)
&& ReferenceEquals(sameName.Controller, controller) && ReferenceEquals(sameName.Controller, controller)
&& ReferenceEquals(sameName.StateController, resolvedStateController)) && ReferenceEquals(sameName.StateController, resolvedStateController)
&& sameName.AuthoredGeometryRevision == authoredGeometryRevision)
return sameName; return sameName;
Unregister(name); Unregister(name);
@ -71,7 +74,8 @@ public sealed class RetailWindowManager : IDisposable
outerFrame, outerFrame,
contentRoot ?? outerFrame, contentRoot ?? outerFrame,
controller, controller,
resolvedStateController); resolvedStateController,
authoredGeometryRevision);
_byName.Add(name, handle); _byName.Add(name, handle);
_byFrame.Add(outerFrame, handle); _byFrame.Add(outerFrame, handle);
handle.NotifyInitialState(); handle.NotifyInitialState();

View file

@ -191,8 +191,15 @@ public sealed class UiHost : System.IDisposable
UiElement window, UiElement window,
UiElement? contentRoot = null, UiElement? contentRoot = null,
IRetainedPanelController? controller = null, IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null) IRetainedWindowStateController? stateController = null,
=> Root.RegisterWindow(name, window, contentRoot, controller, stateController); int authoredGeometryRevision = 0)
=> Root.RegisterWindow(
name,
window,
contentRoot,
controller,
stateController,
authoredGeometryRevision);
public bool UnregisterWindow(string name) => Root.UnregisterWindow(name); public bool UnregisterWindow(string name) => Root.UnregisterWindow(name);

View file

@ -739,8 +739,15 @@ public sealed class UiRoot : UiElement
UiElement window, UiElement window,
UiElement? contentRoot = null, UiElement? contentRoot = null,
IRetainedPanelController? controller = null, IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null) IRetainedWindowStateController? stateController = null,
=> WindowManager.Register(name, window, contentRoot, controller, stateController); int authoredGeometryRevision = 0)
=> WindowManager.Register(
name,
window,
contentRoot,
controller,
stateController,
authoredGeometryRevision);
public bool UnregisterWindow(string name) => WindowManager.Unregister(name); public bool UnregisterWindow(string name) => WindowManager.Unregister(name);

View file

@ -367,7 +367,8 @@ public sealed class SettingsStore
ReadNodeFloat(node, "height", fallback.Height), ReadNodeFloat(node, "height", fallback.Height),
ReadNodeBool(node, "visible", fallback.Visible), ReadNodeBool(node, "visible", fallback.Visible),
ReadNodeBool(node, "collapsed", fallback.Collapsed), 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. // Version-1 migration: radar stored only X/Y and had no resolution key.
@ -417,6 +418,7 @@ public sealed class SettingsStore
["visible"] = layout.Visible, ["visible"] = layout.Visible,
["collapsed"] = layout.Collapsed, ["collapsed"] = layout.Collapsed,
["maximized"] = layout.Maximized, ["maximized"] = layout.Maximized,
["authoredGeometryRevision"] = layout.AuthoredGeometryRevision,
}; };
root["version"] = CurrentSchemaVersion; root["version"] = CurrentSchemaVersion;
WriteMutableRoot(root); WriteMutableRoot(root);
@ -449,7 +451,8 @@ public sealed class SettingsStore
ReadNodeFloat(node, "height", fallback.Height), ReadNodeFloat(node, "height", fallback.Height),
ReadNodeBool(node, "visible", fallback.Visible), ReadNodeBool(node, "visible", fallback.Visible),
ReadNodeBool(node, "collapsed", fallback.Collapsed), ReadNodeBool(node, "collapsed", fallback.Collapsed),
ReadNodeBool(node, "maximized", fallback.Maximized)); ReadNodeBool(node, "maximized", fallback.Maximized),
ReadNodeInt(node, "authoredGeometryRevision", 0));
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -484,6 +487,7 @@ public sealed class SettingsStore
["visible"] = layout.Visible, ["visible"] = layout.Visible,
["collapsed"] = layout.Collapsed, ["collapsed"] = layout.Collapsed,
["maximized"] = layout.Maximized, ["maximized"] = layout.Maximized,
["authoredGeometryRevision"] = layout.AuthoredGeometryRevision,
}; };
private JsonObject LoadMutableRoot() private JsonObject LoadMutableRoot()
@ -518,6 +522,12 @@ public sealed class SettingsStore
catch { return fallback; } catch { return fallback; }
} }
private static int ReadNodeInt(JsonObject node, string key, int fallback)
{
try { return node[key]?.GetValue<int>() ?? fallback; }
catch { return fallback; }
}
private static bool TryParseResolution(string key, out int width, out int height) private static bool TryParseResolution(string key, out int width, out int height)
{ {
width = 0; width = 0;

View file

@ -3,7 +3,9 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary> /// <summary>
/// Complete persisted state for one retained window at one screen resolution. /// Complete persisted state for one retained window at one screen resolution.
/// Position and size are outer-frame pixels; panel flags retain the small amount /// 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.
/// </summary> /// </summary>
public readonly record struct UiWindowLayout( public readonly record struct UiWindowLayout(
float X, float X,
@ -12,4 +14,5 @@ public readonly record struct UiWindowLayout(
float Height, float Height,
bool Visible, bool Visible,
bool Collapsed, bool Collapsed,
bool Maximized); bool Maximized,
int AuthoredGeometryRevision = 0);

View file

@ -37,15 +37,18 @@ public sealed class SpellComponentRequirementServiceTests
} }
[Fact] [Fact]
public void ServerDisabledComponents_BypassesInventoryPreflight() public void ServerDisabledComponents_BypassesPreflightAndUsesModernFormula()
{ {
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" }; var player = new ClientObject { ObjectId = 1u, Name = "Player" };
player.Properties.Bools[SpellComponentRequirementService.SpellComponentsRequiredProperty] = false; player.Properties.Bools[SpellComponentRequirementService.SpellComponentsRequiredProperty] = false;
objects.AddOrUpdate(player); objects.AddOrUpdate(player);
Assert.True(Service(objects, [10u], new Dictionary<uint, uint>()) SpellComponentRequirementService service =
.HasRequiredComponents(50u)); Service(objects, [1u, 63u, 10u], new Dictionary<uint, uint>());
Assert.True(service.HasRequiredComponents(50u));
Assert.Equal(new uint[] { 1u, 0xBCu }, service.GetAppropriateFormula(50u));
} }
[Fact] [Fact]

View file

@ -717,6 +717,17 @@ public sealed class AppraisalUiControllerTests
new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u), new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u),
Owned: false), Owned: false),
}; };
var componentTemplate = new SpellExamineComponentTemplateFactory(
new ElementInfo
{
Id = SpellExamineComponentTemplateFactory.TemplateId,
Type = 3,
Width = 20,
Height = 20,
},
NoTexture,
defaultFont: null);
var resolvedComponentIcons = new List<uint>();
int shown = 0; int shown = 0;
using AppraisalUiController controller = Bind( using AppraisalUiController controller = Bind(
layout, layout,
@ -730,8 +741,14 @@ public sealed class AppraisalUiControllerTests
selection: selection, selection: selection,
spellbook: spellbook, spellbook: spellbook,
resolveSpellIcon: id => id + 1_000u, resolveSpellIcon: id => id + 1_000u,
resolveComponentIcon: did =>
{
resolvedComponentIcons.Add(did);
return did + 2_000u;
},
spellComponents: _ => components, spellComponents: _ => components,
magicSkill: _ => 200u)!; magicSkill: _ => 200u,
spellComponentTemplates: componentTemplate)!;
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId)); Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
Assert.Equal(1, interaction.BusyCount); Assert.Equal(1, interaction.BusyCount);
@ -784,6 +801,17 @@ public sealed class AppraisalUiControllerTests
UiTextureElement icon = Assert.Single( UiTextureElement icon = Assert.Single(
iconHost.Children.OfType<UiTextureElement>()); iconHost.Children.OfType<UiTextureElement>());
Assert.Equal(metadata.SpellId + 1_000u, icon.Texture); 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<UiTextureElement>()
.Select(texture => texture.Texture));
} }
private static AppraisalUiController? Bind( private static AppraisalUiController? Bind(

View file

@ -94,6 +94,87 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
Assert.True(state.Restored.Maximized); 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<UiWindowLayout>(
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] [Fact]
public void DefaultPreLoginKey_NeverOverwritesCharacterLayouts() public void DefaultPreLoginKey_NeverOverwritesCharacterLayouts()
{ {
@ -169,14 +250,17 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
private static RetailWindowHandle Mount( private static RetailWindowHandle Mount(
UiRoot root, UiRoot root,
string name, string name,
IRetainedWindowStateController? state = null) IRetainedWindowStateController? state = null,
int authoredGeometryRevision = 0,
float width = 200f,
float height = 100f)
{ {
var frame = new UiPanel var frame = new UiPanel
{ {
Left = 10f, Left = 10f,
Top = 20f, Top = 20f,
Width = 200f, Width = width,
Height = 100f, Height = height,
MinWidth = 100f, MinWidth = 100f,
MinHeight = 80f, MinHeight = 80f,
MaxWidth = 600f, MaxWidth = 600f,
@ -185,7 +269,11 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
Resizable = true, Resizable = true,
}; };
root.AddChild(frame); root.AddChild(frame);
return root.RegisterWindow(name, frame, stateController: state); return root.RegisterWindow(
name,
frame,
stateController: state,
authoredGeometryRevision: authoredGeometryRevision);
} }
public void Dispose() public void Dispose()