fix(ui): complete retail indicator detail panels

Port the authored effect row template, remaining-time and selection details, synchronize the full gmPanelUI child geometry, and route the burden indicator to Character Information panel 3.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 11:36:43 +02:00
parent a96767ba6d
commit d1d603105f
24 changed files with 3696 additions and 147 deletions

View file

@ -61,10 +61,14 @@ back to inert generic DAT elements.
`gmUIElement_EffectsIndicator`. The retained controller consequently had no
live bindings for session packet age, Vitae, or the player load ratio and did
not dispatch Character Information or the shared logout confirmation path.
The strip and its actions are now ported. Link Status (panel 8), both effect
pages (4/5), Vitae (15), and the authored Mini Game shell (9) are registered as
children of the same retained main-panel owner as Inventory/Character/Magic,
so switching pages preserves one canonical position. Link ping RTT and Vitae
The strip and its actions are now ported. Character Information (panel 3), Link
Status (8), both effect pages (4/5), Vitae (15), and the authored Mini Game
shell (9) are registered as children of the same retained main-panel owner as
Inventory/Character/Magic, so switching pages preserves one canonical size and
position. Helpful/Harmful use retail's authored 300x32 icon/name/time rows,
selection states, `SELECT A SPELL` prompt, and selected spell description.
The burden icon routes to Character Information rather than Attributes/Skills.
Link ping RTT and Vitae
recovery XP are live; transport packet-loss averaging and Mini Game gameplay
remain the explicitly narrowed AP-110 residual.
@ -81,7 +85,7 @@ helpful, harmful, Vitae, and burden react to live state; the burden icon opens
Character Information; effects open their matching panels; crossed swords use
the shared confirmation dialog and close gracefully only after acceptance.
Every non-ghosted detail icon opens its authored page in the shared main-panel
position; closing a restore-previous page returns to the deferred ordinary page.
geometry; closing a restore-previous page returns to the deferred ordinary page.
---

View file

@ -50,7 +50,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` primary children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical placement and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from the retail UI deep-dives and named client methods plus production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content/resize policy while typed handle synchronization gives Inventory/Character/Magic the same persistent parent position | Edge-case input semantics can differ silently; if retail relies on parent-size anchoring across a panel switch, separate child extents may resize differently even though placement and visibility match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; docs/research/retail-ui/04-input-events.md |
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical geometry and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from retail UI deep-dives, named client methods, and production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content tree while typed move/resize synchronization gives all registered toolbar/detail children the same persistent parent rectangle | Edge-case low-level input semantics can differ silently even though outer geometry, visibility, and restore-previous ownership match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-17. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels, the complete authored seven-control gameplay indicator strip and its Link/Vitae detail pages, and shared `gmPanelUI` switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
**Status:** Living document. Updated 2026-07-17. **M3 active; automated implementation complete, visual gates pending.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The remaining M3 work is the connected single-client magic-UI/casting gate and final two-client portal-out/materialization observer gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -23,8 +23,11 @@ active enchantment, then recall through the DAT-driven portal presentation.
positive/negative active-enchantment panels, the seven-control link/effect/
Vitae/burden/mini-game/session indicator strip, and the retail
one-active-main-panel lifecycle. Link Status, Helpful/Harmful, Vitae, and
the inactive Mini Game shell now occupy that same canonical main-panel
position; Link RTT and Vitae recovery XP are live. Connected-gate
Character Information, and the inactive Mini Game shell now occupy that same
canonical main-panel size and position; Link RTT and Vitae recovery XP are
live. Effects now use the exact icon/name/time row template, selection state,
and `SELECT A SPELL`/description field; burden opens Character Information.
Connected-gate
correction now also pins type-12 spellbook tabs, retail zero text margins,
and `PassToChildren` Closed/Open chrome through real-DAT fixtures.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the

View file

@ -714,11 +714,14 @@ the burden/effect/logout actions use the shared panel/dialog owners. Main-panel
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
active only for the connected single-client magic
UI/casting visual gate and the final two-client portal observer gate.
The 2026-07-17 indicator follow-up also mounts the authored Link Status, Vitae,
and Mini Game detail roots as children of that same main-panel owner. Helpful,
Harmful, Link, and Vitae therefore replace Inventory/Character/Magic in one
canonical window position. Link uses retail's payload-free ping/RTT cadence;
Vitae uses PropertyInt 129/139 and the retail XP-pool threshold formula.
The 2026-07-17 indicator follow-up also mounts authored Character Information,
Link Status, Vitae, and Mini Game detail roots as children of that same
main-panel owner. Helpful, Harmful, Character Information, Link, and Vitae
therefore replace Inventory/Character/Magic in one canonical window rectangle.
Helpful/Harmful instantiate retail's DAT icon/name/time row and selected-spell
information presentation. The burden icon sends panel 3 (Character Information),
not panel 11 (Attributes/Skills). Link uses retail's payload-free ping/RTT
cadence; Vitae uses PropertyInt 129/139 and the retail XP-pool threshold formula.
The first connected gate exposed and corrected two retained-import defects:
spellbook tabs are authored stateful text controls (not buttons), and retail
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,

View file

@ -569,6 +569,8 @@ negative root 0x10000121 (inherits 0x10000122)
shared effects template 0x10000122
effect list 0x10000123
information text 0x10000126
effect row template 0x10000128 (300 x 32)
row icon / name / duration 0x10000129 / 0x1000012A / 0x1000012B
effect UI type property 0x1000000C
```
@ -622,6 +624,16 @@ an active token stores its spell ID, so every layer of that spell highlights
together and shows the shared name/description; clicking either layer again
clears the selection.
`InfoRegion::InfoRegion @ 0x004F1450` creates each row with
`UIElement_ListBox::AddItemFromTemplateList(template 0)`. In installed retail
data the icon is x=0 width 32, name is x=37 width 188, and duration is x=225
width 50. `EffectInfoRegion::Update @ 0x004F1C00` formats finite time as
`m:ss` or `h:mm:ss`; a negative/permanent duration leaves the field empty.
`gmEffectsUI::UpdateSelection @ 0x004B7F90` displays localized
`ID_Effects_Info_SelectASpell` while nothing is selected, otherwise spell name
plus two newlines plus description. Normal rows use state 1 and every row with
the selected spell ID uses Highlight state 6.
## Portal presentation boundary
Recall and portal presentation remains DAT-driven. `/ls` starts the retail

View file

