acdream/tests/AcDream.App.Tests/Rendering/CameraCollisionIndoorTests.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

206 lines
9 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Camera (viewer) sweep vs the BUILDING collision channel.
///
/// <para>
/// History: this file was born as the Phase U RED diagnostic for the
/// camera-collision indoor non-engagement bug — the b3ce505 #98 indoor gate
/// in the old radial <c>GetNearbyObjects</c> blocked the viewer sweep from
/// reaching the exterior-shell GfxObj, and the fix was an
/// <c>isViewer</c> exemption. BR-7 / A6.P4 (2026-06-11) deleted that whole
/// stack: building shells are no longer shadow objects at all — they
/// dispatch through the retail per-LandCell building channel
/// (<c>CSortCell::find_collisions</c> → <c>find_building_collisions</c>,
/// Ghidra 0x005340a0/0x006b5300), and the indoor camera is bounded by the
/// interior cell's own physics BSP (the env channel — retail's actual
/// viewer enclosure; pinned against real dat by
/// <c>CameraCornerSealReplayTests</c>).
/// </para>
///
/// <para>
/// What remains here is the end-to-end pin of the NEW channel: a viewer
/// sweep whose primary cell is an outdoor landcell with a cached building
/// must be stopped by the building's shell BSP.
/// </para>
/// </summary>
public class CameraCollisionIndoorTests
{
// ── Geometry constants ─────────────────────────────────────────────────
// Player outdoors at (0, 1, 94). Pivot = (0, 1, 95.5). Camera sweeps
// backward (+Y) and slightly upward toward the building wall at Y=4.0.
private const uint OutdoorCellId = 0xA9B40001u; // landcell (0,0)
private const uint LandblockId = 0xA9B40000u;
private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f);
// Desired eye: backward past the building wall at Y=4.0.
private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f);
private const float BuildingWallY = 4.0f;
// The sphere should be stopped near Y = BuildingWallY - viewerRadius;
// anything ≥ this margin proves the channel engaged.
private const float MinExpectedPullIn = 0.5f;
/// <summary>
/// BR-7 / A6.P4: the viewer sweep is stopped by the building channel —
/// <c>Transition.FindBuildingCollisions</c> runs the shell part-0 BSP
/// for the outdoor primary cell holding the building reference, exactly
/// like retail <c>CLandCell::find_collisions</c> →
/// <c>CSortCell::find_collisions</c> (Ghidra 0x00532d60/0x005340a0).
/// The shell is NOT in the ShadowObjectRegistry (production skips
/// <c>IsBuildingShell</c> entities); the stop can only come from the
/// channel.
/// </summary>
[Fact]
public void SweepEye_OutdoorCell_StoppedByBuildingChannelShell()
{
var (engine, _) = BuildEngineWithBuilding();
var probe = new PhysicsCameraCollisionProbe(engine);
var stoppedEye = probe.SweepEye(
pivot: PivotWorld,
desiredEye: DesiredEye,
cellId: OutdoorCellId,
selfEntityId: 0u,
playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye;
float pulledIn = MathF.Abs(DesiredEye.Y - stoppedEye.Y);
Assert.True(
pulledIn >= MinExpectedPullIn,
$"Camera sweep should be stopped by the building-channel shell BSP at " +
$"Y={BuildingWallY:F1} (cache.GetBuilding({OutdoorCellId:X8}) with ModelId set). " +
$"Actual pulled-in: {pulledIn:F4} m (stopped eye Y={stoppedEye.Y:F4}). " +
$"REGRESSION: Transition.FindBuildingCollisions (retail " +
$"CBuildingObj::find_building_collisions, Ghidra 0x006b5300) is not engaging " +
$"for the viewer's outdoor primary cell.");
}
/// <summary>
/// Channel inertness guard: a building cached WITHOUT a model id
/// (legacy entries, portal-transit-only callers) must not collide —
/// the sweep reaches the desired eye.
/// </summary>
[Fact]
public void SweepEye_BuildingWithoutModelId_ChannelInert()
{
var (engine, cache) = BuildEngineWithBuilding();
// Replace the building entry with a model-less one.
cache.RegisterBuildingForTest(OutdoorCellId, new BuildingPhysics
{
WorldTransform = Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f),
InverseWorldTransform = InvertOrIdentity(Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f)),
Portals = Array.Empty<BldPortalInfo>(),
ModelId = 0u,
});
var probe = new PhysicsCameraCollisionProbe(engine);
var stoppedEye = probe.SweepEye(
pivot: PivotWorld,
desiredEye: DesiredEye,
cellId: OutdoorCellId,
selfEntityId: 0u,
playerPos: PivotWorld - new Vector3(0f, 0f, 1.5f)).Eye;
Assert.True(MathF.Abs(DesiredEye.Y - stoppedEye.Y) < 0.05f,
$"Model-less building entries must keep the channel inert; eye stopped at " +
$"Y={stoppedEye.Y:F4} instead of reaching {DesiredEye.Y:F4}.");
}
// ── Engine + fixture builder ──────────────────────────────────────────
private static Matrix4x4 InvertOrIdentity(Matrix4x4 m)
=> Matrix4x4.Invert(m, out var inv) ? inv : Matrix4x4.Identity;
/// <summary>
/// Minimal engine with ONE outdoor landcell building:
/// <list type="bullet">
/// <item>Shell GfxObj: a single two-sided wall polygon at local Y=0,
/// anchored by the building transform at world Y=<see cref="BuildingWallY"/>.</item>
/// <item><see cref="PhysicsDataCache.RegisterBuildingForTest"/> at
/// <see cref="OutdoorCellId"/> with <c>ModelId</c> set — the
/// per-LandCell building reference (retail CSortCell.building,
/// set once at the origin cell by CLandBlock::init_buildings,
/// Ghidra 0x0052fd80).</item>
/// <item>Stub terrain far below so terrain collision never interferes.</item>
/// </list>
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithBuilding()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// ── 1. Shell GfxObj: one two-sided wall polygon at local Y=0 ──────
const uint ShellGfxId = 0x01AABB01u;
const ushort WallPolyId = 1;
var wallPoly = new ResolvedPolygon
{
Vertices = new[]
{
new Vector3(-3f, 0f, -3f),
new Vector3( 3f, 0f, -3f),
new Vector3( 3f, 0f, 3f),
new Vector3(-3f, 0f, 3f),
},
Plane = new System.Numerics.Plane(new Vector3(0f, -1f, 0f), 0f),
NumPoints = 4,
SidesType = CullMode.None, // two-sided: stops from both directions
};
var gfxLeaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
};
gfxLeaf.Polygons.Add(WallPolyId);
var gfxPhysics = new GfxObjPhysics
{
BSP = new PhysicsBSPTree { Root = gfxLeaf },
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon> { [WallPolyId] = wallPoly },
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f },
};
cache.RegisterGfxObjForTest(ShellGfxId, gfxPhysics);
// ── 2. The per-LandCell building reference ─────────────────────────
var bldTransform = Matrix4x4.CreateTranslation(0f, BuildingWallY, 96f);
cache.RegisterBuildingForTest(OutdoorCellId, new BuildingPhysics
{
WorldTransform = bldTransform,
InverseWorldTransform = InvertOrIdentity(bldTransform),
Portals = Array.Empty<BldPortalInfo>(),
ModelId = ShellGfxId,
});
// ── 3. Stub landblock: terrain far below ───────────────────────────
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: LandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return (engine, cache);
}
}