Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.
This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:
CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
@0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
excluded — its case body is a tangled decompiler artifact, not
resolvable with confidence). Spot-checked ~20 rows directly against
the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
jump family), beyond the ~10 the brief asked for, because the first
pass surfaced two transcription classes the research doc's markdown
silently ate: (1) 7 ids marked "shared string global" resolved by
reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
(2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
therefore their %s marker) the markdown table's cell-trimming ate —
fixed by re-reading each case body, several requiring a SECOND
non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
recover text the ~33-char inline preview cut off. One retail typo is
preserved verbatim: 0x4F4's second placeholder is literal "$s", not
"%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
(MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
5 were truncated in the research doc's own transcription and all 5
turned out to end "...combat mode"/"...this position", not the
shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
expiry, one-frame enqueue/drain decoupling). Placed in Core (not
Runtime as the brief's default) because AcDream.UI.Abstractions
references Core but not Runtime, and SpewBoxVM needs to wrap it
directly — the same constraint ChatVM already satisfies against
ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.
RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
cannot reference Runtime, so this follows the file's own established
pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
UseDone through the full table + router; fixes 0x02EB
CommunicationTransientString's routing type from a CH1-era 0x00
guess to retail's hardcoded ClientLocal (Handle_Communication__
TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
through AddText with the wire chatType verbatim instead of always
writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
RuntimeLocalPlayerMovementState to every controller it installs.
Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
other code are retail-SILENT (DoJump's jump table has exactly 4 real
targets), which contradicts this task's brief ("0x47 -> the
constrained/stamina row per §4.2") — the brief's reading of §4.2
described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
DISPLAY for it. Implemented the decomp-verified silent behavior.
APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
now call Communication.AddText instead of Chat.OnSystemMessage
directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
directly on PortalWaitNoticeController. Position/font/colour/
MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
exhaustively swept the installed client_portal.dat's entire LayoutDesc
id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
against 3 known ids) and found ZERO elements of class 0x10000016 —
gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
the dump cannot recover these values.
REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.
Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
785 lines
32 KiB
C#
785 lines
32 KiB
C#
using AcDream.Headless.Configuration;
|
|
using AcDream.Headless.Credentials;
|
|
using AcDream.Headless.Diagnostics;
|
|
using AcDream.Headless.Policies;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Headless.Hosting;
|
|
|
|
internal sealed class HeadlessSessionHost : IDisposable
|
|
{
|
|
private sealed class SessionCommandRoute(
|
|
ILiveSessionCommandRouting gameplay,
|
|
ILiveSessionCommandRouting commands)
|
|
: ILiveSessionCommandRouting
|
|
{
|
|
private bool _gameplayActive;
|
|
private bool _commandsActive;
|
|
|
|
public void Activate()
|
|
{
|
|
if (_gameplayActive || _commandsActive)
|
|
return;
|
|
gameplay.Activate();
|
|
_gameplayActive = true;
|
|
try
|
|
{
|
|
commands.Activate();
|
|
_commandsActive = true;
|
|
}
|
|
catch
|
|
{
|
|
gameplay.Dispose();
|
|
_gameplayActive = false;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
List<Exception>? failures = null;
|
|
if (_commandsActive)
|
|
{
|
|
try
|
|
{
|
|
commands.Dispose();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
_commandsActive = false;
|
|
}
|
|
if (_gameplayActive)
|
|
{
|
|
try
|
|
{
|
|
gameplay.Dispose();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
_gameplayActive = false;
|
|
}
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Headless command routes did not detach cleanly.",
|
|
failures);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class SessionCommandBridge : IRuntimeSessionCommands
|
|
{
|
|
private HeadlessSessionHost? _owner;
|
|
|
|
internal void Bind(HeadlessSessionHost owner)
|
|
{
|
|
if (_owner is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The headless session command bridge is already bound.");
|
|
}
|
|
_owner = owner
|
|
?? throw new ArgumentNullException(nameof(owner));
|
|
}
|
|
|
|
public RuntimeSessionStartResult Start(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
RequireOwner().StartCore(expectedGeneration, reconnect: false);
|
|
|
|
public RuntimeSessionStartResult Reconnect(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
RequireOwner().StartCore(expectedGeneration, reconnect: true);
|
|
|
|
public RuntimeTeardownAcknowledgement Stop(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
RequireOwner()._liveSession.Stop(expectedGeneration);
|
|
|
|
private HeadlessSessionHost RequireOwner() =>
|
|
_owner
|
|
?? throw new InvalidOperationException(
|
|
"The headless session command bridge is not bound.");
|
|
}
|
|
|
|
private readonly HeadlessSessionDescriptor _descriptor;
|
|
private readonly HeadlessCredentialSecret _credential;
|
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
|
private readonly TimeSpan _reconnectQuiescence;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly HeadlessGenerationResetHost _resetHost = new();
|
|
private readonly IDisposable _hostLease;
|
|
private readonly IHeadlessBotPolicy _policy;
|
|
private readonly IDisposable _policySubscription;
|
|
private readonly LiveSessionHost _liveSession;
|
|
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
|
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
|
_contentLease;
|
|
/// <summary>
|
|
/// B5(a) review fix: test-only seam (mirrors <c>policyOverride</c>'s own
|
|
/// pattern) letting a focused test substitute a deterministic fake
|
|
/// placement sink for the production <see cref="HeadlessRuntimePlacementProjectionSink"/>,
|
|
/// so a test can drive <see cref="Tick"/> itself — the real
|
|
/// <c>_eventRoute?.RetryPending()</c> call this fix covers — instead of
|
|
/// hand-constructing a <see cref="HeadlessSessionEventRoute"/> outside
|
|
/// this host. <c>null</c> (every production caller) keeps today's exact
|
|
/// behavior.
|
|
/// </summary>
|
|
private readonly IRuntimePlacementProjectionSink? _placementSinkOverride;
|
|
/// <summary>C3c: one per-host first-entry drive controller (lazy — its
|
|
/// residence-begin subscription binds once against the persistent
|
|
/// Runtime lifetime) plus the active world projection it pumps
|
|
/// through.</summary>
|
|
private RuntimeFirstEntryDriveController? _firstEntryDrive;
|
|
/// <summary>C4 route 2 (2026-08-03): see the ctor comment in
|
|
/// <see cref="CreateEventRoute"/> — cached across reconnects exactly
|
|
/// like <see cref="_firstEntryDrive"/>.</summary>
|
|
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
|
private AcDream.Core.Net.WorldSession? _currentSession;
|
|
private HeadlessSessionWorldProjection? _worldProjection;
|
|
/// <summary>
|
|
/// A1/A3 review fix (2026-08-05): retained so <see cref="Tick"/> can
|
|
/// pump <see cref="RuntimeLiveEntitySessionController.PumpPortalCompletion"/>
|
|
/// alongside <see cref="_worldProjection"/>'s own
|
|
/// <c>PumpFirstEntry</c> — a parked portal placement must retry on the
|
|
/// host's own per-tick cadence rather than the completion sequence
|
|
/// running unconditionally the instant it is first attempted. Reassigned
|
|
/// on every reconnect exactly like <see cref="_worldProjection"/>.
|
|
/// </summary>
|
|
private RuntimeLiveEntitySessionController? _entities;
|
|
/// <summary>C4 route 4b-1 (N3): the exact route <see cref="CreateEventRoute"/>
|
|
/// last constructed, so <see cref="Tick"/> can republish the canonical
|
|
/// placement FIFO every tick — mirrors the graphical host's per-frame
|
|
/// retry lease (<c>GraphicalSessionEventRoute.Attach</c>). Reassigned on
|
|
/// every reconnect exactly like <see cref="_worldProjection"/>; the prior
|
|
/// route's own disposal (via <c>LiveSessionHost</c>'s route replacement)
|
|
/// is independent of this field.</summary>
|
|
private HeadlessSessionEventRoute? _eventRoute;
|
|
private int _disposeStage;
|
|
private long _reconnectDeadline;
|
|
private bool _reconnectPending;
|
|
private ulong _stoppedGeneration;
|
|
private string _accountName = string.Empty;
|
|
private Exception? _fault;
|
|
private bool _faulted;
|
|
private bool _disposed;
|
|
|
|
internal HeadlessSessionHost(
|
|
HeadlessSessionDescriptor descriptor,
|
|
HeadlessCredentialSecret credential,
|
|
HeadlessDiagnosticWriter diagnostics,
|
|
ILiveSessionOperations? sessionOperations = null,
|
|
TimeProvider? timeProvider = null,
|
|
TimeSpan? reconnectQuiescence = null,
|
|
HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
|
contentLease = null,
|
|
IHeadlessBotPolicy? policyOverride = null,
|
|
IRuntimePlacementProjectionSink? placementSinkOverride = null)
|
|
{
|
|
_descriptor = descriptor
|
|
?? throw new ArgumentNullException(nameof(descriptor));
|
|
_credential = credential
|
|
?? throw new ArgumentNullException(nameof(credential));
|
|
_diagnostics = diagnostics
|
|
?? throw new ArgumentNullException(nameof(diagnostics));
|
|
_placementSinkOverride = placementSinkOverride;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_reconnectQuiescence = reconnectQuiescence
|
|
?? (sessionOperations is null
|
|
? TimeSpan.FromMilliseconds(2500)
|
|
: TimeSpan.Zero);
|
|
if (_reconnectQuiescence < TimeSpan.Zero)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(reconnectQuiescence));
|
|
}
|
|
|
|
GameRuntime? runtimeRef = null;
|
|
IDisposable? hostLease = null;
|
|
IHeadlessBotPolicy? policy = null;
|
|
IDisposable? policySubscription = null;
|
|
try
|
|
{
|
|
var gameplay = new HeadlessGameplayOperations();
|
|
var runtime = new GameRuntime(new GameRuntimeDependencies(
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
gameplay,
|
|
TimeProvider: _timeProvider,
|
|
Log: message => diagnostics.Message(
|
|
descriptor.Id,
|
|
message),
|
|
SessionOperations: sessionOperations,
|
|
CombatTime: () =>
|
|
runtimeRef?.Clock.SimulationTimeSeconds ?? 0d));
|
|
runtimeRef = runtime;
|
|
if (contentLease is { } content)
|
|
{
|
|
runtime.CharacterOwner.InstallSpellMetadata(
|
|
content.MagicCatalog.SpellTable);
|
|
}
|
|
gameplay.Bind(
|
|
runtime,
|
|
contentLease?.MagicCatalog,
|
|
() => _accountName);
|
|
|
|
var bridge = new SessionCommandBridge();
|
|
var commands = new DirectGameRuntimeCommandAdapter(
|
|
runtime,
|
|
bridge);
|
|
var liveSession = new LiveSessionHost(
|
|
runtime.Session,
|
|
new LiveSessionHostBindings(
|
|
new LiveSessionRoutingFactories(
|
|
CreateEventRoute,
|
|
session => new SessionCommandRoute(
|
|
gameplay.CreateRoute(session),
|
|
commands.CreateRoute(session))),
|
|
generation =>
|
|
runtime.ResetGeneration(generation, _resetHost),
|
|
new LiveSessionSelectionBindings(
|
|
id => runtime.PlayerIdentity.ServerGuid = id,
|
|
_ => { },
|
|
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
|
_ => { },
|
|
_ => { },
|
|
runtime.ActionOwner.Combat.Clear),
|
|
new LiveSessionEnteredWorldBindings(
|
|
name => ActiveCharacterName = name,
|
|
() => { },
|
|
() => { },
|
|
_ => { },
|
|
() => { }),
|
|
(host, port, user) =>
|
|
diagnostics.Message(
|
|
descriptor.Id,
|
|
$"connecting:{host}:{port}:{user}",
|
|
runtime.Generation.Value),
|
|
() => diagnostics.Message(
|
|
descriptor.Id,
|
|
"connected",
|
|
runtime.Generation.Value)));
|
|
|
|
Runtime = runtime;
|
|
Commands = commands;
|
|
_liveSession = liveSession;
|
|
_localPlayerFrame =
|
|
runtime.CreateLocalPlayerFrameController(
|
|
new HeadlessLocalPlayerFrameHost(
|
|
runtime,
|
|
liveSession),
|
|
new HeadlessMovementInputSource(
|
|
runtime.MovementOwner));
|
|
_contentLease = contentLease;
|
|
bridge.Bind(this);
|
|
|
|
hostLease = runtime.AcquireHostLease(
|
|
$"headless:{descriptor.Id}");
|
|
policy = policyOverride
|
|
?? HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
|
|
policySubscription = runtime.Subscribe(policy);
|
|
diagnostics.Lifecycle(
|
|
descriptor.Id,
|
|
"constructed",
|
|
runtime);
|
|
|
|
_hostLease = hostLease;
|
|
_policy = policy;
|
|
_policySubscription = policySubscription;
|
|
}
|
|
catch
|
|
{
|
|
policySubscription?.Dispose();
|
|
policy?.Dispose();
|
|
hostLease?.Dispose();
|
|
contentLease?.Dispose();
|
|
credential.Dispose();
|
|
runtimeRef?.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
internal GameRuntime Runtime { get; }
|
|
internal DirectGameRuntimeCommandAdapter Commands { get; }
|
|
internal string SessionId => _descriptor.Id;
|
|
internal string ActiveCharacterName { get; private set; } =
|
|
string.Empty;
|
|
internal bool IsPolicyComplete =>
|
|
_faulted || _policy.IsComplete;
|
|
internal bool IsFaulted => _faulted;
|
|
internal Exception? Fault => _fault;
|
|
internal bool IsReconnectPending => _reconnectPending;
|
|
internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
|
Content => _contentLease;
|
|
internal long ReconnectDeadline => _reconnectPending
|
|
? _reconnectDeadline
|
|
: throw new InvalidOperationException(
|
|
"The headless session has no pending reconnect.");
|
|
|
|
internal RuntimeSessionStartResult Start() =>
|
|
Commands.Session.Start(Runtime.Generation);
|
|
|
|
internal RuntimeSessionStartResult Reconnect() =>
|
|
Commands.Session.Reconnect(Runtime.Generation);
|
|
|
|
internal void Tick(double deltaSeconds)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_reconnectPending)
|
|
return;
|
|
_ = Runtime.Clock.Advance(deltaSeconds);
|
|
_localPlayerFrame.AdvanceBeforeNetwork(
|
|
checked((float)deltaSeconds));
|
|
Runtime.Session.Tick();
|
|
// C3c: pump pending first-entry sequences after the network drain —
|
|
// collision-generation progress and freshly accepted Creates both
|
|
// surface here, mirroring the graphical per-frame retry phase.
|
|
_worldProjection?.PumpFirstEntry();
|
|
// A1/A3 review fix (2026-08-05): retry a parked portal completion
|
|
// (see RuntimeLiveEntitySessionController.PumpPortalCompletion) on
|
|
// the SAME per-tick cadence, after first-entry so a DeferredCell
|
|
// wake first-entry's own pump just resolved is picked up the same
|
|
// tick.
|
|
_entities?.PumpPortalCompletion();
|
|
// C4 route 4b-1 (N3): republish the canonical placement FIFO LAST,
|
|
// same order as the graphical host's retry-lease callback (drives
|
|
// first, retry last) — a declined Place left at the FIFO head by
|
|
// RuntimePlacementProjectionSubscription is otherwise never
|
|
// revisited, because Attach's retryPendingOnSubscribe only fires
|
|
// once, at subscribe time.
|
|
_eventRoute?.RetryPending();
|
|
_localPlayerFrame.RunPostNetworkCommandPhase();
|
|
Runtime.ActionOwner.CombatAttack.Tick();
|
|
_policy.Tick(Runtime, Commands);
|
|
}
|
|
|
|
internal RuntimeTeardownAcknowledgement Stop()
|
|
{
|
|
RuntimeTeardownAcknowledgement result =
|
|
Commands.Session.Stop(Runtime.Generation);
|
|
// R9 review fix (2026-08-03): _currentSession is cached across
|
|
// reconnects (see CreateEventRoute's comment) so the accepted-
|
|
// position drive controller's outbound-ack accessor always reads the
|
|
// CURRENT session, never one captured at first construction. Without
|
|
// clearing it here, that accessor would keep returning a stopped
|
|
// (possibly disposed) WorldSession in the window between this Stop
|
|
// and the next CreateEventRoute call.
|
|
_currentSession = null;
|
|
return result;
|
|
}
|
|
|
|
internal void Quarantine(Exception error)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(error);
|
|
if (_faulted)
|
|
return;
|
|
|
|
_faulted = true;
|
|
_fault = error;
|
|
_reconnectPending = false;
|
|
_reconnectDeadline = 0L;
|
|
_diagnostics.Failure(
|
|
_descriptor.Id,
|
|
"quarantined",
|
|
error);
|
|
try
|
|
{
|
|
// A policy may have faulted from an event callback. Detach it
|
|
// before Runtime publishes teardown deltas so the quarantined
|
|
// observer cannot poison the canonical stop transaction.
|
|
_policySubscription.Dispose();
|
|
RuntimeTeardownAcknowledgement stopped = Stop();
|
|
if (!stopped.IsComplete)
|
|
{
|
|
_fault = new AggregateException(
|
|
error,
|
|
stopped.Error
|
|
?? new InvalidOperationException(
|
|
$"Headless session '{_descriptor.Id}' did not quiesce after a fault."));
|
|
}
|
|
}
|
|
catch (Exception teardownError)
|
|
{
|
|
_fault = new AggregateException(error, teardownError);
|
|
}
|
|
}
|
|
|
|
internal RuntimeSessionStartResult CompletePendingReconnect(
|
|
long nowTimestamp)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!_reconnectPending)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Inactive,
|
|
Runtime.Generation);
|
|
}
|
|
if (nowTimestamp < _reconnectDeadline)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Deferred,
|
|
Runtime.Generation);
|
|
}
|
|
|
|
_reconnectPending = false;
|
|
_reconnectDeadline = 0L;
|
|
return StartLive(reconnect: true);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
while (!_disposed)
|
|
{
|
|
switch (_disposeStage)
|
|
{
|
|
case 0:
|
|
{
|
|
_reconnectPending = false;
|
|
_reconnectDeadline = 0L;
|
|
RuntimeTeardownAcknowledgement stopped = Stop();
|
|
if (!stopped.IsComplete)
|
|
{
|
|
throw stopped.Error
|
|
?? new InvalidOperationException(
|
|
$"Headless session '{_descriptor.Id}' did not complete teardown.");
|
|
}
|
|
_stoppedGeneration =
|
|
stopped.CurrentGeneration.Value;
|
|
_disposeStage++;
|
|
break;
|
|
}
|
|
case 1:
|
|
_diagnostics.Lifecycle(
|
|
_descriptor.Id,
|
|
"stopped",
|
|
Runtime);
|
|
_disposeStage++;
|
|
break;
|
|
case 2:
|
|
_policySubscription.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 3:
|
|
_policy.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 4:
|
|
_hostLease.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 5:
|
|
_credential.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 6:
|
|
Runtime.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 7:
|
|
_contentLease?.Dispose();
|
|
_disposeStage++;
|
|
break;
|
|
case 8:
|
|
_diagnostics.Message(
|
|
_descriptor.Id,
|
|
"disposed",
|
|
_stoppedGeneration);
|
|
_disposeStage++;
|
|
_disposed = true;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException(
|
|
"Unknown headless session teardown stage.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private RuntimeSessionStartResult StartCore(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
bool reconnect)
|
|
{
|
|
if (expectedGeneration != Runtime.Generation)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.StaleGeneration,
|
|
Runtime.Generation);
|
|
}
|
|
if (_reconnectPending)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Deferred,
|
|
Runtime.Generation);
|
|
}
|
|
|
|
if (reconnect)
|
|
{
|
|
RuntimeTeardownAcknowledgement stopped =
|
|
_liveSession.Stop(expectedGeneration);
|
|
if (!stopped.IsComplete)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Failed,
|
|
stopped.CurrentGeneration,
|
|
Error: stopped.Error
|
|
?? new InvalidOperationException(
|
|
"The prior headless session did not quiesce before reconnect."));
|
|
}
|
|
|
|
if (_reconnectQuiescence > TimeSpan.Zero)
|
|
{
|
|
// ACE confirms CharacterLogOff before its account/session
|
|
// index releases the retiring socket. The process scheduler
|
|
// owns this monotonic deadline so other sessions keep moving
|
|
// and no command or worker thread blocks.
|
|
_reconnectDeadline = HeadlessMonotonicTime.Add(
|
|
_timeProvider,
|
|
_timeProvider.GetTimestamp(),
|
|
_reconnectQuiescence);
|
|
_reconnectPending = true;
|
|
_diagnostics.Lifecycle(
|
|
_descriptor.Id,
|
|
"reconnect-deferred",
|
|
Runtime);
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Deferred,
|
|
Runtime.Generation);
|
|
}
|
|
}
|
|
|
|
return StartLive(reconnect);
|
|
}
|
|
|
|
private RuntimeSessionStartResult StartLive(bool reconnect)
|
|
{
|
|
string password = _credential.Reveal();
|
|
try
|
|
{
|
|
LiveSessionConnectOptions options = new(
|
|
Enabled: true,
|
|
_descriptor.Endpoint.Host,
|
|
_descriptor.Endpoint.Port,
|
|
_descriptor.Account,
|
|
password,
|
|
MapCharacterSelector(_descriptor.Character));
|
|
LiveSessionStartResult result = _liveSession.Start(options);
|
|
if (result.Selection is { } selection)
|
|
_accountName = selection.AccountName;
|
|
RuntimeSessionStartResult converted = Convert(result);
|
|
if (converted.Error is { } error)
|
|
{
|
|
_diagnostics.Failure(
|
|
_descriptor.Id,
|
|
reconnect ? "reconnect" : "start",
|
|
error);
|
|
}
|
|
_diagnostics.Lifecycle(
|
|
_descriptor.Id,
|
|
reconnect ? "reconnect-result" : "start-result",
|
|
Runtime);
|
|
return converted;
|
|
}
|
|
finally
|
|
{
|
|
password = string.Empty;
|
|
}
|
|
}
|
|
|
|
private ILiveSessionEventRouting CreateEventRoute(
|
|
AcDream.Core.Net.WorldSession session)
|
|
{
|
|
// C4 route 2 (2026-08-03): reconnects construct a FRESH WorldSession
|
|
// each call, but the accepted-Position drive controller below is
|
|
// cached across reconnects (`??=`) exactly like _firstEntryDrive —
|
|
// its ack-firing accessor must therefore read the CURRENT session
|
|
// through this field, never one captured at first construction.
|
|
_currentSession = session;
|
|
// R9 review note (2026-08-03): a content-less host (_contentLease is
|
|
// null — a validated-legal headless configuration, see
|
|
// RuntimeLiveEntitySessionController.OnSpawned's own R3 comment)
|
|
// never constructs _acceptedPositionDrive OR worldProjection below,
|
|
// so a ForcePosition on such a host resolves NotApplicable with no
|
|
// ProjectPosition fallback either. This is NOT a behaviour change:
|
|
// a content-less host never registers a first-entry residence, so
|
|
// Runtime.MovementOwner.Controller is always null there too, and
|
|
// LocalPlayerOutboundController.SendImmediatePosition's own
|
|
// controller-null guard already made the PRE-route-2 unconditional
|
|
// ack call a no-op in this exact configuration. There is no live
|
|
// controller for either the old or the new path to place or
|
|
// acknowledge.
|
|
IRuntimeDirectWorldProjection? worldProjection = null;
|
|
if (_contentLease is { } content)
|
|
{
|
|
// C3c: one drive controller per host — the residence-begin
|
|
// notification binds once against the persistent Runtime
|
|
// lifetime; reconnects reuse it (its tracked entries are cleared
|
|
// with each retiring route).
|
|
_firstEntryDrive ??= new RuntimeFirstEntryDriveController(
|
|
Runtime.EntityObjects,
|
|
Runtime.Clock,
|
|
content.PreparedCollision,
|
|
() => PlayerMovementConstructionOptions.From(
|
|
Runtime.CharacterOwner.MovementSkills.Snapshot),
|
|
// A headless host registers no shadow payloads — the local
|
|
// player is provably shapeless in the shadow registry, with
|
|
// the same default approach cylinder the deleted
|
|
// hand-resolve used.
|
|
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
|
|
Radius: 0.48f,
|
|
Height: 1.835f,
|
|
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
|
// D-T8 (temporary probe): labels every subsequent
|
|
// PhysicsDiagnostics.LogLocalTeleportArrival line from THIS
|
|
// process as headless — Runtime itself has no host-kind concept
|
|
// (Slice K keeps it presentation-agnostic), so this is a
|
|
// diagnostics-only label set once at composition time, not a
|
|
// Runtime dependency.
|
|
PhysicsDiagnostics.LocalTeleportHostKind = "headless";
|
|
// C4 route 2: one drive controller per host, mirroring
|
|
// _firstEntryDrive exactly — same persistent Runtime lifetime,
|
|
// collision source, and clock.
|
|
_acceptedPositionDrive ??= new RuntimeAcceptedPositionDriveController(
|
|
Runtime.EntityObjects,
|
|
Runtime.Clock,
|
|
content.PreparedCollision,
|
|
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
|
|
() => Runtime.Generation,
|
|
() => Runtime.PlayerIdentity.ServerGuid,
|
|
() => Runtime.MovementOwner.Controller,
|
|
() => Runtime.CharacterOwner.UsePositionFromServer,
|
|
() => _currentSession,
|
|
// C4 route 3: the portal arm's PlayerTeleported port needs
|
|
// the J5.4 autorun latch owner, one level above the raw
|
|
// controller.
|
|
() => Runtime.MovementOwner,
|
|
// A2/D-T2.4 review fix (2026-08-05): same wiring as the
|
|
// graphical composition (SessionPlayerComposition.cs) — the
|
|
// SAME idempotent query TryCompletePortal/PrepareDestination
|
|
// themselves use.
|
|
isPortalAuthorityCurrent: portal => Runtime.TransitOwner
|
|
.CanPlacePortalDestination(
|
|
portal.RevealGeneration,
|
|
portal.TeleportSequence,
|
|
portal.Projection.DestinationCell));
|
|
var projection = new HeadlessSessionWorldProjection(
|
|
Runtime,
|
|
content,
|
|
_firstEntryDrive,
|
|
_acceptedPositionDrive);
|
|
_worldProjection = projection;
|
|
worldProjection = projection;
|
|
}
|
|
var entities = new RuntimeLiveEntitySessionController(
|
|
Runtime,
|
|
session,
|
|
message => _diagnostics.Message(
|
|
_descriptor.Id,
|
|
message,
|
|
Runtime.Generation.Value),
|
|
worldProjection,
|
|
_acceptedPositionDrive);
|
|
_entities = entities;
|
|
var route = new LiveSessionEventRouter(
|
|
session,
|
|
entities.CreateSink(),
|
|
new LiveEnvironmentSessionSink(
|
|
change =>
|
|
_ = Runtime.EnvironmentOwner
|
|
.ApplyAdminEnvirons(change),
|
|
Runtime.EnvironmentOwner.SynchronizeFromServer),
|
|
new LiveInventorySessionBindings(
|
|
Runtime.InventoryOwner.Objects,
|
|
() => Runtime.PlayerIdentity.ServerGuid,
|
|
Runtime.InventoryOwner.Shortcuts.Load,
|
|
error =>
|
|
{
|
|
Runtime.InventoryOwner.ExternalContainers
|
|
.ApplyUseDone(error);
|
|
Runtime.ActionOwner.Transactions.CompleteUse(error);
|
|
},
|
|
Runtime.InventoryOwner.ItemMana,
|
|
Runtime.InventoryOwner.ExternalContainers,
|
|
appraisal =>
|
|
Runtime.ActionOwner.Transactions
|
|
.AcceptAppraisalResponse(appraisal.Guid),
|
|
Vendor: Runtime.InventoryOwner.Vendor),
|
|
new LiveCharacterSessionBindings(
|
|
Runtime.ActionOwner.Combat,
|
|
Runtime.CharacterOwner,
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: null,
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () =>
|
|
Runtime.Clock.SimulationTimeSeconds,
|
|
// Campaign P Slice P1 (2026-07-30): headless bots re-apply
|
|
// burden/stamina/skills at controller construction only
|
|
// (HeadlessSessionWorldProjection.CreateController), same as
|
|
// the pre-P1 OnSkillsUpdated: null pattern — no live
|
|
// controller to reactively re-apply to mid-session here.
|
|
OnMovementStatsUpdated: null),
|
|
new LiveSocialSessionBindings(
|
|
Runtime.CommunicationOwner.Chat,
|
|
Runtime.CommunicationOwner.TurbineChat,
|
|
Runtime.CommunicationOwner.Friends,
|
|
Runtime.CommunicationOwner.Squelch,
|
|
(text, type) => Runtime.CommunicationOwner.AddText(text, type)));
|
|
var eventRoute = new HeadlessSessionEventRoute(
|
|
route,
|
|
Runtime,
|
|
_placementSinkOverride
|
|
?? new HeadlessRuntimePlacementProjectionSink(Runtime),
|
|
_firstEntryDrive,
|
|
_ => session.SendGameAction(GameActionLoginComplete.Build()),
|
|
_acceptedPositionDrive);
|
|
_eventRoute = eventRoute;
|
|
return eventRoute;
|
|
}
|
|
|
|
private static LiveSessionCharacterSelector MapCharacterSelector(
|
|
HeadlessCharacterSelector selector) =>
|
|
new(
|
|
selector.Index,
|
|
selector.Id,
|
|
selector.Name);
|
|
|
|
private RuntimeSessionStartResult Convert(
|
|
LiveSessionStartResult result)
|
|
{
|
|
RuntimeSessionStartStatus status = result.Status switch
|
|
{
|
|
LiveSessionStartStatus.Disabled =>
|
|
RuntimeSessionStartStatus.Disabled,
|
|
LiveSessionStartStatus.MissingCredentials =>
|
|
RuntimeSessionStartStatus.MissingCredentials,
|
|
LiveSessionStartStatus.NoCharacters =>
|
|
RuntimeSessionStartStatus.NoCharacters,
|
|
LiveSessionStartStatus.Connected =>
|
|
RuntimeSessionStartStatus.Connected,
|
|
LiveSessionStartStatus.Deferred =>
|
|
RuntimeSessionStartStatus.Deferred,
|
|
LiveSessionStartStatus.Failed =>
|
|
RuntimeSessionStartStatus.Failed,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(result),
|
|
result.Status,
|
|
"Unknown live-session start result."),
|
|
};
|
|
return new RuntimeSessionStartResult(
|
|
status,
|
|
Runtime.Generation,
|
|
result.Selection?.CharacterId ?? 0u,
|
|
result.Selection?.CharacterName ?? string.Empty,
|
|
result.Error);
|
|
}
|
|
}
|