chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments

Triage step from the plan at C:\Users\erikn\.claude\plans\
i-did-some-work-sharded-acorn.md. Four sessions on issue #98 left the
worktree dirty with ~1352 LOC of mixed work. This commit splits the
work into "keep" (defensible + diagnostic) and "drop" (failed
experiments), then commits the keep set with the drops removed.

Plan asked for three commits (diag / fix / revert); consolidated to one
because the diagnostic emits in TransitionTypes.cs are tightly
interleaved with the multi-sphere CellTransit calls and the CellId
switch. Hunk-level splitting in those files for marginal bisect
granularity didn't justify the misclick risk.

Reverted entirely (failed experiments per slice 7 handoff):
- src/AcDream.Core/Physics/PhysicsDataCache.cs — neg-poly storage
  fields (Stippling, PosSurface, NegSurface, HasNegativeSide,
  IsNegativeSide, NegativeSide).
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs — isBuilding flag
  propagation through Register / ShadowEntry.
- tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs — 165 lines of
  PolygonWithNegativeSide_* tests.
- tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs —
  isBuilding propagation tests.
- src/AcDream.Core/World/WorldEntity.cs — IsLandblockBuilding field
  (no consumer once ShadowObjectRegistry.isBuilding is gone).
- src/AcDream.Core/World/LandblockLoader.cs — IsLandblockBuilding=true
  setter on building entities (kept BuildBuildingTerrainCells).
- src/AcDream.App/Rendering/GameWindow.cs — isBuilding: arg passed to
  ShadowObjects.Register.
- src/AcDream.Core/Physics/BSPQuery.cs — TryAdjustWalkableSide /
  IsWalkableAt helpers, their callers, the Path 5 / Path 6 neg-poly
  branch split, the BldgCheck-tied clearCell conditional, and the
  neg-poly ResolveCellPolygons writes.
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — neg-poly fields
  in the poly-dump format.
- src/AcDream.Core/Physics/TransitionTypes.cs — SpherePath.BldgCheck +
  SpherePath.HitsInteriorCell fields and every consumer, the
  savedBldgCheck try/finally around FindCollisions, and the neg-poly
  format additions to the dump-on-error helper.
- src/AcDream.Core/Physics/CellTransit.cs — FindCellSet overloads
  with hitsInteriorCell out-param and the BuildCellSetAndPickContaining
  out-param threading.

Kept (defensible correctness fixes + diagnostic infrastructure):
- src/AcDream.App/Rendering/GameWindow.cs — render-vs-physics cell
  origin split: the 0.02m render lift no longer leaks into physics
  BSP caching. lb.BuildingTerrainCells threaded into LandblockMesh.Build.
- src/AcDream.Core/World/LoadedLandblock.cs — BuildingTerrainCells
  record field.
- src/AcDream.Core/World/LandblockLoader.cs — BuildBuildingTerrainCells
  (cy*8+cx from LandBlockInfo.Buildings).
- src/AcDream.Core/Terrain/LandblockMesh.cs — hiddenTerrainCells
  param that collapses owned-cell triangles to a zero-area degenerate.
- src/AcDream.App/Streaming/{GpuWorldState,LandblockStreamer}.cs —
  mechanical BuildingTerrainCells threading through LoadedLandblock
  reconstructions.
- src/AcDream.Core/Physics/CellTransit.cs — multi-sphere
  FindTransitCellsSphere variant + multi-sphere AddAllOutsideCells +
  FindCellSet(IReadOnlyList<Sphere>, …) overload + the
  BSPQuery.SphereIntersectsCellBsp call for loaded neighbours. Matches
  retail CObjCell::find_cell_list / CEnvCell::find_transit_cells.
- src/AcDream.Core/Physics/TransitionTypes.cs — multi-sphere FindCellSet
  call site, retail-faithful CellId switch after CheckOtherCells, the
  outdoor-landcell terrain-walkable fallback in CheckOtherCells, and
  the full diagnostic suite ([step-walk], [walkable-nearest],
  [issue98-walkable-detail], [cell-set-summary], LastBspHitPoly
  emits).
