acdream/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
Erik dbfbf8506c T6 (BR-7) C3: per-cell shadow architecture - flood registration, building channel, per-cell query; b3ce505 stopgap DELETED (closes #99)
The A6.P4 port, fused into one installment per the BR-2 half-port lesson
(registration and query are co-dependent: flood-registering shells under
the old radial query would re-open #98 through the vestibule).

REGISTRATION (ShadowObjectRegistry rewritten):
- Register/RegisterMultiPart/UpdatePosition compute the cell set via
  CellTransit.BuildShadowCellSet (the C2 find_cell_list flood) seeded by
  the entity's m_position cell id; the private 24m XY-grid rectangle and
  its single-landblock clamp are deleted. Flood spheres follow retail's
  CylSphere rule (base point + cyl radius, cap 10; BSP bounding-sphere
  fallback - Ghidra 0x0052b9f0). Statics flood with the do_not_load
  prune; dynamics (server spawns, isStatic:false) without.
- Keep-when-empty (SetPositionInternal num_cells gate, pc:283540): a
  failed flood leaves the previous registration in place.
- RefloodLandblock: streaming-race hook re-runs the flood when a
  landblock's cells hydrate (retail init_objects -> recalc_cross_cells,
  Ghidra 0x0052b420/0x00515a30); wired at GameWindow's hydration tail.
- GameWindow sites pass the server position's full cell id as the seed
  (spawn + UpdatePosition); the five static sites pass ParentCellId.

BUILDING CHANNEL (CSortCell.building shape):
- Building SHELLS are not shadow objects in retail (only caller of
  find_building_collisions is CSortCell::find_collisions 0x005340aa;
  one building per origin landcell, init_buildings 0x0052fd80 verified
  verbatim + ACE cross-ref). IsBuildingShell entities skip the registry;
  Transition.FindBuildingCollisions runs the shell part-0 BSP off
  cache.GetBuilding(cellId) with bldg_check set around it
  (find_building_collisions 0x006b5300), CollidedWithEnvironment on
  non-Contact non-OK. BuildingPhysics.ModelId = pre-resolved part-0
  GfxObj (0x02 Setups resolved at the CacheBuilding site).
- Placement/ethereal weakening: BSPQuery Path 1 passes center_solid=0
  when BldgCheck && HitsInteriorCell (BSPTREE::find_collisions 0x0053a82e
  + placement_insert 0x005399d8) so doorway crossings don't hard-fail
  against shell solids. SpherePath gains both retail fields;
  HitsInteriorCell is rebuilt at every cell-array build
  (build_cell_array reset 0x00509ef2 + find_cell_list/check_building_
  transit set sites).

QUERY (retail per-cell order, transitional_insert 0x0050b6f0):
- TransitionalInsert per attempt: env -> building (LandCell only) ->
  objects on the PRIMARY cell, then on OK the check_other_cells pass
  (env -> building -> objects per OTHER overlapped cell) + the
  carried-cell advance - the advance now happens AFTER all per-cell
  object passes (the WF1 ordering divergence), with Adjusted/Slid
  feeding the retry exactly like retail's OK_TS case.
- FindObjCollisionsInCell = CObjCell::find_obj_collisions (0x0052b750):
  iterate ONLY the asked cell's list. DELETED: the radial 9-landblock
  sweep, the +5m query pad, the b3ce505 indoor-primary gate, and the
  isViewer exemption (the camera is bounded by interior cell-BSP env
  collision - retail's own channel; CameraCornerSealReplayTests pins it
  against real dat, and the new building-channel camera test pins the
  outdoor stop).

TESTS: Core 1416/0/2 (was 1398 + 4 pre-existing #99-era fails + 1 skip),
App 225, UI 420, Net 294 - all green.
- 3 of the 4 #99-era reds flipped green as designed: the door apparatus
  (Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks) and tick-13558
  (indoor walkthrough) now assert the door BLOCKS; tick-22760 pins the
  outdoor blocking invariant.
- The 4th (BSPStepUp D4) + 22760's lateral-slide delta are NOT cell-set
  problems (probes prove the door is found + BSP-only dispatched;
  BR-7 left both byte-identical) - filed as issue #116 (slide-response
  family), D4 skipped with the issue reference.
- FindEnvCollisionsMultiCellTests migrated to the public entry (the A4
  multi-cell halt now lives at the retail call site).
- New registry pins: per-cell query surface, outdoor-footprint-never-
  indoor (#98 architectural), door-outdoor-cell membership, reflood.
- CameraCollisionIndoorTests rewritten against the building channel
  (the isViewer-exemption pins died with the exemption).

Closes #99 (doors block both ways via registration-time cell membership
+ the straddle-spanning player cell array). #97 likely closed (the +5m
radial pad that produced phantom-collision candidates is gone) - verify
at T5. #98 stays closed ARCHITECTURALLY (outdoor footprints structurally
cannot reach interior cells; the cellar harness stays green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:37:50 +02:00

