acdream/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs
Erik 1090189d39 fix(phys): #107 indoor-login spawn wedge — validate the server (cell,pos) pair at the player snap (retail AdjustPosition) + unfreeze same-landblock teleport arrivals + self-consistent wire pairs
Root cause (capture resolve-107-login1.jsonl + dat conformance scan): ACE
restored the player with a POISONED (cell, position) pair — cell 0xA9B40162
(one building) with a position inside 0xA9B40171 (a different building 55 m
away). Our entry snap trusted the claim verbatim: the player stood
fake-grounded for minutes (isOnGround passthrough, no contact plane, no
walkable polygon — zero-move resolves short-circuit), the FIRST movement input
ran a real transition, the pick demoted the indoor claim to outdoor
mid-building, and the player fell 2.4 m through the cottage floor onto the
terrain underneath — wedged inside the building shell. The second wedge shape
(flood-fix-gate2.log) was the PortalSpace freeze: the teleport-arrival
detection gated on `differentLandblock || farAway>100m`, an invented
heuristic — ACE's same-landblock short-hop corrections matched neither, so
PortalSpace never exited and movement input stayed frozen all session.

Four legs, all retail-anchored:

1. PhysicsEngine.Resolve (the player snap path: login entry + teleport
   arrival) now runs AdjustPosition first — retail SetPositionInternal step 1
   (acclient :283892, AdjustPosition :280009): validate/correct the claimed
   cell from the foot-sphere center BEFORE any physics. Corrections log one
   [spawn-adjust] line.
2. AdjustPosition's previously-deferred indoor seen_outside →
   adjust_to_outside sub-fallback (:280037-280046) is completed; CellPhysics
   gains the SeenOutside flag (dat EnvCellFlags.SeenOutside) cached in
   CacheCellStruct. The camera path does not reach this sub-branch in the
   gated scenarios (CameraCornerSealReplayTests green).
3. PortalSpace arrival = ANY player position update (holtburger PlayerTeleport
   handler conformant; recenter still only on landblock change). Verified
   live: ACE sent a same-lb dist=69.8 correction that the old gate would have
   frozen on — it now completes.
4. Outbound wire (cell, position) pairs are now SELF-CONSISTENT: derive the
   landblock frame from the resolver's full cell id instead of welding a
   position-derived landblock onto its low word — the old composition could
   write exactly the poisoned pair shape into ACE's character save. Plus the
   #106-gate-2 hold extension: an indoor spawn claim waits for the claimed
   cell's hydration (IsSpawnCellReady) so the validation can act — the async
   equivalent of retail's synchronous cell load.

Live verification (wedge-107-verify1.log): entry clean; ACE's same-lb teleport
correction completed (old code: permanent freeze); the teleport destination
itself carried ANOTHER poisoned claim (0xA9B40150) which [spawn-adjust]
corrected to 0xA9B40019; player fully controllable, walking across landcells.
3 new dat-backed conformance tests pin the poisoned-pair facts
(Issue107SpawnDiagnosticTests). Baseline: 1380+4 pre-existing #99-era
failures+1 skip / 223 / 420 / 294.

Pending user gate: park indoors, log out gracefully, relaunch — expect a clean
indoor spawn standing on the interior floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:52:47 +02:00

133 lines
6.3 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 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");
}
}