- src/AcDream.Core/Physics/PhysicsDiagnostics.cs — ProbeStepWalkEnabled
  gate (ACDREAM_PROBE_STEP_WALK=1) + LogStepWalk helper + FormatVector
  / FormatPlane utilities. All emit-gated.
- src/AcDream.Core/Physics/BSPQuery.cs — diagnostic emits to
  LastBspHitPoly at four sites in SphereIntersectsPolyInternal /
  the placement adjustment path.
- Test files for the kept work: CellTransitFindCellSetTests,
  CellTransitFindTransitCellsSphereTests, PhysicsDiagnosticsTests,
  TransitionCheckOtherCellsTests, LandblockMeshTests,
  LandblockLoaderTests.

Verification:
- dotnet build: green, 0 errors, 3 pre-existing warnings.
- dotnet test: 1156 passed + 8 failed (baseline was 1148 + 8 pre-
  existing; the +8 passing are the new tests for the kept defensible
  work). Same 8 pre-existing failures, no new regressions.

Backup of pre-triage worktree state in stash@{0}.

A6.P3 #98 is still open; this is the apparatus-prep step, not a fix.
Next: cell-dump probe (Step 2 of the plan).
This commit is contained in:
Erik 2026-05-23 15:11:49 +02:00
parent 111aa3e59d
commit 35b37dfb5f
16 changed files with 930 additions and 58 deletions

View file

@ -1806,7 +1806,7 @@ public sealed class GameWindow : IDisposable
// _heightTable and _blendCtx are read-only after initialization. // _heightTable and _blendCtx are read-only after initialization.
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here. // lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
return AcDream.Core.Terrain.LandblockMesh.Build( return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache, lb.BuildingTerrainCells);
}); });
_streamer.Start(); _streamer.Start();
@ -5112,7 +5112,8 @@ public sealed class GameWindow : IDisposable
return new AcDream.Core.World.LoadedLandblock( return new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId, baseLoaded.LandblockId,
baseLoaded.Heightmap, baseLoaded.Heightmap,
merged); merged,
baseLoaded.BuildingTerrainCells);
} }
/// <summary> /// <summary>
@ -5360,11 +5361,16 @@ public sealed class GameWindow : IDisposable
{ {
_pendingCellMeshes[envCellId] = cellSubMeshes; _pendingCellMeshes[envCellId] = cellSubMeshes;
var cellOrigin = envCell.Position.Origin + lbOffset // Keep the small render lift out of physics; retail BSP
+ new System.Numerics.Vector3(0f, 0f, 0.02f); // 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 = var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); 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); var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
@ -5383,7 +5389,7 @@ public sealed class GameWindow : IDisposable
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
// Cache CellStruct physics BSP for indoor collision. // 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 lbX = (id >> 24) & 0xFFu;
uint lbY = (id >> 16) & 0xFFu; uint lbY = (id >> 16) & 0xFFu;
return AcDream.Core.Terrain.LandblockMesh.Build( return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache); lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache, lb.BuildingTerrainCells);
}); });
_streamer.Start(); _streamer.Start();

View file