@ -223,15 +223,44 @@ For every toolbar or indicator detail page:
register page with gmPanelUI panel id
when shown:
hide the current child
move the requested child to the shared main-panel placement
apply the shared main-panel rectangle to the requested child
show only the requested child
when a page with bool property 0x10000049 closes:
restore the deferred ordinary child
```
Helpful/Harmful, Link Status, Vitae, and the Mini Game shell therefore share
the same retained host position as Inventory, Character, and Magic. They do
not remember independent positions.
Character Information, Helpful/Harmful, Link Status, Vitae, and the Mini Game
shell therefore share the same retained host size and position as Inventory,
Character/Skills, and Magic. They do not remember independent rectangles.
The burden/backpack indicator sends panel id `3`, whose production child is
root `0x10000183` under LayoutDesc `0x2100006E`. Panel `11` is the separate
Attributes/Skills child and is not the burden indicator destination.
### Helpful and harmful effect rows
```text
gmEffectsUI::RebuildList @ 0x004B8350:
sort visible enchantment tokens alphabetically by spell name
AddItemFromTemplateList(template 0)
EffectInfoRegion row template 0x10000128 (LayoutDesc 0x2100001B):
icon 0x10000129 at x=0, 32 x 32
name 0x1000012A at x=37, 188 x 32
time left 0x1000012B at x=225, 50 x 32
gmEffectsUI::UpdateSelection @ 0x004B7F90:
no selected spell -> display ID_Effects_Info_SelectASpell
selected spell -> display name + "\n\n" + description
matching rows use Highlight state 6; other rows use Normal state 1
gmEffectsUI::SetSelectedSpell @ 0x004B8290:
clicking the selected spell again clears selection
```
The installed local.dat resolves `ID_Effects_Info_SelectASpell` to
`SELECT A SPELL`. Remaining finite durations use `m:ss` below one hour and
`h:mm:ss` at one hour or more.
### End character session

View file

@ -1,4 +1,4 @@
# Retail shared main-panel placement — pseudocode
# Retail shared main-panel geometry — pseudocode
**Date:** 2026-07-17
**Symptom:** moving Inventory and then opening Character/Skills or Magic opens
@ -73,10 +73,11 @@ SetPanelVisibility(panelId, show):
requested.child.SetVisible(false)
```
The decisive placement fact is structural: Inventory, Character/Skills, and
Magic are children of one `gmPanelUI`. The switch changes child visibility;
there is no child move and no per-panel top-level position. Dragging the parent
therefore moves the one position subsequently used by every child.
The decisive geometry fact is structural: Inventory, Character Information,
Character/Skills, Magic, Effects, and the other detail pages are children of
one `gmPanelUI`. The switch changes child visibility; there is no child move,
resize, or per-panel top-level rectangle. Moving or resizing the parent
therefore changes the one rectangle subsequently used by every child.
## Resize follow-up
@ -86,9 +87,12 @@ The production end-of-retail LayoutDesc supplies the missing geometry oracle:
LayoutDesc 0x2100006E
gmPanelUI host 0x100005FE 310 x 372
content parent 0x10000180 300 x 362, anchored on all edges
Inventory child 0x1000018B 300 x 362, anchored on all edges
Character child 0x1000018E 300 x 362, anchored on all edges
Magic child 0x10000190 300 x 362, anchored on all edges
Character Information 0x10000183 panel 3, 300 x 362
Helpful Effects 0x10000184 panel 4, 300 x 362
Harmful Effects 0x10000185 panel 5, 300 x 362
Inventory child 0x1000018B panel 7, 300 x 362
Character/Skills child 0x1000018E panel 11, 300 x 362
Magic child 0x10000190 panel 13, 300 x 362
top-center 0x1000065C Type 2 Dragbar, cursor 0x06006119
bottom-center 0x10000660 Type 9 Resizebar, cursor 0x06005E66
@ -102,7 +106,8 @@ properties `0x3C..0x3F`. There is no retail 760-pixel Character limit and no
maximum captured from the host's original screen position.
acdream retains independent wrappers under IA-12, so the faithful adaptation
is to give each primary wrapper the same bottom-only vertical resize policy,
is to give each registered child wrapper the same bottom-only vertical resize
policy,
leave its maximum unbounded by an authored substitute, and calculate the
available desktop extent from its current position during the resize. The
viewport remains the physical limit; an old static maximum must not become a
@ -113,31 +118,31 @@ smaller limit after the shared panel moves.
acdream imports these child LayoutDesc roots independently, so each currently
has its own retained wrapper. `RetailPanelUiController` already ports retail's
one-active-child and restore-previous lifecycle. Complete that logical-parent
ownership by adding one canonical placement for the primary toolbar children
(Inventory `7`, Character `11`, Magic `13`):
ownership by adding one canonical rectangle for all registered main-panel
children, including Character Information `3`, Helpful/Harmful `4`/`5`,
Inventory `7`, Character/Skills `11`, and Magic `13`:
```text
on primary child frame moved:
canonicalPosition = moved frame position
move every other primary child frame to canonicalPosition
on registered child frame moved or resized:
canonicalGeometry = frame left, top, width, height
apply canonicalGeometry to every hidden sibling
before showing a primary child:
if canonicalPosition exists:
move requested child frame to canonicalPosition
before showing a registered child:
if canonicalGeometry exists:
apply canonicalGeometry to requested child frame
else:
canonicalPosition = requested child frame position
canonicalGeometry = requested child frame geometry
before hiding the active primary child:
capture its current position as canonicalPosition
before hiding the active child:
capture its current geometry as canonicalGeometry
```
The controller owns this synchronization and subscribes to typed
`RetailWindowHandle.Moved` events. Toolbar commands, keyboard commands,
restore-previous effects, persistence restore, and direct window dragging all
therefore pass through the same owner. Panel-authored size/resize policy stays
with the active child, but the three primary wrappers apply the shared host's
same vertical resize contract; only placement is synchronized between the
independent frames.
`RetailWindowHandle.Moved` and `Resized` events. Toolbar, indicator, and
keyboard commands, restore-previous effects, persistence restore, and direct
window dragging all therefore pass through the same owner. Each wrapper
retains its own imported content tree, but its outer rectangle and bottom-only
resize contract mirror the shared retail host.
ACE contains no client UI implementation, and the in-tree WorldBuilder
reference contains no `gmPanelUI` port, so neither supplies a competing

View file

@ -6,7 +6,8 @@ using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI).
/// Per-window controller for the Character Information child in the production
/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183).
///
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
@ -32,9 +33,14 @@ namespace AcDream.App.UI.Layout;
/// </summary>
public static class CharacterController
{
public const uint LayoutId = 0x2100006Eu;
public const uint RootId = 0x10000183u;
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
public const uint MainTextId = 0x1000011du;
public const uint ScrollbarId = 0x1000011Eu;
public const uint CloseId = 0x100000FCu;
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
@ -48,27 +54,27 @@ public static class CharacterController
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
/// <see cref="CharacterSheet"/> without re-binding.
///
/// <para>The element must resolve as a <see cref="UiText"/> (Type 12 in the dat).
/// If 0x1000011d is absent from the layout the bind is a no-op — partial layouts
/// (e.g. test fakes) don't cause errors.</para>
/// <para>Production data resolves the element as a <see cref="UiText"/>
/// (Type 12). A synthetic fallback is created only for deliberately reduced
/// test layouts.</para>
/// </summary>
/// <param name="layout">Imported 0x2100002E layout tree.</param>
/// <param name="layout">Imported 0x2100006E / 0x10000183 layout tree.</param>
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
/// (falls back to the BitmapFont debug path).</param>
public static void Bind(
ImportedLayout layout,
Func<CharacterSheet> data,
UiDatFont? datFont = null)
UiDatFont? datFont = null,
Action? close = null)
{
// Retail's gmCharacterInfoUI CREATES m_pMainText (0x1000011d) at RUNTIME — it is NOT a static
// element in any LayoutDesc (confirmed: acdream's dat-import of 0x2100002E AND 0x2100006E both
// lack it; only a runtime UI-tree capture has it). So replicate that: build the report text
// element ourselves and place it in the panel body, exactly as the gm*UI does at runtime.
// End-of-retail data authors m_pMainText under the Character Information
// child. The fallback exists only so focused controller tests can bind a
// deliberately reduced tree without fabricating production structure.
var root = layout.Root;
if (root is null) return;
var text = new UiText
UiText text = layout.FindElement(MainTextId) as UiText ?? new UiText
{
EventId = MainTextId,
Name = "m_pMainText",
@ -81,8 +87,17 @@ public static class CharacterController
DatFont = datFont,
ClickThrough = false,
};
if (text.Parent is null)
root.AddChild(text);
else if (text.DatFont is null && datFont is not null)
text.DatFont = datFont;
text.PreserveEndOnLayout = false;
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
text.LinesProvider = () => BuildReport(data());
root.AddChild(text);
if (layout.FindElement(CloseId) is UiButton closeButton)
closeButton.OnClick = close;
}
// ── Report builder ────────────────────────────────────────────────────────

View file

@ -0,0 +1,148 @@
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates retail's effect-token template <c>0x10000128</c>. This is the
/// retained equivalent of <c>InfoRegion::InfoRegion @ 0x004F1450</c> calling
/// <c>UIElement_ListBox::AddItemFromTemplateList</c> for
/// <c>EffectInfoRegion</c>.
/// </summary>
public sealed class EffectRowTemplateFactory
{
private readonly ElementInfo _template;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public EffectRowTemplateFactory(
ElementInfo template,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public float Width => _template.Width;
public float Height => _template.Height;
public static EffectRowTemplateFactory? TryLoad(
DatCollection dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
EffectsUiController.LayoutId,
EffectsUiController.RowTemplateId);
if (template is null
|| Find(template, EffectsUiController.RowIconId) is null
|| Find(template, EffectsUiController.RowLabelId) is null
|| Find(template, EffectsUiController.RowDurationId) is null
|| template.Width <= 0f
|| template.Height <= 0f)
return null;
var fonts = new Dictionary<uint, UiDatFont?>();
CaptureFonts(template, resolveFont, fonts);
return new EffectRowTemplateFactory(
template, resolveSprite, defaultFont, fonts);
}
public EffectRow Create(
uint spellId,
uint iconTexture,
string name,
string remaining)
{
ImportedLayout content = LayoutImporter.Build(
_template,
_resolveSprite,
_defaultFont,
did => _fonts.TryGetValue(did, out UiDatFont? font)
? font
: _defaultFont);
UiElement iconHost = Required(content, EffectsUiController.RowIconId);
UiText label = Required<UiText>(content, EffectsUiController.RowLabelId);
UiText duration = Required<UiText>(content, EffectsUiController.RowDurationId);
iconHost.AddChild(new UiTextureElement
{
Width = iconHost.Width,
Height = iconHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
Texture = iconTexture,
});
label.LinesProvider = () => [new UiText.Line(name, label.DefaultColor)];
var slot = new UiTemplateListSlot(
content,
spellId,
UiButtonStateMachine.Normal,
UiButtonStateMachine.Highlight);
return new EffectRow(slot, duration, remaining);
}
public sealed class EffectRow
{
private readonly UiText _duration;
private string _remaining;
internal EffectRow(
UiTemplateListSlot slot,
UiText duration,
string remaining)
{
Slot = slot;
_duration = duration;
_remaining = remaining;
_duration.LinesProvider = () =>
[new UiText.Line(_remaining, _duration.DefaultColor)];
}
public UiTemplateListSlot Slot { get; }
public string Remaining
{
get => _remaining;
set => _remaining = value;
}
}
private static T Required<T>(ImportedLayout content, uint id)
where T : UiElement
=> content.FindElement(id) as T
?? throw new InvalidOperationException(
$"Retail effect 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 effect template element 0x{id:X8} is missing.");
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found)
return found;
return null;
}
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);
}
}

View file

