acdream/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.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

430 lines
18 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.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// A6.P4 door fix (2026-05-24) — apparatus test. Loads door Setup
/// <c>0x020019FF</c> + GfxObj <c>0x010044B5</c> (the collision-bearing
/// frame/slab part — see
/// <see cref="DoorSetupGfxObjInspectionTests"/>) from the real dat,
/// registers a synthetic door entity at a known world position via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>, and sweeps a
/// player sphere into the door from multiple angles.
///
/// <para>
/// Purpose: with the dat inspection having confirmed that
/// <c>0x010044B5</c> has a complete 1.9 × 0.26 × 2.5 m door-slab
/// PhysicsBSP, this test isolates whether our
/// <see cref="BSPQuery.FindCollisions"/> + the
/// <see cref="RegisterMultiPart"/> registration path actually fires
/// collisions against that BSP. If it does, the live Holtburg door
/// bug is somewhere DOWNSTREAM (live registration race, entity
/// rotation, cell scoping). If it doesn't, the bug is here in
/// the integration we can fix with confidence.
/// </para>
///
/// <para>
/// The test skips gracefully when the dat directory isn't present so
/// CI stays green. Local developer machines always have it.
/// </para>
/// </summary>
public class DoorCollisionApparatusTests
{
private readonly ITestOutputHelper _out;
public DoorCollisionApparatusTests(ITestOutputHelper output) => _out = output;
private const uint DoorSetupId = 0x020019FFu;
private const uint DoorFrameGfxObjId = 0x010044B5u;
private const uint DoorEntityId = 0xF424Fu;
private const uint TestLandblockId = 0xA9B40000u;
// Cell (0, 0) in landblock 0xA9B40000 — outdoor 24-m cell, low byte
// = (0 * 8) + 0 + 1 = 1.
private const uint TestCellId = TestLandblockId | 0x0001u;
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;
/// <summary>
/// Walk the sphere toward the closed door, dead-center, from
/// the door's -Y front face approach. Expect a CollisionNormalValid
/// = true AND the sphere stops before passing through the door.
/// </summary>
[Fact]
public void Apparatus_DeadCenter_FrontApproach_BlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) return;
// Door at world (12, 12, 0). The frame's BSP slab extends
// entity-local Y∈[-0.009, 0.252] (poly Y range plus frame[0] offset).
// Approach from -Y at sphere Y=11 (1 m before the front face),
// walk +Y at 0.10 m/tick.
var start = new Vector3(12f, 11f, 0.5f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
ctx.engine, start, perTick, maxTicks: 30);
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
Assert.True(blocked,
$"Expected collision before passing through the door. Sphere reached " +
$"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " +
$"without firing CollisionNormalValid.");
Assert.True(finalPos.Y < 12.0f,
$"Sphere should stop before the door's front face (Y ≈ 11.99). " +
$"Got Y={finalPos.Y:F3}");
}
/// <summary>
/// 50 cm off-center: the small Sphere shape (r=0.10) can't catch
/// this, but the BSP slab (1.9 m wide) MUST. This is the live
/// regression — pre-fix the user can walk around the cylinder.
/// </summary>
[Fact]
public void Apparatus_50cmOffCenter_FrontApproach_BlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) return;
// Same setup, but start 0.5 m off-center in X. Slab X range
// is approximately [11.05, 12.97] (frame[0].X offset = -0.006).
var start = new Vector3(12.5f, 11f, 0.5f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
ctx.engine, start, perTick, maxTicks: 30);
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
Assert.True(blocked,
$"Expected BSP collision off-center. Sphere reached " +
$"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " +
$"without firing CollisionNormalValid.");
Assert.True(finalPos.Y < 12.0f,
$"Sphere should stop before door front face. Got Y={finalPos.Y:F3}");
}
/// <summary>
/// Reverse approach: walk from the door's +Y back side toward -Y.
/// SidesType=Landblock polys are two-sided, so this must also block.
/// This pins the live "walking out from inside passes through" bug
/// in its simplest geometric form.
/// </summary>
[Fact]
public void Apparatus_DeadCenter_BackApproach_BlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) return;
// Start past the door at Y=13, walk back toward -Y.
var start = new Vector3(12f, 13f, 0.5f);
var perTick = new Vector3(0f, -0.10f, 0f);
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
ctx.engine, start, perTick, maxTicks: 30);
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
Assert.True(blocked,
$"Expected BSP collision from back side (two-sided Landblock poly). " +
$"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) " +
$"after {ticks} ticks.");
Assert.True(finalPos.Y > 12.30f,
$"Sphere should stop after the door's back face (Y ≈ 12.25). " +
$"Got Y={finalPos.Y:F3}");
}
/// <summary>
/// Diagnostic dump: drive 5 ticks with probes on, just to see
/// what the engine reports per-tick. Useful when the assertion
/// tests above fail — the diagnostic test shows the FULL log
/// without yielding a green/red verdict.
/// </summary>
[Fact]
public void Apparatus_DiagnosticDump_FrontApproach()
{
if (!TryBuildScenario(out var ctx)) return;
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeBuildingEnabled = true;
try
{
var pos = new Vector3(12f, 11f, 0.5f);
var perTick = new Vector3(0f, 0.10f, 0f);
uint cellId = TestCellId;
bool isOnGround = false;
for (int tick = 0; tick < 8; tick++)
{
Vector3 target = pos + perTick;
var result = ctx.engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: null,
moverFlags: ObjectInfoState.IsPlayer,
movingEntityId: 0);
_out.WriteLine($"tick={tick} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) → " +
$"({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) " +
$"hit={result.CollisionNormalValid} " +
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
}
Assert.True(true, "Diagnostic test — always passes; check stdout.");
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeBuildingEnabled = false;
}
}
/// <summary>
/// Reproduces the LIVE bug: a grounded player (isOnGround=true with
/// seeded ContactPlane) walking off-center toward a closed door
/// passes through. The path-difference test: this hits Path 5
/// (Contact branch + StepSphereUp), while the other apparatus
/// tests above hit Path 6 (Default). If Path 5 incorrectly
/// declares step-up success the BSP collision returns OK and the
/// sphere walks through — exactly what the user reports in the
/// live Holtburg session 2026-05-24.
/// </summary>
/// <remarks>
/// BR-7 / A6.P4 (2026-06-11) — FLIPPED from documents-the-bug to
/// asserts-the-fix: with the per-cell shadow architecture the door is
/// found from every overlapped cell and this approach now blocks.
/// </remarks>
[Fact]
public void Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks()
{
if (!TryBuildScenario(out var ctx)) return;
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeBuildingEnabled = true;
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1");
// Synthetic floor plane at Z = 0 so the grounded sphere has a
// walkable plane to rest on. Sphere foot center starts at Z=radius
// = 0.48, head at 0.48 + 1.20 = 1.68.
var floorPlane = new Plane(0f, 0f, 1f, 0f); // z = 0 plane
var floorVerts = new[]
{
new Vector3( 0f, 0f, 0f),
new Vector3(24f, 0f, 0f),
new Vector3(24f, 24f, 0f),
new Vector3( 0f, 24f, 0f),
};
var body = new PhysicsBody
{
Position = new Vector3(12.5f, 11f, 0.48f),
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = floorPlane,
ContactPlaneCellId = TestCellId,
WalkablePolygonValid = true,
WalkablePlane = floorPlane,
WalkableVertices = floorVerts,
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
var perTick = new Vector3(0f, 0.10f, 0f);
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool isOnGround = true;
bool blocked = false;
Vector3 lastNormal = Vector3.Zero;
int ticks = 0;
for (int tick = 0; tick < 30; tick++)
{
Vector3 target = pos + perTick;
var result = ctx.engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
ticks = tick;
body.Position = result.Position;
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
lastNormal = result.CollisionNormal;
if (result.CollisionNormalValid)
{
blocked = true;
_out.WriteLine($"Tick {tick}: BLOCKED at pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) normal=({lastNormal.X:F3},{lastNormal.Y:F3},{lastNormal.Z:F3})");
break;
}
}
_out.WriteLine($"Final pos = ({pos.X:F3}, {pos.Y:F3}, {pos.Z:F3}) after {ticks + 1} ticks; blocked={blocked}");
_out.WriteLine($"Grounded={isOnGround}");
// FLIPPED (BR-7 / A6.P4, 2026-06-11): this test documented the #99
// walk-through; the per-cell shadow architecture closes it. The
// grounded Path-5 approach must now BLOCK before the slab
// (Y < 12.0) — the same shape as the Path-6 apparatus tests above.
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeBuildingEnabled = false;
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
Assert.True(blocked,
$"Door must block the grounded off-center approach (#99 closed). " +
$"Current pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) blocked={blocked}");
Assert.True(pos.Y < 12.0f,
$"Sphere must stop before the slab; pos.Y={pos.Y:F3}");
}
// ───────────────────────────────────────────────────────────────
// Apparatus setup
// ───────────────────────────────────────────────────────────────
private sealed record Context(
PhysicsEngine engine,
PhysicsDataCache cache,
Setup setup,
IReadOnlyList<ShadowShape> registeredShapes);
/// <summary>
/// Build the engine + cache + landblock + registered door entity.
/// Returns false if the dat directory is missing (test skips).
/// </summary>
private bool TryBuildScenario(out Context ctx)
{
ctx = default!;
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 false;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Load + cache the door's collision-bearing part.
var doorFrameGfx = dats.Get<GfxObj>(DoorFrameGfxObjId);
Assert.NotNull(doorFrameGfx);
var cache = new PhysicsDataCache();
cache.CacheGfxObj(DoorFrameGfxObjId, doorFrameGfx!);
Assert.NotNull(cache.GetGfxObj(DoorFrameGfxObjId));
var engine = new PhysicsEngine { DataCache = cache };
// Stub landblock at world (0, 0) so TryGetLandblockContext succeeds
// for player XY in the [0, 192) range. Flat far-below terrain so it
// doesn't interact with the door collision.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// Load door setup, build shapes via the production builder.
var setup = dats.Get<Setup>(DoorSetupId);
Assert.NotNull(setup);
var shapes = ShadowShapeBuilder.FromSetup(
setup!,
entScale: 1.0f,
hasPhysicsBsp: id => cache.GetGfxObj(id)?.BSP?.Root is not null);
_out.WriteLine($"Shapes from FromSetup: {shapes.Count} entries");
foreach (var s in shapes)
{
_out.WriteLine($" type={s.CollisionType} gfxObj=0x{s.GfxObjId:X8} " +
$"localPos=({s.LocalPosition.X:F3},{s.LocalPosition.Y:F3},{s.LocalPosition.Z:F3}) " +
$"radius={s.Radius:F3} cylH={s.CylHeight:F3}");
}
// Register the door at world (12, 12, 0). That's cell (0,0) of
// landblock 0xA9B40000.
Vector3 doorWorldPos = new(12f, 12f, 0f);
Quaternion doorWorldRot = Quaternion.Identity;
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | HasDefaultScript (typical)
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,
entityWorldPos: doorWorldPos,
entityWorldRot: doorWorldRot,
shapes: shapes,
state: doorState,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: TestLandblockId);
ctx = new Context(engine, cache, setup!, shapes);
return true;
}
/// <summary>
/// Drive a sphere from <paramref name="start"/> by
/// <paramref name="perTick"/> each tick until either
/// <c>CollisionNormalValid</c> fires or <paramref name="maxTicks"/>
/// elapse. Returns the outcome + final position.
/// </summary>
private (bool blocked, Vector3 finalPos, Vector3 normal, int ticks)
SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks)
{
Vector3 pos = start;
uint cellId = TestCellId;
bool isOnGround = false;
Vector3 lastNormal = Vector3.Zero;
for (int tick = 0; tick < maxTicks; tick++)
{
Vector3 target = pos + perTick;
var result = engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: null,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
if (result.CollisionNormalValid)
return (true, result.Position, result.CollisionNormal, tick);
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
lastNormal = result.CollisionNormal;
}
return (false, pos, lastNormal, maxTicks);
}
}