The FA4 fix round's MUST-FIX 3 placed the 0x00A6 reconnect re-arm at the
wrong lifecycle point (re-review 8bbceff5): ResetSessionTransientUi runs
via the SessionDialogs reset stage BEFORE _inWorld=true, so SetPanelOpen
(world-gated, Validate requireWorld:true) returned Inactive and published
nothing — yet _pageVisible was latched true anyway, so no later hook
re-declared and fellow vitals stayed frozen for the whole new session.
The unit test passed only because the fake recorded unconditionally.
Two-part fix, both retail-faithful mechanisms not suppressions:
- SocialFellowshipPageController.SetPageVisible advances the edge-trigger
latch ONLY when the declaration is Accepted (published), so a dropped
pre-world send leaves the latch clear and a later attempt retries.
- ResetSessionDeclaration (pre-world) now ONLY clears the latch; the new
RedeclareAfterWorldEntry fires from the LiveSession EnteredWorld seam
(wired via RestoreLayout, idempotent if a persisted layout already
re-showed the page) so a still-open Fellowship page re-declares 0x00A6
in world and vitals resume.
Regression pins that actually catch it (the prior test could not):
- SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld
(widget-level root, world-gated fake);
- Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset +
Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen (panel-level,
world-gated). RED-verified: reintroducing the pre-world declaration
fails the reconnect test.
Full Release suite: 13,286 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
625 lines
31 KiB
C#
625 lines
31 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();
|
|
// 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: _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),
|
|
Fellowship: _domain.Runtime.FellowshipOwner,
|
|
Allegiance: _domain.Runtime.AllegianceOwner));
|
|
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 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(),
|
|
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,
|
|
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
|
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
|
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
|
Communication: _domain.Communication,
|
|
CharacterState: _domain.Character,
|
|
SendSingleCharacterOption: SendSingleCharacterOption,
|
|
SaveCharacterOptions: SaveCharacterOptionsIfDirty,
|
|
// 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;
|
|
}
|