704 lines
37 KiB
C#
704 lines
37 KiB
C#
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 IPreparedCollisionSource _preparedCollisions;
|
|
private readonly object _datLock;
|
|
private readonly float[] _heightTable;
|
|
private readonly bool _dumpSceneryZ;
|
|
|
|
public LandblockBuildFactory(
|
|
IDatReaderWriter dats,
|
|
IPreparedCollisionSource preparedCollisions,
|
|
object datLock,
|
|
float[] heightTable,
|
|
bool dumpSceneryZ = false)
|
|
{
|
|
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
|
_preparedCollisions = preparedCollisions ??
|
|
throw new ArgumentNullException(nameof(preparedCollisions));
|
|
_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();
|
|
_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.
|
|
LandblockBuild? build;
|
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
|
|
{
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
lock (_datLock)
|
|
{
|
|
long waitedMs = sw.ElapsedMilliseconds;
|
|
sw.Restart();
|
|
build = BuildLocked(request);
|
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
|
"BUILD", request.LandblockId,
|
|
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
lock (_datLock)
|
|
build = BuildLocked(request);
|
|
}
|
|
|
|
if (build is null ||
|
|
request.Kind == LandblockStreamJobKind.LoadFar)
|
|
{
|
|
return build;
|
|
}
|
|
|
|
AcDream.Core.World.LandblockCollisionBuild collisions =
|
|
LandblockPhysicsContentBuilder.BuildPreparedCollisionClosure(
|
|
_preparedCollisions,
|
|
build.Landblock);
|
|
AcDream.Core.World.PhysicsDatBundle publicationDats =
|
|
(build.Landblock.PhysicsDats
|
|
?? AcDream.Core.World.PhysicsDatBundle.Empty)
|
|
.WithoutCollisionGraphs();
|
|
return build with
|
|
{
|
|
Landblock = build.Landblock with
|
|
{
|
|
PhysicsDats = publicationDats,
|
|
},
|
|
Collisions = collisions,
|
|
};
|
|
}
|
|
|
|
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.
|
|
// GPU upload happens later through the render-thread presentation
|
|
// pipeline, never in this worker-side build.
|
|
IReadOnlyList<AcDream.Core.World.WorldEntity> hydrated =
|
|
LandblockPhysicsContentBuilder.HydrateStaticEntities(
|
|
_dats,
|
|
baseLoaded,
|
|
worldOffset);
|
|
|
|
// Task 8: merge stabs + scenery + interior into one entity list.
|
|
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
|
merged.AddRange(
|
|
_dumpSceneryZ
|
|
? BuildSceneryEntitiesForStreaming(
|
|
baseLoaded,
|
|
lbX,
|
|
lbY,
|
|
request.Origin)
|
|
: LandblockPhysicsContentBuilder
|
|
.HydrateProceduralScenery(
|
|
_dats,
|
|
baseLoaded,
|
|
worldOffset,
|
|
_heightTable));
|
|
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 = LandblockPhysicsContentBuilder.BuildDatBundle(
|
|
_dats,
|
|
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>
|
|
/// 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)
|
|
{
|
|
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)
|
|
{
|
|
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;
|
|
// 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 before the full old-window
|
|
// recenter retirement converges — 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 <c>LandblockPresentationPipeline</c>.
|
|
///
|
|
/// 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 otherwise carries those lights to the static
|
|
// presentation publisher.
|
|
int stabLightCount = 0;
|
|
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
|
{
|
|
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
|
if (gfx is not null)
|
|
{
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
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);
|
|
}
|
|
|
|
}
|