fix(streaming): block login on complete world reveal

Make initial login and portal arrival consume one WorldRevealReadinessBarrier that joins near-tier mesh publication, destination composite uploads, and collision residency before normal world geometry becomes visible. This ports retail SmartBox's blocking-cell completion edge into the asynchronous client instead of exposing a ground-only login.

Add focused outdoor, indoor, texture, and invalid-claim tests; update the retail pseudocode, architecture, divergence record, issue ledger, roadmap baseline, and synchronized agent guidance.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-20 16:08:03 +02:00
parent 6c3bd4ce4b
commit b60cb67009
14 changed files with 360 additions and 102 deletions

View file

@ -11,8 +11,8 @@ namespace AcDream.App.Input;
/// Why is this its own class? The auto-entry has four independent
/// preconditions (live session reaches <c>InWorld</c>, the player
/// entity has been streamed into the world dictionary, the player
/// movement controller is constructible, and the terrain under the
/// spawn position has streamed in) plus a manual-override path
/// movement controller is constructible, and the initial world is fully
/// drawable and collidable) plus a manual-override path
/// (the user can flip into fly mode before the auto-entry fires —
/// their choice wins). All five interact with each other in a way
/// that's painful to test through GameWindow but trivial here against
@ -44,7 +44,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func<bool> _isLiveInWorld;
private readonly Func<bool> _isPlayerEntityPresent;
private readonly Func<bool> _isPlayerControllerReady;
private readonly Func<bool> _isSpawnGroundReady;
private readonly Func<bool> _isWorldReady;
private readonly Action _enterPlayerMode;
private bool _armed;
@ -62,12 +62,10 @@ public sealed class PlayerModeAutoEntry
/// PlayerMovementController is set up. Stays true once player mode
/// is established; the auto-entry's job is to flip it from false
/// to true exactly once.</param>
/// <param name="isSpawnGroundReady">True iff the terrain under the
/// player's spawn position has streamed into the physics engine.
/// #106 gate-2 (2026-06-09): entering player mode earlier integrates
/// gravity against an empty world and free-falls the player into the
/// void. Retail never has this state — it loads cells synchronously;
/// this hold is the async-streaming equivalent of that invariant.</param>
/// <param name="isWorldReady">True iff the player's destination is
/// complete across render publication, composite textures, and collision.
/// Retail keeps position completion behind one blocking cell-load edge;
/// acdream's asynchronous domains must converge before entry.</param>
/// <param name="enterPlayerMode">Action invoked on the firing
/// tick. The same routine the manual Tab handler invokes (fly →
/// player transition). Must construct the controller + chase
@ -77,13 +75,13 @@ public sealed class PlayerModeAutoEntry
Func<bool> isLiveInWorld,
Func<bool> isPlayerEntityPresent,
Func<bool> isPlayerControllerReady,
Func<bool> isSpawnGroundReady,
Func<bool> isWorldReady,
Action enterPlayerMode)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
_isSpawnGroundReady = isSpawnGroundReady ?? throw new ArgumentNullException(nameof(isSpawnGroundReady));
_isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
}
@ -117,7 +115,7 @@ public sealed class PlayerModeAutoEntry
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;
if (!_isSpawnGroundReady()) return false;
if (!_isWorldReady()) return false;
_armed = false;
_enterPlayerMode();

View file