@ -16,16 +16,24 @@ public sealed class EffectsUiController : IRetainedPanelController
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint ScrollbarId = 0x10000124u;
public const uint InfoTextId = 0x10000126u;
public const uint RowTemplateId = 0x10000128u;
public const uint RowIconId = 0x10000129u;
public const uint RowLabelId = 0x1000012Au;
public const uint RowDurationId = 0x1000012Bu;
private readonly Spellbook _spellbook;
private readonly bool _positive;
private readonly Func<double> _serverTime;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly EffectRowTemplateFactory _templates;
private readonly string _selectPrompt;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
private readonly UiScrollbar? _scrollbar;
private readonly Dictionary<uint, EffectRowTemplateFactory.EffectRow> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
private bool _disposed;
@ -38,6 +46,8 @@ public sealed class EffectsUiController : IRetainedPanelController
bool positive,
Func<double> serverTime,
Func<uint, uint> resolveSpellIcon,
EffectRowTemplateFactory templates,
string selectPrompt,
UiItemList list,
Action? close)
{
@ -45,13 +55,18 @@ public sealed class EffectsUiController : IRetainedPanelController
_positive = positive;
_serverTime = serverTime;
_resolveSpellIcon = resolveSpellIcon;
_templates = templates;
_selectPrompt = selectPrompt;
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
_scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = Math.Max(1f, _list.Width);
_list.CellHeight = 32f;
_list.CellWidth = templates.Width;
_list.CellHeight = templates.Height;
if (_scrollbar is not null)
_scrollbar.Model = _list.Scroll;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
@ -64,6 +79,8 @@ public sealed class EffectsUiController : IRetainedPanelController
Func<double> serverTime,
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
Func<uint, uint> resolveSpellIcon,
EffectRowTemplateFactory templates,
string selectPrompt,
Action? close = null)
{
UiElement? host = layout.FindElement(ListId);
@ -82,7 +99,15 @@ public sealed class EffectsUiController : IRetainedPanelController
host.AddChild(list);
}
return new EffectsUiController(
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
layout,
spellbook,
positive,
serverTime,
resolveSpellIcon,
templates,
selectPrompt,
list,
close);
}
public void Tick()
@ -95,8 +120,10 @@ public sealed class EffectsUiController : IRetainedPanelController
return;
_lastDurationUpdate = now;
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
row.Detail = FormatRemaining(enchantment, now);
if (_rows.TryGetValue(
enchantment.Identity,
out EffectRowTemplateFactory.EffectRow? row))
row.Remaining = FormatRemaining(enchantment, now);
}
private void Rebuild()
@ -111,22 +138,18 @@ public sealed class EffectsUiController : IRetainedPanelController
{
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
uint identity = enchantment.Identity;
var row = new UiCatalogSlot
{
EntryId = enchantment.SpellId,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
Detail = FormatRemaining(enchantment, _serverTime()),
ShowLabel = true,
SpriteResolve = _list.SpriteResolve,
};
row.Clicked = () => Select(enchantment.SpellId);
EffectRowTemplateFactory.EffectRow row = _templates.Create(
enchantment.SpellId,
metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
metadata?.Name ?? $"Spell {enchantment.SpellId}",
FormatRemaining(enchantment, _serverTime()));
row.Slot.Clicked = () => Select(enchantment.SpellId);
_rows[identity] = row;
_list.AddItem(row);
_list.AddItem(row.Slot);
}
}
if (_selectedSpellId is uint selected
&& !_rows.Values.Any(row => row.EntryId == selected))
&& !_rows.Values.Any(row => row.Slot.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
}
@ -145,7 +168,9 @@ public sealed class EffectsUiController : IRetainedPanelController
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
{
if (enchantment.Duration < 0) return "Permanent";
// EffectInfoRegion::Update @ 0x004F1C00 starts from retail's empty
// PString and only formats it when the remaining duration is >= 0.
if (enchantment.Duration < 0) return string.Empty;
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
if (!double.IsFinite(remaining)) return "--:--";
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
@ -165,25 +190,27 @@ public sealed class EffectsUiController : IRetainedPanelController
private void SyncSelection()
{
foreach (UiCatalogSlot row in _rows.Values)
row.Selected = row.EntryId == _selectedSpellId;
foreach (EffectRowTemplateFactory.EffectRow row in _rows.Values)
row.Slot.SetSelected(row.Slot.EntryId == _selectedSpellId);
}
private void ConfigureInfo()
{
if (_info is null) return;
_info.PreserveEndOnLayout = false;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return Array.Empty<UiText.Line>();
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return Array.Empty<UiText.Line>();
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(string.Empty, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
};
@ -197,5 +224,6 @@ public sealed class EffectsUiController : IRetainedPanelController
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
if (_scrollbar is not null) _scrollbar.Model = null;
}
}

View file

@ -87,7 +87,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
_harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects);
_link.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.LinkStatus);
_vitae.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Vitae);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.CharacterInformation);
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession;

View file

