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()