From ef96c5548982cd988d5d06168dd75373b4e9e9dd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 11:50:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui,net):=20Campaign=20LA=20gate=20round=202?= =?UTF-8?q?=20=E2=80=94=20char-select=20exit=20confirmation,=20authored=20?= =?UTF-8?q?row=20justify,=20world=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit button (element 0x100003A4, offset 7 from the listbox base in ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog (0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text (table 0x23000002) and m_confirmExitDialogContext re-entry guard are now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760 case 1's ConfirmationResult check) the client exits through the EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings .RequestExit -> d.Window.Close, the same delegate GameplayInputCommandController's Escape fallback already uses) so disconnected/exited status events still fire via GameWindow.OnClosing -> CompleteShutdown. Retail's real post-confirm destination is QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round does not port — recorded as AD-99. Credits (element 0x100003A3, QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like Create, same treatment, out of scope this round. Finding 2 (row names center-aligned, retail is left): the character row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT confirmed HJustify=Left with three stateful Type-3 highlight-art children and no Type-12 caption child) authors its OWN justify directly, with no separate text child to lift a label from. DatWidgetFactory.BuildButton's Left-justify branch required !ReferenceEquals(labelInfo, info) — true only when a label was LIFTED from a distinct child — so a button's own direct HJustify=Left was silently dropped to UiButton's Center default. Widened the branch to also honor the direct case, preserving the existing lifted-child LabelOffsetX behavior and leaving genuinely-centered buttons (CREATE/ENTER/DELETE/RESTORE) untouched. Finding 3 (World box empty): parsed ACE's GameMessageServerName (opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/ GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo @0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 -> ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed by gmCharacterManagementUI::UpdateWorldName@0x004ec120 / RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against holtburger's ServerNameData. WorldSession.ServerNameReceived fires alongside CharacterListReceived (ACE sends both in one SendConnectResponse batch); RuntimeCharacterSelectionState. ApplyWorldName is the new J-owner field (ungated by lifecycle, since either message can arrive first); CharacterManagementUiController binds it onto the WorldTextElementId UiText. Per the LA1 status vocabulary, the characterList STATUS event's worldName field is intentionally NOT added this round (kept bounded to the client-side fix) — a follow-up if the launcher UI wants it. Also corrects AD-44, discovered stale while filing AD-99: its opening claim ("acdream has no retained character-management screen") was false as of this session — LA7/LA8 shipped the screen in earlier commits without updating this row. Tests: exit-confirm open/cancel/confirm/re-entry-guard flow; DatWidgetFactory own-HJustify-Left/Center regression tests plus the live-DAT pinned row-justify assertion; ServerName parse round-trip (byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode cases); WorldSession dispatch test (roster+world in one wire batch); RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent of ApplyRoster, unchanged-value no-op, Reset clears); controller test binding the World text element to the live snapshot. Extended the shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a Confirmation-type branch (Accept/Reject buttons) since this is its first RetailDialogType.Confirmation consumer. Suites: full solution Release build green; AcDream.App.Tests 5100/6 skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1) green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- .../InteractionRetainedUiComposition.cs | 8 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 1 + .../Layout/CharacterManagementUiController.cs | 91 ++++++++++++- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 18 ++- src/AcDream.App/UI/RetailUiRuntime.cs | 32 ++++- src/AcDream.Core.Net/Messages/ServerName.cs | 90 +++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 29 ++++ .../Session/LiveSessionController.cs | 15 ++- .../Session/RuntimeCharacterSelectionState.cs | 31 +++++ .../InteractionUiRuntimeSourcesTests.cs | 1 + .../Layout/CharacterManagementLiveDatTests.cs | 35 +++++ .../CharacterManagementUiControllerTests.cs | 127 +++++++++++++++++- .../UI/Layout/DatWidgetFactoryTests.cs | 61 +++++++++ .../UI/Layout/RetailDialogFactoryTests.cs | 26 ++++ .../Messages/ServerNameTests.cs | 81 +++++++++++ .../WorldSessionCharacterSelectionTests.cs | 37 +++++ .../RuntimeCharacterSelectionStateTests.cs | 49 +++++++ 18 files changed, 724 insertions(+), 13 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/ServerName.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6e05a0c9..01fc2d58 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 74 active rows (AD-98 filed 2026-08-15 at Campaign LA gate round 2 — the char-select root's own background stretches instead of tiling by resizing the mounted root element to the live viewport and marking its background quad UV 0..1, substituting for retail's fixed-800x600-canvas-stretched-at-presentation mechanism which acdream's live-resolution render pipeline has no analogue for; AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 75 active rows (AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2 — the char-select root's own background stretches instead of tiling by resizing the mounted root element to the live viewport and marking its background quad UV 0..1, substituting for retail's fixed-800x600-canvas-stretched-at-presentation mechanism which acdream's live-resolution render pipeline has no analogue for; AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -151,7 +151,7 @@ readiness/requeue adaptation. See | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | -| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | +| AD-44 | **NARROWED 2026-08-15 at Campaign LA gate round 2 (staleness caught while filing AD-99) — the opening clause was WRONG as of this session: Campaign LA's LA7/LA8 slices (landed in earlier commits on this branch) shipped a real retained `gmCharacterManagementUI`-authored character-select screen (`CharacterManagementUiController`, `RuntimeCharacterSelectionState`), and no register row was updated when they did.** What remains true: `TrySelectFirstAvailable` still deterministically picks the first active, non-greyed identity, but ONLY for headless/no-selector sessions and probe connects (LA7's no-selector flow) — a graphical session without a character selector now stops at the retained selection screen instead of auto-entering. Native-window close still performs retail's complete character-logoff handshake plus transport disconnect instead of returning to character selection; there remains no in-client path from in-world back to a live character-select screen (AD-99 documents the adjacent Exit-button gap: the screen's OWN Exit button now exists and confirms, but also closes the client rather than returning to selection). One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.Runtime/Session/LiveSessionController.cs` (`StartCore`'s `AwaitCharacterSelection` branch); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (the retained screen); `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | Headless/probe sessions still need unattended selection (no UI to select from) — the deterministic fallback remains correct THERE. A full in-client "log off character, return to selection" flow is separate session/wire work no slice has scoped yet. | A headless/probe account with multiple playable characters still enters the first wire-order identity without an explicit choice (by design — no UI exists in that host). An eventual in-client "log off character" action still cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management — the graphical screen exists now, but nothing feeds it from an in-world state. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | @@ -192,6 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | | AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | +| AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | --- diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b54b1f67..6673cdf9 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -955,7 +955,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterSelectionRequestDelete, late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionRestore, - late.GameRuntime.CharacterSelectionCancel) + late.GameRuntime.CharacterSelectionCancel, + // Campaign LA gate round 2 finding 1: the SAME + // window-close path GameplayInputCommandController's + // Escape fallback uses (IGameplayWindowCommands.Close + // /GameplayWindowCommands wrap this same d.Window.Close + // delegate) — no separate exit path. + d.Window.Close) : null); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 6e656da7..930e8412 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -181,6 +181,7 @@ internal sealed class CurrentGameRuntimeAdapter AccountName: string.Empty, SlotCount: 0, RosterCount: 0, + WorldName: string.Empty, HighlightedCharacterId: 0u, HighlightedDisplayIndex: -1, PendingDeleteCharacterId: 0u, diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index bc242a25..eb2c7c8c 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -13,25 +13,46 @@ internal sealed class CharacterManagementUiController : IDisposable { internal const uint RootEnum = 0x10000005u; internal const uint RootElementId = 0x1000039Au; + internal const uint WorldTextElementId = 0x1000039Bu; internal const uint ListElementId = 0x1000039Du; internal const uint CreateElementId = 0x100003A0u; internal const uint EnterElementId = 0x100003A2u; internal const uint DeleteElementId = 0x1000039Fu; internal const uint RestoreElementId = 0x1000039Eu; + /// + /// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id + /// switch is keyed off idElement - 0x1000039d (the listbox base); + /// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers + /// (Register@0x0047a69e) — out of scope this round (finding 1 note). + /// + internal const uint CreditsElementId = 0x100003A3u; + /// Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250. + internal const uint ExitElementId = 0x100003A4u; internal sealed record DialogStrings( Func DeleteConfirmation, string DeleteResponse, string PleaseWait, - string EnteringWorld); + string EnteringWorld, + /// + /// Retail ID_CharacterManagement_ConfirmExit (table + /// 0x23000002) — "Are you sure you want to leave?", the text + /// MakeConfirmExitDialog@0x004ed250 resolves via + /// StringInfo::SetStringIDandTableEnum(compute_str_hash( + /// "ID_CharacterManagement_ConfirmExit"), 0x10000002). + /// + string ConfirmExit); private readonly UiRoot _host; private readonly ImportedLayout _layout; + private readonly UiText _worldText; private readonly UiTemplateListBox _list; private readonly UiButton _create; private readonly UiButton _enter; private readonly UiButton _delete; private readonly UiButton _restore; + private readonly UiButton _credits; + private readonly UiButton _exit; private readonly RetailDialogFactory _dialogs; private readonly CharacterSelectionRuntimeBindings _bindings; private readonly DialogStrings _strings; @@ -41,10 +62,12 @@ internal sealed class CharacterManagementUiController : IDisposable private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; private long _lastRevision = long.MinValue; + private string _lastWorldName = string.Empty; private uint _deleteDialogContext; private uint _operationWaitContext; private uint _enterWaitContext; private uint _errorDialogContext; + private uint _confirmExitDialogContext; private bool _active; private bool _restoreCommandInFlight; private bool _suppressDialogCallbacks; @@ -53,22 +76,28 @@ internal sealed class CharacterManagementUiController : IDisposable private CharacterManagementUiController( UiRoot host, ImportedLayout layout, + UiText worldText, UiTemplateListBox list, UiButton create, UiButton enter, UiButton delete, UiButton restore, + UiButton credits, + UiButton exit, RetailDialogFactory dialogs, CharacterSelectionRuntimeBindings bindings, DialogStrings strings) { _host = host; _layout = layout; + _worldText = worldText; _list = list; _create = create; _enter = enter; _delete = delete; _restore = restore; + _credits = credits; + _exit = exit; _dialogs = dialogs; _bindings = bindings; _strings = strings; @@ -101,6 +130,22 @@ internal sealed class CharacterManagementUiController : IDisposable _enter.OnClick = EnterSelected; _delete.OnClick = RequestDelete; _restore.OnClick = RestoreSelected; + + // Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of + // scope this round (finding 1 note) — same "future campaign, visibly + // ghosted, no invented action" treatment as Create above. Filed as + // issue #397. + _credits.Visible = true; + _credits.Enabled = false; + _credits.OnClick = null; + _exit.OnClick = RequestExit; + + // World name (retail UpdateWorldName@0x004ec120 / + // RecvNotice_WorldName@0x004ec360 both just push + // Client::GetWorldName() onto this element). LinesProvider reads the + // live field Tick() updates each time Runtime's snapshot changes. + _worldText.LinesProvider = + () => [new UiText.Line(_lastWorldName, _worldText.DefaultColor)]; } internal UiElement Root => _layout.Root; @@ -109,6 +154,7 @@ internal sealed class CharacterManagementUiController : IDisposable internal uint OperationWaitContext => _operationWaitContext; internal uint EnterWaitContext => _enterWaitContext; internal uint ErrorDialogContext => _errorDialogContext; + internal uint ConfirmExitDialogContext => _confirmExitDialogContext; internal void ResetSession() { @@ -171,11 +217,14 @@ internal sealed class CharacterManagementUiController : IDisposable } if (layout.Root.DatElementId != RootElementId + || layout.FindElement(WorldTextElementId) is not UiText worldText || layout.FindElement(ListElementId) is not UiTemplateListBox list || layout.FindElement(CreateElementId) is not UiButton create || layout.FindElement(EnterElementId) is not UiButton enter || layout.FindElement(DeleteElementId) is not UiButton delete - || layout.FindElement(RestoreElementId) is not UiButton restore) + || layout.FindElement(RestoreElementId) is not UiButton restore + || layout.FindElement(CreditsElementId) is not UiButton credits + || layout.FindElement(ExitElementId) is not UiButton exit) { Console.WriteLine( "[UI] character management: the authored root/list/button contract is incomplete."); @@ -188,11 +237,14 @@ internal sealed class CharacterManagementUiController : IDisposable return new CharacterManagementUiController( host, layout, + worldText, list, create, enter, delete, restore, + credits, + exit, dialogs, bindings, strings); @@ -204,6 +256,7 @@ internal sealed class CharacterManagementUiController : IDisposable enter.OnClick = null; delete.OnClick = null; restore.OnClick = null; + exit.OnClick = null; throw; } } @@ -249,6 +302,11 @@ internal sealed class CharacterManagementUiController : IDisposable _host.BringToFront(Root); } + // World name rides independently of the roster revision gate below — + // ServerName can arrive slightly before or after CharacterList (see + // RuntimeCharacterSelectionState.ApplyWorldName). + _lastWorldName = snapshot.WorldName; + if (_lastGeneration != snapshot.Generation || _lastRevision != snapshot.Revision) { @@ -314,6 +372,7 @@ internal sealed class CharacterManagementUiController : IDisposable _enter.OnClick = null; _delete.OnClick = null; _restore.OnClick = null; + _exit.OnClick = null; foreach (UiButton row in _rows) { row.OnClick = null; @@ -550,6 +609,33 @@ internal sealed class CharacterManagementUiController : IDisposable InvalidateAndTick(); } + private void RequestExit() + { + if (_disposed) + return; + + // MakeConfirmExitDialog @ 0x004ed250's own guard: a second Exit + // click while the confirmation is already open is a no-op. + if (_confirmExitDialogContext != 0u) + return; + + _confirmExitDialogContext = _dialogs.MakeConfirmation( + _strings.ConfirmExit, + data => + { + _confirmExitDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + // RecvNotice_CloseDialog @ 0x004ed760 case 1: only a + // confirmed (OK) close proceeds through the SAME graceful + // shutdown path window-close uses; Cancel leaves the screen + // exactly as it was. + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _bindings.RequestExit(); + }); + } + private void ReconcileDialogs( IRuntimeCharacterSelectionView view, RuntimeCharacterSelectionSnapshot snapshot) @@ -689,6 +775,7 @@ internal sealed class CharacterManagementUiController : IDisposable CloseContext(ref _operationWaitContext, suppressCallback: false); CloseContext(ref _enterWaitContext, suppressCallback: false); CloseContext(ref _errorDialogContext, suppressCallback: false); + CloseContext(ref _confirmExitDialogContext, suppressCallback: false); } finally { diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 5991ba57..eb74843d 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -881,10 +881,24 @@ public static class DatWidgetFactory button.LabelAlign = UiButton.LabelAlignment.Left; button.LabelOffsetX = face.X + face.Width + 4f; } - else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left) + else if (labelInfo.HJustify == HJustify.Left) { + // Campaign LA gate round 2 finding 2: the guard used to require + // labelInfo to be a LIFTED Type-12 text child (!ReferenceEquals), + // so a button authoring its OWN HJustify=Left with no separate + // label child — e.g. gmCharacterManagementUI's character-list row + // template (0x21000004/0x100003A5: HJustify=Left, three stateful + // Type-3 highlight-art children, no Type-12 caption child) — fell + // through with LabelAlign left at UiButton's Center default. + // Live-DAT probe confirmed: rowInfo.HJustify=Left, + // authoredFaces.Length=3 (faceSegments, not a single face), no + // Type-12 child, and the built row's LabelAlign came out Center. + // labelInfo.X is only a valid inner-offset when a distinct child + // was actually lifted; for the direct (labelInfo == info) case, + // leave UiButton's own default 3px LabelOffsetX in place. button.LabelAlign = UiButton.LabelAlignment.Left; - button.LabelOffsetX = labelInfo.X; + if (!ReferenceEquals(labelInfo, info)) + button.LabelOffsetX = labelInfo.X; } return button; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index d8253c91..91c559c9 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -376,6 +376,20 @@ public sealed record KeyboardRuntimeBindings( /// mirror; an absent view means the current adapter has not bound (or has /// already been released). /// +/// +/// Campaign LA gate round 2 finding 1: retail's Exit button +/// (gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0, +/// element offset 7 from the listbox base — id 0x100003A4) opens +/// MakeConfirmExitDialog@0x004ed250; on confirm +/// (RecvNotice_CloseDialog@0x004ed760 case 1) retail queues UI mode +/// 0x10000009 (gmEpilogueUI) rather than exiting immediately — +/// out of scope here. This is a plain host action, not a generation-gated +/// Runtime command: it is the SAME window-close path +/// GameplayWindowCommands/IGameplayWindowCommands.Close already +/// use for the in-world Escape fallback (d.Window.Close at +/// composition), so status events disconnected/exited still +/// fire through GameWindow.OnClosingCompleteShutdown. +/// public sealed record CharacterSelectionRuntimeBindings( Func View, Func Highlight, @@ -383,7 +397,8 @@ public sealed record CharacterSelectionRuntimeBindings( Func RequestDelete, Func ConfirmDelete, Func Restore, - Func Cancel); + Func Cancel, + Action RequestExit); public sealed record RetailUiRuntimeBindings( UiHost Host, @@ -3766,6 +3781,7 @@ public sealed class RetailUiRuntime : IDisposable string? deleteConfirmationProbe; string? pleaseWait; string? enteringWorld; + string? confirmExit; lock (_bindings.Assets.DatLock) { deleteConfirmationProbe = strings.ResolveTemplate( @@ -3787,12 +3803,21 @@ public sealed class RetailUiRuntime : IDisposable strings, stringTableId, "ID_Character_EnteringWorld"); + // Finding 1: MakeConfirmExitDialog@0x004ed250 resolves this via + // compute_str_hash("ID_CharacterManagement_ConfirmExit") against + // the same table-enum-0x10000002 -> 0x23000002 the other + // character-management dialogs already use. + confirmExit = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_ConfirmExit"); } if (deleteConfirmationProbe is null || deleteResponse is null || pleaseWait is null - || enteringWorld is null) + || enteringWorld is null + || confirmExit is null) { Console.WriteLine( "[UI] character management: required retail strings are unavailable."); @@ -3835,7 +3860,8 @@ public sealed class RetailUiRuntime : IDisposable ComposeDeleteConfirmation, deleteResponse, pleaseWait, - enteringWorld)); + enteringWorld, + confirmExit)); } private static string? ResolveCharacterManagementString( diff --git a/src/AcDream.Core.Net/Messages/ServerName.cs b/src/AcDream.Core.Net/Messages/ServerName.cs new file mode 100644 index 00000000..57a39469 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ServerName.cs @@ -0,0 +1,90 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound ServerName GameMessage (opcode 0xF7E1). ACE sends +/// this in the SAME batch as , right after +/// AuthConnectResponse completes — it is the world (server) name the +/// retail character-select screen's "World" box shows. +/// +/// +/// Retail wire path: CM_Login::DispatchUI_WorldInfo@0x006ad860 checks +/// the leading opcode against 0xf7e1, unpacks the trailing +/// PStringBase<char>, and calls +/// ClientUISystem::Handle_Login__WorldInfo@0x005641a0(currentConnections, +/// maxConnections, worldName), which forwards only the name to +/// ECM_Login::SendNotice_WorldName@0x00692b10 (notice id +/// 0x186a2). gmCharacterManagementUI registers for that notice +/// in its ctor (0x004ec8f0) and both +/// RecvNotice_WorldName@0x004ec360 and its own +/// UpdateWorldName@0x004ec120 resolve child element 0x1000039B +/// (UIElement::GetChildRecursive(m_rootField, 0x1000039b), dynamic-cast +/// to UIElement_Text) and call +/// UIElement_Text::SetText(Client::GetInstance()->GetWorldName()) — +/// Client::GetWorldName@0x00401ca0/SetWorldName@0x00402090 just +/// hold the string the notice delivered. The two leading dwords +/// (currentConnections/maxConnections) are read off the wire by the +/// dispatcher but never consumed by the character-management screen itself. +/// +/// +/// +/// ACE: GameMessageOpcode.ServerName = 0xF7E1 +/// (ACE.Server/Network/GameMessages/GameMessageOpcode.cs); +/// GameMessageServerName +/// (ACE.Server/Network/GameMessages/Messages/GameMessageServerName.cs) +/// writes i32 currentConnections, i32 maxConnections, String16L +/// serverName; sent from +/// AuthenticationHandler.SendConnectResponse +/// (ACE.Server/Network/Handlers/AuthenticationHandler.cs:258) +/// alongside GameMessageCharacterList and +/// GameMessageDDDInterrogation. holtburger's +/// ServerNameData +/// (holtburger-protocol/src/messages/character/types.rs) parses the +/// same three fields and cross-checks the field order/types. +/// +/// +/// +/// u32 opcode (0xF7E1) +/// i32 currentConnections +/// i32 maxConnections +/// String16L worldName +/// +/// +public static class ServerName +{ + public const uint Opcode = 0xF7E1u; + + public readonly record struct Parsed( + int CurrentConnections, + int MaxConnections, + string WorldName); + + /// + /// Parse a ServerName body. must start with the + /// 4-byte opcode (0xF7E1) — i.e. pass the full reassembled GameMessage + /// output from . + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != Opcode) + throw new FormatException($"expected ServerName opcode 0x{Opcode:X4}, got 0x{opcode:X8}"); + + int currentConnections = unchecked((int)ReadU32(body, ref pos)); + int maxConnections = unchecked((int)ReadU32(body, ref pos)); + string worldName = StringReader.ReadString16L(body, ref pos); + + return new Parsed(currentConnections, maxConnections, worldName); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 8cfab9f5..04d4397f 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -599,6 +599,12 @@ public sealed class WorldSession : IDisposable public event Action? CharacterDeleteAcknowledged; public event Action? CharacterRestoreReceived; public event Action? CharacterErrorReceived; + /// + /// Campaign LA gate round 2 finding 3: ACE sends this in the same batch + /// as (right after + /// AuthConnectResponse) — see . + /// + public event Action? ServerNameReceived; /// /// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode @@ -691,6 +697,13 @@ public sealed class WorldSession : IDisposable } public CharacterList.Parsed? Characters { get; private set; } + + /// + /// Campaign LA gate round 2 finding 3: last + /// (opcode 0xF7E1) received, mirroring ' shape — + /// ACE sends it in the same batch, right after AuthConnectResponse. + /// + public ServerName.Parsed? ServerInfo { get; private set; } private CharacterError.Parsed? _lastCharacterSelectionError; private readonly IWorldSessionTransport _net; @@ -1789,6 +1802,22 @@ public sealed class WorldSession : IDisposable Characters = parsed; CharacterListReceived?.Invoke(parsed); } + else if (op == ServerName.Opcode) + { + ServerName.Parsed parsed; + try + { + parsed = ServerName.Parse(body); + } + catch + { + // Malformed management messages do not poison the + // remaining ordered UIQueue fragments. + continue; + } + ServerInfo = parsed; + ServerNameReceived?.Invoke(parsed); + } else if (op == CharacterDelete.Opcode && CharacterDelete.IsAcknowledgement(body)) { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 8dae81a4..3cdc3413 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -246,23 +246,27 @@ public sealed class LiveSessionController private readonly Action _delete; private readonly Action _restore; private readonly Action _error; + private readonly Action _worldName; public CharacterSelectionWireBinding( WorldSession session, Action roster, Action delete, Action restore, - Action error) + Action error, + Action worldName) { _session = session; _roster = roster; _delete = delete; _restore = restore; _error = error; + _worldName = worldName; session.CharacterListReceived += roster; session.CharacterDeleteAcknowledged += delete; session.CharacterRestoreReceived += restore; session.CharacterErrorReceived += error; + session.ServerNameReceived += worldName; } public bool IsDisposed => _session is null; @@ -276,6 +280,7 @@ public sealed class LiveSessionController session.CharacterDeleteAcknowledged -= _delete; session.CharacterRestoreReceived -= _restore; session.CharacterErrorReceived -= _error; + session.ServerNameReceived -= _worldName; } } @@ -886,6 +891,14 @@ public sealed class LiveSessionController if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyError(error); } + }, + worldName => + { + lock (_gate) + { + if (IsCurrent(scope, generation)) + CharacterSelectionState.ApplyWorldName(worldName.WorldName); + } }); public RuntimeCommandResult Highlight( diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index 18bfbc93..eb6a07a1 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -36,6 +36,11 @@ public enum RuntimeCharacterSelectionDeltaKind ErrorChanged, EnteringWorld, EnteredWorld, + /// + /// Campaign LA gate round 2 finding 3: arrived — + /// see . + /// + WorldNameChanged, } public readonly record struct RuntimeCharacterSelectionEntry( @@ -77,6 +82,7 @@ public readonly record struct RuntimeCharacterSelectionSnapshot( string AccountName, int SlotCount, int RosterCount, + string WorldName, uint HighlightedCharacterId, int HighlightedDisplayIndex, uint PendingDeleteCharacterId, @@ -202,6 +208,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private long _revision; private string _accountName = string.Empty; private int _slotCount; + private string _worldName = string.Empty; private uint _highlightedCharacterId; private uint _pendingDeleteCharacterId; private uint _lastRestoreRequestedCharacterId; @@ -236,6 +243,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _accountName, _slotCount, _entries.Length, + _worldName, _highlightedCharacterId, selectedIndex, _pendingDeleteCharacterId, @@ -326,6 +334,28 @@ public sealed class RuntimeCharacterSelectionState : IDisposable selected); } + /// + /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName + /// (0x004ec120) / RecvNotice_WorldName (0x004ec360) + /// both just push Client::GetWorldName() onto element + /// 0x1000039B — no lifecycle gate. ACE sends ServerName in + /// the same batch as CharacterList, so this may land slightly + /// before or after ; it is intentionally + /// ungated (beyond disposal) so neither arrival order loses the name. + /// + internal void ApplyWorldName(string worldName) + { + ArgumentNullException.ThrowIfNull(worldName); + lock (_gate) + { + if (_disposed || _worldName == worldName) + return; + _worldName = worldName; + _revision++; + } + Publish(RuntimeCharacterSelectionDeltaKind.WorldNameChanged); + } + internal bool TryHighlight(uint characterId) { lock (_gate) @@ -900,6 +930,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _entries = []; _accountName = string.Empty; _slotCount = 0; + _worldName = string.Empty; _highlightedCharacterId = 0u; _pendingDeleteCharacterId = 0u; _lastRestoreRequestedCharacterId = 0u; diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index c9318931..8017787d 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -308,6 +308,7 @@ public sealed class InteractionUiRuntimeSourcesTests AccountName: "account", SlotCount: 0, RosterCount: 0, + WorldName: string.Empty, HighlightedCharacterId: 0u, HighlightedDisplayIndex: -1, PendingDeleteCharacterId: 0u, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 4a0e911a..500ca6ba 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -67,6 +67,18 @@ public sealed class CharacterManagementLiveDatTests "DELETE"); AssertButton(screen, CharacterManagementUiController.RestoreElementId, "RESTORE"); + // Finding 1: retail's bottom-row Credits/Exit buttons (offsets 6/7 + // from the listbox base in + // gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0). + AssertButton(screen, CharacterManagementUiController.CreditsElementId, + "CREDITS"); + AssertButton(screen, CharacterManagementUiController.ExitElementId, + "EXIT"); + // Finding 3: the World box (retail element 0x1000039B, resolved via + // UpdateWorldName@0x004ec120) imports as a plain UiText the + // controller binds Runtime's ServerName-sourced snapshot field to. + Assert.IsType(screen.FindElement( + CharacterManagementUiController.WorldTextElementId)); Assert.DoesNotContain( Descendants(screen.Root), static element => element is UiViewport); @@ -88,6 +100,21 @@ public sealed class CharacterManagementLiveDatTests ], rowInfo.States.Keys.Order().ToArray()); + // Campaign LA gate round 2 finding 2: the row template's OWN authored + // justify is Left (character names render left-aligned in retail, not + // centered) — it carries three stateful Type-3 highlight-art children + // (0x10000481-0x10000483, the Normal_rollover/Normal_pressed/Highlight/ + // Highlight_rollover face art) and NO Type-12 caption child, so the row's + // Left justify can only come from ElementInfo.HJustify directly, never a + // lifted text child. DatWidgetFactory.BuildButton must honor it. + Assert.Equal(HJustify.Left, rowInfo.HJustify); + Assert.DoesNotContain(rowInfo.Children, static child => child.Type == 12u); + ImportedLayout? builtRowLayout = LayoutImporter.Import( + dats, template.TemplateLayoutId, template.TemplateElementId, + _ => (0u, 0, 0), null, null); + var builtRow = Assert.IsType(builtRowLayout!.Root); + Assert.Equal(UiButton.LabelAlignment.Left, builtRow.LabelAlign); + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); Assert.Equal(0x2100003Cu, dialogDid); ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u); @@ -108,6 +135,14 @@ public sealed class CharacterManagementLiveDatTests "ID_CharacterManagement_PleaseWait")); Assert.Equal("Entering World", Resolve(strings, table, "ID_Character_EnteringWorld")); + // Finding 1: MakeConfirmExitDialog@0x004ed250's text + // (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table + // enum 0x10000002 -> 0x23000002). The raw DAT string carries a + // literal two-character "\n" escape (this test's Resolve() helper + // does not normalize it — RetailUiRuntime does, via + // NormalizeRetailNewlines, before handing it to the controller). + Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table, + "ID_CharacterManagement_ConfirmExit")); string confirmation = Assert.IsType(strings.ResolveTemplate( table, "ID_CharacterManagement_DeleteCharacterConfirmation", diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index d1f73ead..678540f6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -99,6 +99,32 @@ public sealed class CharacterManagementUiControllerTests Assert.True(restore.Enabled); } + /// + /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120 + /// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName() + /// onto element 0x1000039B. The controller binds Runtime's borrowed + /// snapshot field to that same element. + /// + [Fact] + public void WorldName_TicksFromSnapshot_IntoTheWorldTextElement() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + var worldText = Assert.IsType(environment.Screen.FindElement( + CharacterManagementUiController.WorldTextElementId)); + + Assert.Equal( + "sawato", + string.Join(" ", worldText.LinesProvider().Select(static line => line.Text))); + + environment.Runtime.SetWorldName("Frostfell"); + controller.Tick(); + + Assert.Equal( + "Frostfell", + string.Join(" ", worldText.LinesProvider().Select(static line => line.Text))); + } + [Fact] public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters() { @@ -346,6 +372,81 @@ public sealed class CharacterManagementUiControllerTests controller.Rows.Select(static row => row.Label!).ToArray()); } + /// + /// Campaign LA gate round 2 finding 1: Exit -> MakeConfirmExitDialog + /// (0x004ed250, retail's confirm-only dialog type 1) -> Cancel/Reject + /// leaves the screen exactly as it was — no exit request reaches + /// Runtime's window-close binding. + /// + [Fact] + public void ExitButton_OpenThenCancel_KeepsScreenActive_NoExitRequested() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton exit = environment.Button( + CharacterManagementUiController.ExitElementId); + + exit.OnClick!(); + + Assert.NotEqual(0u, controller.ConfirmExitDialogContext); + ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation); + Assert.Equal( + "Are you sure you want to leave?", + Message(dialog)); + DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!(); + + Assert.Equal(0, environment.Runtime.RequestExitCalls); + Assert.Equal(0u, controller.ConfirmExitDialogContext); + Assert.False(environment.Dialogs.IsOpen); + Assert.True(controller.Root.Visible); + } + + /// + /// Confirm reaches the SAME graceful-shutdown seam window-close uses — + /// asserted here via the bindings fake, since the controller/Runtime + /// boundary is a plain host Action + /// (), not a + /// generation-gated Runtime command. + /// + [Fact] + public void ExitButton_OpenThenConfirm_ReachesGracefulShutdownSeam() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton exit = environment.Button( + CharacterManagementUiController.ExitElementId); + + exit.OnClick!(); + ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation); + DialogButton(dialog, RetailConfirmationDialogView.AcceptButtonId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RequestExitCalls); + Assert.Equal(0u, controller.ConfirmExitDialogContext); + Assert.False(environment.Dialogs.IsOpen); + } + + /// + /// MakeConfirmExitDialog's own guard (m_confirmExitDialogContext != 0 + /// -> return): a second Exit click while the confirmation is + /// already open does not open a second dialog. + /// + [Fact] + public void ExitButton_SecondClickWhileOpen_IsNoOp() + { + using var environment = new EnvironmentHarness(); + UiButton exit = environment.Button( + CharacterManagementUiController.ExitElementId); + + exit.OnClick!(); + Assert.Equal(1, environment.DialogLayouts.Count( + entry => entry.Type == RetailDialogType.Confirmation)); + + exit.OnClick!(); + + Assert.Equal(1, environment.DialogLayouts.Count( + entry => entry.Type == RetailDialogType.Confirmation)); + } + [Fact] public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter() { @@ -598,7 +699,8 @@ public sealed class CharacterManagementUiControllerTests name => $"WARNING! {name}\nType DELETE in the box below.", "DELETE", "Please Wait", - "Entering World"); + "Entering World", + "Are you sure you want to leave?"); private static void AssertDetachedAndUnbound(ImportedLayout screen) { @@ -633,6 +735,15 @@ public sealed class CharacterManagementUiControllerTests 0x21000004u, 0x100003A5u)); root.Children.Add(list); + root.Children.Add(new ElementInfo + { + Id = CharacterManagementUiController.WorldTextElementId, + Type = 12u, + X = 21f, + Y = 44f, + Width = 193f, + Height = 110f, + }); root.Children.Add(ButtonInfo( CharacterManagementUiController.CreateElementId)); root.Children.Add(ButtonInfo( @@ -641,6 +752,10 @@ public sealed class CharacterManagementUiControllerTests CharacterManagementUiController.DeleteElementId)); root.Children.Add(ButtonInfo( CharacterManagementUiController.RestoreElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.CreditsElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.ExitElementId)); if (includePreview) { root.Children.Add(new ElementInfo @@ -762,7 +877,8 @@ public sealed class CharacterManagementUiControllerTests RequestDelete, ConfirmDelete, Restore, - Cancel); + Cancel, + RequestExit); } public FakeView View { get; } = new(); @@ -773,6 +889,7 @@ public sealed class CharacterManagementUiControllerTests public int ConfirmDeleteCalls { get; private set; } public int CancelCalls { get; private set; } public int RestoreCalls { get; private set; } + public int RequestExitCalls { get; private set; } public RuntimeCommandStatus RestoreStatus { get; set; } = RuntimeCommandStatus.Accepted; public bool ThrowOnRestore { get; set; } @@ -814,6 +931,9 @@ public sealed class CharacterManagementUiControllerTests public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) => Update(snapshot => snapshot with { Lifecycle = lifecycle }); + public void SetWorldName(string worldName) => + Update(snapshot => snapshot with { WorldName = worldName }); + public void SetError(string message) => Update(snapshot => snapshot with { Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection, @@ -915,6 +1035,8 @@ public sealed class CharacterManagementUiControllerTests return Result(RuntimeCommandStatus.Accepted); } + private void RequestExit() => RequestExitCalls++; + private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) { RuntimeCharacterSelectionEntry? selected = View.Entries @@ -959,6 +1081,7 @@ public sealed class CharacterManagementUiControllerTests "account", SlotCount: 5, RosterCount: View.Entries.Length, + WorldName: "sawato", highlightedCharacterId, HighlightedDisplayIndex: Array.FindIndex( View.Entries, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index a77d51ff..72880d99 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -305,6 +305,67 @@ public class DatWidgetFactoryTests Assert.IsType(e); } + /// + /// Campaign LA gate round 2 finding 2: the retail character-select row + /// template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT + /// confirmed) authors HJustify=Left DIRECTLY on the row's own + /// UIElement_Button — no separate Type-12 caption child (its label comes + /// from the runtime-bound character name, not an authored string), just + /// three stateful Type-3 highlight-art children. The old guard + /// (!ReferenceEquals(labelInfo, info)) only honored HJustify when + /// the label was LIFTED from a distinct Type-12 child, so a button + /// authoring its own justify with no such child fell through to + /// UiButton's Center default. This reproduces that exact shape. + /// + [Fact] + public void BuildButton_OwnHJustifyLeft_NoTextChild_MultipleStatefulFaces_LabelAlignsLeft() + { + var info = new ElementInfo + { + Type = 1, + Width = 160, + Height = 16, + HJustify = HJustify.Left, + }; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" }; + info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" }; + info.States[3u] = new UiStateInfo { Id = 3u, Name = "Highlight" }; + for (int i = 0; i < 3; i++) + { + var face = new ElementInfo { Type = 3, ReadOrder = (uint)i }; + face.StateMedia["Normal_rollover"] = (0x06000000u + (uint)i, 1); + info.Children.Add(face); + } + + var button = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign); + // Direct (non-lifted) case: LabelOffsetX stays at UiButton's own + // default small left padding, not a bogus inner offset. + Assert.Equal(3f, button.LabelOffsetX); + } + + /// + /// A button whose own authored HJustify really is Center (the normal + /// case — CREATE/ENTER/DELETE/RESTORE captions) must stay centered; the + /// fix only widens the Left branch, it must not force every button left. + /// + [Fact] + public void BuildButton_OwnHJustifyCenter_NoTextChild_StaysCentered() + { + var info = new ElementInfo + { + Type = 1, + Width = 160, + Height = 16, + HJustify = HJustify.Center, + }; + + var button = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign); + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 8baca976..17ac70da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -729,6 +729,32 @@ public sealed class RetailDialogFactoryTests Height = 32f, }); } + else if (type == RetailDialogType.Confirmation) + { + // Campaign LA gate round 2 finding 1: CharacterManagementUiController's + // exit-confirm dialog is the first BuildDialogLayout consumer that + // exercises RetailDialogType.Confirmation through this synthetic + // builder (other Confirmation coverage in THIS file uses the real + // FixtureLoader.LoadConfirmationDialog() fixture instead). + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationDialogView.AcceptButtonId, + Type = 1u, + X = 80f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationDialogView.RejectButtonId, + Type = 1u, + X = 240f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + } else if (type == RetailDialogType.ConfirmationTextInput) { var field = new ElementInfo diff --git a/tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs new file mode 100644 index 00000000..dd71ebd2 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs @@ -0,0 +1,81 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class ServerNameTests +{ + [Fact] + public void Parse_MirrorsAceSerializer_ExactFields() + { + // Mirrors ACE's GameMessageServerName: opcode, i32 currentConnections, + // i32 maxConnections, String16L serverName. + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(123) + .Write(1000) + .WriteString16L("sawato"); + + ServerName.Parsed parsed = ServerName.Parse(w.ToArray()); + + Assert.Equal(123, parsed.CurrentConnections); + Assert.Equal(1000, parsed.MaxConnections); + Assert.Equal("sawato", parsed.WorldName); + } + + [Fact] + public void Parse_NegativeMaxConnections_PreservesSign() + { + // ACE's default is maxConnections = -1 (unlimited); the field must + // stay signed rather than being read as a huge unsigned value. + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(0) + .Write(-1) + .WriteString16L("Frostfell"); + + ServerName.Parsed parsed = ServerName.Parse(w.ToArray()); + + Assert.Equal(0, parsed.CurrentConnections); + Assert.Equal(-1, parsed.MaxConnections); + Assert.Equal("Frostfell", parsed.WorldName); + } + + [Fact] + public void Parse_EmptyWorldName_RoundTrips() + { + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(0) + .Write(0) + .WriteString16L(string.Empty); + + ServerName.Parsed parsed = ServerName.Parse(w.ToArray()); + + Assert.Equal(string.Empty, parsed.WorldName); + } + + [Fact] + public void Parse_WrongOpcode_Throws() + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu); + + Assert.Throws(() => ServerName.Parse(bytes)); + } + + [Fact] + public void Parse_TruncatedAfterCurrentConnections_Throws() + { + var w = AceWireWriter.GameMessage(ServerName.Opcode).Write(0); + + Assert.Throws(() => ServerName.Parse(w.ToArray())); + } + + [Fact] + public void Parse_TruncatedBeforeWorldName_Throws() + { + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(0) + .Write(0); + + Assert.Throws(() => ServerName.Parse(w.ToArray())); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 71d5f787..74cba578 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -92,6 +92,30 @@ public sealed class WorldSessionCharacterSelectionTests Assert.Equal(1u, current.SecondsGreyedOut); } + [Fact] + public void ServerName_Dispatches_AndPopulatesServerInfo() + { + // Campaign LA gate round 2 finding 3: ACE's SendConnectResponse + // enqueues CharacterList then ServerName in the same batch + // (AuthenticationHandler.cs:257-261) — assert both arrive, in wire + // order, through the same UIQueue dispatch path. + using var session = CreateSession(); + var events = new List(); + session.CharacterListReceived += _ => events.Add("roster"); + session.ServerNameReceived += info => events.Add($"world:{info.WorldName}"); + + byte[] packet = BuildPacket( + BuildRoster(secondsGreyedOut: 0u), + BuildServerName("sawato", currentConnections: 3, maxConnections: 100)); + InvokeProcessDatagram(session, packet); + + Assert.Equal(["roster", "world:sawato"], events); + Assert.NotNull(session.ServerInfo); + Assert.Equal("sawato", session.ServerInfo!.Value.WorldName); + Assert.Equal(3, session.ServerInfo!.Value.CurrentConnections); + Assert.Equal(100, session.ServerInfo!.Value.MaxConnections); + } + [Fact] public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() { @@ -183,6 +207,19 @@ public sealed class WorldSessionCharacterSelectionTests return writer.ToArray(); } + private static byte[] BuildServerName( + string worldName, + int currentConnections, + int maxConnections) + { + var writer = new PacketWriter(64); + writer.WriteUInt32(ServerName.Opcode); + writer.WriteUInt32(unchecked((uint)currentConnections)); + writer.WriteUInt32(unchecked((uint)maxConnections)); + writer.WriteString16L(worldName); + return writer.ToArray(); + } + private static byte[] BuildRestoreResponse() { var writer = new PacketWriter(64); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs index c8b79b30..aeb6a4fe 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs @@ -291,6 +291,55 @@ public sealed class RuntimeCharacterSelectionStateTests delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation)); } + [Fact] + public void ApplyWorldName_PopulatesSnapshot_IndependentOfRoster() + { + // Campaign LA gate round 2 finding 3: ACE sends ServerName in the + // same batch as CharacterList; ApplyWorldName must not require + // ApplyRoster to have run first (arrival order is not guaranteed). + using var state = new RuntimeCharacterSelectionState(); + state.Begin(new RuntimeGenerationToken(5)); + + Assert.Equal(string.Empty, state.Snapshot.WorldName); + + state.ApplyWorldName("sawato"); + Assert.Equal("sawato", state.Snapshot.WorldName); + + state.ApplyRoster(Roster( + new LiveSessionRosterEntry(0x50000001u, "One", 0u))); + Assert.Equal("sawato", state.Snapshot.WorldName); + Assert.Equal(0x50000001u, state.Snapshot.HighlightedCharacterId); + } + + [Fact] + public void ApplyWorldName_UnchangedValue_DoesNotBumpRevisionOrPublish() + { + using var state = new RuntimeCharacterSelectionState(); + state.Begin(new RuntimeGenerationToken(6)); + state.ApplyWorldName("sawato"); + var deltas = new List(); + using IDisposable subscription = state.View.Subscribe( + new Observer(deltas.Add)); + long revision = state.Snapshot.Revision; + + state.ApplyWorldName("sawato"); + + Assert.Equal(revision, state.Snapshot.Revision); + Assert.Empty(deltas); + } + + [Fact] + public void Reset_ClearsWorldName() + { + using var state = new RuntimeCharacterSelectionState(); + state.Begin(new RuntimeGenerationToken(8)); + state.ApplyWorldName("sawato"); + + state.Reset(new RuntimeGenerationToken(9)); + + Assert.Equal(string.Empty, state.Snapshot.WorldName); + } + private static LiveSessionRosterReport Roster( params LiveSessionRosterEntry[] entries) => new("Canonical", 11, entries);