refactor(streaming): extract landblock physics publisher

Move streamed terrain/cell/building and static collision publication behind a focused update-thread owner. Preserve retail publication order while making replacement and retirement exact by logical landblock ownership, including current-cell rebasing and adjacent-seam isolation.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-21 21:13:22 +02:00
parent acb6b34d01
commit 3613d393e6
7 changed files with 1538 additions and 546 deletions

View file

@ -152,6 +152,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
@ -2424,6 +2425,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
build,
_wbMeshAdapter.MeshManager!),
removeEnvCells: _envCellRenderer.RemoveLandblock);
_landblockPhysicsPublisher =
new AcDream.App.Streaming.LandblockPhysicsPublisher(
_physicsEngine,
_heightTable!);
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
@ -3349,7 +3354,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
// cell-snap resolves the player into the stale center. On a replacement this
// can be the abandoned first destination, not the unplaced player's source.
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
_landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId);
_liveWorldOrigin.Recenter(lbX, lbY);
newWorldPos = new System.Numerics.Vector3(
@ -3814,9 +3819,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
() =>
{
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
_physicsEngine.RemoveLandblock(ticket.LandblockId);
_landblockPhysicsPublisher!.RemoveLandblock(ticket.LandblockId);
else
_physicsEngine.DemoteLandblockToTerrain(ticket.LandblockId);
_landblockPhysicsPublisher!.DemoteToTerrain(ticket.LandblockId);
});
ticket.RunOnce(
AcDream.App.Streaming.LandblockRetirementStage.CellVisibility,
@ -3885,577 +3890,125 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}
}
private void PublishLandblockStaticLightingBeforeCollision(
AcDream.Core.World.WorldEntity entity,
AcDream.Core.World.PhysicsDatBundle datBundle)
{
if (_lightingSink is null || _dats is null)
return;
uint sourceId = entity.SourceGfxObjOrSetupId;
if ((sourceId & 0xFF000000u) != 0x02000000u)
return;
if (!datBundle.Setups.TryGetValue(sourceId, out var setup)
|| setup.Lights.Count == 0)
{
return;
}
// A landblock can reapply without an unload (#168 ForceReloadWindow
// and Far-to-Near promotion). Static IDs are deterministic, so replace
// the prior owner state before registering the Setup lights.
uint effectOwnerId = entity.Id;
_lightingSink.UnregisterOwner(effectOwnerId);
_translucencyFades.ClearEntity(effectOwnerId);
var lights = AcDream.Core.Lighting.LightInfoLoader.Load(
setup,
ownerId: effectOwnerId,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u);
foreach (var light in lights)
_lightingSink.RegisterOwnedLight(light);
}
private void ApplyLoadedTerrainLocked(
AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
var lb = build.Landblock;
if (_landblockRenderPublisher is null || _dats is null || _heightTable is null)
AcDream.Core.World.LoadedLandblock landblock = build.Landblock;
if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null)
return;
// Every DAT object publication consumes was captured by the worker.
// The update-thread publishers consume only this immutable bundle.
var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagBefore =
AcDream.Core.World.PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore =
_landblockRenderPublisher.Diagnostics;
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
_landblockRenderPublisher.BeginPublication(build, meshData);
var origin = renderPublication.Origin;
System.Numerics.Vector3 origin = renderPublication.Origin;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix =
_landblockRenderPublisher.Diagnostics;
if (_frameDiag)
{
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagAfter =
_landblockRenderPublisher.Diagnostics;
long terrainTicks =
renderDiagAfter.TerrainPublishTicks - renderDiagBefore.TerrainPublishTicks;
renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks;
_applyUploadAccumTicks += terrainTicks;
_applyCellAccumTicks +=
renderDiagAfter.BeginPublishTicks
- renderDiagBefore.BeginPublishTicks
renderPrefix.BeginPublishTicks
- renderBefore.BeginPublishTicks
- terrainTicks;
}
// [FRAME-DIAG]: the publisher reports terrain and render-prefix time;
// this checkpoint begins the physics/cache portion of the cell span.
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore =
_landblockPhysicsPublisher.Diagnostics;
AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication =
_landblockPhysicsPublisher.BeginPublication(renderPublication);
// Phase B.3: populate the physics engine with terrain + indoor cell
// surfaces for this landblock. Runs under _datLock (same lock as the
// rest of ApplyLoadedTerrainLocked) so dat reads are safe.
// Retail initializes buildings/visible cells before ordinary static
// collision and scripts. The two receipts retain one captured origin
// while each focused owner commits its exact stage.
_landblockRenderPublisher.CompletePublication(renderPublication);
_landblockPhysicsPublisher.CompletePublication(
physicsPublication,
entity => PublishLandblockStaticLightingBeforeCollision(
entity,
datBundle));
if (_frameDiag)
{
uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu;
uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu;
// Extract per-vertex terrain-type bytes so TerrainSurface can
// classify water cells for ValidateWalkable's water-depth
// adjustment. Each TerrainInfo is a ushort with Type in bits
// 2-6; taking the low byte preserves those bits (+ Road in 0-1,
// which the classifier masks off).
var terrainBytes = new byte[81];
for (int i = 0; i < 81; i++)
terrainBytes[i] = (byte)(ushort)lb.Heightmap.Terrain[i];
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY, terrainBytes);
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();
var lbInfo = datBundle.Info;
if (lbInfo is not null && lbInfo.NumCells > 0)
{
uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
{
uint envCellId = firstCellId + offset;
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) continue;
if (envCell.EnvironmentId == 0) continue;
if (!datBundle.Environments.TryGetValue(
0x0D000000u | envCell.EnvironmentId, out var environment)) continue;
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) continue;
// Transform CellStruct vertices from cell-local to world space.
var rot = envCell.Position.Orientation;
var cellOriginWorld = envCell.Position.Origin + origin;
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(rot) *
System.Numerics.Matrix4x4.CreateTranslation(cellOriginWorld);
// Commit live physics-cache state on the render thread with
// the same completed landblock transaction. The worker only
// builds private output; it no longer mutates cell registries.
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
physicsCellTransform);
var worldVerts = new Dictionary<ushort, System.Numerics.Vector3>(
cellStruct.VertexArray.Vertices.Count);
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices)
{
var localPos = vtx.Origin;
var worldPos = System.Numerics.Vector3.Transform(localPos, rot) + cellOriginWorld;
worldVerts[(ushort)vid] = worldPos;
}
// Extract polygon vertex-id lists from PhysicsPolygons.
// PhysicsPolygons is Dictionary<ushort, Polygon>; iterate Values.
var polyVids = new List<List<short>>(cellStruct.PhysicsPolygons.Count);
foreach (var poly in cellStruct.PhysicsPolygons.Values)
{
var vids = new List<short>(poly.VertexIds.Count);
foreach (var vid in poly.VertexIds)
vids.Add(vid);
polyVids.Add(vids);
}
cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(envCellId, worldVerts, polyVids));
// Extract portal planes from this EnvCell's CellPortals.
// CellPortal.PolygonId indexes cellStruct.Polygons (rendering polygons),
// NOT PhysicsPolygons — confirmed by ACViewer EnvCell.find_transit_cells.
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly))
continue;
if (poly.VertexIds.Count < 3)
continue;
// Collect ALL polygon vertices for accurate centroid + radius.
var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count];
bool allFound = true;
for (int pv = 0; pv < poly.VertexIds.Count; pv++)
{
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv]))
{ allFound = false; break; }
}
if (!allFound) continue;
portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices(
portalVerts.AsSpan(),
portal.OtherCellId, // target cell (0xFFFF = outdoor)
envCellId & 0xFFFFu, // owner cell (low 16 bits)
(ushort)portal.Flags));
}
}
}
// Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit.
// Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world-
// relative origin + orientation) and a Portals list. The landcell id is
// derived from the building's frame origin using retail's row-major grid
// formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock.
if (lbInfo is not null && lbInfo.Buildings.Count > 0)
{
// #146 (2026-06-24): clear this landblock's prior building cache so
// the loop re-bases each building's STREAMING-RELATIVE WorldTransform
// with this completion's captured origin (recomputed above per apply).
// CacheBuilding's per-cell first-wins then applies fresh within this
// pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock).
// Without it, CacheBuilding's idempotent guard locks the transform at
// the frame it was first cached with, so a teleport recenter strands
// the shell BSP at a stale world offset and house walls stop colliding
// (login at Arwic → portal to Holtburg → walls clip; bldOrigin ~5.5km off).
_physicsDataCache.RemoveBuildingsForLandblock(lb.LandblockId);
uint lbPrefix = lb.LandblockId & 0xFFFF0000u;
foreach (var building in lbInfo.Buildings)
{
// #147 (2026-06-24): do NOT skip portal-less buildings. A town's
// PERIMETER WALL is a building with NO portals (you don't enter a
// wall segment through a door) — but it still needs its shell BSP
// registered for COLLISION. The original skip existed for the
// transit/entry feature (CellTransit.CheckBuildingTransit); dropping
// the whole building also dropped its collision shell, so a far
// town's city walls had ZERO collision and the player walked through
// them (Arwic: 16 of 30 buildings are portal-less wall segments
// ringing the town at 24 m intervals). Retail collides with a
// building's shell regardless of portals — find_building_collisions
// (0x006b5300) tests parts[0], independent of the portal list. So a
// portal-less building is now cached with an EMPTY portal list (no
// transit) but its collision shell (ModelId) intact.
var bldPortals = new System.Collections.Generic.List<AcDream.Core.Physics.BldPortalInfo>(
building.Portals.Count);
foreach (var bp in building.Portals)
{
bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
otherCellId: lbPrefix | (uint)bp.OtherCellId,
// DatReaderWriter parses the dat's 16-bit field as
// ushort; retail sign-extends it to a SIGNED int
// (CBldPortal.other_portal_id, acclient.h:32098) and
// check_building_transit gates on `>= 0`
// (Ghidra 0x0052c5dc). 0xFFFF → -1 = no reciprocal.
otherPortalId: unchecked((short)bp.OtherPortalId),
flags: (ushort)bp.Flags));
}
// Build a world transform for the building. Frame.Origin is
// landblock-relative; add the landblock world origin to get
// world space.
var bldOriginWorld = building.Frame.Origin + origin;
var buildingTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
* System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld);
// Derive the outdoor landcell id containing this building.
// Reuse TerrainSurface.ComputeOutdoorCellId rather than
// re-deriving the row-major (gridX * 8 + gridY + 1) formula here.
// Frame.Origin is landblock-relative, same coordinate space as
// ComputeOutdoorCellId expects (local X/Y within the 192m block).
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
building.Frame.Origin.X, building.Frame.Origin.Y);
uint landcellId = lbPrefix | landcellLow;
// BR-7 / A6.P4 (2026-06-11): the shell part-0 GfxObj id
// arms the retail building collision channel
// (CBuildingObj::find_building_collisions, Ghidra
// 0x006b5300 — one BSP test on part_array->parts[0]).
// 0x01 model ids are the GfxObj; 0x02 Setups resolve to
// their first part here, where the dat is at hand.
uint shellPart0 = building.ModelId;
if ((shellPart0 & 0xFF000000u) == 0x02000000u)
{
// _dats is non-null on every streaming-drain path
// (the GfxObj physics cache loop below dereferences
// it unconditionally).
datBundle.Setups.TryGetValue(building.ModelId, out var bldSetup);
shellPart0 = bldSetup is not null && bldSetup.Parts.Count > 0
? bldSetup.Parts[0]
: 0u;
}
_physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform,
modelId: shellPart0);
}
}
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y);
_landblockRenderPublisher.CompletePublication(renderPublication);
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter =
_landblockPhysicsPublisher.Diagnostics;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter =
_landblockRenderPublisher.Diagnostics;
long gfxCacheTicks =
physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks;
_applyCellAccumTicks +=
physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks;
_applyCellAccumTicks +=
renderAfter.CompletePublishTicks
- renderPrefix.CompletePublishTicks;
_applyBspAccumTicks += gfxCacheTicks;
_applyShadowAccumTicks +=
physicsAfter.CompletePublishTicks
- physicsBefore.CompletePublishTicks
- gfxCacheTicks;
}
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
// and the legacy cell-mesh upload path are retired with InstancedMeshRenderer.
// Cache GfxObj physics data (BSP trees) for the physics engine — this
// loop is physics-only, not renderer-side.
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyCellAccumTicks += t - fdCheck; fdCheck = t; }
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
if (!datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) continue;
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
}
}
// [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; }
// Task 7: register static entities into the ShadowObjectRegistry so the
// Transition system can find and collide against them during movement.
// Only entities backed by a GfxObj with a physics BSP are registered —
// entities with no BSP (pure visual, no physics) are skipped.
//
// Radius source priority:
// 1. GfxObj: use the BSP root bounding sphere radius if available.
// 2. Setup: use Setup.Radius (the capsule radius) if available.
// 3. Fallback: 1.0m (conservative default for trees / small objects).
int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0;
int scTried = 0;
foreach (var entity in lb.Entities)
{
// Phase G.2: if the entity's Setup has baked-in LightInfos,
// register them with the LightManager so torches, braziers,
// and lifestones cast real light on nearby geometry. Hooked
// via the LightingHookSink so per-entity owner tracking +
// SetLightHook IsLit toggles all go through one codepath.
// Only applies to Setup-sourced entities (0x02xxxxxx) — raw
// GfxObjs don't carry Lights dictionaries.
if (_lightingSink is not null && _dats is not null)
{
uint src = entity.SourceGfxObjOrSetupId;
if ((src & 0xFF000000u) == 0x02000000u)
{
datBundle.Setups.TryGetValue(src, out var datSetup);
if (datSetup is not null && datSetup.Lights.Count > 0)
{
// #176 site-A light-stacking leak: a landblock can RE-apply
// without an unload in between (#168 ForceReloadWindow, Far→Near
// promotion), and static entity ids are deterministic — so each
// re-apply appended another copy of the same lights under the
// same owner (observed ×2→×4 per session; the wash intensifies
// over time, and the stacked ties destabilize the 128-cap pool
// sort). Clear the owner's previous registration first: the
// re-apply becomes idempotent, and a first apply is a no-op.
uint effectOwnerId = entity.Id;
_lightingSink.UnregisterOwner(effectOwnerId);
_translucencyFades.ClearEntity(effectOwnerId); // #188
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
datSetup,
ownerId: effectOwnerId,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls);
}
}
}
int entityBsp = 0, entityCyl = 0;
// Treat both procedural scenery (0x80000000+) AND LandBlockInfo
// stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities
// that should use visual-mesh-AABB collision. Stabs include landscape
// trees placed by Turbine (not procedural scenery) that otherwise
// have no collision shape registered.
uint _srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
bool _isOutdoorMesh = ((entity.Id & 0x80000000u) != 0) // scenery
|| ((entity.Id < 0x40000000u) // stab
&& (_srcPrefix == 0x01000000u || _srcPrefix == 0x02000000u));
bool _isScenery = _isOutdoorMesh;
// ISSUES #83 / Phase A1 (2026-05-21): landblock stabs
// (LandBlockInfo.Objects + Buildings) use entity.Id with the
// 0xCXXYYIII layout per LandblockStaticEntityIdAllocator. Their BSP
// collision covers the whole structure; the mesh-AABB-fallback
// path below is for canopy-only-BSP procedural scenery
// (0x8XXYYIII) and produces a redundant 1.5m-clamped
// invisible disc at the stab's mesh origin — the user-reported
// "thin air" collision inside cottages. Gate the fallback to
// exclude stabs. Spec:
// docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md.
bool _isLandblockStab =
AcDream.Core.World.LandblockStaticEntityIdAllocator.IsInNamespace(entity.Id);
if (_isScenery) scTried++;
// #185 FIX (2026-07-08): register the entity's BSP parts as ONE
// multi-part shadow object keyed on the entity's UNIQUE 32-bit id,
// via RegisterMultiPart (retail CPhysicsObj::add_shadows_to_cells
// 0x00514ae0 → CPartArray::AddPartsShadow — one object, a part array).
//
// The former per-part Register(entity.Id * 256u + partIndex) OVERFLOWED
// uint32 for class-prefixed landblock ids (0x40/0x80/0xC0…): the << 8
// dropped the prefix byte, so different-class entities sharing the low
// 24 bits collided on ONE shadow part-id and Register's
// deregister-then-insert silently overwrote one entity's collision
// geometry — the #185 "invisible wall half-way up the stairs" (rendered
// steps with no collision; 23 such part-id collisions in landblock
// 0xF682 alone: 0xF6822100 ← {0x40F68221, 0xC0F68221}, …). entity.Id is
// unique, so RegisterMultiPart has no synthetic id and no collision.
// Building shells stay excluded (they collide via the per-LandCell
// building channel, CSortCell::find_collisions). The Setup CylSphere/
// Sphere path below is UNCHANGED and only runs when entityBsp == 0
// (retail binary dispatch: BSP xor cyl). Builder:
// ShadowShapeBuilder.FromLandblockBspParts.
var bspShapes = AcDream.Core.Physics.ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs, entity.IsBuildingShell,
id => _physicsDataCache.GetGfxObj(id));
entityBsp = bspShapes.Count;
if (entityBsp > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.Position, entity.Rotation, bspShapes,
0u, AcDream.Core.Physics.EntityCollisionFlags.None,
origin.X, origin.Y, lb.LandblockId,
seedCellId: entity.ParentCellId ?? 0u, isStatic: true);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// One line per BSP part, all under the entity's single id (no synthetic partId).
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
for (int _pi = 0; _pi < bspShapes.Count; _pi++)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{bspShapes[_pi].GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=multipart-part{_pi} hasPhys=true state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
}
// Register collision shapes from the Setup (if this entity has one).
// Retail uses CylSpheres for trunks/pillars, Spheres for blob-shaped
// collision volumes. We register both as "cylinder" shadow entries
// because our collision system only has BSP and Cylinder types; a
// Sphere is handled as a short cylinder.
//
// SCALE + ROTATION handling:
// - Radius, Height, and the local Origin offset are ALL scaled by
// entity.Scale so they match the visually-scaled mesh.
// - The Origin offset is ROTATED by entity.Rotation so rotated
// scenery has its collision cylinder in the correct world spot.
//
// Keying:
// entity.Id → the primary CylSphere (if any)
// entity.Id + K*0x10000000u → additional CylSpheres/Spheres
// This ensures uniqueness per shape so ShadowObjectRegistry doesn't
// clobber entries via Deregister.
{
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
// Retail binary collision dispatch (CPhysicsObj::FindObjCollisions
// @0x0050f050, see feedback_retail_binary_dispatch): an object uses
// its physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE
// its CSetup CylSpheres/Spheres — never both. The per-part BSP loop
// above already registered every BSP-bearing part (entityBsp counts
// them). So register the Setup's CylSpheres/Spheres ONLY when no BSP
// claimed this object (entityBsp == 0).
//
// This supersedes the ISSUES #83 / A1.6 (2026-05-21) gate that
// skipped Setup cyl/sphere for ALL landblock stabs to stop a
// BSP+cylinder doubled-collision. That gate over-broadened: it also
// killed collision for BSP-LESS stabs whose ONLY shape is a Setup
// CylSphere — e.g. the Holtburg town torch (stab Setup 0x020005D8,
// part 0x01001774 HasPhysics=False/BSP=null, cylSphere r=0.2 h=2.2).
// Retail collides with it via that cylsphere (live cdb 2026-06-25:
// FindObjCollisions target ncyl=1, cyl h=2.2 at world (105.99,17.17));
// acdream walked straight through. Gating on entityBsp==0 keeps #83's
// anti-doubling (stab WITH a BSP → BSP-only) while restoring cyl
// collision for BSP-less stabs. Other landblock entities on this path
// (scenery — tree-trunk cylspheres etc.) are unaffected: with no BSP
// they still register cyl/sphere exactly as before.
if (setup is not null && entityBsp == 0)
{
float entScale = entity.Scale > 0f ? entity.Scale : 1f;
uint shapeIndex = 0;
// Register every CylSphere the Setup defines.
for (int ci = 0; ci < setup.CylSpheres.Count; ci++)
{
var cyl = setup.CylSpheres[ci];
float cylRadius = cyl.Radius * entScale;
float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f;
float cylHeight = baseHeight * entScale;
if (cylRadius <= 0f) continue;
// Rotate the local origin offset by entity rotation,
// then scale it before adding to entity.Position.
var localOffset = new System.Numerics.Vector3(
cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale;
var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation);
uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
_physicsEngine.ShadowObjects.Register(
shapeId, entity.SourceGfxObjOrSetupId,
entity.Position + worldOffset,
entity.Rotation, cylRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight,
seedCellId: entity.ParentCellId ?? 0u);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// state/flags literals: landblock-baked scenery; no server PhysicsState.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-cylsphere#{ci} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
entityCyl++;
}
// Register every Sphere as a short cylinder when no
// CylSphere claimed the object.
if (setup.CylSpheres.Count == 0)
{
for (int si = 0; si < setup.Spheres.Count; si++)
{
var sph = setup.Spheres[si];
if (sph.Radius <= 0f) continue;
float sphRadius = sph.Radius * entScale;
float sphHeight = sphRadius * 2f;
// Rotate + scale the local origin, then offset the
// cylinder base down by the scaled radius so the
// short cylinder is centered on the sphere.
var localOffset = new System.Numerics.Vector3(
sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale;
var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation);
worldOffset.Z -= sphRadius;
uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
_physicsEngine.ShadowObjects.Register(
shapeId, entity.SourceGfxObjOrSetupId,
entity.Position + worldOffset,
entity.Rotation, sphRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight,
seedCellId: entity.ParentCellId ?? 0u);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// state/flags literals: landblock-baked scenery; no server PhysicsState.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-sphere#{si} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
entityCyl++;
}
}
// Setup.Radius fallback: the Setup has NO CylSpheres and NO
// Spheres but has a positive Radius/Height. Use the overall
// bounding cylinder scaled by entity.Scale.
if (setup.CylSpheres.Count == 0 && setup.Spheres.Count == 0
&& setup.Radius > 0f && entityBsp == 0)
{
float fr = setup.Radius * entScale;
float fh = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale;
uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
_physicsEngine.ShadowObjects.Register(
shapeId, entity.SourceGfxObjOrSetupId,
entity.Position, entity.Rotation, fr,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh,
seedCellId: entity.ParentCellId ?? 0u);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// state/flags literals: landblock-baked scenery; no server PhysicsState.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-radius-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
entityCyl++;
}
}
}
// Tally per-entity collision presence (debug counter — optional).
if (entityBsp > 0) lbBspCount++;
if (entityCyl > 0) lbCylCount++;
if (entityBsp == 0 && entityCyl == 0)
{
// Only count as "none" if it's an OUTDOOR entity (0x01/0x02 source).
// EnvCell entities (src = cell ID like 0xAABBxxxx) use BSP collision
// via CellPhysics and don't need cylinder registration.
uint srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
if (srcPrefix == 0x01000000u || srcPrefix == 0x02000000u)
lbNoneCount++;
}
}
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
Console.WriteLine(
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} (outdoorNone={lbNoneCount})");
// Find scenery WITHOUT any cached visual bounds at all
int sceneryNoCache = 0;
var sampleMissing = new List<uint>();
foreach (var entity in lb.Entities)
{
if ((entity.Id & 0x80000000u) == 0) continue; // not scenery
bool anyHaveBounds = false;
foreach (var mr in entity.MeshRefs)
{
var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
if (vb is not null && vb.Radius > 0f) { anyHaveBounds = true; break; }
}
if (!anyHaveBounds)
{
sceneryNoCache++;
if (sampleMissing.Count < 3)
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
}
}
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0)
{
string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
}
// BR-7 / A6.P4 (2026-06-11): this landblock's cells + buildings are
// now hydrated — re-run the registration flood for entities whose
// shadow cell set touches it. Covers the streaming races in both
// directions (server spawn before the landblock hydrated → flood
// couldn't traverse; cells hydrated after the spawn). Retail
// equivalent: CObjCell::init_objects → recalc_cross_cells on cell
// load (Ghidra 0x0052b420 / 0x00515a30).
_physicsEngine.ShadowObjects.RefloodLandblock(lb.LandblockId);
// Register each stab as a plugin snapshot so the plugin host has
// visibility into the streaming world state.
foreach (var entity in lb.Entities)
long pluginStarted =
_frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities)
{
// Live objects publish through LiveEntityRuntime exactly once per
// accepted incarnation. This landblock callback owns only DAT
// statics; a retained live projection must not emit a second spawn
// when its spatial bucket reloads.
// accepted incarnation. This callback owns DAT statics only.
if (entity.ServerGuid != 0)
continue;
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId,
Position: entity.Position,
Rotation: entity.Rotation);
var snapshot =
new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId,
Position: entity.Position,
Rotation: entity.Rotation);
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
}
// [FRAME-DIAG] checkpoint: end ShadowObjects+lights registration (apply tail).
if (_frameDiag) _applyShadowAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdCheck;
if (_frameDiag)
{
_applyShadowAccumTicks +=
System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted;
}
}
private void OnUpdate(double dt)