feat(ui): port retail component book rows

Instantiate gmSpellComponentUI category and component entries from LayoutDesc 0x21000033 instead of synthesizing catalog rows. Preserve localized category titles, authored textures and geometry, live icon/count/desired bindings, selected-object synchronization, and retail's inclusive 0..5000 desired-level contract.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 17:38:24 +02:00
parent 15bb61e05d
commit a1175e6aac
13 changed files with 2395 additions and 60 deletions

View file

@ -101,6 +101,12 @@ public static class FixtureLoader
public static ElementInfo LoadSpellbookInfos()
=> LoadInfos("spellbook_21000034.json");
public static ElementInfo LoadComponentCategoryTemplateInfos()
=> LoadInfos("component_category_21000033_10000466.json");
public static ElementInfo LoadComponentRowTemplateInfos()
=> LoadInfos("component_row_21000033_10000467.json");
public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);

View file

@ -47,6 +47,22 @@ public sealed class RetailLayoutFixtureGenerator
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var localStrings = new DatStringResolver(dats);
foreach ((string key, string expected) in new[]
{
("ID_SpellComp_Category_Scarabs", "SCARABS"),
("ID_SpellComp_Category_Herbs", "HERBS"),
("ID_SpellComp_Category_Gems", "POWDERED GEMS"),
("ID_SpellComp_Category_Alchemical", "ALCHEMICAL SUBSTANCES"),
("ID_SpellComp_Category_Talismans", "TALISMANS"),
("ID_SpellComp_Category_Tapers", "TAPERS"),
("ID_SpellComp_Category_Peas", "PEAS"),
})
{
Assert.Equal(expected, localStrings.Resolve(
0x23000001u, DatStringResolver.ComputeHash(key)));
}
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid));
@ -102,6 +118,26 @@ public sealed class RetailLayoutFixtureGenerator
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
}
foreach ((uint rootId, string fileName) in new[]
{
(0x10000466u,
"component_category_21000033_10000466.json"),
(0x10000467u,
"component_row_21000033_10000467.json"),
})
{
ElementInfo? componentTemplate = LayoutImporter.ImportInfos(
dats, 0x21000033u, rootId);
Assert.NotNull(componentTemplate);
var componentJson = JsonSerializer.Serialize(componentTemplate,
new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), componentJson);
}
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);

View file

@ -2,6 +2,7 @@ using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using System.Numerics;
@ -238,6 +239,130 @@ public sealed class SpellbookWindowControllerTests
Assert.Empty(VisibleSpellIds(list));
}
[Fact]
public void Components_UseAuthoredCategoryAndItemTemplates_WithRetailCountsAndSelection()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 10u,
WeenieClassId = 100u,
Name = "Copper Scarab",
Type = ItemType.SpellComponents,
IconId = 0x06000100u,
StackSize = 2,
ContainerId = 1u,
IsComponentPack = true,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 11u,
WeenieClassId = 101u,
Name = "Diamond Scarab",
Type = ItemType.SpellComponents,
IconId = 0x06000101u,
StackSize = 1,
ContainerId = 1u,
IsComponentPack = true,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 12u,
WeenieClassId = 200u,
Name = "Amaranth",
Type = ItemType.SpellComponents,
IconId = 0x06000200u,
StackSize = 3,
ContainerId = 1u,
IsComponentPack = true,
});
var components = new Dictionary<uint, SpellComponentDescriptor>
{
[100u] = new(100u, "Copper Scarab", 0u, 0x06000100u),
[101u] = new(101u, "Diamond Scarab", 0u, 0x06000101u),
[200u] = new(200u, "Amaranth", 1u, 0x06000200u),
};
Spellbook book = CreateSpellbook();
book.SetDesiredComponent(100u, 7u);
var selected = new List<uint>();
var desired = new List<(uint Component, uint Amount)>();
var selection = new SelectionState();
using SpellbookWindowController controller = Bind(
layout,
book,
objects: objects,
components: components,
selection: selection,
selectObject: objectId =>
{
selected.Add(objectId);
selection.Select(objectId, SelectionChangeSource.Inventory);
},
setDesiredComponent: (component, amount) => desired.Add((component, amount)),
resolveComponentIcon: icon => icon + 0x1000u)!;
controller.ShowPage(SpellbookWindowPage.Components);
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.ComponentListId)?.Children.Single());
Assert.Equal(5, list.GetNumUIItems());
Assert.Same(
list.Scroll,
Assert.IsType<UiScrollbar>(
layout.FindElement(SpellbookWindowController.ComponentScrollbarId)).Model);
UiTemplateListSlot scarabs = Assert.IsType<UiTemplateListSlot>(list.GetItem(0));
UiText scarabTitle = Assert.IsType<UiText>(scarabs.Content.Root);
Assert.Equal("SCARABS", Assert.Single(scarabTitle.LinesProvider()).Text);
Assert.Equal(0x06001392u, scarabTitle.BackgroundSprite);
UiTemplateListSlot copper = Assert.IsType<UiTemplateListSlot>(list.GetItem(1));
UiDatElement copperRoot = Assert.IsType<UiDatElement>(copper.Content.Root);
Assert.Equal((0x06005E21u, 1), copperRoot.ActiveMedia());
Assert.Equal("Copper Scarab", Assert.Single(
Assert.IsType<UiText>(copper.Content.FindElement(
ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
Assert.Equal("2", Assert.Single(
Assert.IsType<UiText>(copper.Content.FindElement(
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement(
ComponentBookTemplateFactory.DesiredCountId));
Assert.Equal((224f, 15f, 60f, 15f),
(desiredField.Left, desiredField.Top, desiredField.Width, desiredField.Height));
Assert.True(desiredField.RightAligned);
Assert.Equal("7", desiredField.Text);
UiElement iconHost = Assert.IsAssignableFrom<UiElement>(copper.Content.FindElement(
ComponentBookTemplateFactory.IconId));
Assert.Equal(
0x06001100u,
Assert.IsType<UiTextureElement>(Assert.Single(iconHost.Children)).Texture);
copper.OnEvent(new UiEvent(0u, copper, UiEventType.Click));
Assert.Equal([10u], selected);
Assert.True(copper.Selected);
Assert.Equal(UiButtonStateMachine.Highlight, copperRoot.ActiveRetailStateId);
Assert.Equal((0x06005E22u, 1), copperRoot.ActiveMedia());
desiredField.SetText("21");
desiredField.OnSubmit!(desiredField.Text);
Assert.Equal([(100u, 21u)], desired);
desiredField.SetText("5001");
desiredField.OnSubmit!(desiredField.Text);
Assert.Equal("21", desiredField.Text);
Assert.Equal([(100u, 21u)], desired);
UiTemplateListSlot herbs = Assert.IsType<UiTemplateListSlot>(list.GetItem(3));
Assert.Equal("HERBS", Assert.Single(
Assert.IsType<UiText>(herbs.Content.Root).LinesProvider()).Text);
UiTemplateListSlot amaranth = Assert.IsType<UiTemplateListSlot>(list.GetItem(4));
selection.Select(12u, SelectionChangeSource.Radar);
Assert.True(amaranth.Selected);
Assert.False(copper.Selected);
selection.Select(0xDEADBEEFu, SelectionChangeSource.World);
Assert.False(amaranth.Selected);
}
private static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
@ -245,23 +370,35 @@ public sealed class SpellbookWindowControllerTests
Action<uint>? sendFilter = null,
Action<uint>? removeSpell = null,
Action<string, Action<bool>>? showConfirmation = null,
Action? close = null)
Action? close = null,
ClientObjectTable? objects = null,
IReadOnlyDictionary<uint, SpellComponentDescriptor>? components = null,
SelectionState? selection = null,
Action<uint>? selectObject = null,
Action<uint, uint>? setDesiredComponent = null,
Func<uint, uint>? resolveComponentIcon = null)
=> SpellbookWindowController.Bind(
layout,
spellbook,
new ClientObjectTable(),
objects ?? new ClientObjectTable(),
() => 1u,
new Dictionary<uint, SpellComponentDescriptor>(),
components ?? new Dictionary<uint, SpellComponentDescriptor>(),
selection ?? new SelectionState(),
spellId => spellId + 1000u,
iconId => iconId,
resolveComponentIcon ?? (iconId => iconId),
spellId => spellId == 103u ? 8 : 1,
_ => { },
selectObject ?? (_ => { }),
addFavorite ?? (_ => { }),
sendFilter ?? (_ => { }),
removeSpell ?? (_ => { }),
showConfirmation ?? ((_, _) => { }),
(_, _) => { },
setDesiredComponent ?? ((_, _) => { }),
close ?? (() => { }),
new ComponentBookTemplateFactory(
FixtureLoader.LoadComponentCategoryTemplateInfos(),
FixtureLoader.LoadComponentRowTemplateInfos(),
_ => (0u, 0, 0),
defaultFont: null),
RowStyle,
rowFont: null);

View file

@ -0,0 +1,358 @@
{
"Id": 268436582,
"Type": 12,
"X": 0,
"Y": 0,
"Width": 284,
"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": {
"File": 100668306,
"DrawMode": 1
},
"Cursor": null,
"Properties": {
"Values": {
"35": {
"Kind": 4,
"MasterPropertyId": 35,
"UnsignedValue": 0,
"IntegerValue": 10,
"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": {}
},
"26": {
"Kind": 7,
"MasterPropertyId": 26,
"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": [
{
"Kind": 2,
"MasterPropertyId": 24,
"UnsignedValue": 1073741828,
"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": {}
}
],
"StructValue": {}
},
"37": {
"Kind": 4,
"MasterPropertyId": 37,
"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": {}
},
"27": {
"Kind": 7,
"MasterPropertyId": 27,
"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": [
{
"Kind": 6,
"MasterPropertyId": 25,
"UnsignedValue": 0,
"IntegerValue": 0,
"FloatValue": 0,
"BoolValue": false,
"StringInfoValue": {
"Token": 0,
"StringId": 0,
"TableId": 0,
"Override": 0,
"English": 0,
"Comment": 0
},
"ColorValue": {
"Blue": 255,
"Green": 255,
"Red": 255,
"Alpha": 255
},
"VectorValue": {
"X": 0,
"Y": 0,
"Z": 0
},
"ArrayValue": [],
"StructValue": {}
}
],
"StructValue": {}
},
"20": {
"Kind": 0,
"MasterPropertyId": 20,
"UnsignedValue": 1,
"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": {}
},
"21": {
"Kind": 0,
"MasterPropertyId": 21,
"UnsignedValue": 1,
"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": {}
}
}
}
},
"13": {
"Id": 13,
"Name": "Ghosted",
"PassToChildren": false,
"IncorporationFlags": 0,
"Image": null,
"Cursor": null,
"Properties": {
"Values": {
"27": {
"Kind": 7,
"MasterPropertyId": 27,
"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": [
{
"Kind": 6,
"MasterPropertyId": 25,
"UnsignedValue": 0,
"IntegerValue": 0,
"FloatValue": 0,
"BoolValue": false,
"StringInfoValue": {
"Token": 0,
"StringId": 0,
"TableId": 0,
"Override": 0,
"English": 0,
"Comment": 0
},
"ColorValue": {
"Blue": 76,
"Green": 76,
"Red": 76,
"Alpha": 255
},
"VectorValue": {
"X": 0,
"Y": 0,
"Z": 0
},
"ArrayValue": [],
"StructValue": {}
}
],
"StructValue": {}
}
}
}
}
},
"DefaultStateId": 0,
"FontDid": 1073741828,
"HJustify": 1,
"VJustify": 1,
"FontColor": {
"X": 1,
"Y": 1,
"Z": 1,
"W": 1
},
"StateMedia": {
"": {
"Item1": 100668306,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
}

File diff suppressed because it is too large Load diff