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)

View file

@ -0,0 +1,654 @@
using System.Diagnostics;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Exact receipt for the two physics-owned stages of one accepted landblock
/// publication. The render publisher commits buildings and EnvCells between
/// these stages without recomputing the captured origin.
/// </summary>
public sealed class LandblockPhysicsPublication
{
internal LandblockPhysicsPublication(
object owner,
LandblockBuild build,
Vector3 origin)
{
Owner = owner;
Build = build;
Origin = origin;
}
internal object Owner { get; }
internal LandblockBuild Build { get; }
internal bool CompletionCommitted { get; set; }
public uint LandblockId => Build.Landblock.LandblockId;
public Vector3 Origin { get; }
}
/// <summary>
/// Cumulative update-thread diagnostics for physics publication and removal.
/// Durations are <see cref="Stopwatch"/> ticks.
/// </summary>
public readonly record struct LandblockPhysicsPublisherDiagnostics(
long BeginCount,
long CompleteCount,
long BasePublishTicks,
long GfxCacheTicks,
long CompletePublishTicks,
long CellSurfaceCount,
long PortalPlaneCount,
long BuildingCount,
long StaticBspOwnerCount,
long StaticCylinderOwnerCount,
long RefloodCount,
long DemotionCount,
long FullRemovalCount);
/// <summary>
/// Update-thread owner of streamed landblock physics publication. It consumes
/// only the immutable build payload: terrain, cells, portal planes, building
/// collision, cached GfxObj shapes, static BSP/cylinder registration, and the
/// final cross-cell reflood. It has no DAT reader or residency policy.
/// </summary>
/// <remarks>
/// The order preserves retail <c>CObjCell::init_objects</c> (0x0052B420),
/// <c>CPhysicsObj::recalc_cross_cells</c> (0x00515A30),
/// <c>CBuildingObj::find_building_collisions</c> (0x006B5300), and
/// <c>CPhysicsObj::add_shadows_to_cells</c> (0x00514AE0). The BSP-or-Setup
/// collision gate follows <c>CPhysicsObj::FindObjCollisions</c> (0x0050F050).
/// Building shells use the per-landcell building channel; ordinary statics
/// use one multipart shadow owner or the mutually exclusive Setup
/// cylinder/sphere fallback.
/// </remarks>
public sealed class LandblockPhysicsPublisher
{
private readonly object _receiptOwner = new();
private readonly PhysicsEngine _physicsEngine;
private readonly PhysicsDataCache _physicsDataCache;
private readonly float[] _heightTable;
private long _beginCount;
private long _completeCount;
private long _basePublishTicks;
private long _gfxCacheTicks;
private long _completePublishTicks;
private long _cellSurfaceCount;
private long _portalPlaneCount;
private long _buildingCount;
private long _staticBspOwnerCount;
private long _staticCylinderOwnerCount;
private long _refloodCount;
private long _demotionCount;
private long _fullRemovalCount;
public LandblockPhysicsPublisher(
PhysicsEngine physicsEngine,
float[] heightTable)
{
ArgumentNullException.ThrowIfNull(physicsEngine);
ArgumentNullException.ThrowIfNull(heightTable);
if (heightTable.Length < 256)
throw new ArgumentException(
"The retail terrain height table must contain at least 256 entries.",
nameof(heightTable));
_physicsEngine = physicsEngine;
_physicsDataCache = physicsEngine.DataCache
?? throw new ArgumentException(
"The physics engine must own its canonical data cache before publication wiring.",
nameof(physicsEngine));
_heightTable = (float[])heightTable.Clone();
}
public LandblockPhysicsPublisherDiagnostics Diagnostics => new(
_beginCount,
_completeCount,
_basePublishTicks,
_gfxCacheTicks,
_completePublishTicks,
_cellSurfaceCount,
_portalPlaneCount,
_buildingCount,
_staticBspOwnerCount,
_staticCylinderOwnerCount,
_refloodCount,
_demotionCount,
_fullRemovalCount);
/// <summary>
/// Publishes terrain, EnvCell physics, portal planes, and building shells.
/// The render publisher completes its building/EnvCell transaction before
/// <see cref="CompletePublication"/> publishes ordinary static collision.
/// </summary>
public LandblockPhysicsPublication BeginPublication(
LandblockRenderPublication renderPublication)
{
ArgumentNullException.ThrowIfNull(renderPublication);
LandblockBuild build = renderPublication.Build;
Vector3 origin = renderPublication.Origin;
if (!IsFinite(origin))
throw new ArgumentOutOfRangeException(nameof(renderPublication));
long started = Stopwatch.GetTimestamp();
LoadedLandblock landblock = build.Landblock;
PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
uint currentCellId = _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u;
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
// A publication is an exact replacement for this landblock prefix.
// CacheCellStruct/CacheBuilding intentionally use retail first-wins
// semantics within one pass, so retire the prior pass before adding
// the replacement. Adjacent prefixes remain untouched.
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId);
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId);
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock(
landblock.LandblockId);
// TerrainInfo.Type occupies bits 2-6. The low byte retains those bits
// plus Road (bits 0-1), which TerrainSurface masks independently.
var terrainBytes = new byte[81];
for (int i = 0; i < terrainBytes.Length; i++)
terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i];
var terrainSurface = new TerrainSurface(
landblock.Heightmap.Height,
_heightTable,
landblockX,
landblockY,
terrainBytes);
var cellSurfaces = new List<CellSurface>();
var portalPlanes = new List<PortalPlane>();
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo = datBundle.Info;
if (landblockInfo is not null && landblockInfo.NumCells > 0)
{
uint firstCellId =
(landblock.LandblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < landblockInfo.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;
}
Quaternion rotation = envCell.Position.Orientation;
Vector3 cellOriginWorld = envCell.Position.Origin + origin;
Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
physicsCellTransform);
var worldVertices = new Dictionary<ushort, Vector3>(
cellStruct.VertexArray.Vertices.Count);
foreach ((ushort vertexId, var vertex) in
cellStruct.VertexArray.Vertices)
{
Vector3 worldPosition =
Vector3.Transform(vertex.Origin, rotation)
+ cellOriginWorld;
worldVertices[(ushort)vertexId] = worldPosition;
}
var polygonVertexIds = new List<List<short>>(
cellStruct.PhysicsPolygons.Count);
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
{
var vertexIds = new List<short>(polygon.VertexIds.Count);
foreach (short vertexId in polygon.VertexIds)
vertexIds.Add(vertexId);
polygonVertexIds.Add(vertexIds);
}
cellSurfaces.Add(new CellSurface(
envCellId,
worldVertices,
polygonVertexIds));
// CellPortal.PolygonId indexes rendering Polygons, not
// PhysicsPolygons (ACViewer EnvCell.find_transit_cells).
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(
portal.PolygonId,
out var polygon)
|| polygon.VertexIds.Count < 3)
{
continue;
}
var portalVertices = new Vector3[polygon.VertexIds.Count];
bool allFound = true;
for (int index = 0; index < polygon.VertexIds.Count; index++)
{
if (!worldVertices.TryGetValue(
(ushort)polygon.VertexIds[index],
out portalVertices[index]))
{
allFound = false;
break;
}
}
if (!allFound)
continue;
portalPlanes.Add(PortalPlane.FromVertices(
portalVertices.AsSpan(),
portal.OtherCellId,
envCellId & 0xFFFFu,
(ushort)portal.Flags));
}
}
}
int buildingCount = PublishBuildings(
landblock,
datBundle,
landblockInfo,
terrainSurface,
origin);
_physicsEngine.AddLandblock(
landblock.LandblockId,
terrainSurface,
cellSurfaces,
portalPlanes,
origin.X,
origin.Y);
if ((currentCellId & 0xFFFF0000u)
== (landblock.LandblockId & 0xFFFF0000u))
{
_physicsEngine.UpdatePlayerCurrCell(currentCellId);
}
_cellSurfaceCount += cellSurfaces.Count;
_portalPlaneCount += portalPlanes.Count;
_buildingCount += buildingCount;
_beginCount++;
_basePublishTicks += Stopwatch.GetTimestamp() - started;
return new LandblockPhysicsPublication(
_receiptOwner,
build,
origin);
}
/// <summary>
/// Publishes ordinary static collision and refloods after the caller has
/// completed the render-owned building/EnvCell suffix. The callback keeps
/// the shipped per-entity light-before-collision order until checkpoint E
/// moves static presentation into the transaction coordinator.
/// </summary>
public void CompletePublication(
LandblockPhysicsPublication publication,
Action<WorldEntity>? beforeStaticCollision = null)
{
ArgumentNullException.ThrowIfNull(publication);
if (!ReferenceEquals(publication.Owner, _receiptOwner))
throw new ArgumentException(
"The physics publication receipt belongs to another publisher.",
nameof(publication));
if (publication.CompletionCommitted)
return;
long completedStarted = Stopwatch.GetTimestamp();
LoadedLandblock landblock = publication.Build.Landblock;
PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
long cacheStarted = Stopwatch.GetTimestamp();
foreach (WorldEntity entity in landblock.Entities)
{
foreach (MeshRef meshRef in entity.MeshRefs)
{
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u)
continue;
if (datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx))
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
}
}
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
// The immutable entity list is an exact Near snapshot. Retire every
// static owner created by the prior snapshot before registering this
// one; RegisterMultiPart can replace matching IDs but cannot discover
// owners omitted by a ForceReload/reapply. Logical-owner removal also
// clears footprints flooded across an adjacent landblock seam.
_physicsEngine.ShadowObjects.DeregisterStaticOwnersForLandblock(
landblock.LandblockId);
int landblockBspCount = 0;
int landblockCylinderCount = 0;
int landblockNoCollisionCount = 0;
int sceneryTried = 0;
foreach (WorldEntity entity in landblock.Entities)
{
beforeStaticCollision?.Invoke(entity);
// Retail building shells collide exclusively through the
// per-landcell building channel. They never become ordinary
// shadow objects, even when their source Setup has cylinders.
if (entity.IsBuildingShell)
continue;
int entityBspCount = 0;
int entityCylinderCount = 0;
uint sourcePrefix =
entity.SourceGfxObjOrSetupId & 0xFF000000u;
bool isOutdoorMesh =
(entity.Id & 0x80000000u) != 0
|| (entity.Id < 0x40000000u
&& (sourcePrefix == 0x01000000u
|| sourcePrefix == 0x02000000u));
if (isOutdoorMesh)
sceneryTried++;
IReadOnlyList<ShadowShape> bspShapes =
ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs,
entity.IsBuildingShell,
_physicsDataCache.GetGfxObj);
entityBspCount = bspShapes.Count;
if (entityBspCount > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
bspShapes,
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
LogMultipartRegistration(landblock, entity, bspShapes);
}
SetupPhysics? setup =
_physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setup is not null && entityBspCount == 0)
{
float scale = entity.Scale > 0f ? entity.Scale : 1f;
var setupShapes = new List<ShadowShape>();
for (int cylinderIndex = 0;
cylinderIndex < setup.CylSpheres.Count;
cylinderIndex++)
{
DatReaderWriter.Types.CylSphere cylinder =
setup.CylSpheres[cylinderIndex];
float radius = cylinder.Radius * scale;
float baseHeight = cylinder.Height > 0f
? cylinder.Height
: cylinder.Radius * 4f;
float height = baseHeight * scale;
if (radius <= 0f)
continue;
Vector3 localOffset = cylinder.Origin * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
if (setup.CylSpheres.Count == 0)
{
for (int sphereIndex = 0;
sphereIndex < setup.Spheres.Count;
sphereIndex++)
{
DatReaderWriter.Types.Sphere sphere =
setup.Spheres[sphereIndex];
if (sphere.Radius <= 0f)
continue;
float radius = sphere.Radius * scale;
Vector3 localOffset = sphere.Origin * scale;
Vector3 localBaseOffset = localOffset
+ Vector3.Transform(
-Vector3.UnitZ * radius,
Quaternion.Inverse(entity.Rotation));
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localBaseOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: radius * 2f));
}
}
if (setup.CylSpheres.Count == 0
&& setup.Spheres.Count == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
if (setupShapes.Count > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
setupShapes,
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
LogSetupRegistration(landblock, entity, setupShapes);
entityCylinderCount = setupShapes.Count;
}
}
if (entityBspCount > 0)
landblockBspCount++;
if (entityCylinderCount > 0)
landblockCylinderCount++;
if (entityBspCount == 0 && entityCylinderCount == 0
&& (sourcePrefix == 0x01000000u
|| sourcePrefix == 0x02000000u))
{
landblockNoCollisionCount++;
}
}
if (PhysicsDiagnostics.ProbeBuildingEnabled && sceneryTried > 0)
{
Console.WriteLine(
$"lb 0x{landblock.LandblockId:X8}: scenery tried={sceneryTried} " +
$"(outdoorNone={landblockNoCollisionCount})");
}
LogMissingSceneryBounds(landblock);
_physicsEngine.ShadowObjects.RefloodLandblock(landblock.LandblockId);
_refloodCount++;
_staticBspOwnerCount += landblockBspCount;
_staticCylinderOwnerCount += landblockCylinderCount;
publication.CompletionCommitted = true;
_completeCount++;
_completePublishTicks +=
Stopwatch.GetTimestamp() - completedStarted;
}
public void DemoteToTerrain(uint landblockId)
{
_physicsEngine.DemoteLandblockToTerrain(landblockId);
_demotionCount++;
}
public void RemoveLandblock(uint landblockId)
{
_physicsEngine.RemoveLandblock(landblockId);
_fullRemovalCount++;
}
private int PublishBuildings(
LoadedLandblock landblock,
PhysicsDatBundle datBundle,
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo,
TerrainSurface terrainSurface,
Vector3 origin)
{
if (landblockInfo is null || landblockInfo.Buildings.Count == 0)
return 0;
uint landblockPrefix = landblock.LandblockId & 0xFFFF0000u;
foreach (var building in landblockInfo.Buildings)
{
var portals = new List<BldPortalInfo>(building.Portals.Count);
foreach (var portal in building.Portals)
{
portals.Add(new BldPortalInfo(
otherCellId: landblockPrefix | (uint)portal.OtherCellId,
otherPortalId: unchecked((short)portal.OtherPortalId),
flags: (ushort)portal.Flags));
}
Vector3 buildingOrigin = building.Frame.Origin + origin;
Matrix4x4 buildingTransform =
Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
* Matrix4x4.CreateTranslation(buildingOrigin);
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
building.Frame.Origin.X,
building.Frame.Origin.Y);
uint landcellId = landblockPrefix | landcellLow;
uint shellPartZero = building.ModelId;
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
{
datBundle.Setups.TryGetValue(
building.ModelId,
out var setup);
shellPartZero = setup is not null && setup.Parts.Count > 0
? setup.Parts[0]
: 0u;
}
_physicsDataCache.CacheBuilding(
landcellId,
portals,
buildingTransform,
modelId: shellPartZero);
}
return landblockInfo.Buildings.Count;
}
private static void LogSetupRegistration(
LoadedLandblock landblock,
WorldEntity entity,
IReadOnlyList<ShadowShape> shapes)
{
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
for (int index = 0; index < shapes.Count; index++)
{
Console.WriteLine(FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{shapes[index].GfxObjId:X8} lb=0x{landblock.LandblockId:X8} type=Cylinder note=setup-part{index} state=0x{0u:X8} flags={EntityCollisionFlags.None}"));
}
}
private static void LogMultipartRegistration(
LoadedLandblock landblock,
WorldEntity entity,
IReadOnlyList<ShadowShape> shapes)
{
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
for (int index = 0; index < shapes.Count; index++)
{
Console.WriteLine(FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{shapes[index].GfxObjId:X8} lb=0x{landblock.LandblockId:X8} type=BSP note=multipart-part{index} hasPhys=true state=0x{0u:X8} flags={EntityCollisionFlags.None}"));
}
}
private void LogMissingSceneryBounds(LoadedLandblock landblock)
{
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return;
int missingCount = 0;
var samples = new List<uint>();
foreach (WorldEntity entity in landblock.Entities)
{
if ((entity.Id & 0x80000000u) == 0)
continue;
bool hasBounds = false;
foreach (MeshRef meshRef in entity.MeshRefs)
{
GfxObjVisualBounds? bounds =
_physicsDataCache.GetVisualBounds(meshRef.GfxObjId);
if (bounds is not null && bounds.Radius > 0f)
{
hasBounds = true;
break;
}
}
if (hasBounds)
continue;
missingCount++;
if (samples.Count < 3)
samples.Add(entity.SourceGfxObjOrSetupId);
}
if (missingCount > 0)
{
string sampleText = string.Join(",", samples.Select(
value => $"0x{value:X8}"));
Console.WriteLine(
$" → {missingCount} scenery entities had no visual bounds cached. " +
$"Samples: {sampleText}");
}
}
private static bool IsFinite(Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
}

View file

@ -124,6 +124,7 @@ public sealed class PhysicsEngine
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
DataCache?.RemoveBuildingsForLandblock(landblockId);
// #145: if the player's current cell belonged to the landblock being removed (a teleport
// drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell

View file

@ -58,6 +58,11 @@ public sealed class CellGraph
public void RemoveLandblock(uint landblockPrefix)
{
uint lb = landblockPrefix & 0xFFFF0000u;
if (CurrCell is { } current
&& (current.Id & 0xFFFF0000u) == lb)
{
CurrCell = null;
}
_terrain.TryRemove(lb, out _);
foreach (var id in new List<uint>(_envCells.Keys))
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
@ -70,6 +75,12 @@ public sealed class CellGraph
public void RemoveEnvCellsForLandblock(uint landblockPrefix)
{
uint lb = landblockPrefix & 0xFFFF0000u;
if (CurrCell is { } current
&& (current.Id & 0xFFFF0000u) == lb
&& (current.Id & 0xFFFFu) >= 0x0100u)
{
CurrCell = null;
}
foreach (var id in new List<uint>(_envCells.Keys))
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
}

View file

@ -0,0 +1,711 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Streaming;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockPhysicsPublisherTests
{
private const uint FirstLandblock = 0xA9B4FFFFu;
private const uint AdjacentLandblock = 0xAAB4FFFFu;
private const uint SetupId = 0x02000042u;
private const uint SecondSetupId = 0x02000043u;
private static readonly float[] HeightTable =
Enumerable.Range(0, 256).Select(index => (float)index).ToArray();
[Fact]
public void Constructor_ClonesHeightTableAndRejectsIncompleteInput()
{
Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher(
new PhysicsEngine(),
new float[255]));
Assert.Throws<ArgumentException>(() => new LandblockPhysicsPublisher(
new PhysicsEngine(),
HeightTable));
float[] mutable = HeightTable.ToArray();
var engine = new PhysicsEngine();
var cache = new PhysicsDataCache();
engine.DataCache = cache;
var publisher = new LandblockPhysicsPublisher(engine, mutable);
mutable[0] = 999f;
LandblockPhysicsPublication receipt = Begin(
publisher,
Build(FirstLandblock));
publisher.CompletePublication(receipt);
Assert.Equal(1, engine.LandblockCount);
}
[Fact]
public void FarPublication_IsTerrainOnlyAndDemotionPreservesIt()
{
var fixture = Fixture();
LandblockPhysicsPublication receipt = Begin(
fixture.Publisher,
Build(FirstLandblock, bundle: PhysicsDatBundle.Empty));
fixture.Publisher.CompletePublication(receipt);
Assert.Equal(1, fixture.Engine.LandblockCount);
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
fixture.Publisher.DemoteToTerrain(FirstLandblock);
Assert.Equal(1, fixture.Engine.LandblockCount);
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.Equal(1, fixture.Publisher.Diagnostics.DemotionCount);
fixture.Publisher.RemoveLandblock(FirstLandblock);
Assert.Equal(0, fixture.Engine.LandblockCount);
Assert.False(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.Equal(1, fixture.Publisher.Diagnostics.FullRemovalCount);
}
[Fact]
public void BeginPublication_CommitsCellPortalAndBuildingPhysicsFromOneBundle()
{
var fixture = Fixture();
const uint envCellId = 0xA9B40100u;
PhysicsDatBundle bundle = CellPortalAndBuildingBundle(envCellId);
LandblockPhysicsPublication receipt = Begin(
fixture.Publisher,
Build(
FirstLandblock,
bundle: bundle,
origin: new LandblockBuildOrigin(0xA8, 0xB4)));
Assert.Equal(new Vector3(192f, 0f, 0f), receipt.Origin);
LandblockPhysicsPublisherDiagnostics diagnostics =
fixture.Publisher.Diagnostics;
Assert.Equal(1, diagnostics.CellSurfaceCount);
Assert.Equal(1, diagnostics.PortalPlaneCount);
Assert.Equal(1, diagnostics.BuildingCount);
Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId));
BuildingPhysics building = Assert.Single(
fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!));
Assert.Equal(new Vector3(204f, 12f, 0f), building.WorldTransform.Translation);
Assert.Equal(0x01000077u, building.ModelId);
}
[Fact]
public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback()
{
const uint gfxObjId = 0x01000077u;
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
fixture.Cache.RegisterGfxObjForTest(gfxObjId, BspGfx(radius: 1.25f));
WorldEntity entity = new()
{
Id = 0x80A9B401u,
SourceGfxObjOrSetupId = SetupId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)],
};
Publish(fixture.Publisher, Build(FirstLandblock, [entity]));
ShadowEntry entry = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(ShadowCollisionType.BSP, entry.CollisionType);
Assert.Equal(1, fixture.Publisher.Diagnostics.StaticBspOwnerCount);
Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount);
}
[Fact]
public void CompletePublication_BuildingShellNeverRegistersSetupCollision()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
WorldEntity shell = new()
{
Id = 0xC0A9B401u,
SourceGfxObjOrSetupId = SetupId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
IsBuildingShell = true,
};
Publish(
fixture.Publisher,
Build(
FirstLandblock,
[shell],
CellPortalAndBuildingBundle(0xA9B40100u)));
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Empty(fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Single(fixture.Cache.BuildingIds);
}
[Fact]
public void CompletePublication_MultipleSetupShapesShareOneLogicalOwner()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache, SetupId, shapeCount: 5);
CacheCylinderSetup(fixture.Cache, SecondSetupId);
const uint firstId = 0x80A9B401u;
const uint collidingLegacyId = 0xC0A9B401u;
WorldEntity first = CylinderEntity(
firstId,
new Vector3(12f, 12f, 0f));
WorldEntity second = CylinderEntity(
collidingLegacyId,
new Vector3(36f, 12f, 0f),
SecondSetupId);
Publish(
fixture.Publisher,
Build(FirstLandblock, [first, second]));
Assert.Equal(2, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
ShadowEntry[] entries = fixture.Engine.ShadowObjects
.AllEntriesForDebug()
.ToArray();
Assert.Equal(6, entries.Length);
Assert.Equal(
[firstId, collidingLegacyId],
entries.Select(entry => entry.EntityId).Distinct().Order().ToArray());
}
[Fact]
public void CompletePublication_RunsPerEntityCallbackBeforeCollisionAndIsIdempotent()
{
var fixture = Fixture();
WorldEntity entity = CylinderEntity(
0x80A9B401u,
new Vector3(12f, 12f, 0f));
CacheCylinderSetup(fixture.Cache);
LandblockPhysicsPublication receipt = Begin(
fixture.Publisher,
Build(FirstLandblock, [entity]));
int callbackCount = 0;
fixture.Publisher.CompletePublication(receipt, observed =>
{
Assert.Same(entity, observed);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
callbackCount++;
});
fixture.Publisher.CompletePublication(receipt, _ => callbackCount++);
Assert.Equal(1, callbackCount);
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
ShadowEntry entry = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(entity.Id, entry.EntityId);
Assert.Equal(ShadowCollisionType.Cylinder, entry.CollisionType);
Assert.Equal(1, fixture.Publisher.Diagnostics.CompleteCount);
Assert.Equal(1, fixture.Publisher.Diagnostics.RefloodCount);
}
[Fact]
public void CompletePublication_CallbackFailureCanRetryWithoutStackingCollision()
{
var fixture = Fixture();
WorldEntity entity = CylinderEntity(
0x80A9B401u,
new Vector3(12f, 12f, 0f));
CacheCylinderSetup(fixture.Cache);
LandblockPhysicsPublication receipt = Begin(
fixture.Publisher,
Build(FirstLandblock, [entity]));
int attempts = 0;
Assert.Throws<InvalidOperationException>(() =>
fixture.Publisher.CompletePublication(receipt, _ =>
{
attempts++;
throw new InvalidOperationException("injected static presentation failure");
}));
fixture.Publisher.CompletePublication(receipt, _ => attempts++);
Assert.Equal(2, attempts);
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Single(fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(1, fixture.Publisher.Diagnostics.CompleteCount);
}
[Fact]
public void NearReapply_ReplacesSameStaticOwnerWithoutDuplication()
{
var fixture = Fixture();
WorldEntity first = CylinderEntity(
0x80A9B401u,
new Vector3(12f, 12f, 0f));
WorldEntity moved = CylinderEntity(
first.Id,
new Vector3(36f, 12f, 0f));
CacheCylinderSetup(fixture.Cache);
LandblockPhysicsPublication firstReceipt = Begin(
fixture.Publisher,
Build(FirstLandblock, [first]));
fixture.Publisher.CompletePublication(firstReceipt);
LandblockPhysicsPublication secondReceipt = Begin(
fixture.Publisher,
Build(FirstLandblock, [moved]));
fixture.Publisher.CompletePublication(secondReceipt);
Assert.Equal(1, fixture.Engine.LandblockCount);
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
ShadowEntry entry = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(moved.Position + new Vector3(0f, 0f, 0.6f), entry.Position);
Assert.Equal(2, fixture.Publisher.Diagnostics.BeginCount);
Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount);
}
[Fact]
public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
WorldEntity omitted = CylinderEntity(
0x80A9B401u,
new Vector3(191.5f, 12f, 0f));
WorldEntity neighbor = CylinderEntity(
0x80AAB401u,
new Vector3(192.5f, 12f, 0f));
Publish(
fixture.Publisher,
Build(FirstLandblock, [omitted]));
Publish(
fixture.Publisher,
Build(AdjacentLandblock, [neighbor]));
Publish(
fixture.Publisher,
Build(FirstLandblock, Array.Empty<WorldEntity>()));
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
ShadowEntry survivor = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(neighbor.Id, survivor.EntityId);
Assert.Contains(
fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u),
entry => entry.EntityId == neighbor.Id);
Assert.DoesNotContain(
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
entry => entry.EntityId == omitted.Id);
}
[Fact]
public void NearReapply_RebasesCellAndBuildingAndRemovesMissingSnapshotData()
{
const uint envCellId = 0xA9B40100u;
var fixture = Fixture();
PhysicsDatBundle populated = CellPortalAndBuildingBundle(envCellId);
Publish(
fixture.Publisher,
Build(FirstLandblock, bundle: populated));
fixture.Engine.UpdatePlayerCurrCell(envCellId);
AcDream.Core.World.Cells.ObjCell originalCurrent =
fixture.Cache.CellGraph.CurrCell!;
Assert.Equal(
Vector3.Zero,
fixture.Cache.CellGraph.GetVisible(envCellId)!.WorldTransform.Translation);
Assert.Equal(
new Vector3(12f, 12f, 0f),
Assert.Single(fixture.Cache.BuildingIds
.Select(id => fixture.Cache.GetBuilding(id)!))
.WorldTransform.Translation);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: populated,
origin: new LandblockBuildOrigin(0xA8, 0xB4)));
Assert.Equal(
new Vector3(192f, 0f, 0f),
fixture.Cache.CellGraph.GetVisible(envCellId)!.WorldTransform.Translation);
Assert.NotSame(originalCurrent, fixture.Cache.CellGraph.CurrCell);
Assert.Same(
fixture.Cache.CellGraph.GetVisible(envCellId),
fixture.Cache.CellGraph.CurrCell);
Assert.Equal(
new Vector3(204f, 12f, 0f),
Assert.Single(fixture.Cache.BuildingIds
.Select(id => fixture.Cache.GetBuilding(id)!))
.WorldTransform.Translation);
Publish(
fixture.Publisher,
Build(FirstLandblock, bundle: PhysicsDatBundle.Empty));
Assert.Null(fixture.Cache.CellGraph.GetVisible(envCellId));
Assert.Null(fixture.Cache.CellGraph.CurrCell);
Assert.Empty(fixture.Cache.BuildingIds);
}
[Fact]
public void NearReapply_DoesNotRetireAdjacentCellOrBuildingCache()
{
const uint firstCellId = 0xA9B40100u;
const uint adjacentCellId = 0xAAB40100u;
var fixture = Fixture();
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: CellPortalAndBuildingBundle(firstCellId)));
Publish(
fixture.Publisher,
Build(
AdjacentLandblock,
bundle: CellPortalAndBuildingBundle(adjacentCellId)));
Publish(
fixture.Publisher,
Build(FirstLandblock, bundle: PhysicsDatBundle.Empty));
Assert.Null(fixture.Cache.CellGraph.GetVisible(firstCellId));
Assert.NotNull(fixture.Cache.CellGraph.GetVisible(adjacentCellId));
Assert.DoesNotContain(
fixture.Cache.BuildingIds,
id => (id & 0xFFFF0000u) == 0xA9B40000u);
Assert.Contains(
fixture.Cache.BuildingIds,
id => (id & 0xFFFF0000u) == 0xAAB40000u);
}
[Fact]
public void AdjacentLandblockDemotion_DoesNotEraseNeighborStaticOwner()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
WorldEntity first = CylinderEntity(
0x80A9B401u,
new Vector3(191.5f, 12f, 0f));
WorldEntity neighbor = CylinderEntity(
0x80AAB401u,
new Vector3(192.5f, 12f, 0f));
Publish(
fixture.Publisher,
Build(FirstLandblock, [first]));
Publish(
fixture.Publisher,
Build(AdjacentLandblock, [neighbor]));
Assert.Equal(2, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Contains(
fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u),
entry => entry.EntityId == neighbor.Id);
fixture.Publisher.DemoteToTerrain(FirstLandblock);
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
ShadowEntry surviving = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(neighbor.Id, surviving.EntityId);
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.True(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock));
}
[Fact]
public void FullRemoval_DoesNotEraseAdjacentLandblockCollision()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
WorldEntity first = CylinderEntity(
0x80A9B401u,
new Vector3(191.5f, 12f, 0f));
WorldEntity neighbor = CylinderEntity(
0x80AAB401u,
new Vector3(192.5f, 12f, 0f));
Publish(
fixture.Publisher,
Build(
FirstLandblock,
[first],
CellPortalAndBuildingBundle(0xA9B40100u)));
Publish(
fixture.Publisher,
Build(
AdjacentLandblock,
[neighbor],
CellPortalAndBuildingBundle(0xAAB40100u)));
fixture.Publisher.RemoveLandblock(FirstLandblock);
Assert.Equal(1, fixture.Engine.LandblockCount);
Assert.False(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.True(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock));
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
Assert.Equal(
neighbor.Id,
Assert.Single(fixture.Engine.ShadowObjects.AllEntriesForDebug()).EntityId);
uint survivingBuilding = Assert.Single(fixture.Cache.BuildingIds);
Assert.Equal(0xAAB40000u, survivingBuilding & 0xFFFF0000u);
}
[Fact]
public void CompletePublication_ForeignReceiptIsRejectedWithoutConsumingIt()
{
var first = Fixture();
var second = Fixture();
LandblockPhysicsPublication receipt = Begin(
first.Publisher,
Build(FirstLandblock));
Assert.Throws<ArgumentException>(() =>
second.Publisher.CompletePublication(receipt));
Assert.Equal(0, second.Publisher.Diagnostics.CompleteCount);
first.Publisher.CompletePublication(receipt);
Assert.Equal(1, first.Publisher.Diagnostics.CompleteCount);
}
[Fact]
public void PublisherSurface_HasNoDatReaderResidencyOrLiveOriginDependency()
{
Type type = typeof(LandblockPhysicsPublisher);
Type[] dependencies = type
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
.Select(field => field.FieldType)
.Concat(type.GetConstructors().SelectMany(constructor =>
constructor.GetParameters().Select(parameter => parameter.ParameterType)))
.ToArray();
Assert.DoesNotContain(dependencies, dependency =>
dependency.FullName?.Contains("DatReader", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencies, dependency =>
dependency.FullName?.Contains("StreamingRegion", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencies, dependency =>
dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true);
MethodInfo begin = Assert.Single(
type.GetMethods(),
method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication));
ParameterInfo parameter = Assert.Single(begin.GetParameters());
Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType);
Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor =>
constructor.GetParameters()), parameterInfo =>
parameterInfo.ParameterType == typeof(PhysicsDataCache));
}
[Fact]
public void GameWindow_HasNoLandblockPhysicsPublicationBodies()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.DoesNotContain("_physicsDataCache.CacheCellStruct", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsDataCache.CacheBuilding", source, StringComparison.Ordinal);
Assert.DoesNotContain("ShadowShapeBuilder.FromLandblockBspParts", source, StringComparison.Ordinal);
Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal);
}
private static void Publish(
LandblockPhysicsPublisher publisher,
LandblockBuild build)
{
LandblockPhysicsPublication receipt = Begin(publisher, build);
publisher.CompletePublication(receipt);
}
private static LandblockPhysicsPublication Begin(
LandblockPhysicsPublisher publisher,
LandblockBuild build) =>
publisher.BeginPublication(RenderReceipt(build));
private static LandblockRenderPublication RenderReceipt(LandblockBuild build)
{
var publisher = new LandblockRenderPublisher(
publishTerrain: (_, _, _) => { },
removeTerrain: _ => { },
cellVisibility: new AcDream.App.Rendering.CellVisibility(),
worldState: new GpuWorldState());
return publisher.BeginPublication(
build,
new AcDream.Core.Terrain.LandblockMeshData(
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
Array.Empty<uint>()));
}
private static (
LandblockPhysicsPublisher Publisher,
PhysicsEngine Engine,
PhysicsDataCache Cache) Fixture()
{
var engine = new PhysicsEngine();
var cache = new PhysicsDataCache();
engine.DataCache = cache;
return (
new LandblockPhysicsPublisher(engine, HeightTable),
engine,
cache);
}
private static void CacheCylinderSetup(
PhysicsDataCache cache,
uint setupId = SetupId,
int shapeCount = 1)
{
var setup = new Setup();
for (int index = 0; index < shapeCount; index++)
{
setup.CylSpheres.Add(
new CylSphere
{
Radius = 0.4f + index * 0.01f,
Height = 1.2f,
Origin = new Vector3(index * 0.1f, 0f, 0.6f),
});
}
cache.CacheSetup(setupId, setup);
}
private static GfxObjPhysics BspGfx(float radius) => new()
{
BSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
},
},
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = radius,
},
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId)
{
var cellStruct = new CellStruct
{
VertexArray = new VertexArray
{
Vertices = new Dictionary<ushort, SWVertex>
{
[0] = new SWVertex { Origin = Vector3.Zero },
[1] = new SWVertex { Origin = Vector3.UnitY },
[2] = new SWVertex { Origin = Vector3.UnitZ },
},
},
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Polygons = new Dictionary<ushort, Polygon>
{
[0] = new Polygon { VertexIds = [0, 1, 2] },
},
};
var environment = new DatReaderWriter.DBObjs.Environment
{
Id = 0x0D000001u,
Cells = { [1] = cellStruct },
};
var envCell = new EnvCell
{
Id = envCellId,
EnvironmentId = 1,
CellStructure = 1,
Position = new Frame { Orientation = Quaternion.Identity },
CellPortals =
{
new CellPortal
{
PolygonId = 0,
OtherCellId = 0xFFFF,
},
},
};
var info = new LandBlockInfo
{
NumCells = 1,
Buildings =
{
new BuildingInfo
{
ModelId = 0x01000077u,
Frame = new Frame
{
Origin = new Vector3(12f, 12f, 0f),
Orientation = Quaternion.Identity,
},
},
},
};
return new PhysicsDatBundle(
info,
new Dictionary<uint, EnvCell> { [envCellId] = envCell },
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>
{
[environment.Id] = environment,
},
new Dictionary<uint, Setup>(),
new Dictionary<uint, GfxObj>());
}
private static WorldEntity CylinderEntity(
uint id,
Vector3 position,
uint setupId = SetupId) => new()
{
Id = id,
SourceGfxObjOrSetupId = setupId,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static LandblockBuild Build(
uint landblockId,
IReadOnlyList<WorldEntity>? entities = null,
PhysicsDatBundle? bundle = null,
LandblockBuildOrigin? origin = null)
{
var landblockInfo = new LandBlockInfo();
PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle(
landblockInfo,
new Dictionary<uint, EnvCell>(),
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>(),
new Dictionary<uint, Setup>(),
new Dictionary<uint, GfxObj>());
return new LandblockBuild(
new LoadedLandblock(
landblockId,
FlatHeightmap(),
entities ?? Array.Empty<WorldEntity>(),
physics),
Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4));
}
private static LandBlock FlatHeightmap() => new()
{
Terrain = new TerrainInfo[81],
Height = new byte[81],
};
private static string FindRepoRoot()
{
string? directory = AppContext.BaseDirectory;
while (directory is not null)
{
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
return directory;
directory = Directory.GetParent(directory)?.FullName;
}
throw new DirectoryNotFoundException("Could not locate repository root.");
}
}

