Complete Render Residual A's faithful port: PhysicsCameraCollisionProbe.SweepEye now mirrors SmartBox::update_viewer (acclient_2013_pseudo_c.txt:92761) end-to-end: - Start cell (pc:92824-92844): indoor (>=0x100) seats the sweep at the head-PIVOT via PhysicsEngine.AdjustPosition (the cellar-lip case — feet in the low connector, head up at floor level); outdoor keeps the player cell. - Sweep pivot -> sought-eye from the seated start cell (unchanged 0x5c viewer flags). - Success (pc:92870): set_viewer(curr_pos), viewer_cell = curr_cell. - Fallback 1 (pc:92878): AdjustPosition(sought_eye). - Fallback 2 / no-cell (pc:92775, 92886): snap to player, viewer_cell = null. This also makes cellId==0 faithful (was returning the desired eye; retail snaps to player_pos) and adds the playerPos arg to ICameraCollisionProbe.SweepEye. Supporting: ResolveResult.Ok surfaces FindTransitionalPosition's return (retail find_valid_position != 0, pc:273898) so SweepEye knows when to fall back. TDD: 11 new tests (FindVisibleChildCell 4, AdjustPosition 3, ResolveResult.Ok 2, SweepEye orchestration 2). The seating test's RED proved the sweep does NOT auto- advance feet->room, so the pivot-seated start cell is genuinely decisive. Core 1326 pass / 4 documented-fail / 1 skip; App 179 pass / 0 fail. No regression. Per the live-capture finding, the visible payoff is the cellar-corner (point 3); the cottage-room bluish void stays for residual C. Spec: docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
5.2 KiB
C#
117 lines
5.2 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>
|
||
/// Render Residual A — the verbatim <c>SmartBox::update_viewer</c>
|
||
/// (<c>acclient_2013_pseudo_c.txt:92761</c>) orchestration in
|
||
/// <see cref="PhysicsCameraCollisionProbe.SweepEye"/>: seat the sweep's start
|
||
/// cell at the head-PIVOT when indoor (via <c>AdjustPosition</c> →
|
||
/// <c>find_visible_child_cell</c>), and snap the eye to the player when there is
|
||
/// no start cell / the sweep fails.
|
||
/// </summary>
|
||
public class CameraCollisionUpdateViewerTests
|
||
{
|
||
private const uint FeetCellId = 0xA9B40174u; // cellar (low ≥ 0x0100 → indoor), interior Z ≤ 94
|
||
private const uint RoomCellId = 0xA9B40171u; // cottage floor above, interior Z ≥ 94, in feet cell's stab list
|
||
private const uint LandblockId = 0xA9B40000u;
|
||
|
||
/// <summary>
|
||
/// The cellar-lip case (user point 3): the player's FEET are in the low cellar
|
||
/// cell, but the head-PIVOT is up at cottage-floor level in a different cell.
|
||
/// Retail seats the sweep at the pivot's cell (<c>AdjustPosition</c>, pc:92832),
|
||
/// so the viewer cell is the room above — NOT the feet cell. Without the start-cell
|
||
/// port the sweep stays in the feet cell.
|
||
/// </summary>
|
||
[Fact]
|
||
public void SweepEye_IndoorPivotInCellAboveFeet_SeatsStartAtPivotCell()
|
||
{
|
||
var engine = BuildTwoCellEngine();
|
||
var probe = new PhysicsCameraCollisionProbe(engine);
|
||
|
||
var feet = new Vector3(0f, 0f, 93f); // in the feet cell (Z ≤ 94)
|
||
var pivot = new Vector3(0f, 0f, 94.5f); // head, up in the room cell (Z ≥ 94)
|
||
var eye = new Vector3(0f, 3f, 95.5f); // behind + up, still in the room region, no wall
|
||
|
||
var result = probe.SweepEye(pivot, eye, cellId: FeetCellId, selfEntityId: 0u, playerPos: feet);
|
||
|
||
Assert.Equal(RoomCellId, result.ViewerCellId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail <c>update_viewer</c> snaps the viewer to the player position when the
|
||
/// player has no cell (pc:92775) and as fallback 2 when the sweep fails
|
||
/// (pc:92886): <c>set_viewer(player_pos); viewer_cell = null</c>.
|
||
/// </summary>
|
||
[Fact]
|
||
public void SweepEye_NoStartCell_SnapsToPlayer()
|
||
{
|
||
var probe = new PhysicsCameraCollisionProbe(new PhysicsEngine());
|
||
|
||
var player = new Vector3(7f, 8f, 9f);
|
||
var result = probe.SweepEye(
|
||
pivot: new Vector3(7f, 8f, 10.5f), desiredEye: new Vector3(7f, 13f, 11f),
|
||
cellId: 0u, selfEntityId: 0u, playerPos: player);
|
||
|
||
Assert.Equal(player, result.Eye);
|
||
Assert.Equal(0u, result.ViewerCellId);
|
||
}
|
||
|
||
// ── fixture ────────────────────────────────────────────────────────────
|
||
|
||
private static PhysicsEngine BuildTwoCellEngine()
|
||
{
|
||
var cache = new PhysicsDataCache();
|
||
var engine = new PhysicsEngine { DataCache = cache };
|
||
|
||
// Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals
|
||
// (so the collision sweep cannot transit to the room — the start cell is decisive).
|
||
cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorZAtMost(94f), new uint[] { RoomCellId }));
|
||
// Room cell: interior Z ≥ 94, no walls, no portals.
|
||
cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty<uint>()));
|
||
|
||
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;
|
||
}
|
||
|
||
private static CellBSPNode InteriorZAtMost(float boundary) => new()
|
||
{
|
||
SplittingPlane = new Plane(new Vector3(0f, 0f, -1f), boundary), // dist = boundary − Z ≥ 0 ⇔ Z ≤ boundary
|
||
PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||
};
|
||
|
||
private static CellBSPNode InteriorZAtLeast(float boundary) => new()
|
||
{
|
||
SplittingPlane = new Plane(new Vector3(0f, 0f, 1f), -boundary), // dist = Z − boundary ≥ 0 ⇔ Z ≥ boundary
|
||
PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||
};
|
||
|
||
private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new()
|
||
{
|
||
BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } },
|
||
WorldTransform = Matrix4x4.Identity,
|
||
InverseWorldTransform = Matrix4x4.Identity,
|
||
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
||
CellBSP = new CellBSPTree { Root = cellBspRoot },
|
||
Portals = Array.Empty<PortalInfo>(),
|
||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
||
VisibleCellIds = new HashSet<uint>(visibleCellIds),
|
||
};
|
||
}
|