1315 lines
59 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// A6.P3 issue #98 (2026-05-23) — deterministic TRAJECTORY replay
/// harness for the cottage cellar-ascent failure. Drives
/// <see cref="PhysicsEngine.ResolveWithTransition"/> through N physics
/// ticks against pre-loaded cell fixtures, capturing a per-tick
/// trajectory record.
///
/// <para>
/// Unlike <see cref="Issue98CellarUpReplayTests"/> (which tests a SINGLE
/// failing-frame's geometry against our walkable predicates), this
/// harness drives MANY ticks through the full engine to reproduce the
/// trajectory itself — once the fixtures support it (see below).
/// </para>
///
/// <h3>Status as of 2026-05-23 evening: harness mechanics WORK, fixtures
/// INCOMPLETE.</h3>
///
/// <para>
/// The harness compiles and runs the engine through N ticks in
/// &lt; 100 ms total. Two findings during commissioning:
/// </para>
///
/// <list type="number">
/// <item>The three issue-#98 cell fixtures
/// (<c>tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40*.json</c>)
/// contain ONLY axis-aligned polygons — cellar floor, cellar
/// ceiling, four cellar walls, cottage floor, cottage walls. The
/// live capture's CELLAR RAMP polygon
/// (normal ≈ <c>(0, ±0.719, 0.695)</c>) is NOT in any of the
/// fixtures. Without it the harness can't reproduce the climb
/// trajectory — the sphere walks across the cellar floor
/// horizontally and never encounters a slope.</item>
/// <item>Independently: at the sphere's initial position resting on
/// the cellar floor, the engine reports
/// <c>hit=yes n=(0,0,1) walkable=False</c> on tick 1 and rejects
/// the forward move. The grounded state flips off and subsequent
/// ticks proceed as airborne (no Z change). This may be a real
/// engine bug (touching the floor classified as non-walkable
/// collision) or a fixture issue (cellar floor poly's
/// containment test mis-firing). Either way, the harness
/// exposes it deterministically — that's the point.</item>
/// </list>
///
/// <para>
/// <b>Before this harness can drive issue-#98 trajectory fix attempts,
/// the fixtures need a re-capture</b> that includes:
/// </para>
///
/// <list type="bullet">
/// <item>The cellar ramp polygon (whichever cell it actually lives
/// in — the live capture said cellar cell <c>0xA9B40147</c>,
/// but our dump doesn't have it; investigate
/// <see cref="CellDumpSerializer"/> to see whether some
/// polygons are being skipped during capture).</item>
/// <item>Any neighboring cells the sphere may transit into during
/// the climb (the live capture's
/// <c>[cell-set-summary]</c> showed overlap with
/// <c>0xA9B40143</c> and <c>0xA9B40146</c>, both already in
/// the fixture set — but additional cells beyond these may
/// appear at tick boundaries we haven't observed).</item>
/// </list>
///
/// <para>
/// The current tests document the harness mechanics + the two
/// findings above. When fixtures are re-captured, flip
/// <see cref="CellarUp_FreezesAtRampTop_DocumentsBug"/>'s assertion
/// to require a successful climb and add additional tests for the
/// trajectory shape.
/// </para>
/// </summary>
public class CellarUpTrajectoryReplayTests : IDisposable
{
// ── T0: constructor + Dispose reset PhysicsResolveCapture static ────────
// xUnit creates a fresh instance per test, so the constructor runs before
// each test and Dispose runs after. Resetting here guarantees that no
// prior test in this class can leave CapturePath set (which would cause
// Capture_SkipsNonPlayerCalls to find an unexpected file).
public CellarUpTrajectoryReplayTests()
{
PhysicsResolveCapture.ResetForTest();
PhysicsDiagnostics.ResetForTest();
}
public void Dispose()
{
PhysicsResolveCapture.ResetForTest();
PhysicsDiagnostics.ResetForTest();
}
// ── Cellar / cottage geometry constants ────────────────────────
private const uint CellarId = 0xA9B40147u;
private const uint CottageNeighborA = 0xA9B40143u;
private const uint CottageNeighborB = 0xA9B40146u;
private const float CellarFloorZ = 90.95f;
private const float CottageFloorZ = 94.00f;
private const float SphereRadius = 0.48f;
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.60f;
private const float StepDownHeight = 0.04f;
/// <summary>
/// Sphere center starts exactly at its natural resting position on
/// the cellar floor: bottom on floor, center at Z = floor + radius.
/// Y=9.5 is ~0.75 m before the ramp foot at Y=8.75 (live-capture
/// ramp plane: <c>0.719·y + 0.695·z = 69.5035</c> → y=8.75 at z=90.95).
/// X=141.5 matches the live capture's X.
/// </summary>
private static readonly Vector3 InitialSphereWorld =
new(141.5f, 9.5f, CellarFloorZ + SphereRadius);
/// <summary>
/// Per-tick forward offset (Y direction toward the ramp).
/// Magnitude (~0.10 m) matches the live capture's observed per-tick
/// requested offset.
/// </summary>
private static readonly Vector3 PerTickOffset =
new(0f, -0.10f, 0f);
private const int SimulationTicks = 200;
// ───────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────
/// <summary>
/// Indoor-flap root cause (2026-06-08). A body resting on the cellar
/// floor with ZERO requested motion must hold a byte-identical position
/// across many ticks — retail's authoritative local position is bit-stable
/// at rest (validate_transition → kill_velocity + SetWalkable on every
/// grounded contact, decomp :272567/:274467).
///
/// <para>
/// The indoor render "flap" (textures battling at the cottage doorway) is
/// portal-flood membership instability. PortalVisibilityBuilder.Build is a
/// proven-deterministic pure function, so the membership can only flip if its
/// INPUT (the camera eye, from the player RenderPosition) varies.
/// RenderPosition = Lerp(_prevPhysicsPos, _currPhysicsPos), and Lerp(a,a,t)==a,
/// so a jittering eye at rest means the physics body's resting Position is not
/// bit-stable. Flat LandCell terrain rest IS bit-stable
/// (<see cref="AcDream.Core.Tests.Input.PlayerMovementControllerTests"/>.
/// Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames passes); the
/// instability is the INDOOR path — the floor-touch is classified
/// walkable=False (no walkable-polygon anchor), so each tick re-fires a
/// step-down probe whose re-found Z is not bit-stable.
/// </para>
///
/// PASSES — the indoor resting body is bit-stable even with the
/// grounded/cp=none contradictory state present. This is evidence (with the
/// flat-terrain variant) that the doorway flap is NOT a physics-rest jitter;
/// it is render-side portal-flood membership instability under a sweeping eye.
/// See docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md.
/// The diagnostic log (on any future regression) names the failing per-tick
/// condition. Kept as a regression guard.
/// </summary>
[Fact]
public void IndoorCellarFloor_AtRestZeroOffset_BodyPositionBitStable()
{
var (engine, _) = BuildEngineWithCellarFixtures();
// Body seeded exactly at its natural resting pose on the cellar floor,
// WITH the walkable-polygon + contact-plane anchor (BuildInitialBody) —
// i.e. the most-favourable starting state. If even this drifts, the rest
// path fails to PERSIST the anchor.
var body = BuildInitialBody();
var rest = body.Position;
uint cell = CellarId;
bool grounded = true;
var log = new List<string>();
float maxDrift = 0f;
for (int tick = 1; tick <= 200; tick++)
{
// ZERO requested motion: currentPos == targetPos == rest pose.
var result = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: body.Position,
cellId: cell,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: grounded,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
cell = result.CellId;
grounded = result.IsOnGround;
float drift = (body.Position - rest).Length();
maxDrift = MathF.Max(maxDrift, drift);
if (tick <= 6 || drift > 0f)
{
log.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"tick{0,3}: pos=({1:F7},{2:F7},{3:F7}) drift={4:F3}µm grounded={5} " +
"walkable={6} cpV={7} ts=0x{8:X}",
tick, body.Position.X, body.Position.Y, body.Position.Z,
drift * 1e6f, grounded, body.WalkablePolygonValid,
body.ContactPlaneValid, (uint)body.TransientState));
}
}
Assert.True(maxDrift == 0f,
$"cellar-floor rest drifted {maxDrift * 1e6f:F3} µm (expected byte-identical):\n "
+ string.Join("\n ", log.Take(24)));
}
/// <summary>
/// Indoor-flap investigation (2026-06-08) — the FULL production loop. Drives
/// <see cref="PlayerMovementController"/> (integration + flag logic + velocity,
/// not just the resolver) on the indoor cellar engine with NO input. PASSES —
/// the RenderPosition the camera reads is byte-identical at rest, confirming
/// the flap is not produced by the indoor controller rest loop. Kept as a
/// regression guard. See
/// docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md.
/// </summary>
[Fact]
public void IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var controller = new PlayerMovementController(engine);
controller.SetPosition(InitialSphereWorld, CellarId);
var settled = controller.Update(1f / 60f, new MovementInput());
var basePos = settled.Position;
var baseRender = settled.RenderPosition;
var log = new List<string>();
float maxPos = 0f, maxRender = 0f;
for (int i = 1; i <= 600; i++)
{
var r = controller.Update(1f / 60f, new MovementInput());
float dp = (r.Position - basePos).Length();
float dr = (r.RenderPosition - baseRender).Length();
maxPos = MathF.Max(maxPos, dp);
maxRender = MathF.Max(maxRender, dr);
if (i <= 4 || dp > 0f || dr > 0f)
{
log.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"f{0,3}: pos=({1:F7},{2:F7},{3:F7}) render=({4:F7},{5:F7},{6:F7}) " +
"grounded={7} cell=0x{8:X8}",
i, r.Position.X, r.Position.Y, r.Position.Z,
r.RenderPosition.X, r.RenderPosition.Y, r.RenderPosition.Z,
r.IsOnGround, r.CellId));
}
}
Assert.True(maxPos == 0f && maxRender == 0f,
$"indoor controller rest drifted: pos={maxPos * 1e6f:F3} µm, "
+ $"render={maxRender * 1e6f:F3} µm (expected byte-identical):\n "
+ string.Join("\n ", log.Take(24)));
}
/// <summary>
/// Confirms the harness compiles, the engine runs the simulation,
/// and a trajectory comes back with the expected number of points.
/// Does NOT assert on trajectory CONTENT — fixture limitations
/// (see class summary) make content-level assertions premature.
/// </summary>
[Fact]
public void Harness_CompilesAndRunsSimulation()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks);
Assert.Equal(SimulationTicks + 1, trajectory.Count);
Assert.Equal(0, trajectory[0].Tick);
Assert.Equal(SimulationTicks, trajectory[^1].Tick);
}
/// <summary>
/// Diagnostic dump: print the first 10 trajectory points + the
/// engine's resolve-probe decisions. Useful when investigating
/// what the harness is actually doing.
/// </summary>
[Fact]
public void Harness_DiagnosticDump_FirstTenTicks()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
try
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, 2);
var msg = "Trajectory (2 ticks):\n " +
string.Join("\n ", trajectory.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}"));
// Always pass — this is a diagnostic test; the probe
// output appears in the test runner's captured stdout
// and the trajectory in the assertion message on failure.
Assert.True(true, msg);
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
}
}
/// <summary>
/// Experiment: drive without a PhysicsBody (no CP seeding, no
/// cross-tick state). Tests whether the airborne-at-tick-1 issue
/// is caused by the seeded CP creating a false collision against
/// the cellar floor.
/// </summary>
[Fact]
public void Harness_DiagnosticDump_NoBodySeed()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
try
{
var (engine, _) = BuildEngineWithCellarFixtures();
uint cellId = CellarId;
bool isOnGround = true;
Vector3 pos = InitialSphereWorld;
var trajectory = new List<TrajectoryPoint>
{
new(0, pos, cellId, isOnGround, false),
};
for (int tick = 1; tick <= 10; tick++)
{
Vector3 target = pos + PerTickOffset;
var result = engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: null, // ← no body, no CP seed
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
trajectory.Add(new(tick, pos, cellId, isOnGround, false));
}
var msg = "No-body trajectory (10 ticks):\n " +
string.Join("\n ", trajectory.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"onGround={p.IsOnGround}"));
Assert.True(true, msg);
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
}
}
/// <summary>
/// Documents the deep-investigation finding (2026-05-23 evening
/// extension): the seeded grounded sphere still goes airborne at
/// tick 1 with hit=(0,1,0) — a +Y wall normal that doesn't match
/// any registered geometry. The hit is set by ValidateTransition
/// after the inner TransitionalInsert returns Collided, but the
/// source of the (0,1,0) inside TransitionalInsert is not yet
/// isolated.
///
/// <para>
/// Investigation excluded:
/// <list type="bullet">
/// <item>Stub landblock terrain (removed; same hit)</item>
/// <item>Synthetic stair GfxObj (removed; same hit)</item>
/// <item>Cell BSP=null on Hydrate (attached synthetic BSP; same hit)</item>
/// <item>WalkablePolygon NOT seeded vs seeded (seeded now: walkable=True survives, but (0,1,0) hit remains)</item>
/// <item>Initial sphere Z lift 0.0 vs 0.05 m (same hit)</item>
/// <item>PhysicsBody seeded vs body=null (same hit)</item>
/// </list>
/// </para>
///
/// <para>
/// Next session's investigation move: build a side-by-side
/// instrumentation harness that calls the EXACT same
/// ResolveWithTransition invocation as production's
/// PlayerMovementController, with identical body state, and
/// compare per-tick state divergence. The harness setup must be
/// missing some piece of state that production carries from a
/// prior live tick — find what piece.
/// </para>
/// </summary>
[Fact]
public void Harness_Finding_SphereGoesAirborneAtTick1()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, 3);
Assert.True(trajectory[0].IsOnGround,
"Tick 0 is the seeded starting state and must report grounded.");
Assert.False(trajectory[1].IsOnGround,
"Open finding: at tick 1 the engine reports the sphere is NOT " +
"grounded, even though it started seeded with ContactPlane + " +
"WalkablePolygon on the cellar floor and the cell has a " +
"synthetic BSP wrapping every polygon. Hit normal is (0,1,0) — " +
"doesn't match any registered geometry. Source of (0,1,0) " +
"inside TransitionalInsert is not yet isolated. See the class " +
"doc for the exclusion list and next investigation move.");
}
/// <summary>
/// Perf budget for the harness: 200 ticks must complete in well
/// under 500 ms. If this ever fails, the inner loop has regressed
/// and the whole point of the harness — fast iteration on physics
/// fixes — is at risk.
/// </summary>
[Fact]
public void Harness_SimulationRunsInUnder500ms()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var sw = System.Diagnostics.Stopwatch.StartNew();
_ = SimulateTicks(engine, body, CellarId, SimulationTicks);
sw.Stop();
Assert.True(sw.ElapsedMilliseconds < 500,
$"200-tick simulation should complete in under 500 ms. " +
$"Took: {sw.ElapsedMilliseconds} ms.");
}
/// <summary>
/// A6.P3 #98 (2026-05-23 evening apparatus extension) — smoke-tests
/// the <see cref="PhysicsResolveCapture"/> probe. Drives 3 ticks with
/// capture enabled, then reads the JSON-Lines file back and verifies:
/// <list type="bullet">
/// <item>One record per call.</item>
/// <item>Inputs round-trip (currentPos, targetPos, cellId, flags).</item>
/// <item>Body-before and body-after snapshots are present.</item>
/// </list>
/// This proves the production probe is wire-correct before we ask the
/// user to run a live capture in the cellar — if this test passes, the
/// only variable left is what's different about the live run.
/// </summary>
[Fact]
public void Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled()
{
string capturePath = Path.Combine(
Path.GetTempPath(),
$"acdream_capture_{Guid.NewGuid():N}.jsonl");
try
{
PhysicsResolveCapture.CapturePath = capturePath;
PhysicsResolveCapture.ResetTickCounter();
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
_ = SimulateTicks(engine, body, CellarId, 3);
PhysicsResolveCapture.Close();
Assert.True(File.Exists(capturePath), "Capture file should exist.");
var lines = File.ReadAllLines(capturePath);
Assert.Equal(3, lines.Length);
var records = lines
.Select(static l => System.Text.Json.JsonSerializer.Deserialize<ResolveCaptureRecord>(
l, CaptureJsonOptions))
.ToList();
// Tick monotonic 0,1,2.
Assert.Equal(0, records[0]!.Tick);
Assert.Equal(1, records[1]!.Tick);
Assert.Equal(2, records[2]!.Tick);
// Inputs at tick 0 must match the harness's initial position.
var firstInput = records[0]!.Input;
Assert.Equal(InitialSphereWorld.X, firstInput.CurrentPos.X, 4);
Assert.Equal(InitialSphereWorld.Y, firstInput.CurrentPos.Y, 4);
Assert.Equal(InitialSphereWorld.Z, firstInput.CurrentPos.Z, 4);
Assert.Equal(CellarId, firstInput.CellId);
Assert.True(firstInput.IsOnGround,
"First tick is seeded grounded.");
// Body before + after snapshots present.
Assert.NotNull(records[0]!.BodyBefore);
Assert.NotNull(records[0]!.BodyAfter);
// Body-before's ContactPlane should match the seeded floor plane.
var cpBefore = records[0]!.BodyBefore!.ContactPlane;
Assert.Equal(0f, cpBefore.Normal.X, 5);
Assert.Equal(0f, cpBefore.Normal.Y, 5);
Assert.Equal(1f, cpBefore.Normal.Z, 5);
Assert.Equal(-CellarFloorZ, cpBefore.D, 3);
}
finally
{
PhysicsResolveCapture.CapturePath = null;
PhysicsResolveCapture.Close();
if (File.Exists(capturePath))
File.Delete(capturePath);
}
}
/// <summary>
/// Capture is filtered to <c>IsPlayer</c> mover flag. Calls without
/// that flag (NPC, remote dead-reckoning) must NOT pollute the
/// capture file.
/// </summary>
[Fact]
public void Capture_SkipsNonPlayerCalls()
{
string capturePath = Path.Combine(
Path.GetTempPath(),
$"acdream_capture_npc_{Guid.NewGuid():N}.jsonl");
try
{
PhysicsResolveCapture.CapturePath = capturePath;
PhysicsResolveCapture.ResetTickCounter();
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
// Drive 3 ticks WITHOUT IsPlayer flag — simulates an NPC path.
uint cellId = CellarId;
bool isOnGround = true;
for (int i = 0; i < 3; i++)
{
Vector3 target = body.Position + PerTickOffset;
var result = engine.ResolveWithTransition(
body.Position, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: body,
moverFlags: ObjectInfoState.EdgeSlide, // ← no IsPlayer
movingEntityId: 0);
body.Position = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
}
PhysicsResolveCapture.Close();
// No records written because no IsPlayer call ran.
Assert.False(File.Exists(capturePath),
"Capture file should NOT exist when only non-player calls ran.");
}
finally
{
PhysicsResolveCapture.CapturePath = null;
PhysicsResolveCapture.Close();
if (File.Exists(capturePath))
File.Delete(capturePath);
}
}
/// <summary>
/// Shared deserialization options matching
/// <see cref="PhysicsResolveCapture"/>'s serializer. <c>IncludeFields</c>
/// is required because Vector3/Quaternion/Plane store components as
/// fields, not properties.
/// </summary>
public static readonly System.Text.Json.JsonSerializerOptions CaptureJsonOptions =
new()
{
IncludeFields = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
};
// ───────────────────────────────────────────────────────────────
// A6.P3 #98 (2026-05-23 PM extension) — live-vs-harness comparison.
// Loads the 3-record fixture sampled from a live capture in the
// Holtburg cottage cellar and replays each through the harness's
// PhysicsEngine. Each test compares one record's outputs (result +
// body-after) to what the live engine produced, reporting the FIRST
// per-field divergence. The divergence pinpoints what world state
// the harness lacks vs production, ending the speculation loop that
// burned 6 hypotheses on the airborne-at-tick-1 bug.
// ───────────────────────────────────────────────────────────────
/// <summary>
/// Tick 0 — spawn/login teleport into the cellar at world Z=92.5333.
/// No velocity, no contact-plane seed; currentPos == targetPos (no
/// motion). The simplest test case: replay the call and verify the
/// harness produces the same ResolveResult + bodyAfter state.
/// </summary>
[Fact]
public void LiveCompare_Tick0_Spawn()
{
var (engine, cache) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record => record.Tick == 0);
AssertCallMatchesCapture(engine, captured);
}
/// <summary>
/// Tick 376 — player on the cellar ramp at world Z=91.49. Live capture
/// has bodyAfter.WalkablePolygon = the ramp polygon (normal ≈
/// (0, 0.719, 0.695), z range 90.99→94.00). If the harness reproduces
/// the same walkable polygon + ResolveResult, the ramp geometry is
/// loaded correctly.
/// </summary>
[Fact]
public void LiveCompare_Tick376_OnRamp()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record => record.Tick == 376);
AssertCallMatchesCapture(engine, captured);
}
/// <summary>
/// First-cap event — replays the live tick where the engine reported
/// the cottage-floor cap (cn=(0,0,-1) at world Z=92.74). This test
/// documents the issue #98 FIX (2026-05-24): with the indoor-primary-cell
/// gate on <see cref="ShadowObjectRegistry.GetNearbyObjects"/>'s outdoor
/// radial sweep, the cottage GfxObj is no longer returned to an indoor
/// (cellar) primary cell, so the head-sphere head-bump into the cottage
/// floor at world Z=94 does not fire.
///
/// <para>
/// Architectural anchor: retail's <c>CObjCell::find_cell_list</c> at
/// <c>acclient_2013_pseudo_c.txt:308751-308769</c> branches indoor/outdoor
/// on the registering object's m_position cell type — outdoor statics
/// like the landblock-baked cottage are added to OUTDOOR cells'
/// shadow_object_list only (via <c>add_all_outside_cells</c>), never to
/// indoor EnvCells. <c>CEnvCell::find_collisions</c> at 309560 only
/// iterates <c>this->shadow_object_list</c>, so indoor cells never test
/// against the cottage. Our fix mirrors this by gating the outdoor
/// radial sweep in <c>GetNearbyObjects</c> on the sphere's primary cell
/// type.
/// </para>
///
/// <para>
/// If this test starts failing because the cap reappears, the
/// <c>primaryCellId</c> wiring at <c>TransitionTypes.cs:2180</c> or the
/// gate at <c>ShadowObjectRegistry.cs:GetNearbyObjects</c> has
/// regressed. The harness still registers the cottage with the
/// production cellScope=0 (landblock-wide) shape, so the apparatus
/// itself proves the fix lives in the query path, not the registration
/// path.
/// </para>
/// </summary>
[Fact]
public void LiveCompare_FirstCap_FixClosesCottageFloorCap()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
// Live must have cn=(0,0,-1) at this point — sanity check that the
// fixture still contains the bug-shape record we're replaying.
Assert.True(captured.Result.CollisionNormalValid,
"Captured record must have collisionNormalValid=true.");
Assert.True(captured.Result.CollisionNormal.Z < -0.99f,
$"Captured record must have downward collision normal; got " +
$"{captured.Result.CollisionNormal}.");
// Replay the call.
Assert.NotNull(captured.BodyBefore);
var body = SeedBodyFromSnapshot(captured.BodyBefore);
var harnessResult = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
// Issue #98 fix: the cottage-floor cap (cn.z near -1) must not fire
// from an indoor primary cell. The harness MAY still produce some
// other collision response (e.g. cn=(0,0,+1) from the ramp walkable
// surface, or a wall hit) — we explicitly assert ONLY that the
// downward-facing cottage-floor cap is gone.
Assert.False(
harnessResult.CollisionNormalValid
&& harnessResult.CollisionNormal.Z < -0.99f,
$"Issue #98 fix should prevent the downward-facing cottage-floor " +
$"cap. Harness produced cn={harnessResult.CollisionNormal} " +
$"(valid={harnessResult.CollisionNormalValid}). If z is back near " +
$"-1, the GetNearbyObjects indoor-primary gate has regressed.");
}
/// <summary>
/// Diagnostic dump: turns on every relevant probe and replays the
/// first-cap record, so the captured stdout shows which polygon the
/// harness BSP hit when it computed cn=(0,0,+1) — pinpoints the
/// missing fixture cell or the wrong-winding-order polygon.
/// Always passes; this is a one-shot tool, not a regression.
/// </summary>
[Fact]
public void LiveCompare_FirstCap_DiagnosticDump()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
PhysicsDiagnostics.ProbePushBackEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
try
{
var (engine, cache) = BuildEngineWithCellarFixtures();
// Dump the cellar cell's polygons so we can see what BSP is
// testing against. The harness hit cn=(0,0,+1) — find which
// polygon has that normal.
DumpCellPolygons(cache, CellarId);
DumpCellPolygons(cache, CottageNeighborA);
DumpCellPolygons(cache, CottageNeighborB);
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
Console.WriteLine($"=== Replay tick {captured.Tick} ===");
var result = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
Console.WriteLine(
$"=== Result pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4}) " +
$"cn=({result.CollisionNormal.X:F4},{result.CollisionNormal.Y:F4},{result.CollisionNormal.Z:F4}) " +
$"cnValid={result.CollisionNormalValid} onGround={result.IsOnGround}");
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
PhysicsDiagnostics.ProbePushBackEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
}
}
private static void DumpCellPolygons(PhysicsDataCache cache, uint cellId)
{
var cell = cache.GetCellStruct(cellId);
if (cell is null)
{
Console.WriteLine($"[cell-dump] 0x{cellId:X8} NOT IN CACHE");
return;
}
var t = cell.WorldTransform;
Console.WriteLine($"[cell-dump] 0x{cellId:X8} resolved-poly-count={cell.Resolved.Count}");
Console.WriteLine($" WorldTransform.M14={t.M14:F4} M24={t.M24:F4} M34={t.M34:F4} (origin XYZ?)");
Console.WriteLine($" Translation=({t.Translation.X:F4},{t.Translation.Y:F4},{t.Translation.Z:F4})");
foreach (var kv in cell.Resolved)
{
var p = kv.Value;
// Show world-frame vertices for the first 2 polys with normal-Z>0.9
// (floor candidates) — these are the polygons the head sphere
// could hit from below.
string vertsWorld = "";
if (p.Plane.Normal.Z > 0.9f || p.Plane.Normal.Z < -0.9f)
{
vertsWorld = " worldVerts=[" + string.Join(",", p.Vertices.Select(v =>
{
var w = Vector3.Transform(v, cell.WorldTransform);
return $"({w.X:F2},{w.Y:F2},{w.Z:F2})";
})) + "]";
}
Console.WriteLine(
$" poly id=0x{p.Id:X4} sides={p.SidesType} n=({p.Plane.Normal.X:F4},{p.Plane.Normal.Y:F4},{p.Plane.Normal.Z:F4}) d={p.Plane.D:F4} numV={p.NumPoints}{vertsWorld}");
}
}
/// <summary>
/// Reads the live-capture.jsonl fixture and returns the FIRST record
/// matching <paramref name="predicate"/>. Throws with a clear error
/// when none match — keeps the test failure attributed to the
/// fixture, not to deserialization.
/// </summary>
private static ResolveCaptureRecord LoadCapturedRecord(
Func<ResolveCaptureRecord, bool> predicate)
{
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
Assert.True(File.Exists(path),
$"Live-capture fixture missing: {path}. Re-run live capture " +
$"with ACDREAM_CAPTURE_RESOLVE set.");
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line))
continue;
var record = System.Text.Json.JsonSerializer
.Deserialize<ResolveCaptureRecord>(line, CaptureJsonOptions)!;
if (predicate(record))
return record;
}
throw new Xunit.Sdk.XunitException(
"No captured record matched the predicate. Update the fixture " +
"to include a representative record.");
}
/// <summary>
/// Replays one captured ResolveWithTransition call through the harness
/// engine, seeded with the captured body-before state, and compares
/// the harness's ResolveResult + body-after vs the captured values.
/// Reports the FIRST per-field divergence with both values so the
/// missing apparatus state is named.
/// </summary>
private static void AssertCallMatchesCapture(
PhysicsEngine engine,
ResolveCaptureRecord captured)
{
Assert.NotNull(captured.BodyBefore);
Assert.NotNull(captured.BodyAfter);
var body = SeedBodyFromSnapshot(captured.BodyBefore);
var harnessResult = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
// Compare in priority order — most consequential divergence first.
var divergences = new List<string>();
// 1. Result fields
AddIfDifferent(divergences, "Result.Position",
captured.Result.Position, harnessResult.Position);
AddIfDifferent(divergences, "Result.CellId",
$"0x{captured.Result.CellId:X8}",
$"0x{harnessResult.CellId:X8}");
AddIfDifferent(divergences, "Result.IsOnGround",
captured.Result.IsOnGround, harnessResult.IsOnGround);
AddIfDifferent(divergences, "Result.CollisionNormalValid",
captured.Result.CollisionNormalValid,
harnessResult.CollisionNormalValid);
if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid)
{
AddIfDifferent(divergences, "Result.CollisionNormal",
captured.Result.CollisionNormal,
harnessResult.CollisionNormal);
}
// 2. Body-after fields (subset that's most likely to diverge first)
AddIfDifferent(divergences, "BodyAfter.Position",
captured.BodyAfter.Position, body.Position);
AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid",
captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid);
if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid)
{
AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal",
captured.BodyAfter.ContactPlane.Normal,
body.ContactPlane.Normal);
AddIfDifferent(divergences, "BodyAfter.ContactPlane.D",
captured.BodyAfter.ContactPlane.D,
body.ContactPlane.D);
}
AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid",
captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid);
AddIfDifferent(divergences, "BodyAfter.TransientState",
$"0x{captured.BodyAfter.TransientState:X}",
$"0x{(uint)body.TransientState:X}");
if (divergences.Count > 0)
{
string summary = string.Join("\n • ", divergences);
string header = string.Format(System.Globalization.CultureInfo.InvariantCulture,
"Harness replay of captured tick {0} diverges from live engine. " +
"Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " +
"cellId=0x{7:X8} isOnGround={8}",
captured.Tick,
captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z,
captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z,
captured.Input.CellId, captured.Input.IsOnGround);
throw new Xunit.Sdk.XunitException(
header + "\nDivergences (live → harness):\n • " + summary);
}
}
private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new()
{
Position = snap.Position,
Orientation = snap.Orientation,
Velocity = snap.Velocity,
Acceleration = snap.Acceleration,
Omega = snap.Omega,
GroundNormal = snap.GroundNormal,
SlidingNormal = snap.SlidingNormal,
ContactPlaneValid = snap.ContactPlaneValid,
ContactPlane = snap.ContactPlane,
ContactPlaneCellId = snap.ContactPlaneCellId,
ContactPlaneIsWater = snap.ContactPlaneIsWater,
WalkablePolygonValid = snap.WalkablePolygonValid,
WalkablePlane = snap.WalkablePlane,
WalkableVertices = snap.WalkableVertices,
WalkableUp = snap.WalkableUp,
Elasticity = snap.Elasticity,
Friction = snap.Friction,
State = (PhysicsStateFlags)snap.State,
TransientState = (TransientStateFlags)snap.TransientState,
LastUpdateTime = snap.LastUpdateTime,
};
private static void AddIfDifferent<T>(
List<string> divergences, string name, T live, T harness)
{
if (EqualityComparer<T>.Default.Equals(live, harness))
return;
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}: live={1} harness={2}", name, live, harness));
}
private static void AddIfDifferent(
List<string> divergences, string name, Vector3 live, Vector3 harness)
{
if (Vector3.DistanceSquared(live, harness) < 1e-6f)
return;
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})",
name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z));
}
private static void AddIfDifferent(
List<string> divergences, string name, float live, float harness)
{
if (MathF.Abs(live - harness) < 1e-3f)
return;
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}: live={1:F4} harness={2:F4}", name, live, harness));
}
// ───────────────────────────────────────────────────────────────
// Harness internals
// ───────────────────────────────────────────────────────────────
/// <summary>
/// One point in the simulated trajectory. Captured per tick.
/// </summary>
public sealed record TrajectoryPoint(
int Tick,
Vector3 Position,
uint CellId,
bool IsOnGround,
bool CpValid);
/// <summary>
/// Builds a <see cref="PhysicsEngine"/> with:
/// <list type="bullet">
/// <item>The three issue-#98 cottage/cellar cell fixtures registered.</item>
/// <item>A stub landblock so <c>TryGetLandblockContext</c> succeeds
/// at the cellar XY (needed for FindObjCollisions to query
/// the shadow registry).</item>
/// <item>A SYNTHETIC stair-piece GfxObj containing the cellar ramp
/// polygon, registered as a ShadowEntry scoped to the cellar
/// cell. Reconstructed programmatically from the live-capture
/// <c>[poly-dump]</c> data
/// (<c>docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log</c>),
/// transformed to world coordinates so the registered object
/// sits at world origin with identity rotation/scale.</item>
/// </list>
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithCellarFixtures()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// ── 1. Cell fixtures (existing) + synthetic BSP ──────────────
// CellDumpSerializer.Hydrate intentionally sets BSP=null (the DAT
// PhysicsBSPTree isn't in the dump format). Without a non-null BSP,
// FindEnvCollisions's indoor branch (TransitionTypes.cs:1840) is
// skipped — the engine falls through to outdoor terrain queries
// that produce spurious wall hits. Construct a single-leaf BSP
// wrapping the cell's Resolved polygons, so the indoor path fires
// like production.
foreach (var cellId in new[] { CellarId, CottageNeighborA, CottageNeighborB })
{
var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
Assert.True(File.Exists(path),
$"Fixture missing: {path}. Re-run cell-dump capture " +
$"(commit 3f56915 captured the originals).");
var dump = CellDumpSerializer.Read(path);
var cell = CellDumpSerializer.Hydrate(dump);
var cellWithBsp = AttachSyntheticBsp(cell);
cache.RegisterCellStructForTest(cellId, cellWithBsp);
}
// ── 2. Minimum landblock context for FindObjCollisions ──────
// FindObjCollisions (TransitionTypes.cs:2153) early-returns
// TransitionState.OK when TryGetLandblockContext fails for the
// sphere XY. Without a landblock the harness can't query the
// cottage GfxObj's shadow entries — and that's where the
// first-cap collision actually lives (live capture confirmed
// obj=0xA9B47900 fires the cn=(0,0,-1) push).
//
// Register an EMPTY-terrain landblock 0xA9B40000 anchored at
// world origin (0,0). The landblock test
// (worldX >= 0 && worldX < 192) covers every harness sphere
// position (X≈141, Y≈7). TerrainSurface gets a flat far-below
// surface so SampleTerrainZ returns something the indoor BSP
// path never consults (FindEnvCollisions's indoor branch fires
// first when the cell has BSP). Outdoor-fallback queries are
// harmless because the cell's synthetic BSP returns Collided
// before terrain is checked.
var heights = new byte[81]; // 9x9 corners
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below cellar
var stubTerrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(
landblockId: 0xA9B40000u,
terrain: stubTerrain,
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// ── 3. Cottage GfxObj 0x01000A2B from dumped fixture ────────
// Live capture (2026-05-23 PM v2) attributes the first-cap event
// to obj=0xA9B47900 (entity 0x00A9B479 partIdx=0) — a landblock-
// baked static building registered as a ShadowEntry. The full
// polygon table was extracted via ACDREAM_DUMP_GFXOBJS=0x01000A2B
// (issue #98 evening-v2 apparatus); 74 polygons including six
// downward-facing cottage-floor triangles at object-local Z=0
// that the head sphere bumps from below at world Z=94.
RegisterCottageGfxObj(engine, cache);
return (engine, cache);
}
/// <summary>
/// Wraps a hydrated <see cref="CellPhysics"/> with a synthetic
/// single-leaf <see cref="PhysicsBSPTree"/> that references every
/// polygon in <c>cell.Resolved</c>. <c>CellDumpSerializer.Hydrate</c>
/// intentionally sets BSP=null (per its xmldoc) because the dump
/// format doesn't capture the DAT BSP tree. Without a non-null BSP,
/// FindEnvCollisions's indoor branch is skipped — the engine then
/// falls through to outdoor terrain queries that misfire. A flat
/// single-leaf BSP is sufficient for the BSP query to find every
/// polygon by exhaustive iteration (slower than a real BSP but
/// correct).
/// </summary>
private static CellPhysics AttachSyntheticBsp(CellPhysics cell)
{
// Compute a bounding sphere that encompasses every polygon in the
// cell — center at the origin of the cell's WORLD transform plus
// a margin radius. The cellar fixture is ~12 m × 12 m × 3 m.
var bsphereCenter = new Vector3(0f, 0f, 0f); // cell local
var bsphereRadius = 15f;
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = bsphereCenter, Radius = bsphereRadius },
};
foreach (var kv in cell.Resolved)
leaf.Polygons.Add(kv.Key);
var bspTree = new PhysicsBSPTree { Root = leaf };
// CellPhysics has init-only properties; rebuild a new instance
// with BSP set, copying every other field unchanged.
return new CellPhysics
{
BSP = bspTree,
PhysicsPolygons = cell.PhysicsPolygons,
Vertices = cell.Vertices,
WorldTransform = cell.WorldTransform,
InverseWorldTransform = cell.InverseWorldTransform,
Resolved = cell.Resolved,
CellBSP = cell.CellBSP,
Portals = cell.Portals,
PortalPolygons = cell.PortalPolygons,
VisibleCellIds = cell.VisibleCellIds,
};
}
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2). Loads the cottage GfxObj
/// <c>0x01000A2B</c> from the JSON fixture
/// (<c>tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json</c>,
/// produced via the <c>ACDREAM_DUMP_GFXOBJS</c> capture infrastructure),
/// hydrates it as a <see cref="GfxObjPhysics"/> with a synthetic
/// single-leaf BSP, and registers it as a ShadowEntry at the cottage's
/// world transform — the same shape production's GameWindow.cs:5893
/// registration uses for landblock-baked statics.
///
/// <para>
/// Transform values come from two evidence sources:
/// <list type="bullet">
/// <item>The cellar cell 0xA9B40147's WorldTransform has translation
/// (130.5, 11.5, 94.0) and a 3×3 with M11=M22=-1 / M33=+1
/// (a 180° rotation around Z). The cottage GfxObj sits at the
/// SAME world transform (its building origin is also at
/// (130.5, 11.5, 94.0) per the existing [resolve-bldg] capture
/// <c>entOrigin_lb=(130.5,11.5,94.0)</c>).</item>
/// <item>BoundingSphere radius from the dump's
/// <see cref="GfxObjDump.BoundingSphereRadius"/> — 13.989 m.
/// Matches the live <c>bspR=13.99</c> observed in the
/// [resolve-bldg] capture; cross-validation that the same
/// building is in play.</item>
/// </list>
/// </para>
///
/// <para>
/// Entity id <c>0x00A9B479</c> mirrors the live capture's
/// <c>obj=0xA9B47900</c> formula (entity.Id × 256 + partIdx=0). Using
/// the same id keeps any future probe correlation aligned with live
/// log conventions.
/// </para>
/// </summary>
private static void RegisterCottageGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
{
const uint CottageGfxId = 0x01000A2Bu;
const uint CottageEntityId = 0x00A9B479u;
var fixturePath = Path.Combine(FixtureDir, "0x01000A2B.gfxobj.json");
Assert.True(File.Exists(fixturePath),
$"Cottage GfxObj fixture missing: {fixturePath}. Re-run live " +
$"capture with ACDREAM_DUMP_GFXOBJS=0x01000A2B.");
var dump = GfxObjDumpSerializer.Read(fixturePath);
var physics = GfxObjDumpSerializer.Hydrate(dump);
cache.RegisterGfxObjForTest(CottageGfxId, physics);
// World transform from the cellar cell's WorldTransform: translation
// (130.5, 11.5, 94.0) + 180° rotation around Z. The cottage GfxObj
// shares this transform (it IS the cellar/cottage geometry).
var worldPos = new Vector3(130.5f, 11.5f, 94.0f);
var worldRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
engine.ShadowObjects.Register(
entityId: CottageEntityId,
gfxObjId: CottageGfxId,
worldPos: worldPos,
rotation: worldRot,
radius: physics.BoundingSphere?.Radius ?? 14f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xA9B40000u,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
// BR-7: outdoor seed derived from the world position. (In
// production the cottage SHELL no longer registers as a shadow
// object at all — it dispatches via the per-LandCell building
// channel; this fixture keeps the shadow registration to pin the
// #98 regression shape: an outdoor-registered footprint must
// stay invisible to fully-interior cellar queries.)
seedCellId: 0u);
}
/// <summary>
/// Sphere on the cellar floor with BOTH a seeded ContactPlane AND a
/// seeded WalkablePolygon. Both are required by the engine to treat
/// the body as truly grounded:
/// <list type="bullet">
/// <item><c>ContactPlaneValid</c> + <c>ContactPlane</c>: copied into
/// <c>CollisionInfo.ContactPlane</c> via the body parameter
/// seeding in <see cref="PhysicsEngine.ResolveWithTransition"/>.</item>
/// <item><c>WalkablePolygonValid</c> + <c>WalkablePlane</c> +
/// <c>WalkableVertices</c>: read by
/// <see cref="PhysicsEngine.ResolveWithTransition"/> lines
/// 665-673 to call <c>SpherePath.SetWalkable(...)</c>, which
/// sets <c>HasWalkablePolygon=true</c>. Without this, the
/// engine treats the sphere as "grounded but with no walkable
/// polygon anchor" — a contradictory state that fires step-down
/// probes which reject and clear the grounded flag.</item>
/// </list>
/// </summary>
private static PhysicsBody BuildInitialBody() => new()
{
Position = InitialSphereWorld,
Orientation = Quaternion.Identity,
// ContactPlane: cellar floor at world Z=90.95.
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
ContactPlaneCellId = CellarId,
// WalkablePolygon: cellar floor poly 24 (the cellar quad under
// sphere XY=(141.5, 9.5)), transformed to world coordinates via
// the cell's 180° yaw + origin (130.5, 11.5, 94.0). Local verts
// [(-11.6, 0, -3.05), (-11.6, 3.1, -3.05), (-9.6, 3.1, -3.05),
// (-9.6, 0, -3.05)] → world [(142.1, 11.5, 90.95),
// (142.1, 8.4, 90.95), (140.1, 8.4, 90.95), (140.1, 11.5, 90.95)].
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
WalkableVertices = new[]
{
new Vector3(142.1f, 11.5f, 90.95f),
new Vector3(142.1f, 8.4f, 90.95f),
new Vector3(140.1f, 8.4f, 90.95f),
new Vector3(140.1f, 11.5f, 90.95f),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable,
};
/// <summary>
/// Drives <paramref name="tickCount"/> physics ticks. Each tick
/// applies <see cref="PerTickOffset"/> as the requested forward
/// motion, calls <see cref="PhysicsEngine.ResolveWithTransition"/>,
/// writes the result back to <paramref name="body"/>, and records
/// a <see cref="TrajectoryPoint"/>.
///
/// <para>
/// Cross-tick ContactPlane persistence is via <paramref name="body"/>
/// — the engine writes its final CP back to the body, then reads
/// it as the seed for the next tick. This mirrors the production
/// pattern in <c>PlayerMovementController</c>.
/// </para>
/// </summary>
private static List<TrajectoryPoint> SimulateTicks(
PhysicsEngine engine,
PhysicsBody body,
uint initialCellId,
int tickCount)
{
uint cellId = initialCellId;
bool isOnGround = true;
var trajectory = new List<TrajectoryPoint>(tickCount + 1)
{
new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid),
};
for (int tick = 1; tick <= tickCount; tick++)
{
Vector3 target = body.Position + PerTickOffset;
var result = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: target,
cellId: cellId,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer
| ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
trajectory.Add(new(
tick,
body.Position,
cellId,
isOnGround,
body.ContactPlaneValid));
}
return trajectory;
}
private static string FixtureDir =>
Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
"Fixtures", "issue98");
private static string SolutionRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return dir;
dir = Path.GetDirectoryName(dir);
}
throw new InvalidOperationException(
"Could not locate AcDream.slnx from " + AppContext.BaseDirectory);
}
}