@ -21,8 +21,8 @@ public sealed class RetailPanelUiController : IDisposable
private uint? _activePanel;
private uint? _deferredPanel;
private bool _applying;
private bool _synchronizingPlacement;
private PanelPlacement? _mainPanelPlacement;
private bool _synchronizingGeometry;
private PanelGeometry? _mainPanelGeometry;
private bool _disposed;
public RetailPanelUiController(
@ -38,11 +38,11 @@ public sealed class RetailPanelUiController : IDisposable
public uint? ActivePanelId => _activePanel;
/// <summary>
/// Registers one of retail <c>gmPanelUI</c>'s primary toolbar children.
/// Inventory, Character, and Magic retain panel-specific content/resize
/// policy but share the one parent placement. A drag of any registered
/// child updates every hidden sibling through typed window handles, so
/// persistence observes the same canonical position too.
/// Registers one of retail <c>gmPanelUI</c>'s toolbar or detail children.
/// All primary and detail children retain panel-specific content but share
/// one parent geometry. A move or resize of any registered child updates
/// every hidden sibling through typed window handles, so persistence sees
/// the same canonical rectangle too.
/// </summary>
public void RegisterMainPanel(
uint panelId,
@ -61,7 +61,7 @@ public sealed class RetailPanelUiController : IDisposable
windowName,
restorePrevious,
window,
sharesMainPanelPlacement: true);
sharesMainPanelGeometry: true);
}
/// <param name="restorePrevious">
@ -75,14 +75,14 @@ public sealed class RetailPanelUiController : IDisposable
windowName,
restorePrevious,
window: null,
sharesMainPanelPlacement: false);
sharesMainPanelGeometry: false);
private void RegisterCore(
uint panelId,
string windowName,
bool restorePrevious,
RetailWindowHandle? window,
bool sharesMainPanelPlacement)
bool sharesMainPanelGeometry)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
@ -95,14 +95,17 @@ public sealed class RetailPanelUiController : IDisposable
windowName,
restorePrevious,
window,
sharesMainPanelPlacement);
sharesMainPanelGeometry);
_byPanel.Add(panelId, entry);
_byWindow.Add(windowName, panelId);
if (window is not null)
{
window.Moved += OnWindowMoved;
window.Resized += OnWindowResized;
}
if (sharesMainPanelPlacement && _mainPanelPlacement is { } placement)
MoveWindowTo(entry, placement);
if (sharesMainPanelGeometry && _mainPanelGeometry is { } geometry)
ApplyWindowGeometry(entry, geometry);
if (_isVisible(windowName))
ObserveWindowVisibility(windowName, visible: true);
}
@ -144,7 +147,7 @@ public sealed class RetailPanelUiController : IDisposable
{
if (_activePanel == panelId)
{
PrepareMainPanelPlacement(requested);
PrepareMainPanelGeometry(requested);
return _show(requested.WindowName);
}
@ -156,7 +159,7 @@ public sealed class RetailPanelUiController : IDisposable
: null;
if (previous is { } previousEntry)
CaptureMainPanelPlacement(previousEntry, synchronizeSiblings: true);
CaptureMainPanelGeometry(previousEntry, synchronizeSiblings: true);
_deferredPanel = requested.RestorePrevious
&& previous is { RestorePrevious: false }
@ -165,7 +168,7 @@ public sealed class RetailPanelUiController : IDisposable
_activePanel = panelId;
if (previous is not null)
_hide(previous.Value.WindowName);
PrepareMainPanelPlacement(requested);
PrepareMainPanelGeometry(requested);
return _show(requested.WindowName);
}
@ -175,7 +178,7 @@ public sealed class RetailPanelUiController : IDisposable
return _hide(requested.WindowName);
}
CaptureMainPanelPlacement(requested, synchronizeSiblings: true);
CaptureMainPanelGeometry(requested, synchronizeSiblings: true);
bool hidden = _hide(requested.WindowName);
_activePanel = null;
if (_deferredPanel is not uint deferredId
@ -184,7 +187,7 @@ public sealed class RetailPanelUiController : IDisposable
_deferredPanel = null;
_activePanel = deferredId;
PrepareMainPanelPlacement(deferred);
PrepareMainPanelGeometry(deferred);
return _show(deferred.WindowName) || hidden;
}
finally
@ -205,76 +208,109 @@ public sealed class RetailPanelUiController : IDisposable
private void OnWindowMoved(RetailWindowHandle window)
{
if (_disposed || _synchronizingPlacement) return;
if (_disposed || _synchronizingGeometry) return;
foreach (PanelEntry entry in _byPanel.Values)
{
if (!entry.SharesMainPanelPlacement
if (!entry.SharesMainPanelGeometry
|| !ReferenceEquals(entry.Window, window))
continue;
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
SynchronizeMainPanelSiblings(window);
CaptureAndSynchronizeMainPanelGeometry(window);
return;
}
}
private void CaptureMainPanelPlacement(
private void OnWindowResized(RetailWindowHandle window)
{
if (_disposed || _synchronizingGeometry) return;
foreach (PanelEntry entry in _byPanel.Values)
{
if (!entry.SharesMainPanelGeometry
|| !ReferenceEquals(entry.Window, window))
continue;
CaptureAndSynchronizeMainPanelGeometry(window);
return;
}
}
private void CaptureAndSynchronizeMainPanelGeometry(RetailWindowHandle source)
{
_mainPanelGeometry = new PanelGeometry(
source.Left,
source.Top,
source.Width,
source.Height);
SynchronizeMainPanelSiblings(source);
}
private void CaptureMainPanelGeometry(
PanelEntry entry,
bool synchronizeSiblings)
{
if (!entry.SharesMainPanelPlacement || entry.Window is not { } window)
if (!entry.SharesMainPanelGeometry || entry.Window is not { } window)
return;
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
_mainPanelGeometry = new PanelGeometry(
window.Left,
window.Top,
window.Width,
window.Height);
if (synchronizeSiblings)
SynchronizeMainPanelSiblings(window);
}
private void PrepareMainPanelPlacement(PanelEntry entry)
private void PrepareMainPanelGeometry(PanelEntry entry)
{
if (!entry.SharesMainPanelPlacement || entry.Window is not { } window)
if (!entry.SharesMainPanelGeometry || entry.Window is not { } window)
return;
if (_mainPanelPlacement is not { } placement)
if (_mainPanelGeometry is not { } geometry)
{
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
_mainPanelGeometry = new PanelGeometry(
window.Left,
window.Top,
window.Width,
window.Height);
SynchronizeMainPanelSiblings(window);
return;
}
MoveWindowTo(entry, placement);
ApplyWindowGeometry(entry, geometry);
}
private void SynchronizeMainPanelSiblings(RetailWindowHandle source)
{
if (_mainPanelPlacement is not { } placement) return;
if (_mainPanelGeometry is not { } geometry) return;
_synchronizingPlacement = true;
_synchronizingGeometry = true;
try
{
foreach (PanelEntry sibling in _byPanel.Values)
{
if (!sibling.SharesMainPanelPlacement
if (!sibling.SharesMainPanelGeometry
|| sibling.Window is not { } window
|| ReferenceEquals(window, source))
continue;
MoveWindowTo(sibling, placement);
ApplyWindowGeometry(sibling, geometry);
}
}
finally
{
_synchronizingPlacement = false;
_synchronizingGeometry = false;
}
}
private void MoveWindowTo(PanelEntry entry, PanelPlacement placement)
private static void ApplyWindowGeometry(PanelEntry entry, PanelGeometry geometry)
{
if (entry.Window is not { } window
|| (window.Left == placement.Left && window.Top == placement.Top))
return;
if (entry.Window is not { } window) return;
window.MoveTo(placement.Left, placement.Top);
if (window.Left != geometry.Left || window.Top != geometry.Top)
window.MoveTo(geometry.Left, geometry.Top);
if (window.Width != geometry.Width || window.Height != geometry.Height)
window.ResizeTo(geometry.Width, geometry.Height);
}
public void Dispose()
@ -283,14 +319,21 @@ public sealed class RetailPanelUiController : IDisposable
_disposed = true;
foreach (PanelEntry entry in _byPanel.Values)
if (entry.Window is { } window)
{
window.Moved -= OnWindowMoved;
window.Resized -= OnWindowResized;
}
}
private readonly record struct PanelPlacement(float Left, float Top);
private readonly record struct PanelGeometry(
float Left,
float Top,
float Width,
float Height);
private readonly record struct PanelEntry(
string WindowName,
bool RestorePrevious,
RetailWindowHandle? Window,
bool SharesMainPanelPlacement);
bool SharesMainPanelGeometry);
}

View file

@ -7,6 +7,7 @@ namespace AcDream.App.UI;
/// </summary>
public static class RetailPanelCatalog
{
public const uint CharacterInformation = 3u;
public const uint PositiveEffects = 4u;
public const uint NegativeEffects = 5u;
public const uint Inventory = 7u;
@ -18,6 +19,7 @@ public static class RetailPanelCatalog
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(CharacterInformation, WindowNames.CharacterInformation),
(PositiveEffects, WindowNames.PositiveEffects),
(NegativeEffects, WindowNames.NegativeEffects),
(Inventory, WindowNames.Inventory),

View file

@ -847,6 +847,8 @@ public sealed class RetailUiRuntime : IDisposable
uint rootId = positive ? EffectsUiController.PositiveRootId : EffectsUiController.NegativeRootId;
ElementInfo? rootInfo;
ImportedLayout? layout;
EffectRowTemplateFactory? rowTemplates;
string selectPrompt;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
@ -861,10 +863,20 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
rowTemplates = EffectRowTemplateFactory.TryLoad(
_bindings.Assets.Dats,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
var strings = new DatStringResolver(_bindings.Assets.Dats);
selectPrompt = strings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Effects_Info_SelectASpell"))
?? "SELECT A SPELL";
}
if (rootInfo is null || layout is null)
if (rootInfo is null || layout is null || rowTemplates is null)
{
Console.WriteLine($"[M3] effects: root 0x{rootId:X8} not found.");
Console.WriteLine($"[M3] effects: root or row template for 0x{rootId:X8} not found.");
return;
}
@ -875,6 +887,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.ServerTime,
_bindings.Assets.ResolveSprite,
_bindings.Magic.ResolveSpellIcon,
rowTemplates,
selectPrompt,
close: () => CloseWindow(
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects));
if (controller is null)
@ -893,14 +907,17 @@ public sealed class RetailUiRuntime : IDisposable
new RetailWindowFrame.Options
{
WindowName = positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
Chrome = RetailWindowChrome.Imported,
Chrome = RetailWindowChrome.NineSlice,
Left = Math.Max(0f, Host.Root.Width - root.Width - 12f),
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
Controller = controller,
});
@ -919,6 +936,41 @@ public sealed class RetailUiRuntime : IDisposable
MountLinkStatusPanel();
MountVitaePanel();
MountMiniGamePanel();
MountCharacterInformationPanel();
}
private void MountCharacterInformationPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
CharacterController.LayoutId,
CharacterController.RootId);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
new DatStringResolver(_bindings.Assets.Dats).Resolve);
}
if (rootInfo is null || layout is null) return;
CharacterController.Bind(
layout,
_bindings.Character.Provider.BuildSheet,
_bindings.Assets.DefaultFont,
() => CloseWindow(WindowNames.CharacterInformation));
RegisterIndicatorDetailPanel(
RetailPanelCatalog.CharacterInformation,
WindowNames.CharacterInformation,
rootInfo,
layout.Root,
controller: null);
}
private void MountLinkStatusPanel()
@ -1054,7 +1106,7 @@ public sealed class RetailUiRuntime : IDisposable
string windowName,
ElementInfo rootInfo,
UiElement root,
IRetainedPanelController controller)
IRetainedPanelController? controller)
{
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
@ -1063,14 +1115,17 @@ public sealed class RetailUiRuntime : IDisposable
new RetailWindowFrame.Options
{
WindowName = windowName,
Chrome = RetailWindowChrome.Imported,
Chrome = RetailWindowChrome.NineSlice,
Left = 18f,
Top = 18f,
Visible = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
Controller = controller,
});

View file

@ -152,6 +152,13 @@ public sealed class UiText : UiElement, IUiDatStateful
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new();
/// <summary>
/// Keeps a view that is already at the end pinned there when content or
/// geometry changes. Chat uses the default; top-oriented reports such as
/// Character Information disable it.
/// </summary>
public bool PreserveEndOnLayout { get; set; } = true;
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
@ -448,7 +455,7 @@ public sealed class UiText : UiElement, IUiDatStateful
Scroll.SetExtents(
(int)MathF.Ceiling(contentH),
(int)MathF.Floor(innerH),
preserveEnd: true);
preserveEnd: PreserveEndOnLayout);
// Overflow keeps the UiScrollable convention: ScrollY=0 is TOP/oldest and
// ScrollY=MaxScroll is BOTTOM/newest. Fitting DAT-authored content instead

View file

@ -7,6 +7,7 @@ public static class WindowNames
public const string Vitals = "vitals";
public const string Toolbar = "toolbar";
public const string Character = "character";
public const string CharacterInformation = "character-information";
public const string Inventory = "inventory";
public const string Chat = "chat";
public const string Radar = "radar";

View file

@ -6,12 +6,37 @@ namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit tests for <see cref="CharacterController"/> and <see cref="SampleData.SampleCharacter"/>.
/// The controller CREATES the m_pMainText report element (0x1000011d) at bind time — retail's
/// gm*UI builds it at runtime; it is not a static dat element — and attaches it to the layout root.
/// No dats, no GL — pure data-wiring + report-content tests.
/// Production Character Information data authors m_pMainText (0x1000011d);
/// reduced unit-test layouts exercise the controller's fallback element.
/// No GL — pure data-wiring + report-content tests.
/// </summary>
public class CharacterControllerTests
{
[Fact]
public void AuthoredCharacterInformationPanel_BindsTextScrollbarAndClose()
{
ImportedLayout layout = FixtureLoader.LoadCharacterInformation();
int closes = 0;
CharacterController.Bind(
layout, SampleData.SampleCharacter, close: () => closes++);
Assert.Equal(CharacterController.RootId, layout.Root.DatElementId);
Assert.Equal((300f, 362f), (layout.Root.Width, layout.Root.Height));
UiText text = Assert.IsType<UiText>(
layout.FindElement(CharacterController.MainTextId));
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(CharacterController.ScrollbarId));
Assert.Same(text.Scroll, scrollbar.Model);
Assert.False(text.PreserveEndOnLayout);
Assert.Contains("Studio Player", text.LinesProvider().Select(line => line.Text));
UiButton close = Assert.IsType<UiButton>(
layout.FindElement(CharacterController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
}
/// <summary>Bind on an empty layout, then return the created m_pMainText element.</summary>
private static UiText BindAndGetText(System.Func<CharacterSheet> data)
{

View file

@ -7,6 +7,28 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class EffectsUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
private static EffectRowTemplateFactory Templates()
=> new(FixtureLoader.LoadEffectRowTemplateInfos(), NoTex, defaultFont: null);
[Fact]
public void AuthoredEffectRowTemplate_HasRetailIconNameDurationAndSelectionStates()
{
ElementInfo row = FixtureLoader.LoadEffectRowTemplateInfos();
Assert.Equal(EffectsUiController.RowTemplateId, row.Id);
Assert.Equal((300f, 32f), (row.Width, row.Height));
Assert.Contains(UiButtonStateMachine.Normal, row.States.Keys);
Assert.Contains(UiButtonStateMachine.Highlight, row.States.Keys);
Assert.Equal((0f, 32f),
(FindInfo(row, EffectsUiController.RowIconId)!.X,
FindInfo(row, EffectsUiController.RowIconId)!.Width));
Assert.Equal((37f, 188f),
(FindInfo(row, EffectsUiController.RowLabelId)!.X,
FindInfo(row, EffectsUiController.RowLabelId)!.Width));
Assert.Equal((225f, 50f),
(FindInfo(row, EffectsUiController.RowDurationId)!.X,
FindInfo(row, EffectsUiController.RowDurationId)!.Width));
}
[Fact]
public void Rebuild_DoesNotAutoSelectFirstEffect_AndClearsRemovedSelection()
@ -25,8 +47,10 @@ public sealed class EffectsUiControllerTests
[EffectsUiController.InfoTextId] = info,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id)!;
layout, spellbook, positive: true, () => 0d, NoTex, id => id,
Templates(), "SELECT A SPELL")!;
Assert.False(info.PreserveEndOnLayout);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 42u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
@ -34,10 +58,15 @@ public sealed class EffectsUiControllerTests
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 3u, 60f, 1u, Bucket: 8u, SpellCategory: 42u));
Assert.Null(controller.SelectedSpellId);
Assert.Equal("SELECT A SPELL", Assert.Single(info.LinesProvider()).Text);
Assert.Equal(1, list.GetNumUIItems());
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiTemplateListSlot row = Assert.IsType<UiTemplateListSlot>(list.GetItem(0));
UiText duration = Assert.IsType<UiText>(
row.Content.FindElement(EffectsUiController.RowDurationId));
Assert.Equal("1:00", Assert.Single(duration.LinesProvider()).Text);
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
Assert.Equal(42u, controller.SelectedSpellId);
Assert.Equal(["Boon", "", ""], info.LinesProvider().Select(line => line.Text));
spellbook.OnEnchantmentRemoved(1u, 42u);
Assert.Null(controller.SelectedSpellId);
@ -57,14 +86,15 @@ public sealed class EffectsUiControllerTests
[EffectsUiController.ListId] = list,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id)!;
layout, spellbook, positive: true, () => 0d, NoTex, id => id,
Templates(), "SELECT A SPELL")!;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60f, 1u, Bucket: 1u, SpellCategory: 100u));
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 2u, 60f, 2u, Bucket: 2u, SpellCategory: 101u));
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
UiTemplateListSlot first = Assert.IsType<UiTemplateListSlot>(list.GetItem(0));
UiTemplateListSlot second = Assert.IsType<UiTemplateListSlot>(list.GetItem(1));
first.OnEvent(new UiEvent(0, first, UiEventType.Click));
Assert.Equal(42u, controller.SelectedSpellId);
@ -77,6 +107,32 @@ public sealed class EffectsUiControllerTests
Assert.False(second.Selected);
}
[Fact]
public void PermanentEffect_LeavesRetailDurationColumnEmpty()
{
var table = SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Boon,0x4\n"));
var spellbook = new Spellbook(table);
var root = new UiPanel { Width = 160f, Height = 100f };
var list = new UiItemList(NoTex) { Width = 160f, Height = 64f };
root.AddChild(list);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[EffectsUiController.ListId] = list,
});
using EffectsUiController controller = EffectsUiController.Bind(
layout, spellbook, positive: true, () => 0d, NoTex, id => id,
Templates(), "SELECT A SPELL")!;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, -1f, 1u, Bucket: 1u, SpellCategory: 42u));
UiTemplateListSlot row = Assert.IsType<UiTemplateListSlot>(list.GetItem(0));
UiText duration = Assert.IsType<UiText>(
row.Content.FindElement(EffectsUiController.RowDurationId));
Assert.Equal(string.Empty, Assert.Single(duration.LinesProvider()).Text);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
@ -117,11 +173,13 @@ public sealed class EffectsUiControllerTests
int closes = 0;
using EffectsUiController? controller = EffectsUiController.Bind(
layout, spellbook, positive, () => 0d, NoTex, id => id, () => closes++);
layout, spellbook, positive, () => 0d, NoTex, id => id,
Templates(), "SELECT A SPELL", () => closes++);
Assert.NotNull(controller);
Assert.NotNull(layout.FindElement(EffectsUiController.ListId));
Assert.IsType<UiText>(layout.FindElement(EffectsUiController.InfoTextId));
Assert.IsType<UiScrollbar>(layout.FindElement(EffectsUiController.ScrollbarId));
Assert.Equal(expectedRoot, layout.Root.DatElementId);
UiButton close = Assert.IsType<UiButton>(layout.FindElement(EffectsUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));

View file

@ -137,6 +137,16 @@ public static class FixtureLoader
public static ElementInfo LoadNegativeEffectsInfos()
=> LoadInfos("effects_negative_2100001B.json");
public static ElementInfo LoadEffectRowTemplateInfos()
=> LoadInfos("effects_row_2100001B_10000128.json");
public static ImportedLayout LoadCharacterInformation()
=> LayoutImporter.Build(
LoadCharacterInformationInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadCharacterInformationInfos()
=> LoadInfos("character_info_2100006E_10000183.json");
public static ImportedLayout LoadIndicators()
=> LayoutImporter.Build(LoadIndicatorsInfos(), _ => (0u, 0, 0), null);

View file

@ -98,7 +98,7 @@ public sealed class IndicatorBarControllerTests
burden.OnEvent(new UiEvent(0, burden, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.Character], h.ToggledPanels);
Assert.Equal([RetailPanelCatalog.CharacterInformation], h.ToggledPanels);
}
[Theory]

View file

