acdream/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
Erik 77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
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>
2026-08-09 17:04:02 +02:00

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