From 306a1670d34d5644d85e4e6857bfc05e2351d45b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:34:56 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(ui):=20vitals=20=E2=80=94=20click=20to?= =?UTF-8?q?ggles=20retail's=20numeric/graphical=20detail=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of gmVitalsUI's press toggle, derived end-to-end from the named retail decomp + the authored DAT data (installed-DAT probe 2026-08-17): - gmVitalsUI::ListenToElementMessage @0x004BFC00: mouse press (msg 0x1C, dwParam1 7=left or 0xA=right — the same param pair the spellbook's select/favorite handler @0x0048C033 disambiguates) flips SetState(m_state == HideDetail ? ShowDetail : HideDetail). Both floaty subclasses (gmFloatyVitalsUI 0x1000004D / gmFloatySideVitalsUI 0x10000056) inherit it verbatim. - UIElement::SetState @0x00464E70 cascades through the authored PassToChildren chain: root and meters author media-less HideDetail/ShowDetail StateDescs with PassToChildren=true. - HideDetail (0x10000006) = the NUMERIC mode: the cur/max labels author {0x3B:false} (0x3B = invisible; UIElement::OnSetAttribute case 8 @0x00462DAE is SetVisible(value == 0)), the 0x100004A9 overlays author File=0. - ShowDetail (0x10000007) = the GRAPHICAL mode: labels author {0x3B:true} (numbers hidden); each bar shows its authored icon pair — dim back icon unclipped over the track, bright front icon clipped with the front container to the fill fraction (UIElement_Meter::DrawChildren @0x0046FBD0 clips the whole element-id-2 child; m_pcChildImage = GetChildRecursive(this, 2) @0x0046F7E3). Health heart 0x06007490/91 (18x16 @66,0), stamina sword 0x06007492/93 (85x16 @32,0), mana scepter 0x06007494/95 (100x16 @25,0) — identical authoring in both 0x2100006C and 0x21000075. - Initial state is the authored Undef (numbers visible, no icons — visually HideDetail); retail's first press lands on HideDetail, then the pair toggles forever. NOT persisted: SaveScreenLayout @0x004EAD50 writes window rects only, and no PlayerModule option is touched — the mode resets per session, per window. - Presses on drag bars / resize grips do not toggle: retail's UIElement_Dragbar @0x0046C850 and UIElement_Resizebar @0x0046B930 consume the press (return 2) before it can bubble to the root. Implementation: new UiVitalsRoot behavioral widget registered for the three gmVitals class ids (press handler + state flip over the existing UiDatElement state machine); UiMeter absorbs the two 0x100004A9 overlays (ConfigureDetailOverlay + ShowDetail-keyed draw, back unclipped / front fill-clipped) and forwards the detail states to its absorbed text child; UiText.ApplyDatState gains the same named-state-only 0x3B honor UiDatElement already had (the DirectState 0x3B class stays gated — #408). 8 new fixture-driven conformance tests (toggle sequence, right-press, label cascade, chrome exclusions, per-window independence, overlay extraction). App suite Release live-DAT: 5495 passed / 3 skips. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 67 +++++- src/AcDream.App/UI/Layout/LayoutImporter.cs | 12 +- src/AcDream.App/UI/Layout/UiVitalsRoot.cs | 95 +++++++++ src/AcDream.App/UI/UiMeter.cs | 101 +++++++++ src/AcDream.App/UI/UiText.cs | 16 ++ .../UI/Layout/VitalsDetailToggleTests.cs | 199 ++++++++++++++++++ 6 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/UiVitalsRoot.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 2a636103..a037ed75 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -27,10 +27,12 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// The expand-detail overlay present in the front container carries ONLY named -/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the -/// TryGetValue("") filter in excludes it -/// automatically. +/// The expand-detail icon overlay present in EACH container (back + front) +/// carries ONLY named states ("HideDetail"/"ShowDetail") — no "" DirectState +/// entry — so the TryGetValue("") filter in +/// excludes it from slice extraction; then absorbs +/// it into for the retail +/// HideDetail/ShowDetail click toggle. /// /// public static class DatWidgetFactory @@ -76,6 +78,15 @@ public static class DatWidgetFactory UiElement e = info.Type switch { UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) + // The vitals window roots — gmVitalsUI (0x10000009 @0x004BFE10), + // gmFloatyVitalsUI (0x1000004D, stacked 0x2100006C), and + // gmFloatySideVitalsUI (0x10000056, side-by-side 0x21000075). All + // three share the inherited HideDetail/ShowDetail press toggle + // (gmVitalsUI::ListenToElementMessage @0x004BFC00) — see UiVitalsRoot. + UiVitalsRoot.GmVitalsClassId + or UiVitalsRoot.GmFloatyVitalsClassId + or UiVitalsRoot.GmFloatySideVitalsClassId + => new UiVitalsRoot(info, resolve), 1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button 2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840) { @@ -446,8 +457,11 @@ public static class DatWidgetFactory /// │ ├── left-cap image (→ front-left sprite) /// │ ├── center image (→ front-tile sprite) /// │ ├── right-cap image (→ front-right sprite) - /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) - /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController) + /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — + /// │ absorbed as the bright fill-clipped detail icon; the back + /// │ container has a matching dim one — see ConfigureDetailOverlay) + /// └── text label (Type 0) (built as a real UiText child by LayoutImporter's meter + /// carve-out; Fill/Label providers bound by VitalsController) /// /// /// @@ -508,6 +522,35 @@ public static class DatWidgetFactory m.FrontLeft = fl; m.FrontTile = ft; m.FrontRight = fr; + + // The expand-detail icon overlays (the 0x100004A9 child of EACH + // container — dim icon in the back track, bright icon in the + // fill-clipped front layer; health heart 0x06007490/91, stamina + // sword 0x06007492/93, mana scepter 0x06007494/95). They author + // media only for the named ShowDetail state (HideDetail authors + // File=0), which is why SliceIds' DirectState filter already + // excludes them from the slice extraction above. Absorb them into + // the meter alongside the slices so the HideDetail/ShowDetail + // click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00) + // can draw retail's graphical vitals mode. PassToChildren on the + // meter's own media-less HideDetail/ShowDetail StateDescs gates + // the cascade to the absorbed number label + // (UIElement::SetState @0x00464E70). + ElementInfo? backOverlay = DetailOverlay(containers[0]); + ElementInfo? frontOverlay = DetailOverlay(containers[1]); + if (backOverlay is not null || frontOverlay is not null) + { + bool passToChildren = info.States.Values.Any( + static s => s.Name is "HideDetail" or "ShowDetail" && s.PassToChildren); + m.ConfigureDetailOverlay( + backOverlay is not null ? backOverlay.StateMedia["ShowDetail"].File : 0u, + backOverlay?.X ?? 0f, backOverlay?.Y ?? 0f, + backOverlay?.Width ?? 0f, backOverlay?.Height ?? 0f, + frontOverlay is not null ? frontOverlay.StateMedia["ShowDetail"].File : 0u, + frontOverlay?.X ?? 0f, frontOverlay?.Y ?? 0f, + frontOverlay?.Width ?? 0f, frontOverlay?.Height ?? 0f, + passToChildren); + } } else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey("")) { @@ -617,6 +660,18 @@ public static class DatWidgetFactory => container.Children.Count(c => c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3; + /// + /// Finds a container's expand-detail icon overlay: the child that authors a + /// non-zero image for the named ShowDetail state and NO DirectState image + /// (the 0x100004A9 shape in both vitals layouts 0x2100006C / 0x21000075). + /// Returns null when the container has none (non-vitals meters). + /// + private static ElementInfo? DetailOverlay(ElementInfo container) + => container.Children.FirstOrDefault(static c => + !c.StateMedia.ContainsKey("") + && c.StateMedia.TryGetValue("ShowDetail", out var media) + && media.File != 0); + private static bool HasStatefulFill(ElementInfo container) => container.States.Any(pair => pair.Key != UiStateInfo.DirectStateId diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 32cbaa16..6bc56c04 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -181,11 +181,15 @@ public static class LayoutImporter // widgets via FindElement and bind LinesProvider without injecting new runtime nodes. // // Type-3 children are SKIPPED here because BuildMeter already consumed them (they - // carry the 3-slice sprite ids, not text content; building them again would - // double-draw the bar art). All other child types are built normally. + // carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content; + // building them again would double-draw the bar art). All other child types are + // built normally. // - // Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children - // (no text children). This loop finds nothing for them → no change to vitals. + // For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF, + // Type 0 → merged Type 12 → UiText): VitalsController binds its LinesProvider, and + // the HideDetail/ShowDetail cascade from the window root reaches it THROUGH the + // meter (UiMeter.TrySetRetailState forwards to stateful children), flipping its + // per-state 0x3B visibility exactly like retail's PassToChildren SetState walk. foreach (var child in info.Children) { if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter diff --git a/src/AcDream.App/UI/Layout/UiVitalsRoot.cs b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs new file mode 100644 index 00000000..429a0560 --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs @@ -0,0 +1,95 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Behavioral root widget for the vitals windows — retail's gmVitalsUI +/// family (gmVitalsUI 0x10000009, gmFloatyVitalsUI 0x1000004D = +/// the stacked window LayoutDesc 0x2100006C, gmFloatySideVitalsUI +/// 0x10000056 = the side-by-side window LayoutDesc 0x21000075; registrations +/// @0x004BFE10 / @0x004CED90 / @0x004D0490). +/// +/// +/// The click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00, +/// inherited verbatim by both floaty subclasses): on Element_mouse_press +/// (0x1C) with dwParam1 7 (left) or 0xA (right) the root flips +/// SetState(m_state == HideDetail ? ShowDetail : HideDetail). +/// State semantics from the authored data + UIElement::OnSetAttribute +/// case 8 (0x3B = invisible, SetVisible(value == 0) @0x00462DAE): +/// +/// +/// Undef (login default; DefaultState is authored +/// Undef and nothing calls SetState at init): numbers visible, no icons — +/// visually identical to HideDetail. +/// HideDetail (0x10000006): the numeric mode — the +/// cur/max labels author {0x3B:false} (visible); the icon overlays author +/// File=0 (nothing). +/// ShowDetail (0x10000007): the graphical mode — +/// labels author {0x3B:true} (hidden); each bar shows its authored icon pair +/// (dim back + bright fill-clipped front: heart / sword / scepter). +/// +/// +/// +/// The first press from Undef lands on HideDetail (retail's exact expression: +/// ecx = (m_state == 0x10000006); SetState(ecx + 0x10000006) — any +/// state that is not HideDetail, including the initial Undef, goes to +/// HideDetail first), so the first click appears to do nothing and the second +/// enters icon mode. NOT persisted anywhere: gmGamePlayUI::SaveScreenLayout +/// @0x004EAD50 writes only window rects, and no PlayerModule option is +/// touched — the mode resets to Undef every session, per window. +/// +/// +/// +/// Press routing: retail broadcasts the press to the pressed element +/// and forwards up the parent chain (UIElement::ListenToElementMessage +/// @0x00462340 → ForwardElementMessage), but UIElement_Dragbar +/// (@0x0046C850) and UIElement_Resizebar (@0x0046B930) both return 2 +/// (consumed) unconditionally — a press that starts a window move or resize +/// never reaches the vitals root. Mirrored here: presses whose hit target is a +/// move handle or resize grip are ignored. The handler returns false so the +/// event keeps bubbling, matching retail's fall-through to the base handler. +/// +/// +public sealed class UiVitalsRoot : UiDatElement +{ + /// gmVitalsUI registered element class (@0x004BFE1A). + public const uint GmVitalsClassId = 0x10000009u; + /// gmFloatyVitalsUI registered element class (@0x004CED9A) — the stacked window root. + public const uint GmFloatyVitalsClassId = 0x1000004Du; + /// gmFloatySideVitalsUI registered element class (@0x004D049A) — the side-by-side window root. + public const uint GmFloatySideVitalsClassId = 0x10000056u; + + public UiVitalsRoot(ElementInfo info, Func resolve) + : base(info, resolve) + { + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type is UiEventType.MouseDown or UiEventType.RightDown + && !PressConsumedByChrome(e.Target)) + { + // gmVitalsUI::ListenToElementMessage @0x004BFC04: + // this->SetState(m_state == HideDetail ? ShowDetail : HideDetail) + // then falls through to the base handler (keep bubbling → false). + TrySetRetailState( + ActiveRetailStateId == RetailUiStateIds.HideDetail + ? RetailUiStateIds.ShowDetail + : RetailUiStateIds.HideDetail); + } + return false; + } + + /// + /// True when the pressed element is (or sits inside) a window-move handle + /// or resize grip — the two retail element classes that consume the press + /// before it can bubble to the vitals root. + /// + private bool PressConsumedByChrome(UiElement? target) + { + for (UiElement? w = target; w is not null && w != this; w = w.Parent) + if (w.WindowMoveHandle || w is UiResizeGrip) + return true; + return false; + } +} diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index db74eaaf..6a90d87e 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful private readonly Dictionary _stateLabels = new(); private (string Text, UiMeterLabelAlign Align)? _activeStateLabel; + // Vitals ShowDetail icon overlays (see ConfigureDetailOverlay). + private bool _detailConfigured; + private bool _detailPassToChildren; + private uint _detailBackSprite; + private uint _detailFrontSprite; + private (float X, float Y, float W, float H) _detailBackRect; + private (float X, float Y, float W, float H) _detailFrontRect; + + /// True when this meter absorbed the vitals detail-icon overlays. Exposed for tests. + internal bool HasDetailOverlay => _detailConfigured; + /// The dim back-container detail icon (ShowDetail media). Exposed for tests. + internal uint DetailBackSprite => _detailBackSprite; + /// The bright fill-clipped front-container detail icon. Exposed for tests. + internal uint DetailFrontSprite => _detailFrontSprite; + /// The back overlay's authored meter-local rect. Exposed for tests. + internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect; + /// Dat element id, set by the layout importer so duplicated page copies can be scoped. public uint ElementId { get; set; } @@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful _stateLabels[stateId] = (text, align); } + /// + /// Registers the vitals "detail" icon overlays absorbed from the meter's + /// two slice containers (the 0x100004A9 children — health heart + /// 0x06007490/91, stamina sword 0x06007492/93, mana scepter 0x06007494/95). + /// Retail authors each icon TWICE: a dim version in the BACK container + /// (drawn unclipped over the empty track) and a bright version in the + /// FRONT container (fill-clipped with the rest of the front layer — + /// UIElement_Meter::DrawChildren @0x0046FBD0 clips the whole + /// m_pcChildImage child, element id 2, to the 0x69 fraction), so + /// the icon itself fills up with the vital. Both overlays author media + /// ONLY for ShowDetail (HideDetail authors File=0), so they + /// draw solely in that state. Rects are the overlays' authored X/Y/W/H + /// local to the meter (the containers span the meter at 0,0). + /// + internal void ConfigureDetailOverlay( + uint backSprite, float backX, float backY, float backW, float backH, + uint frontSprite, float frontX, float frontY, float frontW, float frontH, + bool passToChildren) + { + _detailBackSprite = backSprite; + _detailBackRect = (backX, backY, backW, backH); + _detailFrontSprite = frontSprite; + _detailFrontRect = (frontX, frontY, frontW, frontH); + _detailConfigured = backSprite != 0 || frontSprite != 0; + _detailPassToChildren = passToChildren; + } + public bool TrySetRetailState(uint stateId) { + // Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00 + // flips the window root between HideDetail 0x10000006 and ShowDetail + // 0x10000007; the meter receives the state through the authored + // PassToChildren cascade — UIElement::SetState @0x00464E70). The + // meter's own HideDetail/ShowDetail StateDescs are media-less and + // exist purely to keep propagating (PassToChildren=true), so forward + // to the absorbed text child (whose per-state 0x3B hides the numbers + // in ShowDetail) and let OnDraw key the icon overlays off the state. + if (_detailConfigured + && stateId is RetailUiStateIds.HideDetail or RetailUiStateIds.ShowDetail) + { + ActiveRetailStateId = stateId; + if (_detailPassToChildren) + foreach (UiElement child in Children) + if (child is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); + return true; + } + bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId); bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption); if (!hasFill && !hasLabel) @@ -198,9 +261,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful // drawn at FULL width too but horizontally CLIPPED to the fill fraction. // The front carries its own right-cap (shown at 100%); clipping below 100% // removes it and reveals the back track's right-cap — retail's scissor-fill. + // + // ShowDetail icon overlays ride their authored containers: the dim BACK + // icon draws over the full track (its 0x100004A9 child draws AFTER the + // three slice children — higher ReadOrder within the back container); + // the bright FRONT icon is clipped with the rest of the front layer to + // the fill fraction (retail clips the whole element-id-2 child). + bool detail = _detailConfigured + && ActiveRetailStateId == RetailUiStateIds.ShowDetail; DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); + if (detail) + DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width); if (pct is not null && p > 0f) + { DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); + if (detail) + DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p); + } } } else @@ -302,6 +379,30 @@ public sealed class UiMeter : UiElement, IUiDatStateful ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); } + /// + /// Draws one ShowDetail icon overlay at its authored meter-local rect, + /// horizontally clipped to local px (the back icon + /// passes the full width; the front icon passes Width * fraction, + /// mirroring retail's whole-front-container fill clip). The visible portion + /// is UV-cropped so the icon reveals left-to-right with the fill. Height is + /// clamped to the meter's box — retail clips children to the parent rect + /// (the stamina FRONT overlay authors H=28 in a 16px bar; retail shows 16). + /// + private void DrawDetailIcon( + UiRenderContext ctx, Func resolve, + uint spriteId, (float X, float Y, float W, float H) rect, float clipW) + { + if (spriteId == 0 || rect.W <= 0f || rect.H <= 0f) return; + var (tex, _, _) = resolve(spriteId); + if (tex == 0) return; + float visibleW = MathF.Min(rect.W, clipW - rect.X); + if (visibleW <= 0f) return; + float h = MathF.Min(rect.H, Height - rect.Y); + if (h <= 0f) return; + float u1 = visibleW / rect.W; + ctx.DrawSprite(tex, rect.X, rect.Y, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); + } + /// Draw a slice over local [, /// pieceX+], with the texture repeating every /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 5bd2ab0e..1a9f64e3 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful && TryColor(color, out Vector4 resolvedColor)) DefaultColor = resolvedColor; + // Per-state Invisible (dat property 0x3B): retail's SetState applies the + // committed state's properties through UIElement::OnSetAttribute, whose + // case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is + // `SetVisible(value == 0)`. Same NAMED-states-only scoping as + // UiDatElement.TrySetRetailState (a DirectState 0x3B is the + // construction-time "authored invisible" class — #408, separately + // gated). First consumer here: the vitals cur/max number labels + // (0x100000EB/ED/EF) author HideDetail={0x3B:false} / + // ShowDetail={0x3B:true} — the numbers hide when the click toggle + // switches the window to the graphical icon mode. + if (stateId != UiStateInfo.DirectStateId + && state is not null + && state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp) + && invisibleProp.Kind == UiPropertyKind.Bool) + Visible = !invisibleProp.BoolValue; + // Retail's state cascade also swaps the element's AUTHORED string // when the incoming state carries its own 0x17 (the friends row's // status cell: 'Online'/'Offline' with per-state colors). Resolved diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs new file mode 100644 index 00000000..75fb4e4f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs @@ -0,0 +1,199 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// The retail vitals numeric/graphical click toggle, against the committed +/// vitals fixture (vitals_2100006C.json) — no dats, no GL. +/// +/// Retail mechanism (fully derived 2026-08-17): +/// +/// gmVitalsUI::ListenToElementMessage @0x004BFC00 — +/// mouse press (msg 0x1C) with dwParam1 7 (left) or 0xA (right) flips +/// SetState(m_state == HideDetail ? ShowDetail : HideDetail). +/// UIElement::SetState @0x00464E70 — the state +/// cascades through the authored PassToChildren chain (root and meters +/// author media-less HideDetail/ShowDetail with PassToChildren=true). +/// The cur/max labels author per-state 0x3B (invisible — +/// UIElement::OnSetAttribute case 8 @0x00462DAE is +/// SetVisible(value == 0)): HideDetail={0x3B:false} (numbers shown), +/// ShowDetail={0x3B:true} (numbers hidden). +/// The 0x100004A9 overlay children author icon media only +/// for ShowDetail (heart 0x06007490/91, sword 0x06007492/93, scepter +/// 0x06007494/95) — dim back icon unclipped, bright front icon fill-clipped. +/// UIElement_Dragbar @0x0046C850 / +/// UIElement_Resizebar @0x0046B930 consume presses (return 2) — a +/// window move/resize press never toggles. +/// Not persisted: gmGamePlayUI::SaveScreenLayout +/// @0x004EAD50 writes window rects only; each window resets to the +/// authored Undef default per session. +/// +/// +[Trait("Category", "Conformance")] +public class VitalsDetailToggleTests +{ + private const uint DragBarTop = 0x1000063Cu; // Type 2 — WindowMoveHandle + private const uint GripTopLeft = 0x1000063Bu; // Type 9 — UiResizeGrip + + // ── Import shape ───────────────────────────────────────────────────────── + + [Fact] + public void VitalsTree_RootIsVitalsRootWidget() + { + var layout = FixtureLoader.LoadVitals(); + Assert.IsType(layout.Root); + } + + [Fact] + public void VitalsTree_MetersAbsorbDetailIconOverlays() + { + var layout = FixtureLoader.LoadVitals(); + + // MeterId → (dim back icon, bright front icon) from the authored + // 0x100004A9 ShowDetail media (format doc §11 + installed-DAT probe). + (uint MeterId, uint Back, uint Front)[] cases = + [ + (VitalsController.Health, 0x06007490u, 0x06007491u), // heart + (VitalsController.Stamina, 0x06007492u, 0x06007493u), // sword + (VitalsController.Mana, 0x06007494u, 0x06007495u), // scepter + ]; + + foreach (var (meterId, back, front) in cases) + { + var m = Assert.IsType(layout.FindElement(meterId)); + Assert.True(m.HasDetailOverlay); + Assert.Equal(back, m.DetailBackSprite); + Assert.Equal(front, m.DetailFrontSprite); + } + + // Health's authored overlay rect: 18x16 at x=66 (the heart). + var health = Assert.IsType(layout.FindElement(VitalsController.Health)); + Assert.Equal((66f, 0f, 18f, 16f), health.DetailBackRect); + } + + // ── The press toggle ───────────────────────────────────────────────────── + + [Fact] + public void Press_TogglesUndefThenHideDetailThenShowDetail() + { + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var stamina = Assert.IsType(layout.FindElement(VitalsController.Stamina)); + var staminaText = Assert.IsType(layout.FindElement(VitalsController.StaminaText)); + + // Login default: authored DefaultState is Undef and nothing calls + // SetState — numbers visible, no icons. + Assert.True(staminaText.Visible); + + // Press 1 (from Undef): retail lands on HideDetail — visually identical + // (labels author {0x3B:false} there), never back to Undef afterwards. + Press(root, root); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.HideDetail, stamina.ActiveRetailStateId); + Assert.True(staminaText.Visible); + + // Press 2: ShowDetail — the graphical mode. Numbers hidden, icons on. + Press(root, root); + Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.ShowDetail, stamina.ActiveRetailStateId); + Assert.False(staminaText.Visible); + + // Press 3: back to numeric. + Press(root, root); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Assert.True(staminaText.Visible); + } + + [Fact] + public void RightPress_TogglesLikeLeftPress() + { + // Retail accepts dwParam1 7 (left) OR 0xA (right) — @0x004BFC19. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + + Press(root, root, UiEventType.RightDown); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Press(root, root, UiEventType.RightDown); + Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId); + } + + [Fact] + public void Press_CascadesToAllThreeLabels() + { + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + + Press(root, root); // Undef → HideDetail + Press(root, root); // HideDetail → ShowDetail + foreach (uint textId in new[] + { + VitalsController.HealthText, + VitalsController.StaminaText, + VitalsController.ManaText, + }) + { + var text = Assert.IsType(layout.FindElement(textId)); + Assert.False(text.Visible); + } + } + + // ── Chrome exclusions ──────────────────────────────────────────────────── + + [Fact] + public void Press_OnDragBar_DoesNotToggle() + { + // Retail UIElement_Dragbar::ListenToElementMessage returns 2 (consumed) + // for every message — the press never reaches gmVitalsUI. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var dragBar = layout.FindElement(DragBarTop); + Assert.NotNull(dragBar); + Assert.True(dragBar!.WindowMoveHandle); + + uint before = root.ActiveRetailStateId; + Press(root, dragBar); + Assert.Equal(before, root.ActiveRetailStateId); + } + + [Fact] + public void Press_OnResizeGrip_DoesNotToggle() + { + // Retail UIElement_Resizebar::ListenToElementMessage returns 2 likewise. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var grip = layout.FindElement(GripTopLeft); + Assert.NotNull(grip); + Assert.IsType(grip); + + uint before = root.ActiveRetailStateId; + Press(root, grip!); + Assert.Equal(before, root.ActiveRetailStateId); + } + + // ── Independence: each window keeps its own state ───────────────────────── + + [Fact] + public void TwoWindows_ToggleIndependently() + { + // Retail: gmFloatyVitalsUI and gmFloatySideVitalsUI each carry their + // own m_state; toggling one never touches the other. + var a = FixtureLoader.LoadVitals(); + var b = FixtureLoader.LoadVitals(); + var rootA = Assert.IsType(a.Root); + var rootB = Assert.IsType(b.Root); + + Press(rootA, rootA); + Press(rootA, rootA); + Assert.Equal(RetailUiStateIds.ShowDetail, rootA.ActiveRetailStateId); + Assert.NotEqual(RetailUiStateIds.ShowDetail, rootB.ActiveRetailStateId); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Delivers a press to as UiRoot's bubble + /// walk would: the hit target rides in . + private static void Press(UiVitalsRoot root, UiElement target, + int type = UiEventType.MouseDown) + => root.OnEvent(new UiEvent(target.EventId, target, type)); +} From db8fa328dc3ba3038d3e801909769797bbc1807f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:40:49 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(ui):=20vitals=20=E2=80=94=20Side=20By?= =?UTF-8?q?=20Side=20Vitals=20swaps=20retail's=20two=20vitals=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of retail's SideBySideVitals character option, derived end-to-end: retail authors TWO complete vitals windows and swaps their VISIBILITY on the option bit — nothing is rearranged in place. - gmFloatySideVitalsUI (0x10000056, Register @0x004D0490) is a second full vitals window from LayoutDesc 0x21000075: 460x26, the same three meters (0x100000E6/EC/EE), cur/max labels (0x100000EB/ED/EF), and detail-icon overlays authored id-for-id with the stacked window — so the same VitalsController.Bind and the inherited UiVitalsRoot click toggle apply unchanged. Authored constraints ride the root's 0x3C..0x3F (fixed 26 height, width 360..3000) through DatConstraintSource. - Visibility ownership: gmFloatyVitalsUI::UpdateFromPlayerModule @0x004CF140 shows the stacked window iff PlayerModule::SideBySideVitals == 0; gmFloatySideVitalsUI::UpdateFromPlayerModule @0x004D0810 shows the side row iff set; gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0 flips both live on option id 0x13. - The bit: PlayerModule::SideBySideVitals @0x005D3070 = (options_ >> 0x15) & 1 — CharacterOptions1 0x00200000, ACE-confirmed; CharacterOptionTable already carried the exact row (PlayerModule-blob group, not a 0x0005 auto-save id). acdream shape: MountSideVitals mounts the second window hidden; VitalsSideBySideController polls the borrowed J4 option bit once per frame from RetailUiRuntime.Tick and applies BOTH windows' visibility on the edge — covering the mount default, the PlayerModule blob arriving after mount, and the Character tab's live checkbox with one mechanism. Both window names join stateManagedVisibilityWindows so the saved layout never restores a visibility the option owns. The Character tab's SideBySideVitals row un-dims (StoreOnly → Live) with a real reader — 33 dimmed / 17 live. 4 new controller tests (initial apply both directions, live edge swap both directions, steady-bit non-reassertion). App suite Release live-DAT 5499 passed / 3 skips; Runtime 1744/0. Co-Authored-By: Claude Fable 5 --- .../Layout/CharacterOptionsPageController.cs | 23 +++-- .../UI/Layout/VitalsSideBySideController.cs | 84 ++++++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 96 +++++++++++++++++-- src/AcDream.App/UI/WindowNames.cs | 6 ++ .../CharacterOptionsPageControllerTests.cs | 18 +++- .../Layout/VitalsSideBySideControllerTests.cs | 95 ++++++++++++++++++ 6 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/VitalsSideBySideController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs index 6e7158f0..d4ee0084 100644 --- a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs @@ -111,7 +111,7 @@ public static class CharacterOptionsPageController /// steps 13-18, written after the code landed): /// /// - /// NOT dimmed (16 rows, real acdream-side + /// NOT dimmed (17 rows, real acdream-side /// consumer): Group B rows actually bound — /// ViewCombatTarget/AutoTarget/AutoRepeatAttack /// (combat: LiveCombatAttackOperations.cs, @@ -124,15 +124,20 @@ public static class CharacterOptionsPageController /// DragItemOnPlayerOpensSecureTrade (TS-48, /// InteractionRetainedUiComposition.cs:326), all six /// ListenTo*Chat ids (TurbineChatMembershipGate.cs:105-136 - /// gates every Turbine room join on the matching bit), and — landed at + /// gates every Turbine room join on the matching bit), — landed at /// Campaign FA slice FA4, D7, SURVIVING the fix-round correction below /// — FellowshipShareXP (the Create-flow click genuinely reads - /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons). - /// Dimmed (34 rows, store-only): every + /// it as the sent shareXP bit, SocialFellowshipPageController.WireButtons), + /// and — the vitals retail-modes round, 2026-08-17 — + /// SideBySideVitals (VitalsSideBySideController swaps the + /// stacked 0x2100006C / side-by-side 0x21000075 vitals windows on the + /// bit, retail's gmGamePlayUI::RecvNotice_PlayerOptionChanged + /// @0x004E9DA0). + /// Dimmed (33 rows, store-only): every /// remaining Group A row (wire+store only — ACE, not acdream, is the /// consumer) and every remaining Group B row the OP4 gate script's own /// step 16 lists as "no consumer surface" (ShowTooltips, - /// SideBySideVitals, SpellDuration, + /// SpellDuration, /// AdvancedCombatUI, StayInChatMode, /// DisableMostWeatherEffects, PersistentAtDay, /// FilterLanguage, MainPackPreferred), plus every Group D @@ -181,9 +186,9 @@ public static class CharacterOptionsPageController /// /// /// Net: 35 (pre-FA4) → FA4 shipped 31 → fix round reverts three of the - /// four un-dims (Ignore, AutoAccept, ShareLoot) → 34 of 50 dimmed / - /// 16 live, ONE net un-dim from pre-FA4 baseline - /// (FellowshipShareXP only). + /// four un-dims (Ignore, AutoAccept, ShareLoot) → 34 dimmed / 16 live → + /// the vitals retail-modes round (2026-08-17) un-dims + /// SideBySideVitals33 of 50 dimmed / 17 live. /// /// Flagged ambiguity, resolved by code evidence /// (see final report, not re-litigated here): the research doc's @@ -226,7 +231,7 @@ public static class CharacterOptionsPageController new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator", Live), // Group C new(CharacterOptionId.ShowTooltips, "ShowTooltips", StoreOnly), // Group B, unbound new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar", Live), // Group C - new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", StoreOnly), // Group B, unbound + new(CharacterOptionId.SideBySideVitals, "SideBySideVitals", Live), // vitals retail-modes round (2026-08-17): consumed by VitalsSideBySideController — the bit swaps the stacked (0x2100006C) and side-by-side (0x21000075) vitals windows live, retail's gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0 new(CharacterOptionId.SpellDuration, "SpellDuration", StoreOnly), // Group B, unbound new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects", StoreOnly), // Group B, unbound new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog", Live), // Group B, bound (GameWindow.cs:657) diff --git a/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs b/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs new file mode 100644 index 00000000..da74c597 --- /dev/null +++ b/src/AcDream.App/UI/Layout/VitalsSideBySideController.cs @@ -0,0 +1,84 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Owns which of the two vitals windows is visible — retail's +/// "Side By Side Vitals" character option (Options → Character tab). +/// +/// +/// Retail authors TWO complete vitals windows and swaps their visibility on +/// the option bit; nothing is rearranged in place: +/// +/// +/// gmFloatyVitalsUI (LayoutDesc 0x2100006C, the +/// stacked 160x58 window): UpdateFromPlayerModule @0x004CF140 — +/// visible iff PlayerModule::SideBySideVitals() == 0. +/// gmFloatySideVitalsUI (LayoutDesc 0x21000075, the +/// 460x26 single-row window): UpdateFromPlayerModule @0x004D0810 — +/// visible iff the bit is set. +/// Live apply: gmGamePlayUI::RecvNotice_PlayerOptionChanged +/// @0x004E9DA0 flips both windows the moment the +/// SideBySideVitals_PlayerOption (0x13) changes; both +/// UpdateFromPlayerModule bodies re-apply on PlayerDescReceived. +/// The bit is PlayerModule::SideBySideVitals @0x005D3070 +/// = (options_ >> 0x15) & 1 — CharacterOptions1 0x00200000, +/// option id 0x13, riding the 0x01A1 PlayerModule blob (not a 0x0005 +/// auto-save id) — CharacterOptionTable row confirmed against ACE. +/// +/// +/// +/// acdream shape: the option lives in the J4 Runtime owner +/// (RuntimeCharacterState.Options); this controller polls the borrowed +/// bit once per frame from RetailUiRuntime.Tick (the OP campaign's +/// established live-apply seam) and applies BOTH windows' visibility on the +/// edge — covering the mount-time default, the PlayerModule blob arriving +/// after mount, and the Character tab's live toggle with one mechanism. +/// Both window names ride stateManagedVisibilityWindows so the saved +/// window layout never restores a visibility this option owns. +/// +/// +public sealed class VitalsSideBySideController +{ + private readonly UiRoot _root; + private readonly Func _sideBySideVitals; + private readonly string _stackedWindow; + private readonly string _sideWindow; + private bool? _applied; + + public VitalsSideBySideController( + UiRoot root, + Func sideBySideVitals, + string stackedWindow, + string sideWindow) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + _sideBySideVitals = sideBySideVitals + ?? throw new ArgumentNullException(nameof(sideBySideVitals)); + _stackedWindow = stackedWindow; + _sideWindow = sideWindow; + } + + /// The last applied bit (null before the first apply). Exposed for tests. + public bool? Applied => _applied; + + public void Tick() + { + bool sideBySide = _sideBySideVitals(); + if (_applied == sideBySide) return; + _applied = sideBySide; + + // Retail's exact swap (RecvNotice_PlayerOptionChanged @0x004E9DBE): + // option ON → stacked hidden, side shown; OFF → the opposite. + if (sideBySide) + { + _root.HideWindow(_stackedWindow); + _root.ShowWindow(_sideWindow); + } + else + { + _root.ShowWindow(_stackedWindow); + _root.HideWindow(_sideWindow); + } + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index aca63939..dd52aada 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -495,6 +495,7 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private Layout.VitalsSideBySideController? _vitalsSideBySide; private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; @@ -586,6 +587,14 @@ public sealed class RetailUiRuntime : IDisposable WindowNames.JumpPowerbar, WindowNames.ExternalContainer, WindowNames.Vendor, + // Retail owns which vitals window shows via the + // SideBySideVitals character option (both + // UpdateFromPlayerModule bodies @0x004CF140/@0x004D0810 + // apply it unconditionally at login) — the saved layout + // must never restore a visibility the option decides. + // See VitalsSideBySideController. + WindowNames.Vitals, + WindowNames.SideVitals, // Trade gate round 2 (2026-08-14): the trade window is // transient — visibility belongs to RuntimeTradeState's // open/closed lifecycle, never to the layout file (a @@ -827,6 +836,7 @@ public sealed class RetailUiRuntime : IDisposable { FpsController?.Tick(); _vividTargetIndicator?.Tick(); + _vitalsSideBySide?.Tick(); SpellbookWindowController?.Tick(); AppraisalController?.Tick(deltaSeconds); SpellcastingUiController?.Tick(); @@ -1312,14 +1322,7 @@ public sealed class RetailUiRuntime : IDisposable return; } - VitalsVM vm = _bindings.Vitals.ViewModel; - VitalsController.Bind(layout, - () => vm.HealthPercent, - () => vm.StaminaPercent ?? 0f, - () => vm.ManaPercent ?? 0f, - () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", - () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", - () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); + BindVitalsLayout(layout); RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite, new RetailWindowFrame.Options { @@ -1333,6 +1336,83 @@ public sealed class RetailUiRuntime : IDisposable ContentClickThrough = false, }); Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); + + MountSideVitals(); + } + + /// + /// The side-by-side vitals row — retail's SECOND complete vitals window + /// (gmFloatySideVitalsUI 0x10000056, LayoutDesc 0x21000075, 460x26: the + /// same three meters + cur/max labels laid out in one row). Retail swaps + /// it with the stacked window on the SideBySideVitals character option + /// (both UpdateFromPlayerModule bodies @0x004CF140/@0x004D0810 + + /// gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0) — see + /// , which owns both + /// windows' visibility from the per-frame option poll. Mounted hidden; + /// the controller's first Tick applies the real bit. + /// + /// Element ids (meters 0x100000E6/EC/EE, labels 0x100000EB/ED/EF) + /// and the detail-icon overlays are authored identically to 0x2100006C, + /// so the same and the inherited + /// click toggle apply as-is. Width constraints + /// ride the root's authored 0x3C..0x3F (min 360 / max 3000, fixed 26 + /// height) through . + /// + private void MountSideVitals() + { + ElementInfo? info; + ImportedLayout? layout; + lock (_bindings.Assets.DatLock) + { + info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, 0x21000075u); + layout = info is null ? null : LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + if (info is null || layout is null) + { + Console.WriteLine("[D.2b] side vitals: LayoutDesc 0x21000075 not found — SideBySideVitals unavailable."); + return; + } + + BindVitalsLayout(layout); + RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite, + new RetailWindowFrame.Options + { + WindowName = WindowNames.SideVitals, + Chrome = RetailWindowChrome.Imported, + Left = 10f, + Top = 30f, + ResizeX = true, + ResizeY = false, + DatConstraintSource = info, + Visible = false, + ContentClickThrough = false, + }); + _vitalsSideBySide = new Layout.VitalsSideBySideController( + Host.Root, + () => _bindings.Options.CurrentCharacterOption( + (uint)CharacterOptionId.SideBySideVitals), + WindowNames.Vitals, + WindowNames.SideVitals); + Console.WriteLine("[D.2b] side-by-side vitals window from LayoutDesc importer (0x21000075)."); + } + + /// Binds the shared vitals ViewModel providers to a vitals layout — + /// both windows (0x2100006C stacked / 0x21000075 side-by-side) author the + /// same meter/label element ids. + private void BindVitalsLayout(ImportedLayout layout) + { + VitalsVM vm = _bindings.Vitals.ViewModel; + VitalsController.Bind(layout, + () => vm.HealthPercent, + () => vm.StaminaPercent ?? 0f, + () => vm.ManaPercent ?? 0f, + () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", + () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", + () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); } private void MountRadar() diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 0f741703..1d11d7e5 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -5,6 +5,12 @@ namespace AcDream.App.UI; public static class WindowNames { public const string Vitals = "vitals"; + + /// The side-by-side vitals row (retail gmFloatySideVitalsUI, + /// LayoutDesc 0x21000075) — shown instead of when the + /// SideBySideVitals character option is set (see + /// ). + public const string SideVitals = "side-vitals"; public const string Toolbar = "toolbar"; public const string Character = "character"; public const string CharacterInformation = "character-information"; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs index 10645d1f..b8a0c79e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs @@ -698,7 +698,7 @@ public sealed class CharacterOptionsPageControllerTests // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── /// - /// The exact 34 values this slice dims + /// The exact 33 values currently dimmed /// ( == true), transcribed independently /// of CharacterOptionsPageController.Groups from the derivation /// table in that class's own doc comment. Wiring a future consumer @@ -720,15 +720,23 @@ public sealed class CharacterOptionsPageControllerTests /// fellowship Create flow reads it as the sent shareXP bit). /// Net: 35 (pre-FA4) → 34 (post-fix-round), one net un-dim. /// + /// + /// + /// The vitals retail-modes round (2026-08-17) un-dims + /// SideBySideVitals: VitalsSideBySideController genuinely + /// READS the bit every frame and swaps the stacked (0x2100006C) / + /// side-by-side (0x21000075) vitals windows — retail's + /// gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0. + /// Net: 34 → 33 dimmed / 17 live. + /// /// private static readonly HashSet ExpectedStoreOnlyIds = [ // Group 1 (UI Behavior) — SalvageMultiple (D), MainPackPreferred (B, unbound) CharacterOptionId.SalvageMultiple, CharacterOptionId.MainPackPreferred, - // Group 2 (UI Display) — 11 of 15 + // Group 2 (UI Display) — 10 of 15 (vitals round: SideBySideVitals is live) CharacterOptionId.ShowTooltips, - CharacterOptionId.SideBySideVitals, CharacterOptionId.SpellDuration, CharacterOptionId.DisableMostWeatherEffects, CharacterOptionId.PersistentAtDay, @@ -774,8 +782,8 @@ public sealed class CharacterOptionsPageControllerTests .ToHashSet(); Assert.Equal(ExpectedStoreOnlyIds, actualStoreOnly); - Assert.Equal(34, actualStoreOnly.Count); - Assert.Equal(16, 50 - actualStoreOnly.Count); // the 16 live rows (fix round: net +1 from pre-FA4 baseline) + Assert.Equal(33, actualStoreOnly.Count); + Assert.Equal(17, 50 - actualStoreOnly.Count); // the 17 live rows (vitals round: SideBySideVitals un-dimmed) } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs new file mode 100644 index 00000000..5106ac5d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsSideBySideControllerTests.cs @@ -0,0 +1,95 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Retail's "Side By Side Vitals" character option swaps which of the TWO +/// complete vitals windows is visible — nothing is rearranged in place: +/// gmFloatyVitalsUI::UpdateFromPlayerModule @0x004CF140 (stacked +/// visible iff the bit is CLEAR), gmFloatySideVitalsUI:: +/// UpdateFromPlayerModule @0x004D0810 (side row visible iff SET), and the +/// live flip in gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004E9DA0. +/// The bit is PlayerModule::SideBySideVitals @0x005D3070 = +/// CharacterOptions1 0x00200000 (option id 0x13) — ACE-confirmed. +/// +public sealed class VitalsSideBySideControllerTests +{ + [Fact] + public void FirstTick_AppliesTheCurrentBit_BothDirections() + { + var (root, stacked, side) = Windows(); + bool bit = false; + var controller = new VitalsSideBySideController( + root, () => bit, WindowNames.Vitals, WindowNames.SideVitals); + + controller.Tick(); + Assert.True(stacked.Visible); + Assert.False(side.Visible); + Assert.False(controller.Applied!.Value); + } + + [Fact] + public void FirstTick_WithBitSet_ShowsTheSideRow() + { + var (root, stacked, side) = Windows(); + var controller = new VitalsSideBySideController( + root, () => true, WindowNames.Vitals, WindowNames.SideVitals); + + controller.Tick(); + Assert.False(stacked.Visible); + Assert.True(side.Visible); + } + + [Fact] + public void BitEdge_SwapsLive_BothDirections() + { + // The Character tab's checkbox (or the inbound PlayerModule blob) + // flips the stored bit; the next frame swaps the windows — retail's + // RecvNotice_PlayerOptionChanged live apply. + var (root, stacked, side) = Windows(); + bool bit = false; + var controller = new VitalsSideBySideController( + root, () => bit, WindowNames.Vitals, WindowNames.SideVitals); + controller.Tick(); + + bit = true; + controller.Tick(); + Assert.False(stacked.Visible); + Assert.True(side.Visible); + + bit = false; + controller.Tick(); + Assert.True(stacked.Visible); + Assert.False(side.Visible); + } + + [Fact] + public void SteadyBit_DoesNotReassertVisibility() + { + // Edge-detected: a steady bit must not fight other visibility + // machinery every frame (only the option EDGE applies, mirroring + // retail's notice-driven apply rather than a per-frame force). + var (root, stacked, side) = Windows(); + var controller = new VitalsSideBySideController( + root, () => false, WindowNames.Vitals, WindowNames.SideVitals); + controller.Tick(); + + // Simulate an out-of-band hide (e.g. a future toggle surface). + stacked.Visible = false; + controller.Tick(); + Assert.False(stacked.Visible); + } + + private static (UiRoot Root, UiPanel Stacked, UiPanel Side) Windows() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var stacked = new UiPanel { Width = 160, Height = 58 }; + var side = new UiPanel { Width = 460, Height = 26, Visible = false }; + root.AddChild(stacked); + root.AddChild(side); + root.RegisterWindow(WindowNames.Vitals, stacked, stacked, null); + root.RegisterWindow(WindowNames.SideVitals, side, side, null); + return (root, stacked, side); + } +} From c5bfa38dd2b81fc1905d457f937441c2b26e5636 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:43:29 +0200 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20register=20IA-15=20=E2=80=94=20add?= =?UTF-8?q?=20the=200x21000075=20side-vitals=20production=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bookkeeping for the vitals retail-modes round (306a1670 + db8fa328): the side-by-side vitals row joins IA-15's production LayoutDesc import list. No new divergence class — the window shell, layout persistence, and whole-surface drag regions the two vitals windows ride are already registered under IA-12/IA-15/AP-98. Co-Authored-By: Claude Fable 5 --- docs/architecture/retail-divergence-register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b1d2689d..82b8619b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -53,7 +53,7 @@ accepted-divergence entries (#96, #49, #50). | 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 with per-window authored-geometry revisions, 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 `0x2100006F` (Campaign CH slice CH6a corrected this from the previously-imported, unrelated `0x21000006`), toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). 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/ExternalContainerController.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; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | 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. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | +| 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 with per-window authored-geometry revisions, 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` plus the side-by-side vitals row `0x21000075` (the vitals retail-modes round, 2026-08-17 — visibility swapped on the SideBySideVitals option by `VitalsSideBySideController`, retail `@0x004E9DA0`), chat `0x2100006F` (Campaign CH slice CH6a corrected this from the previously-imported, unrelated `0x21000006`), toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). 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/ExternalContainerController.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; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | 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. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target — do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | From 5337899f3f85a3fda7dfe419db381eaff48112ee Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:48:25 +0200 Subject: [PATCH 4/8] =?UTF-8?q?fix(ui):=20vitals=20=E2=80=94=20UiVitalsRoo?= =?UTF-8?q?t.OnEvent=20delegates=20to=20the=20UiDatElement=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The press toggle returned false directly, silently swallowing the base class's Click dispatch (OnClick/OnClickAt) for any future controller wiring on a vitals root. Retail's handler falls through to the base listener the same way (@0x004BFC47). No behavior change today — nothing sets OnClick on a vitals root. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/UiVitalsRoot.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/Layout/UiVitalsRoot.cs b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs index 429a0560..8afb068c 100644 --- a/src/AcDream.App/UI/Layout/UiVitalsRoot.cs +++ b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs @@ -71,13 +71,13 @@ public sealed class UiVitalsRoot : UiDatElement { // gmVitalsUI::ListenToElementMessage @0x004BFC04: // this->SetState(m_state == HideDetail ? ShowDetail : HideDetail) - // then falls through to the base handler (keep bubbling → false). + // then falls through to the base handler (keep bubbling). TrySetRetailState( ActiveRetailStateId == RetailUiStateIds.HideDetail ? RetailUiStateIds.ShowDetail : RetailUiStateIds.HideDetail); } - return false; + return base.OnEvent(in e); } /// From 7fe83e2bb99d467f97e0cf40ca099c2d4e4bcc8e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:51:15 +0200 Subject: [PATCH 5/8] =?UTF-8?q?test(ui):=20vitals=20=E2=80=94=20toggle=20v?= =?UTF-8?q?erified=20through=20the=20real=20UiRoot=20mouse=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two end-to-end additions to the fixture toggle suite: a body press through UiRoot.OnMouseDown's actual hit test + bubble (left toggles, right toggles), and a press on the authored top drag bar (0x1000063C) arming the window move WITHOUT toggling — the retail Dragbar-consumes-the-press semantics proven against the real input path, not just injected OnEvent calls. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/VitalsDetailToggleTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs index 75fb4e4f..33bc7009 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs @@ -189,6 +189,58 @@ public class VitalsDetailToggleTests Assert.NotEqual(RetailUiStateIds.ShowDetail, rootB.ActiveRetailStateId); } + // ── End-to-end: the REAL mouse path (hit test + bubble) ────────────────── + + [Fact] + public void MouseDown_OnWindowBody_TogglesThroughTheRealHitPath() + { + var (root, vitalsRoot) = MountedWindow(); + + // Body press: the stamina meter's center (meters are ClickThrough, so + // the hit lands on the window content and bubbles to the vitals root). + root.OnMouseDown(UiMouseButton.Left, 90, 59); + root.OnMouseUp(UiMouseButton.Left, 90, 59); + Assert.Equal(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId); + + // Right press toggles too (retail dwParam1 0xA). + root.OnMouseDown(UiMouseButton.Right, 90, 59); + root.OnMouseUp(UiMouseButton.Right, 90, 59); + Assert.Equal(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId); + } + + [Fact] + public void MouseDown_OnTopDragBar_MovesArmWithoutToggling() + { + var (root, vitalsRoot) = MountedWindow(); + + // The authored top drag bar (0x1000063C) spans content-local + // (5,0)-(155,5) → canvas y just below the window top. Retail's + // Dragbar consumes the press; the toggle must not fire. + root.OnMouseDown(UiMouseButton.Left, 90, 32); + root.OnMouseUp(UiMouseButton.Left, 90, 32); + Assert.NotEqual(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId); + Assert.NotEqual(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId); + } + + private static (UiRoot Root, UiVitalsRoot VitalsRoot) MountedWindow() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var layout = FixtureLoader.LoadVitals(); + RetailWindowFrame.Mount(root, layout.Root, static _ => (0u, 0, 0), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Vitals, + Chrome = RetailWindowChrome.Imported, + Left = 10f, + Top = 30f, + ResizeX = true, + ResizeY = false, + MinWidth = 40f, + ContentClickThrough = false, + }); + return (root, Assert.IsType(layout.Root)); + } + // ── Helpers ────────────────────────────────────────────────────────────── /// Delivers a press to as UiRoot's bubble From 5ca1d47d7aba06e70d82d7e28a90e9b4bd3ea2ab Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:51:33 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat(world):=20the=20login=20wormhole=20?= =?UTF-8?q?=E2=80=94=20every=20world=20entry=20runs=20retail's=20portal-sp?= =?UTF-8?q?ace=20presentation=20with=20sound=20(TS-28=20narrowed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751 teleport, with no F751 involved: SmartBox::teleport_in_progress @0x00451C20 returns 1 the moment the login player exists with position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9 (playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime @0x00455483 ends the hold once destination cells stop blocking, Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and LoginComplete goes out at the WorldFadeIn end @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own GameActionLoginComplete comment names this contract: 'called when the client player exits portal space. It includes initial login'). acdream skipped all of it at login — every entry route (direct auto-select, character-select Enter, enter-after-create) dropped onto the sky-only 'waiting for login' backdrop until the world reveal completed. The fix engages the EXISTING F751 presentation machinery on Runtime's login reveal — no duplicated presentation code, no timers: - LocalPlayerTeleportController gains a login arm keyed off the Runtime-owned login reveal generation (RuntimeWorldTransitState .BeginLoginReveal, begun on the first accepted local-player position on every entry route). It drives the same TeleportAnimSequencer/ PortalTunnelPresentation lifecycle and the same enter/exit cues; the Place edge is a no-op at login (the first-entry conductor already committed the canonical placement — retail's analogue only flips position_update_complete), and FireLoginComplete now performs EnterWorld + the single LoginComplete send + reveal Complete, exactly like the F751 pump. worldReady is latched on BOTH canonical first placement (OnLocalPlayerFirstEntryCompleted, the repointed GraphicalSessionEventRoute completion callback that used to send LoginComplete immediately) AND destination reveal readiness. ActiveDestinationCell now also reports the login destination so the render frame's reveal-preparation arm keeps running after portal-space entry flips ChaseModeEverEntered. - PlayerModeController.TryEnterPortalSpaceForLogin performs the player-mode presentation attach (the same BuildControllerAndCamera the post-reveal auto-entry used to run) before flipping into portal space — at login no player-mode entry has happened yet. TryEnterPortalSpace itself now refuses (retryable) on a constructed-but-unpublished Runtime controller via the documented CanExecuteLiveMovement skip predicate instead of faulting — the first connected run crashed on exactly that pre-publication State write. - HouseQuery stays at first-entry completion (retail: tail-called from CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge, not a tunnel edge). - An F751 arriving mid-login-tunnel withdraws the login claim and hands the presentation to the portal pump, which owns the single LoginComplete — matching retail's one teleportInProgress flag. TS-28 narrowed: the graphical host now runs the full login wormhole; the residual is headless-only (no presentation; placement-edge send). Live gates (testaccount2/+Horan vs local ACE, Release): the character-select Enter route and the --session-config direct auto-select route both play the wormhole with Sound_UI_EnterPortal at animation begin, hold with retail's 'In Portal Space - Please Wait...' notice until readiness, fade out with the view-plane warp, send LoginComplete at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login tests), Runtime 1744/0, full solution green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Input/PlayerModeController.cs | 28 +- .../Net/LiveSessionRuntimeFactory.cs | 25 +- .../LocalPlayerTeleportController.cs | 333 +++++++++++++++++- ...ityNetworkOnPositionCollapseMatrixTests.cs | 2 + ...yNetworkRemoteTeleportPresentationTests.cs | 2 + .../LocalPlayerTeleportControllerTests.cs | 157 ++++++++- 7 files changed, 533 insertions(+), 16 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b1d2689d..091f74a4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -436,7 +436,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | -| TS-28 | **NARROWED 2026-08-03** — F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | +| TS-28 | **NARROWED 2026-08-17 (enter-world round)** — the GRAPHICAL host now runs retail's full login wormhole: the login reveal (`RuntimeWorldTransitState.BeginLoginReveal`, shared by direct auto-select, character-select Enter, and enter-after-create) arms the same `TeleportAnimSequencer`/`PortalTunnelPresentation` machine the F751 pump uses (`LocalPlayerTeleportController` login arm), with `Sound_UI_EnterPortal`/`Sound_UI_ExitPortal` at retail's edges and LoginComplete sent at the WorldFadeIn end gated on canonical first placement. Residual: HEADLESS hosts have no presentation — prepared headless sends LoginComplete once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs` (login arm); `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (first-entry completion latch); `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` | Headless hosts are bots — an animation hold would only delay automation; their placement-edge send remains the truthful admission contract. | A headless bot's LoginComplete reaches ACE seconds earlier than a graphical client's, so its observer-visible materialization is earlier than retail cadence. | `SmartBox::teleport_in_progress @ 0x00451C20`; `gmSmartBoxUI::UseTime @ 0x004D6E30` (login edge @ 0x004D6EAB, LoginComplete @ 0x004D745D); `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300` (enter cue @ 0x004D638E); `SmartBox::UseTime @ 0x00455410` (position_update_complete @ 0x00455483); `CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90`; holtburger `client/messages.rs:391-422` | | ~~TS-29~~ | **RETIRED 2026-08-08 (Campaign A slices A5/A6).** Both halves are resolved, in opposite directions. **Ambient:** ported. `AmbientSoundGatherer` walks retail's 3x3 landblock ring x 64 land cells off the region file's `SoundInfo`/`SceneInfo`/`TerrainInfo` chain, `AmbientSoundScheduler` runs the absolute-deadline queue, and continuous beds are re-fired one-shots on `min_rate` rather than looping voices — retail never sets the DirectSound loop flag, so the `StartAmbient`/`StopAmbient` handle API this row described modelled a mechanism that does not exist and is deleted. **Music:** there is nothing to port. Retail EoR links a complete winmm MIDI player and never feeds it — `midiPlay` has zero callers, the string "music" appears zero times in the 65 MB decomp, `SoundType` has no music member, `InitPrefs` registers no music key, and the retail install ships no music files. What players remember as dungeon music is the AdminEnvirons `UI_*` stinger family (TS-54, landed at A4). | retired | — | — | `Ambient::UpdatePlayQueue @ 0x551A50`; `Ambient::Play @ 0x5517A0`; `Ambient::UseTime @ 0x551880`; `CLandBlock::add_ambient_sounds @ 0x530310`; `docs/research/2026-08-08-audio-retail-ambient-runtime.md`; `docs/research/2026-08-08-audio-retail-music-absence.md` | | TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index ee8903d3..faa2e1d5 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -141,13 +141,39 @@ internal sealed class PlayerModeController : if (Controller is null && !TryEnter("teleport")) return false; - if (Controller is not { } controller) + // Enter-world round (2026-08-17): the Runtime first-entry conductor + // can have CONSTRUCTED the controller without publishing it yet + // (CandidatePreparing/Sealed/Dormant). A State write in that window + // throws (EnsurePublishedForRuntimeOperation); CanExecuteLiveMovement + // is the documented skip-instead-of-fault predicate for exactly this + // pre-publication login window. Refuse — every caller retries on its + // own tick cadence. + if (Controller is not { CanExecuteLiveMovement: true } controller) return false; controller.State = PlayerState.PortalSpace; return true; } + /// + /// Enter-world round (2026-08-17): the login tunnel's portal-space entry. + /// The login wormhole begins before any player-mode entry has happened + /// (retail's camera set simply follows its always-present player), so + /// this first performs the SAME presentation attach the post-reveal + /// auto-entry used to run ( — chase camera, shadow, + /// animation sinks, approach lifetime), then flips into portal space. + /// itself refuses until the Runtime first-entry + /// conductor commits, so pre-publication calls fail closed and retry. + /// + public bool TryEnterPortalSpaceForLogin() + { + _autoEntry?.Cancel(); + if (!_mode.IsPlayerMode && !TryEnter("login")) + return false; + + return TryEnterPortalSpace(); + } + public void EnterWorld() { if (Controller is { } controller) diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index cecf9923..9791477e 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -337,16 +337,25 @@ internal sealed class LiveSessionRuntimeFactory _world.FirstEntryDrive, _ => { - session.SendGameAction(GameActionLoginComplete.Build()); + // Enter-world round (2026-08-17): the graphical host no + // longer sends LoginComplete here. Retail sends 0xA1 at the + // END of the login wormhole (gmSmartBoxUI::UseTime + // @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification + // @0x00562E90), and the login portal-space presentation now + // runs on this host, so the send rides its + // FireLoginComplete edge (LocalPlayerTeleportController's + // login pump) — the same edge the F751 pump already uses. + // This notification is the presentation's worldReady latch: + // the canonical local-player first placement committed + // (retail's position_update_complete=1 analogue, + // SmartBox::UseTime @0x00455483). + _world.Teleport.OnLocalPlayerFirstEntryCompleted(); // Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00 // is tail-called, unconditionally, from the end of - // CPlayerSystem::InitializePlayer @0x00563570 — the SAME - // once-per-session function AttemptSendLoginCompleteNotification - // lives in (guarded by player_initialized), right after that - // notification. This is the graphical host's direct - // (non-portal) first-entry completion edge — the exact - // analogue. Portal-space re-entries (LocalPlayerTeleportController) - // do NOT resend it, matching retail's single-shot guard. + // CPlayerSystem::InitializePlayer @0x00563570 (guarded by + // player_initialized, once per session) — an object-arrival + // edge, NOT a tunnel edge, so it stays at first-entry + // completion rather than moving with LoginComplete. session.SendHouseQuery(); }, _world.AcceptedPositionDrive, diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index ec960380..9b447e78 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -25,6 +25,21 @@ internal interface ILocalPlayerTeleportNetworkSink RuntimeTeleportDestination destination, bool teleportTimestampAdvanced); + /// + /// Enter-world round (2026-08-17): the graphical host's local-player + /// first-entry conductor completed its canonical initial placement. + /// This is the login analogue of retail's + /// SmartBox::UseTime @ 0x00455410 setting + /// position_update_complete = 1 — the fact that lets the login + /// portal-space presentation leave its Tunnel hold. LoginComplete + /// (0xA1) itself now rides the presentation's own + /// edge, matching + /// retail's send at the WorldFadeIn end + /// (gmSmartBoxUI::UseTime @ 0x004D745D → + /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90). + /// + void OnLocalPlayerFirstEntryCompleted(); + void ResetSession(); void ResetGenerationPresentation(); @@ -65,6 +80,9 @@ internal sealed class DeferredLocalPlayerTeleportNetworkSink bool teleportTimestampAdvanced) => Required().OfferDestination(destination, teleportTimestampAdvanced); + public void OnLocalPlayerFirstEntryCompleted() => + Required().OnLocalPlayerFirstEntryCompleted(); + public void ResetSession() => Required().ResetSession(); public void ResetGenerationPresentation() => @@ -102,6 +120,19 @@ internal interface ILocalPlayerTeleportModeOperations PlayerMovementController? Controller { get; } Matrix4x4 Projection { get; } bool TryEnterPortalSpace(); + + /// + /// Enter-world round (2026-08-17): the LOGIN arm's portal-space entry. + /// Unlike — whose F751 callers run when + /// player-mode presentation is already attached (or deliberately absent, + /// e.g. fly mode) — the login tunnel begins BEFORE any player-mode entry + /// has happened, so this operation must also perform the same + /// presentation attach the post-reveal auto-entry used to do (chase + /// camera, shadow, animation sinks) before flipping the controller into + /// portal space. Refuses (retryable) until the Runtime first-entry + /// conductor has published the movement controller. + /// + bool TryEnterPortalSpaceForLogin(); void EnterWorld(); } @@ -449,6 +480,37 @@ internal sealed class LocalPlayerTeleportController private long _lifetimeGeneration; private bool _disposed; + /// + /// Enter-world round (2026-08-17): the login reveal generation this + /// controller's presentation currently owns (0 = none). Retail runs the + /// SAME wormhole machine at initial login as at an F751 teleport — + /// SmartBox::teleport_in_progress @ 0x00451C20 returns 1 whenever + /// the SmartBox has a player whose position_update_complete is + /// still 0, which is true the moment the login CreatePlayer lands, and + /// gmSmartBoxUI::UseTime @ 0x004D6EAB edge-detects that flag into + /// BeginTeleportAnimation(TAS_TUNNEL) @ 0x004D6EC9 (which plays + /// Sound_UI_EnterPortal @ 0x004D638E) with no F751 involved. + /// acdream's canonical equivalent of that condition is Runtime's login + /// reveal (, + /// begun on the first accepted local-player position) — so this arm keys + /// the same presentation off that Runtime-owned lifecycle instead of a + /// teleport start. + /// + private long _loginRevealGeneration; + private bool _loginPresentationActive; + + /// + /// Latched by — the + /// first-entry conductor's canonical initial placement committed. The + /// login pump's worldReady requires it, so the sequencer cannot + /// leave its Tunnel hold (and therefore cannot reach + /// ) before the same + /// placement contract that previously gated the immediate LoginComplete + /// send. Session-scoped: cleared only by session-level resets. + /// + private bool _loginPlacementCompleted; + private float _loginHoldSeconds; + public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -476,8 +538,35 @@ internal sealed class LocalPlayerTeleportController public bool IsActive => _transit.IsTeleportActive; public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible; - public uint ActiveDestinationCell => - _transit.IsTeleportActive ? _pendingCell : 0u; + + /// + /// The destination the portal viewport is currently holding for. The + /// render frame's reveal-preparation arm + /// () + /// keys composite-texture preparation and readiness evaluation off this + /// cell; the login presentation must report its own destination here + /// because entering portal space flips ChaseModeEverEntered, which + /// retires the fallback "waiting for login" cell source that used to keep + /// preparation running pre-entry. + /// + public uint ActiveDestinationCell + { + get + { + if (_transit.IsTeleportActive) + return _pendingCell; + if (_loginPresentationActive) + { + RuntimePortalSnapshot snapshot = _transit.Snapshot; + if (snapshot.Kind == RuntimePortalKind.Login + && snapshot.Generation == _loginRevealGeneration) + { + return snapshot.Readiness.DestinationCell; + } + } + return 0u; + } + } public void OnTeleportStarted(uint sequence) { @@ -515,13 +604,22 @@ internal sealed class LocalPlayerTeleportController TryAimAcceptedDestination(); } + public void OnLocalPlayerFirstEntryCompleted() + { + ThrowIfDisposed(); + _loginPlacementCompleted = true; + } + public void Tick(float deltaSeconds) { ThrowIfDisposed(); TryActivatePendingPresentation(); TryAimAcceptedDestination(); if (!_transit.IsTeleportActive) + { + TickLoginPresentation(deltaSeconds); return; + } long generation = _lifetimeGeneration; ushort sequence = _transit.ActiveTeleportSequence; @@ -902,6 +1000,225 @@ internal sealed class LocalPlayerTeleportController + $"(seq={_transit.ActiveTeleportSequence})"); } + /// + /// Enter-world round (2026-08-17): the login half of retail's ONE + /// wormhole machine. Retail begins the identical TAS_TUNNEL animation for + /// initial login and for F751 teleports from the same + /// gmSmartBoxUI::UseTime @ 0x004D6EAB flag edge — + /// SmartBox::teleport_in_progress @ 0x00451C20 goes high the + /// moment the login player object exists with + /// position_update_complete == 0, no F751 required. acdream's + /// canonical login edge is Runtime's login reveal generation + /// (, begun on the + /// first accepted local-player position on every entry route: direct + /// auto-select, character-select Enter, and enter-after-create). + /// + /// + /// Activation retries every Tick until + /// + /// succeeds — the same retry shape the F751 arm uses — which requires the + /// Runtime first-entry conductor's published movement controller. + /// Entering portal space cancels the player-mode auto-entry (its first + /// statement), so this arm owns the reveal's + /// EnterWorld/LoginComplete/Complete suffix exactly like the teleport + /// pump owns its own; the update-frame order (teleport phase before + /// auto-entry) guarantees this claim happens before auto-entry could + /// fire. + /// + /// + private void TryActivateLoginPresentation() + { + if (_transit.HasPendingTeleportStart || _transit.IsTeleportActive) + return; + + RuntimePortalSnapshot snapshot = _transit.Snapshot; + if (snapshot.Kind != RuntimePortalKind.Login + || snapshot.Generation == 0 + || snapshot.Completed + || snapshot.Cancelled + || _loginRevealGeneration == snapshot.Generation) + { + return; + } + + // Quiet pre-gate: until the first-entry conductor PUBLISHES the + // movement controller, portal-space entry cannot succeed (and the + // full entry path would log a refusal every tick). Retry silently. + if (_mode.Controller is not { CanExecuteLiveMovement: true }) + return; + + long generation = _lifetimeGeneration; + if (!_mode.TryEnterPortalSpaceForLogin() + || _lifetimeGeneration != generation + || _mode.Controller is null) + { + return; + } + + // Re-read after the mode entry: TryEnterPortalSpaceForLogin can run + // arbitrary presentation attach work. + snapshot = _transit.Snapshot; + if (snapshot.Kind != RuntimePortalKind.Login + || snapshot.Generation == 0 + || snapshot.Completed + || snapshot.Cancelled) + { + return; + } + + _loginRevealGeneration = snapshot.Generation; + _loginPresentationActive = true; + _loginHoldSeconds = 0f; + _presentation.Begin(_mode.Projection); + Console.WriteLine( + $"live: login portal-space presentation started " + + $"(gen={snapshot.Generation} " + + $"cell=0x{snapshot.Readiness.DestinationCell:X8})"); + } + + /// + /// Per-frame pump for the login presentation — the login mirror of the + /// teleport pump in . Differences, each anchored in + /// retail: there is no Place edge to drive (the first-entry conductor + /// committed the canonical placement before this presentation could + /// begin — retail's SmartBox::UseTime @ 0x00455483 likewise only + /// flips position_update_complete, it does not place), and + /// LoginComplete rides + /// at the WorldFadeIn end (gmSmartBoxUI::UseTime @ 0x004D745D → + /// CPlayerSystem::SendLoginCompleteNotification @ 0x00562E90) + /// instead of at raw first placement. + /// + private void TickLoginPresentation(float deltaSeconds) + { + TryActivateLoginPresentation(); + + RuntimePortalSnapshot snapshot = _transit.Snapshot; + bool revealActive = snapshot.Kind == RuntimePortalKind.Login + && snapshot.Generation != 0 + && !snapshot.Completed + && !snapshot.Cancelled; + if (!revealActive || _loginRevealGeneration != snapshot.Generation) + { + if (_loginPresentationActive) + { + // The reveal this presentation was serving ended underneath + // it (cancel, supersession, or session reset that did not + // route through this controller's own reset). Drop the claim + // and retire the visuals; a successor reveal re-activates + // through TryActivateLoginPresentation above. + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + _presentation.Reset(); + } + return; + } + + if (!_loginPresentationActive) + return; + + long generation = _lifetimeGeneration; + long revealGeneration = snapshot.Generation; + uint destinationCell = snapshot.Readiness.DestinationCell; + + bool originReady = !_streaming.IsRecenterPending; + bool worldReady = _loginPlacementCompleted + && originReady + && _worldReveal.Evaluate(destinationCell).IsReady; + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + + if (!worldReady) + _loginHoldSeconds += deltaSeconds; + _presentation.SetWaitCue( + !worldReady + && _worldReveal.ObserveWait( + TimeSpan.FromSeconds(_loginHoldSeconds))); + + var (_, events) = _presentation.Tick(deltaSeconds, worldReady); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + + foreach (TeleportAnimEvent teleportEvent in events) + { + switch (teleportEvent) + { + case TeleportAnimEvent.PlayEnterSound: + // Sound_UI_EnterPortal as the animation begins — + // BeginTeleportAnimation @ 0x004D638E, identical for the + // login entry. Logged (once per login) as the audio-start + // evidence line for connected gates. + Console.WriteLine( + "live: login portal-space enter cue " + + "(Sound_UI_EnterPortal)"); + _presentation.PlayEnterCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.EnterTunnel: + _presentation.EnterTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.Place: + // No login Place edge: the canonical initial placement is + // the first-entry conductor's, already committed (the + // worldReady latch above requires it). Retail's login + // analogue only flips position_update_complete + // (SmartBox::UseTime @ 0x00455483). + break; + case TeleportAnimEvent.PlayExitSound: + // Release destination cell blocking at the exact + // portal/world viewport swap — same edge as the teleport + // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with + // Sound_UI_ExitPortal @ 0x004D7405. + _worldReveal.RevealWorldViewport(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.PlayExitCue(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _presentation.ExitTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + break; + case TeleportAnimEvent.FireLoginComplete: + _mode.EnterWorld(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _session.SendLoginComplete(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; + _worldReveal.Complete(); + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + Console.WriteLine( + "live: login portal-space presentation complete"); + return; + default: + break; + } + } + + _presentation.TickTunnel(deltaSeconds); + } + + /// + /// The login pump's currency check — the login mirror of + /// : same controller + /// lifetime, still no active teleport (an F751 supersedes the login + /// presentation), and the transit snapshot still carries the exact + /// claimed login reveal generation. + /// + private bool IsCurrentLoginLifetime( + long lifetimeGeneration, + long revealGeneration) => + _lifetimeGeneration == lifetimeGeneration + && !_transit.IsTeleportActive + && _loginRevealGeneration == revealGeneration + && _transit.Snapshot.Generation == revealGeneration; + private void TryAimAcceptedDestination() { if (!_transit.TryGetAcceptedTeleportDestination( @@ -1030,6 +1347,18 @@ internal sealed class LocalPlayerTeleportController _placementCommitted = false; _awaitingDeferredWake = false; _holdSeconds = 0f; + // Enter-world round (2026-08-17): an F751 arriving mid-login-tunnel + // (clearSession: false) withdraws the login presentation's claim — + // the portal pump supersedes it and owns the single LoginComplete, + // exactly as retail's one teleportInProgress flag stays high across + // both and sends once at the eventual WorldFadeIn end. The + // placement-completed latch is a session fact: it survives the + // teleport-scoped reset and clears only with the session. + _loginRevealGeneration = 0; + _loginPresentationActive = false; + _loginHoldSeconds = 0f; + if (clearSession) + _loginPlacementCompleted = false; _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index cb21a238..8d8d99ff 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -1825,6 +1825,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs index 74f03454..1b5eaa34 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -1059,6 +1059,8 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() { } + public void ResetSession() { } public void ResetGenerationPresentation() { } diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index b1f9ae53..e9174380 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -911,12 +911,22 @@ public sealed class LocalPlayerTeleportControllerTests public readonly RuntimeLocalPlayerMovementState Movement; public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource(); + /// + /// Enter-world round (2026-08-17): mutable so a login-arm test can + /// flip destination readiness mid-flight (the login tunnel's hold is + /// exactly "readiness not yet true"). Initialized from the ctor's + /// existing worldReady parameter, so every prior test reads + /// identically. + /// + public bool WorldReady; + public Harness( int centerX = 0x20, int centerY = 0x21, bool worldReady = true, List? order = null) { + WorldReady = worldReady; order ??= new List(); Mode = new FakeMode(order); Streaming = new FakeStreaming(centerX, centerY, order); @@ -927,10 +937,10 @@ public sealed class LocalPlayerTeleportControllerTests Reveal = new WorldRevealCoordinator( Transit, revealWindow: () => RevealWindow, - isRenderNeighborhoodReady: (_, _, _) => worldReady, - isSpawnCellReady: _ => worldReady, - isTerrainNeighborhoodReady: (_, _) => worldReady, - areCompositeTexturesReady: () => worldReady, + isRenderNeighborhoodReady: (_, _, _) => WorldReady, + isSpawnCellReady: _ => WorldReady, + isTerrainNeighborhoodReady: (_, _) => WorldReady, + areCompositeTexturesReady: () => WorldReady, prepareCompositeTextures: (_, _) => { }, invalidateCompositeTextures: () => { }, isSpawnClaimUnhydratable: _ => false, @@ -1330,6 +1340,14 @@ public sealed class LocalPlayerTeleportControllerTests return true; } + public int EnterPortalForLoginCount; + + public bool TryEnterPortalSpaceForLogin() + { + EnterPortalForLoginCount++; + return TryEnterPortalSpace(); + } + public void EnterWorld() { _order.Add("enter-world"); @@ -1439,12 +1457,14 @@ public sealed class LocalPlayerTeleportControllerTests private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink { public List Starts { get; } = []; + public int FirstEntryCompletions; public void OnTeleportStarted(uint sequence) => Starts.Add(sequence); public void OfferDestination( RuntimeTeleportDestination destination, bool teleportTimestampAdvanced) { } + public void OnLocalPlayerFirstEntryCompleted() => FirstEntryCompletions++; public void ResetSession() { } @@ -1482,6 +1502,135 @@ public sealed class LocalPlayerTeleportControllerTests Assert.False(harness.Presentation.IsPortalViewportVisible); } + // ── Enter-world round (2026-08-17): the LOGIN portal-space arm ────── + // + // Retail runs the identical TAS_TUNNEL wormhole on initial login — + // SmartBox::teleport_in_progress @ 0x00451C20 goes high the moment the + // login player exists with position_update_complete == 0, and + // gmSmartBoxUI::UseTime @ 0x004D6EAB begins the same animation (and the + // same Sound_UI_EnterPortal @ 0x004D638E) it begins for an F751. These + // tests drive the login reveal (RuntimeWorldTransitState.BeginLoginReveal + // via WorldRevealCoordinator.BeginLogin — the shared edge of every entry + // route) through the controller's login pump. + + [Fact] + public void LoginReveal_ArmsThePortalSpacePresentation_WithRetailCues() + { + var order = new List(); + var harness = new Harness(worldReady: false, order: order); + + harness.Reveal.BeginLogin(0x20210001u); + Assert.Equal(RuntimePortalKind.Login, harness.Reveal.Snapshot.Kind); + + // First tick: the arm claims the reveal, enters portal space, and + // begins the presentation. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection); + Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); + + // Enter cue at animation begin, tunnel viewport on its own edge — + // the same event contract as the F751 pump. + harness.Presentation.Enqueue(TeleportAnimEvent.PlayEnterSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter"], harness.Presentation.Cues); + + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // Destination not ready and first placement not yet completed: the + // sequencer must keep seeing worldReady == false (its Tunnel hold). + Assert.All(harness.Presentation.WorldReadyValues, value => Assert.False(value)); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // Readiness alone is not enough — the first-entry conductor's + // canonical placement is half of the latch. + harness.WorldReady = true; + harness.Controller.Tick(0.016f); + Assert.False(harness.Presentation.WorldReadyValues[^1]); + + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.WorldReadyValues[^1]); + + // The login pump has no Place edge of its own: the conductor already + // placed the player (retail: SmartBox::UseTime @ 0x00455483 only + // flips position_update_complete). + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + harness.Controller.Tick(0.016f); + Assert.False(harness.Placement.Called); + + // Viewport swap: reservation release + exit cue + tunnel retire + // (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405). + harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); + harness.Controller.Tick(0.016f); + Assert.Equal(["enter", "exit"], harness.Presentation.Cues); + Assert.False(harness.Presentation.IsPortalViewportVisible); + Assert.Single(harness.Streaming.ReservationEnds); + + // WorldFadeIn end: EnterWorld + the ONE LoginComplete + reveal + // completion (gmSmartBoxUI::UseTime @ 0x004D745D). + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Contains("enter-world", order); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + + // The completed reveal stays completed; no re-arm, no second send. + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(1, harness.Session.LoginCompleteCount); + } + + [Fact] + public void LoginPump_SendsLoginCompleteOnlyAtThePresentationEnd() + { + var harness = new Harness(worldReady: true); + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + + // Ticks before the FireLoginComplete edge never send. + for (int i = 0; i < 5; i++) + harness.Controller.Tick(0.016f); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + harness.Controller.Tick(0.016f); + Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + } + + [Fact] + public void RealTeleportStart_SupersedesTheLoginPresentation() + { + var order = new List(); + var harness = new Harness(worldReady: true, order: order); + harness.Reveal.BeginLogin(0x20210001u); + harness.Controller.OnLocalPlayerFirstEntryCompleted(); + harness.Controller.Tick(0.016f); + harness.Presentation.Enqueue(TeleportAnimEvent.EnterTunnel); + harness.Controller.Tick(0.016f); + Assert.True(harness.Presentation.IsPortalViewportVisible); + + // An F751 mid-login-tunnel (ACE can relocate a broken spawn at + // login): the portal pump takes the presentation over and owns the + // single LoginComplete, mirroring retail's one teleportInProgress + // flag spanning both. + harness.Controller.OnTeleportStarted(3); + Assert.Contains("presentation-reset", order); + Assert.True(harness.Controller.IsActive); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // The login claim is gone: ticking the portal-active controller + // never re-enters the login pump (no second presentation-begin from + // the login arm beyond the teleport activation's own). + int loginCompletes = harness.Session.LoginCompleteCount; + harness.Controller.Tick(0.016f); + Assert.Equal(loginCompletes, harness.Session.LoginCompleteCount); + } + private sealed class FakePresentation : ILocalPlayerTeleportPresentation { private readonly List _order; From 997b72045572e250049e590cc0b0f6867f77f2a1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 10:52:13 +0200 Subject: [PATCH 7/8] fix(test): UI probe pointer commands convert canvas to window coordinates; char-select Enter logs its outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation probe fed element-center CANVAS coordinates straight into UiRoot.OnMouseMove/Down/Up, which take WINDOW coordinates and map window->canvas internally (UiRoot.MapWindowToCanvas). The two spaces are identical on every screen without UiRoot.FixedCanvasSize — every prior probe gate passed — but the character-select/chargen screens stretch an authored 800x600 canvas across the window, so every synthetic click and hover landed at canvas*(canvas/window): nowhere near the target. The enter-world connected gate's 'click element 0x100003A2' silently did nothing for two full rounds. Element-derived pointer paths (ClickAt, DragAt, HoverElement) now convert canvas->window via UiRoot.CanvasScale; raw 'mousemove x y' stays a passthrough. CharacterManagementUiController.EnterSelected also logs a once-per-click outcome line ('[UI] character enter accepted/rejected status=...') — a refused Enter was previously indistinguishable from a click that never dispatched (both silent), which cost a connected-gate round to tell apart. Co-Authored-By: Claude Fable 5 --- .../Layout/CharacterManagementUiController.cs | 7 +++++ .../UI/Testing/RetailUiAutomationProbe.cs | 29 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index 963d5e23..f42e241a 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -580,6 +580,13 @@ internal sealed class CharacterManagementUiController : IDisposable // remains authoritative and closes it on InWorld/error/reset. EnsureEnterWait(); RuntimeCommandResult result = _bindings.Enter(); + // Enter-world round (2026-08-17): once-per-click outcome line — a + // refused Enter was previously indistinguishable from a click that + // never dispatched (both silent), which cost a full connected-gate + // round to tell apart. + Console.WriteLine(result.Accepted + ? "[UI] character enter accepted" + : $"[UI] character enter rejected status={result.Status}"); if (!result.Accepted) CloseContext(ref _enterWaitContext, suppressCallback: true); InvalidateAndTick(); diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs index d3ab64c1..f9a76784 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Numerics; using System.Text; using AcDream.Core.Items; @@ -147,7 +148,9 @@ public sealed class RetailUiAutomationProbe { var target = FindByDatElementId(datElementId); if (target is null) return Fail($"element 0x{datElementId:X8} not found"); - _root.OnMouseMove((int)target.CenterX, (int)target.CenterY); + // Same canvas→window conversion as ClickAt — see CanvasToWindow. + (int x, int y) = CanvasToWindow((int)target.CenterX, (int)target.CenterY); + _root.OnMouseMove(x, y); return true; } @@ -282,8 +285,30 @@ public sealed class RetailUiAutomationProbe } } + /// + /// Enter-world round (2026-08-17): element coordinates from the probe's + /// tree walk are CANVAS coordinates, but / + /// OnMouseDown/OnMouseUp take WINDOW coordinates and map + /// window→canvas internally (UiRoot.MapWindowToCanvas). The two + /// spaces are identical on every ordinary screen (no + /// ), which is why every prior probe + /// gate passed — the character-select/chargen screens are the first + /// STRETCHED canvases this apparatus drove, and there the mismatch sent + /// every synthetic click to canvas·(canvas/window), i.e. nowhere near the + /// target element. Element-derived pointer paths therefore convert + /// canvas→window here before touching the root. + /// + private (int x, int y) CanvasToWindow(int x, int y) + { + Vector2 scale = _root.CanvasScale; + return scale == Vector2.One + ? (x, y) + : ((int)MathF.Round(x * scale.X), (int)MathF.Round(y * scale.Y)); + } + private void ClickAt(int x, int y) { + (x, y) = CanvasToWindow(x, y); Advance(16); _root.OnMouseMove(x, y); _root.OnMouseDown(UiMouseButton.Left, x, y); @@ -294,6 +319,8 @@ public sealed class RetailUiAutomationProbe private void DragAt(int startX, int startY, int endX, int endY) { + (startX, startY) = CanvasToWindow(startX, startY); + (endX, endY) = CanvasToWindow(endX, endY); Advance(16); _root.OnMouseMove(startX, startY); _root.OnMouseDown(UiMouseButton.Left, startX, startY); From 51183e431f544171491ebc2701b18e03578caf4a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 17 Aug 2026 11:20:12 +0200 Subject: [PATCH 8/8] =?UTF-8?q?test(ui):=20automation=20runner=20gains=20`?= =?UTF-8?q?click=20at=20=20`=20=E2=80=94=20raw=20synthetic=20canvas?= =?UTF-8?q?=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitals round's connected verify needed to click ONE specific Character-tab option row, but every toggle row is a template instance sharing the same dat element ids (0x10000218/0x10000219), so `click element` (first-match by dat id) cannot address a row. The drive script now reads the row's rect from its own `dump` line and clicks its center — same synthetic UiRoot press/release route as ClickElement, never the OS cursor (the same no-real-input constraint the morning gate's hover/mousemove verbs follow). Used live: the Side-By-Side Vitals checkbox + Apply choreography that verified db8fa328's swap both directions over a real ACE session. Co-Authored-By: Claude Fable 5 --- .../UI/Testing/RetailUiAutomationProbe.cs | 16 ++++++++++++++++ .../UI/Testing/RetailUiAutomationScriptRunner.cs | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs index d3ab64c1..069f6d26 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationProbe.cs @@ -131,6 +131,22 @@ public sealed class RetailUiAutomationProbe return true; } + /// + /// Raw synthetic click at canvas coordinates (vitals retail-modes round, + /// 2026-08-17): the Options panel's Character-tab rows are template + /// instances sharing ONE dat element id per control + /// (0x10000218/0x10000219 for every toggle row), so + /// element-id addressing cannot reach a SPECIFIC row's checkbox — a + /// drive script instead reads the row's rect from + /// and clicks its center. Same synthetic route as + /// — never the OS cursor. + /// + public bool ClickAtPoint(int x, int y) + { + ClickAt(x, y); + return true; + } + /// /// 2026-08-17 morning gate: synthetic pointer HOVER (no click) at an /// element's center, for rollover/tooltip verification. Deliberately diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 46d0ec37..7a0c9c7c 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -219,7 +219,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private bool DoClick(ScriptCommand command) { var p = command.Parts; - if (p.Length < 3) return Stop(command, "usage: click element | click item [source]"); + if (p.Length < 3) return Stop(command, "usage: click element | click item [source] | click at "); string target = p[1].ToLowerInvariant(); if (target == "element") { @@ -231,7 +231,19 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'"); return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed"); } - return Stop(command, "usage: click element | click item [source]"); + if (target == "at") + { + // `click at ` — raw synthetic click in canvas coordinates + // (see RetailUiAutomationProbe.ClickAtPoint: template-instanced + // controls share one dat id, so a specific row is addressed by + // the rect its own `dump` line reports). + if (p.Length < 4 + || !TryParseInt(p[2], out int x) + || !TryParseInt(p[3], out int y)) + return Stop(command, "usage: click at "); + return _probe.ClickAtPoint(x, y) || Stop(command, "click at failed"); + } + return Stop(command, "usage: click element | click item [source] | click at "); } /// 2026-08-17 morning gate: `hover element <datId>` — a