@ -173,7 +173,11 @@ public sealed class GpuWorldState
var merged = new List<WorldEntity>(landblock.Entities.Count + pending.Count); var merged = new List<WorldEntity>(landblock.Entities.Count + pending.Count);
merged.AddRange(landblock.Entities); merged.AddRange(landblock.Entities);
merged.AddRange(pending); 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); _pendingByLandblock.Remove(landblock.LandblockId);
} }
@ -232,7 +236,11 @@ public sealed class GpuWorldState
var newList = new List<WorldEntity>(entities.Count - 1); var newList = new List<WorldEntity>(entities.Count - 1);
for (int j = 0; j < entities.Count; j++) for (int j = 0; j < entities.Count; j++)
if (j != i) newList.Add(entities[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) // Add to new (via AppendLiveEntity which handles pending)
AppendLiveEntity(newCanonicalLb, entity); AppendLiveEntity(newCanonicalLb, entity);
@ -333,7 +341,7 @@ public sealed class GpuWorldState
foreach (var e in lb.Entities) foreach (var e in lb.Entities)
if (e.ServerGuid != serverGuid) newList.Add(e); 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; rebuiltLoaded = true;
} }
@ -387,7 +395,11 @@ public sealed class GpuWorldState
var newEntities = new List<WorldEntity>(lb.Entities.Count + 1); var newEntities = new List<WorldEntity>(lb.Entities.Count + 1);
newEntities.AddRange(lb.Entities); newEntities.AddRange(lb.Entities);
newEntities.Add(entity); newEntities.Add(entity);
_loaded[canonicalLandblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities); _loaded[canonicalLandblockId] = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
newEntities,
lb.BuildingTerrainCells);
RebuildFlatView(); RebuildFlatView();
return; return;
} }
@ -448,7 +460,11 @@ public sealed class GpuWorldState
} }
} }
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>()); _loaded[canonical] = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<WorldEntity>(),
lb.BuildingTerrainCells);
_pendingByLandblock.Remove(canonical); _pendingByLandblock.Remove(canonical);
RebuildFlatView(); RebuildFlatView();
} }
@ -484,7 +500,7 @@ public sealed class GpuWorldState
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count); var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities); merged.AddRange(lb.Entities);
merged.AddRange(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) if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);

View file

@ -231,7 +231,8 @@ public sealed class LandblockStreamer : IDisposable
lb = new LoadedLandblock( lb = new LoadedLandblock(
lb.LandblockId, lb.LandblockId,
lb.Heightmap, lb.Heightmap,
System.Array.Empty<AcDream.Core.World.WorldEntity>()); System.Array.Empty<AcDream.Core.World.WorldEntity>(),
lb.BuildingTerrainCells);
} }
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, lb, mesh)); load.LandblockId, tier, lb, mesh));

View file

@ -860,6 +860,9 @@ public static class BSPQuery
if (!resolved.TryGetValue(polyId, out var poly)) continue; if (!resolved.TryGetValue(polyId, out var poly)) continue;
if (HitsSphere(poly, sphere)) if (HitsSphere(poly, sphere))
{ {
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = poly;
if (PhysicsDiagnostics.ProbePlacementFailEnabled) if (PhysicsDiagnostics.ProbePlacementFailEnabled)
{ {
PhysicsDiagnostics.LastPlacementFailPolyId = poly.Id; PhysicsDiagnostics.LastPlacementFailPolyId = poly.Id;
@ -1231,6 +1234,9 @@ public static class BSPQuery
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = polyHit;
return TransitionState.Adjusted; return TransitionState.Adjusted;
} }
@ -1767,6 +1773,9 @@ public static class BSPQuery
collisions.SetContactPlane(worldPlane, path.CheckCellId, false); collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
return TransitionState.Adjusted; return TransitionState.Adjusted;
} }
return TransitionState.OK; return TransitionState.OK;

View file

