fix(phys): #106 follow-up — legacy Resolve returns full prefixed cell ids (teleport bare-id wedge)
The #106 live gate run was sabotaged by a pre-existing bug the corrective
ACE teleport exposed: PhysicsEngine.Resolve (the legacy Phase-D resolve,
still used by the teleport-arrival snap at GameWindow.cs:4869 and the
player-mode-entry snap at :11295) returned BARE low-word cell ids on
every computed exit (ComputeOutdoorCellId, bestCell.CellId & 0xFFFF,
nextCellIndex, enterCellIndex). The teleport committed 0x0000013F into
PlayerMovementController.CellId, and a bare indoor id wedges the entire
membership chain:
- GetCellStruct(0x0000013F) misses (cells are keyed full-prefix) -> no
indoor wall BSP -> walk through walls;
- the b3ce505 #98 gate reads "indoor primary" -> outdoor object radial
sweep skipped -> NO object collision anywhere in the world;
- BuildCellSetAndPickContaining early-returns an unresolvable id forever
(block 0x0000 is a real far-NW map block) -> membership frozen;
- render root never resolves -> interiors draw empty when stepping in.
Probe evidence: probe-cell-106-gate.log has exactly 2 [cell-transit]
lines for the whole session, both reason=teleport — the second one
(0xA9B3003C -> 0x0000013F) is the wedge. This is the L.2e "player CellId
tracked as bare low byte" finding (2026-05-12) finally biting; prefix
survival until now was a race artifact — Resolve only preserved the full
id when the landblock had not streamed in yet (passthrough exit), which
is why login snaps usually came out prefixed.
Fix: capture the matched landblock's key in Resolve's containment loop
and return lbPrefix | (targetCellId & 0xFFFF) on the computed exit —
the same full-32-bit convention Resolve's own doc comment states for
its inputs, and what both production callers (player snaps) require.
The passthrough exits (no landblock / step-reject) still return the
caller's id unchanged.
Tests: Resolve_IndoorStay_ReturnsFullPrefixedCellId (the teleport
shape, red pre-fix) + Resolve_OutdoorStay_ReturnsFullPrefixedCellId;
Resolve_LeaveIndoorCell_TransitionsToOutdoor's unmasked
`CellId < 0x100` assertion codified the bare behavior — now masked +
asserts the prefix. Full suite: 294+218+420 green; Core 1371 green +
the same 4 pre-existing door/#99-era failures + 1 skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7078264291
commit
23adc9c9df
3 changed files with 78 additions and 3 deletions
|
|
@ -66,6 +66,20 @@ geometry goldens (0xA9B40031 → 0xA9B30038/0xA9B30034) and a non-anchor-frame
|
||||||
northbound return. Pseudocode + decomp-artifact notes:
|
northbound return. Pseudocode + decomp-artifact notes:
|
||||||
`docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
|
`docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
|
||||||
|
|
||||||
|
**Gate attempt 1 (2026-06-09):** outside-looking-in correct + running distortion gone,
|
||||||
|
but the walk was sabotaged by a PRE-EXISTING bug the corrective ACE teleport exposed:
|
||||||
|
the teleport-arrival snap (`GameWindow.cs:4869`) committed a BARE indoor cell id
|
||||||
|
(`0x0000013F`) from the legacy `PhysicsEngine.Resolve`, which returned low-word-only
|
||||||
|
cell ids on every computed exit (the L.2e bare-low-byte finding from 2026-05-12).
|
||||||
|
A bare indoor id wedges the chain: no wall BSP (`GetCellStruct` miss), the #98
|
||||||
|
`b3ce505` gate reads "indoor primary" and skips the outdoor object sweep (NO object
|
||||||
|
collision anywhere), and the pick can never re-resolve a malformed id (probe log:
|
||||||
|
2 `[cell-transit]` lines all session, both teleports). Follow-up fix: `Resolve` now
|
||||||
|
returns the matched landblock's full prefixed id on every computed exit; the
|
||||||
|
unmasked `CellId < 0x100` test assertion that codified the bare behavior fixed.
|
||||||
|
Prefix survival before this was a race artifact — `Resolve` only preserved the full
|
||||||
|
id when the landblock wasn't streamed in yet (passthrough exit).
|
||||||
|
|
||||||
**Description:** Walking outdoors across a landblock boundary does NOT update the player's
|
**Description:** Walking outdoors across a landblock boundary does NOT update the player's
|
||||||
outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock,
|
outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock,
|
||||||
indefinitely. Every downstream consumer degrades: entering any building in the new landblock
|
indefinitely. Every downstream consumer degrades: entering any building in the new landblock
|
||||||
|
|
|
||||||
|
|
@ -463,7 +463,14 @@ public sealed class PhysicsEngine
|
||||||
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
|
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
|
||||||
|
|
||||||
// Find the landblock this candidate position falls in.
|
// Find the landblock this candidate position falls in.
|
||||||
|
// #106 follow-up (2026-06-09): capture its high-16 prefix — every
|
||||||
|
// computed cell id below is returned FULL (lbPrefix | low). The old
|
||||||
|
// bare-low-word returns wedged the membership chain whenever a caller
|
||||||
|
// committed them (the teleport-arrival snap wrote 0x0000013F: an
|
||||||
|
// unresolvable indoor id → no wall BSP, #98 gate reads "indoor
|
||||||
|
// primary" and kills the outdoor object sweep → no collision at all).
|
||||||
LandblockPhysics? physics = null;
|
LandblockPhysics? physics = null;
|
||||||
|
uint lbPrefix = 0u;
|
||||||
foreach (var kvp in _landblocks)
|
foreach (var kvp in _landblocks)
|
||||||
{
|
{
|
||||||
var lb = kvp.Value;
|
var lb = kvp.Value;
|
||||||
|
|
@ -472,6 +479,7 @@ public sealed class PhysicsEngine
|
||||||
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
|
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
|
||||||
{
|
{
|
||||||
physics = lb;
|
physics = lb;
|
||||||
|
lbPrefix = kvp.Key & 0xFFFF0000u;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -637,7 +645,7 @@ public sealed class PhysicsEngine
|
||||||
|
|
||||||
return new ResolveResult(
|
return new ResolveResult(
|
||||||
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
|
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
|
||||||
targetCellId,
|
lbPrefix | (targetCellId & 0xFFFFu),
|
||||||
IsOnGround: true);
|
IsOnGround: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -365,12 +365,65 @@ public class PhysicsEngineTests
|
||||||
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
|
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
|
||||||
stepUpHeight: 10f);
|
stepUpHeight: 10f);
|
||||||
|
|
||||||
// Should transition back to outdoor.
|
// Should transition back to outdoor. (#106 follow-up: masked compare —
|
||||||
Assert.True(result.CellId < 0x0100u);
|
// Resolve now returns FULL prefixed cell ids; the old unmasked
|
||||||
|
// `CellId < 0x0100` assertion codified the bare-low-word bug.)
|
||||||
|
Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
|
||||||
|
Assert.Equal(0xA9B40000u, result.CellId & 0xFFFF0000u);
|
||||||
Assert.Equal(50f, result.Position.Z, precision: 1);
|
Assert.Equal(50f, result.Position.Z, precision: 1);
|
||||||
Assert.True(result.IsOnGround);
|
Assert.True(result.IsOnGround);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #106 follow-up (2026-06-09): the live boundary-walk gate was sabotaged by
|
||||||
|
/// the teleport-arrival snap (GameWindow.cs:4869) receiving a BARE indoor
|
||||||
|
/// cell id from Resolve (`0x0000013F`). A bare indoor id wedges the whole
|
||||||
|
/// membership chain: GetCellStruct misses (no wall BSP), the #98 gate reads
|
||||||
|
/// "indoor primary" and skips the outdoor object sweep (no collision with
|
||||||
|
/// anything), and FindCellSet can never re-resolve a malformed id. Resolve
|
||||||
|
/// MUST return the matched landblock's full 32-bit cell id on every
|
||||||
|
/// computed exit — the same convention its own inputs use.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_IndoorStay_ReturnsFullPrefixedCellId()
|
||||||
|
{
|
||||||
|
var engine = new PhysicsEngine();
|
||||||
|
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
||||||
|
|
||||||
|
var cellVerts = new Dictionary<ushort, Vector3>
|
||||||
|
{
|
||||||
|
[0] = new(40f, 40f, 55f),
|
||||||
|
[1] = new(60f, 40f, 55f),
|
||||||
|
[2] = new(60f, 60f, 55f),
|
||||||
|
[3] = new(40f, 60f, 55f),
|
||||||
|
};
|
||||||
|
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
|
||||||
|
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
|
||||||
|
|
||||||
|
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty<PortalPlane>(),
|
||||||
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||||
|
|
||||||
|
// The teleport shape: full indoor cell id in, zero delta (pure snap).
|
||||||
|
var result = engine.Resolve(
|
||||||
|
new Vector3(50f, 50f, 55f), cellId: 0xA9B40100u, delta: Vector3.Zero,
|
||||||
|
stepUpHeight: 5f);
|
||||||
|
|
||||||
|
Assert.Equal(0xA9B40100u, result.CellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_OutdoorStay_ReturnsFullPrefixedCellId()
|
||||||
|
{
|
||||||
|
var engine = MakeFlatEngine(terrainZ: 50f);
|
||||||
|
|
||||||
|
var result = engine.Resolve(
|
||||||
|
new Vector3(96f, 96f, 50f), cellId: 0xA9B40029u, delta: new Vector3(1f, 0f, 0f),
|
||||||
|
stepUpHeight: 2f);
|
||||||
|
|
||||||
|
// (97, 96) is over grid (4, 4) → low = 4*8+4+1 = 0x25, prefixed.
|
||||||
|
Assert.Equal(0xA9B40025u, result.CellId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
|
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue