From 9cf6c52283c9397260351125bf6ca6ab7aeeb23c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 02:35:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice=20CC7=20?= =?UTF-8?q?=E2=80=94=20end-to-end=20create=20flow=20+=20connected=20checkl?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create button un-ghosts: retail's exact gate (gmCharacterManagementUI:: UpdateButtons @0x004ec240, roster count < allowed slot count) ported into RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the chargen screen through the same CharacterCreationUiController.Open() seam the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on chargen needed no new return-path code — character-management is never hidden while chargen is open on top of it — verified end-to-end by a new cross-controller test rather than left as an inspection claim. Full-flow test coverage: a new comprehensive test decodes every 0xF656 field (including the trailing checksum, recomputed via the production CharacterCreate.ComputeChecksum) against a fully populated creation (heritage/gender/all appearance slots/template/explicit skill command/ town/name); a new Theory drives the remaining six 0xF643 rejection codes through the real wire decode path, closing the gap between the already-covered isolated state-machine Theory and an actual WorldSession round trip. Launcher payload cycle: two new tests drive a real Runtime create/reject through the real SessionStatusWriter (wired exactly as LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read the result back with the real Launcher.Core StatusFileTailer/ StatusEventParser — closing the one gap CC2's own per-layer tests never reached. No gap was found in production wiring itself: GameWindow already constructs a real, non-null SessionStatusWriter for both hosts. Also fixes 4 pre-existing LiveSessionControllerTests assertions that compared a full RuntimeCharacterSelectionButtons record and would have failed once CanCreate started being computed; corrects register row AP-211 to reflect that its own predicted resolution (the Create-button gate landing) has now happened — both layers are intentionally kept as retail-matching enforcement plus defense-in-depth, not one superseding the other. Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's connected-gate script covering both the launcher and dev-shortcut launch paths, the six-page create flow, every Finish outcome, and the known cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/ 224/226/228) so they aren't mistaken for new bugs during the gate. Gates: full solution Release build green; Runtime 1735/0 (was 1726/0, +9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged), Launcher.Core 324/0, one full-solution pass across every project clean (no known flakes reproduced this run). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../2026-08-16-campaign-cc-test-script.md | 342 ++++++++++++++++ .../Layout/CharacterManagementUiController.cs | 30 +- src/AcDream.App/UI/RetailUiRuntime.cs | 30 +- .../Session/RuntimeCharacterSelectionState.cs | 30 +- .../CharacterManagementUiControllerTests.cs | 84 +++- ...CharacterScreensFixedCanvasArbiterTests.cs | 70 +++- .../AcDream.Runtime.Tests.csproj | 7 + ...SessionControllerCharacterCreationTests.cs | 375 +++++++++++++++++- .../Session/LiveSessionControllerTests.cs | 24 +- 10 files changed, 957 insertions(+), 37 deletions(-) create mode 100644 docs/research/2026-08-16-campaign-cc-test-script.md diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 142690a7..32964c5b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -404,7 +404,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | -| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | +| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | ## 4. Temporary stopgap (TS) — 49 active rows (TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/research/2026-08-16-campaign-cc-test-script.md b/docs/research/2026-08-16-campaign-cc-test-script.md new file mode 100644 index 00000000..14ca49aa --- /dev/null +++ b/docs/research/2026-08-16-campaign-cc-test-script.md @@ -0,0 +1,342 @@ +# Campaign CC connected-gate test script + +**Status: the campaign is CODE-COMPLETE (CC1-CC7) and this script is its +connected-gate contract.** Every step below is the user's own eyes on the +running client — nothing here was run automatically. **No automated live +character creation has been run against ACE** (see §CC-Not-Automated) — +the first LIVE create is deliberately left to this gate. + +This script covers TWO ways to reach the chargen screen: the real launcher +flow (Campaign LA's product path) and the developer shortcut +(`ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`, still available and still +useful for a fast create-only iteration loop). Both land on the exact same +screen and Runtime owner — there is no second code path being tested. + +Local ACE connection details (per `CLAUDE.md`): host `127.0.0.1`, port +`9000`, account `testaccount` / `testpassword`. Use a FRESH character name +per attempt (ACE does not forget names within a session) — `CC--` +is a good scheme, e.g. `CCAB1`, `CCAB2`. + +--- + +## §CC1 — reaching the screen + +### Path A — the launcher (product path) + +1. Launch `AcDream.Launcher` (already installed/updated per Campaign LA's + own gates — this script does not re-run first-run setup or the update + flow; see `docs/research/2026-08-14-campaign-la-test-script.md` if either + is in question). +2. Open **Check for updates**. Confirm it reports the client already + current (no install prompt) — Campaign LA's own gates already proved the + install/update mechanics; this step just confirms nothing is stale + before the character-creation gate. +3. Confirm (or create) a profile pointed at `127.0.0.1:9000`, + `testaccount` / `testpassword`. +4. Click **GUI — character select**. The retail character-management + screen (`gmCharacterManagementUI`) opens — the flat character list, World + name, Enter/Delete/Restore buttons, and the **Create** button. + +### Path B — the developer shortcut + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_RETAIL_UI = "1" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release +``` + +Confirm the SAME character-management screen appears. `ACDREAM_OPEN_CHARGEN=1` +(add it to the block above) is the CC4-era interim seam that opens the +chargen screen directly on startup, skipping the Create click — still +useful for a fast create-only loop, but Path A/B above is now the REAL path +and should be exercised at least once per gate. + +### The Create button + +1. With at least one free character slot (roster count below the account's + allowed slot count — most test accounts have several free slots), + confirm **Create** is ENABLED (not greyed out). +2. **Click Create.** The chargen screen (`gmCharGenMainUI`) opens directly + on the **Heritage** page — no confirmation, no loading screen. The + character-management screen you were just on is not closed or hidden; + it simply sits behind the new screen (this matters for the Exit step + below). +3. **If your account's roster is completely full** (rare on a fresh test + account — every slot occupied), confirm Create is instead GREYED OUT + and does nothing when clicked. This is retail's own gate + (`gmCharacterManagementUI::UpdateButtons`) — a full roster ghosts + Create exactly like Enter/Delete grey out for an unselected row. + +### Leaving the screen (Back-at-Heritage and Exit) + +1. On the **Heritage** page (the first page), click **Back**. Confirm a + confirmation dialog appears asking whether to leave character creation + (same text/shape as the Exit button below — Back-at-Heritage and Exit + share retail's one `DoExit` confirmation). +2. Click **Exit** (top of the screen, available on every page). Confirm + the SAME confirmation dialog appears. +3. **Confirm the dialog (accept).** The chargen screen closes. You are back + at the character-management screen you started from — roster, World + name, and any prior selection are exactly as you left them (character + management was never hidden, so there is nothing to "restore"). +4. Click **Cancel** on a repeat Exit attempt instead — confirm the dialog + closes and chargen stays open, untouched. + +### What to report for §CC1 + +- Create's enabled/greyed state matching the free-slot count you actually + have. +- Whether clicking Create opens chargen with no lag/flash/black frame. +- Whether returning from Exit shows the character list exactly as it was + (no re-flicker, no lost highlight, no stale World name). + +--- + +## §CC2 — the six-page create flow + +**The screen does NOT open blank.** `gmCharGenMainUI`'s own constructor +rolls a full random character (heritage, gender, appearance, clothing, +template, start area) before the Heritage page ever draws — acdream ports +this faithfully (AP-214, retired). **Expected retail quirk: the gender +shown on the Appearance page is the FLIP of the roll** — the Appearance +page's own init code reads the just-rolled gender and immediately swaps it +to the opposite one. If you open chargen and see (say) a female Aluvian +with the Appearance page showing "Male" selected, that is CORRECT, not a +bug — do not report it. + +### Heritage page + +1. Confirm one of the 13 heritage buttons is already highlighted (the + opening roll) — Human heritages (Aluvian/Gharu'ndim/Sho/Viamontian), + Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, + Olthoi, and OlthoiAcid should all be selectable and each show its own + description text (starting skills, bonus-skills paragraph where retail + has one — Lugian/Olthoi/OlthoiAcid have none, that's retail-correct). +2. Click **Random**. Confirm the highlighted heritage changes to a + uniformly-picked one of the 13 — this is AP-212's documented + approximation (retail's own Heritage-page Random rolls with retail's own + distribution; acdream picks uniformly over every installed heritage). + Not a bug to report unless the button does nothing or crashes. +3. Select **Olthoi** or **OlthoiAcid**. Confirm the Profession, Skills, and + Town tabs are hidden (Olthoi variants skip straight to a fixed Custom + template with no attribute/skill/town choices) and the screen + auto-advances past them. + +### Profession page + +1. Select a NON-Olthoi heritage. Confirm 7 template buttons (Custom + 6 + presets) and 6 attribute sliders (Strength/Endurance/Coordination/ + Quickness/Focus/Self). +2. Drag a slider. Confirm the numeric readout updates live and the + Available-credits counter decreases/increases correspondingly. +3. Click a preset template (not Custom). Confirm all 6 sliders jump to + that preset's values and Available credits updates to match. +4. Click **Random**. Confirm heritage/template selection changes (AP-212 — + uniform pick, not retail's own weighted roll). + +### Skills page + +1. Confirm ONE flat listbox of skills, each row showing name, current + level, and train/specialize costs (AP-213 — retail groups these into + four sorted buckets; acdream's flat list is a presentation + simplification, not a rules difference — do not report the flat + ordering as a bug). +2. Click a trainable row. Confirm it advances (Untrained -> Trained -> + Specialized) and the skill-credits meter decreases; a second click on an + already-Specialized row does nothing further (no fourth state). +3. Confirm **Random** is disabled/greyed on this page (retail's + `RandomizeSkills` primitive is unported — AP-212's own documented gap). + +### Appearance page + +1. Confirm **Face** and **Clothes** sub-tabs, nine spin controls (hair, + eyes, nose, mouth, skin under Face; headgear, shirt, trousers, footwear + under Clothes), nine color swatches, a shade scrollbar, zoom/rotate + buttons, and a live 3D preview playing an idle animation loop. +2. Click a spin's left/right arrow zones. Confirm the selected style index + advances/retreats and the preview model updates. +3. Click a color swatch. Confirm the swatch shows a highlighted "selected" + ring/border (AP-215 — acdream's own selection indicator, not retail's + overlay mechanism; functionally equivalent). +4. Click **Zoom In**. Confirm the preview freezes its pose (idle animation + stops) and the camera tweens closer over about half a second. Click + **Zoom Out** — animation resumes, camera tweens back out. +5. Click **Rotate Clockwise**/**Counter-Clockwise**. Confirm the model + spins continuously at a steady rate (about 3 seconds per full turn); + clicking the SAME direction again stops it, clicking the OPPOSITE + direction reverses it. +6. Click **Random** (on either sub-tab). Confirm hair/eyes/nose/mouth/skin + (Face) or headgear/shirt/trousers/footwear (Clothes) all re-roll + together — this IS retail's real `RandomizeAppearance`/ + `RandomizeClothing` primitive (CC5 ported it verbatim, not approximated). +7. Select **Gearknight**, **Olthoi**, or **OlthoiAcid** on the Heritage + page, then return to Appearance. Confirm the Clothes sub-tab and its + four spins are unreachable, Nose/Mouth spins are hidden, and Eyes' + arrows are disabled (fixed eyes for these forms). + +Known cosmetic gaps on this page — expected, do not report as bugs unless +noticeably worse than described: swatches show static art rather than the +actual color they represent (AP-216); the gradient circle art next to the +swatches never repaints to reflect the current color (AP-217); the four +icon-only spins (hair/eyes/nose/mouth) show a plain number instead of an +icon thumbnail, while the four clothing spins show real names (AP-215/ +AP-218); on Olthoi/OlthoiAcid/Gearknight the Skin spin does not slide up to +close the gap left by the hidden Nose/Mouth spins (AP-219); switching +heritage INTO or OUT OF Gearknight does not automatically re-roll +appearance/clothing the way retail does on that exact transition (AP-220); +the currently-selected spin shows no distinct highlighted state versus the +other eight (AP-222 — this is a MEASURED gap in acdream's own art, not yet +attributed to a specific missing asset; report clearly if you can visually +compare with retail here). + +### Town page + +1. Confirm four town buttons: Holtburg, Shoushi, Yaraq, Sanamar (not id + order — that's retail's own literal ordering, ported faithfully), each + with descriptive text. +2. Click a town. Confirm it highlights and the description text updates. + +### Summary page + +1. Confirm a listbox showing Profession, Gender, Heritage, and Starting + Town lines, an "Attributes" header, then Strength/Endurance/ + Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits as + paired rows, then Specialized and Trained skill name lists (retail also + lists the two Untrained buckets; acdream's Summary omits them — + AP-224, same class of cut as the Skills page's own AP-213). +2. Confirm a static 3D preview of the character (no zoom/rotate controls + on this page — retail has none here either). +3. Click the **name field** and type a name. Confirm only letters, spaces, + apostrophes, and hyphens are accepted (other characters are silently + rejected keystroke-by-keystroke). +4. Click **Random** on this page. Confirm a confirmation dialog appears + first ("are you sure you want to randomize?"); confirming it re-rolls + the ENTIRE character (heritage through name) using retail's real + `RandomizeCharacter` primitive — the same one the screen-open roll uses. + +### What to report for §CC2 + +- Any page that fails to render a control listed above, or where a control + visibly does nothing when clicked. +- Anything from the "known cosmetic gaps" list that looks MORE broken than + described (e.g. a spin that doesn't advance at all, not just a missing + highlight). +- Any crash, freeze, or console error while navigating pages or the tabs. + +--- + +## §CC3 — Finish and its dialogs + +Use a fresh, never-before-used character name for the happy path. For every +scenario below, watch the console/log for `[UI]`/`[CC]`-prefixed lines — +they help distinguish "nothing happened because the click didn't register" +from "the request went out and ACE is thinking about it." + +### Happy path + +1. Complete a legal character (heritage, gender, template with credits + fully spent, at least a default set of skills, a town, a fresh name). +2. Click **Finish** on the Summary page. Confirm the screen closes almost + immediately (no visible "please wait" dialog for a normal accept — ACE's + Ok reply is fast) and you land DIRECTLY in the world as the new + character — no return to character management, no fresh character list, + matching retail's own "log straight in" behavior. +3. If you back out to character management instead (e.g. via a later + logout), confirm the new character now appears in the roster alongside + any pre-existing ones, in the correct slot. + +### NameInUse (duplicate name) + +1. Create a SECOND character using the EXACT name you just used above. +2. Click Finish. Confirm the `ID_Character_Err_NameReserved` dialog appears + ("that name is in use" / similar text) and you stay on the chargen + screen — Finish is clickable again afterward. +3. **Expected log noise (register AD-100):** ACE sends the NameInUse + rejection TWICE for the same request (a real ACE double-send bug, not + an acdream defect). The FIRST reply drives the dialog above; the SECOND + logs something like `unexpected CharacterGenerationVerificationResponse` + in the console. That log line is EXPECTED here — do not report it as an + error. + +### The credit-warning confirm flow + +1. Select the **Custom** template on the Profession page (leaves several + attribute credits unspent) and complete the rest of the character. +2. Click Finish. Confirm a warning dialog appears about unspent attribute + credits, and Finish does NOT send anything yet. +3. Confirm the dialog. Confirm the request now sends anyway, with the + unspent credits — this is retail-correct (`DoFinish`'s own confirm-arm + skips the credit check entirely; ACE accepts an under-spent build). + +### The randomize warning flow + +Already covered in §CC2's Summary-page step 4 above — confirm the warning +appears BEFORE any randomization happens, and Cancel leaves the character +completely untouched. + +### The exit warning flow + +Already covered in §CC1's "Leaving the screen" section above. + +### NameTooLong + +1. On the Summary page, type or paste a name longer than 32 characters and + commit it (press Enter, or click elsewhere to move focus away from the + field). +2. Confirm the `ID_CharGen_NameTooLong` dialog appears and the field + reverts to its previous (shorter) value — the field itself does not cap + your typing at 32 characters as you go; the rejection only fires on + commit. That is retail-correct, not a bug. + +### What to report for §CC3 + +- Whether the happy path truly lands you in-world with no intermediate + screen. +- The exact dialog text shown for each rejection (useful for a later + string-table audit even if it looks right). +- Any case where Finish appears to do nothing at all (no dialog, no + console line, no world entry) — that would be a real regression, not one + of the documented cosmetic gaps above. + +--- + +## §CC4 — ACE-side landmines (not acdream defects) + +- **Heritage-priced skill over-deduction — LATENT, will not fire with the + installed EoR DAT.** ACE's `PlayerFactory.CreatePlayer` has a real + overcharge bug when specializing a skill priced by the active heritage's + own skill list (versus the global skill table). It was measured against + the installed EoR data and found unreachable — every heritage's one + priced skill (Arcane Lore) has a heritage NormalCost of 0, which makes + ACE's overcharge exactly zero. You should NOT be able to trigger a + `FailedToSpecializeSkill` rejection from a retail-legal build during this + gate. If you somehow do, that is worth flagging immediately — it would + mean the installed DAT's costs differ from what was measured. +- **Disabled-Olthoi create -> Pending -> NameDBDown dialog is + retail-correct.** If your local ACE has Olthoi character creation + disabled (a server config option), attempting to create an Olthoi/ + OlthoiAcid character will surface the `ID_Character_Err_NameDBDown` + dialog via a `Pending` response code. This is retail's OWN behavior + (ACE's `olthoi_play_disabled` branch sends `Pending`, and retail's + dispatch has no silent branch for it) — not a "the client swallowed my + request" bug. + +--- + +## §CC-Not-Automated + +No automated test in this repository has created a character against a +LIVE ACE server. Every field-shape and response-code assertion in Campaign +CC's test suite runs against a real `WorldSession` and hand-built response +packets (see `tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`) +— the wire bytes and the state machine are proven byte-for-byte, but the +FIRST character ever created against a real, running ACE process is +whatever you create during this gate. Server state (what names exist, +what heritages are enabled, what the account's slot count is) is entirely +yours to observe; this script deliberately does not assume any of it in +advance. diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index dc6e9be2..963d5e23 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -125,12 +125,16 @@ internal sealed class CharacterManagementUiController : IDisposable Root.Width > 0f ? Root.Width : 800f, Root.Height > 0f ? Root.Height : 600f); - // Create Character belongs to a future campaign. Keep retail's - // authored control in place and visibly ghosted; do not hide it or - // invent an action. + // Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage + // @ 0x004ed5a0 case 3 dispatches Create unconditionally on click + // (QueueUIMode(0x1000000b) — no gate at click time); the gate lives + // entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state + // (see ApplyButtons below), so the click handler is wired once here + // and Enabled tracks the borrowed snapshot every tick. Starts + // disabled/ghosted until the first real snapshot arrives. _create.Visible = true; _create.Enabled = false; - _create.OnClick = null; + _create.OnClick = RequestCreate; _enter.OnClick = EnterSelected; _delete.OnClick = RequestDelete; _restore.OnClick = RestoreSelected; @@ -534,7 +538,7 @@ internal sealed class CharacterManagementUiController : IDisposable private void ApplyButtons(RuntimeCharacterSelectionButtons buttons) { _create.Visible = true; - _create.Enabled = false; + _create.Enabled = buttons.CanCreate; _enter.Enabled = buttons.CanEnter; _delete.Visible = buttons.DeleteVisible; _delete.Enabled = buttons.CanDelete; @@ -550,6 +554,22 @@ internal sealed class CharacterManagementUiController : IDisposable InvalidateAndTick(); } + /// + /// Campaign CC slice CC7: ListenToElementMessage case 3 -> + /// QueueUIMode(0x1000000b). Purely presentational — no Runtime + /// command, no roster/state change here; ' + /// RequestCreate is resolved per-call (never captured) so it + /// reflects whatever wired at the time of + /// the click, matching every other late-bound seam in this bindings + /// record. + /// + private void RequestCreate() + { + if (_disposed) + return; + _bindings.RequestCreate?.Invoke(); + } + private void EnterSelected() { if (_disposed) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 22f7a199..07381f1d 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -398,7 +398,24 @@ public sealed record CharacterSelectionRuntimeBindings( Func ConfirmDelete, Func Restore, Func Cancel, - Action RequestExit); + Action RequestExit, + /// + /// Campaign CC slice CC7: retail's Create button + /// (gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0 + /// case 3 -> UIFramework::QueueUIMode(this, 0x1000000b), the + /// gmCharGenMainUI mode). Wired by + /// itself (it alone holds both the character-management and + /// character-creation controllers) to + /// CharacterCreationController?.Open() — resolved lazily so + /// mount order between the two screens does not matter. + /// when no chargen screen is mounted (e.g. a + /// headless bot's LiveCharacterSelector path, where + /// is also + /// null) — the button then behaves as a no-op click while its own + /// Enabled gate () + /// still reflects the real roster-vs-slot state. + /// + Action? RequestCreate = null); public sealed record RetailUiRuntimeBindings( UiHost Host, @@ -3816,9 +3833,18 @@ public sealed class RetailUiRuntime : IDisposable if (bindings is null || _characterManagementMount is not null) return; + // Campaign CC slice CC7: RetailUiRuntime is the one object holding + // BOTH controllers, so it supplies the cross-screen seam locally + // rather than routing it through the externally-composed bindings + // record (which is built before this runtime exists — see + // CharacterSelectionRuntimeBindings.RequestCreate's own doc + // comment). The lambda closes over `this` and reads + // CharacterCreationController per call, so it is safe even though + // ConfigureCharacterCreation() has not run yet at this point (see + // its call site immediately below this method's own caller). _characterManagementMount = new CharacterManagementUiMountCoordinator( Host.Root, - bindings, + bindings with { RequestCreate = () => CharacterCreationController?.Open() }, EnsureDialogFactory, LoadCharacterManagementResources); } diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index 2c289825..53adf97c 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -64,7 +64,17 @@ public readonly record struct RuntimeCharacterSelectionButtons( bool CanDelete, bool CanRestore, bool DeleteVisible, - bool RestoreVisible) + bool RestoreVisible, + /// + /// Campaign CC slice CC7: retail's Create-character gate + /// (gmCharacterManagementUI::UpdateButtons @ 0x004ec240, + /// ~0x004ec319-0x004ec32e) — unconditional on selection, purely + /// _charSet.set_.m_num < _charSet.numAllowedCharacters_ (the + /// live roster count against the allowed-slot ceiling). Mirrors + /// < + /// . + /// + bool CanCreate = false) { public static RuntimeCharacterSelectionButtons None { get; } = new(false, false, false, true, false); @@ -848,13 +858,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex) { + // gmCharacterManagementUI::UpdateButtons @ 0x004ec240's Create gate + // is unconditional on the delete/selection state below it — it only + // ever compares the live roster count against the allowed-slot + // ceiling (~0x004ec319-0x004ec32e: + // `if (_charSet.set_.m_num < _charSet.numAllowedCharacters_) + // SetState(1); else SetState(0xd);`). + bool canCreate = _entries.Length < _slotCount; + if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged) { - return RuntimeCharacterSelectionButtons.None; + return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate }; } if (selectedIndex < 0) - return RuntimeCharacterSelectionButtons.None; + return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate }; RuntimeCharacterSelectionEntry selected = _entries[selectedIndex]; if (selected.IsPendingDelete) @@ -864,7 +882,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable CanDelete: false, CanRestore: !_restoreResponseArmed, DeleteVisible: false, - RestoreVisible: true); + RestoreVisible: true, + CanCreate: canCreate); } return new RuntimeCharacterSelectionButtons( @@ -872,7 +891,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable CanDelete: selected.CanEnter, CanRestore: false, DeleteVisible: true, - RestoreVisible: false); + RestoreVisible: false, + CanCreate: canCreate); } private int FindDisplayIndex(uint characterId) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 7e884c59..ec21c062 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests CharacterManagementUiController.RestoreElementId); Assert.True(create.Visible); - Assert.False(create.Enabled); - Assert.Null(create.OnClick); + // Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @ + // 0x004ec240's Create gate — 3 characters against SlotCount 5. + Assert.True(create.Enabled); + Assert.NotNull(create.OnClick); + create.OnClick!(); + Assert.Equal(1, environment.Runtime.RequestCreateCalls); Assert.True(enter.Enabled); Assert.True(delete.Visible); Assert.True(delete.Enabled); @@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests Assert.True(restore.Enabled); } + /// + /// Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @ + /// 0x004ec240's Create gate (~0x004ec319-0x004ec32e) is purely + /// _charSet.set_.m_num < _charSet.numAllowedCharacters_ — a + /// full roster (roster count == the allowed-slot ceiling) ghosts Create + /// exactly like retail, and refilling below the ceiling un-ghosts it + /// again on the next Tick. + /// + [Fact] + public void CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton create = environment.Button( + CharacterManagementUiController.CreateElementId); + + // The fixture's SlotCount is 5 — five characters exactly fills it. + RuntimeCharacterSelectionEntry[] full = Enumerable.Range(0, 5) + .Select(index => new RuntimeCharacterSelectionEntry( + index, + (uint)(0x50000200 + index), + $"Full {index:D2}", + 0u)) + .ToArray(); + environment.Runtime.ReplaceRoster(full, highlightedCharacterId: full[0].CharacterId); + controller.Tick(); + + Assert.True(create.Visible); + Assert.False(create.Enabled); + + RuntimeCharacterSelectionEntry[] belowCeiling = full[..4]; + environment.Runtime.ReplaceRoster( + belowCeiling, + highlightedCharacterId: belowCeiling[0].CharacterId); + controller.Tick(); + + Assert.True(create.Enabled); + } + /// /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120 /// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName() @@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests ConfirmDelete, Restore, Cancel, - RequestExit); + RequestExit, + RequestCreate); } public FakeView View { get; } = new(); @@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests public int CancelCalls { get; private set; } public int RestoreCalls { get; private set; } public int RequestExitCalls { get; private set; } + public int RequestCreateCalls { get; private set; } + + /// Campaign CC slice CC7: the fixture's fixed allowed-slot + /// ceiling — mirrors Snapshot's own hard-coded + /// SlotCount: 5 so computes the SAME + /// roster-vs-slot gate the real RuntimeCharacterSelectionState.BuildButtons + /// does, instead of a fixture-only shortcut. + private const int SlotCount = 5; public RuntimeCommandStatus RestoreStatus { get; set; } = RuntimeCommandStatus.Accepted; public bool ThrowOnRestore { get; set; } @@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests RuntimeCharacterSelectionButtons buttons = operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged - ? RuntimeCharacterSelectionButtons.None + ? RuntimeCharacterSelectionButtons.None with + { CanCreate = View.Entries.Length < SlotCount } : ButtonsFor(View.Snapshot.HighlightedCharacterId); Update(snapshot => snapshot with { @@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests { PendingDeleteCharacterId = 0u, Operation = RuntimeCharacterSelectionOperation.DeleteRequested, - Buttons = RuntimeCharacterSelectionButtons.None, + Buttons = RuntimeCharacterSelectionButtons.None with + { CanCreate = View.Entries.Length < SlotCount }, }); return Result(RuntimeCommandStatus.Accepted, id); } @@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests false, false, false, - true), + true, + View.Entries.Length < SlotCount), }); AfterRestoreProjection?.Invoke(); return Result(RuntimeCommandStatus.Accepted, id); @@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests private void RequestExit() => RequestExitCalls++; + private void RequestCreate() => RequestCreateCalls++; + + /// Campaign CC slice CC7: mirrors + /// RuntimeCharacterSelectionState.BuildButtons's own + /// unconditional CanCreate computation — roster length + /// against — so every branch below carries + /// the SAME real gate the production state machine does, not a + /// fixture-only shortcut. private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) { + bool canCreate = View.Entries.Length < SlotCount; RuntimeCharacterSelectionEntry? selected = View.Entries .Cast() .FirstOrDefault(entry => entry?.CharacterId == characterId); if (selected is null) - return RuntimeCharacterSelectionButtons.None; + return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate }; if (selected.Value.IsPendingDelete) { return new RuntimeCharacterSelectionButtons( @@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests false, true, false, - true); + true, + canCreate); } return new RuntimeCharacterSelectionButtons( true, true, false, true, - false); + false, + canCreate); } private void Update( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs index 452d5ff0..764c87f9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs @@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests Assert.Null(environment.Host.FixedCanvasSize); } + /// + /// Campaign CC slice CC7 item 1: the real Create-button wire — retail's + /// gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0 + /// case 3 -> QueueUIMode(0x1000000b) — and the return path on + /// chargen Exit (DoExit @ 0x004e8650 -> + /// QueueUIMode(0x1000000a)). Character-management is never + /// hidden by chargen opening on top of it (see the canvas-arbiter test + /// above), so "return to character management" needs no separate + /// Runtime action beyond chargen's own Close() — this proves + /// that architecture claim end to end rather than by inspection alone. + /// + [Fact] + public void CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement() + { + using var environment = new TwoControllerHarness(); + + // Character-management is active and visible before Create is ever + // clicked (AttachAndTick already ran in its own harness ctor). + Assert.True(environment.Management.Controller.Root.Visible); + Assert.False(environment.Chargen.Controller.Root.Visible); + + UiButton create = environment.Management.Button( + CharacterManagementUiController.CreateElementId); + Assert.True(create.Enabled); + create.OnClick!(); + environment.Chargen.Controller.Tick(); + + Assert.True(environment.Chargen.Controller.Root.Visible); + // Character-management stays active/visible underneath -- chargen + // opening on top never deactivates or hides it. + Assert.True(environment.Management.Controller.Root.Visible); + + environment.Chargen.Button(CharacterCreationUiController.ExitElementId) + .OnClick!(); + environment.Chargen.ConfirmActiveDialog(confirmed: true); + + Assert.False(environment.Chargen.Controller.Root.Visible); + // No separate "return" action was needed -- management was never + // hidden, so it is simply what remains visible. + Assert.True(environment.Management.Controller.Root.Visible); + } + // ── Fixture: one shared UiRoot, both controllers ──────────────────── private sealed class TwoControllerHarness : IDisposable @@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests public TwoControllerHarness() { Host = new UiRoot { Width = 800f, Height = 600f }; - Management = new ManagementHarness(Host); + // Campaign CC slice CC7: chargen must exist FIRST so + // ManagementHarness can wire its Create button straight to the + // real CharacterCreationUiController.Open() — the same shape + // RetailUiRuntime.ConfigureCharacterManagement() uses in + // production (a lazily-resolved lambda closing over the OTHER + // controller, since bindings are always built before both + // controllers exist). Chargen = new ChargenHarness(Host); + Management = new ManagementHarness(Host, Chargen.Controller.Open); } public UiRoot Host { get; } @@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests { private readonly RetailDialogFactory _dialogs; - public ManagementHarness(UiRoot host) + public ManagementHarness(UiRoot host, Action requestCreate) { - ImportedLayout screen = BuildManagementScreen(); - Runtime = new ManagementFakeRuntime(); + Screen = BuildManagementScreen(); + Runtime = new ManagementFakeRuntime(requestCreate); _dialogs = new RetailDialogFactory( host, type => RetailDialogFactoryTests.BuildDialogLayout(type)); Controller = Assert.IsType( CharacterManagementUiController.Bind( host, - screen, + Screen, static (_, _) => BuildRow(), _dialogs, Runtime.Bindings, @@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests "Are you sure you want to leave?"))); } + public ImportedLayout Screen { get; } public ManagementFakeRuntime Runtime { get; } public CharacterManagementUiController Controller { get; } + public UiButton Button(uint id) => + Assert.IsType(Screen.FindElement(id)); + public void Dispose() { Controller.Dispose(); @@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests private static readonly RuntimeGenerationToken Generation = new(11u); private readonly FakeManagementView _view = new(); - public ManagementFakeRuntime() + public ManagementFakeRuntime(Action requestCreate) { _view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)]; _view.Snapshot = new RuntimeCharacterSelectionSnapshot( @@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests LastRestoreRequestedCharacterId: 0u, Operation: RuntimeCharacterSelectionOperation.None, Error: null, - Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false)); + Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true)); Bindings = new CharacterSelectionRuntimeBindings( View: () => _view, Highlight: _ => Result(), @@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests ConfirmDelete: Result, Restore: Result, Cancel: Result, - RequestExit: () => { }); + RequestExit: () => { }, + RequestCreate: requestCreate); } public CharacterSelectionRuntimeBindings Bindings { get; } diff --git a/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj b/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj index fd27c566..868e48f2 100644 --- a/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj +++ b/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj @@ -18,5 +18,12 @@ + + diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs index 91ebbdb4..0f07ee80 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -6,6 +6,7 @@ using AcDream.Core.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Launcher.Core.Status; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.Tests.CharGen; @@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests public List Created { get; } = []; public List Failed { get; } = []; + /// + /// Campaign CC slice CC7 item 3: when set, forwards exactly the way + /// production hosts do (LiveSessionRuntimeFactory.Create's + /// own CharacterCreated/CreationFailed delegates, + /// HeadlessSessionHost's identical pair) — the SAME real + /// a launcher-composed session + /// would use, not a re-implemented shape. + /// + public SessionStatusWriter? Writer { get; set; } + public string SessionId { get; set; } = "s1"; + public LiveSessionBinding BindSession(WorldSession session) => new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } @@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => EnteredWorld.Add(selection); public void DetachSession(WorldSession session) { } - public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => + public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) + { Created.Add(identity); - public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => + Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name); + } + public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) + { Failed.Add(rejection); + Writer?.CreationFailed( + SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName); + } } private static LiveSessionConnectOptions LiveOptions() => new( @@ -399,6 +418,259 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged. } + /// + /// Campaign CC slice CC7 item 2: the SEAMLESS end-to-end walk the + /// campaign closeout asks for — a fully populated creation (heritage, + /// gender, appearance across every one of the fourteen style/color + /// slots and all six shades, template, an EXPLICIT skill command beyond + /// what the template alone applies, an explicit town/start-area + /// selection, name) sent through a REAL , then + /// decoded field-by-field — including the trailing checksum, which the + /// pre-existing + /// test above never checked — against exactly the shape + /// CharacterCreateInfo.Unpack/Appearance.Unpack parse (see + /// 's own doc comment for the ACE + /// cross-reference). The checksum is recomputed via the SAME production + /// formula rather than + /// re-deriving the sum a second time by hand in the test. + /// + [Fact] + public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyesStrip, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.NoseStrip, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.MouthStrip, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairStyle, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairColor, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyeColor, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearColor, 2u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtColor, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersColor, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearColor, 2u).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Skin, 0.25).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Hair, 0.5).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Headgear, 0.75).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Shirt, 0.1).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Trousers, 0.9).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Footwear, 0.6).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); + // Explicit skill command beyond the template's own Normal/Primary + // lists (SkillFreeTrained costs 0 to train — no credit-budget risk). + Assert.True(controller.TrainSkill(generation, RuntimeCharacterCreationStateFixture.SkillFreeTrained).Accepted); + // Town: the fixture's global starter-area list is [Holtburg(0), Yaraq(1)]. + Assert.True(controller.SelectStartArea(generation, 1).Accepted); + Assert.True(controller.SetName(generation, "FullChar").Accepted); + + WorldSession session = operations.Sessions[0]; + byte[]? captured = null; + session.GameMessageCapture = (body, _) => captured = body; + + Assert.True(controller.Finish(generation).Accepted); + Assert.NotNull(captured); + + DecodedFullRequest decoded = DecodeCreateRequestFull(captured!); + + Assert.Equal("testaccount", decoded.AccountName); + Assert.Equal(1u, decoded.Constant); + CharacterCreate.Request r = decoded.Request; + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, r.Heritage); + Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, r.Gender); + Assert.Equal(0u, r.Appearance.EyesStrip); + Assert.Equal(0u, r.Appearance.NoseStrip); + Assert.Equal(0u, r.Appearance.MouthStrip); + Assert.Equal(1u, r.Appearance.HairColor); + Assert.Equal(1u, r.Appearance.EyeColor); + Assert.Equal(1u, r.Appearance.HairStyle); + Assert.Equal(0u, r.Appearance.HeadgearStyle); + Assert.Equal(2u, r.Appearance.HeadgearColor); + Assert.Equal(0u, r.Appearance.ShirtStyle); + Assert.Equal(1u, r.Appearance.ShirtColor); + Assert.Equal(0u, r.Appearance.TrousersStyle); + Assert.Equal(0u, r.Appearance.TrousersColor); + Assert.Equal(0u, r.Appearance.FootwearStyle); + Assert.Equal(2u, r.Appearance.FootwearColor); + Assert.Equal(0.25, r.Appearance.SkinShade); + Assert.Equal(0.5, r.Appearance.HairShade); + Assert.Equal(0.75, r.Appearance.HeadgearShade); + Assert.Equal(0.1, r.Appearance.ShirtShade); + Assert.Equal(0.9, r.Appearance.TrousersShade); + Assert.Equal(0.6, r.Appearance.FootwearShade); + Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, r.Template); + Assert.Equal(16u, r.Attributes.Strength); + Assert.Equal(10u, r.Attributes.Endurance); + Assert.Equal(10u, r.Attributes.Coordination); + Assert.Equal(10u, r.Attributes.Quickness); + Assert.Equal(10u, r.Attributes.Focus); + Assert.Equal(10u, r.Attributes.Self); + Assert.Equal(0u, r.Slot); + // classId: register AP-209's documented placeholder — ACE ignores + // this field (retail's DAT DID lookup has no Core equivalent). + Assert.Equal(0u, r.ClassId); + Assert.Equal( + (uint)CharacterCreate.SkillAdvancementClassCount, + (uint)decoded.SkillAdvancementClasses.Length); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Specialized, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillFreeTrained]); + Assert.Equal("FullChar", r.Name); + Assert.Equal(1u, r.StartArea); + Assert.False(r.IsAdmin); + Assert.False(r.IsEnvoy); + + // The trailing checksum (CG_Pack@0x005c74c3's final store) — never + // read by ACE, sent for byte fidelity with a genuine retail client. + Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum); + } + + /// + /// Campaign CC slice CC7 item 2: the remaining 0xF643 rejection + /// codes beyond NameInUse (already covered by + /// + /// above) — proves CC5's F2 fix (Pending/Undef produce a real rejection + /// instead of a silent reset) holds over the REAL wire decode path, not + /// just the isolated state-machine + /// RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_... + /// theory. + /// + [Theory] + [InlineData(CharGenVerificationResponse.Code.Pending)] + [InlineData(CharGenVerificationResponse.Code.NameBanned)] + [InlineData(CharGenVerificationResponse.Code.Corrupt)] + [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] + [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + [InlineData(CharGenVerificationResponse.Code.Undef)] + public void Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect( + CharGenVerificationResponse.Code code) + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + + InvokeProcessDatagram(session, BuildResponsePacket((uint)code, 0u, string.Empty)); + + Assert.Single(host.Failed); + Assert.Equal((uint)code, host.Failed[0].RawCode); + Assert.Equal(code, host.Failed[0].Code); + Assert.Equal(code.ToString(), host.Failed[0].Reason); + Assert.Equal("NewChar", host.Failed[0].AttemptedName); + Assert.Empty(host.Created); + Assert.False(controller.IsInWorld); + Assert.Empty(operations.EnterWorldByGuidCalls); + Assert.Empty(host.EnteredWorld); + Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); + } + + /// + /// Campaign CC slice CC7 item 3: the launcher payload cycle end to end — + /// a REAL Runtime state transition (Finish -> real + /// -> real inbound 0xF643 Ok reply) + /// through the REAL , wired exactly the + /// way LiveSessionRuntimeFactory.Create (App host) and + /// HeadlessSessionHost wire it (see 's own + /// doc comment), to the REAL Launcher.Core + /// 's parsed event. This is the piece + /// CC2's own tests never reached: SessionStatusWriterTests calls + /// the writer directly and asserts raw JSON; StatusEventParserTests + /// parses a hand-written JSON literal; neither drives a create through + /// Runtime first, so a wiring gap between Runtime's own state machine + /// and the writer (or between the writer's bytes and the tailer's + /// parser) would not have been caught by either. + /// + [Fact] + public void Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer() + { + string path = Path.Combine( + Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl"); + try + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + host.Writer = new SessionStatusWriter(path); + host.SessionId = "cc7-session"; + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); + + // Runtime's own side of the contract already fired. + Assert.Single(host.Created); + + var tailer = new StatusFileTailer(path); + IReadOnlyList events = tailer.ReadNewEvents(); + CharacterCreatedStatusEvent created = + Assert.Single(events.OfType()); + Assert.Equal("cc7-session", created.SessionId); + Assert.Equal(0x50001234u, created.Guid); + Assert.Equal("NewChar", created.Name); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + /// Sibling of the Ok test above for the non-Ok half of the + /// contract (creationFailed{code,reason,name}). + [Fact] + public void Finish_ThenNameInUseResponse_WritesCreationFailedEvent_ParsedByTheRealLauncherTailer() + { + string path = Path.Combine( + Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl"); + try + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + host.Writer = new SessionStatusWriter(path); + host.SessionId = "cc7-session"; + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); + + Assert.Single(host.Failed); + + var tailer = new StatusFileTailer(path); + IReadOnlyList events = tailer.ReadNewEvents(); + CreationFailedStatusEvent failed = + Assert.Single(events.OfType()); + Assert.Equal("cc7-session", failed.SessionId); + Assert.Equal((uint)CharGenVerificationResponse.Code.NameInUse, failed.Code); + Assert.Equal("NameInUse", failed.Reason); + Assert.Equal("NewChar", failed.Name); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) { MethodInfo method = typeof(WorldSession).GetMethod( @@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests accountName, heritage, gender, template, strength, name, numSkills, skills); } + /// Campaign CC slice CC7: the FULL field set — every byte of + /// 's layout, unlike + /// / + /// above which only samples a handful of fields. + private readonly record struct DecodedFullRequest( + string AccountName, + uint Constant, + CharacterCreate.Request Request, + uint[] SkillAdvancementClasses, + uint Checksum); + + private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan body) + { + int pos = 0; + uint opcode = ReadU32(body, ref pos); + Assert.Equal(CharacterCreate.Opcode, opcode); + string accountName = ReadString16L(body, ref pos); + uint constant = ReadU32(body, ref pos); + uint heritage = ReadU32(body, ref pos); + uint gender = ReadU32(body, ref pos); + uint eyesStrip = ReadU32(body, ref pos); + uint noseStrip = ReadU32(body, ref pos); + uint mouthStrip = ReadU32(body, ref pos); + uint hairColor = ReadU32(body, ref pos); + uint eyeColor = ReadU32(body, ref pos); + uint hairStyle = ReadU32(body, ref pos); + uint headgearStyle = ReadU32(body, ref pos); + uint headgearColor = ReadU32(body, ref pos); + uint shirtStyle = ReadU32(body, ref pos); + uint shirtColor = ReadU32(body, ref pos); + uint trousersStyle = ReadU32(body, ref pos); + uint trousersColor = ReadU32(body, ref pos); + uint footwearStyle = ReadU32(body, ref pos); + uint footwearColor = ReadU32(body, ref pos); + double skinShade = ReadF64(body, ref pos); + double hairShade = ReadF64(body, ref pos); + double headgearShade = ReadF64(body, ref pos); + double shirtShade = ReadF64(body, ref pos); + double trousersShade = ReadF64(body, ref pos); + double footwearShade = ReadF64(body, ref pos); + uint template = ReadU32(body, ref pos); + uint strength = ReadU32(body, ref pos); + uint endurance = ReadU32(body, ref pos); + uint coordination = ReadU32(body, ref pos); + uint quickness = ReadU32(body, ref pos); + uint focus = ReadU32(body, ref pos); + uint self = ReadU32(body, ref pos); + uint slot = ReadU32(body, ref pos); + uint classId = ReadU32(body, ref pos); + uint numSkills = ReadU32(body, ref pos); + var skills = new uint[numSkills]; + for (int i = 0; i < numSkills; i++) + skills[i] = ReadU32(body, ref pos); + string name = ReadString16L(body, ref pos); + uint startArea = ReadU32(body, ref pos); + uint isAdmin = ReadU32(body, ref pos); + uint isEnvoy = ReadU32(body, ref pos); + uint checksum = ReadU32(body, ref pos); + + // Nothing left over, nothing missing — the layout is exhaustive. + Assert.Equal(body.Length, pos); + + var request = new CharacterCreate.Request( + heritage, + gender, + new CharacterCreate.Appearance( + eyesStrip, + noseStrip, + mouthStrip, + hairColor, + eyeColor, + hairStyle, + headgearStyle, + headgearColor, + shirtStyle, + shirtColor, + trousersStyle, + trousersColor, + footwearStyle, + footwearColor, + skinShade, + hairShade, + headgearShade, + shirtShade, + trousersShade, + footwearShade), + template, + new CharacterCreate.Attributes( + strength, endurance, coordination, quickness, focus, self), + slot, + classId, + name, + startArea, + isAdmin != 0u, + isEnvoy != 0u); + + return new DecodedFullRequest(accountName, constant, request, skills, checksum); + } + private static uint ReadU32(ReadOnlySpan body, ref int pos) { uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index b2d752a6..a4495cb3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests Assert.Equal( RuntimeCharacterSelectionOperation.DeleteRequested, controller.CharacterSelection.Snapshot.Operation); + // Campaign CC slice CC7: this fixture's roster (2 characters) is + // below its SlotCount (11), so retail's Create gate + // (gmCharacterManagementUI::UpdateButtons) stays enabled through + // the whole delete-request/acknowledge sequence below — CanCreate + // is independent of the delete-in-flight buttons this test is + // actually pinning. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); if (acknowledgeBeforeCompletion) @@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests ? RuntimeCharacterSelectionOperation.DeleteAcknowledged : RuntimeCharacterSelectionOperation.DeleteRequested, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.True(controller.CharacterSelection.TryGet( 0x50000001u, @@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests Assert.Equal( RuntimeCharacterSelectionOperation.DeleteAcknowledged, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.Equal([("Canonical", 1)], operations.DeleteRequests); } @@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests ? RuntimeCharacterSelectionOperation.DeleteAcknowledged : RuntimeCharacterSelectionOperation.DeleteRequested, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.Equal( RuntimeCommandStatus.Rejected, @@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests Assert.Equal( RuntimeCharacterSelectionOperation.DeleteAcknowledged, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.Equal([("Canonical", 1)], operations.DeleteRequests); }