feat(headless): share immutable gameplay content

This commit is contained in:
Erik 2026-07-27 09:00:48 +02:00
parent 12b500d383
commit 9569dadb57
25 changed files with 1031 additions and 429 deletions

View file

@ -1,4 +1,3 @@
using System.Collections.Immutable;
using DatReaderWriter;
using AcDream.Content;
@ -102,7 +101,9 @@ public sealed class LandblockBuildFactory
}
AcDream.Core.World.LandblockCollisionBuild collisions =
BuildPreparedCollisionClosure(build.Landblock);
LandblockPhysicsContentBuilder.BuildPreparedCollisionClosure(
_preparedCollisions,
build.Landblock);
AcDream.Core.World.PhysicsDatBundle publicationDats =
(build.Landblock.PhysicsDats
?? AcDream.Core.World.PhysicsDatBundle.Empty)
@ -155,59 +156,11 @@ public sealed class LandblockBuildFactory
// expand into per-part MeshRefs via SetupMesh.Flatten.
// GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build.
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)
{
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)
{
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;
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);
}
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);
@ -226,7 +179,10 @@ public sealed class LandblockBuildFactory
// 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 physicsDats = LandblockPhysicsContentBuilder.BuildDatBundle(
_dats,
landblockId,
merged);
var completedEnvCells = envCellBuild.Build();
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
@ -237,180 +193,6 @@ public sealed class LandblockBuildFactory
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>
/// Reads the exact prepared collision closure after the serialized DAT
/// transaction completes. The immutable package is independently
/// thread-safe, so these reads never extend the shared DAT-reader lock.
/// Missing or corrupt prepared data fails the complete near-tier build;
/// production must not publish a graph-only partial generation.
/// </summary>
private AcDream.Core.World.LandblockCollisionBuild
BuildPreparedCollisionClosure(
AcDream.Core.World.LoadedLandblock landblock)
{
AcDream.Core.World.PhysicsDatBundle dats =
landblock.PhysicsDats ??
AcDream.Core.World.PhysicsDatBundle.Empty;
ImmutableArray<uint> gfxObjIds = [.. dats.GfxObjs.Keys.Order()];
ImmutableArray<uint> setupIds = [.. dats.Setups.Keys.Order()];
ImmutableArray<uint> envCellIds = [.. dats.EnvCells.Keys.Order()];
var gfxObjs =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatGfxObjCollisionAsset>();
var setups =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatSetupCollision>();
var cellStructures =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatCellStructureCollisionAsset>();
var envCells =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatEnvCellTopology>();
foreach (uint id in gfxObjIds)
{
gfxObjs.Add(
id,
RequirePrepared(
_preparedCollisions.ReadGfxObjCollision(id),
"GfxObj collision",
id));
}
foreach (uint id in setupIds)
{
setups.Add(
id,
RequirePrepared(
_preparedCollisions.ReadSetupCollision(id),
"Setup collision",
id));
}
foreach (uint id in envCellIds)
{
cellStructures.Add(
id,
RequirePrepared(
_preparedCollisions.ReadCellStructureCollision(id),
"CellStruct collision",
id));
envCells.Add(
id,
RequirePrepared(
_preparedCollisions.ReadEnvCellTopology(id),
"EnvCell topology",
id));
}
return new AcDream.Core.World.LandblockCollisionBuild(
gfxObjs.ToImmutable(),
setups.ToImmutable(),
cellStructures.ToImmutable(),
envCells.ToImmutable(),
gfxObjIds,
setupIds,
envCellIds);
}
private static T RequirePrepared<T>(
PreparedCollisionReadResult<T> result,
string kind,
uint id)
where T : class
{
if (result.Status == PreparedAssetReadStatus.Loaded &&
result.Data is not null)
{
return result.Data;
}
throw new InvalidDataException(
$"{kind} 0x{id:X8} is {result.Status}. " +
"The complete near-tier generation cannot be published.");
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.