fix(G.3): pre-collapse dungeon streaming at login/teleport — kill the login FPS ramp (#135)
On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s. Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to the player's single dungeon landblock — AC dungeons have no neighbours) only fires once the per-frame `insideDungeon` gate reads true, and that gate keys on the physics CurrCell, which isn't set until the player is PLACED, which waits for the dungeon landblock to hydrate. So during the whole hydration window NormalTick bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their ~19k entities each — and the collapse only mops them up afterward. That mop-up is the ramp. Fix: trigger the SAME collapse early, the instant we recenter the streaming center onto a sealed dungeon cell, before the first NormalTick. - StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse early (idempotent). The expensive neighbour window is never enqueued. - GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the per-frame gate use, so the early decision matches the eventual one. Distinguishes a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id can't type-confuse a LandBlock record as an EnvCell. - Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport). - Observer robustness: during a teleport PortalSpace hold the streaming observer follows the recentered destination, not the frozen pre-teleport position (which could drift >=2 landblocks off and trip ExitDungeonExpand). And _lastLivePlayerLandblockId is now filtered to the player guid (resolves the Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer off the dungeon. Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new workaround — AP-36 amended in the same commit. Adversarially reviewed across timing / threading / faithfulness lenses; 5 new tests including the real runtime ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a100bc37a7
commit
712f17f0f2
5 changed files with 231 additions and 7 deletions
|
|
@ -2471,6 +2471,23 @@ public sealed class GameWindow : IDisposable
|
|||
_liveCenterY = lbY;
|
||||
}
|
||||
|
||||
// #135: the instant we know the player spawned into a SEALED dungeon,
|
||||
// pre-collapse streaming to that single landblock — BEFORE the first
|
||||
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
|
||||
// player isn't placed yet (physics CurrCell is null), so the per-frame
|
||||
// insideDungeon gate stays false for the entire hydration window and
|
||||
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
|
||||
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
|
||||
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
|
||||
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
|
||||
// (reentrant); the controller call is render-thread-safe (Channel writes).
|
||||
if (spawn.Guid == _playerServerGuid
|
||||
&& _streamingController is not null
|
||||
&& IsSealedDungeonCell(p.LandblockId))
|
||||
{
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
}
|
||||
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
|
|
@ -4484,10 +4501,18 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
// Phase A.1: track the most recently updated entity's landblock so the
|
||||
// streaming controller can follow the player. TODO: filter by our own
|
||||
// character guid once we reliably know it from CharacterList.
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
|
||||
// streaming controller can follow the player in the fly-camera / pre-player-mode
|
||||
// (login hold) views. Filtered to our OWN character guid — resolving the original
|
||||
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
|
||||
// must NOT move the streaming observer: during a dungeon-login hold (player not
|
||||
// yet placed, so _playerController is null and the PortalSpace observer branch
|
||||
// can't apply) that would drift the observer off the pre-collapsed dungeon
|
||||
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
|
||||
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
|
||||
// (~line 1984) before world entry, so it is valid by the time updates arrive.
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
|
|
@ -4936,6 +4961,15 @@ public sealed class GameWindow : IDisposable
|
|||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||
|
||||
// #135: pre-collapse on teleport into a sealed dungeon too — same
|
||||
// race as login. The destination isn't placed until it hydrates, so
|
||||
// without this NormalTick loads the full neighbor window during the
|
||||
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
|
||||
// observer pinned to _liveCenterX/Y while held, so the stale frozen
|
||||
// player position can't drift the observer off the dungeon and re-expand.
|
||||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -6863,7 +6897,27 @@ public sealed class GameWindow : IDisposable
|
|||
int observerCx = _liveCenterX;
|
||||
int observerCy = _liveCenterY;
|
||||
|
||||
if (_playerMode && _playerController is not null)
|
||||
if (_playerMode && _playerController is not null
|
||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
|
||||
{
|
||||
// Teleport hold (#135): the local player position is frozen at the
|
||||
// PRE-teleport spot, expressed in the OLD center frame, but
|
||||
// _liveCenterX/_liveCenterY were already recentered onto the
|
||||
// destination landblock (OnLivePositionUpdated). Follow the
|
||||
// destination directly — the stale position-derived offset
|
||||
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
|
||||
// the dungeon and trip ExitDungeonExpand, re-streaming the very
|
||||
// neighbor window the pre-collapse just suppressed. Correct for an
|
||||
// outdoor teleport too: pre-load the destination during the hold.
|
||||
//
|
||||
// NOTE: these assignments equal the observerCx/Cy defaults initialized
|
||||
// above — the LOAD-BEARING effect of this branch is INHIBITING the
|
||||
// position-derived offset in the else-if below while the player position
|
||||
// is frozen, not the (redundant) assignment. Kept explicit for clarity.
|
||||
observerCx = _liveCenterX;
|
||||
observerCy = _liveCenterY;
|
||||
}
|
||||
else if (_playerMode && _playerController is not null)
|
||||
{
|
||||
// Player mode: follow the physics-resolved player position.
|
||||
// The player walks via the local physics engine; the server
|
||||
|
|
@ -11894,6 +11948,35 @@ public sealed class GameWindow : IDisposable
|
|||
return unhydratable;
|
||||
}
|
||||
|
||||
// #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
|
||||
// (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
|
||||
// a real dungeon (collapse streaming to its single landblock) from a building
|
||||
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
|
||||
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
|
||||
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
|
||||
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
|
||||
// decision matches the eventual gate decision exactly. Returns false when the dat
|
||||
// lacks the cell (out-of-range index / missing record) so we never collapse on a
|
||||
// guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
|
||||
// already hold it (the login spawn handler does).
|
||||
private bool IsSealedDungeonCell(uint cellId)
|
||||
{
|
||||
// Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
|
||||
// structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
|
||||
// `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get<EnvCell> on
|
||||
// 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as
|
||||
// an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport
|
||||
// position never carries a shell id, but exclude them so the read is sound.
|
||||
uint low = cellId & 0xFFFFu;
|
||||
if (low < 0x0100u || low >= 0xFFFEu) return false;
|
||||
if (_dats is null) return false;
|
||||
DatReaderWriter.DBObjs.EnvCell? envCell;
|
||||
lock (_datLock)
|
||||
envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(cellId);
|
||||
return envCell is not null
|
||||
&& !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
|
||||
}
|
||||
|
||||
private void EnterPlayerModeFromAutoEntry()
|
||||
{
|
||||
_playerMode = true;
|
||||
|
|
|
|||
|
|
@ -149,6 +149,37 @@ public sealed class StreamingController
|
|||
DrainAndApply();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
|
||||
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
|
||||
/// from the login / teleport spawn path the instant the streaming center is
|
||||
/// recentered onto a SEALED dungeon landblock.
|
||||
///
|
||||
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
|
||||
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
|
||||
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
|
||||
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
|
||||
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
|
||||
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
|
||||
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
|
||||
///
|
||||
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
|
||||
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
|
||||
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
|
||||
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
|
||||
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
|
||||
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
|
||||
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
|
||||
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
|
||||
/// same as <see cref="Tick"/>.</para>
|
||||
/// </summary>
|
||||
public void PreCollapseToDungeon(int cx, int cy)
|
||||
{
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
|
||||
if (_collapsed && _collapsedCenter == centerId) return;
|
||||
EnterDungeonCollapse(cx, cy, centerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor / building-interior streaming — the original two-tier model.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue