fix(ui): keep spell components in authored foreground

Install examination formula icons as the template root UIRegion image, matching retail ClearImage/SetImage behavior while retaining the authored missing-component overlay. Add the real DAT template fixture and conformance coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 08:08:29 +02:00
parent d4ce64f56b
commit 2c3da8e153
14 changed files with 231 additions and 34 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,
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

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,
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,8 +5,8 @@ namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates the authored formula-icon template used by retail
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>. The controller replaces
/// the template root's placeholder image with the resolved component icon while
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>. The controller installs
/// the component icon as the template root's own <c>UIRegion</c> image while
/// retaining child <c>0x10000330</c> as the missing-component overlay.
/// </summary>
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;

View file

@ -165,8 +165,37 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// </summary>
public bool MediaVisible { get; set; } = true;
/// <summary>
/// Runtime image installed on this region in place of its authored state media.
/// A non-null value mirrors retail <c>UIRegion::ClearImage</c> followed by
/// <c>UIRegion::SetImage</c>; zero deliberately leaves the region image-less.
/// The image remains this element's own media, so authored descendants retain
/// their normal foreground relationship.
/// </summary>
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)

View file

@ -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<UiTextureElement>()
.Select(texture => texture.Texture));
.OfType<UiDatElement>()
.Select(cell => cell.RuntimeImageTexture!.Value));
}
private static AppraisalUiController? Bind(

View file

@ -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);

View file

@ -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);

View file

@ -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<UiDatElement>(
factory.Create(0xABCDu, owned));
UiDatElement missing = Assert.IsType<UiDatElement>(
Assert.Single(root.Children));
Assert.Equal(SpellExamineComponentTemplateFactory.TemplateId, root.ElementId);
Assert.Equal(0xABCDu, root.RuntimeImageTexture);
Assert.Empty(root.Children.OfType<UiTextureElement>());
Assert.Equal(
SpellExamineComponentTemplateFactory.MissingOverlayId,
missing.ElementId);
Assert.Equal(missingVisible, missing.Visible);
}
}

View file

@ -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": []
}
]
}