@ -147,6 +147,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.WorldRevealReadinessBarrier? _worldRevealReadiness;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25)
@ -1413,41 +1414,11 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Net.WorldSession.State.InWorld,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true,
// #106 gate-2: hold player-mode entry until the terrain under
// the spawn position has streamed in — entering earlier
// integrates gravity against an empty world and free-falls
// the player into the void (retail loads cells synchronously;
// this is the async-streaming equivalent of that invariant).
isSpawnGroundReady: () =>
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
// #107 / #135: spawn-ground readiness is spawn-claim aware. For an
// INDOOR claim (sealed dungeon / building interior) the ground the
// player lands on is the EnvCell FLOOR (its BSP), so gate on the
// cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
// A dungeon's cells sit in their landblock at an arbitrary (often
// negative) offset, so the spawn's WORLD position can fall in a
// NEIGHBOUR terrain landblock that the #135 dungeon collapse
// deliberately does not load; requiring terrain there hangs login
// forever (cellReady true, SampleTerrainZ null). Retail loads the
// cell synchronously and places the player on the cell floor —
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
// (Before #135 this only passed by accident: the 25×25 window
// happened to stream the neighbour terrain.)
if (LastSpawns.TryGetValue(_playerServerGuid, out var sp)
&& sp.Position is { } spawnClaim
&& spawnClaim.LandblockId != 0
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
&& !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
// Outdoor spawn, OR an unhydratable indoor claim that will demote to
// an outdoor position: hold until the terrain under the spawn streams
// (the original #106 gate — entering against an empty world free-falls
// the player into the void).
return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
},
// Retail SmartBox::UseTime (0x00455410) completes the player
// position only after CellManager's blocking load clears. The
// shared acdream barrier joins collision, static-mesh upload,
// and composite-texture readiness for both login and portals.
isWorldReady: LoginWorldReady,
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@ -2873,6 +2844,20 @@ public sealed class GameWindow : IDisposable
retirementCoordinator: _landblockRetirements);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
_worldRevealReadiness = new AcDream.App.Streaming.WorldRevealReadinessBarrier(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true,
prepareCompositeTextures: (destinationCell, radius) =>
_wbDrawDispatcher?.PrepareCompositeTextures(
_worldState.Entities,
_worldState.FlatViewGeneration,
destinationCell,
radius),
invalidateCompositeTextures: () =>
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable);
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
@ -3624,6 +3609,13 @@ public sealed class GameWindow : IDisposable
isSealedDungeon: IsSealedDungeonCell(position.LandblockId));
}
// Retail SmartBox::UseTime (0x00455410) does not complete the player
// position while CellManager is blocking for destination cells. Begin
// the same shared reveal lifetime used by portal arrivals: initial
// login must not inherit the dispatcher's default/previous composite
// readiness and expose a terrain-only or partially uploaded world.
_worldRevealReadiness?.Begin();
// Streaming is normally gated until this point, so the next loaded-
// landblock callback will materialize deferred records. Also cover an
// already-resident block (tests, reconnect teardown overlap) without
@ -7465,7 +7457,8 @@ public sealed class GameWindow : IDisposable
// standing on loaded ground with wall collision and a non-empty immediate view. The far
// ring (out to the streaming window) still drains at the per-frame budget after portal
// space exits. Bigger = a more complete arrival but a longer portal-space hold.
private const int TeleportNearRingRadius = 1;
private const int TeleportNearRingRadius =
AcDream.App.Streaming.WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
/// <summary>
/// Bind a sequence-correlated teleport Position to presentation and
@ -7544,7 +7537,7 @@ public sealed class GameWindow : IDisposable
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness();
_worldRevealReadiness?.Begin();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
@ -7615,18 +7608,24 @@ public sealed class GameWindow : IDisposable
/// loudly rather than holding forever.
/// </summary>
private bool TeleportWorldReady(uint destCell)
=> _worldRevealReadiness?.IsReady(destCell) == true;
private bool LoginWorldReady()
{
if (IsSpawnClaimUnhydratable(destCell)) return true;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
destCell,
renderRadius) == true;
renderReady &= _wbDrawDispatcher?.CompositeTexturesReady == true;
return indoor
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
: renderReady
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
return TryGetLoginWorldCell(out uint cell)
&& _worldRevealReadiness?.IsReady(cell) == true;
}
private bool TryGetLoginWorldCell(out uint cell)
{
cell = 0;
if (_liveEntities is null
|| !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn)
|| playerSpawn.Position is not { } position)
return false;
cell = position.LandblockId;
return cell != 0;
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
@ -10113,24 +10112,13 @@ public sealed class GameWindow : IDisposable
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
{
_wbMeshAdapter?.Tick();
if (_teleportTransit.IsActive && _pendingTeleportCell != 0)
{
int compositeRadius = (_pendingTeleportCell & 0xFFFFu) >= 0x0100u
? 0
: TeleportNearRingRadius;
bool destinationMeshesPublished =
_streamingController?.IsRenderNeighborhoodResident(
_pendingTeleportCell,
compositeRadius) == true;
if (destinationMeshesPublished)
{
_wbDrawDispatcher?.PrepareCompositeTextures(
_worldState.Entities,
_worldState.FlatViewGeneration,
_pendingTeleportCell,
compositeRadius);
}
}
uint revealCell = _teleportTransit.IsActive
? _pendingTeleportCell
: IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
? loginCell
: 0u;
if (revealCell != 0)
_worldRevealReadiness?.Prepare(revealCell);
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
}
if (_frameDiag)

