acdream/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
Erik bcfddc97e7 feat(CT): CT2 — Runtime character-title ownership + wire
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>
2026-08-24 22:03:22 +02:00

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;
}