refactor(streaming): extract landblock build factory
This commit is contained in:
parent
e4a9664f09
commit
5af101b2f1
4 changed files with 1361 additions and 794 deletions
|
|
@ -205,26 +205,23 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe.
|
||||
// DatReaderWriter's DatBinReader uses a shared buffer position internally —
|
||||
// concurrent _dats.Get<T> calls from the streaming worker thread (T11+) and
|
||||
// the render thread (BuildLandblockForStreaming on the worker;
|
||||
// ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render
|
||||
// the render thread (LandblockBuildFactory on the worker; live-spawn
|
||||
// handlers + animation ticks on the render
|
||||
// thread) corrupt that state and produce half-populated LandBlock.Height[]
|
||||
// arrays, rendering as "a giant ball with spikes". All _dats.Get<T> call
|
||||
// sites that can race with the streaming worker MUST hold this lock.
|
||||
//
|
||||
// Worker-thread dat reads enter via the factory closures passed to
|
||||
// LandblockStreamer at construction (loadLandblock + buildMeshOrNull).
|
||||
// Those closures already acquire _datLock, so no additional wrapping is
|
||||
// needed for reads inside BuildLandblockForStreamingLocked /
|
||||
// BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming.
|
||||
// Render-thread paths (ApplyLoadedTerrain and live hydration) already
|
||||
// hold this lock via their outer wrappers; all remaining render-thread
|
||||
// Worker-thread DAT reads enter through LandblockBuildFactory.Build, which
|
||||
// holds _datLock for the complete CPU transaction. The terrain-mesh closure
|
||||
// consumes that completed heightmap and performs no DAT read. Render-thread
|
||||
// live hydration holds this lock via its outer wrapper; all remaining
|
||||
// render-thread
|
||||
// _dats.Get calls run only when no worker dat read can be in flight (during
|
||||
// initialization or within the same lock scope).
|
||||
private readonly object _datLock = new();
|
||||
|
||||
// Terrain build context shared across all streamed landblocks. Stored as
|
||||
// fields so ApplyLoadedTerrain (render-thread callback) can call
|
||||
// LandblockMesh.Build without re-deriving these each time.
|
||||
// Terrain build context shared by the off-thread mesh-build closure across
|
||||
// all streamed landblocks.
|
||||
private float[]? _heightTable;
|
||||
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
|
||||
// Was: Dictionary<uint, SurfaceInfo>. ConcurrentDictionary so the off-thread
|
||||
|
|
@ -2491,8 +2488,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
|
||||
// it can call LandblockMesh.Build without a dat read — _heightTable and
|
||||
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
|
||||
var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory(
|
||||
_dats!,
|
||||
_datLock,
|
||||
_heightTable!,
|
||||
_physicsDataCache,
|
||||
_options.DumpSceneryZ);
|
||||
_streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests(
|
||||
loadLandblock: BuildLandblockForStreaming,
|
||||
loadLandblock: landblockBuildFactory.Build,
|
||||
buildMeshOrNull: (id, lb) =>
|
||||
{
|
||||
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
|
||||
|
|
@ -3078,15 +3081,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
session.SendTurbineChatTo(
|
||||
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||
Log: Console.WriteLine);
|
||||
|
||||
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
|
||||
{
|
||||
uint landblockX = (block.Id >> 24) & 0xFFu;
|
||||
uint landblockY = (block.Id >> 16) & 0xFFu;
|
||||
return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap(
|
||||
block.Height, heightTable, landblockX, landblockY, localX, localY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
|
||||
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
|
||||
|
|
@ -3780,774 +3774,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
$"live: AdminEnvirons sound cue = {name} " +
|
||||
$"(0x{environChangeType:X2}) — audio binding pending");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1: streaming load delegate, runs on the worker thread.
|
||||
/// Reads the landblock from the dats, hydrates its stab entities (same
|
||||
/// path as the old preload), and returns a fully-populated LoadedLandblock.
|
||||
/// Thread-safe: uses only DatCollection reads (documented thread-safe by
|
||||
/// DatReaderWriter) and pure CPU work. No GL calls here.
|
||||
///
|
||||
/// MVP scope: stabs only. Scenery + interior added in Task 8.
|
||||
///
|
||||
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
|
||||
/// LandBlockInfo + scenery + interior hydration. They return only the
|
||||
/// LandBlock heightmap dat record + an empty entity list — enough for
|
||||
/// terrain-mesh build on the next phase. Near-tier loads run the full
|
||||
/// path. This replaces Bug A's post-load entity strip in
|
||||
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
|
||||
/// early-out at the source.
|
||||
/// </summary>
|
||||
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
|
||||
AcDream.App.Streaming.LandblockBuildRequest request)
|
||||
{
|
||||
if (_dats is null) return null;
|
||||
|
||||
// Phase A.1 hotfix: hold the dat lock for the entire load. The worker
|
||||
// thread mustn't read dats concurrently with the render thread's
|
||||
// ApplyLoadedTerrain / live-spawn handlers. Hold time is bounded by
|
||||
// the size of a single landblock's CPU-side build (tens of ms worst
|
||||
// case), which blocks the render thread for at most that duration.
|
||||
// This is the minimum correct behavior; a future pass can reduce
|
||||
// contention by pre-building render-thread work on the worker.
|
||||
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
|
||||
// contention signal — large only when the render thread is hammering the
|
||||
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
|
||||
// cost). Identical work in both branches; the probe branch only adds the
|
||||
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
lock (_datLock)
|
||||
{
|
||||
long waitedMs = sw.ElapsedMilliseconds;
|
||||
sw.Restart();
|
||||
var built = BuildLandblockForStreamingLocked(request);
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||
"BUILD", request.LandblockId,
|
||||
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
|
||||
return built;
|
||||
}
|
||||
}
|
||||
lock (_datLock)
|
||||
{
|
||||
return BuildLandblockForStreamingLocked(request);
|
||||
}
|
||||
}
|
||||
|
||||
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
|
||||
AcDream.App.Streaming.LandblockBuildRequest request)
|
||||
{
|
||||
if (_dats is null) return null;
|
||||
uint landblockId = request.LandblockId;
|
||||
|
||||
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
||||
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
|
||||
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
|
||||
// worker-thread cost per far-tier LB from ~tens of ms to a single
|
||||
// dat read.
|
||||
if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
|
||||
{
|
||||
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
|
||||
if (heightmapOnly is null) return null;
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
landblockId,
|
||||
heightmapOnly,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
AcDream.Core.World.PhysicsDatBundle.Empty),
|
||||
Origin: request.Origin); // far tier: no cells/buildings/entities
|
||||
}
|
||||
|
||||
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
|
||||
if (baseLoaded is null) return null;
|
||||
|
||||
int lbX = (int)((landblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
||||
var worldOffset = new System.Numerics.Vector3(
|
||||
(lbX - request.Origin.CenterX) * 192f,
|
||||
(lbY - request.Origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
||||
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
|
||||
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
|
||||
// GPU upload (EnsureUploaded) happens on the render thread in
|
||||
// ApplyLoadedTerrain — NOT here.
|
||||
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||||
foreach (var e in baseLoaded.Entities)
|
||||
{
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
|
||||
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
// Single GfxObj stab — identity part transform.
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
||||
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
}
|
||||
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
// Multi-part Setup — flatten to per-part GfxObj refs.
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meshRefs.Count == 0) continue;
|
||||
|
||||
var entity = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
||||
Position = e.Position + worldOffset,
|
||||
Rotation = e.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
EffectCellId = e.EffectCellId,
|
||||
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
|
||||
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
|
||||
};
|
||||
if (stabBounds.TryGet(out var sbMin, out var sbMax))
|
||||
entity.SetLocalBounds(sbMin, sbMax);
|
||||
hydrated.Add(entity);
|
||||
}
|
||||
|
||||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
||||
merged.AddRange(BuildSceneryEntitiesForStreaming(
|
||||
baseLoaded,
|
||||
lbX,
|
||||
lbY,
|
||||
request.Origin));
|
||||
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
|
||||
merged.AddRange(BuildInteriorEntitiesForStreaming(
|
||||
landblockId,
|
||||
lbX,
|
||||
lbY,
|
||||
request.Origin,
|
||||
envCellBuild));
|
||||
|
||||
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
|
||||
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
|
||||
// `merged` so entity GfxObj/Setup ids are known.
|
||||
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
|
||||
var completedEnvCells = envCellBuild.Build();
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
baseLoaded.LandblockId,
|
||||
baseLoaded.Heightmap,
|
||||
merged,
|
||||
physicsDats),
|
||||
completedEnvCells,
|
||||
request.Origin);
|
||||
}
|
||||
|
||||
private void EnsureEnvCellMeshesAfterPin(
|
||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
|
||||
{
|
||||
if (_wbMeshAdapter?.MeshManager is { } meshManager)
|
||||
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
|
||||
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
|
||||
/// <c>DatCollection</c> calls and the update thread never blocks on the
|
||||
/// worker's lock. MIRRORS the apply's six read sites — gather and apply must
|
||||
/// enumerate the same ids. Reads only; no build / registration.
|
||||
/// </summary>
|
||||
private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
|
||||
uint landblockId,
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.World.WorldEntity> entities)
|
||||
{
|
||||
var envCells = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.EnvCell>();
|
||||
var environments = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Environment>();
|
||||
var setups = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Setup>();
|
||||
var gfxObjs = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.GfxObj>();
|
||||
|
||||
// (1) LandBlockInfo
|
||||
var lbInfo = _dats!.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// (2)+(3) EnvCell + Environment, per cell
|
||||
if (lbInfo.NumCells > 0)
|
||||
{
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||||
if (envCell is null) continue;
|
||||
envCells[envCellId] = envCell;
|
||||
if (envCell.EnvironmentId == 0) continue;
|
||||
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
||||
if (!environments.ContainsKey(envId))
|
||||
{
|
||||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||
if (environment is not null) environments[envId] = environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) building shell Setup, per building
|
||||
foreach (var building in lbInfo.Buildings)
|
||||
{
|
||||
uint modelId = building.ModelId;
|
||||
if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId))
|
||||
{
|
||||
var bldSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
|
||||
if (bldSetup is not null) setups[modelId] = bldSetup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights)
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
uint src = entity.SourceGfxObjOrSetupId;
|
||||
if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src))
|
||||
{
|
||||
var s = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
|
||||
if (s is not null) setups[src] = s;
|
||||
}
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
uint gid = meshRef.GfxObjId;
|
||||
if ((gid & 0xFF000000u) != 0x01000000u) continue;
|
||||
if (gfxObjs.ContainsKey(gid)) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(gid);
|
||||
if (gfx is not null) gfxObjs[gid] = gfx;
|
||||
}
|
||||
}
|
||||
|
||||
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
|
||||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
|
||||
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
|
||||
/// LoadedLandblock instead of iterating worldView.Landblocks.
|
||||
/// </summary>
|
||||
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
|
||||
AcDream.Core.World.LoadedLandblock lb,
|
||||
int lbX,
|
||||
int lbY,
|
||||
AcDream.App.Streaming.LandblockBuildOrigin origin)
|
||||
{
|
||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||
if (_dats is null || _heightTable is null) return result;
|
||||
|
||||
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
||||
if (region is null) return result;
|
||||
|
||||
// Build a set of terrain vertex indices that have buildings on them,
|
||||
// so the scenery generator can skip those cells (ACME conformance fix 4d).
|
||||
HashSet<int>? buildingCells = null;
|
||||
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are
|
||||
// static scenery placeholders themselves (rocks, tree clusters) that
|
||||
// retail does NOT use to suppress scenery generation. Including them
|
||||
// here over-suppressed scenery in town landblocks.
|
||||
buildingCells = new HashSet<int>();
|
||||
foreach (var bldg in lbInfo.Buildings)
|
||||
{
|
||||
int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8);
|
||||
int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8);
|
||||
buildingCells.Add(cx * 9 + cy);
|
||||
}
|
||||
}
|
||||
|
||||
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
|
||||
_dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable);
|
||||
if (spawns.Count == 0) return result;
|
||||
|
||||
var lbOffset = new System.Numerics.Vector3(
|
||||
(lbX - origin.CenterX) * 192f,
|
||||
(lbY - origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Per-landblock id namespace. The top nibble identifies procedural
|
||||
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
|
||||
// DAT-generated landblocks from overflowing into a neighbour's range.
|
||||
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
|
||||
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
|
||||
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
||||
uint sceneryCounter = 0;
|
||||
|
||||
foreach (var spawn in spawns)
|
||||
{
|
||||
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
|
||||
// Scale is baked into the root transform by wrapping each part's
|
||||
// transform with a scale matrix.
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
|
||||
|
||||
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
|
||||
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
|
||||
}
|
||||
}
|
||||
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
// Compose: part's own transform, then the spawn's scale.
|
||||
var partXf = mr.PartTransform * scaleMat;
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) sceneryBounds.Add(partXf, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meshRefs.Count == 0) continue;
|
||||
|
||||
// Sample terrain Z at (localX, localY) to lift scenery onto the
|
||||
// ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as
|
||||
// spawn.LocalPosition.Z) so meshes that specify a vertical offset
|
||||
// from the ground (e.g., flowers at -0.1m, roots below terrain)
|
||||
// settle properly.
|
||||
float localX = spawn.LocalPosition.X;
|
||||
float localY = spawn.LocalPosition.Y;
|
||||
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
|
||||
// — it uses the same AC2D render split-direction formula the
|
||||
// TerrainModernRenderer uses for the visible terrain mesh. This
|
||||
// guarantees trees are placed on the SAME Z height the player
|
||||
// walks on. If physics hasn't registered this landblock yet,
|
||||
// fall back to the local bilinear sample.
|
||||
var worldPx = localX + lbOffset.X;
|
||||
var worldPy = localY + lbOffset.Y;
|
||||
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
|
||||
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
|
||||
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
|
||||
// scoped to the landblock being built. The former global
|
||||
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||||
// build time this landblock is NOT registered in physics yet, so that query
|
||||
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||
// height — the previous location's terrain, still registered after a teleport
|
||||
// recenter (which drops only the single stale CENTER landblock, GameWindow
|
||||
// :5444) until streaming unloads it — planting scenery at the old location's
|
||||
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||||
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
||||
// case, so the global query is removed (also drops its per-spawn cost).
|
||||
float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
|
||||
float finalZ = groundZ + spawn.LocalPosition.Z;
|
||||
|
||||
// Issue #48 diagnostic. One log line per (spawn, rendered-mesh)
|
||||
// disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2
|
||||
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
|
||||
// User identifies a floating tree visually, finds the matching
|
||||
// line by world coords + gfx id, the data picks the hypothesis.
|
||||
if (_options.DumpSceneryZ)
|
||||
{
|
||||
// groundZ now always comes from THIS landblock's own heightmap (the
|
||||
// global physics query was removed — see the trees-in-sky fix above).
|
||||
string source = "heightmap";
|
||||
foreach (var mr in meshRefs)
|
||||
{
|
||||
var dgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (dgfx is null) continue;
|
||||
|
||||
float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity;
|
||||
foreach (var v in dgfx.VertexArray.Vertices.Values)
|
||||
{
|
||||
if (v.Origin.Z < zMin) zMin = v.Origin.Z;
|
||||
if (v.Origin.Z > zMax) zMax = v.Origin.Z;
|
||||
}
|
||||
if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; }
|
||||
|
||||
// Per-part transform offset inside the setup (post-spawn-scale).
|
||||
// For setup spawns this is Setup.PlacementFrames[Default].Frames[i] *
|
||||
// spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale.
|
||||
var partT = mr.PartTransform.Translation;
|
||||
|
||||
bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade);
|
||||
string ddInfo = string.Empty;
|
||||
if (hasDD && dgfx.DIDDegrade != 0)
|
||||
{
|
||||
var ddi = _dats.Get<DatReaderWriter.DBObjs.GfxObjDegradeInfo>(dgfx.DIDDegrade);
|
||||
if (ddi is not null && ddi.Degrades.Count > 0)
|
||||
{
|
||||
uint slot0Id = (uint)ddi.Degrades[0].Id;
|
||||
float slot0Min = 0f;
|
||||
var slot0Gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(slot0Id);
|
||||
if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0)
|
||||
{
|
||||
slot0Min = float.PositiveInfinity;
|
||||
foreach (var v in slot0Gfx.VertexArray.Vertices.Values)
|
||||
if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z;
|
||||
if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f;
|
||||
}
|
||||
ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}";
|
||||
}
|
||||
}
|
||||
|
||||
// partWorldZMin = the lowest vertex of this part in world space.
|
||||
// = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex)
|
||||
// If everything is right and the lowest part of the tree should
|
||||
// touch the ground, we expect partWorldZMin <= groundZ for at
|
||||
// least one part of a multi-part setup.
|
||||
float partWorldZMin = finalZ + partT.Z + zMin;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" +
|
||||
$" source={source}" +
|
||||
$" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" +
|
||||
$" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" +
|
||||
$" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" +
|
||||
$" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" +
|
||||
$" hasDIDDegrade={hasDD}{ddInfo}");
|
||||
}
|
||||
}
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
|
||||
lbXByte,
|
||||
lbYByte,
|
||||
ref sceneryCounter),
|
||||
SourceGfxObjOrSetupId = spawn.ObjectId,
|
||||
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
|
||||
Rotation = spawn.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
Scale = spawn.Scale,
|
||||
EffectCellId = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellId(
|
||||
lb.LandblockId,
|
||||
localX,
|
||||
localY),
|
||||
};
|
||||
if (sceneryBounds.TryGet(out var scbMin, out var scbMax))
|
||||
hydrated.SetLocalBounds(scbMin, scbMax);
|
||||
result.Add(hydrated);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
|
||||
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
|
||||
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Portal cells and drawable shell placements are accumulated in the
|
||||
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
||||
/// cannot observe this landblock until the streaming completion carries the
|
||||
/// finished transaction to ApplyLoadedTerrain.
|
||||
///
|
||||
/// Ported from pre-streaming preload lines 407-565.
|
||||
/// </summary>
|
||||
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
|
||||
uint landblockId,
|
||||
int lbX,
|
||||
int lbY,
|
||||
AcDream.App.Streaming.LandblockBuildOrigin origin,
|
||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
|
||||
{
|
||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||
if (_dats is null) return result;
|
||||
|
||||
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
if (lbInfo is null || lbInfo.NumCells == 0) return result;
|
||||
|
||||
var lbOffset = new System.Numerics.Vector3(
|
||||
(lbX - origin.CenterX) * 192f,
|
||||
(lbY - origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
||||
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
|
||||
// landblock stabs (0xC0000000+, ids from LandblockLoader).
|
||||
//
|
||||
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
|
||||
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
|
||||
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
|
||||
// map Y-row produced the same id base, so interior statics collided across
|
||||
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
|
||||
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
|
||||
// entity's batches to the other (the cache hint at bucket-draw time was the
|
||||
// player's landblock, identical for both) — the session-sticky "broken stairs +
|
||||
// water barrel".
|
||||
//
|
||||
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
|
||||
// past 0xFF still bleeds into the lbY byte." That residual manifested for real:
|
||||
// the Town Network hub (205 cells, one landblock) reached 277 interior entities
|
||||
// after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT
|
||||
// landblock's Y-byte (entity script/particle tracking is keyed on entity.Id
|
||||
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
|
||||
// the fountain's water-spray script silently stopped firing). Widened the
|
||||
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
|
||||
// why this is safe (nothing decodes X/Y back out of an entity id).
|
||||
uint interiorLbX = (landblockId >> 24) & 0xFFu;
|
||||
uint interiorLbY = (landblockId >> 16) & 0xFFu;
|
||||
uint localCounter = 0;
|
||||
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||||
if (envCell is null)
|
||||
{
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
|
||||
// MUST exist in the cell dat — a null here is always a read anomaly.
|
||||
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 7.1: build and register room geometry for this EnvCell.
|
||||
DatReaderWriter.Types.CellStruct? cellStruct = null;
|
||||
if (envCell.EnvironmentId != 0)
|
||||
{
|
||||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
if (environment is null)
|
||||
{
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// a null Environment means this cell's WALLS are silently never
|
||||
// registered while its static objects still draw — the exact
|
||||
// white-walls geometry signature.
|
||||
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
|
||||
}
|
||||
if (environment is not null
|
||||
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
||||
{
|
||||
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
||||
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
|
||||
// drawable-geometry predicate; the actual shell placement is now owned
|
||||
// by this streaming job's EnvCellLandblockBuild transaction.
|
||||
// Static objects inside the cell continue to flow through the dispatcher
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer receives only the completed shell transaction.
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||||
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
|
||||
// shared with every draw-space consumer of portal polygons (OutsideView
|
||||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
||||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
||||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||
// neighbour and the grey clear shows through the opening (#133: ramp
|
||||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||||
// before the flood runs; the cell-build transaction reads portals, NOT
|
||||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
envCellBuild.AddCell(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellOrigin,
|
||||
physicsCellTransform,
|
||||
cellOrigin,
|
||||
cellTransform,
|
||||
hasDrawableGeometry: cellSubMeshes.Count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2d: static objects inside the EnvCell.
|
||||
foreach (var stab in envCell.StaticObjects)
|
||||
{
|
||||
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
|
||||
// targeted stabs. This is the MOMENT MeshRefs are constructed —
|
||||
// a degraded dat read here (setup null / placement frames short /
|
||||
// part GfxObj null) permanently corrupts the entity (H-A), and
|
||||
// nothing downstream ever rebuilds it. Inert when the set is empty.
|
||||
bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics
|
||||
.DumpEntitySourceIds.Contains(stab.Id);
|
||||
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
|
||||
|
||||
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
|
||||
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
|
||||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||||
// WorldBuilder editor shows it at the origin. acdream's render path came from
|
||||
// WB (no distance LOD), so without this skip it draws the marker forever (the
|
||||
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
|
||||
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u
|
||||
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
|
||||
continue;
|
||||
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
// #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a
|
||||
// runtime-hidden marker (#136) flattens to zero mesh refs even though its
|
||||
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
||||
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
|
||||
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
|
||||
// the entity that would otherwise carry those lights to the registration
|
||||
// pass (GameWindow.cs ~7900).
|
||||
int stabLightCount = 0;
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
else if (dumpStab)
|
||||
{
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped");
|
||||
}
|
||||
}
|
||||
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(stab.Id, setup);
|
||||
stabLightCount = setup.Lights.Count;
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
if (dumpStab)
|
||||
{
|
||||
dumpSetupParts = setup.Parts.Count;
|
||||
dumpPlacementFrames = setup.PlacementFrames.Count;
|
||||
dumpFlattened = flat.Count;
|
||||
}
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
// #136: skip an editor-only marker PART (retail hides it at runtime
|
||||
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
|
||||
// part GfxObj 0x010028CA is such a marker — skipping it empties
|
||||
// meshRefs and the whole stab drops below.
|
||||
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
|
||||
continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null)
|
||||
{
|
||||
dumpDropped++;
|
||||
if (dumpStab)
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped");
|
||||
continue;
|
||||
}
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
}
|
||||
else if (dumpStab)
|
||||
{
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped");
|
||||
}
|
||||
}
|
||||
|
||||
if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount))
|
||||
{
|
||||
if (dumpStab)
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped");
|
||||
continue;
|
||||
}
|
||||
if (meshRefs.Count == 0 && dumpStab)
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier");
|
||||
|
||||
// Stabs inside EnvCells are already in landblock-local coordinates
|
||||
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
|
||||
// be wrong — see Phase 2d comment in the pre-streaming preload.
|
||||
var worldPos = stab.Frame.Origin + lbOffset;
|
||||
var worldRot = stab.Frame.Orientation;
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
|
||||
interiorLbX,
|
||||
interiorLbY,
|
||||
ref localCounter),
|
||||
SourceGfxObjOrSetupId = stab.Id,
|
||||
Position = worldPos,
|
||||
Rotation = worldRot,
|
||||
MeshRefs = meshRefs,
|
||||
ParentCellId = envCellId,
|
||||
};
|
||||
if (interiorBounds.TryGet(out var ibMin, out var ibMax))
|
||||
hydrated.SetLocalBounds(ibMin, ibMax);
|
||||
|
||||
if (dumpStab)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " +
|
||||
$"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " +
|
||||
$"built={meshRefs.Count} dropped={dumpDropped} " +
|
||||
$"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})");
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
{
|
||||
var t = meshRefs[i].PartTransform.Translation;
|
||||
Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})");
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(hydrated);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void AdvanceLandblockPresentationRetirement(
|
||||
AcDream.App.Streaming.LandblockRetirementTicket ticket)
|
||||
{
|
||||
|
|
|
|||
808
src/AcDream.App/Streaming/LandblockBuildFactory.cs
Normal file
808
src/AcDream.App/Streaming/LandblockBuildFactory.cs
Normal file
|
|
@ -0,0 +1,808 @@
|
|||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the complete CPU-side transaction for one streamed landblock. Every
|
||||
/// DAT read for a build is serialized by the shared reader gate; no renderer,
|
||||
/// GL, physics-world, residency, or live-origin state is reachable here.
|
||||
/// The hydration and cell-registration order follows the extracted
|
||||
/// WorldBuilder inventory and retail <c>CLandBlock::init_static_objs</c>
|
||||
/// (0x00530A40), <c>CLandBlock::grab_visible_cells</c> (0x0052F460),
|
||||
/// <c>PView::InitCell</c> (0x005A4B70), and
|
||||
/// <c>CObjCell::init_objects</c> (0x0052B420).
|
||||
/// See <c>docs/architecture/worldbuilder-inventory.md</c>.
|
||||
/// </summary>
|
||||
public sealed class LandblockBuildFactory
|
||||
{
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly object _datLock;
|
||||
private readonly float[] _heightTable;
|
||||
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache;
|
||||
private readonly bool _dumpSceneryZ;
|
||||
|
||||
public LandblockBuildFactory(
|
||||
IDatReaderWriter dats,
|
||||
object datLock,
|
||||
float[] heightTable,
|
||||
AcDream.Core.Physics.PhysicsDataCache physicsDataCache,
|
||||
bool dumpSceneryZ = false)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
ArgumentNullException.ThrowIfNull(heightTable);
|
||||
if (heightTable.Length < 256)
|
||||
throw new ArgumentException(
|
||||
"The retail terrain height table must contain at least 256 entries.",
|
||||
nameof(heightTable));
|
||||
_heightTable = (float[])heightTable.Clone();
|
||||
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
|
||||
_dumpSceneryZ = dumpSceneryZ;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Streaming load delegate that runs on the worker thread. Reads the
|
||||
/// landblock from the DATs, hydrates its entities, and returns a complete
|
||||
/// CPU-side build. The shared reader gate serializes the full transaction;
|
||||
/// no GL or update-thread state is accessed here.
|
||||
///
|
||||
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
|
||||
/// LandBlockInfo + scenery + interior hydration. They return only the
|
||||
/// LandBlock heightmap dat record + an empty entity list — enough for
|
||||
/// terrain-mesh build on the next phase. Near-tier loads run the full
|
||||
/// path. This replaces Bug A's post-load entity strip in
|
||||
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
|
||||
/// early-out at the source.
|
||||
/// </summary>
|
||||
public AcDream.App.Streaming.LandblockBuild? Build(
|
||||
AcDream.App.Streaming.LandblockBuildRequest request)
|
||||
{
|
||||
if (!request.Origin.IsSpecified)
|
||||
throw new ArgumentException(
|
||||
"A landblock build requires a specified captured origin.",
|
||||
nameof(request));
|
||||
|
||||
// DatCollection is the sole DAT reader in process, so each landblock
|
||||
// must observe one serialized read transaction. Holding the shared
|
||||
// gate through mesh/bounds hydration prevents another consumer from
|
||||
// interleaving reader cursor/cache state with this build.
|
||||
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
|
||||
// contention signal — large only when the render thread is hammering the
|
||||
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
|
||||
// cost). Identical work in both branches; the probe branch only adds the
|
||||
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
lock (_datLock)
|
||||
{
|
||||
long waitedMs = sw.ElapsedMilliseconds;
|
||||
sw.Restart();
|
||||
var built = BuildLocked(request);
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||
"BUILD", request.LandblockId,
|
||||
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
|
||||
return built;
|
||||
}
|
||||
}
|
||||
lock (_datLock)
|
||||
{
|
||||
return BuildLocked(request);
|
||||
}
|
||||
}
|
||||
|
||||
private AcDream.App.Streaming.LandblockBuild? BuildLocked(
|
||||
AcDream.App.Streaming.LandblockBuildRequest request)
|
||||
{
|
||||
uint landblockId = request.LandblockId;
|
||||
|
||||
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
||||
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
|
||||
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
|
||||
// worker-thread cost per far-tier LB from ~tens of ms to a single
|
||||
// dat read.
|
||||
if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
|
||||
{
|
||||
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
|
||||
if (heightmapOnly is null) return null;
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
landblockId,
|
||||
heightmapOnly,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
AcDream.Core.World.PhysicsDatBundle.Empty),
|
||||
Origin: request.Origin); // far tier: no cells/buildings/entities
|
||||
}
|
||||
|
||||
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
|
||||
if (baseLoaded is null) return null;
|
||||
|
||||
int lbX = (int)((landblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
||||
var worldOffset = new System.Numerics.Vector3(
|
||||
(lbX - request.Origin.CenterX) * 192f,
|
||||
(lbY - request.Origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
||||
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
|
||||
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
|
||||
// GPU upload (EnsureUploaded) happens on the render thread in
|
||||
// ApplyLoadedTerrain — NOT here.
|
||||
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||||
foreach (var e in baseLoaded.Entities)
|
||||
{
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
|
||||
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
// Single GfxObj stab — identity part transform.
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
||||
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
}
|
||||
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
// Multi-part Setup — flatten to per-part GfxObj refs.
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meshRefs.Count == 0) continue;
|
||||
|
||||
var entity = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
||||
Position = e.Position + worldOffset,
|
||||
Rotation = e.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
EffectCellId = e.EffectCellId,
|
||||
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
|
||||
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
|
||||
};
|
||||
if (stabBounds.TryGet(out var sbMin, out var sbMax))
|
||||
entity.SetLocalBounds(sbMin, sbMax);
|
||||
hydrated.Add(entity);
|
||||
}
|
||||
|
||||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
||||
merged.AddRange(BuildSceneryEntitiesForStreaming(
|
||||
baseLoaded,
|
||||
lbX,
|
||||
lbY,
|
||||
request.Origin));
|
||||
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
|
||||
merged.AddRange(BuildInteriorEntitiesForStreaming(
|
||||
landblockId,
|
||||
lbX,
|
||||
lbY,
|
||||
request.Origin,
|
||||
envCellBuild));
|
||||
|
||||
// Pre-read every DAT object publication consumes. Build this after
|
||||
// `merged` so all entity GfxObj/Setup ids are known.
|
||||
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
|
||||
var completedEnvCells = envCellBuild.Build();
|
||||
return new AcDream.App.Streaming.LandblockBuild(
|
||||
new AcDream.Core.World.LoadedLandblock(
|
||||
baseLoaded.LandblockId,
|
||||
baseLoaded.Heightmap,
|
||||
merged,
|
||||
physicsDats),
|
||||
completedEnvCells,
|
||||
request.Origin);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Pre-reads every DAT object that landblock publication consumes, so the
|
||||
/// update thread performs no DAT reads. Gather and publication must
|
||||
/// enumerate the same identifiers. Reads only; no runtime registration.
|
||||
/// </summary>
|
||||
private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
|
||||
uint landblockId,
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.World.WorldEntity> entities)
|
||||
{
|
||||
var envCells = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.EnvCell>();
|
||||
var environments = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Environment>();
|
||||
var setups = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Setup>();
|
||||
var gfxObjs = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.GfxObj>();
|
||||
|
||||
// (1) LandBlockInfo
|
||||
var lbInfo = _dats!.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// (2)+(3) EnvCell + Environment, per cell
|
||||
if (lbInfo.NumCells > 0)
|
||||
{
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||||
if (envCell is null) continue;
|
||||
envCells[envCellId] = envCell;
|
||||
if (envCell.EnvironmentId == 0) continue;
|
||||
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
||||
if (!environments.ContainsKey(envId))
|
||||
{
|
||||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||
if (environment is not null) environments[envId] = environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) building shell Setup, per building
|
||||
foreach (var building in lbInfo.Buildings)
|
||||
{
|
||||
uint modelId = building.ModelId;
|
||||
if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId))
|
||||
{
|
||||
var bldSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
|
||||
if (bldSetup is not null) setups[modelId] = bldSetup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights)
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
uint src = entity.SourceGfxObjOrSetupId;
|
||||
if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src))
|
||||
{
|
||||
var s = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
|
||||
if (s is not null) setups[src] = s;
|
||||
}
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
uint gid = meshRef.GfxObjId;
|
||||
if ((gid & 0xFF000000u) != 0x01000000u) continue;
|
||||
if (gfxObjs.ContainsKey(gid)) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(gid);
|
||||
if (gfx is not null) gfxObjs[gid] = gfx;
|
||||
}
|
||||
}
|
||||
|
||||
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
|
||||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
|
||||
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
|
||||
/// LoadedLandblock instead of iterating worldView.Landblocks.
|
||||
/// </summary>
|
||||
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
|
||||
AcDream.Core.World.LoadedLandblock lb,
|
||||
int lbX,
|
||||
int lbY,
|
||||
AcDream.App.Streaming.LandblockBuildOrigin origin)
|
||||
{
|
||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||
|
||||
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
||||
if (region is null) return result;
|
||||
|
||||
// Build a set of terrain vertex indices that have buildings on them,
|
||||
// so the scenery generator can skip those cells (ACME conformance fix 4d).
|
||||
HashSet<int>? buildingCells = null;
|
||||
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are
|
||||
// static scenery placeholders themselves (rocks, tree clusters) that
|
||||
// retail does NOT use to suppress scenery generation. Including them
|
||||
// here over-suppressed scenery in town landblocks.
|
||||
buildingCells = new HashSet<int>();
|
||||
foreach (var bldg in lbInfo.Buildings)
|
||||
{
|
||||
int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8);
|
||||
int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8);
|
||||
buildingCells.Add(cx * 9 + cy);
|
||||
}
|
||||
}
|
||||
|
||||
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
|
||||
_dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable);
|
||||
if (spawns.Count == 0) return result;
|
||||
|
||||
var lbOffset = new System.Numerics.Vector3(
|
||||
(lbX - origin.CenterX) * 192f,
|
||||
(lbY - origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Per-landblock id namespace. The top nibble identifies procedural
|
||||
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
|
||||
// DAT-generated landblocks from overflowing into a neighbour's range.
|
||||
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
|
||||
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
|
||||
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
||||
uint sceneryCounter = 0;
|
||||
|
||||
foreach (var spawn in spawns)
|
||||
{
|
||||
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
|
||||
// Scale is baked into the root transform by wrapping each part's
|
||||
// transform with a scale matrix.
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
|
||||
|
||||
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
|
||||
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
|
||||
}
|
||||
}
|
||||
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
// Compose: part's own transform, then the spawn's scale.
|
||||
var partXf = mr.PartTransform * scaleMat;
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) sceneryBounds.Add(partXf, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meshRefs.Count == 0) continue;
|
||||
|
||||
// Sample terrain Z at (localX, localY) to lift scenery onto the
|
||||
// ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as
|
||||
// spawn.LocalPosition.Z) so meshes that specify a vertical offset
|
||||
// from the ground (e.g., flowers at -0.1m, roots below terrain)
|
||||
// settle properly.
|
||||
float localX = spawn.LocalPosition.X;
|
||||
float localY = spawn.LocalPosition.Y;
|
||||
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
|
||||
// — it uses the same AC2D render split-direction formula the
|
||||
// TerrainModernRenderer uses for the visible terrain mesh. This
|
||||
// guarantees trees are placed on the SAME Z height the player
|
||||
// walks on. If physics hasn't registered this landblock yet,
|
||||
// fall back to the local bilinear sample.
|
||||
var worldPx = localX + lbOffset.X;
|
||||
var worldPy = localY + lbOffset.Y;
|
||||
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
|
||||
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
|
||||
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
|
||||
// scoped to the landblock being built. The former global
|
||||
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||||
// build time this landblock is NOT registered in physics yet, so that query
|
||||
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||
// height — the previous location's terrain, still registered after a teleport
|
||||
// recenter (which drops only the single stale CENTER landblock, GameWindow
|
||||
// :5444) until streaming unloads it — planting scenery at the old location's
|
||||
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||||
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
||||
// case, so the global query is removed (also drops its per-spawn cost).
|
||||
float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
|
||||
float finalZ = groundZ + spawn.LocalPosition.Z;
|
||||
|
||||
// Issue #48 diagnostic. One log line per (spawn, rendered-mesh)
|
||||
// disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2
|
||||
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
|
||||
// User identifies a floating tree visually, finds the matching
|
||||
// line by world coords + gfx id, the data picks the hypothesis.
|
||||
if (_dumpSceneryZ)
|
||||
{
|
||||
// groundZ now always comes from THIS landblock's own heightmap (the
|
||||
// global physics query was removed — see the trees-in-sky fix above).
|
||||
string source = "heightmap";
|
||||
foreach (var mr in meshRefs)
|
||||
{
|
||||
var dgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (dgfx is null) continue;
|
||||
|
||||
float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity;
|
||||
foreach (var v in dgfx.VertexArray.Vertices.Values)
|
||||
{
|
||||
if (v.Origin.Z < zMin) zMin = v.Origin.Z;
|
||||
if (v.Origin.Z > zMax) zMax = v.Origin.Z;
|
||||
}
|
||||
if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; }
|
||||
|
||||
// Per-part transform offset inside the setup (post-spawn-scale).
|
||||
// For setup spawns this is Setup.PlacementFrames[Default].Frames[i] *
|
||||
// spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale.
|
||||
var partT = mr.PartTransform.Translation;
|
||||
|
||||
bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade);
|
||||
string ddInfo = string.Empty;
|
||||
if (hasDD && dgfx.DIDDegrade != 0)
|
||||
{
|
||||
var ddi = _dats.Get<DatReaderWriter.DBObjs.GfxObjDegradeInfo>(dgfx.DIDDegrade);
|
||||
if (ddi is not null && ddi.Degrades.Count > 0)
|
||||
{
|
||||
uint slot0Id = (uint)ddi.Degrades[0].Id;
|
||||
float slot0Min = 0f;
|
||||
var slot0Gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(slot0Id);
|
||||
if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0)
|
||||
{
|
||||
slot0Min = float.PositiveInfinity;
|
||||
foreach (var v in slot0Gfx.VertexArray.Vertices.Values)
|
||||
if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z;
|
||||
if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f;
|
||||
}
|
||||
ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}";
|
||||
}
|
||||
}
|
||||
|
||||
// partWorldZMin = the lowest vertex of this part in world space.
|
||||
// = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex)
|
||||
// If everything is right and the lowest part of the tree should
|
||||
// touch the ground, we expect partWorldZMin <= groundZ for at
|
||||
// least one part of a multi-part setup.
|
||||
float partWorldZMin = finalZ + partT.Z + zMin;
|
||||
|
||||
Console.WriteLine(
|
||||
$"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" +
|
||||
$" source={source}" +
|
||||
$" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" +
|
||||
$" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" +
|
||||
$" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" +
|
||||
$" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" +
|
||||
$" hasDIDDegrade={hasDD}{ddInfo}");
|
||||
}
|
||||
}
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
|
||||
lbXByte,
|
||||
lbYByte,
|
||||
ref sceneryCounter),
|
||||
SourceGfxObjOrSetupId = spawn.ObjectId,
|
||||
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
|
||||
Rotation = spawn.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
Scale = spawn.Scale,
|
||||
EffectCellId = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellId(
|
||||
lb.LandblockId,
|
||||
localX,
|
||||
localY),
|
||||
};
|
||||
if (sceneryBounds.TryGet(out var scbMin, out var scbMax))
|
||||
hydrated.SetLocalBounds(scbMin, scbMax);
|
||||
result.Add(hydrated);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
|
||||
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
|
||||
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Portal cells and drawable shell placements are accumulated in the
|
||||
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
||||
/// cannot observe this landblock until the streaming completion carries the
|
||||
/// finished transaction to ApplyLoadedTerrain.
|
||||
///
|
||||
/// Ported from pre-streaming preload lines 407-565.
|
||||
/// </summary>
|
||||
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
|
||||
uint landblockId,
|
||||
int lbX,
|
||||
int lbY,
|
||||
AcDream.App.Streaming.LandblockBuildOrigin origin,
|
||||
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
|
||||
{
|
||||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||||
|
||||
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
if (lbInfo is null || lbInfo.NumCells == 0) return result;
|
||||
|
||||
var lbOffset = new System.Numerics.Vector3(
|
||||
(lbX - origin.CenterX) * 192f,
|
||||
(lbY - origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
||||
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
|
||||
// landblock stabs (0xC0000000+, ids from LandblockLoader).
|
||||
//
|
||||
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
|
||||
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
|
||||
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
|
||||
// map Y-row produced the same id base, so interior statics collided across
|
||||
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
|
||||
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
|
||||
// entity's batches to the other (the cache hint at bucket-draw time was the
|
||||
// player's landblock, identical for both) — the session-sticky "broken stairs +
|
||||
// water barrel".
|
||||
//
|
||||
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
|
||||
// past 0xFF still bleeds into the lbY byte." That residual manifested for real:
|
||||
// the Town Network hub (205 cells, one landblock) reached 277 interior entities
|
||||
// after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT
|
||||
// landblock's Y-byte (entity script/particle tracking is keyed on entity.Id
|
||||
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
|
||||
// the fountain's water-spray script silently stopped firing). Widened the
|
||||
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
|
||||
// why this is safe (nothing decodes X/Y back out of an entity id).
|
||||
uint interiorLbX = (landblockId >> 24) & 0xFFu;
|
||||
uint interiorLbY = (landblockId >> 16) & 0xFFu;
|
||||
uint localCounter = 0;
|
||||
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||||
if (envCell is null)
|
||||
{
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
|
||||
// MUST exist in the cell dat — a null here is always a read anomaly.
|
||||
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 7.1: build and register room geometry for this EnvCell.
|
||||
DatReaderWriter.Types.CellStruct? cellStruct = null;
|
||||
if (envCell.EnvironmentId != 0)
|
||||
{
|
||||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
if (environment is null)
|
||||
{
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// a null Environment means this cell's WALLS are silently never
|
||||
// registered while its static objects still draw — the exact
|
||||
// white-walls geometry signature.
|
||||
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
|
||||
}
|
||||
if (environment is not null
|
||||
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
||||
{
|
||||
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
||||
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
|
||||
// drawable-geometry predicate; the actual shell placement is now owned
|
||||
// by this streaming job's EnvCellLandblockBuild transaction.
|
||||
// Static objects inside the cell continue to flow through the dispatcher
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer receives only the completed shell transaction.
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||||
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
|
||||
// shared with every draw-space consumer of portal polygons (OutsideView
|
||||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
||||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
||||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||
// neighbour and the grey clear shows through the opening (#133: ramp
|
||||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||||
// before the flood runs; the cell-build transaction reads portals, NOT
|
||||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
envCellBuild.AddCell(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellOrigin,
|
||||
physicsCellTransform,
|
||||
cellOrigin,
|
||||
cellTransform,
|
||||
hasDrawableGeometry: cellSubMeshes.Count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2d: static objects inside the EnvCell.
|
||||
foreach (var stab in envCell.StaticObjects)
|
||||
{
|
||||
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
|
||||
// targeted stabs. This is the MOMENT MeshRefs are constructed —
|
||||
// a degraded dat read here (setup null / placement frames short /
|
||||
// part GfxObj null) permanently corrupts the entity (H-A), and
|
||||
// nothing downstream ever rebuilds it. Inert when the set is empty.
|
||||
bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics
|
||||
.DumpEntitySourceIds.Contains(stab.Id);
|
||||
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
|
||||
|
||||
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
|
||||
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
|
||||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||||
// WorldBuilder editor shows it at the origin. acdream's render path came from
|
||||
// WB (no distance LOD), so without this skip it draws the marker forever (the
|
||||
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
|
||||
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u
|
||||
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
|
||||
continue;
|
||||
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
// #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a
|
||||
// runtime-hidden marker (#136) flattens to zero mesh refs even though its
|
||||
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
||||
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
|
||||
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
|
||||
// the entity that would otherwise carry those lights to the registration
|
||||
// pass (GameWindow.cs ~7900).
|
||||
int stabLightCount = 0;
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
else if (dumpStab)
|
||||
{
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped");
|
||||
}
|
||||
}
|
||||
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(stab.Id, setup);
|
||||
stabLightCount = setup.Lights.Count;
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
if (dumpStab)
|
||||
{
|
||||
dumpSetupParts = setup.Parts.Count;
|
||||
dumpPlacementFrames = setup.PlacementFrames.Count;
|
||||
dumpFlattened = flat.Count;
|
||||
}
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
// #136: skip an editor-only marker PART (retail hides it at runtime
|
||||
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
|
||||
// part GfxObj 0x010028CA is such a marker — skipping it empties
|
||||
// meshRefs and the whole stab drops below.
|
||||
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
|
||||
continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null)
|
||||
{
|
||||
dumpDropped++;
|
||||
if (dumpStab)
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped");
|
||||
continue;
|
||||
}
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||||
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
}
|
||||
else if (dumpStab)
|
||||
{
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped");
|
||||
}
|
||||
}
|
||||
|
||||
if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount))
|
||||
{
|
||||
if (dumpStab)
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped");
|
||||
continue;
|
||||
}
|
||||
if (meshRefs.Count == 0 && dumpStab)
|
||||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier");
|
||||
|
||||
// Stabs inside EnvCells are already in landblock-local coordinates
|
||||
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
|
||||
// be wrong — see Phase 2d comment in the pre-streaming preload.
|
||||
var worldPos = stab.Frame.Origin + lbOffset;
|
||||
var worldRot = stab.Frame.Orientation;
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
|
||||
interiorLbX,
|
||||
interiorLbY,
|
||||
ref localCounter),
|
||||
SourceGfxObjOrSetupId = stab.Id,
|
||||
Position = worldPos,
|
||||
Rotation = worldRot,
|
||||
MeshRefs = meshRefs,
|
||||
ParentCellId = envCellId,
|
||||
};
|
||||
if (interiorBounds.TryGet(out var ibMin, out var ibMax))
|
||||
hydrated.SetLocalBounds(ibMin, ibMax);
|
||||
|
||||
if (dumpStab)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " +
|
||||
$"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " +
|
||||
$"built={meshRefs.Count} dropped={dumpDropped} " +
|
||||
$"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})");
|
||||
for (int i = 0; i < meshRefs.Count; i++)
|
||||
{
|
||||
var t = meshRefs[i].PartTransform.Translation;
|
||||
Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})");
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(hydrated);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
|
||||
{
|
||||
uint landblockX = (block.Id >> 24) & 0xFFu;
|
||||
uint landblockY = (block.Id >> 16) & 0xFFu;
|
||||
return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap(
|
||||
block.Height, heightTable, landblockX, landblockY, localX, localY);
|
||||
}
|
||||
|
||||
}
|
||||
509
tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
Normal file
509
tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
using System.Reflection;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class LandblockBuildFactoryTests
|
||||
{
|
||||
private const uint LandblockId = 0xA9B4FFFFu;
|
||||
private const uint AdjacentLandblockId = 0xA9B5FFFFu;
|
||||
private static readonly LandblockBuildOrigin HoltburgOrigin = new(0xA9, 0xB4);
|
||||
|
||||
[Fact]
|
||||
public void BuildFar_ReturnsOnlyHeightmapAndCapturedOrigin()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
var heightmap = new LandBlock { Id = LandblockId };
|
||||
proxy.Add(LandblockId, heightmap);
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Same(heightmap, result.Landblock.Heightmap);
|
||||
Assert.Empty(result.Landblock.Entities);
|
||||
Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats);
|
||||
Assert.Null(result.EnvCells);
|
||||
Assert.Equal(HoltburgOrigin, result.Origin);
|
||||
Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RejectsUnspecifiedOriginBeforeReadingDat()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
Assert.Throws<ArgumentException>(() => factory.Build(
|
||||
new LandblockBuildRequest(
|
||||
LandblockId,
|
||||
LandblockStreamJobKind.LoadFar,
|
||||
Generation: 1,
|
||||
Origin: default)));
|
||||
Assert.Empty(proxy.Reads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MissingHeightmapReturnsNullWithoutPartialPayload()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar));
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNear_MissingLandblockInfoReturnsCompleteEmptyNearPayload()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.Landblock.Entities);
|
||||
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
|
||||
result.EnvCells);
|
||||
Assert.Empty(envCells.VisibilityCells);
|
||||
Assert.Empty(envCells.Shells);
|
||||
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
|
||||
Assert.Null(physics.Info);
|
||||
Assert.Empty(physics.EnvCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNear_MissingEnvCellSkipsOnlyThatCellWithoutPartialVisibilityEntry()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2);
|
||||
uint missingCell = (LandblockId & 0xFFFF0000u) | 0x0101u;
|
||||
proxy.Remove<EnvCell>(missingCell);
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear));
|
||||
|
||||
Assert.NotNull(result);
|
||||
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
|
||||
result.EnvCells);
|
||||
Assert.Equal(
|
||||
[(LandblockId & 0xFFFF0000u) | 0x0100u],
|
||||
envCells.VisibilityCells.Select(cell => cell.CellId));
|
||||
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
|
||||
Assert.Single(physics.EnvCells);
|
||||
Assert.DoesNotContain(missingCell, physics.EnvCells.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_SerializesCompleteNearTransactionsOnSharedGate()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
AddNearFixture(proxy, LandblockId, environmentId: 1);
|
||||
AddNearFixture(proxy, AdjacentLandblockId, environmentId: 2);
|
||||
object gate = new();
|
||||
var factory = Factory(dat, gate);
|
||||
|
||||
Assert.NotNull(factory.Build(Request(
|
||||
LandblockId,
|
||||
LandblockStreamJobKind.LoadNear,
|
||||
generation: 1,
|
||||
HoltburgOrigin)));
|
||||
(Type Type, uint Id)[] firstSequence = proxy.Reads.ToArray();
|
||||
proxy.ClearReads();
|
||||
Assert.NotNull(factory.Build(Request(
|
||||
AdjacentLandblockId,
|
||||
LandblockStreamJobKind.LoadNear,
|
||||
generation: 1,
|
||||
new LandblockBuildOrigin(0xA9, 0xB5))));
|
||||
(Type Type, uint Id)[] secondSequence = proxy.Reads.ToArray();
|
||||
proxy.ClearReads();
|
||||
proxy.ReadDelay = TimeSpan.FromMilliseconds(5);
|
||||
|
||||
Task<LandblockBuild?> first = Task.Run(() =>
|
||||
factory.Build(Request(
|
||||
LandblockId,
|
||||
LandblockStreamJobKind.LoadNear,
|
||||
generation: 2,
|
||||
HoltburgOrigin)));
|
||||
Task<LandblockBuild?> second = Task.Run(() =>
|
||||
factory.Build(Request(
|
||||
AdjacentLandblockId,
|
||||
LandblockStreamJobKind.LoadNear,
|
||||
generation: 3,
|
||||
new LandblockBuildOrigin(0xA9, 0xB5))));
|
||||
LandblockBuild?[] results = await Task.WhenAll(first, second);
|
||||
|
||||
Assert.All(results, Assert.NotNull);
|
||||
Assert.Equal(1, proxy.MaxConcurrentReads);
|
||||
(Type Type, uint Id)[] combined = proxy.Reads.ToArray();
|
||||
Assert.True(
|
||||
combined.SequenceEqual(firstSequence.Concat(secondSequence))
|
||||
|| combined.SequenceEqual(secondSequence.Concat(firstSequence)),
|
||||
"Near-build DAT reads interleaved instead of remaining one serialized transaction.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_UsesTheSuppliedSharedReaderGate()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
|
||||
object gate = new();
|
||||
var factory = Factory(dat, gate);
|
||||
using var started = new ManualResetEventSlim();
|
||||
|
||||
Monitor.Enter(gate);
|
||||
Task<LandblockBuild?> build;
|
||||
try
|
||||
{
|
||||
build = Task.Run(() =>
|
||||
{
|
||||
started.Set();
|
||||
return factory.Build(Request(LandblockStreamJobKind.LoadFar));
|
||||
});
|
||||
Assert.True(started.Wait(TimeSpan.FromSeconds(2)));
|
||||
Assert.False(proxy.ReadObserved.Wait(TimeSpan.FromMilliseconds(100)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Monitor.Exit(gate);
|
||||
}
|
||||
|
||||
Assert.NotNull(await build);
|
||||
Assert.True(proxy.ReadObserved.IsSet);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LandblockStreamJobKind.LoadNear)]
|
||||
[InlineData(LandblockStreamJobKind.PromoteToNear)]
|
||||
public void Build_NearAndPromoteIncludeEveryValidZeroSubmeshVisibilityCell(
|
||||
LandblockStreamJobKind kind)
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2);
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
LandblockBuild? result = factory.Build(Request(kind));
|
||||
|
||||
Assert.NotNull(result);
|
||||
var envCells = Assert.IsType<AcDream.App.Rendering.Wb.EnvCellLandblockBuild>(
|
||||
result.EnvCells);
|
||||
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
|
||||
Assert.Equal(
|
||||
[(LandblockId & 0xFFFF0000u) | 0x0100u, (LandblockId & 0xFFFF0000u) | 0x0101u],
|
||||
envCells.VisibilityCells.Select(cell => cell.CellId));
|
||||
Assert.Empty(envCells.Shells);
|
||||
Assert.NotNull(physics.Info);
|
||||
Assert.Equal(2, physics.EnvCells.Count);
|
||||
Assert.Single(physics.Environments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorSnapshotsTheRetailHeightTable()
|
||||
{
|
||||
var dat = CreateDat(out _);
|
||||
var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray();
|
||||
var factory = new LandblockBuildFactory(
|
||||
dat,
|
||||
new object(),
|
||||
heights,
|
||||
new PhysicsDataCache());
|
||||
heights[42] = -1f;
|
||||
|
||||
var snapshot = Assert.IsType<float[]>(typeof(LandblockBuildFactory)
|
||||
.GetField("_heightTable", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(factory));
|
||||
Assert.NotSame(heights, snapshot);
|
||||
Assert.Equal(42f, snapshot[42]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorRejectsIncompleteRetailHeightTable()
|
||||
{
|
||||
var dat = CreateDat(out _);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => new LandblockBuildFactory(
|
||||
dat,
|
||||
new object(),
|
||||
new float[255],
|
||||
new PhysicsDataCache()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ExceptionReleasesSharedGateForTheNextTransaction()
|
||||
{
|
||||
var dat = CreateDat(out RecordingDatProxy proxy);
|
||||
proxy.Add(LandblockId, new LandBlock { Id = LandblockId });
|
||||
proxy.ExceptionToThrow = new InvalidDataException("fixture read failure");
|
||||
var factory = Factory(dat, new object());
|
||||
|
||||
Assert.Throws<InvalidDataException>(() =>
|
||||
factory.Build(Request(LandblockStreamJobKind.LoadFar)));
|
||||
proxy.ExceptionToThrow = null;
|
||||
|
||||
Assert.NotNull(factory.Build(Request(LandblockStreamJobKind.LoadFar)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactorySurface_HasNoRendererWorldStateOrLiveOriginDependency()
|
||||
{
|
||||
Type type = typeof(LandblockBuildFactory);
|
||||
Type[] dependencyTypes = type
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Select(field => field.FieldType)
|
||||
.Concat(type.GetConstructors().SelectMany(ctor =>
|
||||
ctor.GetParameters().Select(parameter => parameter.ParameterType)))
|
||||
.ToArray();
|
||||
|
||||
Assert.DoesNotContain(dependencyTypes, dependency =>
|
||||
dependency.FullName?.Contains("GameWindow", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(dependencyTypes, dependency =>
|
||||
dependency.FullName?.Contains("GpuWorldState", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(dependencyTypes, dependency =>
|
||||
dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(dependencyTypes, dependency =>
|
||||
dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstalledDatNearBuild_IsDeterministicAndClosesEntityPhysicsPayload()
|
||||
{
|
||||
string? datDirectory = ResolveDatDirectory();
|
||||
if (datDirectory is null)
|
||||
throw SkipException.ForSkip("Installed retail DATs are required.");
|
||||
|
||||
using var dat = new BoundedTestDatCollection(datDirectory);
|
||||
var bounded = (IDatReaderWriter)dat;
|
||||
Region? region = bounded.Get<Region>(0x13000000u);
|
||||
float[]? heights = region?.LandDefs.LandHeightTable;
|
||||
Assert.NotNull(heights);
|
||||
Assert.True(heights.Length >= 256);
|
||||
var factory = new LandblockBuildFactory(
|
||||
bounded,
|
||||
new object(),
|
||||
heights,
|
||||
new PhysicsDataCache());
|
||||
LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4);
|
||||
|
||||
LandblockBuild? first = factory.Build(request);
|
||||
LandblockBuild? second = factory.Build(request);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.Equal(HoltburgOrigin, first.Origin);
|
||||
Assert.NotNull(first.EnvCells);
|
||||
Assert.NotEmpty(first.Landblock.Entities);
|
||||
AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities);
|
||||
Assert.Equal(
|
||||
first.EnvCells.VisibilityCells.Select(cell => cell.CellId),
|
||||
second.EnvCells!.VisibilityCells.Select(cell => cell.CellId));
|
||||
Assert.Equal(
|
||||
first.EnvCells.Shells.Select(shell => shell.GeometryId),
|
||||
second.EnvCells.Shells.Select(shell => shell.GeometryId));
|
||||
|
||||
LandBlockInfo? info = bounded.Get<LandBlockInfo>(
|
||||
(LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
Assert.NotNull(info);
|
||||
var expectedVisibilityCells = new List<uint>();
|
||||
uint firstCellId = (LandblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < info.NumCells; offset++)
|
||||
{
|
||||
uint cellId = firstCellId + offset;
|
||||
EnvCell? cell = bounded.Get<EnvCell>(cellId);
|
||||
if (cell is null || cell.EnvironmentId == 0)
|
||||
continue;
|
||||
DatReaderWriter.DBObjs.Environment? environment =
|
||||
bounded.Get<DatReaderWriter.DBObjs.Environment>(
|
||||
0x0D000000u | cell.EnvironmentId);
|
||||
if (environment?.Cells.ContainsKey(cell.CellStructure) == true)
|
||||
expectedVisibilityCells.Add(cellId);
|
||||
}
|
||||
Assert.Equal(
|
||||
expectedVisibilityCells,
|
||||
first.EnvCells.VisibilityCells.Select(cell => cell.CellId));
|
||||
|
||||
PhysicsDatBundle physics = Assert.IsType<PhysicsDatBundle>(
|
||||
first.Landblock.PhysicsDats);
|
||||
Assert.NotNull(physics.Info);
|
||||
foreach (WorldEntity entity in first.Landblock.Entities)
|
||||
{
|
||||
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
Assert.Contains(entity.SourceGfxObjOrSetupId, physics.Setups.Keys);
|
||||
foreach (MeshRef mesh in entity.MeshRefs)
|
||||
Assert.Contains(mesh.GfxObjId, physics.GfxObjs.Keys);
|
||||
}
|
||||
}
|
||||
|
||||
private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) =>
|
||||
new(dat, gate, new float[256], new PhysicsDataCache());
|
||||
|
||||
private static LandblockBuildRequest Request(
|
||||
LandblockStreamJobKind kind,
|
||||
ulong generation = 1) =>
|
||||
new(LandblockId, kind, generation, HoltburgOrigin);
|
||||
|
||||
private static LandblockBuildRequest Request(
|
||||
uint landblockId,
|
||||
LandblockStreamJobKind kind,
|
||||
ulong generation,
|
||||
LandblockBuildOrigin origin) =>
|
||||
new(landblockId, kind, generation, origin);
|
||||
|
||||
private static void AddNearFixture(
|
||||
RecordingDatProxy proxy,
|
||||
uint landblockId,
|
||||
ushort environmentId,
|
||||
uint cellCount = 1)
|
||||
{
|
||||
proxy.Add(landblockId, new LandBlock { Id = landblockId });
|
||||
proxy.Add(
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFEu,
|
||||
new LandBlockInfo { NumCells = cellCount });
|
||||
|
||||
var environment = new DatReaderWriter.DBObjs.Environment
|
||||
{
|
||||
Id = 0x0D000000u | environmentId,
|
||||
};
|
||||
for (uint offset = 0; offset < cellCount; offset++)
|
||||
{
|
||||
ushort structure = checked((ushort)(offset + 1));
|
||||
uint cellId = (landblockId & 0xFFFF0000u) | (0x0100u + offset);
|
||||
proxy.Add(cellId, new EnvCell
|
||||
{
|
||||
Id = cellId,
|
||||
EnvironmentId = environmentId,
|
||||
CellStructure = structure,
|
||||
Position = new Frame
|
||||
{
|
||||
Orientation = Quaternion.Identity,
|
||||
},
|
||||
});
|
||||
environment.Cells[structure] = EmptyCellStruct();
|
||||
}
|
||||
proxy.Add(environment.Id, environment);
|
||||
}
|
||||
|
||||
private static CellStruct EmptyCellStruct() => new()
|
||||
{
|
||||
VertexArray = new VertexArray
|
||||
{
|
||||
Vertices = new Dictionary<ushort, SWVertex>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static IDatReaderWriter CreateDat(out RecordingDatProxy proxy)
|
||||
{
|
||||
IDatReaderWriter dat = DispatchProxy.Create<IDatReaderWriter, RecordingDatProxy>();
|
||||
proxy = (RecordingDatProxy)(object)dat;
|
||||
return dat;
|
||||
}
|
||||
|
||||
private static void AssertEntityBuildsEqual(
|
||||
IReadOnlyList<WorldEntity> expected,
|
||||
IReadOnlyList<WorldEntity> actual)
|
||||
{
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
for (int i = 0; i < expected.Count; i++)
|
||||
{
|
||||
WorldEntity left = expected[i];
|
||||
WorldEntity right = actual[i];
|
||||
Assert.Equal(left.Id, right.Id);
|
||||
Assert.Equal(left.SourceGfxObjOrSetupId, right.SourceGfxObjOrSetupId);
|
||||
Assert.Equal(left.Position, right.Position);
|
||||
Assert.Equal(left.Rotation, right.Rotation);
|
||||
Assert.Equal(left.ParentCellId, right.ParentCellId);
|
||||
Assert.Equal(left.EffectCellId, right.EffectCellId);
|
||||
Assert.Equal(left.MeshRefs.Count, right.MeshRefs.Count);
|
||||
for (int part = 0; part < left.MeshRefs.Count; part++)
|
||||
Assert.Equal(left.MeshRefs[part], right.MeshRefs[part]);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveDatDirectory()
|
||||
{
|
||||
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
|
||||
return configured;
|
||||
const string installed = @"C:\Turbine\Asheron's Call";
|
||||
if (Directory.Exists(installed))
|
||||
return installed;
|
||||
string documents = Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
return Directory.Exists(documents) ? documents : null;
|
||||
}
|
||||
|
||||
private class RecordingDatProxy : DispatchProxy
|
||||
{
|
||||
private readonly Dictionary<(Type Type, uint Id), object> _objects = new();
|
||||
private readonly object _gate = new();
|
||||
private int _activeReads;
|
||||
|
||||
internal List<(Type Type, uint Id)> Reads { get; } = new();
|
||||
internal int MaxConcurrentReads { get; private set; }
|
||||
internal TimeSpan ReadDelay { get; set; }
|
||||
internal Exception? ExceptionToThrow { get; set; }
|
||||
internal ManualResetEventSlim ReadObserved { get; } = new();
|
||||
|
||||
internal void Add<T>(uint id, T value) where T : class =>
|
||||
_objects[(typeof(T), id)] = value;
|
||||
|
||||
internal void Remove<T>(uint id) where T : class =>
|
||||
_objects.Remove((typeof(T), id));
|
||||
|
||||
internal void ClearReads()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Reads.Clear();
|
||||
MaxConcurrentReads = 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
if (targetMethod?.Name == "Get" && args is [uint id])
|
||||
{
|
||||
Type type = targetMethod.ReturnType;
|
||||
int active = Interlocked.Increment(ref _activeReads);
|
||||
ReadObserved.Set();
|
||||
lock (_gate)
|
||||
{
|
||||
Reads.Add((type, id));
|
||||
MaxConcurrentReads = Math.Max(MaxConcurrentReads, active);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (ReadDelay > TimeSpan.Zero)
|
||||
Thread.Sleep(ReadDelay);
|
||||
if (ExceptionToThrow is { } error)
|
||||
throw error;
|
||||
return _objects.TryGetValue((type, id), out object? value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _activeReads);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMethod?.ReturnType == typeof(void))
|
||||
return null;
|
||||
if (targetMethod?.ReturnType.IsValueType == true)
|
||||
return Activator.CreateInstance(targetMethod.ReturnType);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -232,27 +232,45 @@ public sealed class LandblockBuildOriginTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindowBuildAndPublication_UseCapturedOriginInsteadOfLiveCenter()
|
||||
public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter()
|
||||
{
|
||||
string root = FindRepoRoot();
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
string gameWindowSource = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
string buildSection = Slice(
|
||||
source,
|
||||
"private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(",
|
||||
"private void AdvanceLandblockPresentationRetirement(");
|
||||
string buildSource = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Streaming",
|
||||
"LandblockBuildFactory.cs"));
|
||||
string publicationSection = Slice(
|
||||
source,
|
||||
gameWindowSource,
|
||||
"private void ApplyLoadedTerrainLocked(",
|
||||
"private void OnUpdate(double dt)");
|
||||
|
||||
Assert.Contains("request.Origin", buildSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", buildSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", buildSection, StringComparison.Ordinal);
|
||||
Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"BuildLandblockForStreaming",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"BuildSceneryEntitiesForStreaming",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"BuildInteriorEntitiesForStreaming",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"BuildPhysicsDatBundle",
|
||||
gameWindowSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal);
|
||||
Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue