The camera spring-arm sweep already resolves the collided eye's cell (ResolveResult.CellId = sp.CurCellId = retail viewer_cell = sphere_path.curr_cell, update_viewer pc:92871). Return it from SweepEye so the render can root on the viewer cell (Phase W single-viewpoint V1, Task 1). Pure plumbing — behavior unchanged; callers extract .Eye. 174 App tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
436 lines
22 KiB
C#
436 lines
22 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>
|
|
/// Phase U — RED diagnostic test for the camera-collision indoor non-engagement bug.
|
|
///
|
|
/// <para>
|
|
/// Root cause (b): when the camera sphere is in an indoor cell, <see cref="ShadowObjectRegistry.GetNearbyObjects"/>
|
|
/// returns early at line 480 (<c>if ((primaryCellId & 0xFFFF) >= 0x0100) return;</c>),
|
|
/// skipping the outdoor radial sweep. The cottage exterior-shell GfxObj is registered
|
|
/// with <c>cellScope=0</c> (landblock-wide, outdoor) — it lives in the outdoor per-cell
|
|
/// shadow lists. With the indoor-primary gate active, the camera sweep (which uses
|
|
/// <see cref="ObjectInfoState.IsViewer"/> not <see cref="ObjectInfoState.IsPlayer"/>) never
|
|
/// finds the exterior shell while its sphere center is inside the indoor CellBSP volume.
|
|
/// Once the sphere center exits the CellBSP boundary (<see cref="PhysicsEngine.ResolveCellId"/>
|
|
/// falls through to outdoor), the outdoor sweep runs — but by then the sphere may have
|
|
/// already crossed the exterior wall polygon's front face (going in the same direction).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Evidence from post-fix live capture (<c>u4c-fix.log</c>): <c>eyeInRoot=n</c> ~90%
|
|
/// of frames; eye-player distance mean 3.43 m (full/zoomed chase, NOT pulled in).
|
|
/// The <c>[flap-sweep]</c> diagnostic in <see cref="PhysicsCameraCollisionProbe.SweepEye"/>
|
|
/// was designed to confirm this: <c>bsp=ok pulledIn≈0</c> means the cell is loaded with
|
|
/// a valid BSP but the sweep returns full eye distance, confirming the exterior shell is
|
|
/// not reached from the indoor context.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The issue #98 fix (2026-05-24) deliberately gates the outdoor sweep when the primary
|
|
/// cell is indoor — this is CORRECT for the player (prevents the cottage floor from
|
|
/// capping the player's head sphere). But it is WRONG for the camera probe
|
|
/// (<see cref="ObjectInfoState.IsViewer"/>), which needs to find the exterior building
|
|
/// shell to implement retail's <c>SmartBox::update_viewer</c> spring-arm pull-in.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Fixture gap: the actual residual cells (0xA9B40174/0175, main-floor cottage) are not
|
|
/// in the fixture set (the issue-98 fixtures cover 0xA9B4014X, a different cellar
|
|
/// cottage). This test uses a fully synthetic setup to prove the mechanism identically —
|
|
/// the issue #98 gate fires on any indoor primary cell id.
|
|
/// </para>
|
|
///
|
|
/// <para>Diagnosis doc: <c>docs/research/2026-05-31-camera-collision-indoor-diagnosis.md</c>.</para>
|
|
/// </summary>
|
|
public class CameraCollisionIndoorTests
|
|
{
|
|
// ── Geometry constants ─────────────────────────────────────────────────
|
|
// Room interior: player at world (0, 1, 94). Pivot = (0, 1, 95.5).
|
|
// Camera sweeps backward (+Y) and slightly upward.
|
|
// The EXTERIOR WALL GfxObj is at Y = 4.0 (just outside the room's back boundary
|
|
// at Y = 3.5). The interior CellBSP covers Y ∈ [-2, 3.5].
|
|
//
|
|
// Desired eye: Y = 5.0 — past the exterior wall.
|
|
//
|
|
// Expected: sweep stops at the exterior wall (pulledIn ≥ MinExpectedPullIn = 0.5 m).
|
|
// Actual: sweep reaches Y = 5.0 (pulledIn ≈ 0) because GetNearbyObjects skips the
|
|
// outdoor sweep when primaryCellId is indoor, so the GfxObj exterior wall is not
|
|
// tested while the sphere is inside the CellBSP volume. After the sphere crosses the
|
|
// CellBSP boundary (Y > 3.5 + ~0.3 = 3.8), ResolveCellId returns an outdoor cell
|
|
// and the outdoor sweep IS run — but the exterior wall is at Y = 4.0 and the sphere
|
|
// center is approaching from Y = 3.8 toward +Y, so the exterior wall polygon (with
|
|
// inward normal = -Y) is hit from its BACK FACE. If the wall polygon is one-sided
|
|
// (CullMode.Clockwise from the outer face), the back-face hit is suppressed and the
|
|
// sphere passes through. The net result is no stop.
|
|
|
|
private const uint IndoorCellId = 0xA9B40175u; // low 16 bits 0x0175 ≥ 0x0100 → indoor
|
|
private const uint LandblockId = 0xA9B40000u;
|
|
|
|
// Player head-pivot in world space.
|
|
private static readonly Vector3 PivotWorld = new(0f, 1f, 95.5f);
|
|
|
|
// Desired eye: backward and slightly above pivot.
|
|
// Goes from Y=1 to Y=5, passing through the exterior wall at Y=4.0.
|
|
private static readonly Vector3 DesiredEye = new(0f, 5f, 96.25f);
|
|
|
|
// Exterior wall GfxObj position: at Y=4.0, normal facing INTO the room (-Y).
|
|
// When seen from outside, the front face has +Y normal (outward). When seen
|
|
// from inside (camera going toward +Y), the facing side is the back face.
|
|
// The wall is registered with cellScope=0 (landblock-wide, outdoor shadow list).
|
|
private const float ExteriorWallY = 4.0f;
|
|
|
|
// The sphere should be stopped at approximately Y = ExteriorWallY - ViewerSphereRadius.
|
|
// Pulled-in distance ≥ MinExpectedPullIn.
|
|
private const float MinExpectedPullIn = 0.5f;
|
|
|
|
// CellBSP inner boundary: sphere is considered "inside" the cell when Y ≤ 3.5.
|
|
// Once the sphere center crosses Y = 3.5 + (radius + 0.01) ≈ 3.81, ResolveCellId
|
|
// will classify it as outdoor.
|
|
private const float CellBspBoundaryY = 3.5f;
|
|
|
|
// ── Test ───────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Documents the fix for the camera-collision indoor non-engagement bug (cause b).
|
|
///
|
|
/// <para>
|
|
/// Setup: indoor cell <c>0xA9B40175</c> with a CellBSP boundary at Y=3.5 and no
|
|
/// solid physics wall at that boundary (the room opens toward +Y, representing the
|
|
/// cottage front wall / portal). A landblock-baked exterior-shell GfxObj is registered
|
|
/// at Y=4.0 with <c>cellScope=0</c> (outdoor shadow list, NOT in the indoor cell's
|
|
/// portal-reachable set).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The fix: <see cref="ShadowObjectRegistry.GetNearbyObjects"/> now accepts an
|
|
/// <c>isViewer</c> parameter (default <c>false</c>). When <c>isViewer=true</c>, the
|
|
/// issue-#98 indoor gate is bypassed so the camera probe can reach the exterior-shell
|
|
/// GfxObj. <see cref="Transition.FindObjCollisions"/> passes <c>oi.IsViewer</c> (i.e.
|
|
/// <c>ObjectInfo.IsViewer</c> at TransitionTypes.cs:75) at the <c>GetNearbyObjects</c>
|
|
/// call site. The #98 gate remains active for all non-viewer (player, NPC) sweeps.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail faithfulness: <c>SmartBox::update_viewer</c> at
|
|
/// <c>acclient_2013_pseudo_c.txt:92761</c> bounds the viewer via the player-cell
|
|
/// enclosure; retail's interior EnvCells are self-enclosing. In acdream's data model
|
|
/// the enclosure is the exterior-shell GfxObj (issue #98). Retail's
|
|
/// <c>find_obj_collisions</c> at <c>:308918</c> has no indoor gate — so exempting
|
|
/// <c>IsViewer</c> is the faithful analog.
|
|
/// </para>
|
|
///
|
|
/// <para>This test PASSES with the fix, FAILS without it.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void SweepEye_IndoorCellExteriorGfxObjWall_StoppedByExteriorShell_AfterViewerGateExemption()
|
|
{
|
|
var (engine, _) = BuildEngineWithSyntheticRoom();
|
|
var probe = new PhysicsCameraCollisionProbe(engine);
|
|
|
|
var stoppedEye = probe.SweepEye(
|
|
pivot: PivotWorld,
|
|
desiredEye: DesiredEye,
|
|
cellId: IndoorCellId,
|
|
selfEntityId: 0u).Eye;
|
|
|
|
// The eye should be stopped before the exterior wall at Y=4.0.
|
|
// Expected stopped eye Y ≈ 4.0 - ViewerSphereRadius = 3.7.
|
|
// Pulled-in = |DesiredEye.Y - stoppedEye.Y| should be ≥ 0.5 m.
|
|
float pulledIn = MathF.Abs(DesiredEye.Y - stoppedEye.Y);
|
|
|
|
Assert.True(
|
|
pulledIn >= MinExpectedPullIn,
|
|
$"Camera sweep should be stopped by the exterior-shell GfxObj wall at " +
|
|
$"Y={ExteriorWallY:F1} (registered outdoor/landblock-wide, cellScope=0). " +
|
|
$"Actual pulled-in: {pulledIn:F4} m (stopped eye Y={stoppedEye.Y:F4}). " +
|
|
$"REGRESSION: ShadowObjectRegistry.GetNearbyObjects indoor gate is incorrectly " +
|
|
$"blocking the IsViewer (camera) sweep from reaching the exterior-shell GfxObj. " +
|
|
$"Fix: pass isViewer=true at the FindObjCollisions call site so the indoor gate " +
|
|
$"is bypassed for camera sweeps (ShadowObjectRegistry.cs, TransitionTypes.cs:~2307).");
|
|
}
|
|
|
|
// ── Issue #98 regression guard ────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Regression guard: the issue-#98 indoor gate must remain active for non-viewer sweeps.
|
|
///
|
|
/// <para>
|
|
/// A GfxObj registered with <c>cellScope=0</c> (outdoor shadow list) must NOT be returned
|
|
/// by <see cref="ShadowObjectRegistry.GetNearbyObjects"/> when the primary cell is indoor
|
|
/// and <c>isViewer=false</c> (i.e. the default player / NPC path). This is the protection
|
|
/// that prevents the cottage-floor polygon from capping the player's head sphere while the
|
|
/// player is in the cellar directly below.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Issue #98 fix (2026-05-24): gate fires at <c>ShadowObjectRegistry.cs:~480</c> when
|
|
/// <c>(primaryCellId & 0xFFFF) >= 0x0100</c> AND <c>isViewer=false</c>. This test
|
|
/// ensures the guard cannot regress.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj()
|
|
{
|
|
var registry = new ShadowObjectRegistry();
|
|
|
|
// Register a GfxObj at cellScope=0 (landblock-wide / outdoor shadow list).
|
|
const uint EntityId = 0x00010001u;
|
|
const uint GfxId = 0x01000001u;
|
|
const uint LbId = 0xA9B40000u;
|
|
var pos = new Vector3(0f, 4f, 96f);
|
|
registry.Register(
|
|
entityId: EntityId,
|
|
gfxObjId: GfxId,
|
|
worldPos: pos,
|
|
rotation: System.Numerics.Quaternion.Identity,
|
|
radius: 10f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LbId,
|
|
collisionType: ShadowCollisionType.BSP,
|
|
scale: 1.0f,
|
|
cellScope: 0u); // outdoor, landblock-wide
|
|
|
|
var results = new List<ShadowEntry>();
|
|
|
|
// Non-viewer query with indoor primary cell — gate must fire, GfxObj NOT returned.
|
|
registry.GetNearbyObjects(
|
|
worldPos: pos,
|
|
queryRadius: 20f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LbId,
|
|
results: results,
|
|
portalReachableCells: null,
|
|
primaryCellId: 0xA9B40175u, // indoor cell (low 16 bits 0x0175 >= 0x0100)
|
|
isViewer: false);
|
|
|
|
Assert.Empty(results); // Issue #98 gate must block the outdoor GfxObj for non-viewer sweeps.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression guard: the viewer exemption allows the camera to reach outdoor GfxObjs
|
|
/// registered at <c>cellScope=0</c> even when the primary cell is indoor.
|
|
///
|
|
/// <para>
|
|
/// This is the dual of <see cref="GetNearbyObjects_IndoorPrimaryCell_NonViewer_DoesNotReturnOutdoorGfxObj"/>:
|
|
/// the same GfxObj / same indoor primary cell, but <c>isViewer=true</c>.
|
|
/// The outdoor sweep must run and return the GfxObj.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail faithfulness: <c>SmartBox::update_viewer</c> (acclient_2013_pseudo_c.txt:92761)
|
|
/// calls <c>find_obj_collisions</c> (:308918) which has no indoor-cell gate — the viewer
|
|
/// reaches any geometry in the player's cell enclosure. The #98 gate is an acdream-specific
|
|
/// workaround that must not apply to the viewer.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void GetNearbyObjects_IndoorPrimaryCell_IsViewer_DoesReturnOutdoorGfxObj()
|
|
{
|
|
var registry = new ShadowObjectRegistry();
|
|
|
|
// Same outdoor-scope GfxObj as the non-viewer test above.
|
|
const uint EntityId = 0x00010002u;
|
|
const uint GfxId = 0x01000002u;
|
|
const uint LbId = 0xA9B40000u;
|
|
var pos = new Vector3(0f, 4f, 96f);
|
|
registry.Register(
|
|
entityId: EntityId,
|
|
gfxObjId: GfxId,
|
|
worldPos: pos,
|
|
rotation: System.Numerics.Quaternion.Identity,
|
|
radius: 10f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LbId,
|
|
collisionType: ShadowCollisionType.BSP,
|
|
scale: 1.0f,
|
|
cellScope: 0u); // outdoor, landblock-wide
|
|
|
|
var results = new List<ShadowEntry>();
|
|
|
|
// Viewer query with indoor primary cell — gate must be bypassed, GfxObj IS returned.
|
|
registry.GetNearbyObjects(
|
|
worldPos: pos,
|
|
queryRadius: 20f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LbId,
|
|
results: results,
|
|
portalReachableCells: null,
|
|
primaryCellId: 0xA9B40175u, // indoor cell (low 16 bits 0x0175 >= 0x0100)
|
|
isViewer: true);
|
|
|
|
Assert.NotEmpty(results); // Viewer must bypass the indoor gate and find the exterior GfxObj.
|
|
Assert.Equal(EntityId, results[0].EntityId);
|
|
}
|
|
|
|
// ── Engine + fixture builder ──────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a minimal <see cref="PhysicsEngine"/> with:
|
|
/// <list type="bullet">
|
|
/// <item>One synthetic indoor cell (<see cref="IndoorCellId"/>), identity world transform.
|
|
/// CellBSP boundary at Y=<see cref="CellBspBoundaryY"/>.
|
|
/// PhysicsBSP is an empty leaf (no interior wall polygons at the target side —
|
|
/// represents an open portal/doorway toward +Y).</item>
|
|
/// <item>One exterior-shell GfxObj registered with <c>cellScope=0</c>
|
|
/// (landblock-wide, outdoor shadow list). The GfxObj has a wall polygon
|
|
/// at Y=<see cref="ExteriorWallY"/>, representing the cottage exterior shell
|
|
/// that retail's camera spring-arm should stop on.</item>
|
|
/// <item>A stub landblock with terrain far below (Z=-1000) to prevent outdoor
|
|
/// terrain collision from interfering.</item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// This fixture directly reproduces the production gap: the issue-#98 fix
|
|
/// (<see cref="ShadowObjectRegistry.GetNearbyObjects"/> early-return at line 480)
|
|
/// correctly prevents indoor spheres (the PLAYER) from being capped by the landblock-baked
|
|
/// cottage floor. But it also prevents the camera sphere (<see cref="ObjectInfoState.IsViewer"/>)
|
|
/// from seeing the exterior shell GfxObj — the same fix that closes issue #98 is what
|
|
/// breaks camera-collision indoors.
|
|
/// </para>
|
|
/// </summary>
|
|
private static (PhysicsEngine engine, PhysicsDataCache cache)
|
|
BuildEngineWithSyntheticRoom()
|
|
{
|
|
var cache = new PhysicsDataCache();
|
|
var engine = new PhysicsEngine { DataCache = cache };
|
|
|
|
// ── 1. Indoor cell with open-toward-+Y boundary ────────────────────
|
|
// PhysicsBSP: empty leaf — no walls on the +Y side. This represents
|
|
// a room that has a portal (doorway / open passage) toward +Y.
|
|
// The exterior shell is NOT part of any indoor cell's BSP.
|
|
var emptyLeaf = new PhysicsBSPNode
|
|
{
|
|
Type = BSPNodeType.Leaf,
|
|
BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 20f },
|
|
};
|
|
var emptyBsp = new PhysicsBSPTree { Root = emptyLeaf };
|
|
|
|
// CellBSP: splitting plane at Y = CellBspBoundaryY with normal = -Y
|
|
// (interior is at Y ≤ CellBspBoundaryY).
|
|
// SphereIntersectsCellBsp returns false when:
|
|
// dist = dot(-Y, center) + CellBspBoundaryY = CellBspBoundaryY - center.Y
|
|
// < -(radius + 0.01f)
|
|
// i.e. center.Y > CellBspBoundaryY + radius + 0.01
|
|
// For radius=0.3: center.Y > 3.5 + 0.31 = 3.81.
|
|
var cellBspLeaf = new CellBSPNode { Type = BSPNodeType.Leaf };
|
|
var cellBspRoot = new CellBSPNode
|
|
{
|
|
SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), CellBspBoundaryY),
|
|
PosNode = cellBspLeaf,
|
|
};
|
|
|
|
var indoorCell = new CellPhysics
|
|
{
|
|
BSP = emptyBsp,
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
Resolved = new Dictionary<ushort, ResolvedPolygon>(), // no interior walls toward +Y
|
|
CellBSP = new CellBSPTree { Root = cellBspRoot },
|
|
Portals = Array.Empty<PortalInfo>(),
|
|
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
|
VisibleCellIds = new System.Collections.Generic.HashSet<uint>(),
|
|
};
|
|
cache.RegisterCellStructForTest(IndoorCellId, indoorCell);
|
|
|
|
// ── 2. Exterior shell GfxObj registered OUTDOORS (cellScope=0) ─────
|
|
// This is the landblock-baked cottage exterior shell. The wall polygon
|
|
// at Y=ExteriorWallY has its front face pointing INTO the room (-Y normal)
|
|
// — so from the outside the polygon's front face faces +Y (outward).
|
|
// When the camera sphere approaches from inside (+Y direction), it hits
|
|
// the BACK face of this polygon.
|
|
//
|
|
// We register it with cellScope=0 (landblock-wide), which puts it in the
|
|
// outdoor per-cell shadow lists — NOT in the indoor cell's portal-reachable
|
|
// set. This mirrors how production registers landblock-baked statics:
|
|
// GameWindow.cs:5899 uses entity.ParentCellId ?? 0u → 0 for top-level statics.
|
|
const uint ExteriorShellEntityId = 0x00990001u;
|
|
const uint ExteriorShellGfxId = 0x01AABB01u;
|
|
|
|
// Wall polygon in OBJECT-LOCAL space (GfxObj registered at world Y=ExteriorWallY).
|
|
// In local space the wall is at Y=0 (directly at the GfxObj's origin).
|
|
// Normal stays (-Y, facing INTO the room) — same direction as in world space.
|
|
// X ∈ [-3, 3], Z ∈ [-3, 3] (local, centered on the GfxObj's world origin).
|
|
var wallNormal = new Vector3(0f, -1f, 0f);
|
|
const float wallLocalD = 0f; // wall at local Y=0 (GfxObj origin)
|
|
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 Plane(wallNormal, wallLocalD),
|
|
NumPoints = 4,
|
|
SidesType = CullMode.None, // two-sided: should stop from both directions
|
|
};
|
|
|
|
// GfxObj PhysicsBSP: single leaf containing the exterior wall.
|
|
// BoundingSphere in OBJECT-LOCAL space: centered at origin (0,0,0), radius 10.
|
|
var gfxLeaf = new PhysicsBSPNode
|
|
{
|
|
Type = BSPNodeType.Leaf,
|
|
BoundingSphere = new Sphere
|
|
{
|
|
Origin = Vector3.Zero, // local-space center
|
|
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(ExteriorShellGfxId, gfxPhysics);
|
|
|
|
// Register in the OUTDOOR shadow list (cellScope=0 → landblock-wide).
|
|
// This mirrors production's GameWindow.cs:5893 for landblock-baked statics.
|
|
engine.ShadowObjects.Register(
|
|
entityId: ExteriorShellEntityId,
|
|
gfxObjId: ExteriorShellGfxId,
|
|
worldPos: new Vector3(0f, ExteriorWallY, 96f),
|
|
rotation: Quaternion.Identity,
|
|
radius: 10f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LandblockId,
|
|
collisionType: ShadowCollisionType.BSP,
|
|
scale: 1.0f,
|
|
cellScope: 0u); // ← landblock-wide outdoor, NOT indoor cell scope
|
|
|
|
// ── 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;
|
|
var stubTerrain = new TerrainSurface(heights, heightTable);
|
|
engine.AddLandblock(
|
|
landblockId: LandblockId,
|
|
terrain: stubTerrain,
|
|
cells: Array.Empty<CellSurface>(),
|
|
portals: Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
|
|
return (engine, cache);
|
|
}
|
|
}
|