Derived the mechanism from the decomp before writing code: neither gmHouseUI::PostInit @0x004a2710 nor gmMapUI::PostInit @0x004a1c70 sends a HouseQuery, and six of gmHouseUI's seven Display* builders early-return on m_pHouseData == 0. The only text a houseless character's House tab shows is gmHouseUI::DisplayPurchaseTimeText @0x004a3110's expired branch (it doesn't gate on m_pHouseData) — the local player's PropertyInt.HousePurchaseTimestamp plus HouseSystem::HasPurchaseWaitPeriodExpired renders exactly "You may buy another house immediately." for a fresh character. Exhaustive search of the 2013 EoR decomp, ACE, and the live DAT found zero support for a second "You do not currently own a house." line the task brief described — this commit ports what the decomp actually shows. Ships: - RuntimeHouseState: a minimal (no disposal, no construction-transaction Fault() point) Runtime owner per ISSUES #413's own sizing note, wired through GameEventWiring's existing HouseData/HouseStatus delegate holes, LiveSessionEventRouter, and GameRuntime.HouseOwner. Participates in RuntimeGenerationReset (new House stage) since a fresh login must not show a stale character's house state. - HousePageController.Bindings.Lines/OnShown wired to real data; OnShown fires WorldSession.SendHouseQuery() on tab-open (AD-107: an acdream trigger, not a ported retail call site — filed in the divergence register). - Fixed a real bug found along the way: HousePageController.Bind never wired UiTemplateListBox.TemplateResolver, so no row could ever render regardless of Lines content. Now reuses the Map tab's generic hotspot resolver. Live-verified against a real local ACE server and the +Acdream character (--session-config auto-select + a UI automation script): screenshot and structural UI-tree dump both confirm the House tab renders exactly "You may buy another house immediately." Graceful logout confirmed both launches. ISSUES #413 narrowed to its one remaining piece: the six owned-house-only Display* builders (DisplayBuyPayment/RentPayment/BuyTime/RentTimes/ Location/WarningText), unexercisable without a test character that owns a house. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
978 lines
39 KiB
C#
978 lines
39 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.Runtime.World;
|
|
|
|
namespace AcDream.Runtime;
|
|
|
|
public sealed record GameRuntimeDependencies(
|
|
IRuntimeCombatAttackOperations CombatAttackOperations,
|
|
IRuntimeCombatTargetOperations CombatTargetOperations,
|
|
IRuntimeCombatModeOperations CombatModeOperations,
|
|
IRuntimeSpellCastOperations SpellCastOperations,
|
|
TimeProvider? TimeProvider = null,
|
|
Action<string>? Log = null,
|
|
Action<string>? TimeSyncDiagnostic = null,
|
|
ILiveSessionOperations? SessionOperations = null,
|
|
Func<double>? CombatTime = null,
|
|
uint FirstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
|
|
int MaximumChatEntries = 500,
|
|
// Review fix round F13 (2026-08-16): the shared RNG source for
|
|
// process-wide randomize commands reachable from a headless bot
|
|
// (RuntimeCharacterCreationState's Randomize* family —
|
|
// RandomizeCharacter/RandomizeAppearance/RandomizeClothing, exposed on
|
|
// IRuntimeCharacterCreationCommands). null (the default) keeps every
|
|
// existing caller's production behavior unchanged (Random.Shared,
|
|
// threaded the same way TimeProvider already is here) — this only
|
|
// exists so a future deterministic-bot config (Slice K's contract,
|
|
// project_linux_headless_bots.md) can supply a seeded Random without a
|
|
// second construction path.
|
|
Random? Random = null);
|
|
|
|
[Flags]
|
|
public enum GameRuntimeTeardownStage
|
|
{
|
|
None = 0,
|
|
HostLeasesReleased = 1 << 0,
|
|
EventsDetached = 1 << 1,
|
|
SessionDisposed = 1 << 2,
|
|
TransitReset = 1 << 3,
|
|
ActionsDisposed = 1 << 4,
|
|
MovementDisposed = 1 << 5,
|
|
CharacterDisposed = 1 << 6,
|
|
InventoryDisposed = 1 << 7,
|
|
CommunicationDisposed = 1 << 8,
|
|
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
|
FellowshipDisposed = 1 << 9,
|
|
AllegianceDisposed = 1 << 10,
|
|
// Secure trade (2026-08-14): third sibling J-owner, same shape.
|
|
TradeDisposed = 1 << 11,
|
|
IdentityDisposed = 1 << 12,
|
|
EntityObjectsDisposed = 1 << 13,
|
|
Complete =
|
|
HostLeasesReleased
|
|
| EventsDetached
|
|
| SessionDisposed
|
|
| TransitReset
|
|
| ActionsDisposed
|
|
| MovementDisposed
|
|
| CharacterDisposed
|
|
| InventoryDisposed
|
|
| CommunicationDisposed
|
|
| FellowshipDisposed
|
|
| AllegianceDisposed
|
|
| TradeDisposed
|
|
| IdentityDisposed
|
|
| EntityObjectsDisposed,
|
|
}
|
|
|
|
public readonly record struct GameRuntimeOwnershipSnapshot(
|
|
bool IsDisposeRequested,
|
|
bool IsDisposeDrainActive,
|
|
bool IsDisposed,
|
|
int HostLeaseCount,
|
|
GameRuntimeTeardownStage CompletedTeardownStages,
|
|
LiveSessionOwnershipSnapshot Session,
|
|
RuntimeLocalPlayerIdentityOwnershipSnapshot PlayerIdentity,
|
|
RuntimeSimulationOwnershipSnapshot Simulation,
|
|
RuntimeWorldEnvironmentOwnershipSnapshot Environment,
|
|
RuntimeWorldTransitOwnershipSnapshot Transit,
|
|
RuntimeGenerationResetSnapshot GenerationReset,
|
|
GameRuntimeEventOwnershipSnapshot Events)
|
|
{
|
|
public bool IsConverged =>
|
|
IsDisposed
|
|
&& IsDisposeRequested
|
|
&& !IsDisposeDrainActive
|
|
&& HostLeaseCount == 0
|
|
&& CompletedTeardownStages == GameRuntimeTeardownStage.Complete
|
|
&& Session.IsConverged
|
|
&& PlayerIdentity.IsConverged
|
|
&& Simulation.IsConverged
|
|
&& Transit.IsSessionIdle
|
|
&& GenerationReset.IsConverged
|
|
&& Events.IsConverged;
|
|
}
|
|
|
|
internal enum GameRuntimeConstructionPoint
|
|
{
|
|
ClockCreated,
|
|
SessionCreated,
|
|
PlayerIdentityCreated,
|
|
EntityObjectsCreated,
|
|
InventoryCreated,
|
|
CharacterCreated,
|
|
CommunicationCreated,
|
|
FellowshipCreated,
|
|
AllegianceCreated,
|
|
TradeCreated,
|
|
HouseCreated,
|
|
MovementCreated,
|
|
ActionsCreated,
|
|
EnvironmentCreated,
|
|
TransitCreated,
|
|
EventsCreated,
|
|
}
|
|
|
|
internal sealed class GameRuntimeConstructionContext
|
|
{
|
|
public LiveSessionController? Session { get; set; }
|
|
public RuntimeLocalPlayerIdentityState? PlayerIdentity { get; set; }
|
|
public RuntimeEntityObjectLifetime? EntityObjects { get; set; }
|
|
public RuntimeInventoryState? Inventory { get; set; }
|
|
public RuntimeCharacterState? Character { get; set; }
|
|
public RuntimeCommunicationState? Communication { get; set; }
|
|
public RuntimeFellowshipState? Fellowship { get; set; }
|
|
public RuntimeAllegianceState? Allegiance { get; set; }
|
|
public RuntimeTradeState? Trade { get; set; }
|
|
public RuntimeHouseState? House { get; set; }
|
|
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
|
public RuntimeActionState? Actions { get; set; }
|
|
public GameRuntimeEventHub? Events { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// One instance-scoped, presentation-independent ownership root for the
|
|
/// complete live client kernel. Graphical and direct hosts borrow this exact
|
|
/// object graph; they never reconstruct or copy its mutable state.
|
|
/// </summary>
|
|
public sealed class GameRuntime
|
|
: IGameRuntimeView,
|
|
IRuntimeEventSource,
|
|
IDisposable
|
|
{
|
|
private const int TeardownStageCount = 14;
|
|
|
|
private readonly object _lifetimeGate = new();
|
|
private readonly Dictionary<long, string> _hostLeases = [];
|
|
private readonly GameRuntimeEventHub _events;
|
|
private long _nextHostLeaseId;
|
|
private int _disposeStage;
|
|
private bool _disposeRequested;
|
|
private bool _disposeDrainActive;
|
|
private bool _disposed;
|
|
|
|
public GameRuntime(GameRuntimeDependencies dependencies)
|
|
: this(dependencies, faultInjection: null)
|
|
{
|
|
}
|
|
|
|
internal GameRuntime(
|
|
GameRuntimeDependencies dependencies,
|
|
Action<GameRuntimeConstructionPoint, GameRuntimeConstructionContext>?
|
|
faultInjection)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dependencies);
|
|
ArgumentNullException.ThrowIfNull(
|
|
dependencies.CombatAttackOperations);
|
|
ArgumentNullException.ThrowIfNull(
|
|
dependencies.CombatTargetOperations);
|
|
ArgumentNullException.ThrowIfNull(
|
|
dependencies.CombatModeOperations);
|
|
ArgumentNullException.ThrowIfNull(
|
|
dependencies.SpellCastOperations);
|
|
if (dependencies.MaximumChatEntries <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(dependencies.MaximumChatEntries));
|
|
}
|
|
|
|
var context = new GameRuntimeConstructionContext();
|
|
var construction = new ConstructionTransaction();
|
|
try
|
|
{
|
|
var clock = new GameRuntimeClock();
|
|
Fault(
|
|
GameRuntimeConstructionPoint.ClockCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Session = dependencies.SessionOperations is null
|
|
? new LiveSessionController(
|
|
ProductionLiveSessionOperations.Instance,
|
|
dependencies.TimeProvider,
|
|
random: dependencies.Random)
|
|
: new LiveSessionController(
|
|
dependencies.SessionOperations,
|
|
dependencies.TimeProvider,
|
|
random: dependencies.Random);
|
|
construction.Own(context.Session);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.SessionCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.PlayerIdentity = new RuntimeLocalPlayerIdentityState();
|
|
construction.Own(context.PlayerIdentity);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.PlayerIdentityCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.EntityObjects = new RuntimeEntityObjectLifetime(
|
|
dependencies.FirstLocalEntityId,
|
|
dependencies.TimeProvider,
|
|
clock);
|
|
construction.Own(context.EntityObjects);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.EntityObjectsCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Inventory = new RuntimeInventoryState(
|
|
context.EntityObjects);
|
|
construction.Own(context.Inventory);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.InventoryCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Character = new RuntimeCharacterState(
|
|
timeProvider: dependencies.TimeProvider);
|
|
construction.Own(context.Character);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.CharacterCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Communication = new RuntimeCommunicationState(
|
|
dependencies.MaximumChatEntries);
|
|
construction.Own(context.Communication);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.CommunicationCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
// Campaign FA slice FA2 (2026-08-12): two sibling J-owners, not
|
|
// children of Communication. Originally documented as differing
|
|
// in reset semantics (fellowship session-scoped, allegiance
|
|
// surviving reconnect) — the FA2 fix-round MUST-FIX 1
|
|
// (docs/research/2026-08-12-fa2-review-mechanism.md) found that
|
|
// citation inverted (retail clears BOTH at
|
|
// OnEndCharacterSession) and corrected both owners to clear at
|
|
// every generation reset. They remain two owners because
|
|
// fellowship and allegiance are still independent retail
|
|
// systems with independent wire families — not because their
|
|
// lifetimes differ anymore.
|
|
context.Fellowship = new RuntimeFellowshipState(
|
|
timeProvider: dependencies.TimeProvider);
|
|
construction.Own(context.Fellowship);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.FellowshipCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Allegiance = new RuntimeAllegianceState();
|
|
construction.Own(context.Allegiance);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.AllegianceCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
// Secure trade (2026-08-14): third sibling J-owner —
|
|
// session-scoped like fellowship (a disconnect closes the trade
|
|
// server-side), clears at every generation reset. Borrows the
|
|
// canonical object table so staged items carry retail's
|
|
// client-side trade state (SetTradeState @ 0x004CA801).
|
|
context.Trade = new RuntimeTradeState(context.EntityObjects!.Objects);
|
|
construction.Own(context.Trade);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.TradeCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
|
|
// deliberately minimal owner (ISSUES #413's own sizing note) —
|
|
// no live-object side effects, nothing to dispose, so no
|
|
// construction.Own() (unlike Trade above, which owns staged
|
|
// items' TradeState flags on live objects).
|
|
context.House = new RuntimeHouseState(context.EntityObjects.Objects);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.HouseCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Movement = new RuntimeLocalPlayerMovementState();
|
|
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
|
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
|
// the SpewBox through the SAME AddText router server-sent
|
|
// WeenieErrors use. Wired here, at construction, because
|
|
// Communication (built just above) always exists before any
|
|
// PlayerMovementController is installed.
|
|
context.Movement.OnInterfaceText =
|
|
(text, type) => context.Communication.AddText(text, type);
|
|
construction.Own(context.Movement);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.MovementCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
context.Actions = new RuntimeActionState(
|
|
context.Inventory.Transactions,
|
|
context.Character.Spellbook,
|
|
dependencies.CombatAttackOperations,
|
|
dependencies.CombatTargetOperations,
|
|
dependencies.CombatModeOperations,
|
|
dependencies.SpellCastOperations,
|
|
dependencies.CombatTime);
|
|
construction.Own(context.Actions);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.ActionsCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
var environment = new RuntimeWorldEnvironmentState(
|
|
dependencies.TimeProvider,
|
|
dependencies.Log,
|
|
dependencies.TimeSyncDiagnostic);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.EnvironmentCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
var transit = new RuntimeWorldTransitState(dependencies.Log);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.TransitCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
var generationReset = new RuntimeGenerationReset(
|
|
transit,
|
|
context.Communication,
|
|
context.Inventory,
|
|
context.Actions,
|
|
context.Movement,
|
|
context.EntityObjects,
|
|
context.Character,
|
|
context.PlayerIdentity,
|
|
context.Fellowship,
|
|
context.Allegiance,
|
|
context.Trade,
|
|
context.House);
|
|
|
|
context.Movement.AttachPhysicsPublication(
|
|
new RuntimeLocalPlayerPhysicsPublicationState(
|
|
context.EntityObjects.Entities,
|
|
context.EntityObjects.Physics,
|
|
context.Movement,
|
|
context.PlayerIdentity));
|
|
// C3c: the C3a conductor's "first act" — bind the publication
|
|
// owner the conductor was constructed without (it is built by
|
|
// RuntimeEntityObjectLifetime BEFORE
|
|
// RuntimeLocalPlayerPhysicsPublicationState exists; see the F2
|
|
// late-bind note on RuntimeLocalPlayerFirstEntryState's ctor).
|
|
context.EntityObjects.LocalPlayerFirstEntry.BindPublication(
|
|
context.Movement.PhysicsPublication);
|
|
|
|
context.EntityObjects.BindEventContext(
|
|
() => generationReset.ActiveRetiringGeneration
|
|
?? context.Session.Generation,
|
|
() => clock.FrameNumber);
|
|
// C0-2: bind the executor's live-input sources to the real
|
|
// Runtime owners now that both exist (RuntimeCharacterState at
|
|
// CharacterCreated, RuntimeLocalPlayerMovementState at
|
|
// MovementCreated - both after EntityObjectsCreated, so this
|
|
// cannot move earlier). UsePositionFromServer mirrors retail
|
|
// CommandInterpreter::UsePositionFromServer exactly; PlayerDistance
|
|
// is derived per Execute call from the live physics-controller
|
|
// position, matching the legacy remote path's own distance basis
|
|
// (LiveEntityNetworkUpdateController's MaxPhysicsDistance/dist).
|
|
// F3: the position source is nullable - a null Controller (the
|
|
// login-window drain, before the local player's own controller
|
|
// exists yet) must yield null, never a fabricated Vector3.Zero
|
|
// that would misclassify every remote entity as implausibly far.
|
|
context.EntityObjects.BindLiveInputs(
|
|
() => context.Character.UsePositionFromServer,
|
|
() => context.Movement.Controller?.Position);
|
|
context.Events = new GameRuntimeEventHub(
|
|
context.EntityObjects,
|
|
context.Communication,
|
|
context.Actions);
|
|
construction.Own(context.Events);
|
|
Fault(
|
|
GameRuntimeConstructionPoint.EventsCreated,
|
|
context,
|
|
faultInjection);
|
|
|
|
Clock = clock;
|
|
Session = context.Session;
|
|
PlayerIdentity = context.PlayerIdentity;
|
|
EntityObjects = context.EntityObjects;
|
|
InventoryOwner = context.Inventory;
|
|
CharacterOwner = context.Character;
|
|
CommunicationOwner = context.Communication;
|
|
FellowshipOwner = context.Fellowship;
|
|
AllegianceOwner = context.Allegiance;
|
|
TradeOwner = context.Trade;
|
|
HouseOwner = context.House;
|
|
MovementOwner = context.Movement;
|
|
ActionOwner = context.Actions;
|
|
EnvironmentOwner = environment;
|
|
TransitOwner = transit;
|
|
GenerationReset = generationReset;
|
|
_events = context.Events;
|
|
|
|
// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11): wire
|
|
// retail's two remaining CPlayerModule::SaveToServer trigger
|
|
// sites — the 480 s auto-save timer (CPlayerModule::UseTime) and
|
|
// the pre-logoff flush (CPlayerSystem::LogOffCharacter) — through
|
|
// LiveSessionController's own tick/stop transaction, closing
|
|
// TS-71. Both share the SAME flush body the explicit SaveOptions
|
|
// command already uses (CharacterOptionsBlobSource.Capture +
|
|
// WorldSession.SendSetCharacterOptions); this talks to the
|
|
// WorldSession directly rather than through App's
|
|
// LiveSessionCommandRouter/LiveCommandBus, which is what keeps
|
|
// it off the S2 lock-order hazard the blast-lens review named.
|
|
// The IsDirty pre-check lives HERE, in the once-allocated hook
|
|
// lambdas, not (only) inside FlushCharacterOptions: SendBlob's
|
|
// closure environment is allocated in that method's PROLOGUE,
|
|
// ahead of any guard inside it (OP1 re-review R1), so the clean-
|
|
// tick fast path must never ENTER the method at all. Retail-
|
|
// faithful too — CPlayerModule::UseTime @0x0059A710 opens with
|
|
// the identical m_bDirty byte compare.
|
|
context.Session.ConfigureAutoSaveTick(
|
|
session =>
|
|
{
|
|
if (CharacterOwner.Options.IsDirty)
|
|
FlushCharacterOptions(session, ifAutoSaveDue: true);
|
|
});
|
|
context.Session.ConfigurePreLogoffFlush(
|
|
session =>
|
|
{
|
|
if (CharacterOwner.Options.IsDirty)
|
|
FlushCharacterOptions(session, ifAutoSaveDue: false);
|
|
});
|
|
|
|
construction.Complete();
|
|
}
|
|
catch (Exception failure)
|
|
{
|
|
construction.RollbackAndThrow(failure);
|
|
throw new System.Diagnostics.UnreachableException();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one flush body shared by every trigger that can send the batched
|
|
/// <c>SetCharacterOptions (0x01A1)</c> blob: the explicit
|
|
/// <c>SaveOptions</c> command (both <c>IRuntimeCharacterCommands</c>
|
|
/// adapters), the 480 s auto-save timer, and the pre-logoff flush (the
|
|
/// latter two wired via <see cref="LiveSessionController.
|
|
/// ConfigureAutoSaveTick"/>/<see cref="LiveSessionController.
|
|
/// ConfigurePreLogoffFlush"/> in the constructor above). Reads
|
|
/// <see cref="CharacterOwner"/>/<see cref="InventoryOwner"/> at
|
|
/// invocation time, not construction time, so this is safe to bind
|
|
/// before either property's backing value is technically "public" —
|
|
/// both are always populated long before the session can ever tick or
|
|
/// stop.
|
|
/// <para>
|
|
/// The allocation-load-bearing <see cref="RuntimeCharacterOptionsState.
|
|
/// IsDirty"/> pre-check lives in the two HOOK LAMBDAS in the constructor,
|
|
/// not here: <c>SendBlob</c>'s closure environment is allocated in this
|
|
/// method's prologue — ahead of any guard written inside the body (OP1
|
|
/// re-review R1) — so keeping clean ticks out of this method entirely is
|
|
/// what protects the K4 headless per-tick allocation envelope. The check
|
|
/// below remains only as cheap idempotent defense for direct callers.
|
|
/// </para>
|
|
/// </summary>
|
|
private void FlushCharacterOptions(WorldSession session, bool ifAutoSaveDue)
|
|
{
|
|
RuntimeCharacterOptionsState options = CharacterOwner.Options;
|
|
if (!options.IsDirty)
|
|
return;
|
|
|
|
void SendBlob()
|
|
{
|
|
CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture(
|
|
CharacterOwner,
|
|
InventoryOwner.Shortcuts);
|
|
session.SendSetCharacterOptions(
|
|
echo.Options1,
|
|
echo.Options2,
|
|
echo.Shortcuts,
|
|
echo.FavoriteSpells,
|
|
echo.DesiredComponents,
|
|
echo.SpellbookFilters);
|
|
}
|
|
|
|
if (ifAutoSaveDue)
|
|
options.TryFlushIfAutoSaveDue(SendBlob);
|
|
else
|
|
options.TryFlush(SendBlob);
|
|
}
|
|
|
|
public GameRuntimeClock Clock { get; }
|
|
public LiveSessionController Session { get; }
|
|
public RuntimeLocalPlayerIdentityState PlayerIdentity { get; }
|
|
public RuntimeEntityObjectLifetime EntityObjects { get; }
|
|
public RuntimeInventoryState InventoryOwner { get; }
|
|
public RuntimeCharacterState CharacterOwner { get; }
|
|
public RuntimeCommunicationState CommunicationOwner { get; }
|
|
public RuntimeFellowshipState FellowshipOwner { get; }
|
|
public RuntimeAllegianceState AllegianceOwner { get; }
|
|
|
|
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
|
public RuntimeTradeState TradeOwner { get; }
|
|
|
|
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
|
|
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
|
|
/// rationale.</summary>
|
|
public RuntimeHouseState HouseOwner { get; }
|
|
public RuntimeActionState ActionOwner { get; }
|
|
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
|
internal RuntimeLocalPlayerPhysicsPublicationState
|
|
LocalPlayerPhysicsPublication => MovementOwner.PhysicsPublication;
|
|
public RuntimeWorldEnvironmentState EnvironmentOwner { get; }
|
|
public RuntimeWorldTransitState TransitOwner { get; }
|
|
public RuntimeGenerationReset GenerationReset { get; }
|
|
public RuntimePlacementProjectionChannel Placements =>
|
|
EntityObjects.Placements;
|
|
|
|
public RuntimeGenerationToken Generation => Session.Generation;
|
|
|
|
public RuntimeLifecycleSnapshot Lifecycle
|
|
{
|
|
get
|
|
{
|
|
RuntimeLifecycleState state;
|
|
if (_disposed)
|
|
state = RuntimeLifecycleState.Disposed;
|
|
else if (Session.IsInWorld)
|
|
state = RuntimeLifecycleState.InWorld;
|
|
else if (Session.CurrentSession is not null)
|
|
state = RuntimeLifecycleState.Starting;
|
|
else if (Session.SessionGeneration == 0UL)
|
|
state = RuntimeLifecycleState.Constructed;
|
|
else
|
|
state = RuntimeLifecycleState.Stopped;
|
|
|
|
return new RuntimeLifecycleSnapshot(
|
|
Generation,
|
|
state,
|
|
PlayerIdentity.ServerGuid,
|
|
Session.CurrentSession is not null);
|
|
}
|
|
}
|
|
|
|
IGameRuntimeClock IGameRuntimeView.Clock => Clock;
|
|
public IRuntimeEntityView Entities => EntityObjects.EntityView;
|
|
public IRuntimeInventoryView Inventory => EntityObjects.InventoryView;
|
|
public IRuntimeInventoryStateView InventoryState => InventoryOwner.View;
|
|
public IRuntimeCharacterView Character => CharacterOwner.View;
|
|
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
|
|
public IRuntimeChatView Chat => CommunicationOwner.View;
|
|
public IRuntimeCharacterSelectionView CharacterSelection =>
|
|
Session.CharacterSelection;
|
|
public IRuntimeCharacterCreationView CharacterCreation =>
|
|
Session.CharacterCreation;
|
|
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
|
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
|
|
|
public IRuntimeTradeView Trade => TradeOwner.View;
|
|
public IRuntimeActionView Actions => ActionOwner.View;
|
|
public IRuntimeMovementView Movement => MovementOwner.View;
|
|
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
|
|
public IRuntimePortalView Portal => TransitOwner;
|
|
|
|
internal IGameRuntimeEventSink EventSink => _events;
|
|
|
|
public RuntimeStateCheckpoint CaptureCheckpoint() =>
|
|
new(
|
|
Generation,
|
|
Lifecycle.State,
|
|
Clock.FrameNumber,
|
|
Entities.Count,
|
|
Entities.MaterializedCount,
|
|
Inventory.ObjectCount,
|
|
Inventory.ContainerCount,
|
|
InventoryState.Snapshot,
|
|
Character.Snapshot,
|
|
Social.Snapshot,
|
|
Chat.Revision,
|
|
Chat.Count,
|
|
Actions.Snapshot,
|
|
Movement.Snapshot,
|
|
Environment.Snapshot,
|
|
Environment.Ownership,
|
|
Portal.Snapshot,
|
|
Portal.Ownership,
|
|
Fellowship.Snapshot,
|
|
Allegiance.Snapshot);
|
|
|
|
public RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
|
|
IRuntimeLocalPlayerFrameHost host,
|
|
IRuntimeMovementInputSource input)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
|
|
return new RuntimeLocalPlayerFrameController(
|
|
host,
|
|
input,
|
|
() =>
|
|
{
|
|
_events.EmitMovement(MovementOwner.Snapshot);
|
|
// Slice 5.3: the local-player movement publish already fires
|
|
// once per advanced frame for both graphical and no-window
|
|
// hosts (RuntimeLocalPlayerFrameController.RunPostNetworkCommandPhase),
|
|
// so the client-local vendor distance watcher piggybacks on
|
|
// it instead of adding a second polling loop.
|
|
RuntimeVendorRangeQuery.EnforceRange(this);
|
|
});
|
|
}
|
|
|
|
public void ResetGeneration(
|
|
RuntimeGenerationToken retiringGeneration,
|
|
IRuntimeGenerationResetHost host) =>
|
|
GenerationReset.Reset(retiringGeneration, host);
|
|
|
|
public IDisposable Subscribe(IRuntimeEventObserver observer)
|
|
{
|
|
lock (_lifetimeGate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(
|
|
_disposeRequested || _disposed,
|
|
this);
|
|
return _events.Subscribe(observer);
|
|
}
|
|
}
|
|
|
|
public IDisposable AcquireHostLease(string name)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
lock (_lifetimeGate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(
|
|
_disposeRequested || _disposed,
|
|
this);
|
|
long id = checked(++_nextHostLeaseId);
|
|
_hostLeases.Add(id, name);
|
|
return new HostLease(this, id);
|
|
}
|
|
}
|
|
|
|
public GameRuntimeOwnershipSnapshot CaptureOwnership()
|
|
{
|
|
lock (_lifetimeGate)
|
|
{
|
|
return new GameRuntimeOwnershipSnapshot(
|
|
_disposeRequested,
|
|
_disposeDrainActive,
|
|
_disposed,
|
|
_hostLeases.Count,
|
|
CompletedTeardownStages,
|
|
Session.CaptureOwnership(),
|
|
PlayerIdentity.CaptureOwnership(),
|
|
RuntimeSimulationOwnership.Capture(
|
|
EntityObjects,
|
|
InventoryOwner,
|
|
CharacterOwner,
|
|
CommunicationOwner,
|
|
ActionOwner,
|
|
MovementOwner,
|
|
FellowshipOwner,
|
|
AllegianceOwner,
|
|
TradeOwner),
|
|
EnvironmentOwner.CaptureOwnership(),
|
|
TransitOwner.CaptureOwnership(),
|
|
GenerationReset.CaptureSnapshot(),
|
|
_events.CaptureOwnership());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Makes transport and all per-generation routes inert while retaining the
|
|
/// root for ordered host projection teardown.
|
|
/// </summary>
|
|
public void StopSession()
|
|
{
|
|
Session.Dispose();
|
|
if (!Session.IsDisposalComplete)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The Runtime session shutdown was deferred by a re-entrant callback.");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_lifetimeGate)
|
|
{
|
|
if (_disposed || _disposeDrainActive)
|
|
return;
|
|
_disposeRequested = true;
|
|
_disposeDrainActive = true;
|
|
}
|
|
|
|
List<Exception>? completedStageFailures = null;
|
|
try
|
|
{
|
|
while (_disposeStage < TeardownStageCount)
|
|
{
|
|
bool complete;
|
|
try
|
|
{
|
|
complete = DrainCurrentStage();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
complete = IsCurrentStageComplete();
|
|
if (!complete)
|
|
throw;
|
|
(completedStageFailures ??= []).Add(error);
|
|
}
|
|
|
|
if (!complete)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"GameRuntime teardown stage {_disposeStage} did not complete.");
|
|
}
|
|
_disposeStage++;
|
|
}
|
|
|
|
lock (_lifetimeGate)
|
|
_disposed = true;
|
|
}
|
|
finally
|
|
{
|
|
lock (_lifetimeGate)
|
|
_disposeDrainActive = false;
|
|
}
|
|
|
|
if (completedStageFailures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"GameRuntime reached terminal ownership with callback failures.",
|
|
completedStageFailures);
|
|
}
|
|
}
|
|
|
|
private GameRuntimeTeardownStage CompletedTeardownStages =>
|
|
_disposeStage switch
|
|
{
|
|
<= 0 => GameRuntimeTeardownStage.None,
|
|
1 => GameRuntimeTeardownStage.HostLeasesReleased,
|
|
2 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached,
|
|
3 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached
|
|
| GameRuntimeTeardownStage.SessionDisposed,
|
|
4 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached
|
|
| GameRuntimeTeardownStage.SessionDisposed
|
|
| GameRuntimeTeardownStage.TransitReset,
|
|
5 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached
|
|
| GameRuntimeTeardownStage.SessionDisposed
|
|
| GameRuntimeTeardownStage.TransitReset
|
|
| GameRuntimeTeardownStage.ActionsDisposed,
|
|
6 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached
|
|
| GameRuntimeTeardownStage.SessionDisposed
|
|
| GameRuntimeTeardownStage.TransitReset
|
|
| GameRuntimeTeardownStage.ActionsDisposed
|
|
| GameRuntimeTeardownStage.MovementDisposed,
|
|
7 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached
|
|
| GameRuntimeTeardownStage.SessionDisposed
|
|
| GameRuntimeTeardownStage.TransitReset
|
|
| GameRuntimeTeardownStage.ActionsDisposed
|
|
| GameRuntimeTeardownStage.MovementDisposed
|
|
| GameRuntimeTeardownStage.CharacterDisposed,
|
|
8 => GameRuntimeTeardownStage.HostLeasesReleased
|
|
| GameRuntimeTeardownStage.EventsDetached
|
|
| GameRuntimeTeardownStage.SessionDisposed
|
|
| GameRuntimeTeardownStage.TransitReset
|
|
| GameRuntimeTeardownStage.ActionsDisposed
|
|
| GameRuntimeTeardownStage.MovementDisposed
|
|
| GameRuntimeTeardownStage.CharacterDisposed
|
|
| GameRuntimeTeardownStage.InventoryDisposed,
|
|
// Blast MF-1 (docs/research/2026-08-12-fa2-review-blast.md,
|
|
// 2026-08-12 fix round): stage 9 disposes Fellowship (see
|
|
// DrainCurrentStage/case 9 below), so at _disposeStage == 9
|
|
// only stages 0..8 have COMPLETED — the flag set must end at
|
|
// CommunicationDisposed (9 flags), not include
|
|
// FellowshipDisposed. The original longhand spelling had one
|
|
// flag too many; restored to the self-checking subtraction form
|
|
// every other case already uses.
|
|
9 => GameRuntimeTeardownStage.Complete
|
|
& ~GameRuntimeTeardownStage.FellowshipDisposed
|
|
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
|
& ~GameRuntimeTeardownStage.TradeDisposed
|
|
& ~GameRuntimeTeardownStage.IdentityDisposed
|
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
|
10 => GameRuntimeTeardownStage.Complete
|
|
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
|
& ~GameRuntimeTeardownStage.TradeDisposed
|
|
& ~GameRuntimeTeardownStage.IdentityDisposed
|
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
|
11 => GameRuntimeTeardownStage.Complete
|
|
& ~GameRuntimeTeardownStage.TradeDisposed
|
|
& ~GameRuntimeTeardownStage.IdentityDisposed
|
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
|
12 => GameRuntimeTeardownStage.Complete
|
|
& ~GameRuntimeTeardownStage.IdentityDisposed
|
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
|
13 => GameRuntimeTeardownStage.Complete
|
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
|
_ => GameRuntimeTeardownStage.Complete,
|
|
};
|
|
|
|
private bool DrainCurrentStage()
|
|
{
|
|
switch (_disposeStage)
|
|
{
|
|
case 0:
|
|
lock (_lifetimeGate)
|
|
{
|
|
if (_hostLeases.Count != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"GameRuntime cannot retire while host leases remain: "
|
|
+ string.Join(", ", _hostLeases.Values));
|
|
}
|
|
}
|
|
return true;
|
|
case 1:
|
|
_events.Dispose();
|
|
return _events.CaptureOwnership().IsConverged;
|
|
case 2:
|
|
StopSession();
|
|
return Session.CaptureOwnership().IsConverged;
|
|
case 3:
|
|
GenerationReset.DrainPending();
|
|
TransitOwner.ResetSession();
|
|
return TransitOwner.CaptureOwnership().IsSessionIdle;
|
|
case 4:
|
|
ActionOwner.Dispose();
|
|
return ActionOwner.CaptureOwnership().IsConverged;
|
|
case 5:
|
|
MovementOwner.Dispose();
|
|
return MovementOwner.CaptureOwnership().IsConverged;
|
|
case 6:
|
|
CharacterOwner.Dispose();
|
|
return CharacterOwner.CaptureOwnership().IsConverged;
|
|
case 7:
|
|
InventoryOwner.Dispose();
|
|
return InventoryOwner.CaptureOwnership().IsConverged;
|
|
case 8:
|
|
CommunicationOwner.Dispose();
|
|
return CommunicationOwner.CaptureOwnership().IsConverged;
|
|
case 9:
|
|
FellowshipOwner.Dispose();
|
|
return FellowshipOwner.CaptureOwnership().IsConverged;
|
|
case 10:
|
|
AllegianceOwner.Dispose();
|
|
return AllegianceOwner.CaptureOwnership().IsConverged;
|
|
case 11:
|
|
TradeOwner.Dispose();
|
|
return TradeOwner.CaptureOwnership().IsConverged;
|
|
case 12:
|
|
PlayerIdentity.Dispose();
|
|
return PlayerIdentity.CaptureOwnership().IsConverged;
|
|
case 13:
|
|
EntityObjects.Dispose();
|
|
return EntityObjects.CaptureOwnership().IsConverged
|
|
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private bool IsCurrentStageComplete() =>
|
|
_disposeStage switch
|
|
{
|
|
0 => HostLeaseCount == 0,
|
|
1 => _events.CaptureOwnership().IsConverged,
|
|
2 => Session.CaptureOwnership().IsConverged,
|
|
3 => TransitOwner.CaptureOwnership().IsSessionIdle,
|
|
4 => ActionOwner.CaptureOwnership().IsConverged,
|
|
5 => MovementOwner.CaptureOwnership().IsConverged,
|
|
6 => CharacterOwner.CaptureOwnership().IsConverged,
|
|
7 => InventoryOwner.CaptureOwnership().IsConverged,
|
|
8 => CommunicationOwner.CaptureOwnership().IsConverged,
|
|
9 => FellowshipOwner.CaptureOwnership().IsConverged,
|
|
10 => AllegianceOwner.CaptureOwnership().IsConverged,
|
|
11 => TradeOwner.CaptureOwnership().IsConverged,
|
|
12 => PlayerIdentity.CaptureOwnership().IsConverged,
|
|
13 => EntityObjects.CaptureOwnership().IsConverged
|
|
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
|
|
_ => true,
|
|
};
|
|
|
|
private int HostLeaseCount
|
|
{
|
|
get
|
|
{
|
|
lock (_lifetimeGate)
|
|
return _hostLeases.Count;
|
|
}
|
|
}
|
|
|
|
private void ReleaseHostLease(long id)
|
|
{
|
|
lock (_lifetimeGate)
|
|
_hostLeases.Remove(id);
|
|
}
|
|
|
|
private static void Fault(
|
|
GameRuntimeConstructionPoint point,
|
|
GameRuntimeConstructionContext context,
|
|
Action<GameRuntimeConstructionPoint, GameRuntimeConstructionContext>?
|
|
faultInjection) =>
|
|
faultInjection?.Invoke(point, context);
|
|
|
|
private sealed class HostLease(GameRuntime owner, long id) : IDisposable
|
|
{
|
|
private GameRuntime? _owner = owner;
|
|
|
|
public void Dispose() =>
|
|
Interlocked.Exchange(ref _owner, null)?.ReleaseHostLease(id);
|
|
}
|
|
|
|
private sealed class ConstructionTransaction
|
|
{
|
|
private readonly List<IDisposable> _owners = [];
|
|
private bool _complete;
|
|
|
|
public void Own(IDisposable owner)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(owner);
|
|
if (_complete)
|
|
throw new InvalidOperationException(
|
|
"Runtime construction already completed.");
|
|
_owners.Add(owner);
|
|
}
|
|
|
|
public void Complete()
|
|
{
|
|
_complete = true;
|
|
_owners.Clear();
|
|
}
|
|
|
|
public void RollbackAndThrow(Exception failure)
|
|
{
|
|
var failures = new List<Exception> { failure };
|
|
for (int i = _owners.Count - 1; i >= 0; i--)
|
|
{
|
|
try
|
|
{
|
|
_owners[i].Dispose();
|
|
}
|
|
catch (Exception cleanup)
|
|
{
|
|
failures.Add(cleanup);
|
|
}
|
|
}
|
|
_owners.Clear();
|
|
if (failures.Count == 1)
|
|
System.Runtime.ExceptionServices.ExceptionDispatchInfo
|
|
.Capture(failure)
|
|
.Throw();
|
|
throw new AggregateException(
|
|
"GameRuntime construction and rollback both failed.",
|
|
failures);
|
|
}
|
|
}
|
|
}
|