Campaign CT slice CT2: the client now learns the character's earned titles and current display title from the server, owns that state in Runtime, and can send a display-title change. No UI (CT3/CT4). Wire (Core.Net): - GameEvents.ParseCharacterTitleTable (0x0029 CharacterTitle): retail CharacterTitleTable::UnPack @0x005c6e90 skips a leading u32 into no field — its own Pack @0x005c6e40 always writes the literal 1 there, matching ACE's unconditional Writer.Write(1u) — then reads displayTitleId, then a count-prefixed PList<uint> of earned ids. - GameEvents.ParseUpdateTitle (0x002B UpdateTitle): titleId + setAsDisplay, per CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0 -> Handle_Social__AddOrSetCharacterTitle @0x00564260, which ALWAYS adds (SendNotice_AddCharacterTitle, unconditional) and additionally sets display only when setAsDisplay != 0 (SendNotice_SetDisplayCharacterTitle, gated). - SocialActions.BuildTitleSet / WorldSession.SendSetTitle: outbound TitleSet (0x002C), u32 titleId, matching ACE's GameActionSetTitle. - GameEventWiring gains onCharacterTitleTable/onUpdateTitle delegate holes (Core.Net cannot reference AcDream.Runtime directly). Runtime: - New RuntimeCharacterTitleState (RuntimeCharacterState.Titles): earned title id set + display title id, TableReplaced/TitleAdded/ DisplayTitleChanged events matching retail's unconditional-add / gated-display-set contract, clears at generation reset. RuntimeCharacterOwnershipSnapshot/CaptureOwnership/IsConverged and RuntimeCharacterSnapshot extended (trailing optional fields, no existing call site broken). - IRuntimeCharacterCommands.SetTitle: generation-gated, sends TitleSet only — NO optimistic local mutation. Verified against retail's own CM_Social::Event_SetDisplayCharacterTitle @0x006a5720, which sends the wire message and touches no local field; the display title updates only from the server's own echo (the CA-campaign lesson: never re-add an optimistic write). Implemented on both hosts (DirectGameRuntimeCommandAdapter direct-send; CurrentGameRuntimeCommandAdapter via LiveCommandBus / LiveSessionCommandRouter's new SetTitleRuntimeCmd). - LiveSessionEventRouter wires the two inbound events unconditionally (RuntimeCharacterState.Titles is a required child, not an optional sibling like Fellowship/Allegiance). App (non-UI plumbing + resolver): - CharacterTitleResolver (src/AcDream.App/UI/Layout/): ports CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0 — titleId -> EnumMapper(0x22000041) canonical key -> compute_str_hash -> StringTable(0x2300000E) localized text. Runtime stays id-only; CT3/ CT4 consume this for display. DIDs hardcoded per the RetailKeyNames precedent (CT1 verified them end-to-end). Register: no new row. Retail's send path is non-optimistic and so is ours — no deviation to record for this slice. Tests: wire conformance (byte-exact + truncation) in CharacterTitleEventsTests.cs + SocialActionsTests.cs; Runtime owner unit tests in RuntimeCharacterTitleStateTests.cs plus integration in RuntimeCharacterStateTests.cs; a no-local-mutation command test in DirectGameRuntimeCommandAdapterTests.cs; an InstalledDat pin (CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14, run green with ACDREAM_RUN_INSTALLED_DAT_TESTS=1). Full solution build green; hermetic filtered suite green (15,380 passed / 0 failed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
794 lines
39 KiB
C#
794 lines
39 KiB
C#
using System.Diagnostics;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Selection;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Settings;
|
|
using AcDream.App.Spells;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.World;
|
|
using AcDream.Content;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.Runtime.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Net;
|
|
|
|
internal sealed record LiveSessionPlayerRuntime(
|
|
LocalPlayerIdentityState Identity,
|
|
RuntimeLocalPlayerMovementState Controller,
|
|
LiveWorldOriginState WorldOrigin);
|
|
|
|
internal sealed record LiveSessionDomainRuntime(
|
|
GameRuntime Runtime,
|
|
RuntimeEntityObjectLifetime EntityObjects,
|
|
RuntimeCharacterState Character,
|
|
RuntimeActionState Actions,
|
|
RuntimeInventoryState Inventory,
|
|
RuntimeCommunicationState Communication);
|
|
|
|
internal sealed record LiveSessionUiRuntime(
|
|
RetailUiRuntime? RetailUi,
|
|
VitalsVM? Vitals,
|
|
CharacterSheetProvider? CharacterSheet,
|
|
MagicRuntime? Magic,
|
|
PaperdollFramePresenter? Paperdoll);
|
|
|
|
internal sealed record LiveSessionInteractionRuntime(
|
|
RuntimeSettingsController Settings,
|
|
GameplayInputFrameController GameplayInput,
|
|
PlayerModeController PlayerMode,
|
|
PlayerModeAutoEntry PlayerModeAutoEntry,
|
|
ItemInteractionController ItemInteraction,
|
|
RuntimeCombatAttackState CombatAttack,
|
|
SelectionInteractionController SelectionInteractions);
|
|
|
|
internal sealed record LiveSessionWorldRuntime(
|
|
IDatReaderWriter Dats,
|
|
// Logout-audio round (2026-08-17): null only when audio is disabled
|
|
// (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world
|
|
// resume both no-op then.
|
|
Audio.WorldAudioSessionGate? WorldAudio,
|
|
GpuWorldState WorldState,
|
|
LiveEntityRuntime LiveEntities,
|
|
LiveEntitySessionController EntitySession,
|
|
WorldEnvironmentController Environment,
|
|
DeferredLocalPlayerTeleportNetworkSink Teleport,
|
|
DatSpawnClaimHydrationClassifier SpawnClaims,
|
|
EquippedChildRenderController EquippedChildren,
|
|
RetailSelectionScene SelectionScene,
|
|
ParticleVisibilityController ParticleVisibility,
|
|
RetailInboundEventDispatcher InboundEvents,
|
|
LiveEntityLivenessController Liveness,
|
|
LiveEntityNetworkUpdateController NetworkUpdates,
|
|
LiveEntityHydrationController Hydration,
|
|
EntityEffectController EntityEffects,
|
|
AnimationHookFrameQueue AnimationHookFrames,
|
|
LiveEntityPresentationController Presentation,
|
|
RemoteMovementObservationTracker RemoteMovementObservations,
|
|
RenderSceneShadowRuntime? RenderSceneShadow,
|
|
RuntimePlacementPresentationSink PlacementProjection,
|
|
RuntimePlacementProjectionRetrySlot PlacementRetries,
|
|
RuntimeFirstEntryDriveController FirstEntryDrive,
|
|
RuntimeAcceptedPositionDriveController AcceptedPositionDrive,
|
|
RuntimeRemotePlacementDriveController RemotePlacementDrive);
|
|
|
|
/// <summary>
|
|
/// Builds the exact per-generation route/reset graph for the canonical live
|
|
/// session. It owns no mirrored session state and retains no window callback.
|
|
/// </summary>
|
|
internal sealed class LiveSessionRuntimeFactory
|
|
{
|
|
private readonly LiveSessionPlayerRuntime _player;
|
|
private readonly LiveSessionDomainRuntime _domain;
|
|
private readonly LiveSessionUiRuntime _ui;
|
|
private readonly LiveSessionInteractionRuntime _interaction;
|
|
private readonly LiveSessionWorldRuntime _world;
|
|
private readonly LiveSessionCommandSurface _commands;
|
|
private readonly Action<string> _log;
|
|
private readonly LiveMovementStatsApplier _movementStats;
|
|
private readonly SessionStatusWriter _statusWriter;
|
|
private readonly string _sessionId;
|
|
private readonly IReadOnlyList<string> _loginCommands;
|
|
private readonly TimeSpan _loginCommandDelay;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
/// <summary>
|
|
/// Where a bare <c>@log</c> filename lands. See <see cref="ChatSessionLog"/>
|
|
/// for why this is not the install directory retail names.
|
|
/// </summary>
|
|
private readonly string _chatLogDirectory;
|
|
|
|
private ChatSessionLog? _chatSessionLog;
|
|
|
|
private ChatTranscriptLogWriter? _chatLogWriter;
|
|
|
|
public LiveSessionRuntimeFactory(
|
|
LiveSessionPlayerRuntime player,
|
|
LiveSessionDomainRuntime domain,
|
|
LiveSessionUiRuntime ui,
|
|
LiveSessionInteractionRuntime interaction,
|
|
LiveSessionWorldRuntime world,
|
|
LiveSessionCommandSurface commands,
|
|
Action<string> log,
|
|
SessionStatusWriter? statusWriter = null,
|
|
string sessionId = "app",
|
|
IReadOnlyList<string>? loginCommands = null,
|
|
int loginCommandDelayMs = 500,
|
|
TimeProvider? timeProvider = null,
|
|
string? chatLogDirectory = null)
|
|
{
|
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
|
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
|
_ui = ui ?? throw new ArgumentNullException(nameof(ui));
|
|
_interaction = interaction
|
|
?? throw new ArgumentNullException(nameof(interaction));
|
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
|
// Campaign LA slice LA1: a no-op instance when the caller has no
|
|
// status file configured — every call site below stays unconditional.
|
|
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
|
|
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
|
if (loginCommandDelayMs < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(loginCommandDelayMs));
|
|
}
|
|
_chatLogDirectory = chatLogDirectory
|
|
?? AcDream.Platform.ApplicationPathSet.Resolve().LogsDirectory;
|
|
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
|
|
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
|
// typed application seam; App keeps zero direct controller mutations.
|
|
_movementStats = new LiveMovementStatsApplier(
|
|
_player.Controller,
|
|
_domain.Character.MovementSkills,
|
|
_log);
|
|
}
|
|
|
|
public LiveSessionHost Create(
|
|
LiveSessionController controller,
|
|
LiveSessionConnectOptions connectOptions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(controller);
|
|
ArgumentNullException.ThrowIfNull(connectOptions);
|
|
var resetHost = new GraphicalRuntimeGenerationResetHost(
|
|
_world.LiveEntities,
|
|
() => _world.RenderSceneShadow?.DrainUpdateBoundary());
|
|
LiveSessionResetPlan reset =
|
|
LiveSessionResetManifest.Create(
|
|
CreateResetBindings(resetHost));
|
|
var loginCommands = new LoginCommandSequence(
|
|
_loginCommands,
|
|
_loginCommandDelay,
|
|
new RuntimeChatCommandFeedback(_domain.Communication),
|
|
_commands,
|
|
failure => _statusWriter.LoginCommandFailed(
|
|
_sessionId,
|
|
failure.CommandIndex,
|
|
failure.Command,
|
|
failure.Error),
|
|
_timeProvider);
|
|
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
|
Routing: new(
|
|
CreateEventRouter,
|
|
session => _commands.Attach(new LiveSessionCommandRouter(
|
|
CreateCommandBindings(session)))),
|
|
Reset: reset.Execute,
|
|
Selection: new(
|
|
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
|
|
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
|
|
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
|
|
MarkPersistent: _world.WorldState.MarkPersistent,
|
|
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
|
|
ClearCombat: _domain.Actions.Combat.Clear,
|
|
// Enter-click round (2026-08-17): the login wormhole arms at
|
|
// the selected-character edge — before the EnterWorld wire
|
|
// send on every entry route — so the tunnel covers the whole
|
|
// server round-trip (registered deviation; retail shows
|
|
// black until CreatePlayer).
|
|
ArmLoginTunnel: _world.Teleport.ArmLoginTunnel),
|
|
EnteredWorld: new(
|
|
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
|
|
RestoreLayout: () =>
|
|
{
|
|
_ui.RetailUi?.RestoreLayout();
|
|
// MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a
|
|
// still-open Fellowship page's 0x00A6 now we are in world —
|
|
// RestoreLayout is the post-world UI-restore moment, and
|
|
// this is idempotent if RestoreLayout already re-showed the
|
|
// page (the latch is already set). ResetSessionTransientUi
|
|
// (pre-world) only cleared the latch.
|
|
_ui.RetailUi?.RedeclareSocialPanelAfterWorldEntry();
|
|
},
|
|
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
|
|
LoadCharacterSettings: name =>
|
|
{
|
|
_interaction.Settings.LoadCharacterContext(name);
|
|
// Campaign QJ slice QJ5: retail loads the journal on
|
|
// entering the world, from the same per-character moment
|
|
// the settings context uses. RetailUiRuntime owns the file
|
|
// because the panel that writes it does.
|
|
_ui.RetailUi?.LoadJournal(name);
|
|
},
|
|
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm,
|
|
// Logout-audio round (2026-08-17): reopen the world-audio
|
|
// pool the session reset closed (see the reset manifest's
|
|
// "world audio" step).
|
|
ResumeWorldAudio: () => _world.WorldAudio?.ResumeForWorldEntry()),
|
|
Connecting: (host, port, user) =>
|
|
_domain.Communication.Chat.OnSystemMessage(
|
|
$"connecting to {host}:{port} as {user}",
|
|
chatType: 1),
|
|
Connected: () =>
|
|
{
|
|
_domain.Communication.Chat.OnSystemMessage(
|
|
"connected — character list received",
|
|
chatType: 1);
|
|
_statusWriter.Connected(_sessionId);
|
|
},
|
|
Roster: roster => _statusWriter.CharacterList(_sessionId, roster),
|
|
CharacterEntered: selection => _statusWriter.EnteredWorld(
|
|
_sessionId,
|
|
selection.CharacterId,
|
|
selection.CharacterName),
|
|
LoginCommands: loginCommands,
|
|
// Campaign CC slice CC4: the two sibling events to Roster/
|
|
// CharacterEntered above — see SessionStatusWriter's own doc
|
|
// for why characterCreated precedes an eventual enteredWorld
|
|
// rather than replacing it.
|
|
CharacterCreated: identity => _statusWriter.CharacterCreated(
|
|
_sessionId,
|
|
identity.Guid,
|
|
identity.Name),
|
|
CreationFailed: rejection => _statusWriter.CreationFailed(
|
|
_sessionId,
|
|
rejection.RawCode,
|
|
rejection.Reason,
|
|
rejection.AttemptedName)),
|
|
connectOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's <c>@log</c> file lifecycle
|
|
/// (<c>ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0</c> /
|
|
/// <c>CloseLogFile @0x0057ACC0</c>). An empty name closes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The writer attaches to the transcript on OPEN rather than at startup,
|
|
/// which is what retail's own help promises: "All the information that
|
|
/// appears in your chat window AFTER you type this command will be copied".
|
|
/// It detaches on close, so a closed log costs nothing per line.
|
|
/// <para>
|
|
/// The line written is the composed display line, because that is what
|
|
/// retail logs — <c>fprintf @0x00563E5B</c> sits inside
|
|
/// <c>AddTextToScroll</c>, downstream of composition and upstream of glyph
|
|
/// layout. <see cref="ChatLog.Append"/> is acdream's equivalent single
|
|
/// fan-in, and it already owns the timestamp decision the log shares.
|
|
/// </para>
|
|
/// </remarks>
|
|
private ChatLogResult SetChatLogFile(string name)
|
|
{
|
|
ChatSessionLog log = _chatSessionLog ??= new ChatSessionLog(_chatLogDirectory);
|
|
ChatTranscriptLogWriter writer = _chatLogWriter ??= new ChatTranscriptLogWriter(log);
|
|
string? closedName = log.CurrentName;
|
|
|
|
writer.Detach();
|
|
bool closed = log.Close();
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return new ChatLogResult(Opened: false, closed, string.Empty, closedName);
|
|
|
|
bool opened = log.Open(name, out string resolved);
|
|
if (opened)
|
|
writer.Attach(_domain.Communication.Chat);
|
|
|
|
return new ChatLogResult(opened, closed, resolved, closedName);
|
|
}
|
|
|
|
private LiveSessionResetBindings CreateResetBindings(
|
|
IRuntimeGenerationResetHost resetHost) => new()
|
|
{
|
|
MouseCapture = _interaction.GameplayInput.ResetSession,
|
|
PlayerPresentation = ResetPlayerPresentation,
|
|
TeleportPresentation =
|
|
_world.Teleport.ResetGenerationPresentation,
|
|
WorldAudio = () => _world.WorldAudio?.SuspendForSessionReset(),
|
|
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
|
SettingsCharacterContext =
|
|
_interaction.Settings.RestoreDefaultCharacterContext,
|
|
EquippedChildren = _world.EquippedChildren.Clear,
|
|
InteractionPresentation =
|
|
_interaction.SelectionInteractions.ResetGenerationPresentation,
|
|
SelectionPresentation = _world.SelectionScene.Reset,
|
|
ParticleVisibility = _world.ParticleVisibility.Reset,
|
|
InboundEventFifo = _world.InboundEvents.Clear,
|
|
LiveLiveness = _world.Liveness.Clear,
|
|
RuntimeGeneration = generation =>
|
|
_domain.Runtime.ResetGeneration(generation, resetHost),
|
|
SessionIdentityPresentation = ResetIdentityPresentation,
|
|
NetworkEffects = _world.EntityEffects.ClearNetworkState,
|
|
AnimationHookFrames = _world.AnimationHookFrames.Clear,
|
|
LivePresentation = _world.Presentation.Clear,
|
|
RemoteMovementDiagnostics = _world.RemoteMovementObservations.Clear,
|
|
};
|
|
|
|
private void ResetPlayerPresentation()
|
|
{
|
|
_movementStats.Reset();
|
|
_interaction.PlayerMode.ResetSession();
|
|
_world.SpawnClaims.Reset();
|
|
}
|
|
|
|
private void ResetIdentityPresentation(
|
|
RuntimeGenerationToken retiringGeneration)
|
|
{
|
|
RuntimeGenerationResetSnapshot reset =
|
|
_domain.Runtime.GenerationReset.CaptureSnapshot();
|
|
if (reset.IsActive
|
|
|| reset.LastCompletedGeneration != retiringGeneration)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime generation {retiringGeneration.Value} has not "
|
|
+ "converged before App identity projection teardown.");
|
|
}
|
|
|
|
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
|
|
// components, and character option objects. CPlayerSystem::Begin
|
|
// @ 0x0055D410 resets player identity and session fields. The option
|
|
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
|
|
_ui.Vitals?.SetLocalPlayerGuid(0u);
|
|
EntityVanishProbe.PlayerGuid = 0u;
|
|
_interaction.Settings.ResetActiveCharacterKey();
|
|
_world.NetworkUpdates.ResetSessionState();
|
|
_world.Hydration.ResetSessionState();
|
|
_ui.Paperdoll?.ResetSession();
|
|
|
|
// X/Y are ignored until the next logical player CreateObject claims a
|
|
// new origin. Keeping the last values avoids inventing a synthetic
|
|
// landblock while still forcing first-spawn initialization.
|
|
_player.WorldOrigin.Reset();
|
|
}
|
|
|
|
private ILiveSessionEventRouting CreateEventRouter(WorldSession session)
|
|
{
|
|
SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u);
|
|
if (_ui.CharacterSheet is not null)
|
|
{
|
|
_ui.CharacterSheet.SkillTable = skillTable;
|
|
_ui.CharacterSheet.ExperienceTable =
|
|
CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log);
|
|
}
|
|
|
|
var route = new LiveSessionEventRouter(
|
|
session,
|
|
_world.EntitySession.CreateSink(),
|
|
new LiveEnvironmentSessionSink(
|
|
_world.Environment.ApplyAdminEnvirons,
|
|
_world.Environment.SynchronizeFromServer),
|
|
CreateInventoryBindings(),
|
|
CreateCharacterBindings(skillTable),
|
|
new LiveSocialSessionBindings(
|
|
_domain.Communication.Chat,
|
|
_domain.Communication.TurbineChat,
|
|
_domain.Communication.Friends,
|
|
_domain.Communication.Squelch,
|
|
(text, type) => _domain.Communication.AddText(text, type),
|
|
Fellowship: _domain.Runtime.FellowshipOwner,
|
|
Allegiance: _domain.Runtime.AllegianceOwner,
|
|
Trade: _domain.Runtime.TradeOwner,
|
|
House: _domain.Runtime.HouseOwner,
|
|
Contracts: _domain.Runtime.ContractsOwner));
|
|
return new GraphicalSessionEventRoute(
|
|
route,
|
|
_domain.Runtime,
|
|
_world.PlacementProjection,
|
|
_world.PlacementRetries,
|
|
_world.FirstEntryDrive,
|
|
_ =>
|
|
{
|
|
// Enter-world round (2026-08-17): the graphical host no
|
|
// longer sends LoginComplete here. Retail sends 0xA1 at the
|
|
// END of the login wormhole (gmSmartBoxUI::UseTime
|
|
// @0x004D745D -> CPlayerSystem::SendLoginCompleteNotification
|
|
// @0x00562E90), and the login portal-space presentation now
|
|
// runs on this host, so the send rides its
|
|
// FireLoginComplete edge (LocalPlayerTeleportController's
|
|
// login pump) — the same edge the F751 pump already uses.
|
|
// This notification is the presentation's worldReady latch:
|
|
// the canonical local-player first placement committed
|
|
// (retail's position_update_complete=1 analogue,
|
|
// SmartBox::UseTime @0x00455483).
|
|
_world.Teleport.OnLocalPlayerFirstEntryCompleted();
|
|
// Night-round review F2: CM_House::Event_QueryHouse @0x006aaa00
|
|
// is tail-called, unconditionally, from the end of
|
|
// CPlayerSystem::InitializePlayer @0x00563570 (guarded by
|
|
// player_initialized, once per session) — an object-arrival
|
|
// edge, NOT a tunnel edge, so it stays at first-entry
|
|
// completion rather than moving with LoginComplete.
|
|
session.SendHouseQuery();
|
|
},
|
|
_world.AcceptedPositionDrive,
|
|
_world.RemotePlacementDrive);
|
|
}
|
|
|
|
private LiveInventorySessionBindings CreateInventoryBindings() => new(
|
|
_domain.Inventory.Objects,
|
|
PlayerGuid: () => _player.Identity.ServerGuid,
|
|
OnShortcuts: _domain.Inventory.Shortcuts.Load,
|
|
OnUseDone: error =>
|
|
{
|
|
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
|
|
_domain.Actions.Transactions.CompleteUse(error);
|
|
},
|
|
_domain.Inventory.ItemMana,
|
|
ExternalContainers: _domain.Inventory.ExternalContainers,
|
|
OnAppraisal: appraisal =>
|
|
{
|
|
if (_ui.RetailUi is { } retailUi)
|
|
retailUi.HandleAppraisal(appraisal);
|
|
else
|
|
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
|
|
},
|
|
Vendor: _domain.Inventory.Vendor);
|
|
|
|
private LiveCharacterSessionBindings CreateCharacterBindings(
|
|
SkillTable? skillTable)
|
|
{
|
|
var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
|
|
return new LiveCharacterSessionBindings(
|
|
_domain.Actions.Combat,
|
|
_domain.Character,
|
|
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
|
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
|
_movementStats.Apply("skills"),
|
|
OnConfirmationRequest: request =>
|
|
_ui.RetailUi?.HandleConfirmationRequest(request),
|
|
OnConfirmationDone: done =>
|
|
_ui.RetailUi?.HandleConfirmationDone(done),
|
|
ClientTime: ClientTimerNow,
|
|
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
|
|
// reactively re-apply through the SAME seam skills already used
|
|
// (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
|
|
// movement owner's typed application entry — see
|
|
// LiveMovementStatsApplier.
|
|
OnMovementStatsUpdated: () => _movementStats.Apply("stats"),
|
|
// Campaign CH slice CH3 (2026-08-09): reseed the Settings Chat
|
|
// draft from server truth every time a PlayerDescription lands
|
|
// (research doc §5.2/§6.4). N4 (CH3 Opus review, 2026-08-09)
|
|
// aligned ChatSettings.Default itself to ACE's real
|
|
// CharacterOptions2.Default, but this reseed stays load-bearing
|
|
// regardless — a per-character persisted settings.json can
|
|
// still diverge from server truth (e.g. an older save, or a
|
|
// character whose allegiance/society changed), and the server
|
|
// is always authoritative.
|
|
//
|
|
// OP4 review-fix round (2026-08-11, MF-1/blast M1): re-seed
|
|
// LockUI's visual push the SAME way — the radar's polled
|
|
// `GetOptionBit` lambda self-corrects every frame, but
|
|
// `host.Root.UiLocked` (InteractionRetainedUiComposition.cs)
|
|
// is a ONE-SHOT assignment taken pre-login, at composition
|
|
// time, from RuntimeCharacterOptionsState's constructor-
|
|
// default word. Routing the real value through SetUiLocked
|
|
// here — the exact seam ToggleUiLock already uses to push
|
|
// RuntimeSettingsTargets.ApplyUiLock — converges
|
|
// the retained window lock to server truth on every fresh
|
|
// PlayerDescription, matching the radar's own convergence
|
|
// instead of only updating on the next manual /lockui toggle.
|
|
OnCharacterOptionsChanged: (_, options2) =>
|
|
{
|
|
_interaction.Settings.SyncChatFromServerOptions(options2);
|
|
_interaction.Settings.SetUiLocked(
|
|
_domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI));
|
|
// OP4 re-review R2: open option-bearing panels re-read live
|
|
// bits at every seed (login + reconnect) — see
|
|
// RuntimeSettingsController.ServerOptionsSeeded.
|
|
_interaction.Settings.NotifyServerOptionsSeeded();
|
|
});
|
|
}
|
|
|
|
private LiveSessionCommandBindings CreateCommandBindings(
|
|
WorldSession session)
|
|
{
|
|
// CH4 re-review SHOULD-FIX 2 (2026-08-09), widened by Campaign OP
|
|
// slice OP1 (2026-08-10): single write-then-send/dirty chokepoint
|
|
// for retail's PlayerModule::OnChanged policy, reached by BOTH
|
|
// entrances that can flip a character option — @join/@leave
|
|
// (ClientCommandController.Bindings.SetSingleCharacterOption below)
|
|
// and the Settings Chat toggles (LiveSessionCommandBindings.
|
|
// SendSingleCharacterOption at the bottom of this method, routed
|
|
// through RuntimeSettingsController.PublishHearOptionChange ->
|
|
// RuntimeSettingsTargets.SetSingleCharacterOption ->
|
|
// SetSingleCharacterOptionRuntimeCmd -> LiveSessionCommandRouter).
|
|
// OP1 moved the actual write-then-send/dirty POLICY into
|
|
// RuntimeCharacterOptionsState.TrySetOption (the shared Runtime seam
|
|
// src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs's
|
|
// SetSingleOption now also calls) so graphical and headless hosts —
|
|
// and every entrance within this host — share exactly one ordering:
|
|
// write the bit locally FIRST, then either send 0x0005 immediately
|
|
// (retail's auto-save ids) or mark the batched module dirty.
|
|
void SendSingleCharacterOption(uint optionId, bool value) =>
|
|
_domain.Character.Options.TrySetOption(
|
|
optionId,
|
|
value,
|
|
// MF-2 (OP1 review fix, 2026-08-11): TrySetOption now takes
|
|
// (id, value) so its fellowship mutual-exclusion recursion
|
|
// can send a DIFFERENT id/value than this call's own.
|
|
sendAutoSave: session.SendSetSingleCharacterOption);
|
|
|
|
// OP1: the explicit SaveOptions verb — retail's
|
|
// CPlayerModule::SaveToServer(force: 0). No-ops when the batched
|
|
// module is clean.
|
|
void SaveCharacterOptionsIfDirty() =>
|
|
_domain.Character.Options.TryFlush(() =>
|
|
{
|
|
CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture(
|
|
_domain.Character,
|
|
_domain.Inventory.Shortcuts);
|
|
session.SendSetCharacterOptions(
|
|
echo.Options1,
|
|
echo.Options2,
|
|
echo.Shortcuts,
|
|
echo.FavoriteSpells,
|
|
echo.DesiredComponents,
|
|
echo.SpellbookFilters);
|
|
});
|
|
|
|
return new(
|
|
ClientCommands: new ClientCommandController.Bindings(
|
|
TeleportToLifestone: session.SendTeleportToLifestone,
|
|
TeleportToMarketplace: session.SendTeleportToMarketplace,
|
|
TeleportToPkArena: session.SendTeleportToPkArena,
|
|
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
|
|
TeleportToHouse: session.SendTeleportToHouse,
|
|
TeleportToMansion: session.SendTeleportToMansion,
|
|
QueryAge: session.SendQueryAge,
|
|
QueryBirth: session.SendQueryBirth,
|
|
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
|
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): flips the
|
|
// SERVER bit through the shared write-then-send/dirty seam
|
|
// (SendSingleCharacterOption above, itself
|
|
// RuntimeCharacterOptionsState.TrySetOption) — LockUI is
|
|
// auto-save (CharacterOptionTable), so this sends 0x0005
|
|
// immediately, exactly like the Character-tab panel row.
|
|
// SetUiLocked still runs too, for its existing
|
|
// _runtimeTargets?.ApplyUiLock(locked) immediate visual push
|
|
// (host.Root.UiLocked) — the legacy GameplaySettings mirror it
|
|
// used to also maintain was retired outright at OP9.
|
|
ToggleUiLock: () =>
|
|
{
|
|
bool locked = !_domain.Character.Options.GetOptionBit(
|
|
CharacterOptionId.LockUI);
|
|
SendSingleCharacterOption((uint)CharacterOptionId.LockUI, locked);
|
|
_interaction.Settings.SetUiLocked(locked);
|
|
},
|
|
// ClientCommandController's informational output sink — 0x00
|
|
// Default, NOT 0x1A (corrected 2026-08-09, Opus review of
|
|
// 172c6f9a). Retail types command output like @version/@loc
|
|
// green; 0x1A (bright red) is reserved for genuine refusals.
|
|
//
|
|
// Comment corrected 2026-08-09, CH2 REJECT-review rework
|
|
// (NIT 2, docs/research/2026-08-09-ch2-review-findings.md):
|
|
// CH2's SpewBox routing covers WeenieError/WeenieErrorWithString
|
|
// ids (see ShowWeenieError below), which carry their own
|
|
// resolved RetailLogTextType — it did NOT reach this sink,
|
|
// which takes plain pre-formatted text with no error code
|
|
// attached. Per-call-site refusal-vs-informational
|
|
// classification of this sink's callers, plus retail's
|
|
// windowId dual-destination echo (register row AP-180), remain
|
|
// unstarted — CH4/CH5 scope at the earliest, not CH2.
|
|
ShowSystemMessage:
|
|
text => _domain.Communication.Chat.OnSystemMessage(text, 0x00u),
|
|
// SHOULD-FIX 3 (docs/research/2026-08-09-ch2-review-findings.md):
|
|
// route through the AddText chokepoint instead of the deleted
|
|
// ChatLog.OnWeenieError, which hardcoded LogTextType 0x00 —
|
|
// several ShowWeenieError call sites (e.g. 0x0561, the friends-
|
|
// list-full refusal) resolve to ClientLocal and belong in the
|
|
// SpewBox, not green in chat. An id WeenieErrorMessages has no
|
|
// row for resolves to a null Text — retail's switch has no
|
|
// default case, so it produces no player-facing text.
|
|
ShowWeenieError:
|
|
code =>
|
|
{
|
|
(string? text, RetailLogTextType type) = WeenieErrorMessages.Resolve(code, null);
|
|
if (text is not null)
|
|
_domain.Communication.AddText(text, type);
|
|
else
|
|
// CH2 re-review nit 5 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
|
// matches GameEventWiring's WeenieError/
|
|
// WeenieErrorWithString handlers, which already log
|
|
// this same case. An unmapped id resolves to a null
|
|
// Text — retail's switch has no default case, so it
|
|
// produces no player-facing text — but the raw id
|
|
// stays visible to us instead of silently vanishing.
|
|
Console.WriteLine($"[weenie-error] unmapped code=0x{code:X4}");
|
|
},
|
|
PlayerPublicWeenieBitfield: () =>
|
|
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
|
|
.PublicWeenieBitfield,
|
|
ClientVersion: () =>
|
|
typeof(LiveSessionRuntimeFactory).Assembly
|
|
.GetName().Version?.ToString(3)
|
|
?? "unknown",
|
|
CurrentPosition: () => _player.Controller.Controller?.CellPosition,
|
|
LastOutsideCorpsePosition: () =>
|
|
_domain.Character.LocalPlayer.GetPosition(0x0Eu),
|
|
ShowConfirmation: (message, completed) =>
|
|
_ui.RetailUi?.ShowConfirmation(message, completed),
|
|
Suicide: session.SendSuicide,
|
|
ClearChat: _ => _domain.Communication.Chat.Clear(),
|
|
SetChatLogFile: SetChatLogFile,
|
|
SaveUi: name => _ui.RetailUi?.SaveNamedLayout(name),
|
|
LoadUi: name => _ui.RetailUi?.RestoreNamedLayout(name),
|
|
SaveAutoUi: () => _ui.RetailUi?.SaveLayout(),
|
|
LoadAutoUi: () => _ui.RetailUi?.RestoreLayout(),
|
|
IsAway: () =>
|
|
_domain.EntityObjects.Objects.Get(_player.Identity.ServerGuid)?
|
|
.Properties.GetBool(0x6Eu) == true,
|
|
SetAway: session.SendSetAfkMode,
|
|
SetAwayMessage: session.SendSetAfkMessage,
|
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): closes the
|
|
// unfiled divergence character-options-map.md §0 names —
|
|
// `/consent on|off` flipped only the client-local
|
|
// GameplaySettings.AcceptLootPermits bool and never reached the
|
|
// wire, even though retail auto-saves this id (0x0005
|
|
// immediately). Now routes through the SAME
|
|
// SendSingleCharacterOption seam the Character-tab panel row
|
|
// uses. OP9 retired the GameplaySettings mirror this used to
|
|
// ALSO maintain (RuntimeSettingsController.SetAcceptLootPermits)
|
|
// — the server bit read below is the sole authority now.
|
|
AcceptLootPermits: () =>
|
|
_domain.Character.Options.GetOptionBit(
|
|
CharacterOptionId.AcceptLootPermits),
|
|
SetAcceptLootPermits: value =>
|
|
SendSingleCharacterOption(
|
|
(uint)CharacterOptionId.AcceptLootPermits, value),
|
|
DisplayConsent: session.SendDisplayConsent,
|
|
ClearConsent: session.SendClearConsent,
|
|
RemoveConsent: session.SendRemoveConsent,
|
|
SendEmote: session.SendEmote,
|
|
_domain.Communication.Friends,
|
|
AddFriend: session.SendAddFriend,
|
|
RemoveFriend: session.SendRemoveFriend,
|
|
ClearFriends: session.SendClearFriends,
|
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
|
_domain.Communication.Squelch,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
LastTeller: () =>
|
|
_domain.Communication.CommandTargets.LastIncomingTellSender,
|
|
ClearDesiredComponents: () =>
|
|
{
|
|
session.SendClearDesiredComponents();
|
|
},
|
|
HasOpenVendor: () => false,
|
|
FillComponentBuyList: (_, _) => { },
|
|
EnterPkLite: session.SendEnterPkLite,
|
|
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
|
IsUsingTurbineChat: () => _domain.Communication.TurbineChat.Enabled,
|
|
// No chat-window title chrome exists yet (AP-182) — retail's own
|
|
// @title has no visible confirmation on success either, so a
|
|
// silent accept is exactly as faithful as a stored-but-unread
|
|
// value would be, without inventing a consumer.
|
|
SetChatTitle: _ => { },
|
|
// CH4 re-review SHOULD-FIX 2 (2026-08-09): @join/@leave route
|
|
// through the same SendSingleCharacterOption local function as
|
|
// the Settings-route binding below — see the chokepoint comment
|
|
// at the top of CreateCommandBindings. Retail's
|
|
// PlayerModule::SetHear*Chat family writes the bit into the
|
|
// local options copy FIRST, then notifies the server — match
|
|
// that ordering so TurbineChatMembershipGate (which reads
|
|
// _domain.Character.Options) stops refusing the newly-joined
|
|
// room before the next PlayerDescription happens to arrive.
|
|
SetSingleCharacterOption: SendSingleCharacterOption,
|
|
AddPlayerPermission: session.SendAddPlayerPermission,
|
|
RemovePlayerPermission: session.SendRemovePlayerPermission,
|
|
RequestAvailableHouses: session.SendListAvailableHouses,
|
|
RequestChannelIndex: session.SendIndexChannels,
|
|
RequestChannelList: session.SendListChannel,
|
|
JoinGmChannel: session.SendOnChannel,
|
|
LeaveGmChannel: session.SendOffChannel,
|
|
RecallAllegianceHometown: session.SendRecallAllegianceHometown,
|
|
RequestAllegianceInfo: session.SendAllegianceInfoRequest,
|
|
AbandonHouse: session.SendAbandonHouse),
|
|
_domain.Communication.Chat,
|
|
_domain.Communication.TurbineChat,
|
|
PlayerGuid: () => _player.Identity.ServerGuid,
|
|
SendTalk: session.SendTalk,
|
|
SendTell: session.SendTell,
|
|
SendChannel: session.SendChannel,
|
|
SendTurbineChat: (
|
|
roomId,
|
|
chatType,
|
|
dispatchType,
|
|
senderGuid,
|
|
text,
|
|
cookie) => session.SendTurbineChatTo(
|
|
roomId,
|
|
chatType,
|
|
dispatchType,
|
|
senderGuid,
|
|
text,
|
|
cookie),
|
|
AddShortcut: session.SendAddShortcut,
|
|
RemoveShortcut: session.SendRemoveShortcut,
|
|
AddFavorite: session.SendAddSpellFavorite,
|
|
RemoveFavorite: session.SendRemoveSpellFavorite,
|
|
SetSpellbookFilter: session.SendSpellbookFilter,
|
|
ForgetSpell: session.SendRemoveSpell,
|
|
SetDesiredComponent: session.SendSetDesiredComponentLevel,
|
|
ClearDesiredComponents: session.SendClearDesiredComponents,
|
|
RaiseAttribute: session.SendRaiseAttribute,
|
|
RaiseVital: session.SendRaiseVital,
|
|
RaiseSkill: session.SendRaiseSkill,
|
|
TrainSkill: session.SendTrainSkill,
|
|
AddFriend: session.SendAddFriend,
|
|
RemoveFriend: session.SendRemoveFriend,
|
|
ClearFriends: session.SendClearFriends,
|
|
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
|
// Secure trade (2026-08-14): AcceptTrade's echoed payload is
|
|
// ACE-discarded (lane B); the initiator field carries the partner
|
|
// guid — the only initiator identity ACE itself ever put on the
|
|
// wire (the RegisterTrade landmine).
|
|
OpenTradeNegotiations: session.SendOpenTradeNegotiations,
|
|
CloseTradeNegotiations: session.SendCloseTradeNegotiations,
|
|
AddToTrade: item => session.SendAddToTrade(item),
|
|
AcceptTrade: (partner, selfAccepted, partnerAccepted) =>
|
|
session.SendAcceptTrade(
|
|
partner, 0d, 0u, partner, selfAccepted, partnerAccepted),
|
|
DeclineTrade: session.SendDeclineTrade,
|
|
ResetTrade: session.SendResetTrade,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
Communication: _domain.Communication,
|
|
CharacterState: _domain.Character,
|
|
SendSingleCharacterOption: SendSingleCharacterOption,
|
|
SaveCharacterOptions: SaveCharacterOptionsIfDirty,
|
|
// Campaign CT slice CT2 (2026-08-24).
|
|
SendSetTitle: session.SendSetTitle,
|
|
// Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send
|
|
// wrappers, matching the WorldSession.SendXxx methods FA2 added.
|
|
SendFellowshipCreate: session.SendFellowshipCreate,
|
|
SendFellowshipRecruit: session.SendFellowshipRecruit,
|
|
SendFellowshipDismiss: session.SendFellowshipDismiss,
|
|
SendFellowshipQuit: session.SendFellowshipQuit,
|
|
SendFellowshipAssignNewLeader: session.SendFellowshipAssignNewLeader,
|
|
SendFellowshipChangeOpenness: session.SendFellowshipChangeOpenness,
|
|
SendFellowshipUpdateRequest: session.SendFellowshipUpdateRequest,
|
|
SendAllegianceSwear: session.SendAllegianceSwear,
|
|
SendAllegianceBreak: session.SendAllegianceBreak,
|
|
SendAllegianceKick: session.SendAllegianceKick,
|
|
SendAllegianceInfoRequest: session.SendAllegianceInfoRequest,
|
|
SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest,
|
|
Log: _log);
|
|
}
|
|
|
|
private static double ClientTimerNow() =>
|
|
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
|
|
}
|