using System.Diagnostics; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.World; namespace AcDream.App.Streaming; /// /// 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. /// 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 BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } public uint LandblockId => Build.Landblock.LandblockId; public Vector3 Origin { get; } } /// /// Cumulative update-thread diagnostics for physics publication and removal. /// Durations are ticks. /// 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); /// /// 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. /// /// /// The order preserves retail CObjCell::init_objects (0x0052B420), /// CPhysicsObj::recalc_cross_cells (0x00515A30), /// CBuildingObj::find_building_collisions (0x006B5300), and /// CPhysicsObj::add_shadows_to_cells (0x00514AE0). The BSP-or-Setup /// collision gate follows CPhysicsObj::FindObjCollisions (0x0050F050). /// Building shells use the per-landcell building channel; ordinary statics /// use one multipart shadow owner or the mutually exclusive Setup /// cylinder/sphere fallback. /// 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); /// /// Publishes terrain, EnvCell physics, portal planes, and building shells. /// The render publisher completes its building/EnvCell transaction before /// publishes ordinary static collision. /// public LandblockPhysicsPublication BeginPublication( LandblockRenderPublication renderPublication) { LandblockPhysicsPublication publication = PreparePublication( renderPublication); BeginPublication(publication); return publication; } /// /// 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. /// public LandblockPhysicsPublication PreparePublication( LandblockRenderPublication renderPublication) { ArgumentNullException.ThrowIfNull(renderPublication); LandblockBuild build = renderPublication.Build; Vector3 origin = renderPublication.Origin; if (!IsFinite(origin)) throw new ArgumentOutOfRangeException(nameof(renderPublication)); return new LandblockPhysicsPublication( _receiptOwner, build, origin); } /// /// Advances the exact terrain/cell/building physics replacement from a /// retained receipt. /// public void BeginPublication(LandblockPhysicsPublication publication) { ValidateReceipt(publication); if (publication.BeginCommitted) return; long started = Stopwatch.GetTimestamp(); LandblockBuild build = publication.Build; Vector3 origin = publication.Origin; 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(); var portalPlanes = new List(); 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( 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>( cellStruct.PhysicsPolygons.Count); foreach (var polygon in cellStruct.PhysicsPolygons.Values) { var vertexIds = new List(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; publication.BeginCommitted = true; _beginCount++; _basePublishTicks += Stopwatch.GetTimestamp() - started; } /// /// 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. /// public void CompletePublication( LandblockPhysicsPublication publication, Action? beforeStaticCollision = null) { ValidateReceipt(publication); if (!publication.BeginCommitted) throw new InvalidOperationException( "Physics publication cannot complete before its prefix commits."); 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 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(); 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(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 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 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(); 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); 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)); } } }