feat(app): walk interior EnvCells for in-building static objects

Addresses the "foundry statue still missing" user feedback after Phase
2c. Diagnostic spike confirmed the statue is not a Stab on
LandBlockInfo.Objects, not a Building on LandBlockInfo.Buildings, and not
a hierarchical Setup part — the highest entity on Holtburg center was
at Z=104 with no entity above the foundry cluster.

Root cause per LandBlockInfo.NumCells's own doc comment: interior cells
live at dat id 0xAAAA0100 + N where AAAA is the landblock id high word
and N runs from 0 to NumCells-1. Each EnvCell has a StaticObjects list
(List<Stab>) holding in-building decorations — statues, furniture,
lamps, crates, rugs, and the like. We weren't loading any of it.

GameWindow.OnLoad now iterates each landblock's LandBlockInfo.NumCells,
loads each EnvCell at the canonical id, walks its StaticObjects, and
hydrates each as a WorldEntity. Position is composed as:

    worldPos = landblockOffset + cellOrigin + (cellRotation * stabLocal)
    worldRot = cellRotation * stabRotation

where cellOrigin/cellRotation come from EnvCell.Position (a Frame in
landblock-local space) and stabLocal/stabRotation come from the Stab's
own Frame (cell-local space). Cell rotation is applied to the stab
position because some cells in AC are rotated relative to the landblock
grid.

Entity counts on Holtburg 3x3:
  Stabs + Buildings      239
  Procedural scenery     419
  Interior StaticObjects 475  (NEW)
  -------
  Total                 1133

Phase 2d done. Interior geometry (walls, floors, ceilings) is still not
rendered — cell shells are Phase 3+ work. Only the StaticObjects list is
walked, which is enough to surface the visible decorations the user sees
inside buildings in real AC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-10 21:45:02 +02:00
parent 985cdc0044
commit abcfb55418

View file

@ -320,8 +320,103 @@ public sealed class GameWindow : IDisposable
}
Console.WriteLine($"scenery: spawned {scenerySpawned} entities across {worldView.Landblocks.Count} landblocks");
// Phase 2d: walk interior EnvCells and add their StaticObjects. Buildings'
// rooftop statues, doors, interior decorations, and other in-building static
// objects live here rather than in LandBlockInfo.Objects. EnvCell ids for a
// landblock are packed at 0xAAAABBBB where AAAA is the landblock id high word
// and BBBB starts at 0x0100 — documented on LandBlockInfo.NumCells.
int interiorSpawned = 0;
uint interiorIdCounter = 0x40000000u; // distinct from scenery (0x80000000+) and stabs
foreach (var lb in worldView.Landblocks)
{
// Re-fetch LandBlockInfo to get NumCells. WorldView.LoadedLandblock exposes
// Heightmap + Entities but not the raw info record.
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is null || lbInfo.NumCells == 0) continue;
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
var lbOffset = new System.Numerics.Vector3(
(lbX - centerX) * 192f,
(lbY - centerY) * 192f,
0f);
// Interior cells start at 0xAAAA0100 and run for NumCells.
uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
{
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(firstCellId + offset);
if (envCell is null) continue;
foreach (var stab in envCell.StaticObjects)
{
// Resolve stab id to mesh (same as LandBlockInfo.Objects).
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((stab.Id & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(stab.Id, subMeshes);
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)
{
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 subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
meshRefs.Add(mr);
}
}
}
if (meshRefs.Count == 0) continue;
// Compose: the stab's position is cell-local, and the cell's Position
// is landblock-local. Rotate the stab-local position by the cell's
// orientation before adding the cell origin.
var stabLocalPos = stab.Frame.Origin;
var cellOrigin = envCell.Position.Origin;
var cellRot = envCell.Position.Orientation;
var rotatedStab = System.Numerics.Vector3.Transform(stabLocalPos, cellRot);
var landblockLocalPos = cellOrigin + rotatedStab;
var worldPos = landblockLocalPos + lbOffset;
var worldRot = cellRot * stab.Frame.Orientation;
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = interiorIdCounter++,
SourceGfxObjOrSetupId = stab.Id,
Position = worldPos,
Rotation = worldRot,
MeshRefs = meshRefs,
};
hydratedEntities.Add(hydrated);
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: hydrated.Id,
SourceId: hydrated.SourceGfxObjOrSetupId,
Position: hydrated.Position,
Rotation: hydrated.Rotation);
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
interiorSpawned++;
}
}
}
Console.WriteLine($"interior: spawned {interiorSpawned} static objects from EnvCells");
_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery)");
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery + interior)");
}
/// <summary>