The two remaining flagged workarounds retired, per the BR-7 plan +
the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add):
1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the
collision cell array ONLY on the retail straddle gate - |dist| <
radius + F_EPSILON against an exterior portal plane
(CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6,
live-binary verified) - the same flag that already gated the
membership pick (#112 rider). The widening existed so outdoor-
registered doors stayed findable from indoor cells under the old flat
registry query; with per-cell shadow lists the door is found in the
straddle-admitted outdoor cell's own list (tick-13558 pin holds).
The hasExitPortal out-param + plumbing deleted from
FindTransitCellsSphere; the AddAllOutsideCells call in
BuildCellSetAndPickContaining re-gated on exitOutsideStraddle
(once-per-walk = retail CELLARRAY.added_outside).
2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596
workaround, deferred-to-A6.P4 in the physics digest). It was dead
code: the method's only caller is FindEnvCollisions' cache-null TEST
fallback, and the indoor branch (where the stickiness lived) required
a non-null DataCache. Production membership flows exclusively through
the collide-then-pick advance whose ordered-array hysteresis (current
cell at index 0, interior-wins-break) is the retail mechanism the
workaround approximated. ResolveCellId reduced to the bare
prefix-preserving outdoor re-derive, documented test-only.
Test updates (pins of the deleted behaviors inverted to retail):
- A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the
topology widening verbatim) -> DeepInteriorSphere_NoStraddle_
AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells.
- A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_
ReachesDoorOutdoorCell (the captured alcove position genuinely
straddles - the retail-positive half).
- Issue112MembershipTests straddle pin + the second-sphere straddle test
updated to the single-flag signature.
Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142 lines
5.6 KiB
C#
142 lines
5.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Env = System.Environment;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="CellTransit"/> portal-graph expansion.
|
|
///
|
|
/// <para>
|
|
/// History: A6.P5 (2026-05-25) widened the collision cell set on exit-
|
|
/// portal TOPOLOGY (outdoor cells added whenever an overlapped cell had a
|
|
/// 0xFFFF portal) so outdoor-registered doors stayed findable from indoor
|
|
/// cells under the flat registry query. BR-7 / A6.P4 C4 (2026-06-11)
|
|
/// removed the widening: outdoor cells enter the array only on the retail
|
|
/// STRADDLE gate (|dist| < radius + F_EPSILON vs the exterior portal
|
|
/// plane — CEnvCell::find_transit_cells, Ghidra 0x0052c820, live-binary
|
|
/// verified), and doors are found per-cell via registration-time
|
|
/// membership instead. These tests pin BOTH halves of the retail
|
|
/// semantics on real Holtburg dat geometry.
|
|
/// </para>
|
|
/// </summary>
|
|
public class CellTransitTests
|
|
{
|
|
[Fact]
|
|
public void DeepInteriorSphere_NoStraddle_AddsNoOutdoorCells()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) return;
|
|
|
|
// Hydrate the cells in the portal chain.
|
|
// 0xA9B4013F — deep interior cell (the old over-penetration tick)
|
|
// 0xA9B40150 — alcove cell adjacent to the doorway (exit portal)
|
|
var cache = new PhysicsDataCache();
|
|
HydrateCell(cache, datDir, 0xA9B4013Fu);
|
|
HydrateCell(cache, datDir, 0xA9B40150u);
|
|
|
|
Assert.NotNull(cache.GetCellStruct(0xA9B4013Fu));
|
|
Assert.NotNull(cache.GetCellStruct(0xA9B40150u));
|
|
|
|
// Sphere deep in 0x13F's volume — no path sphere straddles
|
|
// 0x150's exit portal plane from here (the A6.P5 pin's own
|
|
// message documented exactly that). Retail adds NO outdoor cells
|
|
// in this state; the door (outdoor 0xA9B40029) is reached only
|
|
// when the sphere actually straddles the threshold — pinned by
|
|
// the alcove test below and the tick-13558 door pin.
|
|
var sphereWorld = new Vector3(132.5935f, 16.350428f, 94.48f);
|
|
const float sphereRadius = 0.48f;
|
|
const uint startCellId = 0xA9B4013Fu;
|
|
|
|
_ = CellTransit.FindCellSet(
|
|
cache,
|
|
sphereWorld,
|
|
sphereRadius,
|
|
startCellId,
|
|
out var cellSet);
|
|
|
|
Assert.DoesNotContain(cellSet, c => (c & 0xFFFFu) < 0x0100u);
|
|
Assert.Contains(startCellId, cellSet);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlcoveSphere_StraddlesExitPortal_ReachesDoorOutdoorCell()
|
|
{
|
|
var datDir = ResolveDatDir();
|
|
if (datDir is null) return;
|
|
|
|
// The alcove cell 0xA9B40150 IS the cell with the exit portal, and
|
|
// at this captured position the foot sphere straddles its plane
|
|
// (|dist| < 0.48 + ε — same geometry the tick-13558 straddle pin
|
|
// proves). Retail admits the outdoor cells exactly here, which is
|
|
// how the indoor-side sphere reaches the outdoor-registered door's
|
|
// per-cell shadow list.
|
|
var cache = new PhysicsDataCache();
|
|
HydrateCell(cache, datDir, 0xA9B40150u);
|
|
|
|
var sphereWorld = new Vector3(132.4014f, 16.761757f, 94.48f);
|
|
const float sphereRadius = 0.48f;
|
|
|
|
_ = CellTransit.FindCellSet(
|
|
cache,
|
|
sphereWorld,
|
|
sphereRadius,
|
|
0xA9B40150u,
|
|
out var cellSet);
|
|
|
|
Assert.True(
|
|
cellSet.Contains(0xA9B40029u),
|
|
$"Straddle-gated outside-add: the alcove sphere straddles " +
|
|
$"0xA9B40150's exit portal, so the door's outdoor cell " +
|
|
$"0xA9B40029 must enter the set (retail " +
|
|
$"CEnvCell::find_transit_cells straddle, Ghidra 0x0052c820). " +
|
|
$"Actual: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private static string? ResolveDatDir()
|
|
{
|
|
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
|
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
|
"Documents", "Asheron's Call");
|
|
return Directory.Exists(datDir) ? datDir : null;
|
|
}
|
|
|
|
private static void HydrateCell(PhysicsDataCache cache, string datDir, uint cellId)
|
|
{
|
|
const uint EnvCellPrefix = 0x0D000000u;
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
var envCell = dats.Get<EnvCell>(cellId);
|
|
if (envCell is null)
|
|
throw new InvalidOperationException(
|
|
$"Cell 0x{cellId:X8} missing from dat at {datDir}.");
|
|
|
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
|
|
EnvCellPrefix | envCell.EnvironmentId);
|
|
if (environment is null)
|
|
throw new InvalidOperationException(
|
|
$"Environment 0x{EnvCellPrefix | envCell.EnvironmentId:X8} missing.");
|
|
|
|
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
|
|
throw new InvalidOperationException(
|
|
$"CellStructure {envCell.CellStructure} missing in environment.");
|
|
|
|
var worldTransform =
|
|
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
|
Matrix4x4.CreateTranslation(envCell.Position.Origin);
|
|
|
|
cache.CacheCellStruct(cellId, envCell, cellStruct, worldTransform);
|
|
}
|
|
}
|