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>
This commit is contained in:
parent
fb360ab3cc
commit
1090189d39
4 changed files with 287 additions and 19 deletions
|
|
@ -0,0 +1,133 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue