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