diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 90c82257..79d30650 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 35 rows +## 3. Documented approximation (AP) — 36 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -130,6 +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-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 b3e5efa0..0e992fa2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1914,6 +1914,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities @@ -6882,7 +6883,20 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); } - _streamingController.Tick(observerCx, observerCy); + // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell + // (indoor cell that doesn't see outside — the same predicate that kills + // the sun/sky, playerInsideCell below), collapse streaming to the single + // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25 + // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building + // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated + // and keep their surrounding terrain. + // Mirrors the playerInsideCell computation below (CurrCell → registry + // LoadedCell.SeenOutside): true only for a sealed indoor cell. + bool insideDungeon = + _physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv + && _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg) + && pcReg is { SeenOutside: false }; + _streamingController.Tick(observerCx, observerCy, insideDungeon); // Re-inject persistent entities rescued from unloaded landblocks // into the current center landblock (the one the observer is in). @@ -8418,6 +8432,11 @@ public sealed class GameWindow : IDisposable } _lastFps = fps; _lastFrameMs = avgFrameTime; + // TEMP (A7 FPS measurement, strip after): headless FPS/frame-time so the + // launch log can be correlated against the [WB-DIAG] draw stats. + if (Environment.GetEnvironmentVariable("ACDREAM_LOG_FPS") == "1") + Console.WriteLine( + $"[FPS] {fps:F1} fps | {avgFrameTime:F2} ms | lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount} anim {animatedCount}"); _perfAccum = 0; _perfFrameCount = 0; } @@ -10589,6 +10608,7 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, + clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { if (_lightingSink is not null && diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index c5e36815..050c1265 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId) { public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); + + /// + /// Control job: drop every queued (not-yet-started) Load from the worker's + /// priority queues, keeping Unloads. Posted by + /// when the player enters a + /// dungeon and the in-flight outdoor/neighbor window load must be cancelled + /// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by + /// convention; readers pattern-match on the type. + /// + public sealed record ClearLoads() : LandblockStreamJob(0); } /// diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index 19b2a94b..ffaa6de7 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); } + /// + /// Cancel every queued-but-not-started Load. Posts a + /// control job which the worker + /// honours at read time, dropping all pending Loads from both priority + /// queues (Unloads survive). Used on the dungeon-entry edge to abort the + /// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never + /// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still + /// complete; the StreamingController's collapsed-sweep unloads those few. + /// + public void ClearPendingLoads() + { + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + _inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads()); + } + /// /// Drain up to completed results. /// Non-blocking. Call from the render thread once per OnUpdate. @@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable } while (_inbox.Reader.TryRead(out var job)) + { + if (job is LandblockStreamJob.ClearLoads) + { + // Dungeon-entry cancellation: drop every queued Load, + // keep Unloads. Handled at read time so it supersedes + // Loads sitting in the priority queues ahead of it. + DropLoadJobs(highPriority); + DropLoadJobs(lowPriority); + continue; + } EnqueuePrioritized(job, highPriority, lowPriority); + } if (highPriority.Count == 0 && lowPriority.Count == 0) continue; @@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable lowPriority.Enqueue(job); } + /// + /// Drop every from a priority queue, + /// preserving Unloads (and any other control jobs). Rotates the queue once + /// in place. Used by the path. + /// + private static void DropLoadJobs(Queue queue) + { + int count = queue.Count; + for (int i = 0; i < count; i++) + { + var job = queue.Dequeue(); + if (job is not LandblockStreamJob.Load) + queue.Enqueue(job); + } + } + private static void RemoveLowPriorityJobsForLandblock( Queue queue, uint landblockId, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index f0bc0955..dfae63ef 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,9 +22,16 @@ public sealed class StreamingController private readonly Func> _drainCompletions; private readonly Action _applyTerrain; private readonly Action? _removeTerrain; + private readonly Action? _clearPendingLoads; private readonly GpuWorldState _state; private StreamingRegion? _region; + // True while streaming is collapsed to the single dungeon landblock the + // player stands in (the dungeon gate, #133 FPS). AC dungeons have NO + // adjacent landblocks — neighbors are unrelated ocean-grid dungeons that + // are never visible, so we stop loading the 25×25 window entirely. + private bool _collapsed; + /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. @@ -71,13 +78,15 @@ public sealed class StreamingController GpuWorldState state, int nearRadius, int farRadius, - Action? removeTerrain = null) + Action? removeTerrain = null, + Action? clearPendingLoads = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; + _clearPendingLoads = clearPendingLoads; _state = state; NearRadius = nearRadius; FarRadius = farRadius; @@ -97,7 +106,32 @@ public sealed class StreamingController /// → enqueue full unload /// /// - public void Tick(int observerCx, int observerCy) + public void Tick(int observerCx, int observerCy, bool insideDungeon = false) + { + uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); + + if (insideDungeon) + { + if (!_collapsed) + EnterDungeonCollapse(observerCx, observerCy, centerId); + else + SweepCollapsed(centerId); + } + else + { + if (_collapsed) + ExitDungeonExpand(observerCx, observerCy); + else + NormalTick(observerCx, observerCy); + } + + DrainAndApply(); + } + + /// + /// Outdoor / building-interior streaming — the original two-tier model. + /// + private void NormalTick(int observerCx, int observerCy) { if (_region is null) { @@ -116,9 +150,77 @@ public sealed class StreamingController foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } + } - // Drain up to N completions per frame so a big diff doesn't spike - // GPU upload time. Remaining completions wait for the next frame. + /// + /// Dungeon-entry edge: cancel the in-flight window load, unload every + /// resident neighbor, and pin streaming to the player's single dungeon + /// landblock. Retail-faithful — AC dungeons have no adjacent landblocks + /// (ACE LandblockManager.GetAdjacentIDs returns empty for a dungeon); + /// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and + /// their thousands of emitters (#133 FPS). Unloading them also tears down + /// their lights, shrinking the static-light set toward retail's ≤40. + /// + private void EnterDungeonCollapse(int cx, int cy, uint centerId) + { + _collapsed = true; + _clearPendingLoads?.Invoke(); + + foreach (var id in _state.LoadedLandblockIds) + if (id != centerId) _enqueueUnload(id); + + // Pin a radius-0 region so RecenterTo never re-expands while inside, + // and so the post-exit rebuild starts from a clean, consistent state. + _region = new StreamingRegion(cx, cy, 0, 0); + _region.MarkResidentFromBootstrap(); + + // The dungeon landblock itself must be (or become) loaded. If a prior + // ClearPendingLoads cancelled its queued load, re-enqueue it. + if (!_state.IsLoaded(centerId)) + _enqueueLoad(centerId, LandblockStreamJobKind.LoadNear); + } + + /// + /// While collapsed, unload any landblock that finished loading after the + /// collapse edge — a Load the worker had already dequeued before the + /// control job took + /// effect. At steady state only the dungeon landblock is resident, so this + /// is a no-op. + /// + private void SweepCollapsed(uint centerId) + { + foreach (var id in _state.LoadedLandblockIds) + if (id != centerId) _enqueueUnload(id); + } + + /// + /// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full + /// two-tier window at the new center and unload anything resident from the + /// collapsed state that falls outside it. + /// + private void ExitDungeonExpand(int observerCx, int observerCy) + { + _collapsed = false; + var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); + + foreach (var id in _state.LoadedLandblockIds) + if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id); + + var boot = rebuilt.ComputeFirstTickDiff(); + foreach (var id in boot.ToLoadNear) + if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + foreach (var id in boot.ToLoadFar) + if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + rebuilt.MarkResidentFromBootstrap(); + _region = rebuilt; + } + + /// + /// Drain up to N completions per frame so a big diff doesn't spike GPU + /// upload time. Remaining completions wait for the next frame. + /// + private void DrainAndApply() + { var drained = _drainCompletions(MaxCompletionsPerFrame); foreach (var result in drained) { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs new file mode 100644 index 00000000..ab4a4d62 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.Streaming; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +/// +/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent +/// landblocks (ACE LandblockManager.GetAdjacentIDs returns empty for a +/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window +/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player +/// is inside a sealed dungeon cell, Tick(insideDungeon: true) collapses +/// streaming to the single dungeon landblock and unloads the neighbors. +/// +public class StreamingControllerDungeonGateTests +{ + private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + + private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock( + Encode(x, y), + Heightmap: null!, + Entities: Array.Empty()); + + private sealed record Harness( + StreamingController Ctrl, + List<(uint Id, LandblockStreamJobKind Kind)> Loads, + List Unloads, + Func ClearCalls, + GpuWorldState State); + + private static Harness Make() + { + var loads = new List<(uint, LandblockStreamJobKind)>(); + var unloads = new List(); + int clearCalls = 0; + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 4, + farRadius: 12, + clearPendingLoads: () => clearCalls++); + return new Harness(ctrl, loads, unloads, () => clearCalls, state); + } + + [Fact] + public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter() + { + var h = Make(); + uint center = Encode(0, 7); + h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock + h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon + h.State.AddLandblock(MakeLb(1, 7)); // another neighbor + + h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); + + Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled + Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded + Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded + Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept + Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload + } + + [Fact] + public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad() + { + var h = Make(); // empty state — the dungeon landblock isn't resident yet + + h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); + + Assert.Equal(1, h.ClearCalls()); + Assert.Contains(h.Loads, l => l.Id == Encode(0, 7) + && l.Kind == LandblockStreamJobKind.LoadNear); + } + + [Fact] + public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge + h.Unloads.Clear(); + + // A Load the worker had already dequeued before ClearLoads now completes. + h.State.AddLandblock(MakeLb(0, 8)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep + + Assert.Contains(Encode(0, 8), h.Unloads); + Assert.DoesNotContain(Encode(0, 7), h.Unloads); + } + + [Fact] + public void StayingCollapsed_DoesNotReClearOrReloadCenter() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1) + h.Loads.Clear(); + + h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed + + Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge + Assert.Empty(h.Loads); // no spurious center reloads + } + + [Fact] + public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock() + { + var h = Make(); + h.State.AddLandblock(MakeLb(0, 7)); + h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse + h.Loads.Clear(); + h.Unloads.Clear(); + + // Exit through a portal to an outdoor location far from the dungeon block. + h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false); + + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window + } + + [Fact] + public void NormalOutdoorTick_Unchanged_NoCollapseNoClear() + { + var h = Make(); + + h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false + + Assert.Equal(0, h.ClearCalls()); + Assert.Empty(h.Unloads); + // 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued. + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); + Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); + } +}