From 1029f8372e6fbb6c2e9571400d29ca6602103740 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:47:01 +0200 Subject: [PATCH] refactor(streaming): extract landblock render publisher --- src/AcDream.App/Rendering/GameWindow.cs | 169 +++----- .../Streaming/LandblockRenderPublisher.cs | 290 ++++++++++++++ .../Streaming/LandblockBuildOriginTests.cs | 12 +- .../LandblockRenderPublisherTests.cs | 365 ++++++++++++++++++ 4 files changed, 708 insertions(+), 128 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockRenderPublisher.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 21b41a7a..69d4cf12 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -151,6 +151,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; + private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; @@ -228,14 +229,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock. private System.Collections.Concurrent.ConcurrentDictionary? _surfaceCache; - // Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock - // id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain - // time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame - // indoor-cell scoping. Entries are removed in the removeTerrain callbacks. - // Only touched on the render thread — no lock required. - private readonly System.Collections.Generic.Dictionary - _buildingRegistries = new(); - // Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render // through this dedicated pipeline now, NOT through WbDrawDispatcher. // The dispatcher's IndoorPass still walks cell-static stabs (WorldEntity @@ -2419,6 +2412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer( _gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum); _envCellRenderer.Initialize(_meshShader!); + _landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher( + publishTerrain: (landblockId, meshData, origin) => + _terrain!.AddLandblockWithMesh(landblockId, meshData, origin), + removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId), + cellVisibility: _cellVisibility, + worldState: _worldState, + commitEnvCells: _envCellRenderer.CommitLandblock, + prepareEnvCells: build => + AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule( + build, + _wbMeshAdapter.MeshManager!), + removeEnvCells: _envCellRenderer.RemoveLandblock); _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( @@ -2531,7 +2536,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // objects it thinks we still know, so we re-project them ourselves. onLandblockLoaded: loadedLandblockId => _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), - ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin, + ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -3774,12 +3779,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - private void EnsureEnvCellMeshesAfterPin( - AcDream.App.Rendering.Wb.EnvCellLandblockBuild build) - { - if (_wbMeshAdapter?.MeshManager is { } meshManager) - AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager); - } private void AdvanceLandblockPresentationRetirement( AcDream.App.Streaming.LandblockRetirementTicket ticket) { @@ -3807,7 +3806,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.Terrain, - () => _terrain?.RemoveLandblock(ticket.LandblockId)); + () => _landblockRenderPublisher?.RemoveTerrain(ticket.LandblockId)); } ticket.RunOnce( @@ -3821,15 +3820,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.CellVisibility, - () => _cellVisibility.RemoveLandblock( - (ticket.LandblockId >> 16) & 0xFFFFu)); + () => _landblockRenderPublisher?.RemoveCellVisibility(ticket.LandblockId)); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry, - () => _buildingRegistries.Remove( - ticket.LandblockId & 0xFFFF0000u)); + () => _landblockRenderPublisher?.RemoveBuildingRegistry(ticket.LandblockId)); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells, - () => _envCellRenderer?.RemoveLandblock(ticket.LandblockId)); + () => _landblockRenderPublisher?.RemoveEnvironmentCells(ticket.LandblockId)); } /// @@ -3893,76 +3890,35 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Terrain.LandblockMeshData meshData) { var lb = build.Landblock; - // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker). - // _heightTable still needed for physics TerrainSurface below. - if (_terrain is null || _dats is null || _heightTable is null) return; + if (_landblockRenderPublisher is null || _dats is null || _heightTable is null) + return; - // datLock fix: every dat the apply needs was pre-read by the worker into - // lb.PhysicsDats. Read from the bundle — NO _dats.Get here, so this method - // (and its caller) need no _datLock. + // Every DAT object publication consumes was captured by the worker. + // The update-thread publishers consume only this immutable bundle. var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; - uint lbXu = (lb.LandblockId >> 24) & 0xFFu; - uint lbYu = (lb.LandblockId >> 16) & 0xFFu; - int lbX = (int)lbXu; - int lbY = (int)lbYu; - var origin = new System.Numerics.Vector3( - (lbX - build.Origin.CenterX) * 192f, - (lbY - build.Origin.CenterY) * 192f, - 0f); - - // Phase A.5 T15/T16: route through AddLandblockWithMesh — the named - // two-tier entry point. Delegates to AddLandblock internally; both - // paths share one GPU upload path. - // [FRAME-DIAG]: bracket the terrain glBufferSubData sub-span so we can tell - // the (tiny ~17KB) GPU upload apart from the dat-read/registration tail. - long fdU0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - _terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin); + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagBefore = + _landblockRenderPublisher.Diagnostics; + AcDream.App.Streaming.LandblockRenderPublication renderPublication = + _landblockRenderPublisher.BeginPublication(build, meshData); + var origin = renderPublication.Origin; if (_frameDiag) - _applyUploadAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdU0; + { + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagAfter = + _landblockRenderPublisher.Diagnostics; + long terrainTicks = + renderDiagAfter.TerrainPublishTicks - renderDiagBefore.TerrainPublishTicks; + _applyUploadAccumTicks += terrainTicks; + _applyCellAccumTicks += + renderDiagAfter.BeginPublishTicks + - renderDiagBefore.BeginPublishTicks + - terrainTicks; + } - // [FRAME-DIAG]: split the post-upload apply CPU into three sub-spans via - // method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights. + // [FRAME-DIAG]: the publisher reports terrain and render-prefix time; + // this checkpoint begins the physics/cache portion of the cell span. long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - // Commit the exact visibility-cell snapshot produced by THIS streaming - // completion. The former global ConcurrentBag let one landblock drain - // another landblock's cells and exposed partial builds to the flood. - var committedCells = new System.Collections.Generic.Dictionary(); - if (build.EnvCells is { } envCellBuild) - { - if (envCellBuild.LandblockId != lb.LandblockId) - throw new InvalidOperationException( - $"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " + - $"landblock 0x{lb.LandblockId:X8}."); - - _cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells); - foreach (var cell in envCellBuild.VisibilityCells) - committedCells[cell.CellId] = cell; - } - - // Compute the per-landblock AABB for frustum culling. XY from the - // landblock's world origin + 192 footprint. Z from the terrain vertex - // range padded +50 above (for trees/buildings) and -10 below (for - // basements). TerrainRenderer already scans vertices internally; we - // replicate here so GpuWorldState has the same bounds for the static - // mesh renderer's culling pass. - { - float zMin = float.MaxValue, zMax = float.MinValue; - foreach (var v in meshData.Vertices) - { - float z = v.Position.Z; - if (z < zMin) zMin = z; - if (z > zMax) zMax = z; - } - if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } - zMax += 50f; // generous pad for trees and buildings - zMin -= 10f; // below-ground buffer for basements/cellars - var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin); - var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax); - _worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax); - } - // Phase B.3: populate the physics engine with terrain + indoor cell // surfaces for this landblock. Runs under _datLock (same lock as the // rest of ApplyLoadedTerrainLocked) so dat reads are safe. @@ -4155,47 +4111,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, portalPlanes, origin.X, origin.Y); - - // Phase A8 (2026-05-26 / fix 2026-05-28): build per-landblock - // BuildingRegistry from LandBlockInfo.Buildings, stamping - // LoadedCell.BuildingId for each cell in a building's cell set. - // - // FIX 2026-05-28: previously passed `drainedCells` only — that's the - // dict of cells drained THIS frame. Cells loaded on prior frames - // were missed, leaving their BuildingId null and the - // `cameraInsideBuilding` gate FALSE even when the player was inside - // a tagged cottage. (First visual-gate launch showed 8737 [vis] - // lines with inside=True really=True but ZERO [buildings] probe - // emissions — same root cause as the RR7.1 / RR7.2 saga.) The fix: - // merge `drainedCells` with every cell currently registered with - // _cellVisibility for this landblock prefix. BuildingLoader's BFS - // + stamping pass then sees the complete cell set. - // - // Cells without a building stay at BuildingId == null (outdoor - // surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings). - if (lbInfo is not null) - { - // The streaming result now carries the complete landblock cell - // set; no cross-frame merge or global-bag recovery is required. - var lbStampCells = committedCells; - // FIX 2026-05-28: normalize storage key to the cell-prefix - // convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId - // field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the - // landblock's own LandBlockInfo dat id), but the runtime - // lookup at line ~7110 derives the key from a cell id via - // `cellId & 0xFFFF0000u` which yields 0xXXYY_0000. Storage - // and lookup must agree. Mask both sides to the upper-16 - // form so the registry resolves correctly. This is the same - // bug the RR7.2 commit (`efe3520`, reverted by `9aaae02`) - // tried to fix; landing it here in the data-flow layer. - uint regKey = lb.LandblockId & 0xFFFF0000u; - _buildingRegistries[regKey] = - AcDream.App.Rendering.Wb.BuildingLoader.Build( - lbInfo, lb.LandblockId, lbStampCells); - } - - if (build.EnvCells is { } renderBuild) - _envCellRenderer?.CommitLandblock(renderBuild); + _landblockRenderPublisher.CompletePublication(renderPublication); } // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via @@ -5623,7 +5539,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // routes interior-root look-ins to its landscape-stage sub-pass // (DrawBuildingLookIns); the root's own building self-excludes // via the seed eye-side test. - foreach (var registry in _buildingRegistries.Values) + foreach (var registry in _landblockRenderPublisher?.BuildingRegistries + ?? Array.Empty()) { foreach (var b in registry.All()) { diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs new file mode 100644 index 00000000..eb41ae7d --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -0,0 +1,290 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; + +namespace AcDream.App.Streaming; + +/// +/// Immutable receipt for the render-owned prefix of one accepted landblock +/// publication. Physics and static presentation consume the same captured +/// origin between and +/// . +/// +public sealed class LandblockRenderPublication +{ + private readonly IReadOnlyDictionary _visibilityCells; + + internal LandblockRenderPublication( + object owner, + LandblockBuild build, + Vector3 origin, + Dictionary visibilityCells) + { + Owner = owner; + Build = build; + Origin = origin; + _visibilityCells = new ReadOnlyDictionary(visibilityCells); + } + + internal object Owner { get; } + internal LandblockBuild Build { get; } + internal bool CompletionCommitted { get; set; } + + public uint LandblockId => Build.Landblock.LandblockId; + public Vector3 Origin { get; } + public IReadOnlyDictionary VisibilityCells => _visibilityCells; +} + +/// +/// Typed cumulative diagnostics owned by the render publication boundary. +/// Times use ticks so callers can aggregate without +/// losing sub-millisecond precision. +/// +public readonly record struct LandblockRenderPublisherDiagnostics( + long BeginCount, + long CompleteCount, + long TerrainPublishTicks, + long BeginPublishTicks, + long CompletePublishTicks, + long EnvCellPreparationCount, + long TerrainRemovalCount, + long CellVisibilityRemovalCount, + long BuildingRegistryRemovalCount, + long EnvCellRemovalCount); + +/// +/// Update-thread owner of landblock render publication. It publishes terrain, +/// the complete visibility-cell transaction, frustum bounds, building +/// registries, and EnvCell renderer state without reading DATs or deciding +/// residency. Render-ID acquisition remains in ; +/// schema-aware EnvCell preparation is replayed only after that acquisition. +/// +/// +/// The ordering preserves retail LScape::grab_visible_cells +/// (0x00504EC0), CLandBlock::init_buildings (0x0052FD80), +/// CLandBlock::grab_visible_cells (0x0052F460), and the extracted +/// WorldBuilder one-job/one-EnvCell-transaction seam documented in +/// docs/architecture/worldbuilder-inventory.md. +/// +public sealed class LandblockRenderPublisher +{ + private readonly object _receiptOwner = new(); + private readonly Action _publishTerrain; + private readonly Action _removeTerrain; + private readonly CellVisibility _cellVisibility; + private readonly GpuWorldState _worldState; + private readonly Action? _commitEnvCells; + private readonly Action? _prepareEnvCells; + private readonly Action? _removeEnvCells; + private readonly Dictionary _buildingRegistries = new(); + + private long _beginCount; + private long _completeCount; + private long _terrainPublishTicks; + private long _beginPublishTicks; + private long _completePublishTicks; + private long _envCellPreparationCount; + private long _terrainRemovalCount; + private long _cellVisibilityRemovalCount; + private long _buildingRegistryRemovalCount; + private long _envCellRemovalCount; + + public LandblockRenderPublisher( + Action publishTerrain, + Action removeTerrain, + CellVisibility cellVisibility, + GpuWorldState worldState, + Action? commitEnvCells = null, + Action? prepareEnvCells = null, + Action? removeEnvCells = null) + { + ArgumentNullException.ThrowIfNull(publishTerrain); + ArgumentNullException.ThrowIfNull(removeTerrain); + ArgumentNullException.ThrowIfNull(cellVisibility); + ArgumentNullException.ThrowIfNull(worldState); + + _publishTerrain = publishTerrain; + _removeTerrain = removeTerrain; + _cellVisibility = cellVisibility; + _worldState = worldState; + _commitEnvCells = commitEnvCells; + _prepareEnvCells = prepareEnvCells; + _removeEnvCells = removeEnvCells; + } + + public IReadOnlyCollection BuildingRegistries => + _buildingRegistries.Values; + + public LandblockRenderPublisherDiagnostics Diagnostics => new( + _beginCount, + _completeCount, + _terrainPublishTicks, + _beginPublishTicks, + _completePublishTicks, + _envCellPreparationCount, + _terrainRemovalCount, + _cellVisibilityRemovalCount, + _buildingRegistryRemovalCount, + _envCellRemovalCount); + + /// + /// Publishes the prefix that retail establishes before physics/static + /// activation: terrain, complete visibility cells, and spatial bounds. + /// + public LandblockRenderPublication BeginPublication( + LandblockBuild build, + LandblockMeshData meshData) + { + ArgumentNullException.ThrowIfNull(build); + ArgumentNullException.ThrowIfNull(meshData); + if (!build.Origin.IsSpecified) + throw new ArgumentException( + "Render publication requires the build's captured origin.", + nameof(build)); + + long beginStarted = Stopwatch.GetTimestamp(); + uint landblockId = build.Landblock.LandblockId; + if (build.EnvCells is { } candidateEnvCells && + candidateEnvCells.LandblockId != landblockId) + { + throw new InvalidOperationException( + $"EnvCell transaction 0x{candidateEnvCells.LandblockId:X8} was attached to " + + $"landblock 0x{landblockId:X8}."); + } + + Vector3 origin = ComputeOrigin(landblockId, build.Origin); + + long terrainStarted = Stopwatch.GetTimestamp(); + _publishTerrain(landblockId, meshData, origin); + _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; + + var committedCells = new Dictionary(); + if (build.EnvCells is { } envCells) + { + _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); + foreach (LoadedCell cell in envCells.VisibilityCells) + committedCells[cell.CellId] = cell; + } + + (Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin); + _worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax); + + _beginCount++; + _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; + return new LandblockRenderPublication( + _receiptOwner, + build, + origin, + committedCells); + } + + /// + /// Completes the render-owned suffix after physics has consumed the same + /// receipt: building membership is stamped before the EnvCell renderer + /// receives the complete immutable transaction. + /// + public void CompletePublication(LandblockRenderPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + throw new ArgumentException( + "The render publication receipt belongs to another publisher.", + nameof(publication)); + if (publication.CompletionCommitted) + return; + + long started = Stopwatch.GetTimestamp(); + LandblockBuild build = publication.Build; + uint landblockId = publication.LandblockId; + if (build.Landblock.PhysicsDats?.Info is { } info) + { + uint registryKey = landblockId & 0xFFFF0000u; + _buildingRegistries[registryKey] = BuildingLoader.Build( + info, + landblockId, + publication.VisibilityCells); + } + + if (build.EnvCells is { } envCells) + _commitEnvCells?.Invoke(envCells); + + publication.CompletionCommitted = true; + _completeCount++; + _completePublishTicks += Stopwatch.GetTimestamp() - started; + } + + /// + /// Replays EnvCell schema preparation after + /// has acquired every synthetic render identifier. + /// + public void PrepareAfterRenderPins(EnvCellLandblockBuild build) + { + ArgumentNullException.ThrowIfNull(build); + _prepareEnvCells?.Invoke(build); + _envCellPreparationCount++; + } + + public void RemoveTerrain(uint landblockId) + { + _removeTerrain(landblockId); + _terrainRemovalCount++; + } + + public void RemoveCellVisibility(uint landblockId) + { + _cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu); + _cellVisibilityRemovalCount++; + } + + public void RemoveBuildingRegistry(uint landblockId) + { + _buildingRegistries.Remove(landblockId & 0xFFFF0000u); + _buildingRegistryRemovalCount++; + } + + public void RemoveEnvironmentCells(uint landblockId) + { + _removeEnvCells?.Invoke(landblockId); + _envCellRemovalCount++; + } + + private static Vector3 ComputeOrigin( + uint landblockId, + LandblockBuildOrigin capturedOrigin) + { + int landblockX = (int)((landblockId >> 24) & 0xFFu); + int landblockY = (int)((landblockId >> 16) & 0xFFu); + return new Vector3( + (landblockX - capturedOrigin.CenterX) * 192f, + (landblockY - capturedOrigin.CenterY) * 192f, + 0f); + } + + private static (Vector3 Min, Vector3 Max) ComputeAabb( + LandblockMeshData meshData, + Vector3 origin) + { + float zMin = float.MaxValue; + float zMax = float.MinValue; + foreach (TerrainVertex vertex in meshData.Vertices) + { + float z = vertex.Position.Z; + if (z < zMin) zMin = z; + if (z > zMax) zMax = z; + } + if (zMin == float.MaxValue) + { + zMin = 0f; + zMax = 0f; + } + + zMax += 50f; + zMin -= 10f; + return ( + new Vector3(origin.X, origin.Y, zMin), + new Vector3(origin.X + 192f, origin.Y + 192f, zMax)); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index 27518587..9ec20493 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -247,6 +247,12 @@ public sealed class LandblockBuildOriginTests "AcDream.App", "Streaming", "LandblockBuildFactory.cs")); + string renderPublisherSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Streaming", + "LandblockRenderPublisher.cs")); string publicationSection = Slice( gameWindowSource, "private void ApplyLoadedTerrainLocked(", @@ -271,8 +277,10 @@ public sealed class LandblockBuildOriginTests "BuildPhysicsDatBundle", gameWindowSource, StringComparison.Ordinal); - Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal); - Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal); + Assert.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal); + Assert.Contains("renderPublication.Origin", publicationSection, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs new file mode 100644 index 00000000..5bf84ba2 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs @@ -0,0 +1,365 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockRenderPublisherTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + private static readonly LandblockBuildOrigin CapturedOrigin = new(0xA8, 0xB3); + + [Fact] + public void BeginPublication_PublishesTerrainVisibilityAndAabbFromCapturedOrigin() + { + var calls = new List(); + var visibility = new CellVisibility(); + var state = new GpuWorldState(); + LoadedCell cell = VisibilityCell(0xA9B40100u); + LandblockBuild build = Build( + new EnvCellLandblockBuild( + LandblockId, + [cell], + Array.Empty())); + LandblockMeshData mesh = MeshAtHeights(7f, 19f); + var publisher = Publisher( + calls, + visibility, + state, + out _, + out _, + out _); + + LandblockRenderPublication receipt = publisher.BeginPublication(build, mesh); + + Assert.Equal(new Vector3(192f, 192f, 0f), receipt.Origin); + Assert.Equal(["terrain:A9B4FFFF:192:192"], calls); + Assert.True(visibility.TryGetCell(cell.CellId, out LoadedCell? committed)); + Assert.Same(cell, committed); + Assert.Single(receipt.VisibilityCells); + + state.AddLandblock(build.Landblock); + var bounds = Assert.Single(state.LandblockBounds); + Assert.Equal(new Vector3(192f, 192f, -3f), bounds.AabbMin); + Assert.Equal(new Vector3(384f, 384f, 69f), bounds.AabbMax); + } + + [Fact] + public void BeginPublication_VisibilityOnlyCellDoesNotRequireDrawableShell() + { + var visibility = new CellVisibility(); + LoadedCell cell = VisibilityCell(0xA9B40100u); + LandblockBuild build = Build(new EnvCellLandblockBuild( + LandblockId, + [cell], + Array.Empty())); + var publisher = Publisher( + new List(), + visibility, + new GpuWorldState(), + out _, + out _, + out _); + + LandblockRenderPublication receipt = publisher.BeginPublication( + build, + EmptyMesh()); + publisher.CompletePublication(receipt); + + Assert.True(visibility.TryGetCell(cell.CellId, out _)); + Assert.Single(publisher.BuildingRegistries); + Assert.Empty(Assert.Single(publisher.BuildingRegistries).All()); + } + + [Fact] + public void BeginPublication_MismatchedEnvCellTransactionHasNoSideEffects() + { + var calls = new List(); + var visibility = new CellVisibility(); + var state = new GpuWorldState(); + LoadedCell cell = VisibilityCell(0xAAB40100u); + LandblockBuild build = Build(new EnvCellLandblockBuild( + 0xAAB4FFFFu, + [cell], + Array.Empty())); + var publisher = Publisher( + calls, + visibility, + state, + out _, + out _, + out _); + + Assert.Throws(() => + publisher.BeginPublication(build, EmptyMesh())); + + Assert.Empty(calls); + Assert.False(visibility.TryGetCell(cell.CellId, out _)); + Assert.Null(state.DetachLandblock(LandblockId)); + Assert.Equal(0, publisher.Diagnostics.BeginCount); + } + + [Fact] + public void CompletePublication_CommitsBuildingThenEnvCellsAndIsIdempotent() + { + var calls = new List(); + LandblockBuild build = Build(EmptyEnvCells()); + int envCommits = 0; + LandblockRenderPublisher? publisher = null; + publisher = new LandblockRenderPublisher( + publishTerrain: (_, _, _) => calls.Add("terrain"), + removeTerrain: _ => { }, + cellVisibility: new CellVisibility(), + worldState: new GpuWorldState(), + commitEnvCells: _ => + { + Assert.Single(publisher!.BuildingRegistries); + calls.Add("env-cells"); + envCommits++; + }); + LandblockRenderPublication receipt = publisher.BeginPublication(build, EmptyMesh()); + + publisher.CompletePublication(receipt); + publisher.CompletePublication(receipt); + + Assert.Equal(["terrain", "env-cells"], calls); + Assert.Equal(1, envCommits); + Assert.Single(publisher.BuildingRegistries); + Assert.Equal(1, publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void CompletePublication_FailedEnvCommitRetriesWithoutDuplicatingRegistry() + { + int attempts = 0; + LandblockBuild build = Build(EmptyEnvCells()); + var publisher = new LandblockRenderPublisher( + publishTerrain: (_, _, _) => { }, + removeTerrain: _ => { }, + cellVisibility: new CellVisibility(), + worldState: new GpuWorldState(), + commitEnvCells: _ => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("injected EnvCell failure"); + }); + LandblockRenderPublication receipt = publisher.BeginPublication(build, EmptyMesh()); + + Assert.Throws(() => + publisher.CompletePublication(receipt)); + publisher.CompletePublication(receipt); + + Assert.Equal(2, attempts); + Assert.Single(publisher.BuildingRegistries); + Assert.Equal(1, publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void CompletePublication_ForeignReceiptIsRejectedWithoutConsumingIt() + { + var first = Publisher( + new List(), + new CellVisibility(), + new GpuWorldState(), + out int[] firstCommits, + out _, + out _); + var second = Publisher( + new List(), + new CellVisibility(), + new GpuWorldState(), + out int[] secondCommits, + out _, + out _); + LandblockRenderPublication receipt = first.BeginPublication( + Build(EmptyEnvCells()), + EmptyMesh()); + + Assert.Throws(() => + second.CompletePublication(receipt)); + + Assert.Empty(second.BuildingRegistries); + Assert.Equal(0, secondCommits[0]); + Assert.Equal(0, second.Diagnostics.CompleteCount); + + first.CompletePublication(receipt); + Assert.Single(first.BuildingRegistries); + Assert.Equal(1, firstCommits[0]); + Assert.Equal(1, first.Diagnostics.CompleteCount); + } + + [Fact] + public void PrepareAndRetirementOperationsStayBalancedAtTheirOwnerBoundary() + { + var calls = new List(); + var visibility = new CellVisibility(); + var publisher = Publisher( + calls, + visibility, + new GpuWorldState(), + out _, + out int[] preparations, + out int[] envRemovals); + LoadedCell cell = VisibilityCell(0xA9B40100u); + EnvCellLandblockBuild envCells = new( + LandblockId, + [cell], + Array.Empty()); + LandblockRenderPublication receipt = publisher.BeginPublication( + Build(envCells), + EmptyMesh()); + publisher.CompletePublication(receipt); + + publisher.PrepareAfterRenderPins(envCells); + publisher.RemoveTerrain(LandblockId); + publisher.RemoveCellVisibility(LandblockId); + publisher.RemoveBuildingRegistry(LandblockId); + publisher.RemoveEnvironmentCells(LandblockId); + + Assert.Equal(1, preparations[0]); + Assert.Equal(1, envRemovals[0]); + Assert.Equal( + [ + "terrain:A9B4FFFF:192:192", + "prepare", + "remove-terrain:A9B4FFFF", + "remove-env:A9B4FFFF", + ], + calls); + Assert.False(visibility.TryGetCell(cell.CellId, out _)); + Assert.Empty(publisher.BuildingRegistries); + LandblockRenderPublisherDiagnostics diagnostics = publisher.Diagnostics; + Assert.Equal(1, diagnostics.EnvCellPreparationCount); + Assert.Equal(1, diagnostics.TerrainRemovalCount); + Assert.Equal(1, diagnostics.CellVisibilityRemovalCount); + Assert.Equal(1, diagnostics.BuildingRegistryRemovalCount); + Assert.Equal(1, diagnostics.EnvCellRemovalCount); + } + + [Fact] + public void PublisherSurface_HasNoDatResidencyPolicyOrLiveOriginDependency() + { + Type type = typeof(LandblockRenderPublisher); + Type[] dependencies = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .Concat(type.GetConstructors().SelectMany(ctor => + ctor.GetParameters().Select(parameter => parameter.ParameterType))) + .ToArray(); + + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("DatReader", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("StreamingRegion", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); + } + + [Fact] + public void GameWindow_HasNoDirectRenderPublicationBodies() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.DoesNotContain("_terrain.AddLandblockWithMesh", source, StringComparison.Ordinal); + Assert.DoesNotContain("_cellVisibility.CommitLandblock", source, StringComparison.Ordinal); + Assert.DoesNotContain("_buildingRegistries", source, StringComparison.Ordinal); + Assert.DoesNotContain("_envCellRenderer?.CommitLandblock", source, StringComparison.Ordinal); + Assert.DoesNotContain("EnsureEnvCellMeshesAfterPin", source, StringComparison.Ordinal); + } + + private static LandblockRenderPublisher Publisher( + List calls, + CellVisibility visibility, + GpuWorldState state, + out int[] envCommits, + out int[] preparations, + out int[] envRemovals) + { + envCommits = [0]; + preparations = [0]; + envRemovals = [0]; + int[] commitCounter = envCommits; + int[] preparationCounter = preparations; + int[] removalCounter = envRemovals; + return new LandblockRenderPublisher( + publishTerrain: (id, _, origin) => calls.Add( + $"terrain:{id:X8}:{origin.X:0}:{origin.Y:0}"), + removeTerrain: id => calls.Add($"remove-terrain:{id:X8}"), + cellVisibility: visibility, + worldState: state, + commitEnvCells: _ => commitCounter[0]++, + prepareEnvCells: _ => + { + preparationCounter[0]++; + calls.Add("prepare"); + }, + removeEnvCells: id => + { + removalCounter[0]++; + calls.Add($"remove-env:{id:X8}"); + }); + } + + private static LandblockBuild Build(EnvCellLandblockBuild envCells) + { + var physics = new PhysicsDatBundle( + new LandBlockInfo(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + return new LandblockBuild( + new LoadedLandblock( + LandblockId, + new LandBlock(), + Array.Empty(), + physics), + envCells, + CapturedOrigin); + } + + private static EnvCellLandblockBuild EmptyEnvCells() => new( + LandblockId, + Array.Empty(), + Array.Empty()); + + private static LoadedCell VisibilityCell(uint cellId) => new() + { + CellId = cellId, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + private static LandblockMeshData MeshAtHeights(float first, float second) => new( + [ + new TerrainVertex(new Vector3(0f, 0f, first), Vector3.UnitZ, 0, 0, 0, 0), + new TerrainVertex(new Vector3(1f, 1f, second), Vector3.UnitZ, 0, 0, 0, 0), + ], + Array.Empty()); + + private static LandblockMeshData EmptyMesh() => + new(Array.Empty(), Array.Empty()); + + private static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory is not null) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Directory.GetParent(directory)?.FullName; + } + throw new DirectoryNotFoundException("Could not locate repository root."); + } +}