@ -1,12 +1,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
/// <summary> /// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, /// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
/// ported from retail's <c>CObjCell::find_cell_list</c> family /// ported from retail's <c>CObjCell::find_cell_list</c> family
/// (sphere variant for the player's single foot sphere). /// (sphere variant for the player's path spheres).
/// ///
/// <para> /// <para>
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail- /// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
@ -50,40 +51,109 @@ public static class CellTransit
float sphereRadius, float sphereRadius,
HashSet<uint> candidates, HashSet<uint> candidates,
out bool exitOutside) out bool exitOutside)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
FindTransitCellsSphere(
cache, currentCell, currentCellId,
spheres, spheres.Length, candidates, out exitOutside);
}
/// <summary>
/// Multi-sphere form used by retail's <c>CObjCell::find_cell_list</c>:
/// pass <c>sphere_path.num_sphere</c> and <c>sphere_path.global_sphere</c>.
/// Any sphere can trigger a portal neighbor or outdoor exit.
/// </summary>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
HashSet<uint> candidates,
out bool exitOutside)
{ {
exitOutside = false; exitOutside = false;
if (currentCell.PortalPolygons is null) return;
uint lbPrefix = currentCellId & 0xFFFF0000u; uint lbPrefix = currentCellId & 0xFFFF0000u;
float rad = sphereRadius + EPSILON; int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
// Cell-local sphere center. if (currentCell.PortalPolygons is null || sphereCount == 0) return;
var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform);
foreach (var portal in currentCell.Portals) foreach (var portal in currentCell.Portals)
{ {
if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly)) if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
continue; 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) if (portal.OtherCellId == 0xFFFF)
{ {
// Exit portal. Sphere must straddle the plane. // Exit portal. Any path sphere straddling the plane triggers
if (dist > -rad && dist < rad) // the outdoor cell expansion.
exitOutside = true; 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; continue;
} }
uint otherId = lbPrefix | portal.OtherCellId; uint otherId = lbPrefix | portal.OtherCellId;
// Conservative add: the sphere is near the portal plane and on the // Retail CEnvCell::find_transit_cells first asks the loaded
// outward side (per PortalSide). This is the load-hint branch from // neighbour cell whether the sphere intersects its CellBSP.
// the research doc. A more retail-faithful path would call // The portal-plane side test is only the unloaded-cell load hint.
// CellBSP.sphere_intersects_cell on the neighbour — deferred. var otherCell = cache.GetCellStruct(otherId);
if (portal.PortalSide ? dist > -rad : dist < rad) if (otherCell?.CellBSP?.Root is not null)
candidates.Add(otherId); {
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); if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
} }
/// <summary>
/// Multi-sphere outdoor expansion. Retail's sphere variant loops every
/// path sphere and adds the outdoor landcells touched by any of them.
/// </summary>
public static void AddAllOutsideCells(
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
HashSet<uint> 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<uint> candidates, uint lbPrefix, int gridX, int gridY) private static void AddOutsideCell(HashSet<uint> candidates, uint lbPrefix, int gridX, int gridY)
{ {
if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return; if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
@ -242,9 +330,7 @@ public static class CellTransit
float sphereRadius, float sphereRadius,
uint currentCellId) uint currentCellId)
{ {
return BuildCellSetAndPickContaining( return FindCellSet(cache, worldSphereCenter, sphereRadius, currentCellId, out _);
cache, worldSphereCenter, sphereRadius, currentCellId,
out _);
} }
/// <summary> /// <summary>
@ -267,9 +353,33 @@ public static class CellTransit
float sphereRadius, float sphereRadius,
uint currentCellId, uint currentCellId,
out IReadOnlyCollection<uint> cellSet) out IReadOnlyCollection<uint> cellSet)
{
var spheres = new[]
{
new Sphere
{
Origin = worldSphereCenter,
Radius = sphereRadius,
},
};
return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet);
}
/// <summary>
/// Multi-sphere form of <see cref="FindCellSet(PhysicsDataCache, Vector3, float, uint, out IReadOnlyCollection{uint})"/>.
/// Containment still uses sphere 0's center, matching retail's
/// <c>CObjCell::find_cell_list</c> loop after the transit set is built.
/// </summary>
public static uint FindCellSet(
PhysicsDataCache cache,
IReadOnlyList<Sphere> worldSpheres,
int numSpheres,
uint currentCellId,
out IReadOnlyCollection<uint> cellSet)
{ {
var containing = BuildCellSetAndPickContaining( var containing = BuildCellSetAndPickContaining(
cache, worldSphereCenter, sphereRadius, currentCellId, cache, worldSpheres, numSpheres, currentCellId,
out var candidates); out var candidates);
cellSet = candidates; cellSet = candidates;
return containing; return containing;
@ -277,12 +387,17 @@ public static class CellTransit
private static uint BuildCellSetAndPickContaining( private static uint BuildCellSetAndPickContaining(
PhysicsDataCache cache, PhysicsDataCache cache,
Vector3 worldSphereCenter, IReadOnlyList<Sphere> worldSpheres,
float sphereRadius, int numSpheres,
uint currentCellId, uint currentCellId,
out HashSet<uint> candidates) out HashSet<uint> candidates)
{ {
candidates = new HashSet<uint>(); candidates = new HashSet<uint>();
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return currentCellId;
Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu; uint currentLow = currentCellId & 0xFFFFu;
if (currentLow >= 0x0100u) if (currentLow >= 0x0100u)
@ -307,7 +422,7 @@ public static class CellTransit
var sizeBefore = candidates.Count; var sizeBefore = candidates.Count;
FindTransitCellsSphere( FindTransitCellsSphere(
cache, cell, cellId, worldSphereCenter, sphereRadius, cache, cell, cellId, worldSpheres, sphereCount,
candidates, out bool exitOutside); candidates, out bool exitOutside);
if (candidates.Count > sizeBefore) if (candidates.Count > sizeBefore)
@ -322,7 +437,7 @@ public static class CellTransit
if (exitOutside) if (exitOutside)
{ {
// Add neighbour outdoor cells too. // 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 // Outdoor seed: expand neighbour landcells AND check for building stabs
// with portals into interior EnvCells. // 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, // 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 // 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); var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
{
return candId; return candId;
}
} }
// No cell contained the sphere center. Stay in the input cell. // No cell contained the sphere center. Stay in the input cell.
return currentCellId; return currentCellId;
} }
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
}
} }

View file

@ -378,6 +378,23 @@ public static class PhysicsDiagnostics
public static bool ProbePlacementFailEnabled { get; set; } = public static bool ProbePlacementFailEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLACEMENT_FAIL") == "1"; Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLACEMENT_FAIL") == "1";
/// <summary>
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
/// emits one <c>[step-walk]</c> 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.
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_STEP_WALK=1</c>. One-shot
/// diagnostic; no DebugPanel mirror until the root cause is identified.
/// </para>
/// </summary>
public static bool ProbeStepWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1";
/// <summary> /// <summary>
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c> /// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
/// at the leaf where it returns true. Either /// at the leaf where it returns true. Either
@ -549,6 +566,82 @@ public static class PhysicsDiagnostics
primaryCellId, otherCellId, bspResult, halted)); primaryCellId, otherCellId, bspResult, halted));
} }
/// <summary>
/// Emit one <c>[step-walk]</c> line for issue #98's cellar-ramp
/// investigation. Caller MUST guard with
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
/// </summary>
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) public static void LogCpBoolWrite(string field, bool oldValue, bool newValue)
{ {
var caller = GetCpCallerName(); var caller = GetCpCallerName();

View file

@ -668,18 +668,48 @@ public sealed class Transition
// Main stepping loop // Main stepping loop
// ------------------------------------------------------------------ // ------------------------------------------------------------------
var transitionState = TransitionState.OK; 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++) for (int i = 0; i < numSteps; i++)
{ {
Vector3 requestedOffset = offsetPerStep;
// Per ACE order: AdjustOffset FIRST (uses state from previous step), // Per ACE order: AdjustOffset FIRST (uses state from previous step),
// THEN clear the state. This lets the sliding/contact normals from // THEN clear the state. This lets the sliding/contact normals from
// the previous step's collision project the current step's offset. // 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 // Abort if adjusted offset is negligible (stuck against a wall
// with no slide tangent available). // with no slide tangent available).
if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq) 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; return i != 0 && transitionState == TransitionState.OK;
}
// Interpolate orientation (non-free-rotate path). // Interpolate orientation (non-free-rotate path).
if (!ObjectInfo.FreeRotate) if (!ObjectInfo.FreeRotate)
@ -697,14 +727,47 @@ public sealed class Transition
// Apply the offset, then check collisions. // Apply the offset, then check collisions.
sp.AddOffsetToCheckPos(sp.GlobalOffset); sp.AddOffsetToCheckPos(sp.GlobalOffset);
if (stepWalkProbe)
{
PhysicsDiagnostics.LogStepWalk(
"before-insert", i, numSteps, sp, CollisionInfo, ObjectInfo,
requestedOffset, sp.GlobalOffset,
transitionState);
}
var result = TransitionalInsert(3, engine); var result = TransitionalInsert(3, engine);
if (stepWalkProbe)
{
PhysicsDiagnostics.LogStepWalk(
"after-insert", i, numSteps, sp, CollisionInfo, ObjectInfo,
requestedOffset, sp.GlobalOffset,
result);
}
transitionState = ValidateTransition(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. // PathClipped objects stop at the first collision.
if (CollisionInfo.CollisionNormalValid && ObjectInfo.PathClipped) if (CollisionInfo.CollisionNormalValid && ObjectInfo.PathClipped)
break; break;
} }
if (stepWalkProbe)
{
PhysicsDiagnostics.LogStepWalk(
"find-end", -1, numSteps, sp, CollisionInfo, ObjectInfo,
Vector3.Zero, Vector3.Zero,
transitionState);
}
return transitionState == TransitionState.OK; return transitionState == TransitionState.OK;
} }
@ -1450,6 +1513,43 @@ public sealed class Transition
{ {
if (cellId == sp.CheckCellId) continue; 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); var cell = engine.DataCache.GetCellStruct(cellId);
// R2 guard: stale CellPhysics loaded for render but not physics. // R2 guard: stale CellPhysics loaded for render but not physics.
if (cell?.BSP?.Root is null) continue; if (cell?.BSP?.Root is null) continue;
@ -1488,6 +1588,9 @@ public sealed class Transition
cellOrigin = cell.WorldTransform.Translation; cellOrigin = cell.WorldTransform.Translation;
} }
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = null;
var result = BSPQuery.FindCollisions( var result = BSPQuery.FindCollisions(
cell.BSP.Root, cell.Resolved, this, cell.BSP.Root, cell.Resolved, this,
localSphere, localSphere1, localCurrCenter, localSphere, localSphere1, localCurrCenter,
@ -1496,8 +1599,23 @@ public sealed class Transition
if (PhysicsDiagnostics.ProbeIndoorBspEnabled) 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( 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) if (PhysicsDiagnostics.ProbePushBackEnabled)
@ -1516,6 +1634,163 @@ public sealed class Transition
return TransitionState.OK; return TransitionState.OK;
} }
private void LogIssue98CellSetSummary(
PhysicsEngine engine,
uint containingCellId,
System.Collections.Generic.IReadOnlyCollection<uint> 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<uint>(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<ushort, ResolvedPolygon> 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}]"));
}
/// <summary> /// <summary>
/// Phase A4 (2026-05-20). Combine helper for /// Phase A4 (2026-05-20). Combine helper for
/// <see cref="CheckOtherCells"/>. Mirrors retail's switch at /// <see cref="CheckOtherCells"/>. Mirrors retail's switch at
@ -1664,13 +1939,21 @@ public sealed class Transition
// walls bug (cell 0xA9B40164 has only 4 polys; adjacent // walls bug (cell 0xA9B40164 has only 4 polys; adjacent
// 0xA9B40157 has the actual walls) closes here. // 0xA9B40157 has the actual walls) closes here.
// //
// Discard the containing-cell return — sp.CheckCellId is uint containingCellId = CellTransit.FindCellSet(
// already authoritative for the primary cell we just queried. engine.DataCache, sp.GlobalSphere, sp.NumSphere,
_ = CellTransit.FindCellSet(engine.DataCache, footCenter, sphereRadius, sp.CheckCellId, out var cellSet);
sp.CheckCellId, out var cellSet); LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet); var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet);
if (otherCellsState != TransitionState.OK) if (otherCellsState != TransitionState.OK)
return otherCellsState; 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) ─ // ── 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. // the [resolve] probe surfaces the responsible entity id.
bool collisionWasValidPre = ci.CollisionNormalValid; bool collisionWasValidPre = ci.CollisionNormalValid;
// L.2d slice 1.5 (2026-05-13): no per-iteration LastBspHitPoly // A6.P3 issue #98 (2026-05-23): clear the diagnostic side-channel
// clear. BSPQuery writes the side-channel early (inside // per object, then rely on BSPQuery to preserve the outer grounded
// `if (hit0 || hitPoly0 != null)` BEFORE any StepSphereUp call), // hit across nested StepSphereUp work. Without the clear, stale
// so by the time we read it back for the [resolve-bldg] emission // polygons can leak between static entries; without the preserve,
// it reflects THIS entity's hit (or stays null if BSP didn't // nested step-up probes can erase the real ramp-mouth hit.
// hit). For cylinder dispatch we key the "n/a (cylinder)" label if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
// off `obj.CollisionType` directly at the emission site, so a PhysicsDiagnostics.LastBspHitPoly = null;
// stale BSP value from a prior iteration can't leak through.
TransitionState result; TransitionState result;
@ -2477,15 +2759,42 @@ public sealed class Transition
sp.StepDownAmt = stepDownHeight; sp.StepDownAmt = stepDownHeight;
sp.WalkInterp = 1.0f; 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 NOT in step-up mode, apply the downward offset.
if (!sp.StepUp) 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. // Run collision detection with the step-down flag active.
var transitState = TransitionalInsert(5, engine); 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; sp.StepDown = false;
// Accept step-down if: // Accept step-down if:
@ -2574,9 +2883,27 @@ public sealed class Transition
sp.StepUp)); 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; 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; return false;
} }

View file

@ -40,13 +40,15 @@ public static class LandblockMesh
/// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param> /// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param>
/// <param name="ctx">TerrainAtlas-derived blending inputs.</param> /// <param name="ctx">TerrainAtlas-derived blending inputs.</param>
/// <param name="surfaceCache">Shared SurfaceInfo cache keyed by palette code.</param> /// <param name="surfaceCache">Shared SurfaceInfo cache keyed by palette code.</param>
/// <param name="hiddenTerrainCells">Optional cell indices (cy * 8 + cx) to draw as zero-area triangles.</param>
public static LandblockMeshData Build( public static LandblockMeshData Build(
LandBlock block, LandBlock block,
uint landblockX, uint landblockX,
uint landblockY, uint landblockY,
float[] heightTable, float[] heightTable,
TerrainBlendingContext ctx, TerrainBlendingContext ctx,
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache) System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache,
System.Collections.Generic.IReadOnlySet<int>? hiddenTerrainCells = null)
{ {
ArgumentNullException.ThrowIfNull(block); ArgumentNullException.ThrowIfNull(block);
ArgumentNullException.ThrowIfNull(heightTable); 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++) 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; indices[i] = i;
}
return new LandblockMeshData(vertices, indices); return new LandblockMeshData(vertices, indices);
} }

