acdream/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
Erik bc43fb1d1d fix(ui,runtime): OP4 review fixes — live re-seed, enable-gating, Combat panel re-point, universal timestamps
Both OP4 reviews converged on one headline bug (Character-tab rows never
re-read live server truth after their pre-login constructor-word seed) plus
overlapping MUST-FIXes. All ten converged/consolidated findings land here:

MUST-FIX:
- BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's
  GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab
  switch in, initial activation — instead of trusting the pre-login
  constructor word it was built with. Reset/tab-switch can now only
  restore values that were actually live at the last show. LockUI's
  host.Root.UiLocked one-shot mount seed now also converges on every
  PlayerDescription via the existing OnCharacterOptionsChanged hook.
- Apply/Reset are wired to OptionPage.OnOptionChanged in production
  (Ghosted when nothing changed, Normal when dirty, run once at bind so
  both start disabled per retail's PostInit); Defaults stays ungated.
- The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View)
  now read/write the same RuntimeCharacterOptionsState seam the Character
  tab uses instead of a disconnected client-local GameplaySettings copy —
  closes the "two writable copies" divergence. The three now-orphaned
  GameplaySettings fields and RuntimeSettingsController's mirror
  properties/SetCombatGameplay are deleted outright; the headless host's
  hardcoded AutoRepeatAttack/AutoTarget now read the live option bit.
- RuntimeSettingsController.SetUiLocked's convergence guard now compares
  against the last value actually applied to the runtime target instead
  of the persisted GameplaySettings.LockUI snapshot, which could already
  match a server-derived request without ever having been pushed.

SHOULD-FIX:
- DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is
  the one seam all of them funnel through), not just AddText's own
  callers — heard speech, emotes, Turbine channels, and combat text were
  previously missed. The prefix format escapes its colons and forces
  InvariantCulture instead of the culture-dependent TimeSeparator
  placeholder.
- sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain
  shaders, so Disable Distance Fog stops the sky dome's horizon band from
  blending toward fog color too.
- Corrected the "byte-verified" overclaim on the timestamp format string
  doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column
  class-name typo; the RunAsDefaultMovement doc comments now cite retail's
  actual acclient.h enumerator name.
- Added: DispatcherMovementInputSource's option x modifier truth table
  (incl. || AutoRunActive with the option off), the per-page Apply/Reset
  enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click
  test, and hash-pins for the six header string keys.
- Gate script step 8 corrected for the logout-flush false-failure
  (closing the panel before relogging is load-bearing); a new step
  documents the enable-gate sequence and the Combat-panel/Character-tab
  cross-check.

Register: AP-196 (the Group-C default-source change + GameplaySettings
retirement) and AP-197 (the ignored per-character timestamp format
override) filed in this commit.

Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0;
net +36 tests from new coverage and legitimate assertion updates from the
GameplaySettings retirement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:30:26 +02:00

596 lines
29 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.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,
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;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
LiveSessionDomainRuntime domain,
LiveSessionUiRuntime ui,
LiveSessionInteractionRuntime interaction,
LiveSessionWorldRuntime world,
LiveSessionCommandSurface commands,
Action<string> log)
{
_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));
// 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));
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),
EnteredWorld: new(
SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
RestoreLayout: () => _ui.RetailUi?.RestoreLayout(),
SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm),
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)),
connectOptions);
}
private LiveSessionResetBindings CreateResetBindings(
IRuntimeGenerationResetHost resetHost) => new()
{
MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation,
TeleportPresentation =
_world.Teleport.ResetGenerationPresentation,
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)));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
_world.PlacementProjection,
_world.PlacementRetries,
_world.FirstEntryDrive,
_ => session.SendGameAction(GameActionLoginComplete.Build()),
_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/SaveGameplay already
// use 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));
});
}
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) and its legacy GameplaySettings mirror
// (retired in 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(),
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.
AcceptLootPermits: () =>
_domain.Character.Options.GetOptionBit(
CharacterOptionId.AcceptLootPermits),
SetAcceptLootPermits: value =>
{
SendSingleCharacterOption(
(uint)CharacterOptionId.AcceptLootPermits, value);
_interaction.Settings.SetAcceptLootPermits(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,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
Communication: _domain.Communication,
CharacterState: _domain.Character,
SendSingleCharacterOption: SendSingleCharacterOption,
SaveCharacterOptions: SaveCharacterOptionsIfDirty,
Log: _log);
}
private static double ClientTimerNow() =>
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
}