diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 36e79f4e..82eb6ae9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -297,7 +297,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | -| TS-28 | **NARROWED 2026-07-15** — F751 teleports now resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login still sends LoginComplete directly from the PlayerCreate (0xF746) handler and does not enter the portal-space presentation. | `src/AcDream.Core.Net/WorldSession.cs` (PlayerCreate branch); `src/AcDream.App/Rendering/GameWindow.cs` (F751 `FireLoginComplete`) | The live-session bootstrap currently needs the acknowledgement to unlock the initial authoritative object/property stream; moving initial login behind the App presentation requires an explicit session→presentation readiness contract rather than withholding it inside Core.Net | Initial login can expose server updates earlier than retail and skips the wormhole presentation; recalls/portals now have retail ordering | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | +| TS-28 | **NARROWED 2026-08-03** — F751 teleports resend LoginComplete only after the DAT-authored portal-space viewport and final world fade finish. Initial login no longer acknowledges raw PlayerCreate receipt: graphical and prepared headless hosts send exactly once after canonical local-player first placement; content-less headless sends after its accepted direct Create because it has no placement conductor. Residual: initial login still does not enter the full portal-space presentation. | `src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs`; `src/AcDream.App/Net/GraphicalSessionEventRoute.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs`; `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs`; `src/AcDream.Core.Net/WorldSession.cs` | Initial placement is now the shared readiness contract that releases ACE's intentional Hidden/pink-bubble state without racing presentation. The content-less direct host uses its only truthful admission edge. | The persistent login materialization haze is fixed and server updates no longer unlock before canonical placement. The remaining difference is presentation-only: initial login skips retail's wormhole sequence. | `gmSmartBoxUI::UseTime @ 0x004D6E30`; retail post-EnterWorld flow; holtburger `client/messages.rs:391-422` | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | | TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs index 518a2ae8..6bc25897 100644 --- a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -1,4 +1,5 @@ using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; @@ -16,6 +17,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting private readonly Func _generation; private readonly RuntimePlacementProjectionRetrySlot _retries; private readonly RuntimeFirstEntryDriveController? _firstEntry; + private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private IDisposable? _retryLease; private bool _attachStarted; @@ -27,7 +29,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting GameRuntime runtime, IRuntimePlacementProjectionSink placements, RuntimePlacementProjectionRetrySlot retries, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + Action? localPlayerCompleted = null) : this( events, () => new RuntimePlacementProjectionSubscription( @@ -36,7 +39,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting retryPendingOnSubscribe: false), () => runtime.Generation, retries, - firstEntry) + firstEntry, + localPlayerCompleted) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(placements); @@ -47,7 +51,8 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting Func createSubscription, Func generation, RuntimePlacementProjectionRetrySlot retries, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + Action? localPlayerCompleted = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _createSubscription = createSubscription @@ -56,6 +61,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting ?? throw new ArgumentNullException(nameof(generation)); _retries = retries ?? throw new ArgumentNullException(nameof(retries)); _firstEntry = firstEntry; + _localPlayerCompleted = localPlayerCompleted; } public void Attach() @@ -68,7 +74,7 @@ internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting // C3c-R1 review F6: assert (not assume) that the prior route // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. - _firstEntry?.AttachRoute(this); + _firstEntry?.AttachRoute(this, _localPlayerCompleted); _events.Attach(); RuntimePlacementProjectionSubscription? subscription = null; diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index b9103078..c6d4dfda 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -18,6 +18,7 @@ using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Player; using AcDream.Core.Social; using AcDream.Core.Spells; @@ -262,7 +263,8 @@ internal sealed class LiveSessionRuntimeFactory _domain.Runtime, _world.PlacementProjection, _world.PlacementRetries, - _world.FirstEntryDrive); + _world.FirstEntryDrive, + _ => session.SendGameAction(GameActionLoginComplete.Build())); } private LiveInventorySessionBindings CreateInventoryBindings() => new( diff --git a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs index 84dc5313..cd087aeb 100644 --- a/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs +++ b/src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs @@ -29,8 +29,9 @@ namespace AcDream.Core.Net.Messages; /// Retail clients send it once the portal-space transition animation finishes. /// acdream's F751 teleport path now does the same through /// TeleportAnimSequencer.FireLoginComplete. Initial session bootstrap -/// still sends from the PlayerCreate handler; that remaining ordering gap is -/// tracked as TS-28 in the divergence register. +/// sends from the canonical local-player first-placement completion edge, not +/// from raw packet receipt, so ACE's intentional Hidden/pink-bubble state is +/// released only after the client can actually present the player. /// /// public static class GameActionLoginComplete diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9a3af53c..a56ce33d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -555,16 +555,6 @@ public sealed class WorldSession : IDisposable /// public double LastServerTimeTicks { get; private set; } - /// - /// Allow re-sending LoginComplete after a portal teleport. The normal - /// _loginCompleteSent latch prevents duplicate sends on the initial spawn - /// path; this method resets it so the teleport completion path can send - /// another LoginComplete to tell the server the client has finished loading - /// the destination cell. Pattern from holtburger's PlayerTeleport handler - /// (client/messages.rs line 434-440: call send_login_complete on teleport). - /// - public void ResetLoginComplete() => _loginCompleteSent = false; - /// Raised every time the state machine transitions. public event Action? StateChanged; @@ -771,14 +761,6 @@ public sealed class WorldSession : IDisposable Buffer.AsMemory(0, Length); } - /// - /// Phase 4.10 latch — true after we've sent the LoginComplete game - /// action in response to PlayerCreate. Prevents re-sending if the - /// server emits multiple PlayerCreate messages (rare but possible - /// across recall / portal teleports). - /// - private bool _loginCompleteSent; - /// L.2g slice 1: one-shot guard so the [setstate-hex] probe /// emits the first SetState's body bytes only, not 5–10/sec. private bool _setStateHexDumped; @@ -1061,13 +1043,10 @@ public sealed class WorldSession : IDisposable // login form. ACE validates this canonical account value. SendGameMessage(selection.EnterWorldBody); - // NOTE: LoginComplete used to be sent here unconditionally. That was - // wrong — per holtburger's flow (see references/holtburger/.../client/ - // messages.rs lines 391-422), LoginComplete is sent in response to the - // server's PlayerCreate (0xF746) game message, NOT immediately after - // EnterWorld. Sending it too early means the player object isn't - // ready and the server ignores it. The actual trigger lives in - // ProcessDatagram. + // LoginComplete is emitted by the host only after the accepted local + // Create has completed its canonical first placement. Sending it at + // EnterWorld or merely on PlayerCreate races the server's intentional + // Hidden/pink-bubble login state. Transition(State.InWorld); // Phase A.3: start the background receive thread now that the @@ -1705,17 +1684,6 @@ public sealed class WorldSession : IDisposable // references/holtburger/.../client/messages.rs::DddInterrogation SendGameMessage(DddInterrogationResponse.Build()); } - else if (op == 0xF746u && !_loginCompleteSent) // PlayerCreate — server creates our player object - { - // Phase 4.10: PlayerCreate for our character is the cue to - // send LoginComplete. Sending it earlier (right after the - // outbound CharacterEnterWorld) was wrong because the server - // hadn't finished spawning the player yet. Holtburger's - // client/messages.rs (PlayerCreate handler) confirms this is - // the correct trigger. Send once per session. - _loginCompleteSent = true; - SendGameMessage(GameActionLoginComplete.Build()); - } else if (op == CreateObject.Opcode) { var parsed = CreateObject.TryParse(body); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs index afb0c400..87ca6e4c 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs @@ -1,4 +1,5 @@ using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; @@ -16,6 +17,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting private readonly GameRuntime _runtime; private readonly IRuntimePlacementProjectionSink _placements; private readonly RuntimeFirstEntryDriveController? _firstEntry; + private readonly Action? _localPlayerCompleted; private RuntimePlacementProjectionSubscription? _subscription; private bool _attachStarted; private bool _eventsDisposed; @@ -25,13 +27,15 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting ILiveSessionEventRouting events, GameRuntime runtime, IRuntimePlacementProjectionSink placements, - RuntimeFirstEntryDriveController? firstEntry = null) + RuntimeFirstEntryDriveController? firstEntry = null, + Action? localPlayerCompleted = null) { _events = events ?? throw new ArgumentNullException(nameof(events)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _placements = placements ?? throw new ArgumentNullException(nameof(placements)); _firstEntry = firstEntry; + _localPlayerCompleted = localPlayerCompleted; } public void Attach() @@ -47,7 +51,7 @@ internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting // C3c-R1 review F6: assert (not assume) that the prior route // detached — session reset precedes a new route — before this route // takes ownership of the shared drive controller's tracked entries. - _firstEntry?.AttachRoute(this); + _firstEntry?.AttachRoute(this, _localPlayerCompleted); _events.Attach(); _subscription = new RuntimePlacementProjectionSubscription( _runtime, diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 8d5ca3b0..16d14d80 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -2,6 +2,7 @@ using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Policies; +using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; @@ -618,7 +619,8 @@ internal sealed class HeadlessSessionHost : IDisposable route, Runtime, new HeadlessRuntimePlacementProjectionSink(Runtime), - _firstEntryDrive); + _firstEntryDrive, + _ => session.SendGameAction(GameActionLoginComplete.Build())); } private static LiveSessionCharacterSelector MapCharacterSelector( diff --git a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs index 921e15ea..8dd3aff9 100644 --- a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs @@ -61,6 +61,7 @@ internal sealed class RuntimeFirstEntryDriveController private bool _driving; /// C3c-R1 review F6: see . private object? _routeOwner; + private Action? _localPlayerCompleted; internal RuntimeFirstEntryDriveController( RuntimeEntityObjectLifetime entityObjects, @@ -155,7 +156,9 @@ internal sealed class RuntimeFirstEntryDriveController /// before the prior route detached would otherwise let the OLD route's /// dispose wipe the NEW route's tracked entries. /// - internal void AttachRoute(object route) + internal void AttachRoute( + object route, + Action? localPlayerCompleted = null) { ArgumentNullException.ThrowIfNull(route); if (_routeOwner is not null && !ReferenceEquals(_routeOwner, route)) @@ -166,6 +169,7 @@ internal sealed class RuntimeFirstEntryDriveController + "precedes a new route) before a replacement attaches."); } _routeOwner = route; + _localPlayerCompleted = localPlayerCompleted; } /// @@ -181,6 +185,7 @@ internal sealed class RuntimeFirstEntryDriveController if (!ReferenceEquals(_routeOwner, route)) return; _routeOwner = null; + _localPlayerCompleted = null; _pending.Clear(); } @@ -198,6 +203,7 @@ internal sealed class RuntimeFirstEntryDriveController bool terminal; bool awaitingContinuationPlacement; + bool localPlayerCompleted = false; if (pending.IsLocalPlayer) { RuntimeLocalPlayerFirstEntryStatus status = @@ -214,6 +220,8 @@ internal sealed class RuntimeFirstEntryDriveController is RuntimeLocalPlayerFirstEntryStatus.Completed or RuntimeLocalPlayerFirstEntryStatus.RejectedToken or RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + localPlayerCompleted = status + is RuntimeLocalPlayerFirstEntryStatus.Completed; awaitingContinuationPlacement = status is RuntimeLocalPlayerFirstEntryStatus .AwaitingContinuationPlacement; @@ -241,6 +249,8 @@ internal sealed class RuntimeFirstEntryDriveController if (terminal) { _pending.Remove(key); + if (localPlayerCompleted) + _localPlayerCompleted?.Invoke(pending.Record); return; } if (!awaitingContinuationPlacement) diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 55140db2..bf7123d2 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -40,6 +40,7 @@ public sealed class RuntimeLiveEntitySessionController private readonly IRuntimeDirectWorldProjection? _worldProjection; private readonly LocalPlayerOutboundController _localPlayerOutbound = new((_, _, _, _, _, _) => { }); + private bool _initialLoginCompleteSent; public RuntimeLiveEntitySessionController( GameRuntime runtime, @@ -111,6 +112,17 @@ public sealed class RuntimeLiveEntitySessionController canonical, canonical.ServerGuid == _runtime.PlayerIdentity.ServerGuid); + if (_worldProjection is null + && canonical.ServerGuid + == _runtime.PlayerIdentity.ServerGuid + && !_initialLoginCompleteSent) + { + // A content-less direct host has no first-entry placement + // conductor. Its accepted local Create is therefore its + // truthful terminal admission edge. + _initialLoginCompleteSent = true; + _session.SendGameAction(GameActionLoginComplete.Build()); + } } } diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 647f1ee8..123e68ac 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -165,6 +165,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests Spawn(playerGuid, incarnation: 1); sink.Spawned(spawn); + Assert.Single(gameActions); + Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]); + sink.Spawned(spawn); + Assert.Single(gameActions); + gameActions.Clear(); + sink.TeleportStarted(1u); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( playerGuid, @@ -182,6 +188,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests ForcePositionSequence: 0)); Assert.Single(gameActions); + Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]); Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); Assert.True(runtime.Portal.Snapshot.Completed); Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell); @@ -278,6 +285,41 @@ public sealed class RuntimeLiveEntitySessionControllerTests drive.DetachRoute(routeB); } + [Fact] + public void FirstEntryDriveSignalsLocalCompletionOnceAfterCanonicalPlacement() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + const uint playerGuid = 0x50000003u; + runtime.PlayerIdentity.ServerGuid = playerGuid; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + var route = new object(); + int completed = 0; + drive.AttachRoute(route, record => + { + Assert.Equal(playerGuid, record.ServerGuid); + completed++; + }); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + + sink.Spawned(Spawn(playerGuid, incarnation: 1)); + Assert.Equal(0, completed); + + DrainFirstEntry(runtime, drive); + + Assert.Equal(1, completed); + Assert.Equal(0, drive.PendingCount); + drive.DetachRoute(route); + } + /// /// C3c: initial-residence admission requires a live session generation /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct @@ -372,6 +414,18 @@ public sealed class RuntimeLiveEntitySessionControllerTests Height: 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + private static void DrainFirstEntry( + GameRuntime runtime, + RuntimeFirstEntryDriveController drive) + { + for (int attempt = 0; attempt < 8 && drive.PendingCount != 0; attempt++) + { + drive.DriveAll(); + DrainPlacementFifo(runtime); + } + Assert.Equal(0, drive.PendingCount); + } + /// /// Drains/acknowledges every still-pending placement receipt (the /// ExecutorCompleted correlation is reaped by its acknowledgement) the