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