fix(session): acknowledge login after first placement

ACE intentionally creates the local player Hidden and releases that materialization state on LoginComplete. Sending LoginComplete from raw F746 receipt raced canonical placement and left the login haze visible. Route one one-shot completion callback from Runtime's local first-entry terminal edge to graphical and prepared headless hosts; retain a guarded accepted-Create edge only for content-less headless sessions. Focused Runtime login tests, all 79 Headless tests, the connected user gate, and the Release build pass.
This commit is contained in:
Erik 2026-08-03 12:10:42 +02:00
parent f24532adf3
commit 175ad6b0d0
10 changed files with 107 additions and 48 deletions

View file

@ -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 |

View file

@ -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<RuntimeGenerationToken> _generation;
private readonly RuntimePlacementProjectionRetrySlot _retries;
private readonly RuntimeFirstEntryDriveController? _firstEntry;
private readonly Action<RuntimeEntityRecord>? _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<RuntimeEntityRecord>? 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<RuntimePlacementProjectionSubscription> createSubscription,
Func<RuntimeGenerationToken> generation,
RuntimePlacementProjectionRetrySlot retries,
RuntimeFirstEntryDriveController? firstEntry = null)
RuntimeFirstEntryDriveController? firstEntry = null,
Action<RuntimeEntityRecord>? 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;

View file

@ -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(

View file

@ -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
/// <c>TeleportAnimSequencer.FireLoginComplete</c>. 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.
/// </para>
/// </summary>
public static class GameActionLoginComplete

View file

@ -555,16 +555,6 @@ public sealed class WorldSession : IDisposable
/// </summary>
public double LastServerTimeTicks { get; private set; }
/// <summary>
/// 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).
/// </summary>
public void ResetLoginComplete() => _loginCompleteSent = false;
/// <summary>Raised every time the state machine transitions.</summary>
public event Action<State>? StateChanged;
@ -771,14 +761,6 @@ public sealed class WorldSession : IDisposable
Buffer.AsMemory(0, Length);
}
/// <summary>
/// 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).
/// </summary>
private bool _loginCompleteSent;
/// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
/// emits the first SetState's body bytes only, not 510/sec.</summary>
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);

View file

@ -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<RuntimeEntityRecord>? _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<RuntimeEntityRecord>? 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,

View file

@ -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(

View file

@ -61,6 +61,7 @@ internal sealed class RuntimeFirstEntryDriveController
private bool _driving;
/// <summary>C3c-R1 review F6: see <see cref="AttachRoute"/>.</summary>
private object? _routeOwner;
private Action<RuntimeEntityRecord>? _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.
/// </summary>
internal void AttachRoute(object route)
internal void AttachRoute(
object route,
Action<RuntimeEntityRecord>? 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;
}
/// <summary>
@ -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)

View file

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

View file

@ -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);
}
/// <summary>
/// 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);
}
/// <summary>
/// Drains/acknowledges every still-pending placement receipt (the
/// ExecutorCompleted correlation is reaped by its acknowledgement) the