@ -64,6 +64,11 @@ public sealed class RetailLayoutFixtureGenerator
Assert.Equal(expected, localStrings.Resolve(
0x23000001u, DatStringResolver.ComputeHash(key)));
}
Assert.Equal(
"SELECT A SPELL",
localStrings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Effects_Info_SelectASpell")));
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
@ -120,6 +125,25 @@ public sealed class RetailLayoutFixtureGenerator
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
}
foreach ((uint layoutId, uint rootId, string fileName) in new[]
{
(EffectsUiController.LayoutId, EffectsUiController.RowTemplateId,
"effects_row_2100001B_10000128.json"),
(CharacterController.LayoutId, CharacterController.RootId,
"character_info_2100006E_10000183.json"),
})
{
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
Assert.NotNull(panelPart);
File.WriteAllText(
Path.Combine(FixtureDirectory(), fileName),
JsonSerializer.Serialize(panelPart, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
}));
}
ElementInfo? miniGame = LayoutImporter.ImportInfos(
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
Assert.NotNull(miniGame);

View file

@ -79,9 +79,9 @@ public sealed class RetailPanelUiControllerTests
public void MainPanels_ShareOneCanonicalPlacementAcrossMoveCloseAndSwitch()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f);
RetailWindowHandle spellbook = Mount(root, WindowNames.Spellbook, 18f, 18f);
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f, 360f);
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f, 500f);
RetailWindowHandle spellbook = Mount(root, WindowNames.Spellbook, 18f, 18f, 420f);
using var controller = Create(root);
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
controller.RegisterMainPanel(11u, WindowNames.Character, character);
@ -94,9 +94,12 @@ public sealed class RetailPanelUiControllerTests
controller.SetPanelVisibility(7u, visible: true);
inventory.MoveTo(312f, 147f);
inventory.ResizeTo(310f, 540f);
Assert.Equal((312f, 147f), (character.Left, character.Top));
Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top));
Assert.Equal((310f, 540f), (character.Width, character.Height));
Assert.Equal((310f, 540f), (spellbook.Width, spellbook.Height));
Assert.True(characterMoves > 0);
Assert.True(spellbookMoves > 0);
@ -104,12 +107,14 @@ public sealed class RetailPanelUiControllerTests
Assert.False(inventory.IsVisible);
Assert.True(character.IsVisible);
Assert.Equal((312f, 147f), (character.Left, character.Top));
Assert.Equal((310f, 540f), (character.Width, character.Height));
controller.SetPanelVisibility(11u, visible: false);
controller.SetPanelVisibility(13u, visible: true);
Assert.False(character.IsVisible);
Assert.True(spellbook.IsVisible);
Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top));
Assert.Equal((310f, 540f), (spellbook.Width, spellbook.Height));
}
[Fact]
@ -130,15 +135,18 @@ public sealed class RetailPanelUiControllerTests
controller.SetPanelVisibility(7u, visible: true);
inventory.MoveTo(280f, 120f);
inventory.ResizeTo(310f, 600f);
controller.SetPanelVisibility(4u, visible: true);
Assert.Equal((280f, 120f), (effect.Left, effect.Top));
Assert.Equal((310f, 600f), (effect.Width, effect.Height));
Assert.False(inventory.IsVisible);
Assert.True(effect.IsVisible);
controller.SetPanelVisibility(4u, visible: false);
Assert.True(inventory.IsVisible);
Assert.Equal((280f, 120f), (inventory.Left, inventory.Top));
Assert.Equal((310f, 600f), (inventory.Width, inventory.Height));
controller.SetPanelVisibility(11u, visible: true);
Assert.Equal((280f, 120f), (character.Left, character.Top));
@ -165,9 +173,10 @@ public sealed class RetailPanelUiControllerTests
UiRoot root,
string name,
float left,
float top)
float top,
float contentHeight = 500f)
{
var content = new UiPanel { Width = 300f, Height = 500f };
var content = new UiPanel { Width = 300f, Height = contentHeight };
return RetailWindowFrame.Mount(
root,
content,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,961 @@
{
"Id": 268435752,
"Type": 3,
"X": 0,
"Y": 0,
"Width": 300,
"Height": 32,
"OriginalParentWidth": 0,
"OriginalParentHeight": 0,
"HasOriginalParentSize": false,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 2,
"ReadOrder": 3,
"ZLevel": 0,
"States": {
"4294967295": {
"Id": 4294967295,
"Name": "",
"PassToChildren": false,
"IncorporationFlags": 30,
"Image": null,
"Cursor": null,
"Properties": {
"Values": {}
}
},
"1": {
"Id": 1,
"Name": "Normal",
"PassToChildren": false,
"IncorporationFlags": 0,
"Image": {
"File": 100668310,
"DrawMode": 1
},
"Cursor": null,
"Properties": {
"Values": {}
}
},
"6": {
"Id": 6,
"Name": "Highlight",
"PassToChildren": false,
"IncorporationFlags": 0,
"Image": {
"File": 100668311,
"DrawMode": 1
},
"Cursor": null,
"Properties": {
"Values": {}
}
}
},
"DefaultStateId": 1,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"Normal": {
"Item1": 100668310,
"Item2": 1
},
"Highlight": {
"Item1": 100668311,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "Normal",
"Children": [
{
"Id": 268435753,
"Type": 3,
"X": 0,
"Y": 0,
"Width": 32,
"Height": 32,
"OriginalParentWidth": 300,
"OriginalParentHeight": 32,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 2,
"Bottom": 2,
"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": 268435754,
"Type": 12,
"X": 37,
"Y": 0,
"Width": 188,
"Height": 32,
"OriginalParentWidth": 300,
"OriginalParentHeight": 32,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 1,
"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": {}
},
"199": {
"Kind": 5,
"MasterPropertyId": 199,
"UnsignedValue": 0,
"IntegerValue": 0,
"FloatValue": 0,
"BoolValue": false,
"StringInfoValue": {
"Token": 0,
"StringId": 58579907,
"TableId": 587202561,
"Override": 0,
"English": 1,
"Comment": 0
},
"ColorValue": {
"Blue": 0,
"Green": 0,
"Red": 0,
"Alpha": 0
},
"VectorValue": {
"X": 0,
"Y": 0,
"Z": 0
},
"ArrayValue": [],
"StructValue": {}
},
"71": {
"Kind": 0,
"MasterPropertyId": 71,
"UnsignedValue": 268436373,
"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": {}
},
"72": {
"Kind": 2,
"MasterPropertyId": 72,
"UnsignedValue": 553648193,
"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": {}
},
"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": {}
},
"208": {
"Kind": 1,
"MasterPropertyId": 208,
"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": 1073741825,
"HJustify": 0,
"VJustify": 1,
"FontColor": {
"X": 1,
"Y": 1,
"Z": 1,
"W": 1
},
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435755,
"Type": 12,
"X": 225,
"Y": 0,
"Width": 50,
"Height": 32,
"OriginalParentWidth": 300,
"OriginalParentHeight": 32,
"HasOriginalParentSize": true,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 1,
"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": 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": 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": {}
}
}
}
},
"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": 2,
"VJustify": 1,
"FontColor": {
"X": 1,
"Y": 1,
"Z": 1,
"W": 1
},
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
}
]
}