acdream/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs
Erik 9e70031bc6 feat(A): wire SweepEye to the verbatim update_viewer (start-cell + fallbacks)
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>
2026-06-05 11:10:32 +02:00

117 lines
5.2 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.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),
};
}