diff --git a/AGENTS.md b/AGENTS.md
index 9f582297..621249df 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 formula-media/geometry correction passes
-3,555 App tests / 3 skips and 7,986 complete-solution tests / 5 skips.
+Release builds; the appraisal formula-media/foreground correction passes
+3,557 App tests / 3 skips and 7,988 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
diff --git a/CLAUDE.md b/CLAUDE.md
index f9a618b6..c706d4a4 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 formula-media/geometry correction passes
-3,555 App tests / 3 skips and 7,986 complete-solution tests / 5 skips.
+Release builds; the appraisal formula-media/foreground correction passes
+3,557 App tests / 3 skips and 7,988 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
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 6c2b24ec..4a71252e 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -168,11 +168,14 @@ 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.
+their actual scarab/taper images. Each image is installed as the authored
+template root UIRegion's own image, matching retail's `ClearImage`/`SetImage`
+path instead of sinking a synthetic child behind the window content. 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`;
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 12239f26..a0e43cfa 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -47,8 +47,10 @@ name/school/mana/duration/range/description and authored appropriate-formula
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.
+disabled (IA-21), installs each icon through the shared retained
+`UIRegion::SetImage` seam so it keeps the authored foreground stack, 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
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 3715bca4..f3f6be28 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -114,9 +114,10 @@ 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 correction resolves
component icons by DAT DID, projects scarabs/prismatic tapers when ACE
-disables component enforcement (IA-21), and resets obsolete saved dimensions
+disables component enforcement (IA-21), installs each icon as its authored
+template root's own foreground image, 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
+tracked test warnings, 3,557 App tests / 3 skips, and 7,988 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
diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md
index 5807d4d9..dffe763e 100644
--- a/docs/plans/2026-07-23-world-interaction-completion.md
+++ b/docs/plans/2026-07-23-world-interaction-completion.md
@@ -11,7 +11,8 @@ 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. 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
+resolves formula icons by their DAT icon DIDs, installs those icons as each
+authored template root's own foreground image, and migrates stale examination
dimensions once to the authored 310 x 400 extent. Their connected visual gate
is pending.
**Milestone:** M4 prerequisite/preamble.
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 2376c5fa..d16731e0 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
@@ -105,14 +105,21 @@ for each SCID in GetAppropriateSpellFormula(spell):
component = SpellComponentTable[SCID]
texture = GetSpellComponentIcon(component.iconDID)
cell = AddItemFromTemplateList(0x1000032E)
- cell.icon = texture
+ cell.ClearImage()
+ cell.SetBlitMode(Blit_3Alpha)
+ cell.SetImage(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.
+`SetImage` replaces the template root UIRegion's own image; retail does not add
+a separate, negatively layered icon child. Template child `0x10000330` remains
+the missing-component overlay. `UIRegion::DrawHere @ 0x0069FA30` draws the
+root's image before its children for this template, preserving the authored
+icon/overlay foreground stack. 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
diff --git a/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs b/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs
index a92c8ad5..9d592114 100644
--- a/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs
+++ b/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs
@@ -5,8 +5,8 @@ namespace AcDream.App.UI.Layout;
///
/// Instantiates the authored formula-icon template used by retail
-/// SpellExamineUI::ExamineSpell @ 0x004B6210. The controller replaces
-/// the template root's placeholder image with the resolved component icon while
+/// SpellExamineUI::ExamineSpell @ 0x004B6210. The controller installs
+/// the component icon as the template root's own UIRegion image while
/// retaining child 0x10000330 as the missing-component overlay.
///
public sealed class SpellExamineComponentTemplateFactory
@@ -62,20 +62,13 @@ public sealed class SpellExamineComponentTemplateFactory
did => _fonts.TryGetValue(did, out UiDatFont? font)
? font
: _defaultFont);
- UiElement root = content.Root;
- if (root is UiDatElement datRoot)
- datRoot.MediaVisible = false;
-
- var icon = new UiTextureElement
+ if (content.Root is not UiDatElement root)
{
- Width = root.Width,
- Height = root.Height,
- Anchors = AnchorEdges.Left | AnchorEdges.Top
- | AnchorEdges.Right | AnchorEdges.Bottom,
- Texture = iconTexture,
- ZOrder = int.MinValue / 2,
- };
- root.AddChild(icon);
+ throw new InvalidOperationException(
+ $"Retail spell-component template 0x{TemplateId:X8} "
+ + "must resolve to a UIRegion-compatible element.");
+ }
+ root.RuntimeImageTexture = iconTexture;
if (content.FindElement(MissingOverlayId) is { } missing)
missing.Visible = !owned;
diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs
index 1905e469..b67a6c70 100644
--- a/src/AcDream.App/UI/Layout/UiDatElement.cs
+++ b/src/AcDream.App/UI/Layout/UiDatElement.cs
@@ -165,8 +165,37 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
///
public bool MediaVisible { get; set; } = true;
+ ///
+ /// Runtime image installed on this region in place of its authored state media.
+ /// A non-null value mirrors retail UIRegion::ClearImage followed by
+ /// UIRegion::SetImage; zero deliberately leaves the region image-less.
+ /// The image remains this element's own media, so authored descendants retain
+ /// their normal foreground relationship.
+ ///
+ public uint? RuntimeImageTexture { get; set; }
+
protected override void OnDraw(UiRenderContext ctx)
{
+ if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
+ {
+ if (runtimeTexture != 0u)
+ {
+ ctx.DrawSprite(
+ runtimeTexture,
+ 0f,
+ 0f,
+ Width,
+ Height,
+ 0f,
+ 0f,
+ 1f,
+ 1f,
+ Vector4.One);
+ }
+ DrawLabel(ctx);
+ return;
+ }
+
var (file, _) = ActiveMedia();
if (MediaVisible && file != 0)
{
@@ -180,6 +209,11 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
}
}
+ DrawLabel(ctx);
+ }
+
+ private void DrawLabel(UiRenderContext ctx)
+ {
// Centered text label over the sprite (retail draws button captions as text;
// their dat sprites are blank frames).
if (Label is { Length: > 0 } label && LabelFont is { } lf)
diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
index 491c3d73..fe8dfbde 100644
--- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
@@ -809,9 +809,8 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(
new uint[] { 0x06000010u + 2_000u, 0x06000011u + 2_000u },
formula.Children
- .SelectMany(cell => cell.Children)
- .OfType()
- .Select(texture => texture.Texture));
+ .OfType()
+ .Select(cell => cell.RuntimeImageTexture!.Value));
}
private static AppraisalUiController? Bind(
diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
index 2ac0a94c..c3897b13 100644
--- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
@@ -116,6 +116,9 @@ public static class FixtureLoader
public static ElementInfo LoadExaminationRowTemplateInfos()
=> LoadInfos("examine_row_2100006B_10000166.json");
+ public static ElementInfo LoadExaminationComponentTemplateInfos()
+ => LoadInfos("examine_component_2100006B_1000032E.json");
+
public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
index 2477194f..97853500 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
@@ -134,6 +134,8 @@ public sealed class RetailLayoutFixtureGenerator
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
(AppraisalUiController.LayoutId, CreatureAppraisalRowTemplateFactory.TemplateId,
"examine_row_2100006B_10000166.json"),
+ (AppraisalUiController.LayoutId, SpellExamineComponentTemplateFactory.TemplateId,
+ "examine_component_2100006B_1000032E.json"),
})
{
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellExamineComponentTemplateFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellExamineComponentTemplateFactoryTests.cs
new file mode 100644
index 00000000..bccff893
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellExamineComponentTemplateFactoryTests.cs
@@ -0,0 +1,35 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+public sealed class SpellExamineComponentTemplateFactoryTests
+{
+ [Theory]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ public void Create_InstallsIconOnRetailRootAndKeepsMissingOverlayAboveIt(
+ bool owned,
+ bool missingVisible)
+ {
+ ElementInfo template =
+ FixtureLoader.LoadExaminationComponentTemplateInfos();
+ var factory = new SpellExamineComponentTemplateFactory(
+ template,
+ did => (did + 1_000u, 32, 32),
+ defaultFont: null);
+
+ UiDatElement root = Assert.IsType(
+ factory.Create(0xABCDu, owned));
+ UiDatElement missing = Assert.IsType(
+ Assert.Single(root.Children));
+
+ Assert.Equal(SpellExamineComponentTemplateFactory.TemplateId, root.ElementId);
+ Assert.Equal(0xABCDu, root.RuntimeImageTexture);
+ Assert.Empty(root.Children.OfType());
+ Assert.Equal(
+ SpellExamineComponentTemplateFactory.MissingOverlayId,
+ missing.ElementId);
+ Assert.Equal(missingVisible, missing.Visible);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/examine_component_2100006B_1000032E.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/examine_component_2100006B_1000032E.json
new file mode 100644
index 00000000..d8952a84
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/examine_component_2100006B_1000032E.json
@@ -0,0 +1,117 @@
+{
+ "Id": 268436270,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 32,
+ "Height": 32,
+ "OriginalParentWidth": 0,
+ "OriginalParentHeight": 0,
+ "HasOriginalParentSize": false,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 1,
+ "ZLevel": 0,
+ "States": {
+ "4294967295": {
+ "Id": 4294967295,
+ "Name": "",
+ "PassToChildren": false,
+ "IncorporationFlags": 30,
+ "Image": null,
+ "Cursor": null,
+ "Properties": {
+ "Values": {}
+ }
+ }
+ },
+ "DefaultStateId": 0,
+ "FontDid": 0,
+ "HJustify": 1,
+ "VJustify": 1,
+ "FontColor": null,
+ "StateMedia": {},
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268436272,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 32,
+ "Height": 32,
+ "OriginalParentWidth": 32,
+ "OriginalParentHeight": 32,
+ "HasOriginalParentSize": true,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 1,
+ "ZLevel": 0,
+ "States": {
+ "4294967295": {
+ "Id": 4294967295,
+ "Name": "",
+ "PassToChildren": false,
+ "IncorporationFlags": 30,
+ "Image": {
+ "File": 100682946,
+ "DrawMode": 3
+ },
+ "Cursor": null,
+ "Properties": {
+ "Values": {
+ "59": {
+ "Kind": 1,
+ "MasterPropertyId": 59,
+ "UnsignedValue": 0,
+ "IntegerValue": 0,
+ "FloatValue": 0,
+ "BoolValue": false,
+ "StringInfoValue": {
+ "Token": 0,
+ "StringId": 0,
+ "TableId": 0,
+ "Override": 0,
+ "English": 0,
+ "Comment": 0
+ },
+ "ColorValue": {
+ "Blue": 0,
+ "Green": 0,
+ "Red": 0,
+ "Alpha": 0
+ },
+ "VectorValue": {
+ "X": 0,
+ "Y": 0,
+ "Z": 0
+ },
+ "ArrayValue": [],
+ "StructValue": {}
+ }
+ }
+ }
+ }
+ },
+ "DefaultStateId": 0,
+ "FontDid": 0,
+ "HJustify": 1,
+ "VJustify": 1,
+ "FontColor": null,
+ "StateMedia": {
+ "": {
+ "Item1": 100682946,
+ "Item2": 3
+ }
+ },
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": []
+ }
+ ]
+}
\ No newline at end of file