Campaign OVERHAUL S1 chunk B. - CellMesh.Build's render role is deleted; CellMesh.HasDrawableGeometry is the exact predicate (any sides_type candidate whose resolved surface passes the built-EnvCell (Surface.Type & 6) != 0 test), and the streaming build job consumes it. Core and Content no longer carry two CellStruct interpretations. - Installed-DAT scan over the complete Environment corpus (5,346 landblocks, 729,888 cells, 8,601,560 polygons, 8,608,746 candidates): 3,197 old-rule admissions were untextured surfaces the NoPos proxy let through; zero new-only; zero unexplained; zero unknown sides values; counts pinned as goldens against the contract's DAT hashes. - Canonical pins: 0xF4180104 has eight ST_DOUBLE clip-map polygons and 44 drawable side calls; the canonical NoPos surfaces are type 0x11 and are constructed but skipped; two independent extractions of all ten canonical cells hash identically. - AP-234 retired from the divergence register (161 active rows) with ConstructMesh 0x0059DFA0 / DrawEnvCell 0x0059F170 / DrawMesh 0x0059D4A0. - StipplingSurfaceEquivalenceTests no longer claims a build-time NoPos skip; the cell half is report-only, the building half stays pinned. - Inventory and policy remarks updated; plan ledger records chunk A and the capture kit. Core Meshing+Conformance 95/95, Content 208/208 (Lane!=Timing, Lane!=PreparedPackage), App EnvCell/LandblockBuild/Streaming 432/432, Release solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
700 lines
36 KiB
C#
700 lines
36 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);
|
|
// Campaign FW3.1: the walk landscape's per-block z-slab
|
|
// (CLandBlock::calc_lighting-adjacent unpack @0x0052f1d0). Every
|
|
// near-tier landblock has outdoor terrain heights regardless of
|
|
// whether it has interior cells or buildings, so this is
|
|
// unconditional — unlike WalkBuildings (added below, inside
|
|
// BuildInteriorEntitiesForStreaming, where LandBlockInfo is already
|
|
// in hand).
|
|
(float walkMaxZ, float walkMinZ) = ComputeWalkZSlab(baseLoaded.Heightmap.Height);
|
|
envCellBuild.SetWalkZSlab(walkMaxZ, walkMinZ);
|
|
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);
|
|
|
|
// Campaign FW3.1: the walk's building placements (portals + drawing
|
|
// BSP + degrade ladder + sort center), read from the SAME
|
|
// LandBlockInfo.Buildings this method already fetched above — no
|
|
// extra DAT read. This closes the gap where Wb.BuildingLoader
|
|
// .AddBuilding (update thread, no DAT access) keeps only
|
|
// ModelId/Frame/portal-flags/stab-lists for its own BFS seeding and
|
|
// drops everything the frame walk needs.
|
|
envCellBuild.AddWalkBuildings(
|
|
AcDream.App.Rendering.Walk.WalkBuildingFactory.Build(
|
|
_dats, landblockId, lbInfo.Buildings, lbOffset));
|
|
|
|
// 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.HasDrawableGeometry (OH2/S1
|
|
// chunk B, retired CellMesh.Build's render-production role and AP-234's
|
|
// NoPos approximation with it) remains the 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 AND the
|
|
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
|
// not just drawable ones. Campaign FW3.3 (2026-08-30): the +0.02 m
|
|
// ShellDrawLiftZ render lift is RETIRED (register row AP-32 deleted) —
|
|
// retail draws cell geometry at the dat EnvCell origin verbatim, and the
|
|
// coplanar tie-breaks the lift stood in for are owned by the walk's
|
|
// retail draw order under depth Less. Render and physics now share the
|
|
// one verbatim transform.
|
|
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
|
var cellOrigin = physicsCellOrigin;
|
|
var cellTransform =
|
|
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
|
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
|
var physicsCellTransform = cellTransform;
|
|
|
|
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
|
// of whether CellMesh.HasDrawableGeometry reports drawable geometry. 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).
|
|
bool hasDrawableGeometry =
|
|
AcDream.Core.Meshing.CellMesh.HasDrawableGeometry(envCell, cellStruct, _dats);
|
|
envCellBuild.AddCell(
|
|
envCellId,
|
|
envCell,
|
|
cellStruct,
|
|
physicsCellOrigin,
|
|
physicsCellTransform,
|
|
cellOrigin,
|
|
cellTransform,
|
|
hasDrawableGeometry: hasDrawableGeometry);
|
|
}
|
|
}
|
|
|
|
// Phase 2d: static objects inside the EnvCell.
|
|
foreach (var stab in envCell.StaticObjects)
|
|
{
|
|
// #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 ((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);
|
|
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)
|
|
{
|
|
continue;
|
|
}
|
|
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
|
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
|
|
meshRefs.Add(mr);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 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);
|
|
|
|
result.Add(hydrated);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
/// <summary>Campaign FW3.1: retail's per-block z-slab
|
|
/// (<c>WalkLandscapeDatBuilder</c>'s port target —
|
|
/// <c>CLandBlock::calc_lighting</c>-adjacent unpack @0x0052f1d0):
|
|
/// <c>max_zval = heightTable[maxByte] + 200</c>,
|
|
/// <c>min_zval = heightTable[minByte] - 1</c>, over the landblock's own
|
|
/// 81-byte heightmap. Uses <see cref="_heightTable"/> — already a
|
|
/// constructor field, so this needs no additional DAT read.</summary>
|
|
private (float MaxZ, float MinZ) ComputeWalkZSlab(byte[] heights)
|
|
{
|
|
byte maxByte = 0, minByte = 255;
|
|
foreach (byte h in heights)
|
|
{
|
|
if (h > maxByte) maxByte = h;
|
|
if (h < minByte) minByte = h;
|
|
}
|
|
return (_heightTable[maxByte] + 200f, _heightTable[minByte] - 1f);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
}
|