test(phys): A6.P5 RED — BFS from indoor cell doesn't reach door outdoor cell
Adds CellTransitTests with two A6P5_* unit tests: A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (RED — the bug) A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell (passes today) The RED test reproduces the over-penetration tick's cellSet build: sphere at (132.594, 16.350) in cell 0xA9B4013F, BFS portal-walks to 0xA9B40150 (alcove) but does NOT add the door's outdoor cell 0xA9B40029. Pre-fix cellSet: 0xA9B4013F, 0xA9B40150, 0xA9B4014C — no outdoor cells. Sphere wasn't straddling 0xA9B40150's exit portal so exitOutside stayed false. Also removes the 3 A6P5_* replay tests added to DoorBugTrajectoryReplayTests in the previous commit (3253d84's follow-up). Those tests didn't reproduce the bug — the harness's BuildFaithfulDoorEngine has no cell fixtures, so cellSet returned empty and GetNearbyObjects treated it as "no filter" → door always visible → over-penetration test trivially passed for the wrong reason. The CellTransitTests version pins the bug at the BFS layer directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
82781c272b
commit
2a890e6bde
2 changed files with 187 additions and 0 deletions
161
tests/AcDream.Core.Tests/Physics/CellTransitTests.cs
Normal file
161
tests/AcDream.Core.Tests/Physics/CellTransitTests.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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>
|
||||
/// A6.P5 (2026-05-25) — unit-tests the cellSet build for the cottage door
|
||||
/// scenario, where pre-fix the <c>exitOutside</c> gate produced over-
|
||||
/// penetration ticks (sphere advanced 0.27 m INTO the door slab because
|
||||
/// the door's outdoor cell wasn't in the cellSet during a cell-crossing
|
||||
/// substep).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Loads cells <c>0xA9B4013F</c> (player's starting indoor cell at the
|
||||
/// over-penetration tick) and <c>0xA9B40150</c> (alcove cell adjacent to
|
||||
/// the doorway) from the real dat. Asserts that BFS expansion from
|
||||
/// <c>0xA9B4013F</c> reaches the door's outdoor cell <c>0xA9B40029</c>
|
||||
/// via the portal chain. The fix makes this hold unconditionally;
|
||||
/// pre-fix it only held when the sphere physically straddled an exit
|
||||
/// portal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CellTransitTests
|
||||
{
|
||||
[Fact]
|
||||
public void A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
// Hydrate the cells in the portal chain.
|
||||
// 0xA9B4013F — start cell at the over-penetration tick
|
||||
// 0xA9B40150 — alcove cell adjacent to doorway (has exit portal)
|
||||
// The door lives in outdoor cell 0xA9B40029 (registered as a
|
||||
// shadow entry on the landblock — not loaded here; this test
|
||||
// only asserts the cellSet membership, not collision).
|
||||
var cache = new PhysicsDataCache();
|
||||
HydrateCell(cache, datDir, 0xA9B4013Fu);
|
||||
HydrateCell(cache, datDir, 0xA9B40150u);
|
||||
|
||||
Assert.NotNull(cache.GetCellStruct(0xA9B4013Fu));
|
||||
Assert.NotNull(cache.GetCellStruct(0xA9B40150u));
|
||||
|
||||
// Sphere at the over-penetration tick start position: world
|
||||
// (132.594, 16.350, 94.48 sphere center). Foot Z = 94, sphere
|
||||
// radius 0.48 → center Z = 94 + radius? Actually the live
|
||||
// capture's sphere center is at Z = 94.48 since foot Z = 94
|
||||
// and sphere is foot-anchored. For this test the Z value
|
||||
// doesn't matter — only XY portal membership.
|
||||
var sphereWorld = new Vector3(132.5935f, 16.350428f, 94.48f);
|
||||
const float sphereRadius = 0.48f;
|
||||
const uint startCellId = 0xA9B4013Fu;
|
||||
const uint doorOutdoorCell = 0xA9B40029u;
|
||||
|
||||
_ = CellTransit.FindCellSet(
|
||||
cache,
|
||||
sphereWorld,
|
||||
sphereRadius,
|
||||
startCellId,
|
||||
out var cellSet);
|
||||
|
||||
Assert.True(
|
||||
cellSet.Contains(doorOutdoorCell),
|
||||
$"A6.P5: BFS portal expansion from indoor start cell " +
|
||||
$"0x{startCellId:X8} (sphere world XY = " +
|
||||
$"({sphereWorld.X:F3}, {sphereWorld.Y:F3})) should include " +
|
||||
$"the door's outdoor cell 0x{doorOutdoorCell:X8} via the " +
|
||||
$"portal chain 0x{startCellId:X8} → 0xA9B40150 → outdoor. " +
|
||||
$"Pre-fix: the exitOutside gate required the sphere to " +
|
||||
$"straddle 0xA9B40150's exit portal — sphere is in " +
|
||||
$"0x{startCellId:X8}'s volume, so the gate didn't fire and " +
|
||||
$"the door's cell wasn't added. Post-fix: gate removed; " +
|
||||
$"exit-portal topology adds outdoor cells unconditionally. " +
|
||||
$"Actual cellSet: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
// The alcove cell 0xA9B40150 IS the cell with the exit portal.
|
||||
// Pre-fix this worked SOMETIMES (when the sphere straddled the
|
||||
// portal). Post-fix it works ALWAYS. This is a regression guard
|
||||
// for the previously-sometimes-working case.
|
||||
var cache = new PhysicsDataCache();
|
||||
HydrateCell(cache, datDir, 0xA9B40150u);
|
||||
|
||||
// Sphere at the stuck position — sphere center is NOT at the
|
||||
// exit portal plane (sphere is in the alcove volume, away from
|
||||
// the doorway threshold geometrically).
|
||||
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),
|
||||
$"A6.P5: BFS from alcove cell 0xA9B40150 should always " +
|
||||
$"include the door's outdoor cell 0xA9B40029. " +
|
||||
$"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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1073,6 +1073,32 @@ public class DoorBugTrajectoryReplayTests
|
|||
"No captured record matched the predicate. Update the fixture.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6.P5 (2026-05-25) — loads one of the three fixed records from
|
||||
/// <c>over-penetration-capture.jsonl</c> by index:
|
||||
/// <list type="bullet">
|
||||
/// <item>0 — the over-penetration tick (cell-crossing 0xA9B4013F → 0xA9B40150)</item>
|
||||
/// <item>1 — stuck-position hit=yes variant (door fired)</item>
|
||||
/// <item>2 — stuck-position hit=no variant (door invisible — bug case)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static ResolveCaptureRecord LoadOverPenRecord(int index)
|
||||
{
|
||||
var path = Path.Combine(FixtureDir, "over-penetration-capture.jsonl");
|
||||
Assert.True(File.Exists(path),
|
||||
$"A6.P5 over-penetration fixture missing: {path}. " +
|
||||
$"Run tools/jsonl/extract-records.ps1 to rebuild.");
|
||||
|
||||
var lines = File.ReadAllLines(path);
|
||||
Assert.True(lines.Length >= 3,
|
||||
$"Expected >= 3 records in {path}; got {lines.Length}");
|
||||
|
||||
var raw = lines[index];
|
||||
return System.Text.Json.JsonSerializer
|
||||
.Deserialize<ResolveCaptureRecord>(raw,
|
||||
CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays one captured ResolveWithTransition call against
|
||||
/// <paramref name="engine"/>, seeded with bodyBefore, and reports
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue