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:
parent
acb6b34d01
commit
3613d393e6
7 changed files with 1538 additions and 546 deletions
|
|
@ -152,6 +152,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
||||||
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
|
private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements;
|
||||||
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
|
private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher;
|
||||||
|
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
|
||||||
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
||||||
private AcDream.App.Streaming.StreamingController? _streamingController;
|
private AcDream.App.Streaming.StreamingController? _streamingController;
|
||||||
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
|
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
|
||||||
|
|
@ -2424,6 +2425,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
build,
|
build,
|
||||||
_wbMeshAdapter.MeshManager!),
|
_wbMeshAdapter.MeshManager!),
|
||||||
removeEnvCells: _envCellRenderer.RemoveLandblock);
|
removeEnvCells: _envCellRenderer.RemoveLandblock);
|
||||||
|
_landblockPhysicsPublisher =
|
||||||
|
new AcDream.App.Streaming.LandblockPhysicsPublisher(
|
||||||
|
_physicsEngine,
|
||||||
|
_heightTable!);
|
||||||
|
|
||||||
_clipFrame ??= ClipFrame.NoClip();
|
_clipFrame ??= ClipFrame.NoClip();
|
||||||
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
|
_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
|
// 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
|
// 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.
|
// can be the abandoned first destination, not the unplaced player's source.
|
||||||
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
|
_landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId);
|
||||||
|
|
||||||
_liveWorldOrigin.Recenter(lbX, lbY);
|
_liveWorldOrigin.Recenter(lbX, lbY);
|
||||||
newWorldPos = new System.Numerics.Vector3(
|
newWorldPos = new System.Numerics.Vector3(
|
||||||
|
|
@ -3814,9 +3819,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
|
if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full)
|
||||||
_physicsEngine.RemoveLandblock(ticket.LandblockId);
|
_landblockPhysicsPublisher!.RemoveLandblock(ticket.LandblockId);
|
||||||
else
|
else
|
||||||
_physicsEngine.DemoteLandblockToTerrain(ticket.LandblockId);
|
_landblockPhysicsPublisher!.DemoteToTerrain(ticket.LandblockId);
|
||||||
});
|
});
|
||||||
ticket.RunOnce(
|
ticket.RunOnce(
|
||||||
AcDream.App.Streaming.LandblockRetirementStage.CellVisibility,
|
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(
|
private void ApplyLoadedTerrainLocked(
|
||||||
AcDream.App.Streaming.LandblockBuild build,
|
AcDream.App.Streaming.LandblockBuild build,
|
||||||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||||||
{
|
{
|
||||||
var lb = build.Landblock;
|
AcDream.Core.World.LoadedLandblock landblock = build.Landblock;
|
||||||
if (_landblockRenderPublisher is null || _dats is null || _heightTable is null)
|
if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Every DAT object publication consumes was captured by the worker.
|
AcDream.Core.World.PhysicsDatBundle datBundle =
|
||||||
// The update-thread publishers consume only this immutable bundle.
|
landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
|
||||||
var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
|
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore =
|
||||||
|
|
||||||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagBefore =
|
|
||||||
_landblockRenderPublisher.Diagnostics;
|
_landblockRenderPublisher.Diagnostics;
|
||||||
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
|
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
|
||||||
_landblockRenderPublisher.BeginPublication(build, meshData);
|
_landblockRenderPublisher.BeginPublication(build, meshData);
|
||||||
var origin = renderPublication.Origin;
|
System.Numerics.Vector3 origin = renderPublication.Origin;
|
||||||
|
|
||||||
|
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix =
|
||||||
|
_landblockRenderPublisher.Diagnostics;
|
||||||
if (_frameDiag)
|
if (_frameDiag)
|
||||||
{
|
{
|
||||||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagAfter =
|
|
||||||
_landblockRenderPublisher.Diagnostics;
|
|
||||||
long terrainTicks =
|
long terrainTicks =
|
||||||
renderDiagAfter.TerrainPublishTicks - renderDiagBefore.TerrainPublishTicks;
|
renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks;
|
||||||
_applyUploadAccumTicks += terrainTicks;
|
_applyUploadAccumTicks += terrainTicks;
|
||||||
_applyCellAccumTicks +=
|
_applyCellAccumTicks +=
|
||||||
renderDiagAfter.BeginPublishTicks
|
renderPrefix.BeginPublishTicks
|
||||||
- renderDiagBefore.BeginPublishTicks
|
- renderBefore.BeginPublishTicks
|
||||||
- terrainTicks;
|
- terrainTicks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// [FRAME-DIAG]: the publisher reports terrain and render-prefix time;
|
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore =
|
||||||
// this checkpoint begins the physics/cache portion of the cell span.
|
_landblockPhysicsPublisher.Diagnostics;
|
||||||
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication =
|
||||||
|
_landblockPhysicsPublisher.BeginPublication(renderPublication);
|
||||||
|
|
||||||
// Phase B.3: populate the physics engine with terrain + indoor cell
|
// Retail initializes buildings/visible cells before ordinary static
|
||||||
// surfaces for this landblock. Runs under _datLock (same lock as the
|
// collision and scripts. The two receipts retain one captured origin
|
||||||
// rest of ApplyLoadedTerrainLocked) so dat reads are safe.
|
// 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;
|
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter =
|
||||||
uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu;
|
_landblockPhysicsPublisher.Diagnostics;
|
||||||
// Extract per-vertex terrain-type bytes so TerrainSurface can
|
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter =
|
||||||
// classify water cells for ValidateWalkable's water-depth
|
_landblockRenderPublisher.Diagnostics;
|
||||||
// adjustment. Each TerrainInfo is a ushort with Type in bits
|
long gfxCacheTicks =
|
||||||
// 2-6; taking the low byte preserves those bits (+ Road in 0-1,
|
physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks;
|
||||||
// which the classifier masks off).
|
_applyCellAccumTicks +=
|
||||||
var terrainBytes = new byte[81];
|
physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks;
|
||||||
for (int i = 0; i < 81; i++)
|
_applyCellAccumTicks +=
|
||||||
terrainBytes[i] = (byte)(ushort)lb.Heightmap.Terrain[i];
|
renderAfter.CompletePublishTicks
|
||||||
|
- renderPrefix.CompletePublishTicks;
|
||||||
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(
|
_applyBspAccumTicks += gfxCacheTicks;
|
||||||
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY, terrainBytes);
|
_applyShadowAccumTicks +=
|
||||||
|
physicsAfter.CompletePublishTicks
|
||||||
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
|
- physicsBefore.CompletePublishTicks
|
||||||
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();
|
- gfxCacheTicks;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
|
long pluginStarted =
|
||||||
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
|
_frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||||
// and the legacy cell-mesh upload path are retired with InstancedMeshRenderer.
|
foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities)
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
// Live objects publish through LiveEntityRuntime exactly once per
|
// Live objects publish through LiveEntityRuntime exactly once per
|
||||||
// accepted incarnation. This landblock callback owns only DAT
|
// accepted incarnation. This callback owns DAT statics only.
|
||||||
// statics; a retained live projection must not emit a second spawn
|
|
||||||
// when its spatial bucket reloads.
|
|
||||||
if (entity.ServerGuid != 0)
|
if (entity.ServerGuid != 0)
|
||||||
continue;
|
continue;
|
||||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
||||||
Id: entity.Id,
|
var snapshot =
|
||||||
SourceId: entity.SourceGfxObjOrSetupId,
|
new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||||
Position: entity.Position,
|
Id: entity.Id,
|
||||||
Rotation: entity.Rotation);
|
SourceId: entity.SourceGfxObjOrSetupId,
|
||||||
|
Position: entity.Position,
|
||||||
|
Rotation: entity.Rotation);
|
||||||
_worldGameState.Add(snapshot);
|
_worldGameState.Add(snapshot);
|
||||||
_worldEvents.FireEntitySpawned(snapshot);
|
_worldEvents.FireEntitySpawned(snapshot);
|
||||||
}
|
}
|
||||||
// [FRAME-DIAG] checkpoint: end ShadowObjects+lights registration (apply tail).
|
if (_frameDiag)
|
||||||
if (_frameDiag) _applyShadowAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdCheck;
|
{
|
||||||
|
_applyShadowAccumTicks +=
|
||||||
|
System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnUpdate(double dt)
|
private void OnUpdate(double dt)
|
||||||
|
|
|
||||||
654
src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
Normal file
654
src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -124,6 +124,7 @@ public sealed class PhysicsEngine
|
||||||
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
|
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
|
||||||
ShadowObjects.RemoveLandblock(landblockId);
|
ShadowObjects.RemoveLandblock(landblockId);
|
||||||
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
|
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
|
// #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
|
// drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ public sealed class CellGraph
|
||||||
public void RemoveLandblock(uint landblockPrefix)
|
public void RemoveLandblock(uint landblockPrefix)
|
||||||
{
|
{
|
||||||
uint lb = landblockPrefix & 0xFFFF0000u;
|
uint lb = landblockPrefix & 0xFFFF0000u;
|
||||||
|
if (CurrCell is { } current
|
||||||
|
&& (current.Id & 0xFFFF0000u) == lb)
|
||||||
|
{
|
||||||
|
CurrCell = null;
|
||||||
|
}
|
||||||
_terrain.TryRemove(lb, out _);
|
_terrain.TryRemove(lb, out _);
|
||||||
foreach (var id in new List<uint>(_envCells.Keys))
|
foreach (var id in new List<uint>(_envCells.Keys))
|
||||||
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
||||||
|
|
@ -70,6 +75,12 @@ public sealed class CellGraph
|
||||||
public void RemoveEnvCellsForLandblock(uint landblockPrefix)
|
public void RemoveEnvCellsForLandblock(uint landblockPrefix)
|
||||||
{
|
{
|
||||||
uint lb = landblockPrefix & 0xFFFF0000u;
|
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))
|
foreach (var id in new List<uint>(_envCells.Keys))
|
||||||
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -151,4 +151,36 @@ public class PhysicsEngineResidencyTests
|
||||||
entry => entry.EntityId == dynamicId);
|
entry => entry.EntityId == dynamicId);
|
||||||
Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount);
|
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>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,21 @@ public class CellGraphTests
|
||||||
Assert.Null(g.GetVisible(0xA9B40014u));
|
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]
|
[Fact]
|
||||||
public void RemoveEnvCellsForLandblock_PreservesTerrain()
|
public void RemoveEnvCellsForLandblock_PreservesTerrain()
|
||||||
{
|
{
|
||||||
|
|
@ -69,6 +84,21 @@ public class CellGraphTests
|
||||||
Assert.IsType<LandCell>(g.GetVisible(0xA9B40014u));
|
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]
|
[Fact]
|
||||||
public void Neighbor_ResolvesPortalOtherCellId()
|
public void Neighbor_ResolvesPortalOtherCellId()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue