Merge branch 'main' into claude/eloquent-hugle-42119e
# Conflicts: # .gitignore
This commit is contained in:
commit
093cdb6d57
38 changed files with 5852 additions and 216 deletions
|
|
@ -122,7 +122,9 @@ public class PlayerMoveToCutoverTests
|
|||
// the A9 pending_motions node (a null sink orphans it and the
|
||||
// MoveToManager wait-for-anims gate never opens — the live stall).
|
||||
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
||||
controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations;
|
||||
// #174: production wiring — HandleEnterWorld (strip + drain), not
|
||||
// the bare sequence strip (which orphaned pending manager nodes).
|
||||
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
|
||||
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
|
||||
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,9 @@ public class W6EdgeDrivenMovementTests
|
|||
// dispatch, else its A9 pending_motions node is orphaned).
|
||||
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
||||
var s = seq;
|
||||
controller.Motion.RemoveLinkAnimations = s.RemoveAllLinkAnimations;
|
||||
// #174: production wiring — HandleEnterWorld (strip + drain), not
|
||||
// the bare sequence strip (which orphaned pending manager nodes).
|
||||
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
|
||||
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
|
||||
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
|
|||
|
||||
public sealed class LightManagerTests
|
||||
{
|
||||
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true)
|
||||
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
|
||||
=> new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
|
|
@ -14,6 +14,7 @@ public sealed class LightManagerTests
|
|||
Range = range,
|
||||
IsLit = lit,
|
||||
OwnerId = ownerId,
|
||||
CellId = cellId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
|
@ -176,6 +177,55 @@ public sealed class LightManagerTests
|
|||
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||
}
|
||||
|
||||
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
|
||||
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
|
||||
// visible cells (plus cell-less globals), not a flat world-space set.
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
|
||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
|
||||
|
||||
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||
|
||||
// Only the visible cell's light survives — the under-room light can't wash
|
||||
// through the floor (retail: its cell isn't in visible_cell_table).
|
||||
Assert.Single(mgr.PointSnapshot);
|
||||
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
|
||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
|
||||
|
||||
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||
|
||||
// The cell-less light (viewer fill) is always a candidate; the non-visible
|
||||
// cell's light is dropped.
|
||||
Assert.Single(mgr.PointSnapshot);
|
||||
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
|
||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
|
||||
|
||||
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
|
||||
|
||||
Assert.Equal(2, mgr.PointSnapshot.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
||||
{
|
||||
|
|
@ -256,4 +306,69 @@ public sealed class LightManagerTests
|
|||
Assert.Equal(na, nb);
|
||||
Assert.Equal(a[0], b[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
|
||||
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
|
||||
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
|
||||
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
|
||||
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
|
||||
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
|
||||
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
|
||||
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
|
||||
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
|
||||
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
|
||||
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
|
||||
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
|
||||
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
|
||||
// visible from the target room). These would have filled every low
|
||||
// camera-distance rank under the old camera-nearest cap.
|
||||
const uint underRoom = 0xAAAA0102u;
|
||||
for (int i = 0; i < 400; i++)
|
||||
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
|
||||
|
||||
// The target torch: far from the origin-side camera, in the VISIBLE room
|
||||
// cell, squarely in range of the target object around (200, 0, 0).
|
||||
const uint targetRoom = 0xAAAA0101u;
|
||||
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
|
||||
mgr.Register(torch);
|
||||
|
||||
// The portal flood says only the target room is visible.
|
||||
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
|
||||
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
||||
|
||||
// Camera parked at the origin end — the torch must still light the visible cell.
|
||||
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
|
||||
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
||||
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
|
||||
int underRoomInPool = 0;
|
||||
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
|
||||
|
||||
// Camera next to the cell — the reference behaviour.
|
||||
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
|
||||
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
|
||||
|
||||
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
|
||||
Assert.True(torchSelectedFar,
|
||||
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
|
||||
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
|
||||
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
|
||||
|
||||
static bool SelectedContains(
|
||||
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
||||
Span<int> indices, int count, LightSource target)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -699,8 +699,13 @@ public class BSPQueryTests
|
|||
// Regression guard for the FULL-HIT case in the same Path 5 branch.
|
||||
// Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT
|
||||
// reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)`
|
||||
// branch. With engine=null we fall through to the slide fallback
|
||||
// (SetCollisionNormal + SetSlidingNormal + return Slid).
|
||||
// branch. With engine=null we fall through to the real slide
|
||||
// (CSphere::slide_sphere via Transition.SlideSphereInternal). No
|
||||
// contact plane is seeded on this bare Transition, so the slide takes
|
||||
// the wall-only branch (project out the into-wall displacement,
|
||||
// return Slid) — and per retail it must NOT write the sliding normal
|
||||
// (#137 mechanism 2; validate_transition 0x0050ac21 is the only
|
||||
// in-transition writer).
|
||||
var (root, resolved) = BuildSingleWallBsp();
|
||||
|
||||
var transition = new Transition();
|
||||
|
|
@ -731,6 +736,9 @@ public class BSPQueryTests
|
|||
Assert.Equal(TransitionState.Slid, state);
|
||||
Assert.True(transition.CollisionInfo.CollisionNormalValid,
|
||||
"Full hit should set the collision normal (slide fallback).");
|
||||
Assert.False(transition.CollisionInfo.SlidingNormalValid,
|
||||
"find_collisions must not write the sliding normal — retail's " +
|
||||
"only in-transition writer is validate_transition (#137).");
|
||||
Assert.False(transition.SpherePath.NegPolyHit,
|
||||
"Full hit should NOT also fire NegPolyHit — that's the near-miss " +
|
||||
"path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " +
|
||||
|
|
|
|||
287
tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Normal file
287
tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for the retail <c>CCylSphere</c> collision family port
|
||||
/// (2026-07-05) — dispatcher <c>0x0053b440</c> + <c>step_sphere_down</c>
|
||||
/// <c>0x0053a9b0</c> + <c>step_sphere_up</c> <c>0x0053b310</c> +
|
||||
/// <c>land_on_cylinder</c> <c>0x0053b3d0</c>. Pseudocode:
|
||||
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
|
||||
///
|
||||
/// <para>
|
||||
/// The driving repro: the Holtburg town-network portal platform (stab
|
||||
/// 0xC0A9B465, Setup 0x020019E3) registers a WIDE LOW cylinder
|
||||
/// (r=2.597 m, h=0.256 m). Retail steps a grounded player UP ONTO its flat
|
||||
/// top; the pre-port approximation could only radial-slide, so the player
|
||||
/// orbited the rim forever (launch-137-repro.log, 2026-07-05). These tests
|
||||
/// pin the three retail behaviors the family provides: grounded
|
||||
/// step-up-onto-top, too-tall side slide, and the airborne top landing.
|
||||
/// Synthetic cylinders only — no dat dependency.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CylSphereFamilyTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public CylSphereFamilyTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint TestLandblockId = 0xA9B40000u;
|
||||
private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0)
|
||||
|
||||
private const float SphereRadius = 0.48f; // retail player capsule radius
|
||||
private const float SphereHeight = 1.20f;
|
||||
private const float StepUpHeight = 0.60f;
|
||||
private const float StepDownHeight = 0.04f;
|
||||
|
||||
// The live platform's registered shape ([cyl-test] launch-137-repro.log).
|
||||
private const float PlatformRadius = 2.597f;
|
||||
private const float PlatformHeight = 0.256f;
|
||||
|
||||
/// <summary>
|
||||
/// The portal-platform repro: a grounded player walking into the wide low
|
||||
/// cylinder must STEP UP onto its flat top (retail
|
||||
/// grounded branch → step_sphere_up → CTransition::step_up, whose
|
||||
/// step-down probe lands via step_sphere_down's top-disc contact plane) —
|
||||
/// not slide around the rim.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Grounded_WalkIntoWideLowCylinder_StepsUpOntoTop()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCylinder(engine, entityId: 0xCAFEu,
|
||||
worldPos: new Vector3(12f, 14f, 0f),
|
||||
radius: PlatformRadius, height: PlatformHeight);
|
||||
|
||||
var body = MakeGroundedBody(new Vector3(12f, 10.4f, 0f));
|
||||
Vector3 pos = body.Position;
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = true;
|
||||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||||
|
||||
for (int tick = 0; tick < 40; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
body.Position = result.Position;
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||||
|
||||
// Rim contact is at Y ≈ 14 − 2.597 − 0.48 = 10.92. Pre-port the player
|
||||
// pinned there (Z stayed 0, Y never passed the rim). Post-port the
|
||||
// player must be standing ON the platform top.
|
||||
Assert.True(pos.Y > 11.5f,
|
||||
$"Player must advance past the rim contact (pre-port it pinned at Y≈10.9); got Y={pos.Y:F3}");
|
||||
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
|
||||
$"Player must stand ON the platform top (Z≈{PlatformHeight:F3}); got Z={pos.Z:F3}");
|
||||
Assert.True(grounded, "Player must remain grounded after stepping onto the platform");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tall thin cylinder (the Holtburg torch shape, r=0.2 h=2.2 — #149)
|
||||
/// exceeds step_up_height: the grounded dead-center approach must NOT
|
||||
/// step up and must NOT pass through — retail slides (dead-center the
|
||||
/// crease projection degenerates to a hard stop).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Grounded_WalkIntoTallCylinder_BlocksBeforeAxis()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCylinder(engine, entityId: 0xF00Du,
|
||||
worldPos: new Vector3(12f, 14f, 0f),
|
||||
radius: 0.2f, height: 2.2f);
|
||||
|
||||
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
|
||||
Vector3 pos = body.Position;
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = true;
|
||||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||||
|
||||
for (int tick = 0; tick < 30; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
body.Position = result.Position;
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||||
|
||||
// Surface contact at Y = 14 − 0.2 − 0.48 = 13.32.
|
||||
Assert.True(pos.Y < 13.4f,
|
||||
$"Tall cylinder must block the dead-center approach; got Y={pos.Y:F3}");
|
||||
Assert.True(pos.Z < 0.5f,
|
||||
$"Player must NOT end up on top of a 2.2 m cylinder; got Z={pos.Z:F3}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Airborne landing: a falling sphere over the platform center must land
|
||||
/// ON the flat top (land_on_cylinder → Collide re-test → branch-5
|
||||
/// exact-TOI rest + top-disc contact plane), not fall through to the
|
||||
/// terrain inside the footprint.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Airborne_FallOntoWideCylinder_LandsOnTop()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCylinder(engine, entityId: 0xCAFEu,
|
||||
worldPos: new Vector3(12f, 14f, 0f),
|
||||
radius: PlatformRadius, height: PlatformHeight);
|
||||
|
||||
Vector3 pos = new(12f, 14f, 1.0f); // 1 m above the base, over the center
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = false;
|
||||
var perTick = new Vector3(0f, 0f, -0.25f);
|
||||
|
||||
int landedTick = -1;
|
||||
for (int tick = 0; tick < 20; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded,
|
||||
body: null,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
|
||||
if (grounded) { landedTick = tick; break; }
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded} landedTick={landedTick}");
|
||||
|
||||
Assert.True(grounded, "Falling sphere must land (ground) on the platform top");
|
||||
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
|
||||
$"Landing must rest on the top disc (Z≈{PlatformHeight:F3}), not the terrain " +
|
||||
$"(Z=0) inside the footprint; got Z={pos.Z:F3}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ethereal cylinders stay fully passable through the caller's Layer-2
|
||||
/// override (pc:276961-276989) — branch 1 detects, the override clears.
|
||||
/// Guards the #150 door behavior against the branch-1 change from the
|
||||
/// old early-OK consume.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Grounded_EtherealCylinder_IsFullyPassable()
|
||||
{
|
||||
var engine = BuildEngine(out _);
|
||||
RegisterCylinder(engine, entityId: 0xE7E7u,
|
||||
worldPos: new Vector3(12f, 14f, 0f),
|
||||
radius: 0.2f, height: 2.2f,
|
||||
state: 0x4u); // ETHEREAL_PS, non-static
|
||||
|
||||
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
|
||||
Vector3 pos = body.Position;
|
||||
uint cellId = TestCellId;
|
||||
bool grounded = true;
|
||||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||||
|
||||
for (int tick = 0; tick < 30; tick++)
|
||||
{
|
||||
var result = engine.ResolveWithTransition(
|
||||
pos, pos + perTick, cellId,
|
||||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||||
grounded,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0);
|
||||
|
||||
body.Position = result.Position;
|
||||
pos = result.Position;
|
||||
cellId = result.CellId;
|
||||
grounded = result.IsOnGround;
|
||||
}
|
||||
|
||||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
|
||||
|
||||
Assert.True(pos.Y > 14.5f,
|
||||
$"Ethereal cylinder must not block (walked from 12.6 to past the axis); got Y={pos.Y:F3}");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Harness
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
|
||||
{
|
||||
cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
|
||||
// Flat terrain at Z=0 across the whole landblock.
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256]; // all zero → terrain Z = 0
|
||||
engine.AddLandblock(
|
||||
landblockId: TestLandblockId,
|
||||
terrain: new TerrainSurface(heights, heightTable),
|
||||
cells: Array.Empty<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static void RegisterCylinder(PhysicsEngine engine, uint entityId,
|
||||
Vector3 worldPos, float radius, float height, uint state = 0u)
|
||||
{
|
||||
engine.ShadowObjects.Register(
|
||||
entityId, gfxObjId: 0u,
|
||||
worldPos, Quaternion.Identity, radius,
|
||||
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: height,
|
||||
state: state);
|
||||
}
|
||||
|
||||
private static PhysicsBody MakeGroundedBody(Vector3 position)
|
||||
{
|
||||
var floorPlane = new Plane(Vector3.UnitZ, 0f);
|
||||
var floorVerts = new[]
|
||||
{
|
||||
new Vector3(-100f, -100f, 0f),
|
||||
new Vector3( 100f, -100f, 0f),
|
||||
new Vector3( 100f, 100f, 0f),
|
||||
new Vector3(-100f, 100f, 0f),
|
||||
};
|
||||
|
||||
return new PhysicsBody
|
||||
{
|
||||
Position = position,
|
||||
Orientation = Quaternion.Identity,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = floorPlane,
|
||||
ContactPlaneCellId = TestCellId,
|
||||
WalkablePolygonValid = true,
|
||||
WalkablePlane = floorPlane,
|
||||
WalkableVertices = floorVerts,
|
||||
WalkableUp = Vector3.UnitZ,
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Env = System.Environment;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe
|
||||
/// evidence (launch-175-verify2.log:42858): crossing corridor cells
|
||||
/// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal
|
||||
/// (−1,0,0) — pointing straight back against the movement — after which the
|
||||
/// stale sliding normal wedges all forward motion (ok=False hit=no, offset
|
||||
/// projected to zero). Question this dump answers: does cell 0x8A02017A's
|
||||
/// PHYSICS polygon set contain a portal-spanning polygon at its entry plane
|
||||
/// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in
|
||||
/// our collision set where retail filters them?
|
||||
/// </summary>
|
||||
public class Issue137CorridorSeamInspectionTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x8A02016Eu)]
|
||||
[InlineData(0x8A02017Au)]
|
||||
[InlineData(0x8A02011Eu)] // the under-floor room the corridor's floor-portals lead to
|
||||
[InlineData(0x8A020179u)] // the ramp corridor cell with the window (the #137 window-climb repro)
|
||||
[InlineData(0x8A02017Eu)] // the cell beyond the window the player climbed into
|
||||
public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId)
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var envCell = dats.Get<EnvCell>(envCellId);
|
||||
Assert.NotNull(envCell);
|
||||
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} ===");
|
||||
_out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " +
|
||||
$"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})");
|
||||
_out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}");
|
||||
_out.WriteLine($" CellPortals={envCell.CellPortals.Count}");
|
||||
foreach (var p in envCell.CellPortals)
|
||||
_out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}");
|
||||
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
Assert.NotNull(environment);
|
||||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||
|
||||
_out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)");
|
||||
foreach (var (id, poly) in cs.PhysicsPolygons)
|
||||
{
|
||||
// Compute the face normal from the vertex fan (same math as
|
||||
// PhysicsDataCache.ResolvePolygons).
|
||||
var verts = poly.VertexIds;
|
||||
if (verts.Count < 3) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
|
||||
var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross(
|
||||
v1.Origin - v0.Origin, v2.Origin - v0.Origin));
|
||||
|
||||
// Only print near-horizontal-normal polys (walls) — the seam wall
|
||||
// candidates; floors/ceilings are noise here.
|
||||
if (MathF.Abs(n.Z) > 0.3f) continue;
|
||||
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
|
||||
}
|
||||
|
||||
// The portal polygons live in the VISUAL polygon set — print their
|
||||
// ids so overlap with the physics set (same id space?) is visible.
|
||||
_out.WriteLine($" VisualPolygons={cs.Polygons.Count}");
|
||||
foreach (var p in envCell.CellPortals)
|
||||
{
|
||||
if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp))
|
||||
{
|
||||
_out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})");
|
||||
bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId);
|
||||
_out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's
|
||||
/// <c>PhysicsPolygons</c> TABLE does not mean the physics BSP ever tests a
|
||||
/// polygon — retail's <c>BSPLEAF::sphere_intersects_poly</c> (0x0053d580)
|
||||
/// iterates the LEAF's <c>in_polys</c> index list (leaf construction
|
||||
/// 0x0053d4a0: <c>in_polys[i] = &pack_poly[index]</c>), and our
|
||||
/// BSPQuery walks the dat's PhysicsBSP leaves the same way. This dump
|
||||
/// answers: do the physics-BSP LEAVES of the corridor cells reference the
|
||||
/// portal polygons? If yes, retail's own BSP query would test them too
|
||||
/// (→ the passable mechanism must be transit/approach-side — the cdb
|
||||
/// question). If no, our collision is testing polys retail never reaches
|
||||
/// (→ a desk-fixable acdream divergence).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A02016Eu)]
|
||||
[InlineData(0x8A02017Au)]
|
||||
public void CorridorCell_PhysicsBspLeafMembership_OfPortalPolys(uint envCellId)
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var envCell = dats.Get<EnvCell>(envCellId);
|
||||
Assert.NotNull(envCell);
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||
Assert.NotNull(environment);
|
||||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||
|
||||
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||||
foreach (var p in envCell.CellPortals)
|
||||
portalPolyIds.Add((ushort)p.PolygonId);
|
||||
|
||||
_out.WriteLine($"=== EnvCell 0x{envCellId:X8} — physics BSP leaf membership ===");
|
||||
_out.WriteLine($" Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " +
|
||||
$"portalPolyIds=[{string.Join(",", portalPolyIds)}] " +
|
||||
$"physicsTable=[{string.Join(",", cs!.PhysicsPolygons.Keys)}]");
|
||||
|
||||
var root = cs.PhysicsBSP?.Root;
|
||||
Assert.NotNull(root);
|
||||
|
||||
int leafCount = 0;
|
||||
var leafPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||||
var portalPolyLeafHits = new System.Collections.Generic.List<string>();
|
||||
var stack = new System.Collections.Generic.Stack<(DatReaderWriter.Types.PhysicsBSPNode Node, string Path)>();
|
||||
stack.Push((root!, "R"));
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var (n, path) = stack.Pop();
|
||||
if (n.Polygons is { Count: > 0 })
|
||||
{
|
||||
leafCount++;
|
||||
foreach (var pid in n.Polygons)
|
||||
{
|
||||
leafPolyIds.Add(pid);
|
||||
if (portalPolyIds.Contains(pid))
|
||||
portalPolyLeafHits.Add($"poly {pid} in leaf@{path} (type={n.Type}, polys=[{string.Join(",", n.Polygons)}])");
|
||||
}
|
||||
}
|
||||
if (n.PosNode is not null) stack.Push((n.PosNode, path + "+"));
|
||||
if (n.NegNode is not null) stack.Push((n.NegNode, path + "-"));
|
||||
}
|
||||
|
||||
_out.WriteLine($" BSP leaves-with-polys={leafCount} distinctLeafPolyIds=[{string.Join(",", leafPolyIds)}]");
|
||||
var tableNotInLeaves = new System.Collections.Generic.List<ushort>();
|
||||
foreach (var pid in cs.PhysicsPolygons.Keys)
|
||||
if (!leafPolyIds.Contains(pid))
|
||||
tableNotInLeaves.Add(pid);
|
||||
_out.WriteLine($" physics-table polys NOT referenced by any BSP leaf: [{string.Join(",", tableNotInLeaves)}]");
|
||||
|
||||
if (portalPolyLeafHits.Count == 0)
|
||||
{
|
||||
_out.WriteLine(" >>> NO portal polygon is referenced by any physics-BSP leaf — " +
|
||||
"retail's sphere_intersects_poly never tests them from this cell's BSP.");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var hit in portalPolyLeafHits)
|
||||
_out.WriteLine($" >>> PORTAL POLY IN PHYSICS LEAF: {hit}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #137 window climb: the dat truth for the player's collision spheres.
|
||||
/// Our InitPath places the head sphere at (sphereHeight − radius) = 0.72
|
||||
/// (capsule top 1.2 m); retail collides with the Setup's SPHERE LIST
|
||||
/// verbatim (CPhysicsObj::transition → init_sphere(GetNumSphere,
|
||||
/// GetSphere, scale)). Print human Setup 0x02000001's spheres.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HumanSetup_CollisionSpheres_DatTruth()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x02000001u);
|
||||
Assert.NotNull(setup);
|
||||
|
||||
_out.WriteLine($"Setup 0x02000001: Height={setup!.Height:F3} Radius={setup.Radius:F3} " +
|
||||
$"StepUp={setup.StepUpHeight:F3} StepDown={setup.StepDownHeight:F3}");
|
||||
_out.WriteLine($"Spheres ({setup.Spheres.Count}):");
|
||||
foreach (var s in setup.Spheres)
|
||||
_out.WriteLine($" origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}");
|
||||
_out.WriteLine($"CylSpheres ({setup.CylSpheres.Count}):");
|
||||
foreach (var c in setup.CylSpheres)
|
||||
_out.WriteLine($" origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #137 window-climb geometry (2026-07-06): full world-space vertex dump
|
||||
/// of the shaft cell 0x8A02017E (all physics polys) and 0x8A020179's
|
||||
/// south-wall family — the opening's lintel/ceiling spans decide where
|
||||
/// retail blocks the head.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WindowShaft_FullPolyDump()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (var cellId in new[] { 0x8A02017Eu, 0x8A020179u })
|
||||
{
|
||||
var envCell = dats.Get<EnvCell>(cellId);
|
||||
Assert.NotNull(envCell);
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||
|
||||
var rot = new System.Numerics.Quaternion(
|
||||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||
|
||||
_out.WriteLine($"=== 0x{cellId:X8} full physics polys (world verts) ===");
|
||||
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||||
{
|
||||
var verts = poly.VertexIds;
|
||||
if (verts.Count < 3) continue;
|
||||
var w = new System.Collections.Generic.List<System.Numerics.Vector3>();
|
||||
foreach (var vid in verts)
|
||||
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
|
||||
w.Add(System.Numerics.Vector3.Transform(v.Origin, world));
|
||||
var n = System.Numerics.Vector3.Normalize(
|
||||
System.Numerics.Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||
|
||||
// 017E: everything. 0179: south-wall family + ceilings only.
|
||||
if (cellId == 0x8A020179u && MathF.Abs(n.Y) < 0.3f && n.Z > -0.3f) continue;
|
||||
|
||||
var vs = string.Join(" ", w.ConvertAll(p => $"({p.X:F2},{p.Y:F2},{p.Z:F2})"));
|
||||
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) verts={vs}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mechanism-1 re-characterization (2026-07-06): the live hit normal
|
||||
/// (−1.00, 0.03, −0.03) at world (85.253, −39.776, −5.992) matches NO
|
||||
/// physics polygon of either corridor cell — 0x8A02016E (identity
|
||||
/// rotation) and 0x8A02017A (180° Z) both have only ±Y-normal wall polys,
|
||||
/// and the PortalSide portals to 0x011E (polys 1/3/5) are ±Y planes
|
||||
/// 1.4 m north of the player's track, perpendicular to the +X run — the
|
||||
/// pos_hits_sphere directional cull rejects them for this movement. This
|
||||
/// sweep hunts the ACTUAL culprit: every physics poly of the seam cell +
|
||||
/// all portal-adjacent neighbors, world-transformed, scored against the
|
||||
/// hit point + normal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CorridorSeam_FindPolygonMatchingLiveHit()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// Live evidence (launch-175-verify2.log:42858).
|
||||
var hitPoint = new System.Numerics.Vector3(85.253f, -39.776f, -5.992f);
|
||||
var hitNormal = new System.Numerics.Vector3(-1.00f, 0.03f, -0.03f);
|
||||
hitNormal = System.Numerics.Vector3.Normalize(hitNormal);
|
||||
const float sphereRadius = 0.48f;
|
||||
|
||||
// Seam cells + every portal-adjacent neighbor of both.
|
||||
var cellIds = new System.Collections.Generic.HashSet<uint>
|
||||
{
|
||||
0x8A02016Eu, 0x8A02017Au,
|
||||
};
|
||||
foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au })
|
||||
{
|
||||
var seedCell = dats.Get<EnvCell>(seed);
|
||||
if (seedCell is null) continue;
|
||||
foreach (var p in seedCell.CellPortals)
|
||||
cellIds.Add(0x8A020000u | p.OtherCellId);
|
||||
}
|
||||
|
||||
foreach (var cellId in cellIds)
|
||||
{
|
||||
var envCell = dats.Get<EnvCell>(cellId);
|
||||
if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; }
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cs))
|
||||
continue;
|
||||
|
||||
var rot = new System.Numerics.Quaternion(
|
||||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||
|
||||
var portalPolyIds = new System.Collections.Generic.HashSet<ushort>();
|
||||
foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId);
|
||||
|
||||
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||||
{
|
||||
var verts = poly.VertexIds;
|
||||
if (verts.Count < 3) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
|
||||
|
||||
var w0 = System.Numerics.Vector3.Transform(v0.Origin, world);
|
||||
var w1 = System.Numerics.Vector3.Transform(v1.Origin, world);
|
||||
var w2 = System.Numerics.Vector3.Transform(v2.Origin, world);
|
||||
var n = System.Numerics.Vector3.Normalize(
|
||||
System.Numerics.Vector3.Cross(w1 - w0, w2 - w0));
|
||||
|
||||
float align = System.Numerics.Vector3.Dot(n, hitNormal);
|
||||
// |align|: the vertex-fan winding convention can flip the
|
||||
// computed normal vs the physics plane's true facing — accept
|
||||
// both signs (2026-07-06 sweep flaw fix).
|
||||
if (MathF.Abs(align) < 0.95f) continue; // within ~18° of the recorded normal
|
||||
|
||||
// Plane distance from the hit point.
|
||||
float d = -System.Numerics.Vector3.Dot(n, w0);
|
||||
float dist = System.Numerics.Vector3.Dot(n, hitPoint) + d;
|
||||
if (MathF.Abs(dist) > sphereRadius + 0.1f) continue;
|
||||
|
||||
// Rough proximity: hit point near the polygon's vertex span.
|
||||
float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)) - 1f;
|
||||
float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)) + 1f;
|
||||
float minY = MathF.Min(w0.Y, MathF.Min(w1.Y, w2.Y)) - 1f;
|
||||
float maxY = MathF.Max(w0.Y, MathF.Max(w1.Y, w2.Y)) + 1f;
|
||||
if (hitPoint.X < minX || hitPoint.X > maxX ||
|
||||
hitPoint.Y < minY || hitPoint.Y > maxY) continue;
|
||||
|
||||
_out.WriteLine(
|
||||
$">>> CANDIDATE cell=0x{cellId:X8} poly={id} " +
|
||||
$"worldN=({n.X:F3},{n.Y:F3},{n.Z:F3}) align={align:F3} planeDist={dist:F3} " +
|
||||
$"isPortalPoly={portalPolyIds.Contains(id)} " +
|
||||
$"w0=({w0.X:F2},{w0.Y:F2},{w0.Z:F2}) w1=({w1.X:F2},{w1.Y:F2},{w1.Z:F2}) w2=({w2.X:F2},{w2.Y:F2},{w2.Z:F2}) " +
|
||||
$"verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}");
|
||||
}
|
||||
}
|
||||
_out.WriteLine("(sweep complete)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry-poly hunt: the synthetic reversed-movement collision normal is
|
||||
/// produced by slide_sphere's opposing-normals branch, which needs an
|
||||
/// INPUT collision normal anti-parallel to the grounded contact plane —
|
||||
/// i.e., a DOWNWARD-facing polygon (lintel / arch underside). Those were
|
||||
/// filtered out of the wall dump (|n.Z| > 0.3). Sweep both corridor
|
||||
/// cells for downward polys near the seam column and print where their
|
||||
/// planes sit relative to the player's head sphere.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CorridorSeam_DownwardPolysNearSeam()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (var cellId in new[] { 0x8A02016Eu, 0x8A02017Au })
|
||||
{
|
||||
var envCell = dats.Get<EnvCell>(cellId);
|
||||
Assert.NotNull(envCell);
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||
Assert.NotNull(environment);
|
||||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||
|
||||
var rot = new System.Numerics.Quaternion(
|
||||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||
|
||||
_out.WriteLine($"=== 0x{cellId:X8} downward physics polys (n.Z < -0.3) ===");
|
||||
foreach (var (id, poly) in cs!.PhysicsPolygons)
|
||||
{
|
||||
var verts = poly.VertexIds;
|
||||
if (verts.Count < 3) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue;
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue;
|
||||
|
||||
var w0 = System.Numerics.Vector3.Transform(v0.Origin, world);
|
||||
var w1 = System.Numerics.Vector3.Transform(v1.Origin, world);
|
||||
var w2 = System.Numerics.Vector3.Transform(v2.Origin, world);
|
||||
var n = System.Numerics.Vector3.Normalize(
|
||||
System.Numerics.Vector3.Cross(w1 - w0, w2 - w0));
|
||||
if (n.Z > -0.3f) continue;
|
||||
|
||||
// Only near the seam column the player crossed.
|
||||
float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X));
|
||||
float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X));
|
||||
if (maxX < 83.5f || minX > 87.0f) continue;
|
||||
|
||||
var allW = new System.Collections.Generic.List<System.Numerics.Vector3>();
|
||||
foreach (var vid in verts)
|
||||
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var vv))
|
||||
allW.Add(System.Numerics.Vector3.Transform(vv.Origin, world));
|
||||
float minZ = float.MaxValue, maxZ = float.MinValue, minY = float.MaxValue, maxY = float.MinValue;
|
||||
foreach (var w in allW)
|
||||
{
|
||||
minZ = MathF.Min(minZ, w.Z); maxZ = MathF.Max(maxZ, w.Z);
|
||||
minY = MathF.Min(minY, w.Y); maxY = MathF.Max(maxY, w.Y);
|
||||
}
|
||||
|
||||
_out.WriteLine(
|
||||
$" poly {id}: worldN=({n.X:F2},{n.Y:F2},{n.Z:F2}) x=[{minX:F2},{maxX:F2}] " +
|
||||
$"y=[{minY:F2},{maxY:F2}] z=[{minZ:F2},{maxZ:F2}] verts={verts.Count} " +
|
||||
$"sides={poly.SidesType} stip={poly.Stippling}");
|
||||
}
|
||||
}
|
||||
_out.WriteLine("(downward sweep complete)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-07-06 gate-session follow-up: seam crossings SUCCEED at
|
||||
/// y≈−40.8..−41.2 and BLOCK at y≈−39.5..−39.8 (cell-transit log,
|
||||
/// launch-137-corridor-gate.log). A y-dependent boundary with no physics
|
||||
/// polygon culprit points at the PORTAL POLYGONS — if the doorway
|
||||
/// openings don't span the full corridor width, the transit/membership
|
||||
/// machinery only hands the sphere to the neighbor inside the portal
|
||||
/// poly's span. Dump every portal polygon's world-space vertex extent.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A02016Eu)]
|
||||
[InlineData(0x8A02017Au)]
|
||||
public void CorridorCell_PortalPolygonWorldSpans(uint envCellId)
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var envCell = dats.Get<EnvCell>(envCellId);
|
||||
Assert.NotNull(envCell);
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell!.EnvironmentId);
|
||||
Assert.NotNull(environment);
|
||||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs));
|
||||
|
||||
var rot = new System.Numerics.Quaternion(
|
||||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||
var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(
|
||||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||
|
||||
_out.WriteLine($"=== 0x{envCellId:X8} portal polygons (world spans) ===");
|
||||
foreach (var p in envCell.CellPortals)
|
||||
{
|
||||
if (!cs!.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
|
||||
{
|
||||
_out.WriteLine($" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} {p.Flags}: NOT in visual set");
|
||||
continue;
|
||||
}
|
||||
|
||||
var min = new System.Numerics.Vector3(float.MaxValue);
|
||||
var max = new System.Numerics.Vector3(float.MinValue);
|
||||
foreach (var vid in poly.VertexIds)
|
||||
{
|
||||
if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) continue;
|
||||
var w = System.Numerics.Vector3.Transform(v.Origin, world);
|
||||
min = System.Numerics.Vector3.Min(min, w);
|
||||
max = System.Numerics.Vector3.Max(max, w);
|
||||
}
|
||||
_out.WriteLine(
|
||||
$" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
|
||||
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
|
||||
$"verts={poly.VertexIds.Count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Env = System.Environment;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #137 corridor-seam replay (2026-07-06) — dat-backed reproduction of the
|
||||
/// Facility Hub phantom hit (launch-175-verify2.log:42858): running +X down
|
||||
/// the corridor, crossing 0x8A02016E → 0x8A02017A at x≈85.25, the live
|
||||
/// client recorded `ok=True hit=yes n=(−1.00,0.03,−0.03)` with full advance,
|
||||
/// persisted the sliding normal, and every later forward resolve absorbed to
|
||||
/// zero (`ok=False hit=no`).
|
||||
///
|
||||
/// <para>
|
||||
/// Dat facts pinned by <see cref="Issue137CorridorSeamInspectionTests"/>:
|
||||
/// neither corridor cell (nor any portal-adjacent neighbor) has a physics
|
||||
/// polygon whose plane matches that normal near the hit point — the recorded
|
||||
/// normal is SYNTHETIC (the negated movement direction), which is exactly
|
||||
/// what slide_sphere's opposing-normals branch records. Retail
|
||||
/// (<c>CSphere::slide_sphere</c> 0x00537440 @0x0053762c) returns
|
||||
/// COLLIDED_TS from that branch; our port returned OK — letting the step
|
||||
/// complete with full advance and the synthetic normal persisted.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This replay drives the real engine over the real dat cells with the
|
||||
/// live-log positions and player dimensions, and pins: the seam crossing
|
||||
/// must complete WITHOUT persisting a sliding normal, and continued forward
|
||||
/// running must keep advancing (no absorbing wedge).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Issue137CorridorSeamReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue137CorridorSeamReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint SeamCellWest = 0x8A02016Eu;
|
||||
private const uint SeamCellEast = 0x8A02017Au;
|
||||
|
||||
private static string? FindDatDir()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(datDir) ? datDir : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hydrate the two seam cells + every portal-adjacent neighbor into a
|
||||
/// PhysicsEngine, exactly as the streaming path does (CacheCellStruct
|
||||
/// with the dat world transform).
|
||||
/// </summary>
|
||||
private static PhysicsEngine BuildCorridorEngine(DatCollection dats)
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
|
||||
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
|
||||
foreach (var seed in new[] { SeamCellWest, SeamCellEast })
|
||||
{
|
||||
var seedCell = dats.Get<EnvCell>(seed);
|
||||
Assert.NotNull(seedCell);
|
||||
foreach (var p in seedCell!.CellPortals)
|
||||
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||||
}
|
||||
|
||||
// Expand three portal rings — the live collision cell array reaches
|
||||
// cells three hops out (0x8A020166, the under-ramp room whose ceiling
|
||||
// is the ramp slab's underside, is ring-3 in the 2026-07-06
|
||||
// seam-shake trace; with only two rings the offline flood can never
|
||||
// add it and the shake does not reproduce).
|
||||
for (int ring = 0; ring < 3; ring++)
|
||||
{
|
||||
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
|
||||
{
|
||||
var cell = dats.Get<EnvCell>(known);
|
||||
if (cell is null) continue;
|
||||
foreach (var p in cell.CellPortals)
|
||||
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var cellId in toLoad)
|
||||
{
|
||||
var envCell = dats.Get<EnvCell>(cellId);
|
||||
if (envCell is null) continue;
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
if (environment is null) continue;
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
|
||||
|
||||
var rot = new Quaternion(
|
||||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||
var world = Matrix4x4.CreateFromQuaternion(rot)
|
||||
* Matrix4x4.CreateTranslation(
|
||||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||
|
||||
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
|
||||
}
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
// Corridor floor at world z = −6 → n·p + d = 0 with n = +Z, d = 6.
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
// The live session carried a walkable polygon (walkable=True on every
|
||||
// [resolve] line) — seed the corridor floor slab so the transition's
|
||||
// SetWalkable path runs like live.
|
||||
body.WalkablePolygonValid = true;
|
||||
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||||
body.WalkableUp = Vector3.UnitZ;
|
||||
body.WalkableVertices = new[]
|
||||
{
|
||||
new Vector3(75f, -41.67f, -6f),
|
||||
new Vector3(85f, -41.67f, -6f),
|
||||
new Vector3(85f, -38.33f, -6f),
|
||||
new Vector3(75f, -38.33f, -6f),
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
private ResolveResult Resolve(PhysicsEngine engine, PhysicsBody body,
|
||||
Vector3 from, Vector3 to, uint cellId)
|
||||
=> engine.ResolveWithTransition(
|
||||
currentPos: from,
|
||||
targetPos: to,
|
||||
cellId: cellId,
|
||||
sphereRadius: 0.48f, // human player, PlayerMovementController:885
|
||||
sphereHeight: 1.2f, // human player, PlayerMovementController:886
|
||||
stepUpHeight: 0.4f, // PlayerMovementController defaults
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||
|
||||
/// <summary>
|
||||
/// 2026-07-06 seam-shake repro, snapshot-exact (probe session
|
||||
/// launch-137-seam-probes.log + resolve-137-seam-capture.jsonl tick 4101,
|
||||
/// repeated ×46): running WEST across the x=75 boundary
|
||||
/// (0x8A02016E → 0x8A020165, the ramp cell) from (75.287, −40.035, −6)
|
||||
/// toward (74.685, −39.988, −6), the resolve blocks with the SYNTHETIC
|
||||
/// reversed-movement normal (0.997, −0.078, −0.002) and out==in — every
|
||||
/// frame — the "shaking at the seam" report.
|
||||
///
|
||||
/// <para>
|
||||
/// Probe-traced chain: the foot sphere (tangent to the floor) crosses
|
||||
/// onto 0165's ramp floor; the ramp slab is double-faced and the
|
||||
/// UNDERSIDE face (poly 0, n=(−0.03,0,−1)) grazes the sphere within the
|
||||
/// hit threshold → recorded as a foot near-miss → neg-poly step-up
|
||||
/// dispatch with a downward normal → the nested step-up's walkable probe
|
||||
/// rejects the exactly-tangent ramp floor ([walkable-nearest]
|
||||
/// gap=−0.0000 overlapsSphere=False) → StepUpSlide →
|
||||
/// slide_sphere(downward normal vs up-facing contact plane) → the
|
||||
/// opposing-normals branch → Collided → revert. Repeat.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SeamShake_WestBoundary_SnapshotExact_Advances()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
|
||||
// Body seeded EXACTLY from the capture's bodyBefore (tick 4101).
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||||
body.ContactPlaneCellId = SeamCellWest;
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
body.WalkablePolygonValid = true;
|
||||
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||||
body.WalkableUp = Vector3.UnitZ;
|
||||
body.WalkableVertices = new[]
|
||||
{
|
||||
new Vector3(75f, -38.33333f, -6f),
|
||||
new Vector3(75f, -41.66667f, -6f),
|
||||
new Vector3(78.33333f, -41.66667f, -6f),
|
||||
new Vector3(78.33333f, -38.33333f, -6f),
|
||||
};
|
||||
|
||||
var from = new Vector3(75.28674f, -40.03537f, -6f);
|
||||
var to = new Vector3(74.6854f, -39.988018f, -6f);
|
||||
|
||||
// Emit the same step-level probes the live session logged so the
|
||||
// offline trace can be line-diffed against launch-137-seam-probes.log
|
||||
// — the first divergent line names the state the replay is missing.
|
||||
var probeBuffer = new System.IO.StringWriter();
|
||||
var prevOut = Console.Out;
|
||||
ResolveResult r1;
|
||||
try
|
||||
{
|
||||
Console.SetOut(probeBuffer);
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||
|
||||
r1 = engine.ResolveWithTransition(
|
||||
currentPos: from,
|
||||
targetPos: to,
|
||||
cellId: SeamCellWest,
|
||||
sphereRadius: 0.48f,
|
||||
sphereHeight: 1.2f,
|
||||
stepUpHeight: 0.6f, // live Setup values from the capture
|
||||
stepDownHeight: 1.5f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||
Console.SetOut(prevOut);
|
||||
}
|
||||
_out.WriteLine(probeBuffer.ToString());
|
||||
|
||||
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
|
||||
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
|
||||
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
|
||||
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}");
|
||||
|
||||
Assert.True(r1.Position.X < from.X - 0.3f,
|
||||
$"The westward boundary crossing onto the ramp must advance " +
|
||||
$"({from.X:F3} → {r1.Position.X:F3}, target {to.X:F3}); zero " +
|
||||
$"advance with the reversed-movement normal = the seam shake.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log):
|
||||
/// running from the ramp top in 0x8A020179 into the corridor-end opening
|
||||
/// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the
|
||||
/// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) →
|
||||
/// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head
|
||||
/// (and camera) through the opening's roof. The opening is ~1.3 m tall
|
||||
/// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry
|
||||
/// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom:
|
||||
/// "should not be able to run into it".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WindowOpening_HeadCannotFit_EntryBlocked()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level
|
||||
body.ContactPlaneCellId = 0x8A020179u;
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
|
||||
// Walk the live approach (ramp-top toward the corridor-end opening)
|
||||
// so the engine self-accumulates its contact-plane/walkable state,
|
||||
// then push into the opening for several held-key frames (the live
|
||||
// climb happened under a held key, not a single resolve).
|
||||
var pos = new Vector3(88.60f, -41.10f, -5.05f);
|
||||
uint cell = 0x8A020179u;
|
||||
ResolveResult r = default;
|
||||
bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1";
|
||||
for (int i = 0; i < 22; i++)
|
||||
{
|
||||
var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f));
|
||||
var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f;
|
||||
|
||||
var probeBuffer = new System.IO.StringWriter();
|
||||
var prevOut = Console.Out;
|
||||
try
|
||||
{
|
||||
if (probeFrames && i >= 9)
|
||||
{
|
||||
Console.SetOut(probeBuffer);
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||
}
|
||||
r = engine.ResolveWithTransition(
|
||||
currentPos: pos,
|
||||
targetPos: pos + step,
|
||||
cellId: cell,
|
||||
sphereRadius: 0.48f,
|
||||
// #137: the corrected capsule top (dat Setup 0x02000001,
|
||||
// head sphere center 1.350 → top 1.830; Height 1.835).
|
||||
// The live climb happened under the old 1.2f (head top
|
||||
// 1.2 m — no head collision at the lintel).
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: 0.6f,
|
||||
stepDownHeight: 1.5f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (probeFrames && i >= 9)
|
||||
{
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||
Console.SetOut(prevOut);
|
||||
}
|
||||
}
|
||||
if (probeFrames && i >= 9 && i <= 10)
|
||||
_out.WriteLine(probeBuffer.ToString());
|
||||
_out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
|
||||
$"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})");
|
||||
pos = r.Position;
|
||||
cell = r.CellId;
|
||||
|
||||
Assert.NotEqual(0x8A02017Eu, r.CellId);
|
||||
Assert.True(r.Position.Y > -41.6f,
|
||||
$"A 1.68 m character must not enter the 1.3 m-tall opening " +
|
||||
$"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " +
|
||||
$"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " +
|
||||
$"0x8A02017E, head through the roof).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window-climb's placement half, pinned at the exact site: at the
|
||||
/// step-up's raised position on the alcove sill (foot −5.019), the HEAD
|
||||
/// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south
|
||||
/// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's
|
||||
/// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's
|
||||
/// step-down placement insert (CTransition::step_down 0x0050b3b3 →
|
||||
/// placement transitional_insert → BSPTREE::sphere_intersects_solid
|
||||
/// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable.
|
||||
/// Our placement passed (the live + offline climb), so our Path-1 solid
|
||||
/// test misses the head-vs-solid overlap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
|
||||
var cell = engine.DataCache!.GetCellStruct(0x8A020179u);
|
||||
Assert.NotNull(cell);
|
||||
Assert.NotNull(cell!.BSP?.Root);
|
||||
|
||||
// The raised (post-sill-climb) pose from the offline repro's r9.
|
||||
var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER
|
||||
var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER
|
||||
|
||||
var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform);
|
||||
var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform);
|
||||
|
||||
var t = new Transition();
|
||||
t.SpherePath.InitPath(
|
||||
new Vector3(89.683f, -41.247f, -5.019f),
|
||||
new Vector3(89.683f, -41.247f, -5.019f),
|
||||
0x8A020179u, 0.48f, 1.2f);
|
||||
t.SpherePath.InsertType = InsertType.Placement;
|
||||
|
||||
Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin);
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
cell.BSP!.Root,
|
||||
cell.Resolved,
|
||||
t,
|
||||
new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f },
|
||||
new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f },
|
||||
footLocal,
|
||||
Vector3.UnitZ,
|
||||
1.0f,
|
||||
cellRot,
|
||||
engine,
|
||||
worldOrigin: cellOrigin);
|
||||
|
||||
_out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " +
|
||||
$"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})");
|
||||
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing
|
||||
/// at (84.851, −39.764, −6.000) — the foot sphere already straddling the
|
||||
/// x=85 cell boundary by 0.33 m — the first move attempt toward
|
||||
/// (85.453, −39.782) blocked with the synthetic reversed-movement normal
|
||||
/// (−1.00, 0.03, −0.02), out==in, cp lost (cp=none), and repeated every
|
||||
/// frame (the "shaking at the seam" report). The deeper straddle start is
|
||||
/// what the original replay frame (84.638 → 85.253) didn't cover.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SeamCrossing_FromDeepStraddleStart_Advances()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
var body = GroundedBody();
|
||||
|
||||
var from = new Vector3(84.851f, -39.764f, -6.000f);
|
||||
var to = new Vector3(85.453f, -39.782f, -6.000f);
|
||||
|
||||
var r1 = Resolve(engine, body, from, to, SeamCellWest);
|
||||
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
|
||||
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
|
||||
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
|
||||
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
|
||||
$"bodyCpValid={body.ContactPlaneValid}");
|
||||
|
||||
Assert.True(r1.Position.X > from.X + 0.2f,
|
||||
$"The straddling-start seam crossing must advance " +
|
||||
$"({from.X:F3} → {r1.Position.X:F3}); zero advance with a " +
|
||||
$"reversed-movement normal = the 2026-07-06 seam shake.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeamCrossing_DoesNotPersistSyntheticSlidingNormal_AndRunContinues()
|
||||
{
|
||||
var datDir = FindDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildCorridorEngine(dats);
|
||||
var body = GroundedBody();
|
||||
|
||||
// ── The live hit frame verbatim (launch-175-verify2.log:42858) ──
|
||||
var from = new Vector3(84.638f, -39.758f, -6.000f);
|
||||
var to = new Vector3(85.253f, -39.776f, -6.000f);
|
||||
|
||||
var r1 = Resolve(engine, body, from, to, SeamCellWest);
|
||||
_out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " +
|
||||
$"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " +
|
||||
$"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " +
|
||||
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
|
||||
$"slidingN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})");
|
||||
|
||||
// The corridor is straight and open: the crossing must not leave the
|
||||
// body carrying a sliding normal (there is no wall to slide on —
|
||||
// Issue137CorridorSeamInspectionTests proved no polygon matches the
|
||||
// live-recorded normal; retail's slide_sphere opposing branch returns
|
||||
// COLLIDED and its validate handling never lets a synthetic
|
||||
// reversed-movement normal survive a clean corridor run).
|
||||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"Crossing the open corridor seam must not persist a sliding " +
|
||||
"normal — the live wedge's entry state (#137 mechanism 2).");
|
||||
|
||||
// ── Keep running +X (the live session's held-W frames) ──────────
|
||||
var pos = r1.Position;
|
||||
var cell = r1.CellId;
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
var step = new Vector3(0.13f, -0.004f, 0f); // ~run speed per tick, same heading
|
||||
var r = Resolve(engine, body, pos, pos + step, cell);
|
||||
_out.WriteLine($"r{i + 2}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||
$"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " +
|
||||
$"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}");
|
||||
Assert.True(r.Position.X > pos.X + 0.05f,
|
||||
$"Forward run must keep advancing through the open corridor " +
|
||||
$"(frame {i + 2}: {pos.X:F3} → {r.Position.X:F3}) — zero advance " +
|
||||
$"= the #137 absorbing wedge.");
|
||||
pos = r.Position;
|
||||
cell = r.CellId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's in-transition <c>collision_info.sliding_normal</c> has exactly ONE
|
||||
/// writer besides the per-frame seed: <c>CTransition::validate_transition</c>
|
||||
/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The
|
||||
/// BSP collision layer NEVER writes it — <c>BSPTREE::find_collisions</c>'
|
||||
/// Contact branch dispatches full hits to <c>step_sphere_up</c> (foot,
|
||||
/// 0x0053a719) / <c>BSPTREE::slide_sphere</c> (head, 0x0053a697), and
|
||||
/// <c>CSphere::slide_sphere</c> (0x00537440) slides IN-FRAME via
|
||||
/// <c>add_offset_to_check_pos</c> without touching sliding_normal
|
||||
/// (grep-verified: zero sliding_normal references between 0x005155 and
|
||||
/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only
|
||||
/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and
|
||||
/// Transition.cs:1027 (validate). The body-side persistence
|
||||
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x005154c2, SLIDING_TS bit-4 sync
|
||||
/// at 0x005154e1) runs only on transition SUCCESS.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// acdream's BSPQuery Contact branch carried stub fallbacks
|
||||
/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real
|
||||
/// slide. The leaked sliding normal survived to the transition end, the
|
||||
/// unconditional body writeback persisted it, and the next frame's seed
|
||||
/// projected an exactly-anti-parallel push to zero — aborting at step 0
|
||||
/// BEFORE any collision test could refresh the state. Live shape: the
|
||||
/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall
|
||||
/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no
|
||||
/// zero-advance resolves; strafe escapes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Issue137SlidingNormalLifecycleTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Site-level pins — BSPQuery.FindCollisions Contact branch must not write
|
||||
// the transition's sliding normal (retail: only validate_transition does).
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Contact foot-sphere FULL HIT with the step-up recursion unavailable
|
||||
/// (engine=null / step-up already in progress) must dispatch the real
|
||||
/// sphere slide — never the SetSlidingNormal stub.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail: a blocked step-up funnels to <c>SPHEREPATH::step_up_slide</c> →
|
||||
/// <c>CSphere::slide_sphere</c> (ACE SpherePath.cs:316 → Sphere.cs:558) —
|
||||
/// in-frame slide, no sliding_normal write. Face-on into a vertical wall
|
||||
/// while grounded: the crease projection (cross(wallN, floorN)) has no
|
||||
/// component along the movement, the slide offset is degenerate
|
||||
/// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
// Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5
|
||||
// (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall.
|
||||
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = to,
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
root, resolved, t, localSphere, null,
|
||||
from, Vector3.UnitZ, 1.0f);
|
||||
|
||||
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||
"find_collisions must not write collision_info.sliding_normal — " +
|
||||
"retail's only in-transition writer is validate_transition " +
|
||||
"(0x0050ac21). A sliding normal leaked here survives to the body " +
|
||||
"writeback and absorbs the next frame's forward offset (#137).");
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid,
|
||||
"The real slide records the collision normal (CSphere::slide_sphere " +
|
||||
"→ set_collision_normal).");
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact HEAD-sphere FULL HIT must dispatch <c>BSPTREE::slide_sphere</c>
|
||||
/// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real
|
||||
/// <c>Sphere.SlideSphere</c> on GlobalSphere[0]) — never the stub.
|
||||
/// The corridor phantom's portal-side polys span head height; this is the
|
||||
/// path that recorded the (−1,0,0) normal the wedge absorbed on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite()
|
||||
{
|
||||
// Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere
|
||||
// (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head
|
||||
// sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it.
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||
|
||||
var floorVerts = new[]
|
||||
{
|
||||
new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f),
|
||||
new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f),
|
||||
};
|
||||
resolved[1] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = floorVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, 0f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var wallNormal = new Vector3(-1f, 0f, 0f);
|
||||
var wallVerts = new[]
|
||||
{
|
||||
new Vector3(0.5f, -1f, 0.6f),
|
||||
new Vector3(0.5f, -1f, 5f),
|
||||
new Vector3(0.5f, 1f, 5f),
|
||||
new Vector3(0.5f, 1f, 0.6f),
|
||||
};
|
||||
resolved[2] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = wallVerts,
|
||||
Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var leaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f },
|
||||
};
|
||||
leaf.Polygons.Add(1);
|
||||
leaf.Polygons.Add(2);
|
||||
|
||||
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||
|
||||
var footSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = to,
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
var headSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = new Vector3(to.X, to.Y, 0.8f),
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
leaf, resolved, t, footSphere, headSphere,
|
||||
from, Vector3.UnitZ, 1.0f);
|
||||
|
||||
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||
"Head full hit must go through the real BSPTREE::slide_sphere — " +
|
||||
"no sliding_normal write at the BSP layer (retail 0x0053a697).");
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CSphere::slide_sphere</c>'s opposing-normals branch (collision
|
||||
/// normal anti-parallel to the contact plane — e.g. a ceiling-facing
|
||||
/// normal while grounded) records the REVERSED displacement as the
|
||||
/// collision normal and returns <b>COLLIDED_TS</b> — retail 0x00537440
|
||||
/// @0x005375d7-0x0053762c: <c>*normal = −gDelta; normalize;
|
||||
/// set_collision_normal; return 2</c>. Our port returned OK (its comment
|
||||
/// even claimed "retail returns OK here"), letting the step complete
|
||||
/// as-is with a synthetic reversed-movement collision normal — the exact
|
||||
/// signature of the live corridor hit (`hit=yes n=(−1.00,0.03,−0.03)` =
|
||||
/// the negated run direction, matching NO dat polygon).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal()
|
||||
{
|
||||
var t = new Transition();
|
||||
t.SpherePath.InitPath(
|
||||
new Vector3(0f, 0f, 0.2f), new Vector3(0.3f, 0f, 0.2f),
|
||||
0xA9B40001u, BSPStepUpFixtures.SphereRadius);
|
||||
t.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), 0xA9B40001u, false);
|
||||
|
||||
// Make gDelta exactly (0.4, 0, 0): currPos = check sphere − (0.4,0,0).
|
||||
var currPos = t.SpherePath.GlobalSphere[0].Origin - new Vector3(0.4f, 0f, 0f);
|
||||
|
||||
// Downward collision normal vs the +Z contact plane → cross ≈ 0
|
||||
// (parallel), dot = −1 < 0 (opposing) → the reverse branch.
|
||||
var result = t.SlideSphereInternal(new Vector3(0f, 0f, -1f), currPos);
|
||||
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||||
Assert.True(t.CollisionInfo.CollisionNormal.X < -0.99f,
|
||||
$"Collision normal must be the normalized reversed displacement " +
|
||||
$"(−1,0,0); got ({t.CollisionInfo.CollisionNormal.X:F3}," +
|
||||
$"{t.CollisionInfo.CollisionNormal.Y:F3},{t.CollisionInfo.CollisionNormal.Z:F3}).");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a
|
||||
// REAL wall. Guards the fix against regressing wall behavior, and
|
||||
// documents where retail CLEARS the body's sliding state (the successful
|
||||
// transition's writeback, when no step re-records a collision).
|
||||
// =========================================================================
|
||||
|
||||
private const uint CellId = 0xA9B40157u;
|
||||
|
||||
private static PhysicsEngine BuildWallEngine()
|
||||
{
|
||||
var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = wallRoot },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = wallResolved,
|
||||
CellBSP = new CellBSPTree
|
||||
{
|
||||
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||||
},
|
||||
};
|
||||
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
|
||||
// Flat terrain strip so the outdoor fall-through has something to
|
||||
// sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests).
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)0);
|
||||
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
engine.DataCache.RegisterCellStructForTest(CellId, cell);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static float[] BuildHeightTable()
|
||||
{
|
||||
var ht = new float[256];
|
||||
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
|
||||
return ht;
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 0f);
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
return body;
|
||||
}
|
||||
|
||||
private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body,
|
||||
Vector3 from, Vector3 to)
|
||||
=> engine.ResolveWithTransition(
|
||||
currentPos: from,
|
||||
targetPos: to,
|
||||
cellId: CellId,
|
||||
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||
sphereHeight: 0f, // single sphere — keeps the scenario deterministic
|
||||
stepUpHeight: 0.04f, // cannot scale the 5 m wall
|
||||
stepDownHeight: 0.04f,
|
||||
isOnGround: true,
|
||||
body: body);
|
||||
|
||||
/// <summary>
|
||||
/// The full retail lifecycle at a real wall:
|
||||
/// (1) a blocked face-on push persists the validate-recorded sliding
|
||||
/// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync,
|
||||
/// 0x005154e1);
|
||||
/// (2) the next exactly-anti-parallel push is absorbed by the seed
|
||||
/// (get_object_info 0x00511d44 → adjust_offset projects to zero →
|
||||
/// find_transitional_position's step-0 small-offset abort) — the
|
||||
/// retail cache semantics: "still pressed against this wall";
|
||||
/// (3) an oblique push escapes along the wall tangent, the step runs
|
||||
/// without re-recording a collision, and the successful writeback
|
||||
/// CLEARS the body's sliding state (sliding_normal_valid==0 → bit
|
||||
/// 4 cleared).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape()
|
||||
{
|
||||
var engine = BuildWallEngine();
|
||||
var body = GroundedBody();
|
||||
|
||||
// ── 1. Face-on +X into the wall at x=0.5 (normal −X) ─────────────
|
||||
var r1 = ResolveForward(engine, body,
|
||||
from: new Vector3(0.10f, 0f, 0f),
|
||||
to: new Vector3(0.35f, 0f, 0f));
|
||||
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A blocked push must persist the validate-recorded sliding normal " +
|
||||
"(retail SetPositionInternal 0x005154c2 on transition success).");
|
||||
Assert.True(body.SlidingNormal.X < -0.9f,
|
||||
$"Persisted normal should face the mover (−X); got {body.SlidingNormal}.");
|
||||
Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius
|
||||
<= 0.5f + PhysicsGlobals.EPSILON * 20f,
|
||||
$"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}.");
|
||||
|
||||
// ── 2. Exactly-anti-parallel push again: absorbed frame ──────────
|
||||
var r2 = ResolveForward(engine, body,
|
||||
from: r1.Position,
|
||||
to: r1.Position + new Vector3(0.15f, 0f, 0f));
|
||||
|
||||
Assert.False(r2.Ok,
|
||||
"The seeded sliding normal projects the exactly-anti-parallel " +
|
||||
"offset to zero → step-0 abort (retail find_transitional_position " +
|
||||
"0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall.");
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A failed transition leaves the body's sliding state untouched " +
|
||||
"(retail: SetPositionInternal never runs on failure).");
|
||||
|
||||
// ── 3. Oblique push escapes and CLEARS the persisted state ───────
|
||||
var r3 = ResolveForward(engine, body,
|
||||
from: r2.Position,
|
||||
to: r2.Position + new Vector3(0.10f, 0.15f, 0f));
|
||||
|
||||
Assert.True(r3.Ok, "Oblique push must escape along the wall tangent.");
|
||||
Assert.True(r3.Position.Y > r2.Position.Y + 0.05f,
|
||||
$"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " +
|
||||
$"(from {r2.Position.Y:F4}).");
|
||||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A successful transition whose steps re-record no collision must " +
|
||||
"CLEAR the body's sliding state (retail SetPositionInternal " +
|
||||
"0x005154e1: bit 4 synced from the transition's final " +
|
||||
"sliding_normal_valid, which each step clears before its insert).");
|
||||
Assert.Equal(Vector3.Zero, body.SlidingNormal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Env = System.Environment;
|
||||
using Placement = DatReaderWriter.Enums.Placement;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #175 (2026-07-05) — read-only dat inspection for the Facility Hub door
|
||||
/// (Setup 0x02000C9D, guid 0x78A020C7 in the live session). User report:
|
||||
/// the door's COLLISION sits displaced to the far side of the VISUAL panel
|
||||
/// (embed from one side deep enough to camera-clip; a phantom wall on the
|
||||
/// other side that can push the player out of use radius).
|
||||
///
|
||||
/// Hypothesis under test: collision registers from the Setup's
|
||||
/// PlacementFrames (ShadowShapeBuilder.FromSetup — Resting|Default|first)
|
||||
/// while the rendered panel poses from the motion table's default/closed
|
||||
/// state through the sequencer; retail tests the part's LIVE pose
|
||||
/// (CPhysicsPart), so a door whose placement frame differs from its
|
||||
/// motion-table closed pose shows exactly this offset. This test dumps both
|
||||
/// poses so the divergence (or its absence) is a dat fact, not a theory.
|
||||
///
|
||||
/// SKIP when the dat directory is absent (CI); local runs have it.
|
||||
/// </summary>
|
||||
public class Issue175HubDoorPoseInspectionTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue175HubDoorPoseInspectionTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint HubDoorSetupId = 0x02000C9Du;
|
||||
|
||||
[Fact]
|
||||
public void HubDoorSetup_PlacementVsMotionPose_DatInspection()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var setup = dats.Get<Setup>(HubDoorSetupId);
|
||||
Assert.NotNull(setup);
|
||||
|
||||
_out.WriteLine($"=== Setup 0x{HubDoorSetupId:X8} ===");
|
||||
_out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})");
|
||||
_out.WriteLine($" Parts = {setup.Parts.Count}");
|
||||
for (int i = 0; i < setup.Parts.Count; i++)
|
||||
_out.WriteLine($" [{i}] gfxObj=0x{setup.Parts[i]:X8}");
|
||||
_out.WriteLine($" DefaultAnimation = 0x{setup.DefaultAnimation:X8}");
|
||||
_out.WriteLine($" DefaultScript = 0x{setup.DefaultScript:X8}");
|
||||
_out.WriteLine($" DefaultMotionTable = 0x{setup.DefaultMotionTable:X8}");
|
||||
_out.WriteLine($" CylSpheres={setup.CylSpheres.Count} Spheres={setup.Spheres.Count} Radius={setup.Radius:F3}");
|
||||
foreach (var c in setup.CylSpheres)
|
||||
_out.WriteLine($" cyl r={c.Radius:F3} h={c.Height:F3} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})");
|
||||
|
||||
_out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}");
|
||||
foreach (var kv in setup.PlacementFrames)
|
||||
{
|
||||
_out.WriteLine($" [{kv.Key}] frames={kv.Value.Frames.Count}");
|
||||
for (int i = 0; i < kv.Value.Frames.Count; i++)
|
||||
{
|
||||
var f = kv.Value.Frames[i];
|
||||
_out.WriteLine(
|
||||
$" part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
|
||||
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
|
||||
}
|
||||
}
|
||||
|
||||
// Part 0's physics BSP bounds — where the slab actually is in
|
||||
// PART-LOCAL space (composed with the poses above for world).
|
||||
foreach (uint gfxId in setup.Parts.Distinct())
|
||||
{
|
||||
var gfx = dats.Get<GfxObj>(gfxId);
|
||||
_out.WriteLine($"=== GfxObj 0x{gfxId:X8} ===");
|
||||
if (gfx is null) { _out.WriteLine(" NULL"); continue; }
|
||||
var root = gfx.PhysicsBSP?.Root;
|
||||
_out.WriteLine($" PhysicsBSP.Root = {(root is null ? "NULL" : "non-null")}");
|
||||
if (root?.BoundingSphere is { } bs)
|
||||
_out.WriteLine($" BSP bounds = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}");
|
||||
if (gfx.PhysicsPolygons is { } pp && gfx.VertexArray?.Vertices is { } verts)
|
||||
{
|
||||
float minX = float.MaxValue, maxX = float.MinValue;
|
||||
float minY = float.MaxValue, maxY = float.MinValue;
|
||||
float minZ = float.MaxValue, maxZ = float.MinValue;
|
||||
foreach (var poly in pp.Values)
|
||||
foreach (var vid in poly.VertexIds)
|
||||
{
|
||||
if (!verts.TryGetValue((ushort)vid, out var sv)) continue;
|
||||
minX = Math.Min(minX, sv.Origin.X); maxX = Math.Max(maxX, sv.Origin.X);
|
||||
minY = Math.Min(minY, sv.Origin.Y); maxY = Math.Max(maxY, sv.Origin.Y);
|
||||
minZ = Math.Min(minZ, sv.Origin.Z); maxZ = Math.Max(maxZ, sv.Origin.Z);
|
||||
}
|
||||
_out.WriteLine($" Physics AABB (part-local) = X[{minX:F3},{maxX:F3}] Y[{minY:F3},{maxY:F3}] Z[{minZ:F3},{maxZ:F3}]");
|
||||
}
|
||||
}
|
||||
|
||||
// The motion-table default (closed) pose, if the setup names one:
|
||||
// frame 0 of the default style's default cycle — what the sequencer
|
||||
// renders for an idle closed door.
|
||||
if (setup.DefaultMotionTable != 0)
|
||||
{
|
||||
var mt = dats.Get<MotionTable>(setup.DefaultMotionTable);
|
||||
_out.WriteLine($"=== MotionTable 0x{setup.DefaultMotionTable:X8} ===");
|
||||
if (mt is null) { _out.WriteLine(" NULL"); return; }
|
||||
_out.WriteLine($" DefaultStyle = 0x{(uint)mt.DefaultStyle:X8}");
|
||||
if (mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var defCycle)
|
||||
&& defCycle.Anims.Count > 0)
|
||||
{
|
||||
var animRef = defCycle.Anims[0];
|
||||
_out.WriteLine($" default cycle anim[0] id=0x{animRef.AnimId:X8} lo={animRef.LowFrame} hi={animRef.HighFrame}");
|
||||
var anim = dats.Get<Animation>(animRef.AnimId);
|
||||
if (anim is not null && anim.PartFrames.Count > 0)
|
||||
{
|
||||
var f0 = anim.PartFrames[Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1)];
|
||||
for (int i = 0; i < f0.Frames.Count; i++)
|
||||
{
|
||||
var f = f0.Frames[i];
|
||||
_out.WriteLine(
|
||||
$" anim frame0 part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
|
||||
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_out.WriteLine(" anim NULL or no PartFrames");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_out.WriteLine(" no default-style cycle");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_out.WriteLine("=== no DefaultMotionTable on the setup ===");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #175 derivation conformance — REAL-DAT pin for
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames"/>.
|
||||
/// The first cut of the derivation looked up <c>Cycles[DefaultStyle]</c>
|
||||
/// with the bare style word; the dictionary is keyed by the COMBINED
|
||||
/// <c>(style << 16) | substate</c> word (CMotionTable.cs:683), so it
|
||||
/// always missed and the #175 fix silently no-oped. This pin loads the
|
||||
/// human motion table (0x09000001 — guaranteed present, default state
|
||||
/// NonCombat/Ready) and asserts the derivation actually resolves a pose.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MotionTablePose_DefaultState_ResolvesOnRealTable()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var mt = dats.Get<MotionTable>(0x09000001u);
|
||||
Assert.NotNull(mt);
|
||||
|
||||
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
|
||||
mt!, id => dats.Get<Animation>(id));
|
||||
|
||||
Assert.NotNull(pose);
|
||||
_out.WriteLine($"human MT default pose parts={pose!.Count} " +
|
||||
$"part0=({pose[0].Origin.X:F3},{pose[0].Origin.Y:F3},{pose[0].Origin.Z:F3})");
|
||||
Assert.True(pose.Count >= 1);
|
||||
}
|
||||
|
||||
// ── #175 fix pins: ShadowShapeBuilder partPoseOverride ──────────────
|
||||
|
||||
private static Setup MakeTwoPartSetup()
|
||||
{
|
||||
var setup = new Setup();
|
||||
setup.Parts.Add(0x01000001u);
|
||||
setup.Parts.Add(0x01000002u);
|
||||
var placement = new AnimationFrame(2);
|
||||
placement.Frames.Clear();
|
||||
placement.Frames.Add(new Frame { Origin = new Vector3(0.88f, -0.44f, 1.37f),
|
||||
Orientation = new Quaternion(0f, 0f, -0.966f, 0.259f) });
|
||||
placement.Frames.Add(new Frame { Origin = new Vector3(-0.88f, -0.44f, 1.37f),
|
||||
Orientation = new Quaternion(0f, 0f, -0.259f, 0.966f) });
|
||||
setup.PlacementFrames[Placement.Default] = placement;
|
||||
return setup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With a motion-table pose override, the BSP part shapes must use it —
|
||||
/// the closed pose, not the ajar placement pose (the #175 offset).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromSetup_PartPoseOverride_ReplacesPlacementFrames()
|
||||
{
|
||||
var setup = MakeTwoPartSetup();
|
||||
var closed = new[]
|
||||
{
|
||||
new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
|
||||
new Frame { Origin = new Vector3(-0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
|
||||
};
|
||||
|
||||
var shapes = ShadowShapeBuilder.FromSetup(
|
||||
setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: closed);
|
||||
|
||||
Assert.Equal(2, shapes.Count);
|
||||
Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition);
|
||||
Assert.Equal(Quaternion.Identity, shapes[0].LocalRotation);
|
||||
Assert.Equal(new Vector3(-0.85f, 0f, 1.37f), shapes[1].LocalPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null override (no motion table) keeps the pre-#175 placement-frame
|
||||
/// behavior — landblock statics and table-less entities unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromSetup_NoOverride_KeepsPlacementFrames()
|
||||
{
|
||||
var setup = MakeTwoPartSetup();
|
||||
|
||||
var shapes = ShadowShapeBuilder.FromSetup(
|
||||
setup, entScale: 1f, hasPhysicsBsp: _ => true);
|
||||
|
||||
Assert.Equal(2, shapes.Count);
|
||||
Assert.Equal(new Vector3(0.88f, -0.44f, 1.37f), shapes[0].LocalPosition);
|
||||
Assert.Equal(new Quaternion(0f, 0f, -0.966f, 0.259f), shapes[0].LocalRotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A short override (fewer frames than parts) falls back to placement
|
||||
/// frames — a mismatched motion table must not misplace collision.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromSetup_ShortOverride_FallsBackPerPart()
|
||||
{
|
||||
var setup = MakeTwoPartSetup();
|
||||
var shortOverride = new[]
|
||||
{
|
||||
new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity },
|
||||
};
|
||||
|
||||
var shapes = ShadowShapeBuilder.FromSetup(
|
||||
setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: shortOverride);
|
||||
|
||||
Assert.Equal(2, shapes.Count);
|
||||
Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); // override
|
||||
Assert.Equal(new Vector3(-0.88f, -0.44f, 1.37f), shapes[1].LocalPosition); // placement fallback
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Env = System.Environment;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #176/#177 membership half: production [cell-transit] lines
|
||||
/// (launch-137-gate2.log) fire 0.1–0.6 m PAST the portal plane in the travel
|
||||
/// direction (016E→017A at x=85.33–85.47 vs the plane at x=85.00), while the
|
||||
/// dat CellBSP volumes partition EXACTLY at the plane
|
||||
/// (Issue176177DungeonSeamInspectionTests.SeamCells_CellBspContainment) —
|
||||
/// retail's center-only point_in_cell flips at the plane. The render root
|
||||
/// (viewer cell) resolves through the same machinery; while it lags, the
|
||||
/// portal flood correctly refuses the boundary portal the eye has already
|
||||
/// crossed and the whole forward chain drops (the purple seam flash /
|
||||
/// stair pop). This replay measures OUR resolver's flip point across the
|
||||
/// x=85 seam in a controlled run.
|
||||
/// </summary>
|
||||
public class Issue176177SeamTransitLagTests
|
||||
{
|
||||
private const uint SeamCellWest = 0x8A02016Eu; // x 75..85
|
||||
private const uint SeamCellEast = 0x8A02017Au; // x 85..88.33
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue176177SeamTransitLagTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(datDir) ? datDir : null;
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildEngine(DatCollection dats)
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
|
||||
var toLoad = new System.Collections.Generic.HashSet<uint> { SeamCellWest, SeamCellEast };
|
||||
for (int ring = 0; ring < 3; ring++)
|
||||
{
|
||||
foreach (var known in new System.Collections.Generic.List<uint>(toLoad))
|
||||
{
|
||||
var cell = dats.Get<EnvCell>(known);
|
||||
if (cell is null) continue;
|
||||
foreach (var p in cell.CellPortals)
|
||||
toLoad.Add(0x8A020000u | p.OtherCellId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var cellId in toLoad)
|
||||
{
|
||||
var envCell = dats.Get<EnvCell>(cellId);
|
||||
if (envCell is null) continue;
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
if (environment is null) continue;
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue;
|
||||
|
||||
var rot = new Quaternion(
|
||||
envCell.Position.Orientation.X, envCell.Position.Orientation.Y,
|
||||
envCell.Position.Orientation.Z, envCell.Position.Orientation.W);
|
||||
var world = Matrix4x4.CreateFromQuaternion(rot)
|
||||
* Matrix4x4.CreateTranslation(
|
||||
envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z);
|
||||
|
||||
engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world);
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 6f);
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
body.WalkablePolygonValid = true;
|
||||
body.WalkablePlane = new Plane(Vector3.UnitZ, 6f);
|
||||
body.WalkableUp = Vector3.UnitZ;
|
||||
body.WalkableVertices = new[]
|
||||
{
|
||||
new Vector3(75f, -41.67f, -6f),
|
||||
new Vector3(85f, -41.67f, -6f),
|
||||
new Vector3(85f, -38.33f, -6f),
|
||||
new Vector3(75f, -38.33f, -6f),
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run +X across the x=85 seam at run-speed tick steps (13.5 cm/tick ≈
|
||||
/// 4 m/s at 30 Hz) and record where ResolveWithTransition's CellId flips.
|
||||
/// Retail (center-only point_in_cell, exact-partition CellBSP) flips on
|
||||
/// the first tick whose END position is past x=85.00 — any flip later
|
||||
/// than one step past the plane is OUR lag.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(+1)] // west → east across x=85
|
||||
[InlineData(-1)] // east → west back across
|
||||
public void RunAcrossSeam_CellFlipPosition(int direction)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var engine = BuildEngine(dats);
|
||||
var body = GroundedBody();
|
||||
|
||||
const float step = 0.135f;
|
||||
float startX = direction > 0 ? 83.8f : 86.2f;
|
||||
uint cell = direction > 0 ? SeamCellWest : SeamCellEast;
|
||||
var pos = new Vector3(startX, -40f, -6f);
|
||||
|
||||
float? flipX = null;
|
||||
for (int tick = 0; tick < 26; tick++)
|
||||
{
|
||||
var target = pos + new Vector3(direction * step, 0f, 0f);
|
||||
var r = engine.ResolveWithTransition(
|
||||
currentPos: pos,
|
||||
targetPos: target,
|
||||
cellId: cell,
|
||||
sphereRadius: 0.48f,
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
|
||||
|
||||
bool flipped = r.CellId != cell;
|
||||
_out.WriteLine($"tick={tick,2} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " +
|
||||
$"cell=0x{r.CellId:X8} ok={r.Ok}{(flipped ? " <<< FLIP" : "")}");
|
||||
if (flipped && flipX is null)
|
||||
flipX = r.Position.X;
|
||||
|
||||
cell = r.CellId;
|
||||
pos = r.Position;
|
||||
if (direction > 0 && pos.X > 86.4f) break;
|
||||
if (direction < 0 && pos.X < 83.6f) break;
|
||||
}
|
||||
|
||||
Assert.NotNull(flipX);
|
||||
float lag = direction > 0 ? flipX!.Value - 85.00f : 85.00f - flipX!.Value;
|
||||
_out.WriteLine($"flip at x={flipX:F3} → lag past the plane = {lag:F3} m " +
|
||||
$"(one-tick quantization bound = {step:F3} m)");
|
||||
// Diagnostic, not a pin: the finding is the printed lag. A lag beyond
|
||||
// one tick step is the divergence under investigation.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics.Motion;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #174 pin (2026-07-05): the RemoveLinkAnimations seam must be retail
|
||||
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||||
// CPartArray::HandleEnterWorld 0x00517d70 → MotionTableManager::
|
||||
// HandleEnterWorld 0x0051bdd0: CSequence::remove_all_link_animations PLUS a
|
||||
// full pending_animations drain (`while (head) AnimationDone(0)`), each pop
|
||||
// relaying MotionDone → CMotionInterp pops its pending_motions node in
|
||||
// lockstep.
|
||||
//
|
||||
// The pre-fix binding was the bare sequence strip: every LeaveGround (jump)
|
||||
// removed the link animations that queued MotionTableManager nodes were
|
||||
// counting down on, orphaning them (NumAnims > 0, animations gone). Both
|
||||
// queues then dammed permanently — MotionsPending() never drained at rest —
|
||||
// and BeginTurnToHeading/BeginMoveForward (retail 0x00529b90 motions_pending
|
||||
// gate) starved every armed moveto: ACE's walk-to-door mt-6 armed but the
|
||||
// body never walked; the close-range Use turn never completed so the
|
||||
// deferred action was silently eaten. Live evidence: launch-174-autowalk.log
|
||||
// (last player pending=False at the first MovementJump press; old jump
|
||||
// motions still completing at rest minutes later).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
public class Issue174LinkStripDrainTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue174LinkStripDrainTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
/// <summary>
|
||||
/// The jam repro: queue motions (link + cycle nodes land in BOTH the
|
||||
/// interp's pending_motions and the manager's pending_animations), then
|
||||
/// fire the LeaveGround-side seam. With the retail HandleEnterWorld
|
||||
/// binding both queues drain to empty; the pre-fix bare-strip binding
|
||||
/// left both non-empty forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemoveLinkAnimationsSeam_DrainsBothQueues()
|
||||
{
|
||||
var h = new RemoteChaseHarness(_out);
|
||||
|
||||
// Drive a motion burst — walk, run, stop — the shape a player's
|
||||
// pre-jump input produces. Each successful dispatch pairs an interp
|
||||
// node with a manager node.
|
||||
var p = new MovementParameters();
|
||||
h.Interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
h.Interp.set_hold_run(true, interrupt: false);
|
||||
h.Interp.StopMotion(MotionCommand.WalkForward, p);
|
||||
|
||||
Assert.True(h.Interp.MotionsPending(),
|
||||
"precondition: the burst must leave pending interp nodes");
|
||||
Assert.NotEmpty(h.Seq.Manager.PendingAnimations);
|
||||
|
||||
// The LeaveGround seam (retail CMotionInterp::LeaveGround 0x00528b00
|
||||
// fires CPhysicsObj::RemoveLinkAnimations).
|
||||
h.Interp.RemoveLinkAnimations!.Invoke();
|
||||
|
||||
Assert.False(h.Interp.MotionsPending(),
|
||||
"HandleEnterWorld's drain must pop every pending interp node " +
|
||||
"(retail: each AnimationDone(0) relays MotionDone)");
|
||||
Assert.Empty(h.Seq.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The post-jump livability pin: after the seam fires mid-activity, a
|
||||
/// NEW moveto-style dispatch must be able to queue and complete — the
|
||||
/// #174 symptom was that BeginTurnToHeading's motions_pending gate never
|
||||
/// re-opened after a jump, permanently starving armed movetos.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AfterSeamDrain_NewMotionsQueueAndComplete()
|
||||
{
|
||||
var h = new RemoteChaseHarness(_out);
|
||||
var p = new MovementParameters();
|
||||
|
||||
// Pre-jump activity, then the jump's LeaveGround strip+drain.
|
||||
h.Interp.DoMotion(MotionCommand.WalkForward, p);
|
||||
h.Interp.RemoveLinkAnimations!.Invoke();
|
||||
Assert.False(h.Interp.MotionsPending());
|
||||
|
||||
// A fresh dispatch (the armed moveto's turn) queues...
|
||||
h.Interp.DoMotion(MotionCommand.TurnRight, p);
|
||||
Assert.True(h.Interp.MotionsPending());
|
||||
|
||||
// ...and the normal completion path (the manager's queue feeding
|
||||
// MotionDone) drains it — the gate re-opens.
|
||||
while (h.Seq.Manager.PendingAnimations.GetEnumerator() is var e && e.MoveNext())
|
||||
h.Seq.Manager.AnimationDone(success: true);
|
||||
|
||||
h.Seq.Manager.CheckForCompletedMotions();
|
||||
Assert.False(h.Interp.MotionsPending(),
|
||||
"the normal AnimationDone → MotionDone chain must drain the new node");
|
||||
}
|
||||
}
|
||||
|
|
@ -174,7 +174,10 @@ internal sealed class RemoteChaseHarness
|
|||
TurnStopped = () => ObservedOmega = Vector3.Zero,
|
||||
};
|
||||
Interp.DefaultSink = Sink;
|
||||
Interp.RemoveLinkAnimations = Seq.RemoveAllLinkAnimations;
|
||||
// #174: production binds the seam to Manager.HandleEnterWorld
|
||||
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
|
||||
// the bare sequence strip orphaned pending manager nodes.
|
||||
Interp.RemoveLinkAnimations = () => Seq.Manager.HandleEnterWorld();
|
||||
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
|
||||
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
|
||||
|
||||
|
|
|
|||
|
|
@ -487,9 +487,20 @@ public class PhysicsEngineTests
|
|||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 1.835f);
|
||||
|
||||
// Without the gate (movingEntityId == 0): the sweep must self-push.
|
||||
// This proves the registry actually causes a collision, so the
|
||||
// following filtered case is not a vacuous pass.
|
||||
// Without the gate (movingEntityId == 0): the sweep must be
|
||||
// INTERFERED WITH by the self-entry. This proves the registry
|
||||
// actually causes a collision, so the following filtered case is not
|
||||
// a vacuous pass.
|
||||
//
|
||||
// Observable updated for the 2026-07-05 CCylSphere family port: the
|
||||
// old hand-rolled response radial-pushed the sphere ~1 m sideways
|
||||
// (the original #42 symptom this test asserted). Retail's dispatcher
|
||||
// (0x0053b440) resolves this geometry — airborne, dead-center on the
|
||||
// cylinder axis, moving up — through land_on_cylinder → the Collide
|
||||
// re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition
|
||||
// then reverts to a stay-put (no sideways teleport, Ok=true). The
|
||||
// response-model-independent interference signal is the DENIED +Z
|
||||
// movement: the sweep must NOT reach the +0.022 target.
|
||||
var unfiltered = engine.ResolveWithTransition(
|
||||
currentPos: bodyPos, targetPos: targetPos,
|
||||
cellId: 0xA9B40039u,
|
||||
|
|
@ -498,11 +509,11 @@ public class PhysicsEngineTests
|
|||
isOnGround: false,
|
||||
movingEntityId: 0u);
|
||||
|
||||
float unfilteredXY = MathF.Sqrt(
|
||||
(unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) +
|
||||
(unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y));
|
||||
Assert.True(unfilteredXY > 0.5f,
|
||||
$"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)");
|
||||
Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f,
|
||||
$"Without movingEntityId, the sweep must collide with the mover's own " +
|
||||
$"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " +
|
||||
$"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " +
|
||||
$"target Z={targetPos.Z:F4}");
|
||||
|
||||
// With the gate: the sweep must leave XY unchanged.
|
||||
var filtered = engine.ResolveWithTransition(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,624 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Env = System.Environment;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #176 (purple flashing on dungeon floors at cell seams) + #177 (stairs pop
|
||||
/// in/out across levels) — dat-truth inspection for the Facility Hub anchor
|
||||
/// cells. The load-bearing topology fact from the #137 arc: corridor FLOORS
|
||||
/// are portal polygons (PortalSide floor-portals to under-rooms, e.g.
|
||||
/// 0x8A02016E visual polys 1/3/5 → 0x011E). These dumps answer:
|
||||
///
|
||||
/// (a) are the floor-portal VISUAL polys textured (drawn by CellMesh.Build)
|
||||
/// or NoPos-stippled (skipped)? Same question for the RECIPROCAL portal
|
||||
/// polys in the other cell — two textured coincident planes would
|
||||
/// z-fight (#176's angle-dependent flash candidate);
|
||||
/// (b) which cell owns the actual stair-step geometry at the
|
||||
/// 0x8A020182 → 0x8A020183 level transit (#177's pop-in subject);
|
||||
/// (c) do any drawn polys reference surfaces that fail to resolve
|
||||
/// (the magenta-placeholder class)?
|
||||
///
|
||||
/// ⚠️ id-space trap (cost the #137 saga a wrong mechanism):
|
||||
/// CellPortal.PolygonId indexes CellStruct.Polygons (VISUAL), never
|
||||
/// PhysicsPolygons — same ids in both tables are unrelated polygons.
|
||||
/// </summary>
|
||||
public class Issue176177DungeonSeamInspectionTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue176177DungeonSeamInspectionTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(datDir) ? datDir : null;
|
||||
}
|
||||
|
||||
private static Matrix4x4 WorldTransform(EnvCell cell)
|
||||
{
|
||||
var rot = new Quaternion(
|
||||
cell.Position.Orientation.X, cell.Position.Orientation.Y,
|
||||
cell.Position.Orientation.Z, cell.Position.Orientation.W);
|
||||
return Matrix4x4.CreateFromQuaternion(rot)
|
||||
* Matrix4x4.CreateTranslation(
|
||||
cell.Position.Origin.X, cell.Position.Origin.Y, cell.Position.Origin.Z);
|
||||
}
|
||||
|
||||
private static (EnvCell cell, DatReaderWriter.Types.CellStruct cs)? LoadCell(DatCollection dats, uint cellId)
|
||||
{
|
||||
var envCell = dats.Get<EnvCell>(cellId);
|
||||
if (envCell is null) return null;
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||
if (environment is null) return null;
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) return null;
|
||||
return (envCell, cs!);
|
||||
}
|
||||
|
||||
private static List<Vector3> WorldVerts(
|
||||
DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world)
|
||||
{
|
||||
var result = new List<Vector3>(poly.VertexIds.Count);
|
||||
foreach (var vid in poly.VertexIds)
|
||||
if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v))
|
||||
result.Add(Vector3.Transform(v.Origin, world));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos
|
||||
/// stippling, PosSurface index in range) — the DRAWN verdict per poly.
|
||||
/// </summary>
|
||||
private static bool WouldDraw(DatReaderWriter.Types.Polygon poly, EnvCell cell) =>
|
||||
poly.VertexIds.Count >= 3
|
||||
&& !poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos)
|
||||
&& poly.PosSurface >= 0
|
||||
&& poly.PosSurface < cell.Surfaces.Count;
|
||||
|
||||
/// <summary>
|
||||
/// (a)+(c): every CellPortal of the cell — the visual portal poly's
|
||||
/// stippling/sides/surface, world plane, span, DRAWN verdict, and whether
|
||||
/// the referenced Surface resolves in the dat.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A02016Eu)] // corridor with floor-portals 1/3/5 → 0x011E (#176 anchor)
|
||||
[InlineData(0x8A02011Eu)] // the under-hall at z=−12 those portals lead to
|
||||
[InlineData(0x8A02017Au)] // adjacent corridor cell (the #137 seam partner)
|
||||
[InlineData(0x8A020182u)] // stair transit upper cell, z −6 (#177 anchor)
|
||||
[InlineData(0x8A020183u)] // stair transit lower cell, z −9 (#177 anchor)
|
||||
public void PortalPolys_SurfaceAndDrawVerdict_Dump(uint cellId)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var loaded = LoadCell(dats, cellId);
|
||||
Assert.NotNull(loaded);
|
||||
var (cell, cs) = loaded!.Value;
|
||||
var world = WorldTransform(cell);
|
||||
|
||||
_out.WriteLine($"=== 0x{cellId:X8} Env=0x{cell.EnvironmentId:X4} struct={cell.CellStructure} " +
|
||||
$"pos=({cell.Position.Origin.X:F2},{cell.Position.Origin.Y:F2},{cell.Position.Origin.Z:F2}) ===");
|
||||
_out.WriteLine($" Surfaces ({cell.Surfaces.Count}): " +
|
||||
string.Join(" ", cell.Surfaces.ConvertAll(s => $"0x{0x08000000u | (uint)s:X8}")));
|
||||
_out.WriteLine($" visualPolys={cs.Polygons.Count} physicsPolys={cs.PhysicsPolygons.Count} portals={cell.CellPortals.Count}");
|
||||
|
||||
// #177 pivot check: dungeon staircases are often EnvCell STATICS (the
|
||||
// #119 tower class) — if one lives here, the vanish subject is the
|
||||
// static's cull, not the shell flood.
|
||||
_out.WriteLine($" StaticObjects={cell.StaticObjects.Count}");
|
||||
foreach (var so in cell.StaticObjects)
|
||||
_out.WriteLine($" static id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2})");
|
||||
|
||||
foreach (var p in cell.CellPortals)
|
||||
{
|
||||
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly))
|
||||
{
|
||||
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] NOT IN VISUAL TABLE");
|
||||
continue;
|
||||
}
|
||||
|
||||
var w = WorldVerts(cs, poly, world);
|
||||
var n = w.Count >= 3
|
||||
? Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]))
|
||||
: Vector3.Zero;
|
||||
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
|
||||
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
|
||||
|
||||
bool drawn = WouldDraw(poly, cell);
|
||||
string surfInfo = "posSurf=OUT-OF-RANGE";
|
||||
if (poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count)
|
||||
{
|
||||
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||
var surface = dats.Get<Surface>(surfaceId);
|
||||
surfInfo = surface is null
|
||||
? $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} SURFACE-MISS"
|
||||
: $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} type={surface.Type} origTex=0x{(uint)surface.OrigTextureId:X8}";
|
||||
}
|
||||
|
||||
_out.WriteLine(
|
||||
$" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " +
|
||||
$"stip={poly.Stippling} sides={poly.SidesType} verts={poly.VertexIds.Count} " +
|
||||
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) " +
|
||||
$"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " +
|
||||
$"{surfInfo} DRAWN={drawn}");
|
||||
}
|
||||
|
||||
// (c) sweep: any DRAWN poly in the whole cell whose surface misses.
|
||||
int drawnCount = 0, missCount = 0;
|
||||
foreach (var (id, poly) in cs.Polygons)
|
||||
{
|
||||
if (!WouldDraw(poly, cell)) continue;
|
||||
drawnCount++;
|
||||
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||
if (dats.Get<Surface>(surfaceId) is null)
|
||||
{
|
||||
missCount++;
|
||||
_out.WriteLine($" >>> DRAWN poly {id} has MISSING surface 0x{surfaceId:X8}");
|
||||
}
|
||||
}
|
||||
_out.WriteLine($" drawn-poly sweep: {drawnCount} drawn, {missCount} with missing surfaces");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (a) reciprocal check: for each anchor pair, world-transform BOTH
|
||||
/// sides' portal polys and test plane coincidence + both-drawn — the
|
||||
/// #176 z-fight candidate is a coincident pair with DRAWN=true twice.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A02016Eu, 0x8A02011Eu)]
|
||||
[InlineData(0x8A02016Eu, 0x8A02017Au)]
|
||||
[InlineData(0x8A020182u, 0x8A020183u)]
|
||||
public void ReciprocalPortalPolys_CoincidenceAndDrawVerdict(uint cellA, uint cellB)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var la = LoadCell(dats, cellA);
|
||||
var lb = LoadCell(dats, cellB);
|
||||
Assert.NotNull(la);
|
||||
Assert.NotNull(lb);
|
||||
var (ca, csa) = la!.Value;
|
||||
var (cb, csb) = lb!.Value;
|
||||
var wa = WorldTransform(ca);
|
||||
var wb = WorldTransform(cb);
|
||||
|
||||
ushort lowA = (ushort)(cellA & 0xFFFFu);
|
||||
ushort lowB = (ushort)(cellB & 0xFFFFu);
|
||||
|
||||
_out.WriteLine($"=== reciprocal pair 0x{cellA:X8} <-> 0x{cellB:X8} ===");
|
||||
foreach (var pa in ca.CellPortals)
|
||||
{
|
||||
if (pa.OtherCellId != lowB) continue;
|
||||
if (!csa.Polygons.TryGetValue((ushort)pa.PolygonId, out var polyA)) continue;
|
||||
var va = WorldVerts(csa, polyA, wa);
|
||||
if (va.Count < 3) continue;
|
||||
var na = Vector3.Normalize(Vector3.Cross(va[1] - va[0], va[2] - va[0]));
|
||||
float da = Vector3.Dot(na, va[0]);
|
||||
bool drawnA = WouldDraw(polyA, ca);
|
||||
|
||||
_out.WriteLine($" A poly={pa.PolygonId} [{pa.Flags}] n=({na.X:F2},{na.Y:F2},{na.Z:F2}) planeD={da:F2} " +
|
||||
$"stip={polyA.Stippling} sides={polyA.SidesType} DRAWN={drawnA}");
|
||||
|
||||
foreach (var pb in cb.CellPortals)
|
||||
{
|
||||
if (pb.OtherCellId != lowA) continue;
|
||||
if (!csb.Polygons.TryGetValue((ushort)pb.PolygonId, out var polyB)) continue;
|
||||
var vb = WorldVerts(csb, polyB, wb);
|
||||
if (vb.Count < 3) continue;
|
||||
var nb = Vector3.Normalize(Vector3.Cross(vb[1] - vb[0], vb[2] - vb[0]));
|
||||
float db = Vector3.Dot(nb, vb[0]);
|
||||
bool drawnB = WouldDraw(polyB, cb);
|
||||
|
||||
float align = Vector3.Dot(na, nb);
|
||||
// Coincident planes: |align|≈1 and same plane offset (sign per normal direction).
|
||||
bool coincident = MathF.Abs(align) > 0.99f
|
||||
&& MathF.Abs(MathF.Abs(da) - MathF.Abs(db)) < 0.05f;
|
||||
|
||||
_out.WriteLine($" B poly={pb.PolygonId} [{pb.Flags}] n=({nb.X:F2},{nb.Y:F2},{nb.Z:F2}) planeD={db:F2} " +
|
||||
$"stip={polyB.Stippling} sides={polyB.SidesType} DRAWN={drawnB} " +
|
||||
$"align={align:F3} coincident={coincident}" +
|
||||
(coincident && drawnA && drawnB ? " >>> Z-FIGHT CANDIDATE (both drawn, same plane)" : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #176 THE STRIPES (user screenshot, 2026-07-06 evening): a floor region
|
||||
/// z-fights in regular bands between a purple-lit copy and an unlit copy —
|
||||
/// two COINCIDENT DRAWN surfaces with different per-cell light sets. This
|
||||
/// sweep hunts the pair in the dat: every pair of DRAWN polys across the
|
||||
/// corridor neighborhood that is coplanar AND overlapping in area. Before
|
||||
/// the light-cap fix both copies were usually equally unlit (the purple
|
||||
/// portal light was cap-evicted) so the fight was invisible; the stable
|
||||
/// light exposed it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// Seed cells around the screenshot location (the 016E/017A seam) +
|
||||
// one portal ring.
|
||||
var cellIds = new HashSet<uint> { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au };
|
||||
foreach (var seed in new List<uint>(cellIds))
|
||||
{
|
||||
var seedCell = dats.Get<EnvCell>(seed);
|
||||
if (seedCell is null) continue;
|
||||
foreach (var p in seedCell.CellPortals)
|
||||
cellIds.Add(0x8A020000u | p.OtherCellId);
|
||||
}
|
||||
|
||||
// Collect all DRAWN polys world-space per cell.
|
||||
var drawn = new List<(uint CellId, ushort PolyId, Vector3 N, float D,
|
||||
Vector3 Min, Vector3 Max, uint SurfaceId)>();
|
||||
foreach (var cellId in cellIds)
|
||||
{
|
||||
var loaded = LoadCell(dats, cellId);
|
||||
if (loaded is null) continue;
|
||||
var (cell, cs) = loaded.Value;
|
||||
var world = WorldTransform(cell);
|
||||
|
||||
foreach (var (id, poly) in cs.Polygons)
|
||||
{
|
||||
if (!WouldDraw(poly, cell)) continue;
|
||||
var w = WorldVerts(cs, poly, world);
|
||||
if (w.Count < 3) continue;
|
||||
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||
float d = Vector3.Dot(n, w[0]);
|
||||
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
|
||||
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
|
||||
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||
drawn.Add((cellId, id, n, d, min, max, surfaceId));
|
||||
}
|
||||
}
|
||||
_out.WriteLine($"cells={cellIds.Count} drawnPolys={drawn.Count}");
|
||||
|
||||
int pairs = 0;
|
||||
for (int i = 0; i < drawn.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < drawn.Count; j++)
|
||||
{
|
||||
var a = drawn[i]; var b = drawn[j];
|
||||
if (a.CellId == b.CellId && a.PolyId == b.PolyId) continue;
|
||||
|
||||
float align = Vector3.Dot(a.N, b.N);
|
||||
if (MathF.Abs(align) < 0.999f) continue;
|
||||
float dB = align > 0 ? b.D : -b.D;
|
||||
if (MathF.Abs(a.D - dB) > 0.02f) continue; // same plane within 2 cm
|
||||
|
||||
// Overlap in world AABB, with meaningful area in the plane.
|
||||
float ox = MathF.Min(a.Max.X, b.Max.X) - MathF.Max(a.Min.X, b.Min.X);
|
||||
float oy = MathF.Min(a.Max.Y, b.Max.Y) - MathF.Max(a.Min.Y, b.Min.Y);
|
||||
float oz = MathF.Min(a.Max.Z, b.Max.Z) - MathF.Max(a.Min.Z, b.Min.Z);
|
||||
if (ox < 0.05f || oy < 0.05f) continue;
|
||||
// For horizontal planes require XY overlap area; for walls allow thin Z.
|
||||
bool horizontal = MathF.Abs(a.N.Z) > 0.85f;
|
||||
if (horizontal && ox * oy < 0.05f) continue;
|
||||
if (!horizontal && oz < 0.05f) continue;
|
||||
|
||||
pairs++;
|
||||
_out.WriteLine(
|
||||
$">>> COPLANAR-OVERLAP {(a.CellId == b.CellId ? "SAME-CELL" : "CROSS-CELL")}: " +
|
||||
$"0x{a.CellId:X8} poly {a.PolyId} (surf 0x{a.SurfaceId:X8}) <-> " +
|
||||
$"0x{b.CellId:X8} poly {b.PolyId} (surf 0x{b.SurfaceId:X8}) " +
|
||||
$"n=({a.N.X:F2},{a.N.Y:F2},{a.N.Z:F2}) align={align:F3} " +
|
||||
$"overlap x={ox:F2} y={oy:F2} z=[{MathF.Max(a.Min.Z, b.Min.Z):F2}..{MathF.Min(a.Max.Z, b.Max.Z):F2}]");
|
||||
}
|
||||
}
|
||||
_out.WriteLine($"coplanar overlapping drawn pairs: {pairs}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE
|
||||
/// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps
|
||||
/// alpha as-sampled). If the corridor floor texture decodes with alpha
|
||||
/// below 1.0, MSAA coverage punches see-through holes in the floor —
|
||||
/// fog-purple clear color — worst at grazing angles (mip-level dependent
|
||||
/// → camera-angle dependent, far floor = at seams). Decode the floor
|
||||
/// surface chain and histogram the alpha channel.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x08000377u)] // corridor floor (portal strips + plain floors)
|
||||
[InlineData(0x08000376u)]
|
||||
[InlineData(0x08000375u)]
|
||||
[InlineData(0x08000378u)]
|
||||
[InlineData(0x08000379u)] // under-level walls
|
||||
[InlineData(0x080000DFu)] // stair-transit cells' 5th surface
|
||||
public void FloorSurface_DecodedAlphaHistogram(uint surfaceId)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var surface = dats.Get<Surface>(surfaceId);
|
||||
Assert.NotNull(surface);
|
||||
_out.WriteLine($"Surface 0x{surfaceId:X8}: type={surface!.Type} origTex=0x{(uint)surface.OrigTextureId:X8} " +
|
||||
$"transl={surface.Translucency:F2}");
|
||||
if (surface.OrigTextureId == 0) { _out.WriteLine(" (no texture — solid color surface)"); return; }
|
||||
|
||||
var surfTex = dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
|
||||
Assert.NotNull(surfTex);
|
||||
_out.WriteLine($" SurfaceTexture 0x{(uint)surface.OrigTextureId:X8}: {surfTex!.Textures.Count} textures " +
|
||||
$"[{string.Join(" ", surfTex.Textures.ConvertAll(t => $"0x{t:X8}"))}]");
|
||||
|
||||
foreach (var texId in surfTex.Textures)
|
||||
{
|
||||
var rs = dats.Get<RenderSurface>((uint)texId);
|
||||
if (rs is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: MISS"); continue; }
|
||||
|
||||
// Decode with the production Core helpers (same paths the WB atlas uses).
|
||||
var data = new byte[rs.Width * rs.Height * 4];
|
||||
bool decodedOk = true;
|
||||
switch (rs.Format)
|
||||
{
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||
{
|
||||
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
|
||||
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: INDEX16 with no palette 0x{rs.DefaultPaletteId:X8}"); decodedOk = false; break; }
|
||||
AcDream.Core.Rendering.Wb.TextureHelpers.FillIndex16(rs.SourceData, pal, data, rs.Width, rs.Height);
|
||||
break;
|
||||
}
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||
{
|
||||
var pal = dats.Get<Palette>(rs.DefaultPaletteId);
|
||||
if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: P8 with no palette"); decodedOk = false; break; }
|
||||
AcDream.Core.Rendering.Wb.TextureHelpers.FillP8(rs.SourceData, pal, data, rs.Width, rs.Height);
|
||||
break;
|
||||
}
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||
AcDream.Core.Rendering.Wb.TextureHelpers.FillR5G6B5(rs.SourceData, data, rs.Width, rs.Height);
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||
AcDream.Core.Rendering.Wb.TextureHelpers.FillA4R4G4B4(rs.SourceData, data, rs.Width, rs.Height);
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||
AcDream.Core.Rendering.Wb.TextureHelpers.FillA8R8G8B8(rs.SourceData, data, rs.Width, rs.Height);
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||
AcDream.Core.Rendering.Wb.TextureHelpers.FillR8G8B8(rs.SourceData, data, rs.Width, rs.Height);
|
||||
break;
|
||||
case DatReaderWriter.Enums.PixelFormat.PFID_DXT1:
|
||||
{
|
||||
// DXT1/BC1: 8-byte blocks — c0 (u16 LE), c1 (u16 LE), 16×2-bit
|
||||
// indices. c0 <= c1 selects 3-COLOR mode where index 3 decodes
|
||||
// to TRANSPARENT BLACK (alpha=0). Our atlas uploads DXT1 as the
|
||||
// RGBA variant (TextureFormatExtensions.ToCompressedGL), so any
|
||||
// such texel reaches the shader with alpha=0 — and the opaque
|
||||
// pass discards alpha<0.05 fragments. Count them.
|
||||
int blocks = rs.SourceData.Length / 8;
|
||||
int threeColorBlocks = 0;
|
||||
long transparentTexels = 0;
|
||||
for (int b = 0; b < blocks; b++)
|
||||
{
|
||||
int o = b * 8;
|
||||
ushort c0 = (ushort)(rs.SourceData[o] | (rs.SourceData[o + 1] << 8));
|
||||
ushort c1 = (ushort)(rs.SourceData[o + 2] | (rs.SourceData[o + 3] << 8));
|
||||
if (c0 > c1) continue; // 4-color opaque mode
|
||||
threeColorBlocks++;
|
||||
for (int bi = 0; bi < 4; bi++)
|
||||
{
|
||||
byte row = rs.SourceData[o + 4 + bi];
|
||||
for (int t = 0; t < 4; t++)
|
||||
if (((row >> (t * 2)) & 0x3) == 3)
|
||||
transparentTexels++;
|
||||
}
|
||||
}
|
||||
_out.WriteLine($" RenderSurface 0x{texId:X8} DXT1 {rs.Width}x{rs.Height}: blocks={blocks} " +
|
||||
$"threeColorBlocks={threeColorBlocks} alpha0Texels={transparentTexels}" +
|
||||
(transparentTexels > 0
|
||||
? " >>> ALPHA=0 TEXELS PRESENT (opaque-pass discard punches holes)"
|
||||
: " (no transparent-mode texels)"));
|
||||
decodedOk = false; // histogram printed above; skip the RGBA path
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_out.WriteLine($" RenderSurface 0x{texId:X8}: fmt={rs.Format} (not decoded by this test)");
|
||||
decodedOk = false;
|
||||
break;
|
||||
}
|
||||
if (!decodedOk) continue;
|
||||
|
||||
// Alpha histogram over the decoded RGBA bytes (stride 4, alpha at +3).
|
||||
int n = data.Length / 4;
|
||||
int a255 = 0, aHigh = 0, aMid = 0, aLow = 0, a0 = 0;
|
||||
byte minA = 255;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
byte a = data[i * 4 + 3];
|
||||
if (a < minA) minA = a;
|
||||
if (a == 255) a255++;
|
||||
else if (a >= 243) aHigh++; // ≥0.95 — safe for A2C
|
||||
else if (a >= 13) aMid++; // 0.05..0.95 — partial coverage
|
||||
else if (a > 0) aLow++;
|
||||
else a0++;
|
||||
}
|
||||
_out.WriteLine($" RenderSurface 0x{texId:X8} fmt={rs.Format} {rs.Width}x{rs.Height}: " +
|
||||
$"alpha histogram n={n} a=255:{a255} 243-254:{aHigh} 13-242:{aMid} 1-12:{aLow} 0:{a0} minA={minA}" +
|
||||
(aMid + aLow + a0 > 0 ? " >>> SUB-OPAQUE ALPHA PRESENT (A2C hole candidate)" : " (fully opaque)"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #176 candidate: the under-hall 0x011E floods in at down-pitches and
|
||||
/// its surface list carries 0x08000034 (Base1Solid|Translucent — a
|
||||
/// COLORED translucent solid). If its drawn polys sit at z=−6 (coplanar
|
||||
/// with the corridor floor), the transparent pass blends that color over
|
||||
/// the floor whenever the under-hall is admitted — angle-dependent
|
||||
/// purple at seams. Dump every DRAWN poly (plane, z-span, surface, and
|
||||
/// the surface's ColorValue) of the under-hall + its under-level
|
||||
/// neighbors.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A02011Eu)]
|
||||
[InlineData(0x8A020119u)]
|
||||
[InlineData(0x8A02011Du)]
|
||||
[InlineData(0x8A020122u)]
|
||||
[InlineData(0x8A02011Fu)]
|
||||
[InlineData(0x8A02016Eu)] // corridor cells — the striped-floor screenshot area
|
||||
[InlineData(0x8A02017Au)]
|
||||
[InlineData(0x8A020165u)]
|
||||
public void UnderHall_DrawnPolys_SurfaceColors(uint cellId)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var loaded = LoadCell(dats, cellId);
|
||||
if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; }
|
||||
var (cell, cs) = loaded.Value;
|
||||
var world = WorldTransform(cell);
|
||||
|
||||
_out.WriteLine($"=== 0x{cellId:X8} drawn polys ===");
|
||||
foreach (var (id, poly) in cs.Polygons)
|
||||
{
|
||||
if (!WouldDraw(poly, cell)) continue;
|
||||
var w = WorldVerts(cs, poly, world);
|
||||
if (w.Count < 3) continue;
|
||||
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||
float minZ = float.MaxValue, maxZ = float.MinValue;
|
||||
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
|
||||
|
||||
uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface];
|
||||
var surface = dats.Get<Surface>(surfaceId);
|
||||
string surfInfo = surface is null
|
||||
? $"0x{surfaceId:X8} MISS"
|
||||
: $"0x{surfaceId:X8} type={surface.Type} color=0x{surface.ColorValue:X8} origTex=0x{(uint)surface.OrigTextureId:X8} lum={surface.Luminosity:F2} transl={surface.Translucency:F2}";
|
||||
_out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
|
||||
$"verts={poly.VertexIds.Count} surf={surfInfo}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transit-lag question (#176/#177 root-cause fork): production
|
||||
/// [cell-transit] lines fire 0.1–0.6 m PAST the portal plane. Is that
|
||||
/// (a) dat-real — the cells' CellBSP volumes OVERLAP past the plane, so
|
||||
/// retail's point_in_cell (same dat, same walk) keeps the old cell too —
|
||||
/// or (b) our membership's bug? Probe raw CellBSP containment for both
|
||||
/// cells of each seam across the portal plane.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A02016Eu, 0x8A02017Au, 85.00f, -40f, -5.0f)]
|
||||
[InlineData(0x8A020182u, 0x8A020183u, 98.333f, -40f, -7.5f)]
|
||||
public void SeamCells_CellBspContainment_AcrossPortalPlane(
|
||||
uint cellAId, uint cellBId, float planeX, float y, float z)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var la = LoadCell(dats, cellAId);
|
||||
var lb = LoadCell(dats, cellBId);
|
||||
Assert.NotNull(la);
|
||||
Assert.NotNull(lb);
|
||||
var (ca, csa) = la!.Value;
|
||||
var (cb, csb) = lb!.Value;
|
||||
|
||||
Matrix4x4.Invert(WorldTransform(ca), out var invA);
|
||||
Matrix4x4.Invert(WorldTransform(cb), out var invB);
|
||||
|
||||
_out.WriteLine($"=== CellBSP containment across plane x={planeX:F2} " +
|
||||
$"(A=0x{cellAId:X8}, B=0x{cellBId:X8}) ===");
|
||||
for (float dx = -0.6f; dx <= 0.65f; dx += 0.05f)
|
||||
{
|
||||
var world = new Vector3(planeX + dx, y, z);
|
||||
bool inA = csa.CellBSP?.Root is not null
|
||||
&& Physics.PointInCellBspViaBspQuery(csa.CellBSP.Root, Vector3.Transform(world, invA));
|
||||
bool inB = csb.CellBSP?.Root is not null
|
||||
&& Physics.PointInCellBspViaBspQuery(csb.CellBSP.Root, Vector3.Transform(world, invB));
|
||||
_out.WriteLine($" x=plane{(dx >= 0 ? "+" : "")}{dx:F2} inA={(inA ? "Y" : "-")} inB={(inB ? "Y" : "-")}" +
|
||||
(inA && inB ? " <<< OVERLAP" : !inA && !inB ? " <<< NEITHER" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
private static class Physics
|
||||
{
|
||||
// Thin forwarder so this Rendering-side test reads clearly; the walk
|
||||
// is the production BSPQuery.PointInsideCellBsp.
|
||||
public static bool PointInCellBspViaBspQuery(
|
||||
DatReaderWriter.Types.CellBSPNode node, Vector3 localPoint)
|
||||
=> AcDream.Core.Physics.BSPQuery.PointInsideCellBsp(node, localPoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (b) #177: which cell owns the stair-step geometry? Histogram of DRAWN
|
||||
/// visual polys by normal class + the z-ladder of horizontal polys
|
||||
/// (stair steps show as a ladder of small floor polys at stepped
|
||||
/// z-levels). Also lists every portal with its plane orientation —
|
||||
/// is the 0x0182↔0x0183 connection a floor-portal or a wall-portal?
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x8A020182u)]
|
||||
[InlineData(0x8A020183u)]
|
||||
public void StairTransit_GeometryOwnerAndPortalOrientation(uint cellId)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var loaded = LoadCell(dats, cellId);
|
||||
Assert.NotNull(loaded);
|
||||
var (cell, cs) = loaded!.Value;
|
||||
var world = WorldTransform(cell);
|
||||
|
||||
int floors = 0, ceilings = 0, walls = 0, inclined = 0;
|
||||
var floorZLevels = new SortedDictionary<int, int>(); // z rounded to 0.1 m → count
|
||||
|
||||
foreach (var (id, poly) in cs.Polygons)
|
||||
{
|
||||
var w = WorldVerts(cs, poly, world);
|
||||
if (w.Count < 3) continue;
|
||||
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||
float az = MathF.Abs(n.Z);
|
||||
if (az > 0.85f)
|
||||
{
|
||||
// Horizontal poly — bucket by mean z.
|
||||
float meanZ = 0; foreach (var v in w) meanZ += v.Z; meanZ /= w.Count;
|
||||
int zKey = (int)MathF.Round(meanZ * 10f);
|
||||
floorZLevels.TryGetValue(zKey, out var c);
|
||||
floorZLevels[zKey] = c + 1;
|
||||
if (n.Z > 0) floors++; else ceilings++;
|
||||
}
|
||||
else if (az < 0.25f) walls++;
|
||||
else
|
||||
{
|
||||
inclined++;
|
||||
float minZ = float.MaxValue, maxZ = float.MinValue;
|
||||
foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); }
|
||||
_out.WriteLine($" INCLINED poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " +
|
||||
$"verts={poly.VertexIds.Count} stip={poly.Stippling} DRAWN={WouldDraw(poly, cell)}");
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"=== 0x{cellId:X8} poly histogram: floors={floors} ceilings={ceilings} walls={walls} inclined={inclined} ===");
|
||||
_out.WriteLine(" horizontal-poly z-ladder (z → count): " +
|
||||
string.Join(" ", System.Linq.Enumerable.Select(floorZLevels, kv => $"{kv.Key / 10f:F1}:{kv.Value}")));
|
||||
|
||||
foreach (var p in cell.CellPortals)
|
||||
{
|
||||
if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) continue;
|
||||
var w = WorldVerts(cs, poly, world);
|
||||
if (w.Count < 3) continue;
|
||||
var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0]));
|
||||
string orient = MathF.Abs(n.Z) > 0.85f ? "HORIZONTAL (floor/ceiling portal)"
|
||||
: MathF.Abs(n.Z) < 0.25f ? "vertical (wall portal)"
|
||||
: "INCLINED portal";
|
||||
var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue);
|
||||
foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); }
|
||||
_out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] {orient} " +
|
||||
$"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue