Both dual-lens reviewers of `9cf6c522`+`ddcbf1fb` returned PASS-with-items. This round closes all nine findings: F1 files AP-229 for the screen-layering divergence (retail destroys/ reconstructs the current UI framework via UIFlow::UseNewMode; acdream keeps both CharacterManagementUiController and CharacterCreationUiController mounted for the whole lifetime and reveals/occludes) plus its narrow residual risk (the shared RetailDialogFactory can hand UiRoot.Modal to a dialog opened by the still-ticking, occluded management screen on an inbound CharacterError) and what already matches retail (selection/ world-name persistence, click-through isolation, one coherent Modal stack). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe and the pending-delete-counts note. F3 adds AP-221's console-diagnostic lines to the known-gaps paragraph. F4 adds an empty-name/AP-227 step. F9 notes that a uniform Random pick over 13 heritages can repeat. F5 adds an App-layer source-text pin (GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBinds CharacterCreatedAndCreationFailedToTheStatusWriter) for the delegate wiring the reviewer proved was deletable without breaking any test — no practical seam exists to construct LiveSessionRuntimeFactory without a GameWindow, so this follows the file's own established source-text-pin pattern; the payload shape is already pinned separately at SessionStatusWriterTests. F6 corrects the CC7 ledger's checksum-assertion wording (it is a round-trip purity check, not an independent golden — the golden is CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet) and cross-references it from the test's own doc comment. F7 corrects the CC7 ledger's fixture-ordering claim (it had chargen constructing first, backwards from RetailUiRuntime.Tick's real management-then-chargen order) and reorders CharacterScreensFixedCanvas ArbiterTests to match production, adding ClickThrough/ZOrder assertions that pin the occlusion the reviewer previously verified only by hand. F8 records a known flake (RuntimeCollisionReportingStateTests. WarmedSteadyContactRefreshDoesNotAllocate, allocation-assertion load sensitivity, pre-existing) seen under full-solution parallel load on both reviewer runs. Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate. Runtime 1735/0 (unchanged), App 5257/3 skips (+1: the new F5 pin). Full Release build: 0 warnings, 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
899 lines
43 KiB
C#
899 lines
43 KiB
C#
using System.Buffers.Binary;
|
|
using System.Net;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
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;
|
|
|
|
namespace AcDream.Runtime.Tests.Session;
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC3: <see cref="LiveSessionController"/>'s
|
|
/// character-creation integration — the wire send (via a REAL
|
|
/// <see cref="WorldSession"/> + <c>GameMessageCapture</c>, the same seam
|
|
/// <c>WorldSessionCharacterCreationTests</c> uses in Core.Net) and the
|
|
/// Ok-response round trip (roster append reusing
|
|
/// <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>, the reused
|
|
/// <c>EnterSelectedCore</c> log-straight-in, and the new
|
|
/// <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/>/
|
|
/// <see cref="ILiveSessionLifecycleHost.ApplyCreationFailed"/> hooks) via a
|
|
/// real inbound <c>0xF643</c> packet through <c>WorldSession.ProcessDatagram</c>
|
|
/// (reflection — the same private test seam Core.Net's own creation tests
|
|
/// use).
|
|
/// </summary>
|
|
public sealed class LiveSessionControllerCharacterCreationTests
|
|
{
|
|
private sealed class TestTransport : IWorldSessionTransport
|
|
{
|
|
public void Send(ReadOnlySpan<byte> datagram) { }
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
|
public int Receive(Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken) =>
|
|
throw new OperationCanceledException(cancellationToken);
|
|
public void Dispose() { }
|
|
}
|
|
|
|
private sealed class TestOperations : ILiveSessionOperations
|
|
{
|
|
public List<WorldSession> Sessions { get; } = [];
|
|
public int EnterWorldCount { get; private set; }
|
|
|
|
/// <summary>F1/F11: captures every guid-based enter call so the
|
|
/// post-create log-straight-in can be asserted against the EXACT
|
|
/// identity sent, instead of a bare counter that cannot tell an
|
|
/// index-based call from a guid-based one, or a right character
|
|
/// from a wrong one.</summary>
|
|
public List<(uint Guid, string AccountName)> EnterWorldByGuidCalls { get; } = [];
|
|
|
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
|
new(IPAddress.Loopback, port);
|
|
|
|
public WorldSession CreateSession(IPEndPoint endpoint)
|
|
{
|
|
var session = new WorldSession(endpoint, new TestTransport());
|
|
Sessions.Add(session);
|
|
return session;
|
|
}
|
|
|
|
public void Connect(WorldSession session, string user, string password) { }
|
|
|
|
public void StartCharacterSelectionReceive(WorldSession session) { }
|
|
|
|
/// <summary>Two pre-existing characters whose WIRE order (array
|
|
/// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from
|
|
/// their ALPHABETICAL display order ("Amy" < "Zed") — F2's
|
|
/// regression gate. A single-character roster cannot distinguish a
|
|
/// correct wire-index-preserving append from a buggy
|
|
/// re-sort-and-renumber, because with only one entry the two orders
|
|
/// coincide.</summary>
|
|
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
|
|
0u,
|
|
[
|
|
new CharacterList.Character(0x50000002u, "Zed", 0u),
|
|
new CharacterList.Character(0x50000003u, "Amy", 0u),
|
|
],
|
|
[],
|
|
SlotCount: 11,
|
|
AccountName: "testaccount",
|
|
true,
|
|
true);
|
|
|
|
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
|
EnterWorldCount++;
|
|
|
|
/// <summary>R1: when positive, the next guid-enter throws retail's
|
|
/// server-rejection shape (the transport-valid path that returns
|
|
/// the controller to selection), decrementing per call — lets a
|
|
/// test reach the create-again-after-rejected-enter flow.</summary>
|
|
public int EnterWorldByGuidRejectionsRemaining { get; set; }
|
|
|
|
public void EnterWorldByGuid(
|
|
WorldSession session,
|
|
uint characterGuid,
|
|
string accountName)
|
|
{
|
|
EnterWorldByGuidCalls.Add((characterGuid, accountName));
|
|
if (EnterWorldByGuidRejectionsRemaining > 0)
|
|
{
|
|
EnterWorldByGuidRejectionsRemaining--;
|
|
throw new CharacterSelectionRejectedException(
|
|
new CharacterError.Parsed(0x0000000Bu));
|
|
}
|
|
}
|
|
|
|
public void Tick(WorldSession session) { }
|
|
|
|
public void DisposeSession(WorldSession session) { }
|
|
}
|
|
|
|
private sealed class TestHost : ILiveSessionLifecycleHost
|
|
{
|
|
public List<LiveSessionRosterReport> Rosters { get; } = [];
|
|
public List<LiveSessionCharacterSelection> EnteredWorld { get; } = [];
|
|
public List<RuntimeCharacterCreationIdentity> Created { 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) =>
|
|
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
|
|
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
|
|
public void ReportConnecting(string host, int port, string user) { }
|
|
public void ReportConnected() { }
|
|
public void ReportRoster(LiveSessionRosterReport roster) => Rosters.Add(roster);
|
|
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
|
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
|
EnteredWorld.Add(selection);
|
|
public void DetachSession(WorldSession session) { }
|
|
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity)
|
|
{
|
|
Created.Add(identity);
|
|
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(
|
|
Enabled: true,
|
|
"127.0.0.1",
|
|
9000,
|
|
"testaccount",
|
|
"password",
|
|
Character: null,
|
|
Probe: false,
|
|
AwaitCharacterSelection: true);
|
|
|
|
private static (LiveSessionController Controller, TestOperations Operations, TestHost Host, RuntimeGenerationToken Generation)
|
|
StartAwaitingSelection()
|
|
{
|
|
var operations = new TestOperations();
|
|
var host = new TestHost();
|
|
var controller = new LiveSessionController(
|
|
operations,
|
|
timeProvider: null,
|
|
RuntimeCharacterCreationStateFixture.Build());
|
|
|
|
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
|
Assert.Equal(LiveSessionStartStatus.AwaitingCharacterSelection, result.Status);
|
|
|
|
return (controller, operations, host, controller.Generation);
|
|
}
|
|
|
|
private static void BuildReadyCharacter(LiveSessionController controller, RuntimeGenerationToken generation)
|
|
{
|
|
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
|
|
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
|
|
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted);
|
|
Assert.True(controller.SetName(generation, "NewChar").Accepted);
|
|
}
|
|
|
|
[Fact]
|
|
public void Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName()
|
|
{
|
|
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
|
|
StartAwaitingSelection();
|
|
BuildReadyCharacter(controller, generation);
|
|
|
|
WorldSession session = operations.Sessions[0];
|
|
byte[]? captured = null;
|
|
GameMessageGroup? capturedGroup = null;
|
|
session.GameMessageCapture = (body, group) =>
|
|
{
|
|
captured = body;
|
|
capturedGroup = group;
|
|
};
|
|
|
|
RuntimeCommandResult result = controller.Finish(generation);
|
|
|
|
Assert.True(result.Accepted);
|
|
Assert.NotNull(captured);
|
|
Assert.Equal(GameMessageGroup.LoginQueue, capturedGroup);
|
|
|
|
CapturedCreateRequest decoded = DecodeCreateRequest(captured!);
|
|
Assert.Equal("testaccount", decoded.AccountName);
|
|
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, decoded.Heritage);
|
|
Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, decoded.Gender);
|
|
Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, decoded.Template);
|
|
Assert.Equal(16u, decoded.Strength);
|
|
Assert.Equal("NewChar", decoded.Name);
|
|
Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, decoded.NumSkills);
|
|
Assert.Equal(CharacterCreate.SkillAdvancementClassCount, decoded.SkillAdvancementClasses.Length);
|
|
Assert.Equal(
|
|
(uint)ChargenSkillAdvancementClass.Trained,
|
|
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]);
|
|
Assert.Equal(
|
|
(uint)ChargenSkillAdvancementClass.Specialized,
|
|
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Finish_ThenOkResponse_AppendsToRosterAndLogsStraightIn()
|
|
{
|
|
(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)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
|
|
|
|
Assert.Single(host.Created);
|
|
Assert.Equal(0x50001234u, host.Created[0].Guid);
|
|
Assert.Equal("NewChar", host.Created[0].Name);
|
|
Assert.Empty(host.Failed);
|
|
|
|
// The roster report following the Ok reply has EVERY pre-existing
|
|
// character and the newly created one.
|
|
LiveSessionRosterReport lastReport = host.Rosters[^1];
|
|
Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar");
|
|
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000002u && e.Name == "Zed");
|
|
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000003u && e.Name == "Amy");
|
|
|
|
// F2 acceptance gate: the append preserves every PRE-EXISTING
|
|
// character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot
|
|
// 1 — deliberately NOT alphabetical order) and assigns the NEW
|
|
// character the true wire count (2, 0-based, after Zed and Amy). A
|
|
// round-trip through ApplyRoster's name-sort-and-renumber would
|
|
// have swapped Zed/Amy to 1/0 instead.
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed));
|
|
Assert.Equal(0, zed.ActiveIndex);
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy));
|
|
Assert.Equal(1, amy.ActiveIndex);
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry newChar));
|
|
Assert.Equal(2, newChar.ActiveIndex);
|
|
|
|
// F1 acceptance gate: gmCharGenMainUI::Update @ 0x004E8460's
|
|
// log-straight-in, reused via EnterCreatedCharacterCore — the
|
|
// controller is now in-world as the new character, entered by the
|
|
// EXACT guid the Ok reply carried (NOT the roster-index path, which
|
|
// the cached wire roster is by-design stale for post-create).
|
|
Assert.True(controller.IsInWorld);
|
|
Assert.Equal(0, operations.EnterWorldCount);
|
|
Assert.Single(operations.EnterWorldByGuidCalls);
|
|
Assert.Equal(0x50001234u, operations.EnterWorldByGuidCalls[0].Guid);
|
|
Assert.Equal("testaccount", operations.EnterWorldByGuidCalls[0].AccountName);
|
|
Assert.Single(host.EnteredWorld);
|
|
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CC3 re-review R1: a SECOND create in the same session must get wire
|
|
/// slot N+1, not N. ACE never resends CharacterList post-create, so the
|
|
/// cached wire count alone under-counts by the creates it hasn't seen;
|
|
/// the controller's creates-since-list counter (reset on every fresh
|
|
/// wire CharacterList) supplies the difference — the equivalent of
|
|
/// retail's own CharacterSet growing via AddIdentity per create. The
|
|
/// create-again path is reached exactly as the re-review described:
|
|
/// first create Ok, guid-enter rejected by the server
|
|
/// (CharacterSelectionRejectedException → ReturnToSelection), then a
|
|
/// second create.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot()
|
|
{
|
|
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
|
StartAwaitingSelection();
|
|
BuildReadyCharacter(controller, generation);
|
|
WorldSession session = operations.Sessions[0];
|
|
session.GameMessageCapture = (_, _) => { };
|
|
operations.EnterWorldByGuidRejectionsRemaining = 1;
|
|
|
|
Assert.True(controller.Finish(generation).Accepted);
|
|
InvokeProcessDatagram(session, BuildResponsePacket(
|
|
(uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
|
|
|
|
// The rejected enter left us back at selection with the first
|
|
// created character appended at the true wire slot 2.
|
|
Assert.False(controller.IsInWorld);
|
|
Assert.Single(operations.EnterWorldByGuidCalls);
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry firstCreated));
|
|
Assert.Equal(2, firstCreated.ActiveIndex);
|
|
|
|
// Second create in the same session: ACE's own list now holds
|
|
// Zed(0), Amy(1), NewChar(2) — the cached wire list still only
|
|
// holds Zed and Amy. The second character's slot must be 3.
|
|
Assert.True(controller.SetName(generation, "SecondChar").Accepted);
|
|
Assert.True(controller.Finish(generation).Accepted);
|
|
InvokeProcessDatagram(session, BuildResponsePacket(
|
|
(uint)CharGenVerificationResponse.Code.Ok, 0x50005678u, "SecondChar"));
|
|
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50005678u, out RuntimeCharacterSelectionEntry secondCreated));
|
|
Assert.Equal(3, secondCreated.ActiveIndex);
|
|
// Pre-existing wire indices still intact after both appends.
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed));
|
|
Assert.Equal(0, zed.ActiveIndex);
|
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy));
|
|
Assert.Equal(1, amy.ActiveIndex);
|
|
Assert.Equal(2, host.Created.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection()
|
|
{
|
|
(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)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty));
|
|
|
|
Assert.Single(host.Failed);
|
|
Assert.Equal(CharGenVerificationResponse.Code.NameInUse, host.Failed[0].Code);
|
|
Assert.Equal("NewChar", host.Failed[0].AttemptedName);
|
|
Assert.Empty(host.Created);
|
|
Assert.False(controller.IsInWorld);
|
|
Assert.Equal(0, operations.EnterWorldCount);
|
|
Assert.Empty(operations.EnterWorldByGuidCalls);
|
|
Assert.Empty(host.EnteredWorld);
|
|
// No roster append on a rejection.
|
|
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Finish_RefusedLocallyWithUnspentAttributeCredits_NeverTouchesTheWire()
|
|
{
|
|
(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.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted);
|
|
Assert.True(controller.SetName(generation, "NewChar").Accepted);
|
|
WorldSession session = operations.Sessions[0];
|
|
bool sent = false;
|
|
session.GameMessageCapture = (_, _) => sent = true;
|
|
|
|
RuntimeCommandResult result = controller.Finish(generation);
|
|
|
|
Assert.False(result.Accepted);
|
|
Assert.False(sent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F3 acceptance gate: retail does NOT force a full attribute spend —
|
|
/// <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c> only warns
|
|
/// (<c>arg2 != 0 && remainingAtrbCredits > 0</c>) and the
|
|
/// credit-warning dialog's own confirm handler re-invokes
|
|
/// <c>DoFinish(this, 0)</c> (@0x004E98BB), which skips the check
|
|
/// entirely and sends. <see cref="LiveSessionController.Finish"/>'s
|
|
/// <c>confirmUnspentCredits</c> parameter is that <c>arg2 == 0</c> case.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Finish_WithUnspentCreditsAndConfirmed_SendsAnyway()
|
|
{
|
|
(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.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted);
|
|
Assert.True(controller.SetName(generation, "NewChar").Accepted);
|
|
WorldSession session = operations.Sessions[0];
|
|
byte[]? captured = null;
|
|
session.GameMessageCapture = (body, _) => captured = body;
|
|
|
|
// Unconfirmed: refused, matching the sibling test above — the
|
|
// warning-dialog gate.
|
|
Assert.False(controller.Finish(generation).Accepted);
|
|
Assert.Null(captured);
|
|
|
|
// Confirmed: sends anyway with the credits still unspent.
|
|
RuntimeCommandResult confirmed = controller.Finish(generation, confirmUnspentCredits: true);
|
|
|
|
Assert.True(confirmed.Accepted);
|
|
Assert.NotNull(captured);
|
|
CapturedCreateRequest decoded = DecodeCreateRequest(captured!);
|
|
Assert.Equal("NewChar", decoded.Name);
|
|
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).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Campaign CC CC7 review-fix round, F6 (2026-08-16): the checksum
|
|
/// assertion below (<c>Assert.Equal(CharacterCreate.ComputeChecksum(r),
|
|
/// decoded.Checksum)</c>) is a ROUND-TRIP/PURITY check, not an
|
|
/// independent golden — it recomputes the SAME production
|
|
/// <see cref="CharacterCreate.ComputeChecksum"/> formula the encode side
|
|
/// already used, rather than re-deriving the sum a second time by hand,
|
|
/// so it proves the wire-encode/decode round trip is lossless but
|
|
/// cannot by itself catch a bug shared by both the encoder and this
|
|
/// formula. The checksum's actual golden value (the 19-term retail
|
|
/// accumulation set, hand-summed to <c>205u</c>) is pinned separately
|
|
/// at <c>CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet</c>
|
|
/// (<c>tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs</c>).
|
|
/// </remarks>
|
|
[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 -> real
|
|
/// <see cref="WorldSession"/> -> 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)
|
|
{
|
|
MethodInfo method = typeof(WorldSession).GetMethod(
|
|
"ProcessDatagram",
|
|
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
|
method.Invoke(session, [new ReadOnlyMemory<byte>(datagram), null, true]);
|
|
}
|
|
|
|
private static byte[] BuildResponseBody(uint code, uint guid, string name)
|
|
{
|
|
var w = new PacketWriter();
|
|
w.WriteUInt32(CharGenVerificationResponse.ResponseOpcode);
|
|
w.WriteUInt32(code);
|
|
if (code == (uint)CharGenVerificationResponse.Code.Ok)
|
|
{
|
|
w.WriteUInt32(guid);
|
|
w.WriteString16L(name);
|
|
w.WriteUInt32(0u);
|
|
}
|
|
return w.ToArray();
|
|
}
|
|
|
|
private static byte[] BuildResponsePacket(uint code, uint guid, string name)
|
|
{
|
|
byte[] message = BuildResponseBody(code, guid, name);
|
|
var fragments = new byte[MessageFragmentHeader.Size + message.Length];
|
|
GameMessageFragment.WriteSingleFragment(fragments.AsSpan(), fragmentSequence: 1u, GameMessageGroup.UIQueue, message);
|
|
return PacketCodec.Encode(
|
|
new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments },
|
|
fragments,
|
|
outboundIsaac: null);
|
|
}
|
|
|
|
private readonly record struct CapturedCreateRequest(
|
|
string AccountName,
|
|
uint Heritage,
|
|
uint Gender,
|
|
uint Template,
|
|
uint Strength,
|
|
string Name,
|
|
uint NumSkills,
|
|
uint[] SkillAdvancementClasses);
|
|
|
|
/// <summary>Manual mirror of <see cref="CharacterCreate.BuildRequestBody"/>'s
|
|
/// exact field order — see that class's doc comment for the full
|
|
/// layout.</summary>
|
|
private static CapturedCreateRequest DecodeCreateRequest(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);
|
|
Assert.Equal(1u, constant);
|
|
uint heritage = ReadU32(body, ref pos);
|
|
uint gender = ReadU32(body, ref pos);
|
|
_ = ReadU32(body, ref pos); // eyesStrip
|
|
_ = ReadU32(body, ref pos); // noseStrip
|
|
_ = ReadU32(body, ref pos); // mouthStrip
|
|
_ = ReadU32(body, ref pos); // hairColor
|
|
_ = ReadU32(body, ref pos); // eyeColor
|
|
_ = ReadU32(body, ref pos); // hairStyle
|
|
_ = ReadU32(body, ref pos); // headgearStyle
|
|
_ = ReadU32(body, ref pos); // headgearColor
|
|
_ = ReadU32(body, ref pos); // shirtStyle
|
|
_ = ReadU32(body, ref pos); // shirtColor
|
|
_ = ReadU32(body, ref pos); // trousersStyle
|
|
_ = ReadU32(body, ref pos); // trousersColor
|
|
_ = ReadU32(body, ref pos); // footwearStyle
|
|
_ = ReadU32(body, ref pos); // footwearColor
|
|
for (int i = 0; i < 6; i++)
|
|
_ = ReadF64(body, ref pos); // six shades
|
|
uint template = ReadU32(body, ref pos);
|
|
uint strength = ReadU32(body, ref pos);
|
|
_ = ReadU32(body, ref pos); // endurance
|
|
_ = ReadU32(body, ref pos); // coordination
|
|
_ = ReadU32(body, ref pos); // quickness
|
|
_ = ReadU32(body, ref pos); // focus
|
|
_ = ReadU32(body, ref pos); // self
|
|
_ = ReadU32(body, ref pos); // slot
|
|
_ = ReadU32(body, ref pos); // classId
|
|
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);
|
|
// startArea, isAdmin, isEnvoy, checksum follow — not needed here.
|
|
return new CapturedCreateRequest(
|
|
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)
|
|
{
|
|
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
return value;
|
|
}
|
|
|
|
private static double ReadF64(ReadOnlySpan<byte> body, ref int pos)
|
|
{
|
|
double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
|
|
pos += 8;
|
|
return value;
|
|
}
|
|
|
|
private static string ReadString16L(ReadOnlySpan<byte> body, ref int pos)
|
|
{
|
|
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
string value = Encoding.ASCII.GetString(body.Slice(pos + 2, len));
|
|
int recordSize = 2 + len;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
pos += recordSize + padding;
|
|
return value;
|
|
}
|
|
}
|