refactor(streaming): extract landblock render publisher

This commit is contained in:
Erik 2026-07-21 20:47:01 +02:00
parent 0f9c03f422
commit 1029f8372e
4 changed files with 708 additions and 128 deletions

View file

@ -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<uint, AcDream.Core.Terrain.SurfaceInfo>? _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<uint, AcDream.App.Rendering.Wb.BuildingRegistry>
_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));
}
/// <summary>
@ -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<uint, LoadedCell>();
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<AcDream.App.Rendering.Wb.BuildingRegistry>())
{
foreach (var b in registry.All())
{

View file

@ -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;
/// <summary>
/// Immutable receipt for the render-owned prefix of one accepted landblock
/// publication. Physics and static presentation consume the same captured
/// origin between <see cref="LandblockRenderPublisher.BeginPublication"/> and
/// <see cref="LandblockRenderPublisher.CompletePublication"/>.
/// </summary>
public sealed class LandblockRenderPublication
{
private readonly IReadOnlyDictionary<uint, LoadedCell> _visibilityCells;
internal LandblockRenderPublication(
object owner,
LandblockBuild build,
Vector3 origin,
Dictionary<uint, LoadedCell> visibilityCells)
{
Owner = owner;
Build = build;
Origin = origin;
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(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<uint, LoadedCell> VisibilityCells => _visibilityCells;
}
/// <summary>
/// Typed cumulative diagnostics owned by the render publication boundary.
/// Times use <see cref="Stopwatch"/> ticks so callers can aggregate without
/// losing sub-millisecond precision.
/// </summary>
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);
/// <summary>
/// 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 <see cref="GpuWorldState"/>;
/// schema-aware EnvCell preparation is replayed only after that acquisition.
/// </summary>
/// <remarks>
/// The ordering preserves retail <c>LScape::grab_visible_cells</c>
/// (0x00504EC0), <c>CLandBlock::init_buildings</c> (0x0052FD80),
/// <c>CLandBlock::grab_visible_cells</c> (0x0052F460), and the extracted
/// WorldBuilder one-job/one-EnvCell-transaction seam documented in
/// <c>docs/architecture/worldbuilder-inventory.md</c>.
/// </remarks>
public sealed class LandblockRenderPublisher
{
private readonly object _receiptOwner = new();
private readonly Action<uint, LandblockMeshData, Vector3> _publishTerrain;
private readonly Action<uint> _removeTerrain;
private readonly CellVisibility _cellVisibility;
private readonly GpuWorldState _worldState;
private readonly Action<EnvCellLandblockBuild>? _commitEnvCells;
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
private readonly Action<uint>? _removeEnvCells;
private readonly Dictionary<uint, BuildingRegistry> _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<uint, LandblockMeshData, Vector3> publishTerrain,
Action<uint> removeTerrain,
CellVisibility cellVisibility,
GpuWorldState worldState,
Action<EnvCellLandblockBuild>? commitEnvCells = null,
Action<EnvCellLandblockBuild>? prepareEnvCells = null,
Action<uint>? 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<BuildingRegistry> BuildingRegistries =>
_buildingRegistries.Values;
public LandblockRenderPublisherDiagnostics Diagnostics => new(
_beginCount,
_completeCount,
_terrainPublishTicks,
_beginPublishTicks,
_completePublishTicks,
_envCellPreparationCount,
_terrainRemovalCount,
_cellVisibilityRemovalCount,
_buildingRegistryRemovalCount,
_envCellRemovalCount);
/// <summary>
/// Publishes the prefix that retail establishes before physics/static
/// activation: terrain, complete visibility cells, and spatial bounds.
/// </summary>
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<uint, LoadedCell>();
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Replays EnvCell schema preparation after <see cref="GpuWorldState"/>
/// has acquired every synthetic render identifier.
/// </summary>
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));
}
}

View file

@ -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);
}

View file

@ -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<string>();
var visibility = new CellVisibility();
var state = new GpuWorldState();
LoadedCell cell = VisibilityCell(0xA9B40100u);
LandblockBuild build = Build(
new EnvCellLandblockBuild(
LandblockId,
[cell],
Array.Empty<EnvCellShellPlacement>()));
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<EnvCellShellPlacement>()));
var publisher = Publisher(
new List<string>(),
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<string>();
var visibility = new CellVisibility();
var state = new GpuWorldState();
LoadedCell cell = VisibilityCell(0xAAB40100u);
LandblockBuild build = Build(new EnvCellLandblockBuild(
0xAAB4FFFFu,
[cell],
Array.Empty<EnvCellShellPlacement>()));
var publisher = Publisher(
calls,
visibility,
state,
out _,
out _,
out _);
Assert.Throws<InvalidOperationException>(() =>
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<string>();
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<InvalidOperationException>(() =>
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<string>(),
new CellVisibility(),
new GpuWorldState(),
out int[] firstCommits,
out _,
out _);
var second = Publisher(
new List<string>(),
new CellVisibility(),
new GpuWorldState(),
out int[] secondCommits,
out _,
out _);
LandblockRenderPublication receipt = first.BeginPublication(
Build(EmptyEnvCells()),
EmptyMesh());
Assert.Throws<ArgumentException>(() =>
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<string>();
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<EnvCellShellPlacement>());
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<string> 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<uint, EnvCell>(),
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
new Dictionary<uint, Setup>(),
new Dictionary<uint, GfxObj>());
return new LandblockBuild(
new LoadedLandblock(
LandblockId,
new LandBlock(),
Array.Empty<WorldEntity>(),
physics),
envCells,
CapturedOrigin);
}
private static EnvCellLandblockBuild EmptyEnvCells() => new(
LandblockId,
Array.Empty<LoadedCell>(),
Array.Empty<EnvCellShellPlacement>());
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<uint>());
private static LandblockMeshData EmptyMesh() =>
new(Array.Empty<TerrainVertex>(), Array.Empty<uint>());
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.");
}
}