feat(chargen): Campaign CC slice CC7 — end-to-end create flow + connected checklist

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 02:35:21 +02:00
parent bb22ee8bde
commit 9cf6c52283
10 changed files with 957 additions and 37 deletions

File diff suppressed because one or more lines are too long

View file

@ -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-<yourinitials>-<n>`
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.

View file

@ -125,12 +125,16 @@ internal sealed class CharacterManagementUiController : IDisposable
Root.Width > 0f ? Root.Width : 800f, Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f); Root.Height > 0f ? Root.Height : 600f);
// Create Character belongs to a future campaign. Keep retail's // Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage
// authored control in place and visibly ghosted; do not hide it or // @ 0x004ed5a0 case 3 dispatches Create unconditionally on click
// invent an action. // (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.Visible = true;
_create.Enabled = false; _create.Enabled = false;
_create.OnClick = null; _create.OnClick = RequestCreate;
_enter.OnClick = EnterSelected; _enter.OnClick = EnterSelected;
_delete.OnClick = RequestDelete; _delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected; _restore.OnClick = RestoreSelected;
@ -534,7 +538,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons) private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
{ {
_create.Visible = true; _create.Visible = true;
_create.Enabled = false; _create.Enabled = buttons.CanCreate;
_enter.Enabled = buttons.CanEnter; _enter.Enabled = buttons.CanEnter;
_delete.Visible = buttons.DeleteVisible; _delete.Visible = buttons.DeleteVisible;
_delete.Enabled = buttons.CanDelete; _delete.Enabled = buttons.CanDelete;
@ -550,6 +554,22 @@ internal sealed class CharacterManagementUiController : IDisposable
InvalidateAndTick(); InvalidateAndTick();
} }
/// <summary>
/// Campaign CC slice CC7: <c>ListenToElementMessage</c> case 3 ->
/// <c>QueueUIMode(0x1000000b)</c>. Purely presentational — no Runtime
/// command, no roster/state change here; <see cref="_bindings"/>'
/// <c>RequestCreate</c> is resolved per-call (never captured) so it
/// reflects whatever <see cref="RetailUiRuntime"/> wired at the time of
/// the click, matching every other late-bound seam in this bindings
/// record.
/// </summary>
private void RequestCreate()
{
if (_disposed)
return;
_bindings.RequestCreate?.Invoke();
}
private void EnterSelected() private void EnterSelected()
{ {
if (_disposed) if (_disposed)

View file

@ -398,7 +398,24 @@ public sealed record CharacterSelectionRuntimeBindings(
Func<RuntimeCommandResult> ConfirmDelete, Func<RuntimeCommandResult> ConfirmDelete,
Func<RuntimeCommandResult> Restore, Func<RuntimeCommandResult> Restore,
Func<RuntimeCommandResult> Cancel, Func<RuntimeCommandResult> Cancel,
Action RequestExit); Action RequestExit,
/// <summary>
/// Campaign CC slice CC7: retail's Create button
/// (<c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>UIFramework::QueueUIMode(this, 0x1000000b)</c>, the
/// <c>gmCharGenMainUI</c> mode). Wired by <see cref="RetailUiRuntime"/>
/// itself (it alone holds both the character-management and
/// character-creation controllers) to
/// <c>CharacterCreationController?.Open()</c> — resolved lazily so
/// mount order between the two screens does not matter.
/// <see langword="null"/> when no chargen screen is mounted (e.g. a
/// headless bot's <c>LiveCharacterSelector</c> path, where
/// <see cref="RetailUiRuntimeBindings.CharacterCreation"/> is also
/// null) — the button then behaves as a no-op click while its own
/// <c>Enabled</c> gate (<see cref="RuntimeCharacterSelectionButtons.CanCreate"/>)
/// still reflects the real roster-vs-slot state.
/// </summary>
Action? RequestCreate = null);
public sealed record RetailUiRuntimeBindings( public sealed record RetailUiRuntimeBindings(
UiHost Host, UiHost Host,
@ -3816,9 +3833,18 @@ public sealed class RetailUiRuntime : IDisposable
if (bindings is null || _characterManagementMount is not null) if (bindings is null || _characterManagementMount is not null)
return; 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( _characterManagementMount = new CharacterManagementUiMountCoordinator(
Host.Root, Host.Root,
bindings, bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory, EnsureDialogFactory,
LoadCharacterManagementResources); LoadCharacterManagementResources);
} }

View file

@ -64,7 +64,17 @@ public readonly record struct RuntimeCharacterSelectionButtons(
bool CanDelete, bool CanDelete,
bool CanRestore, bool CanRestore,
bool DeleteVisible, bool DeleteVisible,
bool RestoreVisible) bool RestoreVisible,
/// <summary>
/// Campaign CC slice CC7: retail's Create-character gate
/// (<c>gmCharacterManagementUI::UpdateButtons @ 0x004ec240</c>,
/// ~0x004ec319-0x004ec32e) — unconditional on selection, purely
/// <c>_charSet.set_.m_num &lt; _charSet.numAllowedCharacters_</c> (the
/// live roster count against the allowed-slot ceiling). Mirrors
/// <see cref="RuntimeCharacterSelectionSnapshot.RosterCount"/> &lt;
/// <see cref="RuntimeCharacterSelectionSnapshot.SlotCount"/>.
/// </summary>
bool CanCreate = false)
{ {
public static RuntimeCharacterSelectionButtons None { get; } = public static RuntimeCharacterSelectionButtons None { get; } =
new(false, false, false, true, false); new(false, false, false, true, false);
@ -848,13 +858,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex) 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 if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged) or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
{ {
return RuntimeCharacterSelectionButtons.None; return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
} }
if (selectedIndex < 0) if (selectedIndex < 0)
return RuntimeCharacterSelectionButtons.None; return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex]; RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
if (selected.IsPendingDelete) if (selected.IsPendingDelete)
@ -864,7 +882,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
CanDelete: false, CanDelete: false,
CanRestore: !_restoreResponseArmed, CanRestore: !_restoreResponseArmed,
DeleteVisible: false, DeleteVisible: false,
RestoreVisible: true); RestoreVisible: true,
CanCreate: canCreate);
} }
return new RuntimeCharacterSelectionButtons( return new RuntimeCharacterSelectionButtons(
@ -872,7 +891,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
CanDelete: selected.CanEnter, CanDelete: selected.CanEnter,
CanRestore: false, CanRestore: false,
DeleteVisible: true, DeleteVisible: true,
RestoreVisible: false); RestoreVisible: false,
CanCreate: canCreate);
} }
private int FindDisplayIndex(uint characterId) private int FindDisplayIndex(uint characterId)

View file

@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests
CharacterManagementUiController.RestoreElementId); CharacterManagementUiController.RestoreElementId);
Assert.True(create.Visible); Assert.True(create.Visible);
Assert.False(create.Enabled); // Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @
Assert.Null(create.OnClick); // 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(enter.Enabled);
Assert.True(delete.Visible); Assert.True(delete.Visible);
Assert.True(delete.Enabled); Assert.True(delete.Enabled);
@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests
Assert.True(restore.Enabled); Assert.True(restore.Enabled);
} }
/// <summary>
/// Campaign CC slice CC7: <c>gmCharacterManagementUI::UpdateButtons @
/// 0x004ec240</c>'s Create gate (~0x004ec319-0x004ec32e) is purely
/// <c>_charSet.set_.m_num &lt; _charSet.numAllowedCharacters_</c> — 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.
/// </summary>
[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);
}
/// <summary> /// <summary>
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120 /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName() /// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests
ConfirmDelete, ConfirmDelete,
Restore, Restore,
Cancel, Cancel,
RequestExit); RequestExit,
RequestCreate);
} }
public FakeView View { get; } = new(); public FakeView View { get; } = new();
@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests
public int CancelCalls { get; private set; } public int CancelCalls { get; private set; }
public int RestoreCalls { get; private set; } public int RestoreCalls { get; private set; }
public int RequestExitCalls { get; private set; } public int RequestExitCalls { get; private set; }
public int RequestCreateCalls { get; private set; }
/// <summary>Campaign CC slice CC7: the fixture's fixed allowed-slot
/// ceiling — mirrors <c>Snapshot</c>'s own hard-coded
/// <c>SlotCount: 5</c> so <see cref="ButtonsFor"/> computes the SAME
/// roster-vs-slot gate the real <c>RuntimeCharacterSelectionState.BuildButtons</c>
/// does, instead of a fixture-only shortcut.</summary>
private const int SlotCount = 5;
public RuntimeCommandStatus RestoreStatus { get; set; } = public RuntimeCommandStatus RestoreStatus { get; set; } =
RuntimeCommandStatus.Accepted; RuntimeCommandStatus.Accepted;
public bool ThrowOnRestore { get; set; } public bool ThrowOnRestore { get; set; }
@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests
RuntimeCharacterSelectionButtons buttons = operation is RuntimeCharacterSelectionButtons buttons = operation is
RuntimeCharacterSelectionOperation.DeleteRequested RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged or RuntimeCharacterSelectionOperation.DeleteAcknowledged
? RuntimeCharacterSelectionButtons.None ? RuntimeCharacterSelectionButtons.None with
{ CanCreate = View.Entries.Length < SlotCount }
: ButtonsFor(View.Snapshot.HighlightedCharacterId); : ButtonsFor(View.Snapshot.HighlightedCharacterId);
Update(snapshot => snapshot with Update(snapshot => snapshot with
{ {
@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests
{ {
PendingDeleteCharacterId = 0u, PendingDeleteCharacterId = 0u,
Operation = RuntimeCharacterSelectionOperation.DeleteRequested, Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
Buttons = RuntimeCharacterSelectionButtons.None, Buttons = RuntimeCharacterSelectionButtons.None with
{ CanCreate = View.Entries.Length < SlotCount },
}); });
return Result(RuntimeCommandStatus.Accepted, id); return Result(RuntimeCommandStatus.Accepted, id);
} }
@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests
false, false,
false, false,
false, false,
true), true,
View.Entries.Length < SlotCount),
}); });
AfterRestoreProjection?.Invoke(); AfterRestoreProjection?.Invoke();
return Result(RuntimeCommandStatus.Accepted, id); return Result(RuntimeCommandStatus.Accepted, id);
@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests
private void RequestExit() => RequestExitCalls++; private void RequestExit() => RequestExitCalls++;
private void RequestCreate() => RequestCreateCalls++;
/// <summary>Campaign CC slice CC7: mirrors
/// <c>RuntimeCharacterSelectionState.BuildButtons</c>'s own
/// unconditional <c>CanCreate</c> computation — roster length
/// against <see cref="SlotCount"/> — so every branch below carries
/// the SAME real gate the production state machine does, not a
/// fixture-only shortcut.</summary>
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
{ {
bool canCreate = View.Entries.Length < SlotCount;
RuntimeCharacterSelectionEntry? selected = View.Entries RuntimeCharacterSelectionEntry? selected = View.Entries
.Cast<RuntimeCharacterSelectionEntry?>() .Cast<RuntimeCharacterSelectionEntry?>()
.FirstOrDefault(entry => entry?.CharacterId == characterId); .FirstOrDefault(entry => entry?.CharacterId == characterId);
if (selected is null) if (selected is null)
return RuntimeCharacterSelectionButtons.None; return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
if (selected.Value.IsPendingDelete) if (selected.Value.IsPendingDelete)
{ {
return new RuntimeCharacterSelectionButtons( return new RuntimeCharacterSelectionButtons(
@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests
false, false,
true, true,
false, false,
true); true,
canCreate);
} }
return new RuntimeCharacterSelectionButtons( return new RuntimeCharacterSelectionButtons(
true, true,
true, true,
false, false,
true, true,
false); false,
canCreate);
} }
private void Update( private void Update(

View file

@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
Assert.Null(environment.Host.FixedCanvasSize); Assert.Null(environment.Host.FixedCanvasSize);
} }
/// <summary>
/// Campaign CC slice CC7 item 1: the real Create-button wire — retail's
/// <c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>QueueUIMode(0x1000000b)</c> — and the return path on
/// chargen Exit (<c>DoExit @ 0x004e8650</c> -&gt;
/// <c>QueueUIMode(0x1000000a)</c>). 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 <c>Close()</c> — this proves
/// that architecture claim end to end rather than by inspection alone.
/// </summary>
[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 ──────────────────── // ── Fixture: one shared UiRoot, both controllers ────────────────────
private sealed class TwoControllerHarness : IDisposable private sealed class TwoControllerHarness : IDisposable
@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
public TwoControllerHarness() public TwoControllerHarness()
{ {
Host = new UiRoot { Width = 800f, Height = 600f }; 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); Chargen = new ChargenHarness(Host);
Management = new ManagementHarness(Host, Chargen.Controller.Open);
} }
public UiRoot Host { get; } public UiRoot Host { get; }
@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
{ {
private readonly RetailDialogFactory _dialogs; private readonly RetailDialogFactory _dialogs;
public ManagementHarness(UiRoot host) public ManagementHarness(UiRoot host, Action requestCreate)
{ {
ImportedLayout screen = BuildManagementScreen(); Screen = BuildManagementScreen();
Runtime = new ManagementFakeRuntime(); Runtime = new ManagementFakeRuntime(requestCreate);
_dialogs = new RetailDialogFactory( _dialogs = new RetailDialogFactory(
host, host,
type => RetailDialogFactoryTests.BuildDialogLayout(type)); type => RetailDialogFactoryTests.BuildDialogLayout(type));
Controller = Assert.IsType<CharacterManagementUiController>( Controller = Assert.IsType<CharacterManagementUiController>(
CharacterManagementUiController.Bind( CharacterManagementUiController.Bind(
host, host,
screen, Screen,
static (_, _) => BuildRow(), static (_, _) => BuildRow(),
_dialogs, _dialogs,
Runtime.Bindings, Runtime.Bindings,
@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
"Are you sure you want to leave?"))); "Are you sure you want to leave?")));
} }
public ImportedLayout Screen { get; }
public ManagementFakeRuntime Runtime { get; } public ManagementFakeRuntime Runtime { get; }
public CharacterManagementUiController Controller { get; } public CharacterManagementUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public void Dispose() public void Dispose()
{ {
Controller.Dispose(); Controller.Dispose();
@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
private static readonly RuntimeGenerationToken Generation = new(11u); private static readonly RuntimeGenerationToken Generation = new(11u);
private readonly FakeManagementView _view = new(); private readonly FakeManagementView _view = new();
public ManagementFakeRuntime() public ManagementFakeRuntime(Action requestCreate)
{ {
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)]; _view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
_view.Snapshot = new RuntimeCharacterSelectionSnapshot( _view.Snapshot = new RuntimeCharacterSelectionSnapshot(
@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
LastRestoreRequestedCharacterId: 0u, LastRestoreRequestedCharacterId: 0u,
Operation: RuntimeCharacterSelectionOperation.None, Operation: RuntimeCharacterSelectionOperation.None,
Error: null, Error: null,
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false)); Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true));
Bindings = new CharacterSelectionRuntimeBindings( Bindings = new CharacterSelectionRuntimeBindings(
View: () => _view, View: () => _view,
Highlight: _ => Result(), Highlight: _ => Result(),
@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
ConfirmDelete: Result, ConfirmDelete: Result,
Restore: Result, Restore: Result,
Cancel: Result, Cancel: Result,
RequestExit: () => { }); RequestExit: () => { },
RequestCreate: requestCreate);
} }
public CharacterSelectionRuntimeBindings Bindings { get; } public CharacterSelectionRuntimeBindings Bindings { get; }

View file

@ -18,5 +18,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" /> <ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" />
<!-- Campaign CC slice CC7: test-only cross-assembly reference for the
Runtime-state-transition -> SessionStatusWriter -> Launcher.Core
StatusFileTailer end-to-end assertion (mirrors the LA1+LA3
precedent of a cross-assembly test enforcing a shared contract).
AcDream.Runtime itself does not, and must not, reference
AcDream.Launcher.Core. -->
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -6,6 +6,7 @@ using AcDream.Core.CharGen;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets; using AcDream.Core.Net.Packets;
using AcDream.Launcher.Core.Status;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Runtime.Tests.CharGen; using AcDream.Runtime.Tests.CharGen;
@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
public List<RuntimeCharacterCreationIdentity> Created { get; } = []; public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
public List<RuntimeCharacterCreationRejection> Failed { get; } = []; public List<RuntimeCharacterCreationRejection> Failed { get; } = [];
/// <summary>
/// Campaign CC slice CC7 item 3: when set, forwards exactly the way
/// production hosts do (<c>LiveSessionRuntimeFactory.Create</c>'s
/// own <c>CharacterCreated</c>/<c>CreationFailed</c> delegates,
/// <c>HeadlessSessionHost</c>'s identical pair) — the SAME real
/// <see cref="SessionStatusWriter"/> a launcher-composed session
/// would use, not a re-implemented shape.
/// </summary>
public SessionStatusWriter? Writer { get; set; }
public string SessionId { get; set; } = "s1";
public LiveSessionBinding BindSession(WorldSession session) => public LiveSessionBinding BindSession(WorldSession session) =>
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
EnteredWorld.Add(selection); EnteredWorld.Add(selection);
public void DetachSession(WorldSession session) { } public void DetachSession(WorldSession session) { }
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity)
{
Created.Add(identity); Created.Add(identity);
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name);
}
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection)
{
Failed.Add(rejection); Failed.Add(rejection);
Writer?.CreationFailed(
SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName);
}
} }
private static LiveSessionConnectOptions LiveOptions() => new( 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. Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged.
} }
/// <summary>
/// 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 <see cref="WorldSession"/>, then
/// decoded field-by-field — including the trailing checksum, which the
/// pre-existing <see cref="Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName"/>
/// test above never checked — against exactly the shape
/// <c>CharacterCreateInfo.Unpack</c>/<c>Appearance.Unpack</c> parse (see
/// <see cref="CharacterCreate"/>'s own doc comment for the ACE
/// cross-reference). The checksum is recomputed via the SAME production
/// <see cref="CharacterCreate.ComputeChecksum"/> formula rather than
/// re-deriving the sum a second time by hand in the test.
/// </summary>
[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);
}
/// <summary>
/// Campaign CC slice CC7 item 2: the remaining <c>0xF643</c> rejection
/// codes beyond NameInUse (already covered by
/// <see cref="Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection"/>
/// 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
/// <c>RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...</c>
/// theory.
/// </summary>
[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"));
}
/// <summary>
/// Campaign CC slice CC7 item 3: the launcher payload cycle end to end —
/// a REAL Runtime state transition (Finish -&gt; real
/// <see cref="WorldSession"/> -&gt; real inbound <c>0xF643</c> Ok reply)
/// through the REAL <see cref="SessionStatusWriter"/>, wired exactly the
/// way <c>LiveSessionRuntimeFactory.Create</c> (App host) and
/// <c>HeadlessSessionHost</c> wire it (see <see cref="TestHost"/>'s own
/// doc comment), to the REAL Launcher.Core
/// <see cref="StatusFileTailer"/>'s parsed event. This is the piece
/// CC2's own tests never reached: <c>SessionStatusWriterTests</c> calls
/// the writer directly and asserts raw JSON; <c>StatusEventParserTests</c>
/// 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.
/// </summary>
[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<StatusEvent> events = tailer.ReadNewEvents();
CharacterCreatedStatusEvent created =
Assert.Single(events.OfType<CharacterCreatedStatusEvent>());
Assert.Equal("cc7-session", created.SessionId);
Assert.Equal(0x50001234u, created.Guid);
Assert.Equal("NewChar", created.Name);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
/// <summary>Sibling of the Ok test above for the non-Ok half of the
/// contract (<c>creationFailed{code,reason,name}</c>).</summary>
[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<StatusEvent> events = tailer.ReadNewEvents();
CreationFailedStatusEvent failed =
Assert.Single(events.OfType<CreationFailedStatusEvent>());
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) private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{ {
MethodInfo method = typeof(WorldSession).GetMethod( MethodInfo method = typeof(WorldSession).GetMethod(
@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests
accountName, heritage, gender, template, strength, name, numSkills, skills); accountName, heritage, gender, template, strength, name, numSkills, skills);
} }
/// <summary>Campaign CC slice CC7: the FULL field set — every byte of
/// <see cref="CharacterCreate.BuildRequestBody"/>'s layout, unlike
/// <see cref="CapturedCreateRequest"/>/<see cref="DecodeCreateRequest"/>
/// above which only samples a handful of fields.</summary>
private readonly record struct DecodedFullRequest(
string AccountName,
uint Constant,
CharacterCreate.Request Request,
uint[] SkillAdvancementClasses,
uint Checksum);
private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan<byte> 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<byte> body, ref int pos) private static uint ReadU32(ReadOnlySpan<byte> body, ref int pos)
{ {
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));

View file

@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests
Assert.Equal( Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteRequested, RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation); 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( Assert.Equal(
RuntimeCharacterSelectionButtons.None, RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons); controller.CharacterSelection.Snapshot.Buttons);
if (acknowledgeBeforeCompletion) if (acknowledgeBeforeCompletion)
@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests
? RuntimeCharacterSelectionOperation.DeleteAcknowledged ? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested, : RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation); 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( Assert.Equal(
RuntimeCharacterSelectionButtons.None, RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons); controller.CharacterSelection.Snapshot.Buttons);
Assert.True(controller.CharacterSelection.TryGet( Assert.True(controller.CharacterSelection.TryGet(
0x50000001u, 0x50000001u,
@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests
Assert.Equal( Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged, RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation); 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( Assert.Equal(
RuntimeCharacterSelectionButtons.None, RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons); controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests); Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
} }
@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests
? RuntimeCharacterSelectionOperation.DeleteAcknowledged ? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested, : RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation); 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( Assert.Equal(
RuntimeCharacterSelectionButtons.None, RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons); controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal( Assert.Equal(
RuntimeCommandStatus.Rejected, RuntimeCommandStatus.Rejected,
@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests
Assert.Equal( Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged, RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation); 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( Assert.Equal(
RuntimeCharacterSelectionButtons.None, RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons); controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests); Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
} }