View file

@ -23,8 +23,30 @@ public static class LandblockLoader
var entities = info is null var entities = info is null
? Array.Empty<WorldEntity>() ? Array.Empty<WorldEntity>()
: BuildEntitiesFromInfo(info, landblockId); : BuildEntitiesFromInfo(info, landblockId);
var buildingTerrainCells = info is null
? null
: BuildBuildingTerrainCells(info);
return new LoadedLandblock(landblockId, block, entities); return new LoadedLandblock(landblockId, block, entities, buildingTerrainCells);
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlySet<int> BuildBuildingTerrainCells(LandBlockInfo info)
{
var result = new HashSet<int>();
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;
} }
/// <summary> /// <summary>

View file

@ -5,4 +5,5 @@ namespace AcDream.Core.World;
public sealed record LoadedLandblock( public sealed record LoadedLandblock(
uint LandblockId, uint LandblockId,
LandBlock Heightmap, LandBlock Heightmap,
IReadOnlyList<WorldEntity> Entities); IReadOnlyList<WorldEntity> Entities,
IReadOnlySet<int>? BuildingTerrainCells = null);

View file

@ -134,4 +134,43 @@ public class CellTransitFindCellSetTests
Assert.Equal(0xA9B40001u, containing); Assert.Equal(0xA9B40001u, containing);
Assert.True(cellSet.Count >= 2, $"Expected ≥2 cells in set (primary + east neighbour), got {cellSet.Count}"); 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);
}
} }

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using DatReaderWriter.Types;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using Xunit; using Xunit;
@ -7,6 +8,20 @@ namespace AcDream.Core.Tests.Physics;
public class CellTransitFindTransitCellsSphereTests 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( private static CellPhysics MakeCellWithPortalAtRightWall(
Matrix4x4 worldTransform, uint otherCellId, ushort flags) Matrix4x4 worldTransform, uint otherCellId, ushort flags)
{ {
@ -88,6 +103,77 @@ public class CellTransitFindTransitCellsSphereTests
Assert.DoesNotContain(0xA9B40101u, candidates); 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<ushort, ResolvedPolygon>(),
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<uint>();
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<ushort, ResolvedPolygon>(),
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<uint>();
CellTransit.FindTransitCellsSphere(
cache, cellA, currentCellId: 0xA9B40100u,
worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside);
Assert.DoesNotContain(0xA9B40101u, candidates);
Assert.False(exitOutside);
}
[Fact] [Fact]
public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside() public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside()
{ {
@ -105,4 +191,29 @@ public class CellTransitFindTransitCellsSphereTests
Assert.True(exitOutside); 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<uint>();
CellTransit.FindTransitCellsSphere(
cache, exitCell, currentCellId: 0xA9B40100u,
spheres, spheres.Length, candidates, out bool exitOutside);
Assert.True(exitOutside);
}
} }

View file

@ -118,4 +118,27 @@ public class PhysicsDiagnosticsTests
PhysicsDiagnostics.ProbePushBackEnabled = initial; 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;
}
}
} }

View file

@ -139,4 +139,33 @@ public class TransitionCheckOtherCellsTests
Assert.Equal(TransitionState.OK, result); 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<CellSurface>(), Array.Empty<PortalPlane>(),
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<uint> { 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);
}
} }

View file

@ -54,6 +54,35 @@ public class LandblockMeshTests
Assert.Equal(128 * 3, mesh.Indices.Length); Assert.Equal(128 * 3, mesh.Indices.Length);
} }
[Fact]
public void Build_HiddenTerrainCell_PreservesCountsAndDegeneratesOnlyThatCell()
{
var block = BuildFlatLandBlock();
var cache = new Dictionary<uint, SurfaceInfo>();
int hiddenCell = (3 * LandblockMesh.CellsPerSide) + 5;
var mesh = LandblockMesh.Build(
block,
0,
0,
IdentityHeightTable,
MakeContext(),
cache,
new HashSet<int> { 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] [Fact]
public void Build_Vertices_CoverExactly192x192WorldUnits() public void Build_Vertices_CoverExactly192x192WorldUnits()
{ {

View file

@ -117,6 +117,35 @@ public class LandblockLoaderTests
Assert.Empty(entities); 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] [Fact]
public void BuildEntitiesFromInfo_WithLandblockId_NamespacesIdsForGlobalUniqueness() public void BuildEntitiesFromInfo_WithLandblockId_NamespacesIdsForGlobalUniqueness()
{ {