View file

@ -151,4 +151,36 @@ public class PhysicsEngineResidencyTests
entry => entry.EntityId == dynamicId);
Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount);
}
[Fact]
public void RemoveLandblock_RemovesOnlyItsCachedBuildings()
{
const uint landblockId = 0xA9B4FFFFu;
const uint retiredBuildingCell = 0xA9B40001u;
const uint adjacentBuildingCell = 0xAAB40001u;
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
cache.RegisterBuildingForTest(
retiredBuildingCell,
Building(Matrix4x4.Identity));
cache.RegisterBuildingForTest(
adjacentBuildingCell,
Building(Matrix4x4.CreateTranslation(192f, 0f, 0f)));
engine.RemoveLandblock(landblockId);
Assert.Null(cache.GetBuilding(retiredBuildingCell));
Assert.NotNull(cache.GetBuilding(adjacentBuildingCell));
}
private static BuildingPhysics Building(Matrix4x4 transform)
{
Matrix4x4.Invert(transform, out Matrix4x4 inverse);
return new BuildingPhysics
{
WorldTransform = transform,
InverseWorldTransform = inverse,
Portals = Array.Empty<BldPortalInfo>(),
};
}
}

View file

@ -56,6 +56,21 @@ public class CellGraphTests
Assert.Null(g.GetVisible(0xA9B40014u));
}
[Fact]
public void RemoveLandblock_ClearsCurrentCellForRetiredPrefixOnly()
{
var g = new CellGraph();
var current = Env(0xA9B40174u);
g.Add(current);
g.CurrCell = current;
g.RemoveLandblock(0xAAB40000u);
Assert.Same(current, g.CurrCell);
g.RemoveLandblock(0xA9B40000u);
Assert.Null(g.CurrCell);
}
[Fact]
public void RemoveEnvCellsForLandblock_PreservesTerrain()
{
@ -69,6 +84,21 @@ public class CellGraphTests
Assert.IsType<LandCell>(g.GetVisible(0xA9B40014u));
}
[Fact]
public void RemoveEnvCellsForLandblock_ClearsOnlyMatchingIndoorCurrentCell()
{
var g = new CellGraph();
var current = Env(0xA9B40174u);
g.Add(current);
g.CurrCell = current;
g.RemoveEnvCellsForLandblock(0xAAB4FFFFu);
Assert.Same(current, g.CurrCell);
g.RemoveEnvCellsForLandblock(0xA9B4FFFFu);
Assert.Null(g.CurrCell);
}
[Fact]
public void Neighbor_ResolvesPortalOtherCellId()
{