Root cause (oracle: CLandCell::point_in_cell :316941 = terrain-poly only;
find_cell_list null-result keep-curr pc:308788-308825; CEnvCell::
check_building_transit :309827 = sphere_intersects_cell per portal-adjacent
cell): retail KEEPS curr_cell when nothing contains the centre — including
inside a house's containment gaps. Our 6dbbf95 escape hatch instead demoted
any hydrated indoor claim the sphere no longer overlaps to the outdoor
column; at the A9B3 hill cottage's real interior gap this stranded the
player outdoor-classified deep indoors, where re-promotion is portal-
adjacent-only (retail-identical) -> the outdoor flood rendered the interior
transparent (the user's "sometimes transparent" walk).
The hatch's actual target - poisoned (cell, position) SAVES - has been
handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition validation
since #107/#111, so the per-tick pick reverts to retail semantics:
1. lateral recovery first - when the sphere no longer overlaps the claim,
search the claim's stab list for a containing cell (retail
find_visible_child_cell :311444, the same recovery AdjustPosition uses);
the #111 adjacent-claim shape now self-heals laterally (dat-backed test:
pick(seed 0x172) at a 0x171-interior point -> 0x171);
2. else KEEP curr_cell (retail null-result).
Two old tests asserting the hatch demote rewritten to the retail semantics
(tests-can-codify-bugs); P1 retail-golden conformance gates explicitly green
(FindCellListConformance + ThresholdPortalCrossing + CottageDoorway +
CameraCornerSeal = 11/11). New Issue112MembershipTests: the lateral-recovery
fact + a DocumentsResidual fact pinning the remaining at-doorway gap demote
(via the NORMAL outdoor-candidate path; open oracle read = retail's
add_all_outside_cells gate in CEnvCell::find_transit_cells pc:317499 -
sphere-proximity vs graph-reachability). Core 1383 + 4 pre-existing #99
failures + 1 skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
7.9 KiB
C#
165 lines
7.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World.Cells;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.Core.Tests.Conformance;
|
|
|
|
/// <summary>
|
|
/// #107 (2026-06-10): the indoor-login spawn wedge. The live capture
|
|
/// (resolve-107-login1.jsonl) showed ACE restoring the player with a POISONED
|
|
/// (cell, position) pair — cell id 0xA9B40162 (a different building, ~55 m
|
|
/// away) with a position inside cell 0xA9B40171 (the threshold cottage). The
|
|
/// unvalidated claim left the player fake-grounded (no contact plane, no
|
|
/// walkable polygon); the first movement demoted them to outdoor mid-building
|
|
/// and they fell 2.4 m through the cottage floor onto the terrain underneath.
|
|
///
|
|
/// These tests pin the dat-level facts that drove the fix (the retail
|
|
/// AdjustPosition validation at the player snap, PhysicsEngine.Resolve head):
|
|
/// the position is genuinely inside 0x171, genuinely NOT inside 0x162's
|
|
/// visible graph, and the production pick recovers correctly from a GOOD seed
|
|
/// while demoting to outdoor from the poisoned one (retail-identical: retail's
|
|
/// find_visible_child_cell also cannot recover a cross-building claim and
|
|
/// falls to seen_outside → adjust_to_outside, acclient :280037).
|
|
/// </summary>
|
|
public sealed class Issue107SpawnDiagnosticTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public Issue107SpawnDiagnosticTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private const float FootRadius = 0.48f; // capture input.sphereRadius
|
|
private const uint PoisonedClaim = 0xA9B40162u; // the inn-side cell ACE reported
|
|
private const uint ActualCell = 0xA9B40171u; // the cell that contains the position
|
|
|
|
/// <summary>The captured spawn position (A9B4-local == capture world frame).</summary>
|
|
private static readonly Vector3 Spawn = new(156.53314f, 11.775104f, 96.5f);
|
|
|
|
[Fact]
|
|
public void SpawnPosition_IsInside0171_NotInside0162_PickRecoversFromGoodSeed()
|
|
{
|
|
var datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
var cache = new PhysicsDataCache();
|
|
|
|
var containing = new List<uint>();
|
|
int loaded = 0;
|
|
for (uint low = 0x0100; low <= 0x01FF; low++)
|
|
{
|
|
uint cellId = 0xA9B40000u | low;
|
|
EnvCell cell;
|
|
try { cell = ConformanceDats.LoadEnvCell(dats, cache, cellId); }
|
|
catch { continue; }
|
|
loaded++;
|
|
if (cell.PointInCell(Spawn + new Vector3(0, 0, FootRadius)))
|
|
{
|
|
containing.Add(cellId);
|
|
_out.WriteLine($"cell 0x{cellId:X8} CONTAINS spawn footCenter");
|
|
}
|
|
}
|
|
Assert.True(loaded > 0, "expected Holtburg interior cells to load");
|
|
|
|
// The poisoned-pair facts: position inside 0x171, NOT inside 0x162.
|
|
Assert.Contains(ActualCell, containing);
|
|
Assert.DoesNotContain(PoisonedClaim, containing);
|
|
|
|
var footCenter = Spawn + new Vector3(0, 0, FootRadius);
|
|
|
|
// Production pick with the GOOD seed keeps the player indoor.
|
|
uint pickGood = CellTransit.FindCellList(cache, footCenter, FootRadius, ActualCell);
|
|
_out.WriteLine($"FindCellList(seed=0x{ActualCell:X8}) -> 0x{pickGood:X8}");
|
|
Assert.Equal(ActualCell, pickGood);
|
|
|
|
// Production pick with the POISONED seed KEEPS it — retail's
|
|
// find_cell_list null-result behavior (pc:308788-308825; #112 removed
|
|
// the non-retail escape-hatch demote that used to return the outdoor
|
|
// column here). The poisoned-save case is handled at the SNAP:
|
|
// PhysicsEngine.Resolve's AdjustPosition validation rejects the claim
|
|
// and demotes via seen_outside BEFORE physics ever runs on it.
|
|
uint pickBad = CellTransit.FindCellList(cache, footCenter, FootRadius, PoisonedClaim);
|
|
_out.WriteLine($"FindCellList(seed=0x{PoisonedClaim:X8}) -> 0x{pickBad:X8}");
|
|
Assert.Equal(PoisonedClaim, pickBad);
|
|
}
|
|
|
|
[Fact]
|
|
public void SeenOutside_IsPopulated_ForGroundFloorInteriors()
|
|
{
|
|
var datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
var cache = new PhysicsDataCache();
|
|
|
|
ConformanceDats.LoadEnvCell(dats, cache, ActualCell);
|
|
ConformanceDats.LoadEnvCell(dats, cache, PoisonedClaim);
|
|
|
|
var room = cache.GetCellStruct(ActualCell);
|
|
Assert.NotNull(room);
|
|
// 0x171 is a ground-floor cottage room with an exterior doorway —
|
|
// retail marks it seen_outside; AdjustPosition's indoor not-found
|
|
// fallback (acclient :280037) depends on this flag being cached.
|
|
Assert.True(room!.SeenOutside, "0xA9B40171 should be seen_outside");
|
|
_out.WriteLine($"0x{ActualCell:X8}.SeenOutside={room.SeenOutside}");
|
|
|
|
var inn = cache.GetCellStruct(PoisonedClaim);
|
|
Assert.NotNull(inn);
|
|
_out.WriteLine($"0x{PoisonedClaim:X8}.SeenOutside={inn!.SeenOutside}");
|
|
}
|
|
|
|
[Fact]
|
|
public void AdjacentRoomClaim_CorrectsToContainingRoom_ViaStabList()
|
|
{
|
|
// 2026-06-10 gate-run regression: ACE saved the cell one room off
|
|
// (claim 0xA9B40172, position inside 0xA9B40171 — adjacent rooms of the
|
|
// threshold cottage, connected via the 20 cm threshold cell 0x173).
|
|
// With every cell hydrated, retail's find_visible_child_cell stab-list
|
|
// search (acclient :311444) must correct the claim. The live failure
|
|
// was the auto-entry hold opening before 0x171 hydrated (the
|
|
// mid-population "any cell present" disambiguator, since removed) —
|
|
// this test pins the hydrated-path correctness.
|
|
var datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
var cache = new PhysicsDataCache();
|
|
for (uint low = 0x0100; low <= 0x01FF; low++)
|
|
{
|
|
try { ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40000u | low); }
|
|
catch { }
|
|
}
|
|
|
|
// The gate-run spawn point (ACE save: cell 0x172, position in 0x171).
|
|
var spawnFootCenter = new Vector3(155.390f, 11.020f, 94.0f + FootRadius);
|
|
|
|
uint corrected = CellTransit.FindVisibleChildCell(
|
|
cache, 0xA9B40172u, spawnFootCenter, useStabList: true);
|
|
_out.WriteLine($"FindVisibleChildCell(claim 0x172, stab) -> 0x{corrected:X8}");
|
|
Assert.Equal(ActualCell, corrected);
|
|
}
|
|
|
|
[Fact]
|
|
public void PoisonedClaim_IsADifferentBuilding_55mAway()
|
|
{
|
|
var datDir = ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
|
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
|
|
|
var claim = dats.Get<DatReaderWriter.DBObjs.EnvCell>(PoisonedClaim);
|
|
var actual = dats.Get<DatReaderWriter.DBObjs.EnvCell>(ActualCell);
|
|
Assert.NotNull(claim);
|
|
Assert.NotNull(actual);
|
|
|
|
float dist = Vector3.Distance(claim!.Position.Origin, actual!.Position.Origin);
|
|
_out.WriteLine(
|
|
$"claim origin=({claim.Position.Origin.X:F1},{claim.Position.Origin.Y:F1}) " +
|
|
$"actual origin=({actual.Position.Origin.X:F1},{actual.Position.Origin.Y:F1}) dist={dist:F1} m");
|
|
// The pair is not a one-tick-stale neighbour confusion — the buildings
|
|
// are tens of metres apart. The poisoning happened on the WIRE side
|
|
// (the old position-derived-landblock wireCellId composition) or in a
|
|
// prior wedged session's chaos, not in the per-tick membership pick.
|
|
Assert.True(dist > 20f, $"expected cross-building distance, got {dist:F1} m");
|
|
}
|
|
}
|