View file

@ -0,0 +1,96 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Joins the render-publication, texture, and collision domains that must be
/// complete before the normal world viewport is revealed. Retail keeps
/// <c>SmartBox::position_update_complete</c> false while
/// <c>CellManager::blocking_for_cells</c> is set
/// (<c>SmartBox::UseTime</c>, 0x00455410). acdream loads those domains
/// asynchronously, so login and portal arrival must share this explicit
/// equivalent of retail's single blocking-cell edge.
/// </summary>
internal sealed class WorldRevealReadinessBarrier
{
internal const int OutdoorNeighborhoodRadius = 1;
private readonly Func<uint, int, bool> _isRenderNeighborhoodReady;
private readonly Func<uint, bool> _isSpawnCellReady;
private readonly Func<uint, int, bool> _isTerrainNeighborhoodReady;
private readonly Func<bool> _areCompositeTexturesReady;
private readonly Action<uint, int> _prepareCompositeTextures;
private readonly Action _invalidateCompositeTextures;
private readonly Func<uint, bool> _isSpawnClaimUnhydratable;
public WorldRevealReadinessBarrier(
Func<uint, int, bool> isRenderNeighborhoodReady,
Func<uint, bool> isSpawnCellReady,
Func<uint, int, bool> isTerrainNeighborhoodReady,
Func<bool> areCompositeTexturesReady,
Action<uint, int> prepareCompositeTextures,
Action invalidateCompositeTextures,
Func<uint, bool> isSpawnClaimUnhydratable)
{
_isRenderNeighborhoodReady = isRenderNeighborhoodReady
?? throw new ArgumentNullException(nameof(isRenderNeighborhoodReady));
_isSpawnCellReady = isSpawnCellReady
?? throw new ArgumentNullException(nameof(isSpawnCellReady));
_isTerrainNeighborhoodReady = isTerrainNeighborhoodReady
?? throw new ArgumentNullException(nameof(isTerrainNeighborhoodReady));
_areCompositeTexturesReady = areCompositeTexturesReady
?? throw new ArgumentNullException(nameof(areCompositeTexturesReady));
_prepareCompositeTextures = prepareCompositeTextures
?? throw new ArgumentNullException(nameof(prepareCompositeTextures));
_invalidateCompositeTextures = invalidateCompositeTextures
?? throw new ArgumentNullException(nameof(invalidateCompositeTextures));
_isSpawnClaimUnhydratable = isSpawnClaimUnhydratable
?? throw new ArgumentNullException(nameof(isSpawnClaimUnhydratable));
}
/// <summary>
/// Starts a new reveal lifetime. Texture readiness is destination-scoped;
/// carrying the prior login/portal result across a new destination would
/// open the barrier before that destination's composites were uploaded.
/// </summary>
public void Begin() => _invalidateCompositeTextures();
/// <summary>
/// Advances render-thread texture preparation after all required static
/// meshes for the destination neighborhood have been published.
/// </summary>
public void Prepare(uint destinationCell)
{
if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell))
return;
int radius = RequiredRenderRadius(destinationCell);
if (_isRenderNeighborhoodReady(destinationCell, radius))
_prepareCompositeTextures(destinationCell, radius);
}
/// <summary>
/// True only when the same destination is drawable and collidable. An
/// impossible indoor claim intentionally crosses the barrier so the
/// existing loud forced-placement path can expose the invalid server data.
/// </summary>
public bool IsReady(uint destinationCell)
{
if (destinationCell == 0)
return false;
if (_isSpawnClaimUnhydratable(destinationCell))
return true;
int radius = RequiredRenderRadius(destinationCell);
if (!_isRenderNeighborhoodReady(destinationCell, radius)
|| !_areCompositeTexturesReady())
return false;
return IsIndoor(destinationCell)
? _isSpawnCellReady(destinationCell)
: _isTerrainNeighborhoodReady(destinationCell, radius);
}
private static int RequiredRenderRadius(uint destinationCell) =>
IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius;
private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u;
}