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

@ -531,7 +531,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Steps 05 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.

View file

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

View file

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

View file

@ -0,0 +1,188 @@
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates retail's category and component listbox templates from LayoutDesc
/// 0x21000033. This is the retained equivalent of
/// <c>UIElement_ListBox::AddItemFromTemplateList</c> used by
/// <c>gmSpellComponentUI::UpdateComponents @ 0x0048A910</c>.
/// </summary>
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<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
private readonly string[] _categoryNames;
public ComponentBookTemplateFactory(
ElementInfo categoryTemplate,
ElementInfo componentTemplate,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null,
IReadOnlyList<string>? categoryNames = null)
{
_categoryTemplate = categoryTemplate;
_componentTemplate = componentTemplate;
_resolveSprite = resolveSprite;
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
_categoryNames = categoryNames?.ToArray()
?? CategoryStrings.Select(value => value.Fallback).ToArray();
}
/// <summary>
/// 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.
/// </summary>
public static ComponentBookTemplateFactory? TryLoad(
DatCollection dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? 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<uint, UiDatFont?>();
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<UiText>(content, NameId);
UiText ownedText = Required<UiText>(content, OwnedCountId);
UiField desiredField = Required<UiField>(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<T>(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<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> 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);
}

View file

@ -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<uint> _playerGuid;
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
private readonly SelectionState _selection;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, int> _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<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
SelectionState selection,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> 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<uint> playerGuid,
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
SelectionState selection,
Func<uint, uint> resolveSpellIcon,
Func<uint, uint> resolveComponentIcon,
Func<uint, int> spellLevel,
@ -170,6 +179,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action<string, Action<bool>> showConfirmation,
Action<uint, uint> 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;

View file

@ -0,0 +1,72 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// One runtime listbox entry instantiated from an authored LayoutDesc template.
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList</c> creates exactly this
/// ownership shape: the list owns the entry while the template owns its visuals.
/// </summary>
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);
}
}

View file

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

View file

@ -0,0 +1,23 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// 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).
/// </summary>
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);
}
}

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