diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9650f0d..1bfce28 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1806,7 +1806,7 @@ public sealed class GameWindow : IDisposable // _heightTable and _blendCtx are read-only after initialization. // lb.Heightmap is the pre-loaded LandBlock; no dat read needed here. return AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); + lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache, lb.BuildingTerrainCells); }); _streamer.Start(); @@ -5112,7 +5112,8 @@ public sealed class GameWindow : IDisposable return new AcDream.Core.World.LoadedLandblock( baseLoaded.LandblockId, baseLoaded.Heightmap, - merged); + merged, + baseLoaded.BuildingTerrainCells); } /// @@ -5360,11 +5361,16 @@ public sealed class GameWindow : IDisposable { _pendingCellMeshes[envCellId] = cellSubMeshes; - var cellOrigin = envCell.Position.Origin + lbOffset - + new System.Numerics.Vector3(0f, 0f, 0.02f); + // Keep the small render lift out of physics; retail BSP + // contact planes use the EnvCell origin verbatim. + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f); var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform); @@ -5383,7 +5389,7 @@ public sealed class GameWindow : IDisposable BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); // Cache CellStruct physics BSP for indoor collision. - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform); + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } @@ -8752,7 +8758,7 @@ public sealed class GameWindow : IDisposable uint lbX = (id >> 24) & 0xFFu; uint lbY = (id >> 16) & 0xFFu; return AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); + lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache, lb.BuildingTerrainCells); }); _streamer.Start(); diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 90472f6..377023b 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -173,7 +173,11 @@ public sealed class GpuWorldState var merged = new List(landblock.Entities.Count + pending.Count); merged.AddRange(landblock.Entities); merged.AddRange(pending); - landblock = new LoadedLandblock(landblock.LandblockId, landblock.Heightmap, merged); + landblock = new LoadedLandblock( + landblock.LandblockId, + landblock.Heightmap, + merged, + landblock.BuildingTerrainCells); _pendingByLandblock.Remove(landblock.LandblockId); } @@ -232,7 +236,11 @@ public sealed class GpuWorldState var newList = new List(entities.Count - 1); for (int j = 0; j < entities.Count; j++) if (j != i) newList.Add(entities[j]); - _loaded[kvp.Key] = new LoadedLandblock(kvp.Value.LandblockId, kvp.Value.Heightmap, newList); + _loaded[kvp.Key] = new LoadedLandblock( + kvp.Value.LandblockId, + kvp.Value.Heightmap, + newList, + kvp.Value.BuildingTerrainCells); // Add to new (via AppendLiveEntity which handles pending) AppendLiveEntity(newCanonicalLb, entity); @@ -333,7 +341,7 @@ public sealed class GpuWorldState foreach (var e in lb.Entities) if (e.ServerGuid != serverGuid) newList.Add(e); - _loaded[kvp.Key] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newList); + _loaded[kvp.Key] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newList, lb.BuildingTerrainCells); rebuiltLoaded = true; } @@ -387,7 +395,11 @@ public sealed class GpuWorldState var newEntities = new List(lb.Entities.Count + 1); newEntities.AddRange(lb.Entities); newEntities.Add(entity); - _loaded[canonicalLandblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities); + _loaded[canonicalLandblockId] = new LoadedLandblock( + lb.LandblockId, + lb.Heightmap, + newEntities, + lb.BuildingTerrainCells); RebuildFlatView(); return; } @@ -448,7 +460,11 @@ public sealed class GpuWorldState } } - _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); + _loaded[canonical] = new LoadedLandblock( + lb.LandblockId, + lb.Heightmap, + System.Array.Empty(), + lb.BuildingTerrainCells); _pendingByLandblock.Remove(canonical); RebuildFlatView(); } @@ -484,7 +500,7 @@ public sealed class GpuWorldState var merged = new List(lb.Entities.Count + entities.Count); merged.AddRange(lb.Entities); merged.AddRange(entities); - _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged, lb.BuildingTerrainCells); if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index f71e0c0..8a126c7 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -231,7 +231,8 @@ public sealed class LandblockStreamer : IDisposable lb = new LoadedLandblock( lb.LandblockId, lb.Heightmap, - System.Array.Empty()); + System.Array.Empty(), + lb.BuildingTerrainCells); } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( load.LandblockId, tier, lb, mesh)); diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 8f20071..e5cddc0 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -860,6 +860,9 @@ public static class BSPQuery if (!resolved.TryGetValue(polyId, out var poly)) continue; if (HitsSphere(poly, sphere)) { + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = poly; + if (PhysicsDiagnostics.ProbePlacementFailEnabled) { PhysicsDiagnostics.LastPlacementFailPolyId = poly.Id; @@ -1231,6 +1234,9 @@ public static class BSPQuery path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = polyHit; + return TransitionState.Adjusted; } @@ -1767,6 +1773,9 @@ public static class BSPQuery collisions.SetContactPlane(worldPlane, path.CheckCellId, false); path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = hitPoly; + return TransitionState.Adjusted; } return TransitionState.OK; diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index c7d9cf0..620889f 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -1,12 +1,13 @@ using System.Collections.Generic; using System.Numerics; +using DatReaderWriter.Types; namespace AcDream.Core.Physics; /// /// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, /// ported from retail's CObjCell::find_cell_list family -/// (sphere variant for the player's single foot sphere). +/// (sphere variant for the player's path spheres). /// /// /// Replaces Phase D's AABB containment. Uses the cell BSP for retail- @@ -50,40 +51,109 @@ public static class CellTransit float sphereRadius, HashSet candidates, out bool exitOutside) + { + var spheres = new[] + { + new Sphere + { + Origin = worldSphereCenter, + Radius = sphereRadius, + }, + }; + + FindTransitCellsSphere( + cache, currentCell, currentCellId, + spheres, spheres.Length, candidates, out exitOutside); + } + + /// + /// Multi-sphere form used by retail's CObjCell::find_cell_list: + /// pass sphere_path.num_sphere and sphere_path.global_sphere. + /// Any sphere can trigger a portal neighbor or outdoor exit. + /// + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + IReadOnlyList worldSpheres, + int numSpheres, + HashSet candidates, + out bool exitOutside) { exitOutside = false; - if (currentCell.PortalPolygons is null) return; uint lbPrefix = currentCellId & 0xFFFF0000u; - float rad = sphereRadius + EPSILON; + int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); - // Cell-local sphere center. - var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform); + if (currentCell.PortalPolygons is null || sphereCount == 0) return; foreach (var portal in currentCell.Portals) { if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly)) continue; - // Signed distance from sphere center to portal plane (cell-local). - float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; - if (portal.OtherCellId == 0xFFFF) { - // Exit portal. Sphere must straddle the plane. - if (dist > -rad && dist < rad) - exitOutside = true; + // Exit portal. Any path sphere straddling the plane triggers + // the outdoor cell expansion. + for (int i = 0; i < sphereCount; i++) + { + var sphere = worldSpheres[i]; + float rad = sphere.Radius + EPSILON; + var localCenter = Vector3.Transform( + sphere.Origin, currentCell.InverseWorldTransform); + float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; + bool hit = dist > -rad && dist < rad; + if (hit) + { + exitOutside = true; + break; + } + } continue; } uint otherId = lbPrefix | portal.OtherCellId; - // Conservative add: the sphere is near the portal plane and on the - // outward side (per PortalSide). This is the load-hint branch from - // the research doc. A more retail-faithful path would call - // CellBSP.sphere_intersects_cell on the neighbour — deferred. - if (portal.PortalSide ? dist > -rad : dist < rad) - candidates.Add(otherId); + // Retail CEnvCell::find_transit_cells first asks the loaded + // neighbour cell whether the sphere intersects its CellBSP. + // The portal-plane side test is only the unloaded-cell load hint. + var otherCell = cache.GetCellStruct(otherId); + if (otherCell?.CellBSP?.Root is not null) + { + for (int i = 0; i < sphereCount; i++) + { + var sphere = worldSpheres[i]; + var otherLocalCenter = Vector3.Transform( + sphere.Origin, otherCell.InverseWorldTransform); + bool hit = BSPQuery.SphereIntersectsCellBsp( + otherCell.CellBSP.Root, otherLocalCenter, sphere.Radius); + if (hit) + { + candidates.Add(otherId); + break; + } + } + + continue; + } + + // Conservative unloaded-cell hint: the sphere is near the portal + // plane and on the outward side (per PortalSide). + for (int i = 0; i < sphereCount; i++) + { + var sphere = worldSpheres[i]; + float rad = sphere.Radius + EPSILON; + var localCenter = Vector3.Transform( + sphere.Origin, currentCell.InverseWorldTransform); + float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; + bool hit = portal.PortalSide ? dist > -rad : dist < rad; + if (hit) + { + candidates.Add(otherId); + break; + } + } } } @@ -141,6 +211,24 @@ public static class CellTransit if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1); } + /// + /// Multi-sphere outdoor expansion. Retail's sphere variant loops every + /// path sphere and adds the outdoor landcells touched by any of them. + /// + public static void AddAllOutsideCells( + IReadOnlyList worldSpheres, + int numSpheres, + uint currentCellId, + HashSet candidates) + { + int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); + for (int i = 0; i < sphereCount; i++) + { + var sphere = worldSpheres[i]; + AddAllOutsideCells(sphere.Origin, sphere.Radius, currentCellId, candidates); + } + } + private static void AddOutsideCell(HashSet candidates, uint lbPrefix, int gridX, int gridY) { if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return; @@ -242,9 +330,7 @@ public static class CellTransit float sphereRadius, uint currentCellId) { - return BuildCellSetAndPickContaining( - cache, worldSphereCenter, sphereRadius, currentCellId, - out _); + return FindCellSet(cache, worldSphereCenter, sphereRadius, currentCellId, out _); } /// @@ -267,9 +353,33 @@ public static class CellTransit float sphereRadius, uint currentCellId, out IReadOnlyCollection cellSet) + { + var spheres = new[] + { + new Sphere + { + Origin = worldSphereCenter, + Radius = sphereRadius, + }, + }; + + return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet); + } + + /// + /// Multi-sphere form of . + /// Containment still uses sphere 0's center, matching retail's + /// CObjCell::find_cell_list loop after the transit set is built. + /// + public static uint FindCellSet( + PhysicsDataCache cache, + IReadOnlyList worldSpheres, + int numSpheres, + uint currentCellId, + out IReadOnlyCollection cellSet) { var containing = BuildCellSetAndPickContaining( - cache, worldSphereCenter, sphereRadius, currentCellId, + cache, worldSpheres, numSpheres, currentCellId, out var candidates); cellSet = candidates; return containing; @@ -277,12 +387,17 @@ public static class CellTransit private static uint BuildCellSetAndPickContaining( PhysicsDataCache cache, - Vector3 worldSphereCenter, - float sphereRadius, + IReadOnlyList worldSpheres, + int numSpheres, uint currentCellId, out HashSet candidates) { candidates = new HashSet(); + int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); + if (sphereCount == 0) return currentCellId; + + Vector3 worldSphereCenter = worldSpheres[0].Origin; + float sphereRadius = worldSpheres[0].Radius; uint currentLow = currentCellId & 0xFFFFu; if (currentLow >= 0x0100u) @@ -307,7 +422,7 @@ public static class CellTransit var sizeBefore = candidates.Count; FindTransitCellsSphere( - cache, cell, cellId, worldSphereCenter, sphereRadius, + cache, cell, cellId, worldSpheres, sphereCount, candidates, out bool exitOutside); if (candidates.Count > sizeBefore) @@ -322,7 +437,7 @@ public static class CellTransit if (exitOutside) { // Add neighbour outdoor cells too. - AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); } } } @@ -330,7 +445,7 @@ public static class CellTransit { // Outdoor seed: expand neighbour landcells AND check for building stabs // with portals into interior EnvCells. - AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); // For each landcell candidate, see if it carries a building stab; if so, // check whether the sphere has crossed into any of the building's interior @@ -361,10 +476,18 @@ public static class CellTransit var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform); if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) + { return candId; + } } // No cell contained the sphere center. Stay in the input cell. return currentCellId; } + + private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres) + { + if (numSpheres <= 0 || worldSpheres.Count == 0) return 0; + return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count; + } } diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 14fe59c..bf6f2bb 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -378,6 +378,23 @@ public static class PhysicsDiagnostics public static bool ProbePlacementFailEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLACEMENT_FAIL") == "1"; + /// + /// A6.P3 issue #98 step-walk investigation (2026-05-23). When true, + /// emits one [step-walk] line at selected points in the transition + /// sub-step loop and step-down probe. The line records requested vs + /// adjusted offset, current/check sphere position, cell id, walk interp, + /// contact planes, and walkable flags so a cellar-ramp capture can answer + /// whether forward motion is being projected into rising Z or lost before + /// the placement check. + /// + /// + /// Initial state from ACDREAM_PROBE_STEP_WALK=1. One-shot + /// diagnostic; no DebugPanel mirror until the root cause is identified. + /// + /// + public static bool ProbeStepWalkEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1"; + /// /// Side-channel populated by BSPQuery.SphereIntersectsSolidInternal /// at the leaf where it returns true. Either @@ -549,6 +566,82 @@ public static class PhysicsDiagnostics primaryCellId, otherCellId, bspResult, halted)); } + /// + /// Emit one [step-walk] line for issue #98's cellar-ramp + /// investigation. Caller MUST guard with + /// if (!ProbeStepWalkEnabled) return; before calling. + /// + public static void LogStepWalk( + string site, + int stepIndex, + int stepCount, + SpherePath sp, + CollisionInfo ci, + ObjectInfo oi, + Vector3 requestedOffset, + Vector3 adjustedOffset, + TransitionState? state = null, + string? detail = null) + { + var culture = System.Globalization.CultureInfo.InvariantCulture; + var checkDelta = sp.CheckPos - sp.CurPos; + string stateText = state.HasValue ? state.Value.ToString() : "n/a"; + string stepText = stepIndex >= 0 && stepCount > 0 + ? string.Format(culture, "{0}/{1}", stepIndex + 1, stepCount) + : "-"; + + Console.WriteLine(string.Format(culture, + "[step-walk] site={0} step={1} state={2} " + + "cur=({3:F4},{4:F4},{5:F4}) check=({6:F4},{7:F4},{8:F4}) " + + "delta=({9:F4},{10:F4},{11:F4}) cell=0x{12:X8}->0x{13:X8} " + + "req=({14:F4},{15:F4},{16:F4}) adj=({17:F4},{18:F4},{19:F4}) " + + "winterp={20:F4} stepUp={21} stepDown={22} insert={23} " + + "oi=0x{24:X} contact={25} onWalkable={26} " + + "cp={27} lkcp={28} hit={29} slide={30} walkPoly={31} lastWalkPoly={32}{33}", + site, stepText, stateText, + sp.CurPos.X, sp.CurPos.Y, sp.CurPos.Z, + sp.CheckPos.X, sp.CheckPos.Y, sp.CheckPos.Z, + checkDelta.X, checkDelta.Y, checkDelta.Z, + sp.CurCellId, sp.CheckCellId, + requestedOffset.X, requestedOffset.Y, requestedOffset.Z, + adjustedOffset.X, adjustedOffset.Y, adjustedOffset.Z, + sp.WalkInterp, + sp.StepUp, sp.StepDown, sp.InsertType, + (uint)oi.State, oi.Contact, oi.OnWalkable, + FormatPlane(ci.ContactPlaneValid, ci.ContactPlane, ci.ContactPlaneCellId, ci.ContactPlaneIsWater), + FormatPlane(ci.LastKnownContactPlaneValid, ci.LastKnownContactPlane, ci.LastKnownContactPlaneCellId, ci.LastKnownContactPlaneIsWater), + FormatVector(ci.CollisionNormalValid, ci.CollisionNormal), + FormatVector(ci.SlidingNormalValid, ci.SlidingNormal), + sp.HasWalkablePolygon, sp.HasLastWalkablePolygon, + string.IsNullOrEmpty(detail) ? string.Empty : " " + detail)); + } + + private static string FormatVector(bool valid, Vector3 value) + { + if (!valid) + return "n/a"; + + return string.Format(System.Globalization.CultureInfo.InvariantCulture, + "({0:F4},{1:F4},{2:F4})", + value.X, value.Y, value.Z); + } + + private static string FormatPlane(bool valid, Plane plane, uint cellId, bool isWater) + { + if (!valid) + return "n/a"; + + float zAtOrigin = MathF.Abs(plane.Normal.Z) > PhysicsGlobals.EPSILON + ? -plane.D / plane.Normal.Z + : float.NaN; + + return string.Format(System.Globalization.CultureInfo.InvariantCulture, + "cell=0x{0:X8},water={1},n=({2:F4},{3:F4},{4:F4}),d={5:F4},z0={6:F4}", + cellId, isWater, + plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D, + zAtOrigin); + } + public static void LogCpBoolWrite(string field, bool oldValue, bool newValue) { var caller = GetCpCallerName(); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 24051a6..deccec7 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -668,18 +668,48 @@ public sealed class Transition // Main stepping loop // ------------------------------------------------------------------ var transitionState = TransitionState.OK; + bool stepWalkProbe = PhysicsDiagnostics.ProbeStepWalkEnabled; + + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "find-start", -1, numSteps, sp, CollisionInfo, ObjectInfo, + Vector3.Zero, Vector3.Zero, + transitionState, + $"dist={dist:F4} radius={radius:F4}"); + } for (int i = 0; i < numSteps; i++) { + Vector3 requestedOffset = offsetPerStep; + // Per ACE order: AdjustOffset FIRST (uses state from previous step), // THEN clear the state. This lets the sliding/contact normals from // the previous step's collision project the current step's offset. - sp.GlobalOffset = AdjustOffset(offsetPerStep); + sp.GlobalOffset = AdjustOffset(requestedOffset); + + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "after-adjust", i, numSteps, sp, CollisionInfo, ObjectInfo, + requestedOffset, sp.GlobalOffset, + transitionState); + } // Abort if adjusted offset is negligible (stuck against a wall // with no slide tangent available). if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq) + { + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "abort-small-offset", i, numSteps, sp, CollisionInfo, ObjectInfo, + requestedOffset, sp.GlobalOffset, + transitionState, + $"offsetLenSq={sp.GlobalOffset.LengthSquared():F8}"); + } return i != 0 && transitionState == TransitionState.OK; + } // Interpolate orientation (non-free-rotate path). if (!ObjectInfo.FreeRotate) @@ -697,14 +727,47 @@ public sealed class Transition // Apply the offset, then check collisions. sp.AddOffsetToCheckPos(sp.GlobalOffset); + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "before-insert", i, numSteps, sp, CollisionInfo, ObjectInfo, + requestedOffset, sp.GlobalOffset, + transitionState); + } + var result = TransitionalInsert(3, engine); + + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "after-insert", i, numSteps, sp, CollisionInfo, ObjectInfo, + requestedOffset, sp.GlobalOffset, + result); + } + transitionState = ValidateTransition(result); + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "after-validate", i, numSteps, sp, CollisionInfo, ObjectInfo, + requestedOffset, sp.GlobalOffset, + transitionState); + } + // PathClipped objects stop at the first collision. if (CollisionInfo.CollisionNormalValid && ObjectInfo.PathClipped) break; } + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "find-end", -1, numSteps, sp, CollisionInfo, ObjectInfo, + Vector3.Zero, Vector3.Zero, + transitionState); + } + return transitionState == TransitionState.OK; } @@ -1450,6 +1513,43 @@ public sealed class Transition { if (cellId == sp.CheckCellId) continue; + if ((cellId & 0xFFFFu) < 0x0100u) + { + var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); + if (terrainWalkable is null || terrainWalkable.Value.CellId != cellId) + continue; + + var terrainState = ValidateWalkable( + footCenter, + sphereRadius, + terrainWalkable.Value.Plane, + terrainWalkable.Value.IsWater, + terrainWalkable.Value.WaterDepth, + cellId: terrainWalkable.Value.CellId, + walkableVertices: terrainWalkable.Value.Vertices); + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + var plane = terrainWalkable.Value.Plane; + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} terrain wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) r={sphereRadius:F3} result={terrainState} n=({plane.Normal.X:F3},{plane.Normal.Y:F3},{plane.Normal.Z:F3}) d={plane.D:F3}")); + } + + if (PhysicsDiagnostics.ProbePushBackEnabled) + { + PhysicsDiagnostics.LogPushBackCellTransit( + primaryCellId: sp.CheckCellId, + otherCellId: cellId, + bspResult: (int)terrainState, + halted: false); + } + + if (ApplyOtherCellResult(terrainState, out var terrainHalted)) + return terrainHalted; + + continue; + } + var cell = engine.DataCache.GetCellStruct(cellId); // R2 guard: stale CellPhysics loaded for render but not physics. if (cell?.BSP?.Root is null) continue; @@ -1488,6 +1588,9 @@ public sealed class Transition cellOrigin = cell.WorldTransform.Translation; } + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = null; + var result = BSPQuery.FindCollisions( cell.BSP.Root, cell.Resolved, this, localSphere, localSphere1, localCurrCenter, @@ -1496,8 +1599,23 @@ public sealed class Transition if (PhysicsDiagnostics.ProbeIndoorBspEnabled) { + var hit = PhysicsDiagnostics.LastBspHitPoly; + string polyDesc = hit is null + ? "poly=n/a" + : System.FormattableString.Invariant( + $"poly=0x{hit.Id:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) d={hit.Plane.D:F3} sides={hit.SidesType}"); + var bs = cell.BSP.Root.BoundingSphere; + string bsDesc = System.FormattableString.Invariant( + $"bs=({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) br={bs.Radius:F3}"); Console.WriteLine(System.FormattableString.Invariant( - $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} result={result}")); + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} {bsDesc} result={result} {polyDesc}")); + } + + if (PhysicsDiagnostics.ProbeStepWalkEnabled + && sp.StepDown + && result == TransitionState.OK) + { + LogNearestWalkableCandidate("other-cell", cellId, localCenter, sphereRadius, cell.Resolved); } if (PhysicsDiagnostics.ProbePushBackEnabled) @@ -1516,6 +1634,163 @@ public sealed class Transition return TransitionState.OK; } + private void LogIssue98CellSetSummary( + PhysicsEngine engine, + uint containingCellId, + System.Collections.Generic.IReadOnlyCollection cellSet, + Vector3 footCenter, + float sphereRadius) + { + if (!PhysicsDiagnostics.ProbeStepWalkEnabled || engine.DataCache is null) + return; + + uint primary = SpherePath.CheckCellId; + if ((primary & 0xFFFF0000u) != 0xA9B40000u) + return; + + uint low = primary & 0xFFFFu; + if (low < 0x0140u || low > 0x0148u) + return; + + var ordered = new System.Collections.Generic.List(cellSet); + ordered.Sort(); + string cells = string.Join(",", ordered.ConvertAll(id => $"0x{id:X8}")); + + Console.WriteLine(System.FormattableString.Invariant( + $"[cell-set-summary] primary=0x{primary:X8} containing=0x{containingCellId:X8} has146={cellSet.Contains(0xA9B40146u)} has147={cellSet.Contains(0xA9B40147u)} foot=({footCenter.X:F4},{footCenter.Y:F4},{footCenter.Z:F4}) r={sphereRadius:F4} cells={cells}")); + + LogIssue98CellBspProbe(engine, 0xA9B40146u, footCenter, sphereRadius); + } + + private void LogIssue98CellBspProbe( + PhysicsEngine engine, + uint cellId, + Vector3 footCenter, + float sphereRadius) + { + var cell = engine.DataCache?.GetCellStruct(cellId); + if (cell?.CellBSP?.Root is null) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[cell-set-summary] target=0x{cellId:X8} unavailable")); + return; + } + + var local = Vector3.Transform(footCenter, cell.InverseWorldTransform); + bool pointInside = BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local); + bool sphereHit = BSPQuery.SphereIntersectsCellBsp(cell.CellBSP.Root, local, sphereRadius); + Console.WriteLine(System.FormattableString.Invariant( + $"[cell-set-summary] target=0x{cellId:X8} local=({local.X:F4},{local.Y:F4},{local.Z:F4}) pointInside={pointInside} sphereHit={sphereHit}")); + + if (cell.Resolved.Count > 0) + LogNearestWalkableCandidate("issue98-target", cellId, local, sphereRadius, cell.Resolved); + } + + private void LogNearestWalkableCandidate( + string site, + uint cellId, + Vector3 localCenter, + float sphereRadius, + Dictionary resolved) + { + var sp = SpherePath; + + ResolvedPolygon? nearest = null; + ushort nearestId = 0; + float nearestAbsDistance = float.MaxValue; + float nearestSignedDistance = 0f; + bool nearestInsideEdges = false; + bool nearestOverlapsSphere = false; + + foreach (var kv in resolved) + { + var poly = kv.Value; + float normalDotUp = Vector3.Dot(Vector3.UnitZ, poly.Plane.Normal); + if (normalDotUp <= sp.WalkableAllowance) + continue; + + float signedDistance = Vector3.Dot(poly.Plane.Normal, localCenter) + poly.Plane.D; + float absDistance = MathF.Abs(signedDistance); + if (absDistance >= nearestAbsDistance) + continue; + + nearest = poly; + nearestId = kv.Key; + nearestAbsDistance = absDistance; + nearestSignedDistance = signedDistance; + nearestOverlapsSphere = absDistance <= sphereRadius - PhysicsGlobals.EPSILON; + nearestInsideEdges = !BSPQuery.FindCrossedEdge( + poly.Plane, poly.Vertices, localCenter, Vector3.UnitZ, out _); + } + + if (nearest is null) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[walkable-nearest] site={site} cell=0x{cellId:X8} center=({localCenter.X:F4},{localCenter.Y:F4},{localCenter.Z:F4}) r={sphereRadius:F4} allowance={sp.WalkableAllowance:F4} none")); + return; + } + + float stepSearch = sp.StepDown ? sp.StepDownAmt * sp.WalkInterp : 0f; + Console.WriteLine(System.FormattableString.Invariant( + $"[walkable-nearest] site={site} cell=0x{cellId:X8} poly=0x{nearestId:X4} center=({localCenter.X:F4},{localCenter.Y:F4},{localCenter.Z:F4}) r={sphereRadius:F4} dist={nearestSignedDistance:F4} abs={nearestAbsDistance:F4} gap={nearestAbsDistance - sphereRadius:F4} insideEdges={nearestInsideEdges} overlapsSphere={nearestOverlapsSphere} n=({nearest.Plane.Normal.X:F4},{nearest.Plane.Normal.Y:F4},{nearest.Plane.Normal.Z:F4}) d={nearest.Plane.D:F4} allowance={sp.WalkableAllowance:F4} stepSearch={stepSearch:F4}")); + + LogIssue98WalkableCandidateDetail( + site, cellId, nearestId, nearest, localCenter, sphereRadius, stepSearch); + } + + private void LogIssue98WalkableCandidateDetail( + string site, + uint cellId, + ushort polyId, + ResolvedPolygon poly, + Vector3 localCenter, + float sphereRadius, + float stepSearch) + { + if (!PhysicsDiagnostics.ProbeStepWalkEnabled) + return; + if (site != "other-cell" || cellId != 0xA9B40143u || polyId != 0x0004) + return; + if (localCenter.Z < -1.1f || localCenter.Z > 0.2f) + return; + + var movement = -Vector3.UnitZ * stepSearch; + float dpPos = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; + float dpMove = Vector3.Dot(movement, poly.Plane.Normal); + bool parallel = dpMove <= PhysicsGlobals.EPSILON + && dpMove >= -PhysicsGlobals.EPSILON; + + float dist = 0f; + float iDist = 0f; + float interp = SpherePath.WalkInterp; + bool adjustWouldApply = false; + + if (!parallel) + { + dist = dpMove <= PhysicsGlobals.EPSILON + ? dpPos - sphereRadius + : -sphereRadius - dpPos; + iDist = dist / dpMove; + interp = (1f - iDist) * SpherePath.WalkInterp; + adjustWouldApply = interp < SpherePath.WalkInterp && interp >= -0.5f; + } + + var projected = localCenter - poly.Plane.Normal * dpPos; + var adjusted = localCenter - movement * iDist; + + var verts = new System.Text.StringBuilder(); + for (int i = 0; i < poly.Vertices.Length; i++) + { + if (i > 0) verts.Append(','); + var v = poly.Vertices[i]; + verts.Append(System.FormattableString.Invariant( + $"({v.X:F4},{v.Y:F4},{v.Z:F4})")); + } + + Console.WriteLine(System.FormattableString.Invariant( + $"[issue98-walkable-detail] cell=0x{cellId:X8} poly=0x{polyId:X4} center=({localCenter.X:F4},{localCenter.Y:F4},{localCenter.Z:F4}) projected=({projected.X:F4},{projected.Y:F4},{projected.Z:F4}) adjusted=({adjusted.X:F4},{adjusted.Y:F4},{adjusted.Z:F4}) r={sphereRadius:F4} stepSearch={stepSearch:F4} walkInterp={SpherePath.WalkInterp:F4} dpPos={dpPos:F4} dpMove={dpMove:F4} dist={dist:F4} iDist={iDist:F4} interp={interp:F4} parallel={parallel} adjustWouldApply={adjustWouldApply} verts=[{verts}]")); + } + /// /// Phase A4 (2026-05-20). Combine helper for /// . Mirrors retail's switch at @@ -1664,13 +1939,21 @@ public sealed class Transition // walls bug (cell 0xA9B40164 has only 4 polys; adjacent // 0xA9B40157 has the actual walls) closes here. // - // Discard the containing-cell return — sp.CheckCellId is - // already authoritative for the primary cell we just queried. - _ = CellTransit.FindCellSet(engine.DataCache, footCenter, sphereRadius, - sp.CheckCellId, out var cellSet); + uint containingCellId = CellTransit.FindCellSet( + engine.DataCache, sp.GlobalSphere, sp.NumSphere, + sp.CheckCellId, out var cellSet); + LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius); var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet); if (otherCellsState != TransitionState.OK) return otherCellsState; + + // Retail CTransition::check_other_cells retargets + // sphere_path.check_cell to var_4c after the other-cell + // loop succeeds, then calls SPHEREPATH::adjust_check_pos. + // For indoor cells our SetCheckPos mirrors that id swap and + // refreshes the cached global sphere centers without moving. + if (containingCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, containingCellId); // ────────────────────────────────────────────────────────── // ── Indoor walkable handling — A6.P3 slice 1 (2026-05-22) ─ @@ -1947,14 +2230,13 @@ public sealed class Transition // the [resolve] probe surfaces the responsible entity id. bool collisionWasValidPre = ci.CollisionNormalValid; - // L.2d slice 1.5 (2026-05-13): no per-iteration LastBspHitPoly - // clear. BSPQuery writes the side-channel early (inside - // `if (hit0 || hitPoly0 != null)` BEFORE any StepSphereUp call), - // so by the time we read it back for the [resolve-bldg] emission - // it reflects THIS entity's hit (or stays null if BSP didn't - // hit). For cylinder dispatch we key the "n/a (cylinder)" label - // off `obj.CollisionType` directly at the emission site, so a - // stale BSP value from a prior iteration can't leak through. + // A6.P3 issue #98 (2026-05-23): clear the diagnostic side-channel + // per object, then rely on BSPQuery to preserve the outer grounded + // hit across nested StepSphereUp work. Without the clear, stale + // polygons can leak between static entries; without the preserve, + // nested step-up probes can erase the real ramp-mouth hit. + if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled) + PhysicsDiagnostics.LastBspHitPoly = null; TransitionState result; @@ -2477,15 +2759,42 @@ public sealed class Transition sp.StepDownAmt = stepDownHeight; sp.WalkInterp = 1.0f; + bool stepWalkProbe = PhysicsDiagnostics.ProbeStepWalkEnabled; + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "stepdown-enter", -1, 0, sp, CollisionInfo, ObjectInfo, + Vector3.Zero, Vector3.Zero, + detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + } + // If NOT in step-up mode, apply the downward offset. if (!sp.StepUp) { - sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -stepDownHeight)); + var downOffset = new Vector3(0f, 0f, -stepDownHeight); + sp.AddOffsetToCheckPos(downOffset); + + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "stepdown-after-offset", -1, 0, sp, CollisionInfo, ObjectInfo, + downOffset, downOffset, + detail: $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + } } // Run collision detection with the step-down flag active. var transitState = TransitionalInsert(5, engine); + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "stepdown-after-insert", -1, 0, sp, CollisionInfo, ObjectInfo, + Vector3.Zero, Vector3.Zero, + transitState, + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + } + sp.StepDown = false; // Accept step-down if: @@ -2574,9 +2883,27 @@ public sealed class Transition sp.StepUp)); } + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "stepdown-after-placement", -1, 0, sp, CollisionInfo, ObjectInfo, + Vector3.Zero, Vector3.Zero, + placeState, + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} winterpBeforePlacement={winterpBeforePlacement:F4}"); + } + return placeState == TransitionState.OK; } + if (stepWalkProbe) + { + PhysicsDiagnostics.LogStepWalk( + "stepdown-reject", -1, 0, sp, CollisionInfo, ObjectInfo, + Vector3.Zero, Vector3.Zero, + transitState, + $"height={stepDownHeight:F4} walkableZ={walkableZ:F4} runPlacement={runPlacement}"); + } + return false; } diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 81e6724..2cf27c0 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -40,13 +40,15 @@ public static class LandblockMesh /// Region.LandDefs.LandHeightTable — 256 float heights. /// TerrainAtlas-derived blending inputs. /// Shared SurfaceInfo cache keyed by palette code. + /// Optional cell indices (cy * 8 + cx) to draw as zero-area triangles. public static LandblockMeshData Build( LandBlock block, uint landblockX, uint landblockY, float[] heightTable, TerrainBlendingContext ctx, - System.Collections.Generic.IDictionary surfaceCache) + System.Collections.Generic.IDictionary surfaceCache, + System.Collections.Generic.IReadOnlySet? hiddenTerrainCells = null) { ArgumentNullException.ThrowIfNull(block); ArgumentNullException.ThrowIfNull(heightTable); @@ -166,9 +168,21 @@ public static class LandblockMesh } } - // Indices are trivial 0..383 since we don't deduplicate verts. + // Indices are trivial 0..383 since we don't deduplicate verts. When + // a building owns an outdoor terrain cell, keep the fixed 384-index + // contract but collapse its two triangles so the building/stair mesh + // can visually own the hole. for (uint i = 0; i < VerticesPerLandblock; i++) + { + int cellIdx = (int)i / VerticesPerCell; + if (hiddenTerrainCells is not null && hiddenTerrainCells.Contains(cellIdx)) + { + indices[i] = (uint)(cellIdx * VerticesPerCell); + continue; + } + indices[i] = i; + } return new LandblockMeshData(vertices, indices); } diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs index b18608a..48f81d0 100644 --- a/src/AcDream.Core/World/LandblockLoader.cs +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -23,8 +23,30 @@ public static class LandblockLoader var entities = info is null ? Array.Empty() : BuildEntitiesFromInfo(info, landblockId); + var buildingTerrainCells = info is null + ? null + : BuildBuildingTerrainCells(info); - return new LoadedLandblock(landblockId, block, entities); + return new LoadedLandblock(landblockId, block, entities, buildingTerrainCells); + } + + /// + /// Map LandBlockInfo.Buildings to 8x8 terrain mesh cells (cy * 8 + cx). + /// Retail attaches each CBuildingObj to its outside landcell during + /// CLandBlock::init_buildings; keep this signal separate from stabs so + /// ordinary static props do not punch holes in terrain. + /// + public static IReadOnlySet BuildBuildingTerrainCells(LandBlockInfo info) + { + var result = new HashSet(); + foreach (var building in info.Buildings) + { + int cx = Math.Clamp((int)(building.Frame.Origin.X / 24f), 0, 7); + int cy = Math.Clamp((int)(building.Frame.Origin.Y / 24f), 0, 7); + result.Add(cy * 8 + cx); + } + + return result; } /// diff --git a/src/AcDream.Core/World/LoadedLandblock.cs b/src/AcDream.Core/World/LoadedLandblock.cs index 492b1f3..3d7ef0e 100644 --- a/src/AcDream.Core/World/LoadedLandblock.cs +++ b/src/AcDream.Core/World/LoadedLandblock.cs @@ -5,4 +5,5 @@ namespace AcDream.Core.World; public sealed record LoadedLandblock( uint LandblockId, LandBlock Heightmap, - IReadOnlyList Entities); + IReadOnlyList Entities, + IReadOnlySet? BuildingTerrainCells = null); diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index 76917e9..aa91a52 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -134,4 +134,43 @@ public class CellTransitFindCellSetTests Assert.Equal(0xA9B40001u, containing); Assert.True(cellSet.Count >= 2, $"Expected ≥2 cells in set (primary + east neighbour), got {cellSet.Count}"); } + + [Fact] + public void IndoorSeed_ExitPortalTouchedOnlyBySecondSphere_AddsOutdoorLandcell() + { + // Retail CObjCell::find_cell_list passes every SPHEREPATH sphere into + // CEnvCell::find_transit_cells. When any sphere straddles an outdoor + // exit portal, CLandCell::add_all_outside_cells runs for the whole + // sphere array. + uint lbPrefix = 0xA9B40000u; + float lbX = ((lbPrefix >> 24) & 0xFFu) * 192f; + float lbY = ((lbPrefix >> 16) & 0xFFu) * 192f; + + var cellTransform = Matrix4x4.CreateTranslation(new Vector3(lbX, lbY, 0f)); + var exitCell = MakeCellWithPortalAtRightWall( + cellTransform, + otherCellId: 0xFFFF, + flags: 0); + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, exitCell); + + var spheres = new[] + { + // Foot sphere is not near the exit portal plane at local x=2.5. + new Sphere { Origin = new Vector3(lbX + 0.0f, lbY + 12.0f, 2.5f), Radius = 0.5f }, + // Head sphere reaches the exit portal plane and should trigger + // outdoor landcell expansion. + new Sphere { Origin = new Vector3(lbX + 2.0f, lbY + 12.0f, 3.2f), Radius = 0.5f }, + }; + + uint containing = CellTransit.FindCellSet( + cache, spheres, spheres.Length, + currentCellId: 0xA9B40100u, + out var cellSet); + + Assert.Equal(0xA9B40100u, containing); + Assert.Contains(0xA9B40100u, cellSet); + Assert.Contains(0xA9B40001u, cellSet); + } } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs index cc9db97..d6bffb2 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Numerics; +using DatReaderWriter.Types; using AcDream.Core.Physics; using Xunit; @@ -7,6 +8,20 @@ namespace AcDream.Core.Tests.Physics; public class CellTransitFindTransitCellsSphereTests { + private static CellBSPTree SinglePlaneCellBsp() + { + var leaf = new CellBSPNode { Type = DatReaderWriter.Enums.BSPNodeType.Leaf }; + return new CellBSPTree + { + Root = new CellBSPNode + { + // Local x >= 0 is inside this synthetic cell. + SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f), + PosNode = leaf, + } + }; + } + private static CellPhysics MakeCellWithPortalAtRightWall( Matrix4x4 worldTransform, uint otherCellId, ushort flags) { @@ -88,6 +103,77 @@ public class CellTransitFindTransitCellsSphereTests Assert.DoesNotContain(0xA9B40101u, candidates); } + [Fact] + public void LoadedNeighbor_SphereIntersectsNeighborCellBsp_AddsEvenWhenPortalHintWouldReject() + { + // Retail CEnvCell::find_transit_cells uses the loaded neighbour's + // CellBSP sphere-overlap test. The portal-plane side test is only + // an unloaded-cell hint. flags=2 makes the old heuristic reject + // this world position even though the sphere overlaps cell B. + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 2); + + var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); + Matrix4x4.Invert(cellBT, out var cellBInv); + var cellB = new CellPhysics + { + WorldTransform = cellBT, + InverseWorldTransform = cellBInv, + Resolved = new Dictionary(), + CellBSP = SinglePlaneCellBsp(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + cache.RegisterCellStructForTest(0xA9B40101u, cellB); + + // Cell B local center is x=-0.25, radius=0.5, so the sphere + // straddles x=0 and intersects the cell volume. + var worldSphereCenter = new Vector3(4.75f, 0f, 2.5f); + + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellA, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.Contains(0xA9B40101u, candidates); + Assert.False(exitOutside); + } + + [Fact] + public void LoadedNeighbor_SphereOutsideNeighborCellBsp_DoesNotUsePortalHintFallback() + { + // With a loaded neighbour, retail trusts sphere_intersects_cell. + // This guards against adding the neighbour merely because the + // current-cell portal plane would have accepted the sphere. + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + + var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); + Matrix4x4.Invert(cellBT, out var cellBInv); + var cellB = new CellPhysics + { + WorldTransform = cellBT, + InverseWorldTransform = cellBInv, + Resolved = new Dictionary(), + CellBSP = SinglePlaneCellBsp(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + cache.RegisterCellStructForTest(0xA9B40101u, cellB); + + // Current portal-plane heuristic would add this (near x=2.5), but + // in cell B local space x=-1.95 with radius=0.5 is fully outside. + var worldSphereCenter = new Vector3(3.05f, 0f, 2.5f); + + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellA, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.DoesNotContain(0xA9B40101u, candidates); + Assert.False(exitOutside); + } + [Fact] public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside() { @@ -105,4 +191,29 @@ public class CellTransitFindTransitCellsSphereTests Assert.True(exitOutside); } + + [Fact] + public void ExitPortal_SecondSphereStraddlesPortalPlane_FlagsCheckOutside() + { + // Retail passes the whole SPHEREPATH global_sphere array into + // CEnvCell::find_transit_cells. The head sphere can be the one that + // overlaps an exit portal while the foot sphere is still clear. + var exitCell = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0); + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, exitCell); + + var spheres = new[] + { + new Sphere { Origin = new Vector3(0.0f, 0f, 2.5f), Radius = 0.5f }, + new Sphere { Origin = new Vector3(2.0f, 0f, 3.2f), Radius = 0.5f }, + }; + var candidates = new HashSet(); + + CellTransit.FindTransitCellsSphere( + cache, exitCell, currentCellId: 0xA9B40100u, + spheres, spheres.Length, candidates, out bool exitOutside); + + Assert.True(exitOutside); + } } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs index ea09735..682b31b 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs @@ -118,4 +118,27 @@ public class PhysicsDiagnosticsTests PhysicsDiagnostics.ProbePushBackEnabled = initial; } } + + // ----------------------------------------------------------------------- + // ProbeStepWalkEnabled - flag gates the [step-walk] emission path. + // A6.P3 issue #98 (2026-05-23). + // ----------------------------------------------------------------------- + + [Fact] + public void ProbeStepWalk_StaticApi_Roundtrip() + { + bool initial = PhysicsDiagnostics.ProbeStepWalkEnabled; + try + { + PhysicsDiagnostics.ProbeStepWalkEnabled = true; + Assert.True(PhysicsDiagnostics.ProbeStepWalkEnabled); + + PhysicsDiagnostics.ProbeStepWalkEnabled = false; + Assert.False(PhysicsDiagnostics.ProbeStepWalkEnabled); + } + finally + { + PhysicsDiagnostics.ProbeStepWalkEnabled = initial; + } + } } diff --git a/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs index fe69e15..644d5f0 100644 --- a/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs @@ -139,4 +139,33 @@ public class TransitionCheckOtherCellsTests Assert.Equal(TransitionState.OK, result); } + + [Fact] + public void CheckOtherCells_OutdoorLandcellCandidate_UsesTerrainWalkable() + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + var heights = new byte[81]; + Array.Fill(heights, (byte)0); + var ht = new float[256]; + for (int i = 0; i < 256; i++) ht[i] = i * 1.0f; + engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, ht), + Array.Empty(), Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + var t = MakeTransition(contactFlag: true); + t.SpherePath.InitPath(new Vector3(10f, 10f, -0.28f), new Vector3(10f, 10f, -0.28f), + cellId: 0xA9B40147u, sphereRadius: 0.48f); + + var footCenter = new Vector3(10f, 10f, 0.20f); + var cellSet = new HashSet { 0xA9B40001u }; + + var result = t.CheckOtherCells(engine, footCenter, 0.48f, cellSet); + + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(t.CollisionInfo.ContactPlaneValid); + Assert.Equal(0xA9B40001u, t.CollisionInfo.ContactPlaneCellId); + Assert.True(t.SpherePath.CheckPos.Z > -0.28f); + } } diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index ee123ae..e1fd8c9 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -54,6 +54,35 @@ public class LandblockMeshTests Assert.Equal(128 * 3, mesh.Indices.Length); } + [Fact] + public void Build_HiddenTerrainCell_PreservesCountsAndDegeneratesOnlyThatCell() + { + var block = BuildFlatLandBlock(); + var cache = new Dictionary(); + int hiddenCell = (3 * LandblockMesh.CellsPerSide) + 5; + + var mesh = LandblockMesh.Build( + block, + 0, + 0, + IdentityHeightTable, + MakeContext(), + cache, + new HashSet { hiddenCell }); + + Assert.Equal(LandblockMesh.VerticesPerLandblock, mesh.Vertices.Length); + Assert.Equal(LandblockMesh.VerticesPerLandblock, mesh.Indices.Length); + + int hiddenBase = hiddenCell * LandblockMesh.VerticesPerCell; + for (int i = 0; i < LandblockMesh.VerticesPerCell; i++) + Assert.Equal((uint)hiddenBase, mesh.Indices[hiddenBase + i]); + + int visibleCell = hiddenCell + 1; + int visibleBase = visibleCell * LandblockMesh.VerticesPerCell; + for (int i = 0; i < LandblockMesh.VerticesPerCell; i++) + Assert.Equal((uint)(visibleBase + i), mesh.Indices[visibleBase + i]); + } + [Fact] public void Build_Vertices_CoverExactly192x192WorldUnits() { diff --git a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs index d1d24b8..05a3aac 100644 --- a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs @@ -117,6 +117,35 @@ public class LandblockLoaderTests Assert.Empty(entities); } + [Fact] + public void BuildBuildingTerrainCells_UsesBuildingsOnlyAndMapsToMeshCellIndex() + { + var info = new LandBlockInfo + { + Objects = + { + new Stab + { + Id = 0x02000001u, + Frame = new Frame { Origin = new Vector3(120, 72, 0) }, + }, + }, + Buildings = + { + new BuildingInfo + { + ModelId = 0x020000AAu, + Frame = new Frame { Origin = new Vector3(141.5f, 7.2f, 94f) }, + }, + }, + }; + + var cells = LandblockLoader.BuildBuildingTerrainCells(info); + + Assert.Single(cells); + Assert.Contains(5, cells); // cy=0, cx=5 => mesh index cy * 8 + cx. + } + [Fact] public void BuildEntitiesFromInfo_WithLandblockId_NamespacesIdsForGlobalUniqueness() {