Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.
CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.
THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.
The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.
Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
claim plus five EXTERNAL positive controls, so a broken enumeration cannot
satisfy it vacuously. Inverting the claim reddens it; emptying the
enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
the landblock id.
Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.
AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.
Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1170 lines
44 KiB
C#
1170 lines
44 KiB
C#
using System.Diagnostics;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime.Physics;
|
|
using DatReaderWriter.Types;
|
|
|
|
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 : IDisposable
|
|
{
|
|
internal LandblockPhysicsPublication(
|
|
object owner,
|
|
LandblockBuild build,
|
|
Vector3 origin,
|
|
uint currentCellId,
|
|
BuildingInfo[] buildings,
|
|
uint[] priorStaticOwnerIds,
|
|
RuntimePhysicsState physics,
|
|
RuntimeCollisionAdmission collisionAdmission,
|
|
PreparedLandblockCollisionGeneration preparedGeneration)
|
|
{
|
|
Owner = owner;
|
|
Build = build;
|
|
Origin = origin;
|
|
CurrentCellId = currentCellId;
|
|
Buildings = buildings;
|
|
PriorStaticOwnerIds = priorStaticOwnerIds;
|
|
Physics = physics;
|
|
CollisionAdmission = collisionAdmission;
|
|
PreparedGeneration = preparedGeneration;
|
|
}
|
|
|
|
internal object Owner { get; }
|
|
internal LandblockBuild Build { get; }
|
|
internal uint CurrentCellId { get; }
|
|
internal BuildingInfo[] Buildings { get; }
|
|
internal uint[] PriorStaticOwnerIds { get; }
|
|
internal RuntimePhysicsState Physics { get; }
|
|
internal RuntimeCollisionAdmission CollisionAdmission { get; }
|
|
internal PreparedLandblockCollisionGeneration PreparedGeneration { get; }
|
|
internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache;
|
|
internal PhysicsEngine StagingEngine => PreparedGeneration.Engine;
|
|
internal SortedSet<uint> GfxObjectIdSet { get; } = new();
|
|
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
|
|
internal int PreparationCursor { get; set; }
|
|
internal bool PreparationCommitted { get; set; }
|
|
internal bool PriorCacheRemoved { get; set; }
|
|
internal TerrainSurface? TerrainSurface { get; set; }
|
|
internal List<CellSurface> CellSurfaces { get; } = new();
|
|
internal List<PortalPlane> PortalPlanes { get; } = new();
|
|
internal uint CellCursor { get; set; }
|
|
internal int BuildingCursor { get; set; }
|
|
internal bool BaseCommitted { get; set; }
|
|
internal int GfxCursor { get; set; }
|
|
internal uint[] SetupObjectIds { get; set; } = Array.Empty<uint>();
|
|
internal int SetupCursor { get; set; }
|
|
internal int PriorStaticCursor { get; set; }
|
|
internal int StaticCursor { get; set; }
|
|
internal int BspOwnerCount { get; set; }
|
|
internal int CylinderOwnerCount { get; set; }
|
|
internal int NoCollisionCount { get; set; }
|
|
internal int SceneryTried { get; set; }
|
|
internal IReadOnlyList<uint>? RefloodOwnerIds { get; set; }
|
|
internal int RefloodCursor { get; set; }
|
|
internal bool RefloodCommitted { get; set; }
|
|
internal bool SealCommitted { get; set; }
|
|
internal bool EngineMutationCommitted { get; set; }
|
|
/// <summary>
|
|
/// The last Runtime mutation poll was nonterminal. This includes exact
|
|
/// placement acknowledgements, collision-report/shadow dispatch debt, and
|
|
/// Runtime's deliberate first quiescence poll.
|
|
/// </summary>
|
|
internal bool RuntimeMutationPending { get; set; }
|
|
internal bool BeginCommitted { get; set; }
|
|
internal bool CompletionCommitted { get; set; }
|
|
internal bool CancellationRequested { get; private set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
if (!TryCancel())
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Collision publication cancellation is waiting for exact placement acknowledgements and must remain retained.");
|
|
}
|
|
}
|
|
|
|
internal bool TryCancel()
|
|
{
|
|
if (CompletionCommitted)
|
|
return true;
|
|
CancellationRequested = true;
|
|
for (int poll = 0; poll < 2; poll++)
|
|
{
|
|
if (Physics.CancelCollisionGeneration(
|
|
CollisionAdmission,
|
|
PreparedGeneration))
|
|
{
|
|
return true;
|
|
}
|
|
if (Physics.CaptureOwnership()
|
|
.PendingCollisionPrefixProjectionCount != 0)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 RuntimePhysicsState _physics;
|
|
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(
|
|
RuntimePhysicsState physics,
|
|
float[] heightTable)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(physics);
|
|
ArgumentNullException.ThrowIfNull(heightTable);
|
|
if (heightTable.Length < 256)
|
|
throw new ArgumentException(
|
|
"The retail terrain height table must contain at least 256 entries.",
|
|
nameof(heightTable));
|
|
|
|
_physics = physics;
|
|
_physicsEngine = physics.Engine;
|
|
_physicsDataCache = physics.DataCache;
|
|
_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)
|
|
{
|
|
LandblockPhysicsPublication publication = PreparePublication(
|
|
renderPublication);
|
|
BeginPublication(publication);
|
|
return publication;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures the exact render receipt before physics mutation begins. The
|
|
/// coordinator retains this receipt and can retry the idempotent exact
|
|
/// replacement when a cache/backend operation fails.
|
|
/// </summary>
|
|
public LandblockPhysicsPublication PreparePublication(
|
|
LandblockRenderPublication renderPublication)
|
|
{
|
|
LandblockPhysicsPublication publication =
|
|
CreatePublication(renderPublication);
|
|
while (!AdvancePreparationOne(publication))
|
|
{
|
|
}
|
|
return publication;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures the immutable physics root without walking the entity suffix.
|
|
/// The concrete pipeline advances that walk under its frame meter.
|
|
/// </summary>
|
|
internal LandblockPhysicsPublication CreatePublication(
|
|
LandblockRenderPublication renderPublication)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(renderPublication);
|
|
LandblockBuild build = renderPublication.Build;
|
|
Vector3 origin = renderPublication.Origin;
|
|
if (!IsFinite(origin))
|
|
throw new ArgumentOutOfRangeException(nameof(renderPublication));
|
|
|
|
PhysicsDatBundle datBundle =
|
|
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
|
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
|
|
?? Array.Empty<BuildingInfo>();
|
|
RuntimeCollisionAdmission collisionAdmission =
|
|
_physics.BeginCollisionAdmission(build.Landblock.LandblockId);
|
|
PreparedLandblockCollisionGeneration? prepared = null;
|
|
try
|
|
{
|
|
prepared = _physics.PrepareCollisionGeneration(collisionAdmission);
|
|
var publication = new LandblockPhysicsPublication(
|
|
_receiptOwner,
|
|
build,
|
|
origin,
|
|
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
|
|
buildings,
|
|
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
|
|
build.Landblock.LandblockId),
|
|
_physics,
|
|
collisionAdmission,
|
|
prepared);
|
|
publication.SetupObjectIds = build.Collisions is { } collisions
|
|
? [.. collisions.SetupIds]
|
|
: datBundle.Setups.Keys.Order().ToArray();
|
|
publication.PreparedGeneration.SetAssetClosure(
|
|
publication.GfxObjectIds,
|
|
publication.SetupObjectIds);
|
|
return publication;
|
|
}
|
|
catch (Exception publicationError)
|
|
{
|
|
if (!_physics.CancelCollisionGeneration(collisionAdmission, prepared))
|
|
{
|
|
throw new AggregateException(
|
|
"Collision preparation failed and its pre-engine cancellation did not converge.",
|
|
publicationError);
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances one entity's stable GfxObj-index preparation. No external
|
|
/// physics state is mutated until this cursor and the static-validation
|
|
/// cursor both complete.
|
|
/// </summary>
|
|
internal bool AdvancePreparationOne(
|
|
LandblockPhysicsPublication publication)
|
|
{
|
|
ValidateReceipt(publication);
|
|
if (publication.PreparationCommitted)
|
|
return true;
|
|
|
|
if (!_physics.AdvanceCollisionGenerationPreparation(
|
|
publication.CollisionAdmission,
|
|
publication.PreparedGeneration).Completed)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
IReadOnlyList<WorldEntity> entities =
|
|
publication.Build.Landblock.Entities;
|
|
if (publication.Build.Collisions is { } collisions)
|
|
{
|
|
publication.GfxObjectIds = [.. collisions.GfxObjIds];
|
|
publication.PreparedGeneration.SetAssetClosure(
|
|
publication.GfxObjectIds,
|
|
publication.SetupObjectIds);
|
|
publication.PreparationCursor = entities.Count;
|
|
publication.PreparationCommitted = true;
|
|
return true;
|
|
}
|
|
|
|
if (publication.PreparationCursor < entities.Count)
|
|
{
|
|
WorldEntity entity = entities[publication.PreparationCursor];
|
|
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
|
{
|
|
uint id = entity.MeshRefs[i].GfxObjId;
|
|
if ((id & 0xFF000000u) == 0x01000000u)
|
|
publication.GfxObjectIdSet.Add(id);
|
|
}
|
|
publication.PreparationCursor++;
|
|
return false;
|
|
}
|
|
|
|
publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray();
|
|
publication.PreparedGeneration.SetAssetClosure(
|
|
publication.GfxObjectIds,
|
|
publication.SetupObjectIds);
|
|
publication.PreparationCommitted = true;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances the exact terrain/cell/building physics replacement from a
|
|
/// retained receipt.
|
|
/// </summary>
|
|
public void BeginPublication(LandblockPhysicsPublication publication)
|
|
{
|
|
ValidateReceipt(publication);
|
|
if (!publication.PreparationCommitted)
|
|
throw new InvalidOperationException(
|
|
"Physics publication cannot begin before preparation commits.");
|
|
while (!AdvanceBeginOne(publication))
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances one exact physics-prefix operation: prior-cache withdrawal,
|
|
/// terrain construction, one EnvCell, one building, or the final canonical
|
|
/// physics-engine replacement. All arrays and cursors live on the receipt.
|
|
/// </summary>
|
|
internal bool AdvanceBeginOne(LandblockPhysicsPublication publication)
|
|
{
|
|
ValidateReceipt(publication);
|
|
if (publication.BeginCommitted)
|
|
return true;
|
|
|
|
long started = Stopwatch.GetTimestamp();
|
|
LandblockBuild build = publication.Build;
|
|
Vector3 origin = publication.Origin;
|
|
LoadedLandblock landblock = build.Landblock;
|
|
PhysicsDatBundle datBundle =
|
|
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
|
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo = datBundle.Info;
|
|
|
|
if (!publication.PriorCacheRemoved)
|
|
{
|
|
// CacheCellStruct/CacheBuilding use first-wins semantics within one
|
|
// publication, so the replacement pass starts with one exact
|
|
// landblock-scoped withdrawal.
|
|
publication.StagingCache.RemoveCellsForLandblock(landblock.LandblockId);
|
|
publication.StagingCache.RemoveBuildingsForLandblock(landblock.LandblockId);
|
|
publication.StagingCache.CellGraph.RemoveEnvCellsForLandblock(
|
|
landblock.LandblockId);
|
|
publication.PriorCacheRemoved = true;
|
|
}
|
|
else if (publication.TerrainSurface is null)
|
|
{
|
|
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
|
|
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
|
|
var terrainBytes = new byte[81];
|
|
for (int i = 0; i < terrainBytes.Length; i++)
|
|
terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i];
|
|
publication.TerrainSurface = new TerrainSurface(
|
|
landblock.Heightmap.Height,
|
|
_heightTable,
|
|
landblockX,
|
|
landblockY,
|
|
terrainBytes);
|
|
}
|
|
else if (landblockInfo is not null
|
|
&& publication.CellCursor < landblockInfo.NumCells)
|
|
{
|
|
PublishCell(publication, datBundle, publication.CellCursor);
|
|
publication.CellCursor++;
|
|
}
|
|
else if (publication.BuildingCursor < publication.Buildings.Length)
|
|
{
|
|
PublishBuilding(
|
|
landblock,
|
|
datBundle,
|
|
publication.StagingCache,
|
|
publication.TerrainSurface,
|
|
origin,
|
|
publication.Buildings[publication.BuildingCursor]);
|
|
publication.BuildingCursor++;
|
|
}
|
|
else if (!publication.BaseCommitted)
|
|
{
|
|
_physics.StageCollisionAssets(
|
|
publication.CollisionAdmission,
|
|
publication.PreparedGeneration,
|
|
new RuntimeLandblockCollisionAssets(
|
|
landblock.LandblockId,
|
|
publication.TerrainSurface,
|
|
publication.CellSurfaces.ToArray(),
|
|
publication.PortalPlanes.ToArray(),
|
|
origin.X,
|
|
origin.Y,
|
|
publication.CurrentCellId));
|
|
publication.BaseCommitted = true;
|
|
}
|
|
else
|
|
{
|
|
_cellSurfaceCount += publication.CellSurfaces.Count;
|
|
_portalPlaneCount += publication.PortalPlanes.Count;
|
|
_buildingCount += publication.Buildings.Length;
|
|
publication.BeginCommitted = true;
|
|
_beginCount++;
|
|
}
|
|
|
|
_basePublishTicks += Stopwatch.GetTimestamp() - started;
|
|
return publication.BeginCommitted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes ordinary static collision and refloods after the caller has
|
|
/// completed the render-owned building/EnvCell suffix. The concrete
|
|
/// presentation pipeline supplies the static-presentation owner callback
|
|
/// that preserves per-entity light-before-collision order.
|
|
/// </summary>
|
|
public bool CompletePublication(
|
|
LandblockPhysicsPublication publication,
|
|
Action<WorldEntity>? beforeStaticCollision = null)
|
|
{
|
|
ValidateReceipt(publication);
|
|
if (!publication.BeginCommitted)
|
|
throw new InvalidOperationException(
|
|
"Physics publication cannot complete before its prefix commits.");
|
|
while (!AdvanceCompleteOne(publication, beforeStaticCollision))
|
|
{
|
|
if (publication.RuntimeMutationPending)
|
|
{
|
|
if (!CanContinueMutationSynchronously())
|
|
return false;
|
|
// The activation transaction deliberately closes its exact
|
|
// quiescence boundary on one poll and transfers the engine on
|
|
// the next. This synchronous compatibility API may consume
|
|
// that finite internal suffix; the frame-budgeted pipeline
|
|
// always yields on the first nonterminal commit below.
|
|
publication.RuntimeMutationPending = false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
internal bool CanContinueMutationSynchronously()
|
|
{
|
|
RuntimePhysicsOwnershipSnapshot ownership = _physics.CaptureOwnership();
|
|
return ownership.PendingCollisionPrefixProjectionCount == 0
|
|
&& ownership.PendingCollisionReportCount == 0
|
|
&& ownership.PendingCollisionSetPositionDispatchCount == 0
|
|
&& ownership.PendingShadowSetPositionDispatchCount == 0
|
|
&& !ownership.IsCollisionReportDispatching;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances one exact physics-suffix operation: one GfxObj cache entry,
|
|
/// prior static withdrawal, one static light/collision entity, or reflood.
|
|
/// </summary>
|
|
internal bool AdvanceCompleteOne(
|
|
LandblockPhysicsPublication publication,
|
|
Action<WorldEntity>? beforeStaticCollision = null)
|
|
{
|
|
ValidateReceipt(publication);
|
|
if (publication.CancellationRequested)
|
|
throw new InvalidOperationException(
|
|
"A cancelled collision publication cannot resume.");
|
|
if (!publication.BeginCommitted)
|
|
throw new InvalidOperationException(
|
|
"Physics publication cannot complete before its prefix commits.");
|
|
if (publication.CompletionCommitted)
|
|
return true;
|
|
publication.RuntimeMutationPending = false;
|
|
|
|
long started = Stopwatch.GetTimestamp();
|
|
LoadedLandblock landblock = publication.Build.Landblock;
|
|
PhysicsDatBundle datBundle =
|
|
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
|
|
|
if (publication.GfxCursor < publication.GfxObjectIds.Length)
|
|
{
|
|
long cacheStarted = Stopwatch.GetTimestamp();
|
|
uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor];
|
|
if (publication.Build.Collisions?.GfxObjs.TryGetValue(
|
|
gfxObjectId,
|
|
out FlatGfxObjCollisionAsset? prepared) == true)
|
|
{
|
|
publication.StagingCache.CacheGfxObj(gfxObjectId, prepared);
|
|
}
|
|
else if (datBundle.GfxObjs.TryGetValue(
|
|
gfxObjectId,
|
|
out var source))
|
|
{
|
|
// Graph-oracle fixture seam. Production near builds always
|
|
// carry the strict prepared closure.
|
|
publication.StagingCache.CacheGfxObj(gfxObjectId, source);
|
|
}
|
|
publication.GfxCursor++;
|
|
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
|
|
}
|
|
else if (publication.SetupCursor < publication.SetupObjectIds.Length)
|
|
{
|
|
uint setupId =
|
|
publication.SetupObjectIds[publication.SetupCursor];
|
|
if (publication.Build.Collisions?.Setups.TryGetValue(
|
|
setupId,
|
|
out FlatSetupCollision? prepared) == true)
|
|
{
|
|
publication.StagingCache.CacheSetup(setupId, prepared);
|
|
}
|
|
else if (datBundle.Setups.TryGetValue(setupId, out var source))
|
|
{
|
|
// Graph-oracle fixture seam only.
|
|
publication.StagingCache.CacheSetup(setupId, source);
|
|
}
|
|
publication.SetupCursor++;
|
|
}
|
|
else if (publication.PriorStaticCursor
|
|
< publication.PriorStaticOwnerIds.Length)
|
|
{
|
|
publication.StagingEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
|
|
publication.PriorStaticOwnerIds[
|
|
publication.PriorStaticCursor],
|
|
landblock.LandblockId);
|
|
publication.PriorStaticCursor++;
|
|
}
|
|
else if (publication.StaticCursor < landblock.Entities.Count)
|
|
{
|
|
WorldEntity entity = landblock.Entities[publication.StaticCursor];
|
|
beforeStaticCollision?.Invoke(entity);
|
|
PublishStaticEntity(publication, entity);
|
|
publication.StaticCursor++;
|
|
}
|
|
else if (publication.RefloodOwnerIds is null)
|
|
{
|
|
RuntimeCollisionOwnerCaptureStep capture =
|
|
_physics.AdvanceCollisionRetainedOwnerCapture(
|
|
publication.CollisionAdmission,
|
|
publication.PreparedGeneration);
|
|
if (capture.Completed)
|
|
{
|
|
publication.RefloodOwnerIds =
|
|
publication.PreparedGeneration.RetainedOwnerIds;
|
|
}
|
|
}
|
|
else if (publication.RefloodCursor
|
|
< publication.RefloodOwnerIds.Count)
|
|
{
|
|
_physics.RefreshCollisionRetainedOwner(
|
|
publication.CollisionAdmission,
|
|
publication.PreparedGeneration,
|
|
publication.RefloodOwnerIds[publication.RefloodCursor]);
|
|
publication.RefloodCursor++;
|
|
}
|
|
else if (!publication.RefloodCommitted)
|
|
{
|
|
if (PhysicsDiagnostics.ProbeBuildingEnabled
|
|
&& publication.SceneryTried > 0)
|
|
{
|
|
Console.WriteLine(
|
|
$"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " +
|
|
$"(outdoorNone={publication.NoCollisionCount})");
|
|
}
|
|
LogMissingSceneryBounds(landblock, publication.StagingCache);
|
|
publication.RefloodCommitted = true;
|
|
}
|
|
else if (!publication.SealCommitted)
|
|
{
|
|
RuntimeCollisionSealStep seal =
|
|
_physics.AdvanceCollisionGenerationSeal(
|
|
publication.CollisionAdmission,
|
|
publication.PreparedGeneration);
|
|
if (seal.WorkUnits > 1)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Collision seal exceeded its one-unit publication budget.");
|
|
}
|
|
publication.SealCommitted = seal.Completed;
|
|
if (seal.Restarted)
|
|
{
|
|
publication.RefloodOwnerIds = null;
|
|
publication.RefloodCursor = 0;
|
|
publication.RefloodCommitted = false;
|
|
}
|
|
}
|
|
|
|
// Seal reconciliation and the zero-work root transfer are one host
|
|
// update-thread transaction. Deferring this commit to the next frame
|
|
// would let continuously moving, unrelated live owners dirty their
|
|
// already-visited journal slots forever even though each metered replay
|
|
// had just caught up exactly.
|
|
if (publication.SealCommitted && !publication.CompletionCommitted)
|
|
{
|
|
RuntimeCollisionGenerationCommit commit =
|
|
_physics.CommitCollisionGeneration(
|
|
publication.CollisionAdmission,
|
|
publication.PreparedGeneration);
|
|
publication.EngineMutationCommitted |= commit.EngineCommitted;
|
|
if (!commit.Completed)
|
|
{
|
|
publication.RuntimeMutationPending = true;
|
|
if (!commit.EngineCommitted)
|
|
{
|
|
// Runtime coalesces post-seal arrivals in its owner
|
|
// journal. Resume that seal tail rather than restarting
|
|
// the completed generation-wide capture/reflood pass.
|
|
publication.SealCommitted = false;
|
|
}
|
|
// Once EngineCommitted is true the replacement is canonical
|
|
// and cannot be resealed or rolled back. Later frames poll
|
|
// only the exact resident-restore acknowledgement suffix.
|
|
_completePublishTicks += Stopwatch.GetTimestamp() - started;
|
|
return false;
|
|
}
|
|
_refloodCount++;
|
|
_staticBspOwnerCount += publication.BspOwnerCount;
|
|
_staticCylinderOwnerCount += publication.CylinderOwnerCount;
|
|
publication.CompletionCommitted = true;
|
|
_completeCount++;
|
|
}
|
|
|
|
_completePublishTicks += Stopwatch.GetTimestamp() - started;
|
|
return publication.CompletionCommitted;
|
|
}
|
|
|
|
public bool DemoteToTerrain(uint landblockId)
|
|
{
|
|
for (int poll = 0; poll < 2; poll++)
|
|
{
|
|
if (AdvanceDemotion(landblockId))
|
|
return true;
|
|
if (_physics.CaptureOwnership()
|
|
.PendingCollisionPrefixProjectionCount != 0)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
internal bool AdvanceDemotion(uint landblockId)
|
|
{
|
|
RuntimeCollisionMutationResult result =
|
|
_physics.DemoteCollisionToTerrain(landblockId);
|
|
if (!result.Completed)
|
|
return false;
|
|
_demotionCount++;
|
|
return true;
|
|
}
|
|
|
|
public bool RemoveLandblock(uint landblockId)
|
|
{
|
|
for (int poll = 0; poll < 2; poll++)
|
|
{
|
|
if (AdvanceRemoval(landblockId))
|
|
return true;
|
|
if (_physics.CaptureOwnership()
|
|
.PendingCollisionPrefixProjectionCount != 0)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
internal bool AdvanceRemoval(uint landblockId)
|
|
{
|
|
RuntimeCollisionMutationResult result =
|
|
_physics.WithdrawCollision(landblockId);
|
|
if (!result.Completed)
|
|
return false;
|
|
_fullRemovalCount++;
|
|
return true;
|
|
}
|
|
|
|
private void PublishCell(
|
|
LandblockPhysicsPublication publication,
|
|
PhysicsDatBundle datBundle,
|
|
uint offset)
|
|
{
|
|
LoadedLandblock landblock = publication.Build.Landblock;
|
|
uint envCellId =
|
|
(landblock.LandblockId & 0xFFFF0000u) | (0x0100u + offset);
|
|
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell))
|
|
return;
|
|
|
|
if (publication.Build.Collisions is not { } collisions)
|
|
{
|
|
PublishGraphFixtureCell(
|
|
publication,
|
|
datBundle,
|
|
envCellId,
|
|
envCell);
|
|
return;
|
|
}
|
|
|
|
if (!collisions.CellStructures.TryGetValue(
|
|
envCellId,
|
|
out FlatCellStructureCollisionAsset? preparedStructure)
|
|
|| !collisions.EnvCells.TryGetValue(
|
|
envCellId,
|
|
out FlatEnvCellTopology? preparedTopology))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Quaternion rotation = envCell.Position.Orientation;
|
|
Vector3 cellOriginWorld = envCell.Position.Origin + publication.Origin;
|
|
Matrix4x4 physicsCellTransform =
|
|
Matrix4x4.CreateFromQuaternion(rotation)
|
|
* Matrix4x4.CreateTranslation(cellOriginWorld);
|
|
|
|
publication.StagingCache.CacheCellStruct(
|
|
envCellId,
|
|
envCell,
|
|
physicsCellTransform,
|
|
preparedStructure,
|
|
preparedTopology);
|
|
|
|
FlatPolygonTable physicsPolygons =
|
|
preparedStructure.PhysicsBsp.PolygonTable;
|
|
|
|
var portalPlanes = new List<PortalPlane>();
|
|
FlatPolygonTable portalPolygons = preparedStructure.PortalPolygons;
|
|
for (int portalIndex = 0;
|
|
portalIndex < preparedTopology.Portals.Length;
|
|
portalIndex++)
|
|
{
|
|
FlatEnvCellPortal portal = preparedTopology.Portals[portalIndex];
|
|
if ((uint)portal.PolygonIndex
|
|
>= (uint)portalPolygons.Polygons.Length)
|
|
{
|
|
continue;
|
|
}
|
|
FlatCollisionPolygon polygon =
|
|
portalPolygons.Polygons[portal.PolygonIndex];
|
|
if (polygon.VertexRange.Count < 3)
|
|
continue;
|
|
var portalVertices = new Vector3[polygon.VertexRange.Count];
|
|
for (int index = 0; index < portalVertices.Length; index++)
|
|
{
|
|
Vector3 local = portalPolygons.Vertices[
|
|
polygon.VertexRange.Start + index];
|
|
portalVertices[index] =
|
|
Vector3.Transform(local, rotation) + cellOriginWorld;
|
|
}
|
|
|
|
portalPlanes.Add(PortalPlane.FromVertices(
|
|
portalVertices.AsSpan(),
|
|
portal.OtherCellId,
|
|
envCellId & 0xFFFFu,
|
|
(ushort)portal.Flags));
|
|
}
|
|
|
|
publication.CellSurfaces.Add(new CellSurface(
|
|
envCellId,
|
|
physicsPolygons,
|
|
rotation,
|
|
cellOriginWorld));
|
|
publication.PortalPlanes.AddRange(portalPlanes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parsed-graph publication is retained only for unit/conformance fixtures
|
|
/// constructed without a prepared collision closure. Production cannot
|
|
/// reach this branch because near-build admission requires that closure.
|
|
/// </summary>
|
|
private void PublishGraphFixtureCell(
|
|
LandblockPhysicsPublication publication,
|
|
PhysicsDatBundle datBundle,
|
|
uint envCellId,
|
|
DatReaderWriter.DBObjs.EnvCell envCell)
|
|
{
|
|
if (envCell.EnvironmentId == 0
|
|
|| !datBundle.Environments.TryGetValue(
|
|
0x0D000000u | envCell.EnvironmentId,
|
|
out var environment)
|
|
|| !environment.Cells.TryGetValue(
|
|
envCell.CellStructure,
|
|
out var cellStruct))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Quaternion rotation = envCell.Position.Orientation;
|
|
Vector3 cellOriginWorld =
|
|
envCell.Position.Origin + publication.Origin;
|
|
Matrix4x4 physicsCellTransform =
|
|
Matrix4x4.CreateFromQuaternion(rotation)
|
|
* Matrix4x4.CreateTranslation(cellOriginWorld);
|
|
publication.StagingCache.CacheCellStruct(
|
|
envCellId,
|
|
envCell,
|
|
cellStruct,
|
|
physicsCellTransform);
|
|
|
|
var worldVertices = new Dictionary<ushort, Vector3>(
|
|
cellStruct.VertexArray.Vertices.Count);
|
|
foreach ((ushort vertexId, var vertex) in
|
|
cellStruct.VertexArray.Vertices)
|
|
{
|
|
worldVertices[vertexId] =
|
|
Vector3.Transform(vertex.Origin, rotation)
|
|
+ cellOriginWorld;
|
|
}
|
|
|
|
var polygonVertexIds = new List<List<short>>(
|
|
cellStruct.PhysicsPolygons.Count);
|
|
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
|
|
polygonVertexIds.Add([.. polygon.VertexIds]);
|
|
|
|
var portalPlanes = new List<PortalPlane>();
|
|
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,
|
|
portal.OtherCellId,
|
|
envCellId & 0xFFFFu,
|
|
(ushort)portal.Flags));
|
|
}
|
|
|
|
publication.CellSurfaces.Add(new CellSurface(
|
|
envCellId,
|
|
worldVertices,
|
|
polygonVertexIds));
|
|
publication.PortalPlanes.AddRange(portalPlanes);
|
|
}
|
|
|
|
private void PublishBuilding(
|
|
LoadedLandblock landblock,
|
|
PhysicsDatBundle datBundle,
|
|
PhysicsDataCache cache,
|
|
TerrainSurface terrainSurface,
|
|
Vector3 origin,
|
|
BuildingInfo building)
|
|
{
|
|
uint landblockPrefix = landblock.LandblockId & 0xFFFF0000u;
|
|
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;
|
|
}
|
|
cache.CacheBuilding(
|
|
landcellId,
|
|
portals,
|
|
buildingTransform,
|
|
modelId: shellPartZero);
|
|
}
|
|
|
|
private void PublishStaticEntity(
|
|
LandblockPhysicsPublication publication,
|
|
WorldEntity entity)
|
|
{
|
|
LoadedLandblock landblock = publication.Build.Landblock;
|
|
// Retail building shells collide exclusively through the per-landcell
|
|
// building channel. They never become ordinary shadow objects.
|
|
if (entity.IsBuildingShell)
|
|
return;
|
|
|
|
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)
|
|
publication.SceneryTried++;
|
|
|
|
IReadOnlyList<ShadowShape> bspShapes =
|
|
ShadowShapeBuilder.FromLandblockBspParts(
|
|
entity.MeshRefs,
|
|
entity.IsBuildingShell,
|
|
publication.StagingCache.GetGfxObj);
|
|
entityBspCount = bspShapes.Count;
|
|
if (entityBspCount > 0)
|
|
{
|
|
publication.StagingEngine.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);
|
|
}
|
|
|
|
FlatSetupCollision? setup =
|
|
publication.StagingCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
|
|
if (setup is null
|
|
&& publication.StagingCache.GetSetup(
|
|
entity.SourceGfxObjOrSetupId) is { } graphSetup)
|
|
{
|
|
// Graph-oracle fixture seam only. Production Setup publication is
|
|
// already package-flat and never allocates this conversion.
|
|
setup = FlatCollisionAssetBuilder.FlattenSetup(graphSetup);
|
|
}
|
|
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.Cylinders.Length;
|
|
cylinderIndex++)
|
|
{
|
|
FlatCollisionCylinder cylinder =
|
|
setup.Cylinders[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.Cylinders.Length == 0)
|
|
{
|
|
for (int sphereIndex = 0;
|
|
sphereIndex < setup.Spheres.Length;
|
|
sphereIndex++)
|
|
{
|
|
FlatCollisionSphere 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));
|
|
}
|
|
}
|
|
|
|
// No Setup-radius fallback: retail synthesizes no shape for a
|
|
// Setup with neither CylSpheres nor Spheres. See
|
|
// LiveEntityCollisionBuilder's remarks for the retail anchor.
|
|
if (setupShapes.Count > 0)
|
|
{
|
|
publication.StagingEngine.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)
|
|
publication.BspOwnerCount++;
|
|
if (entityCylinderCount > 0)
|
|
publication.CylinderOwnerCount++;
|
|
if (entityBspCount == 0 && entityCylinderCount == 0
|
|
&& (sourcePrefix == 0x01000000u
|
|
|| sourcePrefix == 0x02000000u))
|
|
{
|
|
publication.NoCollisionCount++;
|
|
}
|
|
}
|
|
|
|
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 static void LogMissingSceneryBounds(
|
|
LoadedLandblock landblock,
|
|
PhysicsDataCache cache)
|
|
{
|
|
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 =
|
|
cache.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);
|
|
|
|
private void ValidateReceipt(LandblockPhysicsPublication publication)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(publication);
|
|
if (!ReferenceEquals(publication.Owner, _receiptOwner))
|
|
{
|
|
throw new ArgumentException(
|
|
"The physics publication receipt belongs to another publisher.",
|
|
nameof(publication));
|
|
}
|
|
if (!publication.CompletionCommitted
|
|
&& !publication.EngineMutationCommitted)
|
|
{
|
|
ObjectDisposedException.ThrowIf(
|
|
publication.PreparedGeneration.IsDisposed,
|
|
publication);
|
|
}
|
|
}
|
|
}
|