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:
parent
111aa3e59d
commit
35b37dfb5f
16 changed files with 930 additions and 58 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,11 @@ public sealed class GpuWorldState
|
|||
var merged = new List<WorldEntity>(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<WorldEntity>(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<WorldEntity>(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<WorldEntity>());
|
||||
_loaded[canonical] = new LoadedLandblock(
|
||||
lb.LandblockId,
|
||||
lb.Heightmap,
|
||||
System.Array.Empty<WorldEntity>(),
|
||||
lb.BuildingTerrainCells);
|
||||
_pendingByLandblock.Remove(canonical);
|
||||
RebuildFlatView();
|
||||
}
|
||||
|
|
@ -484,7 +500,7 @@ public sealed class GpuWorldState
|
|||
var merged = new List<WorldEntity>(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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -231,7 +231,8 @@ public sealed class LandblockStreamer : IDisposable
|
|||
lb = new LoadedLandblock(
|
||||
lb.LandblockId,
|
||||
lb.Heightmap,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>());
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
lb.BuildingTerrainCells);
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||
load.LandblockId, tier, lb, mesh));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
|
||||
/// 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>
|
||||
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
|
||||
|
|
@ -50,40 +51,109 @@ public static class CellTransit
|
|||
float sphereRadius,
|
||||
HashSet<uint> candidates,
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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 _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -267,9 +353,33 @@ public static class CellTransit
|
|||
float sphereRadius,
|
||||
uint currentCellId,
|
||||
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(
|
||||
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<Sphere> worldSpheres,
|
||||
int numSpheres,
|
||||
uint currentCellId,
|
||||
out HashSet<uint> candidates)
|
||||
{
|
||||
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;
|
||||
|
||||
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<Sphere> worldSpheres, int numSpheres)
|
||||
{
|
||||
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
|
||||
return numSpheres < worldSpheres.Count ? numSpheres : worldSpheres.Count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -378,6 +378,23 @@ public static class PhysicsDiagnostics
|
|||
public static bool ProbePlacementFailEnabled { get; set; } =
|
||||
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>
|
||||
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
|
||||
/// at the leaf where it returns true. Either
|
||||
|
|
@ -549,6 +566,82 @@ public static class PhysicsDiagnostics
|
|||
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)
|
||||
{
|
||||
var caller = GetCpCallerName();
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
/// Phase A4 (2026-05-20). Combine helper for
|
||||
/// <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
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,13 +40,15 @@ public static class LandblockMesh
|
|||
/// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param>
|
||||
/// <param name="ctx">TerrainAtlas-derived blending inputs.</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(
|
||||
LandBlock block,
|
||||
uint landblockX,
|
||||
uint landblockY,
|
||||
float[] heightTable,
|
||||
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(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,30 @@ public static class LandblockLoader
|
|||
var entities = info is null
|
||||
? Array.Empty<WorldEntity>()
|
||||
: 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>
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ namespace AcDream.Core.World;
|
|||
public sealed record LoadedLandblock(
|
||||
uint LandblockId,
|
||||
LandBlock Heightmap,
|
||||
IReadOnlyList<WorldEntity> Entities);
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlySet<int>? BuildingTerrainCells = null);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
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<uint>();
|
||||
|
||||
CellTransit.FindTransitCellsSphere(
|
||||
cache, exitCell, currentCellId: 0xA9B40100u,
|
||||
spheres, spheres.Length, candidates, out bool exitOutside);
|
||||
|
||||
Assert.True(exitOutside);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
public void Build_Vertices_CoverExactly192x192WorldUnits()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue