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>
206 lines
9 KiB
C#
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);
|
|
}
|
|
}
|