using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Plugins; using AcDream.Headless.Policies; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime; using AcDream.Runtime.Chat; 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 { private ILiveSessionCommandRouting? _gameplay; private ILiveSessionCommandRouting? _commands; private ILiveSessionCommandRouting? _chat; private bool _activated; internal SessionCommandRoute( ILiveSessionCommandRouting gameplay, ILiveSessionCommandRouting commands, ILiveSessionCommandRouting chat) { _gameplay = gameplay ?? throw new ArgumentNullException(nameof(gameplay)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _chat = chat ?? throw new ArgumentNullException(nameof(chat)); } public void Activate() { if (_activated) return; if (_gameplay is null || _commands is null || _chat is null) throw new ObjectDisposedException(nameof(SessionCommandRoute)); _gameplay.Activate(); try { _commands.Activate(); _chat.Activate(); _activated = true; } catch (Exception activationError) { try { Dispose(); } catch (Exception disposalError) { throw new AggregateException( "Headless command-route activation and rollback failed.", activationError, disposalError); } throw; } } public void Dispose() { List? failures = null; TryDispose(ref _chat, ref failures); TryDispose(ref _commands, ref failures); TryDispose(ref _gameplay, ref failures); if (failures is not null) { throw new AggregateException( "Headless command routes did not detach cleanly.", failures); } } private static void TryDispose( ref ILiveSessionCommandRouting? route, ref List? failures) { if (route is not { } current) return; try { current.Dispose(); route = null; } catch (Exception error) { (failures ??= []).Add(error); } } } 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; /// /// Campaign LA slice LA1: a SEPARATE per-session sink from /// — a no-op instance when /// was not configured. /// See 's own doc for why this is not a /// rework of the shared-stdout diagnostics writer. /// private readonly SessionStatusWriter _statusWriter; /// /// Campaign LA LA2 review fix: the actual result returned by the process /// start attempt. A configured probe mode is only intent; terminal status /// may claim reason:"probe" after this records /// . Any other /// non-connected result maps to the same connection-error code returned by /// . /// private RuntimeSessionStartStatus? _startOutcome; /// Guards 's disconnected status event /// so a Stop() on a session that never actually reached Connected (e.g. /// disposing a fresh, never-started host) does not report a spurious /// disconnect. private bool _hasConnected; /// /// Campaign OP slice OP7 (2026-08-11), D8: the parsed /// characterOptions block — empty when the config omitted it. /// Parsed once at construction; /// already rejected every unmodelled or out-of-tier name before this /// host was ever built, so the parse here cannot fail. /// private readonly Dictionary _declaredCharacterOptions; /// /// Reassigned on every reconnect exactly like /// — a fresh instance per call gives the /// seeder's own login-complete latch a clean per-session start without a /// separate reset method. /// private HeadlessCharacterOptionsSeeder? _optionsSeeder; 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 HeadlessPluginSession _pluginSession; private readonly LiveSessionHost _liveSession; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? _contentLease; /// /// B5(a) review fix: test-only seam (mirrors policyOverride's own /// pattern) letting a focused test substitute a deterministic fake /// placement sink for the production , /// so a test can drive itself — the real /// _eventRoute?.RetryPending() call this fix covers — instead of /// hand-constructing a outside /// this host. null (every production caller) keeps today's exact /// behavior. /// private readonly IRuntimePlacementProjectionSink? _placementSinkOverride; /// 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. private RuntimeFirstEntryDriveController? _firstEntryDrive; /// C4 route 2 (2026-08-03): see the ctor comment in /// — cached across reconnects exactly /// like . private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private AcDream.Core.Net.WorldSession? _currentSession; private HeadlessSessionWorldProjection? _worldProjection; /// /// A1/A3 review fix (2026-08-05): retained so can /// pump /// alongside 's own /// PumpFirstEntry — 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 . /// private RuntimeLiveEntitySessionController? _entities; /// C4 route 4b-1 (N3): the exact route /// last constructed, so can republish the canonical /// placement FIFO every tick — mirrors the graphical host's per-frame /// retry lease (GraphicalSessionEventRoute.Attach). Reassigned on /// every reconnect exactly like ; the prior /// route's own disposal (via LiveSessionHost's route replacement) /// is independent of this field. private HeadlessSessionEventRoute? _eventRoute; /// /// Campaign FA slice FA6: the single outstanding 0x0274 /// Character.ConfirmationRequest, or when /// none is pending. Graphical hosts route this to /// GameplayConfirmationController /// (LiveSessionRuntimeFactory.cs:315); a headless bot has no /// panel, so this single-slot latch (matching retail's own "one open /// dialog at a time" shape) plus is /// the bot-visible substitute a policy can poll and answer — the /// allegiance swear flow requires it: retail always confirms an /// incoming swear to the PATRON before 0x0020/0x01C8 /// ship to either party (docs/research/2026-08-11-fa-allegiance-wire.md /// §3.3), and there is no auto-accept character option for it (unlike /// fellowship's FellowshipAutoAcceptRequests, which ACE honors /// SERVER-SIDE without ever sending the client a confirmation at all). /// Reassigned on every reconnect exactly like /// — a stale pre-reconnect context id would be meaningless post-reconnect. /// private GameEvents.CharacterConfirmationRequest? _pendingConfirmation; 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, FellowshipAllegianceGateCoordinator? gateCoordinator = null, IEnumerable? pluginRoots = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); _credential = credential ?? throw new ArgumentNullException(nameof(credential)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _declaredCharacterOptions = ParseDeclaredCharacterOptions(descriptor); _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; HeadlessPluginSession? pluginSession = 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); // Campaign LA slice LA1: no-op instance when // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); var chatCommandSurface = new LiveChatCommandSurface(); var loginCommands = new LoginCommandSequence( descriptor.LoginCommands, TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), new RuntimeChatCommandFeedback(runtime.CommunicationOwner), chatCommandSurface, failure => statusWriter.LoginCommandFailed( descriptor.Id, failure.CommandIndex, failure.Command, failure.Error), _timeProvider); pluginSession = HeadlessPluginSession.Create( runtime, diagnostics, statusWriter, descriptor.Id, pluginRoots ?? [], descriptor.Plugins); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( new LiveSessionRoutingFactories( CreateEventRoute, session => new SessionCommandRoute( gameplay.CreateRoute(session), commands.CreateRoute(session), chatCommandSurface.Attach( new LiveChatCommandRoute( CreateChatCommandBindings( session, runtime))))), 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; // FA6: only the Recruit half publishes its own // discovered name — the Leader's proximity // stage reads this to disambiguate the actual // counterpart bot from any other player entity // a shared ACE dev instance happens to have // online (live-run finding, see // RuntimeFriendlyTargetQuery.FindPlayerByName's // doc). Gating on role keeps two sessions // writing the SAME field from ever racing — // only one role ever writes it. if (descriptor.Policy?.Role == HeadlessBotPolicyRole.Recruit && gateCoordinator is not null) { gateCoordinator.RecruitCharacterName = name; } }, () => { }, () => { }, _ => { }, () => { }), (host, port, user) => diagnostics.Message( descriptor.Id, $"connecting:{host}:{port}:{user}", runtime.Generation.Value), () => { diagnostics.Message( descriptor.Id, "connected", runtime.Generation.Value); statusWriter.Connected(descriptor.Id); _hasConnected = true; }, roster => statusWriter.CharacterList(descriptor.Id, roster), selection => statusWriter.EnteredWorld( descriptor.Id, selection.CharacterId, selection.CharacterName), loginCommands)); Runtime = runtime; Commands = commands; _liveSession = liveSession; _statusWriter = statusWriter; _localPlayerFrame = runtime.CreateLocalPlayerFrameController( new HeadlessLocalPlayerFrameHost( runtime, liveSession), new HeadlessMovementInputSource( runtime.MovementOwner)); _contentLease = contentLease; bridge.Bind(this); hostLease = runtime.AcquireHostLease( $"headless:{descriptor.Id}"); // Campaign LA slice LA2: a probe session's descriptor carries no // `policy` at all (the loader rejects the opposite pairing) — a // probe never reaches TrySelectCharacter/EnterWorld, so there is // no policy id to switch on. ProbeHeadlessBotPolicy reports // IsComplete unconditionally so HeadlessProcessScheduler treats // this session as already finished the instant it is // constructed, letting the scheduler's Run() loop return // immediately for a probe-only process instead of waiting for // SIGINT. policy = policyOverride ?? (descriptor.Mode == HeadlessSessionMode.Probe ? new ProbeHeadlessBotPolicy() : HeadlessBotPolicyFactory.Create( descriptor.Policy!, runtime, () => _pendingConfirmation, RespondToConfirmation, gateCoordinator)); policySubscription = runtime.Subscribe(policy); diagnostics.Lifecycle( descriptor.Id, "constructed", runtime); _hostLease = hostLease; _policy = policy; _policySubscription = policySubscription; _pluginSession = pluginSession; } catch { pluginSession?.Dispose(); policySubscription?.Dispose(); policy?.Dispose(); hostLease?.Dispose(); contentLease?.Dispose(); credential.Dispose(); runtimeRef?.Dispose(); throw; } } internal GameRuntime Runtime { get; } internal DirectGameRuntimeCommandAdapter Commands { get; } /// /// OP7 test seam (mirrors 's own visibility): the /// current route's declared-characterOptions diff-and-send engine, /// or null before the first CreateEventRoute call. Lets a /// focused test drive one half of the seeder's two-precondition latch /// directly (e.g. simulate "LoginComplete already sent") without /// reconstructing the first-entry-drive/portal-completion machinery that /// production code uses to reach the same state. /// internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; internal HeadlessPluginSession Plugins => _pluginSession; 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."); /// Campaign FA slice FA6: see . internal GameEvents.CharacterConfirmationRequest? PendingConfirmation => _pendingConfirmation; /// /// Campaign FA slice FA6: sends 0x0275 ConfirmationResponse for /// the current and clears the latch. /// Throws if none is pending — mirrors Require's fail-loud /// convention elsewhere in this file rather than silently no-op'ing. /// A missing (never connected, or /// mid-reconnect) is a silent no-op — matches every other /// _currentSession?.Send* site in this class. /// internal void RespondToConfirmation(bool accepted) { if (_pendingConfirmation is not { } request) { throw new InvalidOperationException( "No confirmation request is pending."); } _currentSession?.SendConfirmationResponse( request.Type, request.ContextId, accepted); _pendingConfirmation = null; } internal RuntimeSessionStartResult Start() { // Campaign LA slice LA1: "started" = session host start — the // earliest point this session actually attempts to connect. _statusWriter.Started(_descriptor.Id); _pluginSession.Start(); RuntimeSessionStartResult result = Commands.Session.Start(Runtime.Generation); _startOutcome = result.Status; return result; } 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)); _liveSession.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(string reason = "stopped") { ArgumentException.ThrowIfNullOrWhiteSpace(reason); 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; // Campaign LA slice LA1: only report a disconnect for a session that // actually reached Connected — a Stop() on a never-started or // never-connected host (e.g. immediate Dispose()) is not a real // disconnect. if (_hasConnected) { _hasConnected = false; _statusWriter.Disconnected(_descriptor.Id, reason); } 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: _pluginSession.Dispose(); _disposeStage++; break; case 5: _hostLease.Dispose(); _disposeStage++; break; case 6: _credential.Dispose(); _disposeStage++; break; case 7: Runtime.Dispose(); _disposeStage++; break; case 8: _contentLease?.Dispose(); _disposeStage++; break; case 9: _diagnostics.Message( _descriptor.Id, "disposed", _stoppedGeneration); // Campaign LA slice LA1: "exited" = terminal — the sole // point every disposal path (graceful and post- // quarantine) converges on. LA2: only an actual // ProbeComplete start outcome reports reason "probe"; // configured probe intent cannot turn a failed start into // a successful terminal event. (int exitCode, string exitReason) = ResolveTerminalStatus(); _statusWriter.Exited( _descriptor.Id, exitCode, exitReason); _disposeStage++; _disposed = true; break; default: throw new InvalidOperationException( "Unknown headless session teardown stage."); } } } /// /// Produces the same terminal classification the owning process host uses. /// Descriptor mode never participates: only an observed ProbeComplete may /// report a successful probe. The surrounding disposal stage and LA1's /// terminal/idempotent make this event /// exact-once even when disposal is retried. /// private (int Code, string Reason) ResolveTerminalStatus() { if (_faulted) { return ( (int)HeadlessExitCode.RuntimeError, "runtime-fault"); } return _startOutcome switch { RuntimeSessionStartStatus.ProbeComplete => ((int)HeadlessExitCode.Success, "probe"), null or RuntimeSessionStartStatus.Connected => ((int)HeadlessExitCode.Success, "graceful"), _ => ((int)HeadlessExitCode.ConnectionError, "connection-error"), }; } 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) { // Campaign LA LA1 review fix F3: route reconnect teardown // through the same status-aware Stop boundary as every other // host stop. The retiring connection therefore publishes a // truthful disconnected(reason: "reconnect") edge before the // fresh LiveSessionHost reports its second connected edge. RuntimeTeardownAcknowledgement stopped = Stop("reconnect"); 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), Probe: _descriptor.Mode == HeadlessSessionMode.Probe); 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 LiveChatCommandBindings CreateChatCommandBindings( AcDream.Core.Net.WorldSession session, GameRuntime runtime) => new( ExecuteClientCommand: command => ExecuteHeadlessClientCommand(session, runtime, command), Communication: runtime.CommunicationOwner, Chat: runtime.CommunicationOwner.Chat, TurbineChat: runtime.CommunicationOwner.TurbineChat, CharacterState: runtime.CharacterOwner, PlayerGuid: () => runtime.PlayerIdentity.ServerGuid, SendTalk: session.SendTalk, SendTell: session.SendTell, SendChannel: session.SendChannel, SendTurbineChat: session.SendTurbineChatTo, Log: message => _diagnostics.Message( _descriptor.Id, message, runtime.Generation.Value)); /// /// Presentation-free subset of retail client commands. Commands whose /// semantics require a graphical confirmation/window or a host-specific /// presentation service fail explicitly; the login sequence reports that /// one line and continues. Wire-only and canonical-state commands take /// the exact same WorldSession/Runtime paths as the graphical bindings. /// private static void ExecuteHeadlessClientCommand( AcDream.Core.Net.WorldSession session, GameRuntime runtime, ExecuteClientCommandCmd command) { switch (command.Command) { case ClientCommandId.LifestoneRecall: session.SendTeleportToLifestone(); return; case ClientCommandId.MarketplaceRecall: session.SendTeleportToMarketplace(); return; case ClientCommandId.PkArenaRecall: session.SendTeleportToPkArena(); return; case ClientCommandId.PkLiteArenaRecall: session.SendTeleportToPkLiteArena(); return; case ClientCommandId.EnterPkLite: session.SendEnterPkLite(); return; case ClientCommandId.HouseRecall: session.SendTeleportToHouse(); return; case ClientCommandId.MansionRecall: session.SendTeleportToMansion(); return; case ClientCommandId.QueryAge: session.SendQueryAge(); return; case ClientCommandId.QueryBirth: session.SendQueryBirth(); return; case ClientCommandId.Emote when !string.IsNullOrWhiteSpace(command.Arguments): session.SendEmote(command.Arguments.Trim()); return; case ClientCommandId.ClearChat: runtime.CommunicationOwner.Chat.Clear(); return; case ClientCommandId.ChatToggle: // Retail DoChatToggle: "off" adds the global Speech // squelch; "on" removes it. session.SendModifyGlobalSquelch( command.Arguments.Equals( "off", StringComparison.OrdinalIgnoreCase), 2u); return; case ClientCommandId.NoTellToggle: // Retail DoNoTell: "on" adds the global Tell squelch; // "off" removes it. session.SendModifyGlobalSquelch( command.Arguments.Equals( "on", StringComparison.OrdinalIgnoreCase), 3u); return; case ClientCommandId.IndexChannels: session.SendIndexChannels(); return; case ClientCommandId.ListChannel: SendResolvedChannel( command.Arguments, session.SendListChannel); return; case ClientCommandId.OnChannel: SendResolvedChannel( command.Arguments, session.SendOnChannel); return; case ClientCommandId.OffChannel: SendResolvedChannel( command.Arguments, session.SendOffChannel); return; case ClientCommandId.AllegianceHometown: session.SendRecallAllegianceHometown(); return; case ClientCommandId.AllegianceInfo: session.SendAllegianceInfoRequest(command.Arguments.Trim()); return; case ClientCommandId.Permit: ExecutePermit(session, command.Arguments); return; case ClientCommandId.HouseAvailableList when RetailClientCommandCatalog.TryResolveHouseType( command.Arguments, out uint houseType): session.SendListAvailableHouses(houseType); return; case ClientCommandId.JoinChannel when RetailClientCommandCatalog.TryResolveJoinLeaveOption( command.Arguments, out uint joinOption): _ = runtime.CharacterOwner.Options.TrySetOption( joinOption, true, session.SendSetSingleCharacterOption); return; case ClientCommandId.LeaveChannel when RetailClientCommandCatalog.TryResolveJoinLeaveOption( command.Arguments, out uint leaveOption): _ = runtime.CharacterOwner.Options.TrySetOption( leaveOption, false, session.SendSetSingleCharacterOption); return; default: throw new NotSupportedException( $"Client command '{command.Command}' is not available " + "in the headless host."); } static void SendResolvedChannel( string arguments, Action send) { if (!RetailChannelTagTable.TryResolve( arguments.Trim(), out uint channelId)) { throw new InvalidOperationException( $"Chat channel '{arguments.Trim()}' does not exist."); } send(channelId); } static void ExecutePermit( AcDream.Core.Net.WorldSession activeSession, string arguments) { // The catalog has already required add/remove plus a name. // Match ClientCommandController's JoinArgsAsName behavior so // multi-word character names remain one exact wire argument. string[] parts = arguments.Split( (char[]?)null, StringSplitOptions.RemoveEmptyEntries); string name = string.Join(' ', parts, 1, parts.Length - 1); if (parts[0].Equals( "add", StringComparison.OrdinalIgnoreCase)) { activeSession.SendAddPlayerPermission(name); } else { activeSession.SendRemovePlayerPermission(name); } } } 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; // FA6: a stale pre-reconnect confirmation context id is meaningless // against the fresh WorldSession above — drop it rather than let a // policy answer a confirmation that no longer has a live listener. _pendingConfirmation = null; // OP7: a fresh seeder per route — see the field's own doc comment // for why this (rather than a reset method) is the right per- // reconnect lifetime. _optionsSeeder = new HeadlessCharacterOptionsSeeder( _declaredCharacterOptions, Runtime, Commands.Character); // 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, // Consolidated-review round (2026-08-10), NIT (b): the // SAME session-labelled diagnostics stream every other // producer in this class writes into. onNonQuiescentStall: message => _diagnostics.Message( _descriptor.Id, message, Runtime.Generation.Value)); _worldProjection = projection; worldProjection = projection; } var entities = new RuntimeLiveEntitySessionController( Runtime, session, message => _diagnostics.Message( _descriptor.Id, message, Runtime.Generation.Value), worldProjection, _acceptedPositionDrive, // OP7: the first two of three production LoginComplete send // sites — see RuntimeLiveEntitySessionController's own doc. onLoginCompleteSent: () => _optionsSeeder?.NoteLoginCompleteSent()); _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: request => { // Kept as a permanent, low-volume diagnostic — docs/ // ISSUES.md #384's open question is precisely "does // ACE ever send 0x0274 for the allegiance swear at // all", and this line is the only place that would // show a positive answer once someone re-enables // AllegianceGateEnabled and reruns the gate. Console.WriteLine( $"[fa6-diag] OnConfirmationRequest received type=" + $"{request.Type} context={request.ContextId} " + $"text='{request.Message}'"); _pendingConfirmation = request; }, 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, // OP7: fires after RuntimeCharacterOptionsState.Replace has // already committed a fresh PlayerDescription's option words // — the seeder's other precondition alongside // NoteLoginCompleteSent (see its own type doc). OnCharacterOptionsChanged: (_, _) => _optionsSeeder?.NoteOptionsSeeded()), new LiveSocialSessionBindings( Runtime.CommunicationOwner.Chat, Runtime.CommunicationOwner.TurbineChat, Runtime.CommunicationOwner.Friends, Runtime.CommunicationOwner.Squelch, (text, type) => Runtime.CommunicationOwner.AddText(text, type), Fellowship: Runtime.FellowshipOwner, Allegiance: Runtime.AllegianceOwner)); var eventRoute = new HeadlessSessionEventRoute( route, Runtime, _placementSinkOverride ?? new HeadlessRuntimePlacementProjectionSink(Runtime), _firstEntryDrive, _ => { session.SendGameAction(GameActionLoginComplete.Build()); // OP7: the THIRD production LoginComplete send site (direct, // non-portal first-entry completion) — see // HeadlessCharacterOptionsSeeder's type doc and // RuntimeLiveEntitySessionController's onLoginCompleteSent // doc for the other two. _optionsSeeder?.NoteLoginCompleteSent(); }, _acceptedPositionDrive); _eventRoute = eventRoute; return eventRoute; } /// /// OP7, D8: parses /// into the typed id space the seeder and /// share. already validated /// every key against the exact bot-declarable name set before this host /// was constructed, so Enum.Parse here cannot fail. /// private static Dictionary ParseDeclaredCharacterOptions( HeadlessSessionDescriptor descriptor) { var declared = new Dictionary(); if (descriptor.CharacterOptions is not { } options) return declared; foreach (KeyValuePair pair in options) { declared[Enum.Parse(pair.Key, ignoreCase: false)] = pair.Value; } return declared; } /// Campaign LA slice LA2: for a probe /// session (the loader guarantees Character is omitted whenever /// Mode is ) — a probe /// never reaches TrySelectCharacter, so "no selector configured" /// is the correct, harmless mapping. private static LiveSessionCharacterSelector? MapCharacterSelector( HeadlessCharacterSelector? selector) => selector is null ? null : 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, LiveSessionStartStatus.ProbeComplete => RuntimeSessionStartStatus.ProbeComplete, _ => 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); } }