diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9a169623..ddb9b279 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -48,11 +48,27 @@ Copy this block when adding a new issue: ## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles -**Status:** OPEN +**Status:** FIX LANDED — pending visual gate (login into the 0x0007 dungeon → FPS steady in ~1–2 s, no neighbour load/unload churn) **Severity:** LOW (startup-only; self-corrects) **Filed:** 2026-06-14 **Component:** streaming — first-frame bootstrap vs the dungeon collapse +**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED +dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window. +- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse` + early (idempotent), so the expensive ocean-grid neighbour window is never enqueued + (teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login). +- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag + (the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn + interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids. +- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport). +- Observer robustness: during a teleport `PortalSpace` hold the observer follows the + recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now + filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift + the login-hold observer off the dungeon and trip `ExitDungeonExpand`. +Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in +`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering). + **Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped up; when logging in I get like 10 then it slowly increases." diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 79d30650..080c4d01 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -130,7 +130,7 @@ accepted-divergence entries (#96, #49, #50). | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | -| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock | `src/AcDream.App/Rendering/GameWindow.cs:6895` (predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | +| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e45101dc..d4729ab1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 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(cellId); + return envCell is not null + && !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside); + } + private void EnterPlayerModeFromAutoEntry() { _playerMode = true; diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 9a357cbb..d6d00518 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -149,6 +149,37 @@ public sealed class StreamingController DrainAndApply(); } + /// + /// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first + /// 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. + /// + /// The per-frame insideDungeon gate keys on the physics + /// CurrCell, 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 + /// 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. + /// + /// 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 _clearPendingLoads 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 . + /// + public void PreCollapseToDungeon(int cx, int cy) + { + uint centerId = StreamingRegion.EncodeLandblockId(cx, cy); + if (_collapsed && _collapsedCenter == centerId) return; + EnterDungeonCollapse(cx, cy, centerId); + } + /// /// Outdoor / building-interior streaming — the original two-tier model. /// diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index fd99fe30..522a4d07 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -147,6 +147,100 @@ public class StreamingControllerDungeonGateTests Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window } + [Fact] + public void PreCollapse_BeforeAnyTick_LoadsOnlyDungeon_NeverBootstrapsWindow() + { + // #135: at a dungeon login/teleport we pre-collapse the instant we recenter, + // BEFORE the first Tick. The full 25×25 neighbor window must NEVER be enqueued + // — only the single dungeon landblock loads. + var h = Make(); // empty state — nothing resident, _region is null + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Single(h.Loads); // exactly one load + Assert.Equal(Encode(0, 7), h.Loads[0].Id); // the dungeon landblock + Assert.Equal(LandblockStreamJobKind.LoadNear, h.Loads[0].Kind); + Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + } + + [Fact] + public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon() + { + // The REAL runtime ordering at a dungeon login: the per-frame streaming Tick + // runs FIRST and bootstraps the full 25×25 window, THEN the spawn handler fires + // PreCollapseToDungeon. The pre-collapse must cancel the queued window loads + // (_clearPendingLoads) and unload any neighbor that already finished streaming. + var h = Make(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // frame 1: NormalTick bootstraps the window + Assert.True(h.Loads.Count > 1); // the full window was enqueued + + // Simulate neighbor landblocks that finished loading during the bootstrap, + // before the collapse edge. + h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock itself + h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon that loaded + h.State.AddLandblock(MakeLb(1, 7)); // another neighbor + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Equal(1, h.ClearCalls()); // queued window loads cancelled + Assert.Contains(Encode(0, 8), h.Unloads); // resident neighbor unloaded + Assert.Contains(Encode(1, 7), h.Unloads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); // dungeon landblock kept + } + + [Fact] + public void PreCollapse_ThenHoldTicksWithStaleObserver_StaysCollapsed() + { + // After pre-collapse the player is held (CurrCell still null → insideDungeon + // false) while the dungeon hydrates. A stale observer that is the SAME dungeon + // landblock must keep streaming collapsed — no full-window reload. + var h = Make(); + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: false); // hold frame: not placed yet + + Assert.Empty(h.Loads); // no neighbor window + Assert.Empty(h.Unloads); + } + + [Fact] + public void PreCollapse_IsIdempotent_OnSameLandblock() + { + // A re-sent player spawn / a same-frame double call must not re-clear or + // re-enqueue. + var h = Make(); + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.Equal(1, h.ClearCalls()); // clear fired only on the first collapse + Assert.Empty(h.Loads); // no second dungeon load + } + + [Fact] + public void PreCollapse_ThenPlaced_InsideDungeonTick_StaysCollapsed() + { + // When placement finally fires, the per-frame Tick(insideDungeon: true) sees + // the same collapsed landblock and holds — no re-collapse churn. + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); // dungeon landblock finished loading + h.Ctrl.PreCollapseToDungeon(0, 7); + h.Loads.Clear(); + h.Unloads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: true); // placed: gate now fires + + Assert.Equal(1, h.ClearCalls()); // no second clear + Assert.Empty(h.Loads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); + } + [Fact] public void NormalOutdoorTick_Unchanged_NoCollapseNoClear() {