diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 46cb90d8..6ae1974b 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -531,7 +531,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
-- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The spellbook now resolves retail UIItem prototype `0x10000343` for its one-column icon/name/separator rows and selected overlay, binds the authored scrollbar, ports exact filters/display ordering, drags learned spells into the open favorite bar without mutating their source, and reuses `RetailDialogFactory` for server-authoritative spell deletion (`CM_Magic::Event_RemoveSpell`, `0x01A8`). `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
+- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 34389ab9..369622f3 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -6,8 +6,8 @@
LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
inventory, and item-giving demo is user-gated. The shared projectile/effect
foundation is hardened through Step 9 and its single-client visual gate passes.
-Next is the connected F.5 spellbook/favorite-bar/delete visual gate, followed
-by cast/enchantment presentation and the two-client portal/observer VFX gate.
+Next is the connected F.5 component-book/favorite-bar visual gate, followed by
+cast/enchantment presentation and the two-client portal/observer VFX gate.
Carried:
#145-residual, #116 slide-response, R6/TS-42, and Track MP0.
@@ -656,6 +656,9 @@ its authored scrollbar, ports exact school/level filter masks and stable
display-order sorting, carries learned spells into the open favorite bar, and
routes Delete through the shared retail confirmation factory plus
server-authoritative `CM_Magic::Event_RemoveSpell (0x01A8)` response path.
+The component-book follow-up now instantiates retail's category and component
+listbox templates from LayoutDesc `0x21000033`, preserving localized headings,
+row/selection textures, icon/name/count columns, and desired-value editing.
**Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
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 678fba78..2065ddcd 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
@@ -292,6 +292,102 @@ sorts by `CSpellBase::_display_order`; school and level buttons apply the
server-persisted filter bitfield. Component rows use the installed component
table and the server-persisted desired purchase amounts.
+### Component-book listbox templates and synchronization
+
+Named-retail anchors:
+
+- `gmSpellComponentUI::PostInit @ 0x0048A190`
+- `gmSpellComponentUI::ListenToElementMessage @ 0x0048A420`
+- `gmSpellComponentUI::SyncComponentRegionWithData @ 0x0048A620`
+- `gmSpellComponentUI::UpdateComponents @ 0x0048A910`
+- `gmSpellComponentUI::RecvNotice_SelectionChanged @ 0x00489C40`
+- `gmSpellComponentUI::UpdateBuyRates @ 0x00489FE0`
+
+The component page is not a generic icon catalog. LayoutDesc `0x21000033`
+authors a listbox with two entry templates, referenced in order by list
+property `0x64`:
+
+```text
+component list 0x10000464 (284 x 600)
+component scrollbar 0x10000465 (16 x 600)
+
+template 0 / category 0x10000466 (284 x 32, UIElement_Text)
+ background 0x06001392
+ centered category font 0x40000004
+
+template 1 / component 0x10000467 (284 x 32, UIElement_Field)
+ normal background 0x06005E21
+ Highlight background 0x06005E22
+ icon region 0x10000468 (x=0, 32 x 32)
+ name 0x10000469 (x=42, width=180, height=32)
+ owned count 0x1000046A (x=224, y=0, width=60, height=15)
+ desired purchase count 0x1000046B (x=224, y=15, width=60, height=15)
+```
+
+The seven localized headings are string hashes in local string table
+`0x23000001`: `SCARABS`, `HERBS`, `POWDERED GEMS`, `ALCHEMICAL SUBSTANCES`,
+`TALISMANS`, `TAPERS`, and `PEAS`. They correspond in order to retail
+`SpellComponentCategory` values 0 through 6. Category 6 is Peas; treating it
+as an invented "Prismatic Components" group is not retail behavior.
+
+```text
+PostInit():
+ componentList = GetChildRecursive(0x10000464) as ListBox
+ register component/player/selection notices
+ if PlayerDescription already arrived: UpdateBuyRates(all components)
+
+UpdateComponents(change):
+ tracker = PlayerSystem.ComponentTracker
+ insertion = 0
+ for category in 0..6:
+ if tracker.componentLists[category] is empty:
+ remove a stale category entry for this category, if present
+ continue
+
+ if entry[insertion] is not this category header:
+ header = list.AddItemFromTemplateList(templateIndex=0, insertion)
+ header.attribute[0x1000004D] = category
+ header.text = localizedCategoryTitle[category]
+ insertion++
+
+ reconcile component rows against tracker.componentLists[category]
+ each new row = list.AddItemFromTemplateList(templateIndex=1, insertion)
+ row.attribute[0x1000004C] = componentWCID
+ row[0x10000468] = MakeIcon(componentWCID)
+ row[0x10000469] = componentName
+ row[0x1000046A] = totalOwnedCount
+ row[0x1000046B] = desiredPurchaseCount
+ row[0x1000046B].inputFilter = NumberInputFilter
+ insertion++
+
+ListenToElementMessage(message):
+ if selection changed:
+ if selected entry is a category:
+ clear selected world object
+ else if selected entry is a component:
+ select the first owned object represented by that component region
+
+ if edit completed on 0x1000046B:
+ parse integer
+ if 0 <= value <= 5000:
+ send SetDesiredComponentLevel(componentWCID, value)
+ update local PlayerModule projection
+ else:
+ restore the authoritative desired value in the field
+
+RecvNotice_SelectionChanged():
+ if the selected world object belongs to a tracked component region:
+ select and expose its matching component row
+ else:
+ clear component-list selection
+```
+
+The retained port instantiates these resolved templates through
+`ComponentBookTemplateFactory` and `UiTemplateListSlot`, the architectural
+equivalent of `AddItemFromTemplateList`. Controllers bind only the live icon,
+texts, selection, and edit callbacks. They do not duplicate the row sprites,
+fonts, or geometry.
+
### Spellbook row, selection, filtering, dragging, and deletion
Named-retail anchors:
diff --git a/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs b/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs
new file mode 100644
index 00000000..41ce9139
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ComponentBookTemplateFactory.cs
@@ -0,0 +1,188 @@
+using DatReaderWriter;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Instantiates retail's category and component listbox templates from LayoutDesc
+/// 0x21000033. This is the retained equivalent of
+/// UIElement_ListBox::AddItemFromTemplateList used by
+/// gmSpellComponentUI::UpdateComponents @ 0x0048A910.
+///
+public sealed class ComponentBookTemplateFactory
+{
+ public const uint LayoutId = 0x21000033u;
+ public const uint CategoryTemplateId = 0x10000466u;
+ public const uint ComponentTemplateId = 0x10000467u;
+ public const uint IconId = 0x10000468u;
+ public const uint NameId = 0x10000469u;
+ public const uint OwnedCountId = 0x1000046Au;
+ public const uint DesiredCountId = 0x1000046Bu;
+
+ private const uint LocalStringTableId = 0x23000001u;
+ private const uint HighlightState = UiButtonStateMachine.Highlight;
+
+ private static readonly (string Key, string Fallback)[] CategoryStrings =
+ [
+ ("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"),
+ ];
+
+ private readonly ElementInfo _categoryTemplate;
+ private readonly ElementInfo _componentTemplate;
+ private readonly Func _resolveSprite;
+ private readonly UiDatFont? _defaultFont;
+ private readonly IReadOnlyDictionary _fonts;
+ private readonly string[] _categoryNames;
+
+ public ComponentBookTemplateFactory(
+ ElementInfo categoryTemplate,
+ ElementInfo componentTemplate,
+ Func resolveSprite,
+ UiDatFont? defaultFont,
+ IReadOnlyDictionary? fonts = null,
+ IReadOnlyList? categoryNames = null)
+ {
+ _categoryTemplate = categoryTemplate;
+ _componentTemplate = componentTemplate;
+ _resolveSprite = resolveSprite;
+ _defaultFont = defaultFont;
+ _fonts = fonts ?? new Dictionary();
+ _categoryNames = categoryNames?.ToArray()
+ ?? CategoryStrings.Select(value => value.Fallback).ToArray();
+ }
+
+ ///
+ /// Loads and resolves the two template definitions while the caller holds the
+ /// shared DAT lock. Fonts and localized category strings are captured eagerly,
+ /// so later inventory refreshes instantiate rows without reading DAT files.
+ ///
+ public static ComponentBookTemplateFactory? TryLoad(
+ DatCollection dats,
+ Func resolveSprite,
+ UiDatFont? defaultFont,
+ Func? resolveFont)
+ {
+ ElementInfo? category = LayoutImporter.ImportInfos(
+ dats, LayoutId, CategoryTemplateId);
+ ElementInfo? component = LayoutImporter.ImportInfos(
+ dats, LayoutId, ComponentTemplateId);
+ if (category is null || component is null)
+ return null;
+
+ var fonts = new Dictionary();
+ CaptureFonts(category, resolveFont, fonts);
+ CaptureFonts(component, resolveFont, fonts);
+
+ var strings = new DatStringResolver(dats);
+ string[] categoryNames = CategoryStrings
+ .Select(value => strings.Resolve(
+ LocalStringTableId,
+ DatStringResolver.ComputeHash(value.Key))
+ ?? value.Fallback)
+ .ToArray();
+
+ return new ComponentBookTemplateFactory(
+ category, component, resolveSprite, defaultFont, fonts, categoryNames);
+ }
+
+ public UiTemplateListSlot CreateCategoryRow(uint category)
+ {
+ ImportedLayout content = Build(_categoryTemplate);
+ if (content.Root is not UiText title)
+ throw new InvalidOperationException(
+ "Retail component category template did not resolve to UIElement_Text.");
+
+ string label = category < _categoryNames.Length
+ ? _categoryNames[category]
+ : "OTHER COMPONENTS";
+ title.LinesProvider = () => [new UiText.Line(label, title.DefaultColor)];
+
+ return new UiTemplateListSlot(
+ content, uint.MaxValue, content.Root is IUiDatStateful stateful
+ ? stateful.ActiveRetailStateId
+ : UiStateInfo.DirectStateId, selectedState: null);
+ }
+
+ public ComponentRow CreateComponentRow(
+ uint componentId,
+ uint iconTexture,
+ string name,
+ int ownedCount,
+ uint desiredCount)
+ {
+ ImportedLayout content = Build(_componentTemplate);
+ UiElement iconHost = Required(content, IconId);
+ UiText nameText = Required(content, NameId);
+ UiText ownedText = Required(content, OwnedCountId);
+ UiField desiredField = Required(content, DesiredCountId);
+
+ var icon = new UiTextureElement
+ {
+ Width = iconHost.Width,
+ Height = iconHost.Height,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top
+ | AnchorEdges.Right | AnchorEdges.Bottom,
+ Texture = iconTexture,
+ };
+ iconHost.AddChild(icon);
+
+ nameText.LinesProvider = () => [new UiText.Line(name, nameText.DefaultColor)];
+ ownedText.LinesProvider = () =>
+ [new UiText.Line(ownedCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ownedText.DefaultColor)];
+
+ // NumberInputFilter + the 0..5000 validation are installed by the controller,
+ // matching gmSpellComponentUI::ListenToElementMessage @ 0x0048A420.
+ desiredField.ClearOnSubmit = false;
+ desiredField.RecordHistory = false;
+ desiredField.SelectAllOnFocus = true;
+ desiredField.CharacterFilter = char.IsAsciiDigit;
+ desiredField.SetText(desiredCount.ToString(
+ System.Globalization.CultureInfo.InvariantCulture));
+
+ uint normalState = content.Root is IUiDatStateful stateful
+ ? stateful.ActiveRetailStateId
+ : UiStateInfo.DirectStateId;
+ var slot = new UiTemplateListSlot(
+ content, componentId, normalState, HighlightState);
+ return new ComponentRow(slot, desiredField);
+ }
+
+ private ImportedLayout Build(ElementInfo template)
+ => LayoutImporter.Build(
+ template,
+ _resolveSprite,
+ _defaultFont,
+ did => _fonts.TryGetValue(did, out UiDatFont? font) ? font : _defaultFont);
+
+ private static T Required(ImportedLayout content, uint id)
+ where T : UiElement
+ => content.FindElement(id) as T
+ ?? throw new InvalidOperationException(
+ $"Retail component template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
+
+ private static UiElement Required(ImportedLayout content, uint id)
+ => content.FindElement(id)
+ ?? throw new InvalidOperationException(
+ $"Retail component template element 0x{id:X8} is missing.");
+
+ private static void CaptureFonts(
+ ElementInfo info,
+ Func? resolveFont,
+ Dictionary fonts)
+ {
+ if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
+ fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
+ foreach (ElementInfo child in info.Children)
+ CaptureFonts(child, resolveFont, fonts);
+ }
+
+ public readonly record struct ComponentRow(
+ UiTemplateListSlot Slot,
+ UiField DesiredField);
+}
diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
index 7301ff1a..1dbfc5d8 100644
--- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
+++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
@@ -2,9 +2,9 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
-using System.Numerics;
using AcDream.App.Spells;
using AcDream.Core.Items;
+using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
@@ -48,6 +48,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly ClientObjectTable _objects;
private readonly Func _playerGuid;
private readonly IReadOnlyDictionary _components;
+ private readonly SelectionState _selection;
private readonly Func _resolveSpellIcon;
private readonly Func _resolveComponentIcon;
private readonly Func _spellLevel;
@@ -66,10 +67,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly UiButton _deleteButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
+ private readonly ComponentBookTemplateFactory _componentTemplates;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
private readonly SpellbookRowStyle _rowStyle;
private readonly UiDatFont? _rowFont;
private uint? _selectedSpell;
+ private uint? _selectedComponent;
private bool _spellsDirty;
private bool _componentsDirty;
private bool _componentEditActive;
@@ -81,6 +84,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
ClientObjectTable objects,
Func playerGuid,
IReadOnlyDictionary components,
+ SelectionState selection,
Func resolveSpellIcon,
Func resolveComponentIcon,
Func spellLevel,
@@ -99,6 +103,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
UiButton deleteButton,
UiItemList spellList,
UiItemList componentList,
+ ComponentBookTemplateFactory componentTemplates,
SpellbookRowStyle rowStyle,
UiDatFont? rowFont)
{
@@ -106,6 +111,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects = objects;
_playerGuid = playerGuid;
_components = components;
+ _selection = selection;
_resolveSpellIcon = resolveSpellIcon;
_resolveComponentIcon = resolveComponentIcon;
_spellLevel = spellLevel;
@@ -124,6 +130,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_deleteButton = deleteButton;
_spellList = spellList;
_componentList = componentList;
+ _componentTemplates = componentTemplates;
_rowStyle = rowStyle;
_rowFont = rowFont;
@@ -148,6 +155,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
+ _selection.Changed += OnSelectionChanged;
ShowPage(SpellbookWindowPage.Spells);
RebuildAll();
}
@@ -160,6 +168,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
ClientObjectTable objects,
Func playerGuid,
IReadOnlyDictionary components,
+ SelectionState selection,
Func resolveSpellIcon,
Func resolveComponentIcon,
Func spellLevel,
@@ -170,6 +179,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action> showConfirmation,
Action setDesiredComponent,
Action close,
+ ComponentBookTemplateFactory componentTemplates,
SpellbookRowStyle rowStyle,
UiDatFont? rowFont)
{
@@ -199,13 +209,14 @@ public sealed class SpellbookWindowController : IRetainedPanelController
}
return new SpellbookWindowController(
- layout, spellbook, objects, playerGuid, components,
+ layout, spellbook, objects, playerGuid, components, selection,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
addFavorite, sendFilter, removeSpell, showConfirmation,
setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
- deleteButton, spellList, componentList, rowStyle, rowFont);
+ deleteButton, spellList, componentList, componentTemplates,
+ rowStyle, rowFont);
}
public void ShowPage(SpellbookWindowPage page)
@@ -337,40 +348,29 @@ public sealed class SpellbookWindowController : IRetainedPanelController
if (category != nextCategory)
{
category = nextCategory;
- _componentList.AddItem(new UiCatalogSlot
+ UiTemplateListSlot categoryRow =
+ _componentTemplates.CreateCategoryRow(nextCategory);
+ categoryRow.Clicked = () =>
{
- EntryId = uint.MaxValue,
- Label = ComponentCategoryName(nextCategory),
- ShowLabel = true,
- SpriteResolve = _componentList.SpriteResolve,
- });
+ _selectedComponent = null;
+ SyncComponentSelection();
+ _selectObject(0u);
+ };
+ _componentList.AddItem(categoryRow);
}
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
- var row = new UiCatalogSlot
- {
- EntryId = componentId,
- CatalogIconTexture = _resolveComponentIcon(
- pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
- Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
- Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
- ShowLabel = true,
- SpriteResolve = _componentList.SpriteResolve,
- };
- var desiredField = new UiField
- {
- Left = width - 47f,
- Top = 4f,
- Width = 43f,
- Height = 24f,
- MaxCharacters = 4,
- ClearOnSubmit = false,
- RecordHistory = false,
- SelectAllOnFocus = true,
- RightAligned = true,
- CharacterFilter = char.IsAsciiDigit,
- BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
- };
- desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
+ string componentName = string.IsNullOrWhiteSpace(pack.Name)
+ ? descriptor?.Name ?? $"Component {componentId}"
+ : pack.Name;
+ ComponentBookTemplateFactory.ComponentRow row =
+ _componentTemplates.CreateComponentRow(
+ componentId,
+ _resolveComponentIcon(
+ pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
+ componentName,
+ packGroup.Quantity,
+ desired);
+ UiField desiredField = row.DesiredField;
desiredField.OnFocusGained = () => _componentEditActive = true;
uint committed = desired;
void Commit(string value)
@@ -392,25 +392,18 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Commit(value);
_componentEditActive = false;
};
- row.AddChild(desiredField);
- row.Clicked = () => _selectObject(pack.ObjectId);
- _componentList.AddItem(row);
+ row.Slot.Clicked = () =>
+ {
+ _selectedComponent = componentId;
+ SyncComponentSelection();
+ _selectObject(pack.ObjectId);
+ };
+ _componentList.AddItem(row.Slot);
}
}
+ SyncComponentSelectionFromWorld();
}
- private static string ComponentCategoryName(uint category) => category switch
- {
- 0u => "Scarabs",
- 1u => "Herbs",
- 2u => "Powdered Gems",
- 3u => "Alchemical Substances",
- 4u => "Talismans",
- 5u => "Tapers",
- 6u => "Prismatic Components",
- _ => "Other Components",
- };
-
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
@@ -479,6 +472,23 @@ public sealed class SpellbookWindowController : IRetainedPanelController
slot.Selected = slot.EntryId == _selectedSpell;
}
+ private void SyncComponentSelection()
+ {
+ for (int i = 0; i < _componentList.GetNumUIItems(); i++)
+ if (_componentList.GetItem(i) is UiTemplateListSlot slot)
+ slot.SetSelected(slot.EntryId == _selectedComponent);
+ }
+
+ private void SyncComponentSelectionFromWorld()
+ {
+ _selectedComponent = _selection.SelectedObjectId is uint selected
+ && _objects.Get(selected) is { } item
+ && IsComponent(item)
+ ? item.WeenieClassId
+ : null;
+ SyncComponentSelection();
+ }
+
public void Tick()
{
if (_spellsDirty)
@@ -505,6 +515,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
}
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
+ private void OnSelectionChanged(SelectionTransition _) => SyncComponentSelectionFromWorld();
private bool IsComponent(ClientObject item)
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
public void OnShown() => RebuildAll();
@@ -520,6 +531,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
+ _selection.Changed -= OnSelectionChanged;
RetailTabBinding.SetClick(_spellTab, null);
RetailTabBinding.SetClick(_componentTab, null);
_closeButton.OnClick = null;
diff --git a/src/AcDream.App/UI/Layout/UiTemplateListSlot.cs b/src/AcDream.App/UI/Layout/UiTemplateListSlot.cs
new file mode 100644
index 00000000..97793596
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/UiTemplateListSlot.cs
@@ -0,0 +1,72 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// One runtime listbox entry instantiated from an authored LayoutDesc template.
+/// Retail UIElement_ListBox::AddItemFromTemplateList creates exactly this
+/// ownership shape: the list owns the entry while the template owns its visuals.
+///
+public sealed class UiTemplateListSlot : UiItemSlot
+{
+ private readonly IUiDatStateful? _statefulRoot;
+ private readonly uint _normalState;
+ private readonly uint? _selectedState;
+ private bool? _stateWasSelected;
+
+ public UiTemplateListSlot(
+ ImportedLayout content,
+ uint entryId,
+ uint normalState,
+ uint? selectedState)
+ {
+ Content = content;
+ EntryId = entryId;
+ _normalState = normalState;
+ _selectedState = selectedState;
+ _statefulRoot = content.Root as IUiDatStateful;
+
+ Width = content.Root.Width;
+ Height = content.Root.Height;
+ content.Root.Left = 0f;
+ content.Root.Top = 0f;
+ content.Root.Anchors = AnchorEdges.Left | AnchorEdges.Top
+ | AnchorEdges.Right | AnchorEdges.Bottom;
+ AddChild(content.Root);
+ }
+
+ public ImportedLayout Content { get; }
+ public uint EntryId { get; }
+ public new Action? Clicked { get; set; }
+
+ public override bool HandlesClick => Clicked is not null;
+
+ public void SetSelected(bool selected)
+ {
+ Selected = selected;
+ ApplySelectionState();
+ }
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (e.Type == UiEventType.MouseDown)
+ return true;
+ if (e.Type == UiEventType.Click && Clicked is not null)
+ {
+ Clicked();
+ return true;
+ }
+ return false;
+ }
+
+ protected override void OnDraw(UiRenderContext ctx)
+ => ApplySelectionState();
+
+ private void ApplySelectionState()
+ {
+ if (_stateWasSelected == Selected)
+ return;
+
+ _stateWasSelected = Selected;
+ _statefulRoot?.TrySetRetailState(
+ Selected && _selectedState.HasValue ? _selectedState.Value : _normalState);
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 807c08f4..0d1c04c4 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -709,6 +709,7 @@ public sealed class RetailUiRuntime : IDisposable
{
ImportedLayout? layout;
SpellbookRowStyle? rowStyle;
+ ComponentBookTemplateFactory? componentTemplates;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
@@ -719,10 +720,16 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
+ componentTemplates = ComponentBookTemplateFactory.TryLoad(
+ _bindings.Assets.Dats,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont);
}
- if (layout is null || rowStyle is null)
+ if (layout is null || rowStyle is null || componentTemplates is null)
{
- Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found.");
+ Console.WriteLine(
+ "[M3] spellbook: layout, spell row, or component list templates not found.");
return;
}
@@ -737,6 +744,7 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.Objects,
_bindings.Magic.PlayerGuid,
_bindings.Magic.Components,
+ _bindings.Magic.Selection,
_bindings.Magic.ResolveSpellIcon,
_bindings.Magic.ResolveComponentIcon,
_bindings.Magic.SpellLevel,
@@ -747,6 +755,7 @@ public sealed class RetailUiRuntime : IDisposable
(message, completed) => ShowConfirmation(message, completed),
_bindings.Magic.SetDesiredComponent,
() => CloseWindow(WindowNames.Spellbook),
+ componentTemplates,
rowStyle.Value,
rowFont);
if (controller is null)
diff --git a/src/AcDream.App/UI/UiTextureElement.cs b/src/AcDream.App/UI/UiTextureElement.cs
new file mode 100644
index 00000000..e061ed87
--- /dev/null
+++ b/src/AcDream.App/UI/UiTextureElement.cs
@@ -0,0 +1,23 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+
+namespace AcDream.App.UI;
+
+///
+/// Runtime-owned texture content inside an authored retained-UI region. LayoutDesc
+/// still owns the region's geometry and chrome; controllers supply only the live
+/// texture handle (for example a composed item icon).
+///
+public sealed class UiTextureElement : UiElement
+{
+ public UiTextureElement() => ClickThrough = true;
+
+ public uint Texture { get; set; }
+ public Vector4 Tint { get; set; } = Vector4.One;
+
+ protected override void OnDraw(UiRenderContext ctx)
+ {
+ if (Texture != 0u)
+ ctx.DrawSprite(Texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
index 79a75e0b..3f5087bb 100644
--- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
@@ -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);
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
index 1bada79a..c8277988 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
@@ -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(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);
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
index 9088aa31..4123490a 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
@@ -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
+ {
+ [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();
+ 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(
+ layout.FindElement(SpellbookWindowController.ComponentListId)?.Children.Single());
+ Assert.Equal(5, list.GetNumUIItems());
+ Assert.Same(
+ list.Scroll,
+ Assert.IsType(
+ layout.FindElement(SpellbookWindowController.ComponentScrollbarId)).Model);
+
+ UiTemplateListSlot scarabs = Assert.IsType(list.GetItem(0));
+ UiText scarabTitle = Assert.IsType(scarabs.Content.Root);
+ Assert.Equal("SCARABS", Assert.Single(scarabTitle.LinesProvider()).Text);
+ Assert.Equal(0x06001392u, scarabTitle.BackgroundSprite);
+
+ UiTemplateListSlot copper = Assert.IsType(list.GetItem(1));
+ UiDatElement copperRoot = Assert.IsType(copper.Content.Root);
+ Assert.Equal((0x06005E21u, 1), copperRoot.ActiveMedia());
+ Assert.Equal("Copper Scarab", Assert.Single(
+ Assert.IsType(copper.Content.FindElement(
+ ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
+ Assert.Equal("2", Assert.Single(
+ Assert.IsType(copper.Content.FindElement(
+ ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
+ UiField desiredField = Assert.IsType(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(copper.Content.FindElement(
+ ComponentBookTemplateFactory.IconId));
+ Assert.Equal(
+ 0x06001100u,
+ Assert.IsType(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(list.GetItem(3));
+ Assert.Equal("HERBS", Assert.Single(
+ Assert.IsType(herbs.Content.Root).LinesProvider()).Text);
+ UiTemplateListSlot amaranth = Assert.IsType(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? sendFilter = null,
Action? removeSpell = null,
Action>? showConfirmation = null,
- Action? close = null)
+ Action? close = null,
+ ClientObjectTable? objects = null,
+ IReadOnlyDictionary? components = null,
+ SelectionState? selection = null,
+ Action? selectObject = null,
+ Action? setDesiredComponent = null,
+ Func? resolveComponentIcon = null)
=> SpellbookWindowController.Bind(
layout,
spellbook,
- new ClientObjectTable(),
+ objects ?? new ClientObjectTable(),
() => 1u,
- new Dictionary(),
+ components ?? new Dictionary(),
+ 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);
diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/component_category_21000033_10000466.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/component_category_21000033_10000466.json
new file mode 100644
index 00000000..e6162133
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/component_category_21000033_10000466.json
@@ -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": []
+}
\ No newline at end of file
diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/component_row_21000033_10000467.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/component_row_21000033_10000467.json
new file mode 100644
index 00000000..9585ddf4
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/component_row_21000033_10000467.json
@@ -0,0 +1,1395 @@
+{
+ "Id": 268436583,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 284,
+ "Height": 32,
+ "OriginalParentWidth": 0,
+ "OriginalParentHeight": 0,
+ "HasOriginalParentSize": false,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 2,
+ "ZLevel": 0,
+ "States": {
+ "4294967295": {
+ "Id": 4294967295,
+ "Name": "",
+ "PassToChildren": false,
+ "IncorporationFlags": 30,
+ "Image": {
+ "File": 100687393,
+ "DrawMode": 1
+ },
+ "Cursor": null,
+ "Properties": {
+ "Values": {}
+ }
+ },
+ "6": {
+ "Id": 6,
+ "Name": "Highlight",
+ "PassToChildren": false,
+ "IncorporationFlags": 0,
+ "Image": {
+ "File": 100687394,
+ "DrawMode": 1
+ },
+ "Cursor": null,
+ "Properties": {
+ "Values": {}
+ }
+ }
+ },
+ "DefaultStateId": 0,
+ "FontDid": 0,
+ "HJustify": 1,
+ "VJustify": 1,
+ "FontColor": null,
+ "StateMedia": {
+ "": {
+ "Item1": 100687393,
+ "Item2": 1
+ },
+ "Highlight": {
+ "Item1": 100687394,
+ "Item2": 1
+ }
+ },
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268436584,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 32,
+ "Height": 32,
+ "OriginalParentWidth": 284,
+ "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": null,
+ "Cursor": null,
+ "Properties": {
+ "Values": {}
+ }
+ }
+ },
+ "DefaultStateId": 0,
+ "FontDid": 0,
+ "HJustify": 1,
+ "VJustify": 1,
+ "FontColor": null,
+ "StateMedia": {},
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": []
+ },
+ {
+ "Id": 268436585,
+ "Type": 12,
+ "X": 42,
+ "Y": 0,
+ "Width": 180,
+ "Height": 32,
+ "OriginalParentWidth": 284,
+ "OriginalParentHeight": 32,
+ "HasOriginalParentSize": true,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 2,
+ "ZLevel": 0,
+ "States": {
+ "4294967295": {
+ "Id": 4294967295,
+ "Name": "",
+ "PassToChildren": false,
+ "IncorporationFlags": 30,
+ "Image": null,
+ "Cursor": null,
+ "Properties": {
+ "Values": {
+ "35": {
+ "Kind": 4,
+ "MasterPropertyId": 35,
+ "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": {}
+ },
+ "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": 1073741825,
+ "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": 2,
+ "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": 1073741825,
+ "HJustify": 0,
+ "VJustify": 1,
+ "FontColor": {
+ "X": 1,
+ "Y": 1,
+ "Z": 1,
+ "W": 1
+ },
+ "StateMedia": {},
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": []
+ },
+ {
+ "Id": 268436586,
+ "Type": 12,
+ "X": 224,
+ "Y": 0,
+ "Width": 60,
+ "Height": 15,
+ "OriginalParentWidth": 284,
+ "OriginalParentHeight": 32,
+ "HasOriginalParentSize": true,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 3,
+ "ZLevel": 0,
+ "States": {
+ "4294967295": {
+ "Id": 4294967295,
+ "Name": "",
+ "PassToChildren": false,
+ "IncorporationFlags": 30,
+ "Image": null,
+ "Cursor": null,
+ "Properties": {
+ "Values": {
+ "35": {
+ "Kind": 4,
+ "MasterPropertyId": 35,
+ "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": {}
+ },
+ "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": 1073741824,
+ "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": 3,
+ "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": {}
+ },
+ "36": {
+ "Kind": 4,
+ "MasterPropertyId": 36,
+ "UnsignedValue": 0,
+ "IntegerValue": 8,
+ "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": 1073741824,
+ "HJustify": 2,
+ "VJustify": 1,
+ "FontColor": {
+ "X": 1,
+ "Y": 1,
+ "Z": 1,
+ "W": 1
+ },
+ "StateMedia": {},
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": []
+ },
+ {
+ "Id": 268436587,
+ "Type": 12,
+ "X": 224,
+ "Y": 15,
+ "Width": 60,
+ "Height": 15,
+ "OriginalParentWidth": 284,
+ "OriginalParentHeight": 32,
+ "HasOriginalParentSize": true,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 4,
+ "ZLevel": 0,
+ "States": {
+ "4294967295": {
+ "Id": 4294967295,
+ "Name": "",
+ "PassToChildren": false,
+ "IncorporationFlags": 30,
+ "Image": {
+ "File": 100682946,
+ "DrawMode": 3
+ },
+ "Cursor": null,
+ "Properties": {
+ "Values": {
+ "35": {
+ "Kind": 4,
+ "MasterPropertyId": 35,
+ "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": {}
+ },
+ "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": 1073741824,
+ "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": 3,
+ "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": {}
+ },
+ "22": {
+ "Kind": 1,
+ "MasterPropertyId": 22,
+ "UnsignedValue": 0,
+ "IntegerValue": 0,
+ "FloatValue": 0,
+ "BoolValue": true,
+ "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": {}
+ },
+ "209": {
+ "Kind": 1,
+ "MasterPropertyId": 209,
+ "UnsignedValue": 0,
+ "IntegerValue": 0,
+ "FloatValue": 0,
+ "BoolValue": true,
+ "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": {}
+ },
+ "36": {
+ "Kind": 4,
+ "MasterPropertyId": 36,
+ "UnsignedValue": 0,
+ "IntegerValue": 8,
+ "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": {}
+ },
+ "203": {
+ "Kind": 1,
+ "MasterPropertyId": 203,
+ "UnsignedValue": 0,
+ "IntegerValue": 0,
+ "FloatValue": 0,
+ "BoolValue": true,
+ "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": {}
+ },
+ "39": {
+ "Kind": 1,
+ "MasterPropertyId": 39,
+ "UnsignedValue": 0,
+ "IntegerValue": 0,
+ "FloatValue": 0,
+ "BoolValue": true,
+ "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": {}
+ },
+ "30": {
+ "Kind": 4,
+ "MasterPropertyId": 30,
+ "UnsignedValue": 0,
+ "IntegerValue": 5,
+ "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": {}
+ },
+ "32": {
+ "Kind": 1,
+ "MasterPropertyId": 32,
+ "UnsignedValue": 0,
+ "IntegerValue": 0,
+ "FloatValue": 0,
+ "BoolValue": true,
+ "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": 1073741824,
+ "HJustify": 2,
+ "VJustify": 1,
+ "FontColor": {
+ "X": 1,
+ "Y": 1,
+ "Z": 1,
+ "W": 1
+ },
+ "StateMedia": {
+ "": {
+ "Item1": 100682946,
+ "Item2": 3
+ }
+ },
+ "StateCursors": {},
+ "DefaultStateName": "",
+ "Children": []
+ }
+ ]
+}
\ No newline at end of file