Gate-run finding (wedge-107-gate-indoor-login.log + dat scan): ACE saved the cell one room off (claim 0xA9B40172, position inside 0xA9B40171 — adjacent rooms). FindVisibleChildCell corrects this fine when hydrated (proven by the new AdjacentRoomClaim regression test), but the live entry committed the raw claim: IsSpawnCellReady's "any cell struct in the landblock => claim is bogus, proceed" disambiguator observed the MID-POPULATION state (interiors hydrate in id order on the background worker; the render-thread predicate read the cache mid-loop) and opened the gate before the claim and its stab neighbors were cached. AdjustPosition then saw a null cell struct and silently passed the claim through; the first movement demoted the player to outdoor inside the house — the user-visible "transparent interior, see straight through walls" (render is downstream of membership: an outdoor-classified viewer only sees the interior through the doorway flood). Fix: the hold now waits for THE CLAIMED CELL's struct, full stop (IsSpawnCellReady simplification; HasAnyCellStructInLandblock removed). Claims that can never hydrate are filtered by GameWindow against the dat's LandBlockInfo.NumCells range (memoized IsSpawnClaimUnhydratable), and PhysicsEngine.Resolve carries a loud lost-cell-equivalent safety net: an indoor claim with NO cell struct AND NO CellSurface floor data demotes to the outdoor landcell with a [spawn-adjust] line instead of committing raw (retail GotoLostCell :283418; documented divergence). Partial hydration (CellSurface present, struct pending) keeps the legacy floor-snap behavior — HasCellSurface uses the file's masked-low-word norm so bare-id fixtures and full-id production both resolve. Baseline restored: Core 1381 (+4 new #107 conformance tests) + 4 pre-existing #99-era failures + 1 skip; App 223 / UI 420 / Net 294. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
163 lines
7.8 KiB
C#
163 lines
7.8 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 demotes to the outdoor column —
|
|
// retail-identical for a cross-building claim (0x171 is not in 0x162's
|
|
// stab list). The login-side fix is AdjustPosition at the snap, which
|
|
// demotes IMMEDIATELY instead of after the first movement.
|
|
uint pickBad = CellTransit.FindCellList(cache, footCenter, FootRadius, PoisonedClaim);
|
|
_out.WriteLine($"FindCellList(seed=0x{PoisonedClaim:X8}) -> 0x{pickBad:X8}");
|
|
Assert.Equal(0xA9B40031u, 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");
|
|
}
|
|
}
|