diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8fb46bb4..6504323a 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) — 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, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); 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) — 76 active rows (AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; 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, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); 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 @@ -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`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | | 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/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 5ae45097..9d9949c4 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -170,6 +170,7 @@ line, writer opens `FileShare.Read`, tailer opens `enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`, `pluginFailed{plugin,error}`, `loginCommandFailed{commandIndex,command,error}`, +`characterCreated{guid,name}`, `creationFailed{code,reason,name}`, `disconnected{reason}`, `exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"` (ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH @@ -177,6 +178,26 @@ sides. Unknown `e` values must parse to a typed Unknown event, never throw; a known `e` with a wrong payload shape should be distinguishable from an unknown `e` (LA3 review finding 12). +**Campaign CC CC2 amendment (this section is the contract; the writer and +tailer below implement it, in that order):** `characterCreated{guid,name}` +fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request — +`guid`/`name` come straight off the shared `0xF643` +`CharGenVerificationResponse` Ok identity payload +(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately +named `guid`/`name` rather than `characterId`/`characterName` to mirror +that payload's own field names and to read distinctly from +`enteredWorld` — a freshly created character is logged straight in by +retail without a fresh `characterList` (see that type's doc comment), so +`characterCreated` can precede an `enteredWorld` for the same character +rather than replacing it. `creationFailed{code,reason,name}` fires on any +non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code` +value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a +reader gets a stable readable reason without hard-coding the numeric +mapping itself, and `name` is the ATTEMPTED character name so a launcher +can render "the name Bob is taken". (CC2 review F4: the enum member +originally rode the `name` key, colliding in meaning with +`characterCreated.name`; renamed before any consumer shipped.) + `loginCommandFailed.commandIndex` is the zero-based index in the configured `loginCommands` array. `command` is the exact configured line and `error` is the isolated parser/router/handler failure. The event is observational: the @@ -214,7 +235,8 @@ Three pieces, one slice, because they share the session-config/status seam: config; absent → permanent no-op sink). Versioned event vocabulary (`"v":1`): `started`, `connected`, `characterList`, `enteredWorld`, `pluginLoaded`/`pluginFailed`, - `loginCommandFailed`, `disconnected`, `exited`. + `loginCommandFailed`, `characterCreated`/`creationFailed` (Campaign CC + CC2), `disconnected`, `exited`. Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL sink with four kinds (lifecycle/failure/event/resources) and NO per-session file — the status writer is a second, separate sink, not a rework of the diff --git a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs new file mode 100644 index 00000000..11eab682 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs @@ -0,0 +1,152 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Shared parser for opcode 0xF643 — retail's +/// CharacterGenerationVerificationResponse shape, which BOTH +/// (opcode 0xF7D9 request) and +/// (opcode 0xF656 request) receive on +/// the exact same wire opcode — a genuine retail opcode reuse, confirmed by +/// ACE's own GameMessageOpcode.cs declaring both +/// CharacterCreateResponse = 0xF643 and +/// CharacterRestoreResponse = 0xF643, // This is a duplicate.... +/// +/// +/// Campaign CC CC2: this type is the promotion of the parse logic +/// that used to live only in (Campaign +/// LA slice LA7a). Character creation now exists (), +/// so the two message families that collide on this opcode are both real and +/// both need it — keeps its own +/// shape for source compatibility and +/// delegates to this type internally; new code (the create response, +/// WorldSession.CharacterCreateResponseReceived) consumes +/// directly. A caller cannot tell "restore response" +/// from "create response" by opcode or shape alone — WorldSession +/// disambiguates by tracking which outbound request (restore vs. create) it +/// is awaiting a reply to (see WorldSession's awaiting-request latch). +/// That latch is not merely a reasonable design — it is retail's OWN +/// mechanism: Handle_CharGenVerificationResponse@0x0055E8B0 case 1 +/// branches on the client's persistent chargen state, +/// GetVerificationState() == PENDING → new CharacterIdentity +/// + AddIdentity (a create it initiated), else → unpack into the +/// existing identity at slot (a restore). Same discriminator, one +/// layer down (CC2 review's fidelity note). +/// +/// +/// +/// Wire layout, verbatim from ACE's GameMessageCharacterCreateResponse.cs +/// / GameMessageCharacterRestore.cs (both write the identical shape) +/// and cross-checked against holtburger's +/// CharacterCreateResponseData::unpack +/// (holtburger-protocol/src/messages/character/types.rs:379-410): +/// +/// +/// +/// u32 opcode (0xF643) +/// u32 code (CharacterGenerationVerificationResponse) +/// -- only when code == Ok -- +/// u32 guid +/// String16L name +/// u32 secondsGreyedOut +/// +/// +/// +/// is a verbatim port of ACE's +/// CharacterGenerationVerificationResponse enum +/// (ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs), +/// which is itself retail's own dialog dispatch table +/// (Handle_CharGenVerificationResponse@0x0055E8B0): NameInUse → +/// ID_Character_Err_NameReserved, NameBanned → +/// ID_Character_Err_NameBanned, Corrupt/DatabaseDown → +/// ID_Character_Err_NameDBDown, AdminPrivilegeDenied → +/// ID_Character_Err_NameAdminDenied. Pending/Undef +/// retail treats as a silent state reset with no dialog — notably ACE sends +/// Pending for a disabled-Olthoi rejection +/// (CharacterHandler.CharacterCreateEx, +/// olthoi_play_disabled branch), so that specific rejection is +/// invisible to the retail-faithful client too; this is a retail quirk to +/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job +/// (App layer), not this Core.Net type's. +/// +/// +public static class CharGenVerificationResponse +{ + public const uint ResponseOpcode = 0xF643u; + + /// + /// Verbatim port of ACE's CharacterGenerationVerificationResponse + /// enum, which is retail's own Handle_CharGenVerificationResponse + /// dispatch table. + /// + public enum Code : uint + { + Undef = 0, + Ok = 1, + Pending = 2, + NameInUse = 3, + NameBanned = 4, + Corrupt = 5, + DatabaseDown = 6, + AdminPrivilegeDenied = 7, + } + + /// + /// Parsed 0xF643 body. , , and + /// are only populated when + /// equals — retail omits + /// them entirely on the wire otherwise (both + /// GameMessageCharacterCreateResponse and + /// GameMessageCharacterRestore gate the trailing fields on + /// response == ... .Ok). + /// + public readonly record struct Parsed( + uint RawCode, + uint? Guid, + string? Name, + uint? SecondsGreyedOut) + { + /// + /// Best-effort named view of . A plain enum + /// cast never throws in C#, so this is safe even for a value retail + /// never defined — always trust as the source + /// of truth. + /// + public Code AsCode => (Code)RawCode; + + /// True when the trailing identity fields are present. + public bool IsOk => RawCode == (uint)Code.Ok; + } + + /// + /// Parse a 0xF643 body. must start with + /// the 4-byte opcode. + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != ResponseOpcode) + throw new FormatException( + $"expected CharacterGenerationVerificationResponse opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}"); + + uint rawCode = ReadU32(body, ref pos); + if (rawCode != (uint)Code.Ok) + return new Parsed(rawCode, null, null, null); + + uint guid = ReadU32(body, ref pos); + string name = StringReader.ReadString16L(body, ref pos); + uint secondsGreyedOut = ReadU32(body, ref pos); + + return new Parsed(rawCode, guid, name, secondsGreyedOut); + } + + 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/Messages/CharacterCreate.cs b/src/AcDream.Core.Net/Messages/CharacterCreate.cs new file mode 100644 index 00000000..56aecaeb --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterCreate.cs @@ -0,0 +1,319 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-creation request (opcode 0xF656). Campaign CC +/// slice CC2 — the outbound half of retail creation; the shared 0xF643 +/// response is (see that type's doc +/// comment for the two-family opcode collision with +/// , and WorldSession's awaiting-request +/// latch for how the two are disambiguated on receipt). +/// +/// +/// Wire layout ported byte-for-byte from +/// Proto_UI::SendCharGenResult@0x00546a70 (packs the account name, +/// then calls ACCharGenResult::Pack@0x005c7570 → +/// ACCharGenResult::CG_Pack@0x005c7200) and cross-checked against +/// ACE's CharacterCreateInfo.Unpack / Appearance.Unpack +/// (ACE.Entity/CharacterCreateInfo.cs, ACE.Entity/Appearance.cs) +/// and holtburger's CharacterCreateRequestData +/// (holtburger-protocol/src/messages/character/types.rs:236-369), +/// which agree on every field and its order: +/// +/// +/// +/// u32 opcode (0xF656) +/// String16L accountName (packed OUTSIDE CG_Pack, by SendCharGenResult itself) +/// -- ACCharGenResult::CG_Pack body -- +/// u32 constant (always 1 — CG_Pack@0x005c7208) +/// u32 heritage +/// u32 gender +/// u32 eyesStrip +/// u32 noseStrip +/// u32 mouthStrip +/// u32 hairColor +/// u32 eyeColor +/// u32 hairStyle +/// u32 headgearStyle +/// u32 headgearColor +/// u32 shirtStyle +/// u32 shirtColor +/// u32 trousersStyle +/// u32 trousersColor +/// u32 footwearStyle +/// u32 footwearColor +/// f64 skinShade +/// f64 hairShade +/// f64 headgearShade +/// f64 shirtShade +/// f64 trousersShade +/// f64 footwearShade +/// u32 template +/// u32 strength +/// u32 endurance +/// u32 coordination +/// u32 quickness +/// u32 focus +/// u32 self +/// u32 slot (ACE: CharacterSlot — NOT the character guid) +/// u32 classId +/// u32 numSkills (MUST be exactly ) +/// u32[] skillAdvancementClasses (numSkills entries) +/// String16L name +/// u32 startArea +/// u32 isAdmin +/// u32 isEnvoy (ACE: IsSentinel) +/// u32 checksum (see ) +/// +/// +/// +/// The 55-slot invariant. ACE's PlayerFactory.Create +/// (reached from CharacterHandler.CharacterCreateEx) rejects a +/// client/server skill-table mismatch by TERMINATING the session +/// (PlayerFactory.CreateResult.ClientServerSkillsMismatch → +/// session.Terminate(SessionTerminationReason.ClientVersionIncorrect, ...)) +/// — there is no graceful recovery from sending the wrong count. Retail's +/// live skill table has exactly +/// (55) skills, so takes +/// skillAdvancementClasses as a and +/// throws for any length other than 55 — +/// structurally impossible to send anything else through this builder. +/// +/// +/// +/// The trailing checksum. Retail computes and sends it +/// (CG_Pack@0x005c74c3, the final *(uint32_t*)ecx_33 = +/// (ebx_18 + self) store); ACE's CharacterCreateInfo.Unpack never +/// reads it (the reader consumes isSentinel and stops — see +/// ACE.Entity/CharacterCreateInfo.cs:67) and holtburger's +/// CharacterCreateRequestData::unpack agrees (its field list ends at +/// is_sentinel, no checksum read). We compute and send it anyway for +/// byte fidelity with a genuine retail client. Decompiled accumulation +/// order (CG_Pack@0x005c7213-0x005c74c3) sums EXACTLY: +/// heritage, gender, the three appearance strips (eyes/nose/mouth), +/// hairColor, eyeColor, hairStyle, headgearStyle, shirtStyle, trousersStyle, +/// footwearStyle, template, and the six attributes (strength through self). +/// Notably ABSENT from the sum despite being adjacent fields on the wire: +/// headgearColor, shirtColor, trousersColor, footwearColor, all six f64 +/// shades, slot, and classId — mirrors that +/// exact (and exactly that) field set. u32 addition is commutative and +/// associative modulo 2^32, so summation order does not affect the result; +/// orders the terms for readability, not +/// wire fidelity. +/// +/// +/// +/// Routing. Proto_UI::SendCharGenResult sends via +/// Proto_UI::SendToLogon@0x00546b03 — the SAME queue as +/// 's request +/// (Proto_UI::SendDeleteCharacter@0x00546b83, also SendToLogon) +/// and CharacterEnterWorld's request +/// (Proto_UI::SendEnterWorld@0x00546c12). WorldSession's outbound +/// helper, SendCharacterCreation, sends on +/// GameMessageGroup.LoginQueue — the same queue +/// WorldSession.SendDeleteCharacter already uses. +/// +/// +/// +/// Account-name gate. ACE's CharacterCreate handler +/// (CharacterHandler.cs:27-32) silently drops the request when the +/// packed account name doesn't match session.Account — the same +/// silent-no-reply shape 's doc comment already +/// warns about for restore. WorldSession's awaiting-request latch +/// must never assume a reply is coming. +/// +/// +/// +/// ACE double-sends NameInUse (CC2 review F3). +/// CharacterHandler.CharacterCreateEx calls +/// IsCharacterNameAvailable TWICE — once at the top and once after +/// PlayerFactory.Create — and the first callback's return +/// exits only the lambda, so a duplicate name yields TWO 0xF643 +/// NameInUse replies. The first consumes the latch; the second hits +/// WorldSession's unrequested-response drop path (register AD-100) +/// and logs "unexpected CharacterGenerationVerificationResponse". During a +/// connected gate against ACE that log line is EXPECTED after a +/// duplicate-name rejection, not an acdream defect — and CC3's verification +/// gate must not treat the second reply as an error. +/// +/// +public static class CharacterCreate +{ + public const uint Opcode = 0xF656u; + + /// + /// Retail's live skill-advancement-class table size. ACE terminates the + /// session on any other count — see the class doc comment. + /// + public const int SkillAdvancementClassCount = 55; + + /// + /// The fourteen style/color strip fields plus the six f64 shade fields — + /// Appearance.Unpack's exact field set and order + /// (ACE.Entity/Appearance.cs). + /// + public readonly record struct Appearance( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairColor, + uint EyeColor, + uint HairStyle, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade); + + /// The six primary attributes, retail's fixed str/end/coord/quick/focus/self order. + public readonly record struct Attributes( + uint Strength, + uint Endurance, + uint Coordination, + uint Quickness, + uint Focus, + uint Self); + + /// + /// Every field of an outbound CharacterCreate EXCEPT the account name + /// (a separate parameter, packed outside + /// CG_Pack — see the class doc comment) and the skill-advancement + /// array (a parameter so its length is + /// validated at the call site rather than smuggled through a record + /// field of unbounded size). + /// + public readonly record struct Request( + uint Heritage, + uint Gender, + Appearance Appearance, + uint Template, + Attributes Attributes, + uint Slot, + uint ClassId, + string Name, + uint StartArea, + bool IsAdmin, + bool IsEnvoy); + + /// + /// Build the body bytes for an outbound CharacterCreate request. + /// See the class doc comment for the exact byte layout. + /// + /// + /// .Length is not exactly + /// — ACE terminates the session + /// on any other count, so this builder refuses to construct the request + /// at all rather than send something retail-invalid. + /// + public static byte[] BuildRequestBody( + string accountName, + Request request, + ReadOnlySpan skillAdvancementClasses) + { + ArgumentNullException.ThrowIfNull(accountName); + ArgumentNullException.ThrowIfNull(request.Name); + if (skillAdvancementClasses.Length != SkillAdvancementClassCount) + { + throw new ArgumentException( + "retail's CG_Pack numSkills must be exactly " + + $"{SkillAdvancementClassCount} — ACE terminates the session " + + "(PlayerFactory.CreateResult.ClientServerSkillsMismatch) on " + + $"any other count. Got {skillAdvancementClasses.Length}.", + nameof(skillAdvancementClasses)); + } + + Appearance appearance = request.Appearance; + Attributes attributes = request.Attributes; + + var w = new PacketWriter( + 256 + (skillAdvancementClasses.Length * 4) + (request.Name.Length * 2)); + w.WriteUInt32(Opcode); + w.WriteString16L(accountName); + + // -- ACCharGenResult::CG_Pack body -- + w.WriteUInt32(1u); // CG_Pack@0x005c7208 constant + w.WriteUInt32(request.Heritage); + w.WriteUInt32(request.Gender); + w.WriteUInt32(appearance.EyesStrip); + w.WriteUInt32(appearance.NoseStrip); + w.WriteUInt32(appearance.MouthStrip); + w.WriteUInt32(appearance.HairColor); + w.WriteUInt32(appearance.EyeColor); + w.WriteUInt32(appearance.HairStyle); + w.WriteUInt32(appearance.HeadgearStyle); + w.WriteUInt32(appearance.HeadgearColor); + w.WriteUInt32(appearance.ShirtStyle); + w.WriteUInt32(appearance.ShirtColor); + w.WriteUInt32(appearance.TrousersStyle); + w.WriteUInt32(appearance.TrousersColor); + w.WriteUInt32(appearance.FootwearStyle); + w.WriteUInt32(appearance.FootwearColor); + w.WriteDouble(appearance.SkinShade); + w.WriteDouble(appearance.HairShade); + w.WriteDouble(appearance.HeadgearShade); + w.WriteDouble(appearance.ShirtShade); + w.WriteDouble(appearance.TrousersShade); + w.WriteDouble(appearance.FootwearShade); + w.WriteUInt32(request.Template); + w.WriteUInt32(attributes.Strength); + w.WriteUInt32(attributes.Endurance); + w.WriteUInt32(attributes.Coordination); + w.WriteUInt32(attributes.Quickness); + w.WriteUInt32(attributes.Focus); + w.WriteUInt32(attributes.Self); + w.WriteUInt32(request.Slot); + w.WriteUInt32(request.ClassId); + w.WriteUInt32((uint)skillAdvancementClasses.Length); + foreach (uint skill in skillAdvancementClasses) + w.WriteUInt32(skill); + w.WriteString16L(request.Name); + w.WriteUInt32(request.StartArea); + w.WriteUInt32(request.IsAdmin ? 1u : 0u); + w.WriteUInt32(request.IsEnvoy ? 1u : 0u); + w.WriteUInt32(ComputeChecksum(request)); + + return w.ToArray(); + } + + /// + /// Retail's trailing checksum field — see the class doc comment for the + /// exact decompiled accumulation and the fields deliberately absent from + /// it. ACE never reads this field; acdream sends it for byte fidelity + /// with a genuine retail client. + /// + public static uint ComputeChecksum(Request request) + { + Appearance a = request.Appearance; + Attributes b = request.Attributes; + return unchecked( + request.Heritage + + request.Gender + + a.EyesStrip + + a.NoseStrip + + a.MouthStrip + + a.HairColor + + a.EyeColor + + a.HairStyle + + a.HeadgearStyle + + a.ShirtStyle + + a.TrousersStyle + + a.FootwearStyle + + request.Template + + b.Strength + + b.Endurance + + b.Coordination + + b.Quickness + + b.Focus + + b.Self); + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterRestore.cs b/src/AcDream.Core.Net/Messages/CharacterRestore.cs index 794cc50d..8d3caf90 100644 --- a/src/AcDream.Core.Net/Messages/CharacterRestore.cs +++ b/src/AcDream.Core.Net/Messages/CharacterRestore.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Messages; @@ -75,11 +74,28 @@ namespace AcDream.Core.Net.Messages; /// fields are read only when verificationFlag == 1. Because the two /// message families are wire-identical when they collide, a caller cannot /// tell "restore response" from "create response" by opcode or shape -/// alone — it must track which outbound request (this file's -/// vs. a future CharacterCreate) it is -/// awaiting a reply to. Character creation is out of this campaign's scope -/// (design spec §7 non-goals); this type does not attempt to disambiguate -/// the two families itself. +/// alone — it must track which outbound request +/// ( vs. +/// ) +/// it is awaiting a reply to. +/// +/// +/// +/// Campaign CC CC2 update: character creation now exists +/// (), so the +/// disambiguation this doc comment used to defer is real work now, done by +/// WorldSession's awaiting-request latch (set by +/// WorldSession.SendRestoreCharacter / +/// WorldSession.SendCharacterCreation, cleared on the matching +/// response), which routes each 0xF643 to +/// WorldSession.CharacterRestoreReceived or +/// WorldSession.CharacterCreateResponseReceived accordingly and drops +/// (rather than misattributes) a 0xF643 with no outstanding request. The +/// wire parse itself is now shared: delegates to +/// , which both families +/// consume. This type's own shape and +/// signature are UNCHANGED by that refactor — every existing caller and test +/// keeps working exactly as before. /// /// public static class CharacterRestore @@ -119,32 +135,14 @@ public static class CharacterRestore /// /// Parse a CharacterRestore response body (opcode 0xF643). - /// must start with the 4-byte opcode. + /// must start with the 4-byte opcode. Delegates + /// to the shared (Campaign + /// CC CC2); this type's shape and this method's + /// exception behavior are unchanged from before that refactor. /// public static Parsed Parse(ReadOnlySpan body) { - int pos = 0; - - uint opcode = ReadU32(body, ref pos); - if (opcode != ResponseOpcode) - throw new FormatException($"expected CharacterRestore response opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}"); - - uint verificationFlag = ReadU32(body, ref pos); - if (verificationFlag != 1u) - return new Parsed(verificationFlag, null, null, null); - - uint guid = ReadU32(body, ref pos); - string name = StringReader.ReadString16L(body, ref pos); - uint secondsGreyedOut = ReadU32(body, ref pos); - - return new Parsed(verificationFlag, guid, name, secondsGreyedOut); - } - - 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; + CharGenVerificationResponse.Parsed shared = CharGenVerificationResponse.Parse(body); + return new Parsed(shared.RawCode, shared.Guid, shared.Name, shared.SecondsGreyedOut); } } diff --git a/src/AcDream.Core.Net/Packets/PacketWriter.cs b/src/AcDream.Core.Net/Packets/PacketWriter.cs index f7edd92a..54e633a1 100644 --- a/src/AcDream.Core.Net/Packets/PacketWriter.cs +++ b/src/AcDream.Core.Net/Packets/PacketWriter.cs @@ -95,6 +95,13 @@ public sealed class PacketWriter _position += 4; } + public void WriteDouble(double value) + { + EnsureCapacity(8); + BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan(_position), value); + _position += 8; + } + /// Pad with zeros so the buffer length is a multiple of 4. public void AlignTo4() { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 04d4397f..0881b2cb 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -598,6 +598,16 @@ public sealed class WorldSession : IDisposable public event Action? CharacterListReceived; public event Action? CharacterDeleteAcknowledged; public event Action? CharacterRestoreReceived; + /// + /// Campaign CC CC2: fires when a 0xF643 + /// () response arrives while + /// this session's awaiting-request latch says Create — i.e. the + /// reply to . See + /// 's doc comment for the + /// opcode collision with and how + /// the two are disambiguated. + /// + public event Action? CharacterCreateResponseReceived; public event Action? CharacterErrorReceived; /// /// Campaign LA gate round 2 finding 3: ACE sends this in the same batch @@ -706,6 +716,60 @@ public sealed class WorldSession : IDisposable public ServerName.Parsed? ServerInfo { get; private set; } private CharacterError.Parsed? _lastCharacterSelectionError; + /// + /// Campaign CC CC2: which outbound character-generation request (if any) + /// this session is awaiting a 0xF643 + /// () reply to. Restore and + /// create requests share that opcode on the wire (see + /// 's doc comment) with no + /// self-describing discriminant, so this latch is the only thing that + /// tells the dispatcher which event to fire. Retail's own discriminator + /// is structurally the same latch: Handle_CharGenVerificationResponse + /// @0x0055E8B0 case 1 branches on + /// GetVerificationState() == PENDING → new CharacterIdentity + + /// AddIdentity (create) versus not-pending → unpack into the existing + /// identity at slot (restore). Set by + /// / + /// immediately before the send; cleared the moment a matching 0xF643 is + /// dispatched (success OR parse failure — a malformed reply must not + /// wedge the latch open forever) and on session teardown + /// (). + /// + /// SCOPE, stated exactly (CC2 review F1): this latch correlates + /// the SINGLE outstanding request. It does NOT refuse overlapping + /// requests — a second send while one is outstanding OVERWRITES the + /// latch and the first request's reply is then delivered to the wrong + /// event. Refusing overlap is the CALLER's job, exactly as in retail: + /// gmCharGenMainUI::DoFinish@0x004e9170 only sends when the + /// verification state is UNDEF (CC3's Runtime verification gate owns + /// that rule here). The overwrite behavior is pinned by + /// WorldSessionCharacterCreationTests so CC3 cannot silently + /// regress against it. + /// + /// Read/written only from the caller's frame thread — the same + /// single-threaded invariant every other per-session field here (e.g. + /// ) relies on; + /// is never invoked concurrently with a + /// send (see 's doc comment — the + /// #260 thread-id probe note; CC2 review F5 corrected this pointer). + /// + private enum PendingCharGenVerificationRequest + { + None, + Restore, + Create, + } + + private PendingCharGenVerificationRequest _pendingCharGenVerification = + PendingCharGenVerificationRequest.None; + + /// + /// One-shot guard so an unexpected 0xF643 (no outstanding create/restore + /// request) logs exactly once per session rather than spamming on a + /// misbehaving or replaying server. + /// + private bool _loggedUnexpectedCharGenVerificationResponse; + private readonly IWorldSessionTransport _net; private long _lastInboundPacketTicks = Stopwatch.GetTimestamp(); private long _lastPingRequestTicks; @@ -1823,18 +1887,56 @@ public sealed class WorldSession : IDisposable { CharacterDeleteAcknowledged?.Invoke(); } - else if (op == CharacterRestore.ResponseOpcode) + else if (op == CharGenVerificationResponse.ResponseOpcode) { - CharacterRestore.Parsed parsed; - try - { - parsed = CharacterRestore.Parse(body); - } - catch + // Campaign CC CC2: this opcode is a genuine retail reuse + // between CharacterRestore and CharacterCreate responses + // (see CharGenVerificationResponse's doc comment) — the + // awaiting-request latch is the only thing that tells us + // which family a given 0xF643 belongs to. Clear it before + // parsing (not after) so a malformed reply can never leave + // the latch stuck open, awaiting a response that will now + // never come and misattributing whatever arrives next. + PendingCharGenVerificationRequest awaited = _pendingCharGenVerification; + if (awaited == PendingCharGenVerificationRequest.None) { + if (!_loggedUnexpectedCharGenVerificationResponse) + { + _loggedUnexpectedCharGenVerificationResponse = true; + Console.Error.WriteLine( + "[session] unexpected CharacterGenerationVerificationResponse " + + "(0xF643) with no outstanding create/restore request — dropped."); + } continue; } - CharacterRestoreReceived?.Invoke(parsed); + _pendingCharGenVerification = PendingCharGenVerificationRequest.None; + + if (awaited == PendingCharGenVerificationRequest.Restore) + { + CharacterRestore.Parsed parsed; + try + { + parsed = CharacterRestore.Parse(body); + } + catch + { + continue; + } + CharacterRestoreReceived?.Invoke(parsed); + } + else + { + CharGenVerificationResponse.Parsed parsed; + try + { + parsed = CharGenVerificationResponse.Parse(body); + } + catch + { + continue; + } + CharacterCreateResponseReceived?.Invoke(parsed); + } } else if (op == CharacterError.Opcode) { @@ -2223,9 +2325,45 @@ public sealed class WorldSession : IDisposable /// /// Send retail CharacterRestore through the control queue. This is /// deliberately non-blocking because ACE silently drops unknown guids. + /// Arms the awaiting-request latch as Restore BEFORE the send; + /// the latch correlates the SINGLE outstanding request — a second + /// create/restore sent while this one is outstanding overwrites it, and + /// refusing that overlap is the caller's job (CC3's verification gate). + /// See (Campaign CC + /// CC2). /// - public void SendRestoreCharacter(uint characterId) => + public void SendRestoreCharacter(uint characterId) + { + _pendingCharGenVerification = PendingCharGenVerificationRequest.Restore; SendControlMessage(CharacterRestore.BuildRequestBody(characterId)); + } + + /// + /// Send retail CharacterCreate (opcode 0xF656) through the + /// login/logon queue — Proto_UI::SendCharGenResult routes via + /// SendToLogon, the same queue + /// uses (see + /// 's class doc comment). Deliberately + /// non-blocking, matching — ACE + /// silently drops a request whose packed account name doesn't match the + /// session's own account. Arms the awaiting-request latch as + /// Create BEFORE the send; the latch correlates the SINGLE + /// outstanding request — overlap refusal is the caller's job (CC3's + /// verification gate; see + /// ) (Campaign CC CC2). + /// + public void SendCharacterCreation( + string accountName, + CharacterCreate.Request request, + ReadOnlySpan skillAdvancementClasses) + { + byte[] body = CharacterCreate.BuildRequestBody( + accountName, + request, + skillAdvancementClasses); + _pendingCharGenVerification = PendingCharGenVerificationRequest.Create; + SendGameMessage(body, GameMessageGroup.LoginQueue); + } /// /// Phase I.3: test-only hook. When non-null, @@ -3177,6 +3315,13 @@ public sealed class WorldSession : IDisposable if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; + // Campaign CC CC2: a teardown mid-flight must not leave a stale + // Restore/Create latch behind it — this session object is never + // reused (a fresh WorldSession is constructed per connection + // attempt), but clearing here keeps the invariant "no outstanding + // request survives teardown" true rather than merely true-in-practice. + _pendingCharGenVerification = PendingCharGenVerificationRequest.None; + SessionShutdownPlan shutdown = BuildShutdownPlan( CurrentState, _transportNegotiated, diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index 2efbf5a9..6b63bfbe 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -44,6 +44,42 @@ public sealed record EnteredWorldStatusEvent : StatusEvent public required string CharacterName { get; init; } } +/// +/// Campaign CC CC2: the Ok reply to an outbound CharacterCreate (opcode +/// 0xF656). / mirror the shared +/// 0xF643 CharGenVerificationResponse Ok identity payload's own +/// field names — deliberately distinct from 's +/// characterId/characterName, since retail logs a freshly +/// created character straight in without a fresh characterList, so +/// this event can precede an for the +/// same character rather than replace it. +/// +public sealed record CharacterCreatedStatusEvent : StatusEvent +{ + public required uint Guid { get; init; } + + public required string Name { get; init; } +} + +/// +/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate. +/// is the raw wire +/// CharGenVerificationResponse.Code value; is +/// that code's enum member name (e.g. "NameInUse"); +/// is the ATTEMPTED character name. The enum member +/// rode the name key until the CC2 review (F4) — same key, +/// different meaning than characterCreated.name — renamed before +/// any consumer shipped. +/// +public sealed record CreationFailedStatusEvent : StatusEvent +{ + public required uint Code { get; init; } + + public required string Reason { get; init; } + + public required string Name { get; init; } +} + public sealed record PluginLoadedStatusEvent : StatusEvent { public required string Plugin { get; init; } diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index 4f5000da..845d647c 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -103,6 +103,10 @@ public static class StatusEventParser ParsePluginFailed(root, v, e, t, sessionId), "loginCommandFailed" => ParseLoginCommandFailed(root, v, e, t, sessionId), + "characterCreated" => + ParseCharacterCreated(root, v, e, t, sessionId), + "creationFailed" => + ParseCreationFailed(root, v, e, t, sessionId), "disconnected" => ParseDisconnected(root, v, e, t, sessionId), "exited" => @@ -131,6 +135,8 @@ public static class StatusEventParser "pluginLoaded" or "pluginFailed" or "loginCommandFailed" or + "characterCreated" or + "creationFailed" or "disconnected" or "exited"; @@ -237,6 +243,39 @@ public static class StatusEventParser CharacterName = RequireString(root, "characterName"), }; + private static StatusEvent ParseCharacterCreated( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new CharacterCreatedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Guid = RequireUInt32(root, "guid"), + Name = RequireString(root, "name"), + }; + + private static StatusEvent ParseCreationFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new CreationFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Code = RequireUInt32(root, "code"), + Reason = RequireString(root, "reason"), + Name = RequireString(root, "name"), + }; + private static StatusEvent ParsePluginLoaded( JsonElement root, int v, diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index c5a01d95..e4c43e12 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -207,6 +207,53 @@ public sealed class SessionStatusWriter characterName, }); + /// + /// Campaign CC CC2: the retail 0xF643 Ok response to an outbound + /// CharacterCreate — see + /// AcDream.Core.Net.Messages.CharGenVerificationResponse. + /// and come straight off that response's Ok + /// identity payload. This is a distinct event from : + /// retail logs a freshly created character straight in without a fresh + /// CharacterList (see the shared response type's doc comment), so a + /// caller can expect this event to precede an eventual + /// for the same character, not replace it. + /// + public void CharacterCreated(string sessionId, uint guid, string name) => + Write(new + { + v = VocabularyVersion, + e = "characterCreated", + t = Now(), + sessionId, + guid, + name, + }); + + /// + /// Campaign CC CC2: a non-Ok 0xF643 response to an outbound + /// CharacterCreate. is the raw wire + /// CharGenVerificationResponse.Code value; + /// is that code's enum member name (e.g. "NameInUse") so a + /// launcher can render a readable reason without hard-coding the + /// server's numeric-to-dialog mapping itself; + /// is the ATTEMPTED character name — the thing a launcher most wants to + /// show ("the name Bob is taken"). The key was name for the enum + /// member until the CC2 review (F4): characterCreated.name is a + /// character name, and one status vocabulary must not give the same key + /// two meanings. Renamed before any consumer shipped. + /// + public void CreationFailed(string sessionId, uint code, string reason, string name) => + Write(new + { + v = VocabularyVersion, + e = "creationFailed", + t = Now(), + sessionId, + code, + reason, + name, + }); + public void PluginLoaded(string sessionId, string plugin) => Write(new { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs new file mode 100644 index 00000000..5ce2a78b --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs @@ -0,0 +1,110 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CC CC2: the shared 0xF643 parser both CharacterRestore and +/// CharacterCreate responses consume. See +/// for the pre-existing CharacterRestore-shaped coverage that must survive +/// this type's promotion unchanged. +/// +public sealed class CharGenVerificationResponseTests +{ + [Fact] + public void Parse_Ok_PopulatesIdentityPayload() + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write((uint)CharGenVerificationResponse.Code.Ok) + .WriteGuid(0x5000000Bu) + .WriteString16L("+NewChar") + .Write(0u); + + CharGenVerificationResponse.Parsed parsed = + CharGenVerificationResponse.Parse(w.ToArray()); + + Assert.Equal(1u, parsed.RawCode); + Assert.Equal(CharGenVerificationResponse.Code.Ok, parsed.AsCode); + Assert.True(parsed.IsOk); + Assert.Equal(0x5000000Bu, parsed.Guid); + Assert.Equal("+NewChar", parsed.Name); + Assert.Equal(0u, parsed.SecondsGreyedOut); + } + + [Theory] + [InlineData(0u, CharGenVerificationResponse.Code.Undef)] + [InlineData(2u, CharGenVerificationResponse.Code.Pending)] + [InlineData(3u, CharGenVerificationResponse.Code.NameInUse)] + [InlineData(4u, CharGenVerificationResponse.Code.NameBanned)] + [InlineData(5u, CharGenVerificationResponse.Code.Corrupt)] + [InlineData(6u, CharGenVerificationResponse.Code.DatabaseDown)] + [InlineData(7u, CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + public void Parse_EveryNonOkCode_IsFlagOnlyWithNullTrailingFields( + uint rawCode, + CharGenVerificationResponse.Code expectedCode) + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(rawCode); + + CharGenVerificationResponse.Parsed parsed = + CharGenVerificationResponse.Parse(w.ToArray()); + + Assert.Equal(rawCode, parsed.RawCode); + Assert.Equal(expectedCode, parsed.AsCode); + Assert.False(parsed.IsOk); + Assert.Null(parsed.Guid); + Assert.Null(parsed.Name); + Assert.Null(parsed.SecondsGreyedOut); + } + + [Fact] + public void Parse_UnknownCode_NeverThrowsOnTheCast() + { + // A plain enum cast never throws in C# — a private-server or future + // retail revision sending a code we haven't named yet must not crash + // the parser. + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(99u); + + CharGenVerificationResponse.Parsed parsed = + CharGenVerificationResponse.Parse(w.ToArray()); + + Assert.Equal(99u, parsed.RawCode); + Assert.Equal((CharGenVerificationResponse.Code)99u, parsed.AsCode); + Assert.False(parsed.IsOk); + } + + [Fact] + public void Parse_WrongOpcode_Throws() + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu); + + Assert.Throws(() => CharGenVerificationResponse.Parse(bytes)); + } + + [Fact] + public void Parse_TruncatedAfterCode_Throws() + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write((uint)CharGenVerificationResponse.Code.Ok); + + Assert.Throws(() => CharGenVerificationResponse.Parse(w.ToArray())); + } + + [Fact] + public void Parse_TruncatedBeforeCode_Throws() + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode); + + Assert.Throws(() => CharGenVerificationResponse.Parse(w.ToArray())); + } + + [Fact] + public void ResponseOpcode_MatchesCharacterRestoresResponseOpcode() + { + // The whole point of this type: both families collide on the exact + // same wire opcode. + Assert.Equal(CharacterRestore.ResponseOpcode, CharGenVerificationResponse.ResponseOpcode); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs new file mode 100644 index 00000000..8080df08 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs @@ -0,0 +1,308 @@ +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CC CC2: byte-exact coverage for the outbound CharacterCreate +/// (0xF656) builder — field order, the 55-slot skill-advancement invariant, +/// and the trailing checksum's exact retail accumulation set (see +/// 's class doc comment for the decompiled +/// source of truth). +/// +public sealed class CharacterCreateTests +{ + private static uint[] MakeSkills(uint seed = 0) + { + var skills = new uint[CharacterCreate.SkillAdvancementClassCount]; + for (int i = 0; i < skills.Length; i++) + skills[i] = seed + (uint)i; + return skills; + } + + private static CharacterCreate.Request MakeRequest() => new( + Heritage: 1u, + Gender: 0u, + Appearance: new CharacterCreate.Appearance( + EyesStrip: 2u, + NoseStrip: 3u, + MouthStrip: 4u, + HairColor: 5u, + EyeColor: 6u, + HairStyle: 7u, + HeadgearStyle: 8u, + HeadgearColor: 9u, + ShirtStyle: 10u, + ShirtColor: 11u, + TrousersStyle: 12u, + TrousersColor: 13u, + FootwearStyle: 14u, + FootwearColor: 15u, + SkinShade: 0.1, + HairShade: 0.2, + HeadgearShade: 0.3, + ShirtShade: 0.4, + TrousersShade: 0.5, + FootwearShade: 0.6), + Template: 16u, + Attributes: new CharacterCreate.Attributes( + Strength: 17u, + Endurance: 18u, + Coordination: 19u, + Quickness: 20u, + Focus: 21u, + Self: 22u), + Slot: 0u, + ClassId: 1u, + Name: "Testcdream", + StartArea: 23u, + IsAdmin: false, + IsEnvoy: false); + + [Fact] + public void BuildRequestBody_Layout_MatchesRetailCGPackFieldOrder() + { + CharacterCreate.Request request = MakeRequest(); + uint[] skills = MakeSkills(); + byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, skills); + + int pos = 0; + uint ReadU32() + { + uint v = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + pos += 4; + return v; + } + double ReadF64() + { + double v = BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(pos)); + pos += 8; + return v; + } + string ReadString16L() + { + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos)); + pos += 2; + string s = Encoding.ASCII.GetString(body, pos, len); + pos += len; + int recordSize = 2 + len; + int padding = (4 - (recordSize & 3)) & 3; + pos += padding; + return s; + } + + Assert.Equal(CharacterCreate.Opcode, ReadU32()); + Assert.Equal("testaccount", ReadString16L()); + Assert.Equal(1u, ReadU32()); // CG_Pack@0x005c7208 constant + Assert.Equal(request.Heritage, ReadU32()); + Assert.Equal(request.Gender, ReadU32()); + Assert.Equal(request.Appearance.EyesStrip, ReadU32()); + Assert.Equal(request.Appearance.NoseStrip, ReadU32()); + Assert.Equal(request.Appearance.MouthStrip, ReadU32()); + Assert.Equal(request.Appearance.HairColor, ReadU32()); + Assert.Equal(request.Appearance.EyeColor, ReadU32()); + Assert.Equal(request.Appearance.HairStyle, ReadU32()); + Assert.Equal(request.Appearance.HeadgearStyle, ReadU32()); + Assert.Equal(request.Appearance.HeadgearColor, ReadU32()); + Assert.Equal(request.Appearance.ShirtStyle, ReadU32()); + Assert.Equal(request.Appearance.ShirtColor, ReadU32()); + Assert.Equal(request.Appearance.TrousersStyle, ReadU32()); + Assert.Equal(request.Appearance.TrousersColor, ReadU32()); + Assert.Equal(request.Appearance.FootwearStyle, ReadU32()); + Assert.Equal(request.Appearance.FootwearColor, ReadU32()); + Assert.Equal(request.Appearance.SkinShade, ReadF64()); + Assert.Equal(request.Appearance.HairShade, ReadF64()); + Assert.Equal(request.Appearance.HeadgearShade, ReadF64()); + Assert.Equal(request.Appearance.ShirtShade, ReadF64()); + Assert.Equal(request.Appearance.TrousersShade, ReadF64()); + Assert.Equal(request.Appearance.FootwearShade, ReadF64()); + Assert.Equal(request.Template, ReadU32()); + Assert.Equal(request.Attributes.Strength, ReadU32()); + Assert.Equal(request.Attributes.Endurance, ReadU32()); + Assert.Equal(request.Attributes.Coordination, ReadU32()); + Assert.Equal(request.Attributes.Quickness, ReadU32()); + Assert.Equal(request.Attributes.Focus, ReadU32()); + Assert.Equal(request.Attributes.Self, ReadU32()); + Assert.Equal(request.Slot, ReadU32()); + Assert.Equal(request.ClassId, ReadU32()); + uint numSkills = ReadU32(); + Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, numSkills); + for (int i = 0; i < skills.Length; i++) + Assert.Equal(skills[i], ReadU32()); + Assert.Equal(request.Name, ReadString16L()); + Assert.Equal(request.StartArea, ReadU32()); + Assert.Equal(0u, ReadU32()); // isAdmin + Assert.Equal(0u, ReadU32()); // isEnvoy + uint checksum = ReadU32(); + Assert.Equal(CharacterCreate.ComputeChecksum(request), checksum); + Assert.Equal(pos, body.Length); + } + + [Fact] + public void BuildRequestBody_ExactByteSequence_ShortAccountAndName() + { + // Minimal fixture with distinct short strings, hand-checked padding. + CharacterCreate.Request request = new( + Heritage: 1u, + Gender: 0u, + Appearance: default, + Template: 0u, + Attributes: default, + Slot: 0u, + ClassId: 1u, + Name: "ab", + StartArea: 0u, + IsAdmin: false, + IsEnvoy: false); + uint[] skills = new uint[CharacterCreate.SkillAdvancementClassCount]; + + byte[] body = CharacterCreate.BuildRequestBody("cd", request, skills); + + int pos = 0; + Assert.Equal(CharacterCreate.Opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; + + // String16L("cd") = u16(2) + 2 bytes, already 4-byte aligned. + Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2; + Assert.Equal("cd", Encoding.ASCII.GetString(body, pos, 2)); pos += 2; + + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // constant + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // heritage + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // gender + + // 14 appearance strip/color u32 fields, all zero (default). + pos += 14 * 4; + + // 6 f64 shades, all zero (default). + pos += 6 * 8; + + pos += 4; // template + pos += 6 * 4; // attributes + pos += 4; // slot + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // classId + + Assert.Equal( + (uint)CharacterCreate.SkillAdvancementClassCount, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); + pos += 4; + pos += CharacterCreate.SkillAdvancementClassCount * 4; + + Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2; + Assert.Equal("ab", Encoding.ASCII.GetString(body, pos, 2)); pos += 2; + + pos += 4; // startArea + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isAdmin + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isEnvoy + + // Checksum: heritage(1) + gender(0) + 3 strips(0) + hairColor(0) + + // eyeColor(0) + hairStyle(0) + headgearStyle(0) + shirtStyle(0) + + // trousersStyle(0) + footwearStyle(0) + template(0) + 6 attrs(0) = 1. + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; + + Assert.Equal(pos, body.Length); + } + + [Fact] + public void ComputeChecksum_ExactRetailAccumulationSet() + { + // CG_Pack@0x005c7213-0x005c74c3: heritage, gender, the three + // appearance strips, hairColor, eyeColor, hairStyle, headgearStyle, + // shirtStyle, trousersStyle, footwearStyle, template, and the six + // attributes — nineteen terms, u32 wraparound addition. + CharacterCreate.Request request = MakeRequest(); + uint expected = unchecked( + request.Heritage + + request.Gender + + request.Appearance.EyesStrip + + request.Appearance.NoseStrip + + request.Appearance.MouthStrip + + request.Appearance.HairColor + + request.Appearance.EyeColor + + request.Appearance.HairStyle + + request.Appearance.HeadgearStyle + + request.Appearance.ShirtStyle + + request.Appearance.TrousersStyle + + request.Appearance.FootwearStyle + + request.Template + + request.Attributes.Strength + + request.Attributes.Endurance + + request.Attributes.Coordination + + request.Attributes.Quickness + + request.Attributes.Focus + + request.Attributes.Self); + + Assert.Equal(expected, CharacterCreate.ComputeChecksum(request)); + // Concretely: 1+0+2+3+4+5+6+7+8+10+12+14+16+17+18+19+20+21+22 = 205. + Assert.Equal(205u, expected); + } + + [Fact] + public void ComputeChecksum_ExcludesColorFieldsShadesSlotAndClassId() + { + // These fields sit adjacent to summed fields on the wire but the + // decompiled CG_Pack accumulation (0x005c7213-0x005c74c3) never + // touches them — mutating only these must not move the checksum. + CharacterCreate.Request baseline = MakeRequest(); + uint baselineChecksum = CharacterCreate.ComputeChecksum(baseline); + + CharacterCreate.Request mutated = baseline with + { + Appearance = baseline.Appearance with + { + HeadgearColor = baseline.Appearance.HeadgearColor + 1000u, + ShirtColor = baseline.Appearance.ShirtColor + 1000u, + TrousersColor = baseline.Appearance.TrousersColor + 1000u, + FootwearColor = baseline.Appearance.FootwearColor + 1000u, + SkinShade = baseline.Appearance.SkinShade + 5.0, + HairShade = baseline.Appearance.HairShade + 5.0, + }, + Slot = baseline.Slot + 7u, + ClassId = baseline.ClassId + 7u, + }; + + Assert.Equal(baselineChecksum, CharacterCreate.ComputeChecksum(mutated)); + } + + [Fact] + public void BuildRequestBody_SkillCountOtherThan55_Throws() + { + CharacterCreate.Request request = MakeRequest(); + + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills().AsSpan(0, 54))); + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, new uint[56])); + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, ReadOnlySpan.Empty)); + } + + [Fact] + public void BuildRequestBody_NullAccountName_Throws() + { + CharacterCreate.Request request = MakeRequest(); + Assert.Throws(() => + CharacterCreate.BuildRequestBody(null!, request, MakeSkills())); + } + + [Fact] + public void BuildRequestBody_NullCharacterName_Throws() + { + CharacterCreate.Request request = MakeRequest() with { Name = null! }; + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills())); + } + + [Fact] + public void BuildRequestBody_AdminAndEnvoyFlags_EncodeAsOneOrZero() + { + CharacterCreate.Request request = MakeRequest() with { IsAdmin = true, IsEnvoy = true }; + byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills()); + + // isAdmin and isEnvoy are the two u32s immediately before the + // trailing checksum. + uint isEnvoy = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 8)); + uint isAdmin = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 12)); + Assert.Equal(1u, isAdmin); + Assert.Equal(1u, isEnvoy); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs new file mode 100644 index 00000000..ecf08b90 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs @@ -0,0 +1,330 @@ +using System.Net; +using System.Reflection; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Tests.Messages; + +namespace AcDream.Core.Net.Tests; + +/// +/// Campaign CC CC2: the awaiting-request latch that disambiguates the two +/// message families sharing opcode 0xF643 (see +/// 's doc comment) — create-then- +/// response routes to the create event, a plain restore is unaffected, an +/// unexpected/uncorrelated response is dropped rather than misattributed, +/// and teardown clears the latch. See +/// for the general +/// character-management wire-order coverage this file complements. +/// +public sealed class WorldSessionCharacterCreationTests +{ + private sealed class NullTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + ValueTask.FromCanceled(cancellationToken); + public void Dispose() { } + } + + private static WorldSession CreateSession() => + new( + new IPEndPoint(IPAddress.Loopback, 9000), + new NullTransport()); + + private static CharacterCreate.Request MakeCreateRequest() => new( + Heritage: 1u, + Gender: 0u, + Appearance: default, + Template: 0u, + Attributes: default, + Slot: 0u, + ClassId: 1u, + Name: "NewChar", + StartArea: 0u, + IsAdmin: false, + IsEnvoy: false); + + private static byte[] BuildVerificationResponseBody(uint code, uint guid, string name) => + code == (uint)CharGenVerificationResponse.Code.Ok + ? AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(code) + .WriteGuid(guid) + .WriteString16L(name) + .Write(0u) + .ToArray() + : AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(code) + .ToArray(); + + private static byte[] BuildPacket(params byte[][] messages) + { + int length = messages.Sum(message => + MessageFragmentHeader.Size + message.Length); + var fragments = new byte[length]; + int position = 0; + uint sequence = 1u; + foreach (byte[] message in messages) + { + position += GameMessageFragment.WriteSingleFragment( + fragments.AsSpan(position), + sequence++, + GameMessageGroup.UIQueue, + message); + } + return PacketCodec.Encode( + new PacketHeader + { + Sequence = 1u, + Flags = PacketHeaderFlags.BlobFragments, + }, + fragments, + outboundIsaac: null); + } + + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) + { + MethodInfo method = typeof(WorldSession).GetMethod( + "ProcessDatagram", + BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(session, [new ReadOnlyMemory(datagram), null, true]); + } + + private static PendingLatch ReadPendingLatch(WorldSession session) + { + FieldInfo field = typeof(WorldSession).GetField( + "_pendingCharGenVerification", + BindingFlags.NonPublic | BindingFlags.Instance)!; + return (PendingLatch)field.GetValue(session)!; + } + + // Mirrors WorldSession's private PendingCharGenVerificationRequest enum + // by name/ordinal — read via reflection above so the test doesn't need + // InternalsVisibleTo for a single private enum. + private enum PendingLatch { None, Restore, Create } + + [Fact] + public void SendCharacterCreation_ThenOkResponse_RoutesToCreateEventNotRestore() + { + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + + var createEvents = new List(); + var restoreEvents = new List(); + session.CharacterCreateResponseReceived += createEvents.Add; + session.CharacterRestoreReceived += restoreEvents.Add; + + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000010u, + "NewChar")); + InvokeProcessDatagram(session, packet); + + CharGenVerificationResponse.Parsed created = Assert.Single(createEvents); + Assert.True(created.IsOk); + Assert.Equal(0x50000010u, created.Guid); + Assert.Equal("NewChar", created.Name); + Assert.Empty(restoreEvents); + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void SendCharacterCreation_ThenFailureResponse_RoutesToCreateEventWithNullIdentity() + { + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + + CharGenVerificationResponse.Parsed? created = null; + session.CharacterCreateResponseReceived += parsed => created = parsed; + + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.NameInUse, + guid: 0u, + name: string.Empty)); + InvokeProcessDatagram(session, packet); + + Assert.NotNull(created); + Assert.Equal(CharGenVerificationResponse.Code.NameInUse, created!.Value.AsCode); + Assert.False(created.Value.IsOk); + Assert.Null(created.Value.Guid); + } + + [Fact] + public void SendRestoreCharacter_ThenResponse_StillRoutesToRestoreEvent() + { + // Regression guard: the correlation latch must not break the + // pre-existing restore-only flow that predates Campaign CC. + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendRestoreCharacter(0x50000001u); + + var restoreEvents = new List(); + var createEvents = new List(); + session.CharacterRestoreReceived += restoreEvents.Add; + session.CharacterCreateResponseReceived += createEvents.Add; + + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000001u, + "Restored")); + InvokeProcessDatagram(session, packet); + + CharacterRestore.Parsed restored = Assert.Single(restoreEvents); + Assert.Equal(0x50000001u, restored.Guid); + Assert.Equal("Restored", restored.Name); + Assert.Empty(createEvents); + } + + /// + /// CC2 review F1: pins the latch's stated scope EXACTLY. The latch + /// correlates the single outstanding request and does NOT refuse + /// overlap — a second send while one is outstanding OVERWRITES it, so + /// the first request's reply is delivered to the second request's + /// event. Refusing overlap is the caller's job (CC3's Runtime + /// verification gate, mirroring retail's DoFinish UNDEF-state gate). + /// If CC3 (or anyone) changes this transport-level behavior, this test + /// must change WITH it, deliberately. + /// + [Fact] + public void OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest() + { + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendRestoreCharacter(0x50000001u); + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + Assert.Equal(PendingLatch.Create, ReadPendingLatch(session)); + + var restoreEvents = new List(); + var createEvents = new List(); + session.CharacterRestoreReceived += restoreEvents.Add; + session.CharacterCreateResponseReceived += createEvents.Add; + + // This reply is semantically the RESTORE's — but the overwritten + // latch routes it to the create event. That is the documented + // overwrite behavior, pinned here. + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000001u, + "Restored")); + InvokeProcessDatagram(session, packet); + + Assert.Empty(restoreEvents); + Assert.Single(createEvents); + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed() + { + using WorldSession session = CreateSession(); + + var restoreEvents = new List(); + var createEvents = new List(); + session.CharacterRestoreReceived += restoreEvents.Add; + session.CharacterCreateResponseReceived += createEvents.Add; + + // No SendRestoreCharacter / SendCharacterCreation call precedes this + // — the latch is None. + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000099u, + "Stray")); + InvokeProcessDatagram(session, packet); + + Assert.Empty(restoreEvents); + Assert.Empty(createEvents); + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void SecondResponse_AfterFirstAlreadyConsumed_IsDroppedNotMisattributed() + { + // A create request is satisfied; a SECOND, uncorrelated 0xF643 + // arriving afterward (e.g. a stray/replayed packet) must not be + // misread as a reply to anything. + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + + var createEvents = new List(); + session.CharacterCreateResponseReceived += createEvents.Add; + + byte[] first = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, 0x50000010u, "NewChar")); + InvokeProcessDatagram(session, first); + Assert.Single(createEvents); + + byte[] second = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, 0x50000011u, "Stray")); + InvokeProcessDatagram(session, second); + + // Still exactly one — the second reply was dropped, not appended. + Assert.Single(createEvents); + } + + [Fact] + public void Dispose_ClearsTheOutstandingLatch() + { + WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + session.SendRestoreCharacter(0x50000001u); + Assert.Equal(PendingLatch.Restore, ReadPendingLatch(session)); + + session.Dispose(); + + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void BuildRequestBody_InvalidSkillCount_DoesNotArmTheLatch() + { + // The latch is armed AFTER the body is built (SendCharacterCreation + // builds first), so a builder-level throw (wrong skill count) must + // leave no outstanding request behind — nothing was actually sent. + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + Assert.Throws(() => + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[10])); + + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 74cba578..3d3b6dc6 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -61,6 +61,13 @@ public sealed class WorldSessionCharacterSelectionTests public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters() { using var session = CreateSession(); + // Campaign CC CC2: a restore response only dispatches when the + // session actually has an outstanding restore request armed — see + // WorldSessionCharacterCreationTests for the correlation-specific + // coverage (create routing, no-outstanding drop, teardown clears). + session.GameMessageCapture = (_, _) => { }; + session.SendRestoreCharacter(0x50000001u); + var events = new List(); session.CharacterListReceived += roster => events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}"); diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index 2055ae92..e27f21c3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -77,6 +77,37 @@ public sealed class StatusEventParserTests Assert.Equal("boom", failed.Error); } + [Fact] + public void ParsesCharacterCreatedAndCreationFailed() + { + var created = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}""")); + Assert.Equal(1342177296u, created.Guid); + Assert.Equal("NewChar", created.Name); + + var failed = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}""")); + Assert.Equal(3u, failed.Code); + Assert.Equal("NameInUse", failed.Reason); + Assert.Equal("Bob", failed.Name); + } + + [Theory] + [InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NewChar\"}")] + [InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"guid\":1342177296}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"reason\":\"NameInUse\",\"name\":\"Bob\"}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"name\":\"Bob\"}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"reason\":\"NameInUse\"}")] + public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line) + { + var malformed = Assert.IsType(StatusEventParser.Parse(line)); + + Assert.Equal("s1", malformed.SessionId); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } + [Fact] public void ParsesLoginCommandFailed() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs index d2ad011c..4f9314aa 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -183,6 +183,34 @@ public sealed class StatusFileTailerTests : IDisposable Assert.Empty(events); } + /// + /// Campaign CC CC2: the two creation-flow events round-trip through the + /// actual file-tailing pipeline (not just + /// in isolation) — matching the exact camelCase shape + /// AcDream.Runtime.Session.SessionStatusWriter writes. + /// + [Fact] + public void TailsCharacterCreatedAndCreationFailedEvents() + { + AppendShared( + """{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}""" + + "\n" + + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}""" + + "\n"); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(2, events.Count); + var created = Assert.IsType(events[0]); + Assert.Equal(1342177296u, created.Guid); + Assert.Equal("NewChar", created.Name); + var failed = Assert.IsType(events[1]); + Assert.Equal(3u, failed.Code); + Assert.Equal("NameInUse", failed.Reason); + Assert.Equal("Bob", failed.Name); + } + [Fact] public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced() { diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index 95e37ff8..90848778 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -109,6 +109,8 @@ public sealed class SessionStatusWriterTests writer.PluginLoaded("s1", "acdream.good"); writer.PluginFailed("s1", "acdream.bad", "failed"); writer.LoginCommandFailed("s1", 0, "", "unknown command"); + writer.CharacterCreated("s1", 0x50000001u, "NewChar"); + writer.CreationFailed("s1", 3u, "NameInUse", "Bob"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); @@ -116,6 +118,43 @@ public sealed class SessionStatusWriterTests Assert.False(File.Exists(file.Path)); } + /// + /// Campaign CC CC2: pins the exact shape of the two new creation-flow + /// status events, added to the LA1 vocabulary alongside + /// CharacterCreate (opcode 0xF656) — see + /// docs/plans/2026-08-14-launcher-campaign.md §LA1's amended + /// status-vocabulary text. + /// + [Fact] + public void CharacterCreatedAndCreationFailed_WriteThePinnedShape() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + + writer.CharacterCreated("s1", 0x50000010u, "NewChar"); + writer.CreationFailed("s1", 3u, "NameInUse", "Bob"); + + string[] lines = File.ReadAllLines(file.Path); + Assert.Equal(2, lines.Length); + + JsonElement created = Parse(lines[0]); + Assert.Equal(1, created.GetProperty("v").GetInt32()); + Assert.Equal("characterCreated", created.GetProperty("e").GetString()); + Assert.Equal("s1", created.GetProperty("sessionId").GetString()); + Assert.Equal(0x50000010u, created.GetProperty("guid").GetUInt32()); + Assert.Equal("NewChar", created.GetProperty("name").GetString()); + AssertExactProperties(lines[0], "v", "e", "t", "sessionId", "guid", "name"); + + JsonElement failed = Parse(lines[1]); + Assert.Equal("creationFailed", failed.GetProperty("e").GetString()); + Assert.Equal("s1", failed.GetProperty("sessionId").GetString()); + Assert.Equal(3u, failed.GetProperty("code").GetUInt32()); + Assert.Equal("NameInUse", failed.GetProperty("reason").GetString()); + Assert.Equal("Bob", failed.GetProperty("name").GetString()); + AssertExactProperties( + lines[1], "v", "e", "t", "sessionId", "code", "reason", "name"); + } + [Fact] public void BlankPathIsTreatedAsAbsent() {