refactor(physics): delete legacy PhysicsEngine.Resolve/ResolvePlacement/HasCellSurface (C5a, AP-1/AD-1)

Member-wise deletion of the three legacy resolver members named in
docs/research/2026-08-05-c5a-contract.md: PhysicsEngine.Resolve,
PhysicsEngine.HasCellSurface, and PhysicsEngine.ResolvePlacement. An
exhaustive receiver census over src/ found zero production callers of any
of the three — every production placement writer already reaches the
canonical PhysicsEngine.SetPosition transaction exclusively through
RuntimeSetPositionState (three call sites total). The deletion is purely
member-wise: IsSpawnCellReady and AdjustPosition, which shared the same
source region as the deleted members, are preserved byte-identical — every
remaining production caller of either (including PhysicsCameraCollisionProbe,
AdjustPosition's sole surviving production caller) is unaffected.

Companion changes:
- PlayerMovementController's 3-argument SetPosition test overload is renamed
  to SeedPlacementForTest (internal) and CommitPreparedPosition is deleted;
  83 call sites across 19 test files were mechanically renamed to match.
- Seven pinned test dispositions from the contract are executed:
  3.1 (PhysicsEngineTests.cs: 11 legacy-resolver tests deleted, 6
  ResolveWithTransition tests kept), 3.2/3.3/3.4 (re-point to canonical
  SetPosition, with TransitionScratchDifferentialTests.cs additionally
  gaining positive IsCommitted assertions after each bitwise comparison so
  the differential proves a placement actually committed, not just that two
  possibly-uncommitted results match), 3.5 (Runtime rename), and 3.6
  (PlayerMovementPlacementTransactionTests.cs rewritten — its xmldoc now
  states plainly that the render-root publish moved to
  RuntimeSetPositionState.cs, but the sticky-release relocation claim was
  false and is retracted; this disposition's coverage loss is the sticky
  release path, not silently absorbed elsewhere).
- Stale `PhysicsEngine.Resolve`/`Resolve` doc citations in CellTransit.cs,
  PlayerMovementController.cs, and HeadlessSessionWorldProjection.cs are
  corrected to name the surviving canonical entry points by symbol
  (SetPosition, AdjustSetPosition/AdjustPosition, ResolveWithTransition)
  rather than fragile line numbers.

Retires AP-1 and AD-1 in docs/architecture/retail-divergence-register.md:
both rows described production zero-delta placement routing remaining on
the legacy resolver pending the Slice 4B2/4B route cutover; that resolver
no longer exists, so the condition each row tracked is now structurally
false rather than merely narrowed. AP-145 (routed through the prior commit)
and this commit's AP-1/AD-1 together bring the section counts to 101 AP / 47
AD active rows.

Builds on the AP-145 fix (previous commit) — this commit's staged tree was
independently rebuilt and its four suites independently rerun on top of
that commit before this commit was created, in addition to the combined
rebuild/rerun below.

Full-solution build: 0 errors (21 pre-existing warnings, all unrelated).
Suite results (combined tree): Core 4270/4271 passed (1 skip; the single
DatSoundCacheTests concurrent-decode-dedup failure is a known load-sensitive
race, confirmed passing standalone and unrelated to this change), Runtime
1176/1176, Headless 86/86, App 4132/4135 (3 skips).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-05 14:11:31 +02:00
parent f8e55ba5e4
commit 6921a02744
34 changed files with 508 additions and 946 deletions

File diff suppressed because one or more lines are too long

View file

@ -875,7 +875,10 @@ public static class CellTransit
// treats the world-frame sphere coordinates as if they were block-local and
// marches the cell one block per tick until lbX or lbY underflows to 0x00
// (the "lbX=0" outbound wire that ACE rejects). "frame not yet authoritative
// → preserve verbatim" mirrors the NO-LANDBLOCK contract in PhysicsEngine.Resolve.
// → preserve verbatim" is the same no-landblock-match invariant canonical
// PhysicsEngine.SetPosition's AdjustSetPosition/DeferredCell path holds today
// (C5a, 2026-08-05: cite by symbol — the legacy PhysicsEngine.Resolve this
// note used to name is deleted, zero production callers).
// Indoor seeds are NOT guarded here because blockOrigin is only consumed by the
// outdoor pick path (outdoorPickAllowed=false for indoor seeds); returning early
// for indoor seeds would break all interior cell-set builds (regression).
@ -1055,9 +1058,15 @@ public static class CellTransit
// outdoor column, stranding the player outdoor-classified deep inside
// the house (outdoor→indoor promotion is portal-adjacent-only, retail-
// identical) → the outdoor flood rendered the interior transparent.
// The hatch's actual target — poisoned (cell, position) SAVES — is
// handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition
// validation since #107/#111; mid-session farness cannot arise (the
// The hatch's actual target — poisoned (cell, position) SAVES — was
// handled at the SNAP by PhysicsEngine.AdjustPosition validation since
// #107/#111 (C5a, 2026-08-05: cite by symbol — the legacy
// PhysicsEngine.Resolve player-snap caller this note used to name is
// deleted, zero production callers; AdjustPosition survives, now
// reached in production only from PhysicsCameraCollisionProbe's
// camera-collision cell resolve, and canonical PhysicsEngine.SetPosition's
// AdjustSetPosition performs the equivalent validation for live
// placement); mid-session farness cannot arise (the
// sphere moves continuously, and real building exits flow through
// exterior portals → outside cells enter the candidate array → the
// normal outdoorResult path above demotes there, retail-faithfully).

View file

@ -22,9 +22,12 @@ namespace AcDream.Core.Physics.Motion;
/// equivalents call <see cref="ConstrainTo"/> at every accepted-position
/// seam: <c>LiveEntityNetworkUpdateController</c> for remotes (anchored to
/// the object's own position, right after the hard-teleport branch returns)
/// and <c>PlayerMovementController.SetPosition</c>/<c>BlipPosition</c> for
/// the local player (anchored to the received position). See
/// <c>docs/research/2026-07-30-constraint-leash-constants.md</c>.</para>
/// and <c>PlayerMovementController.RearmConstraintLeashAtCurrentPosition</c>
/// (called from the teleport-hook rearm inside <c>SetPositionCore</c> and
/// from <c>ArmConstraintLeashAtCommittedPlacement</c>) for the local player
/// (anchored to the received position; C5a, 2026-08-05 — cite by symbol,
/// the old <c>SetPosition</c>/<c>BlipPosition</c> pair this note named is
/// gone). See <c>docs/research/2026-07-30-constraint-leash-constants.md</c>.</para>
/// </summary>
public sealed class ConstraintManager
{

View file

@ -439,9 +439,12 @@ public sealed class PhysicsBody
/// <para>Campaign P P5 (2026-07-30) armed the leash at every current
/// inbound-position acceptance seam
/// (<c>LiveEntityNetworkUpdateController</c> for remotes,
/// <c>PlayerMovementController.SetPosition</c>/<c>BlipPosition</c> for the
/// local player — see <c>docs/research/2026-07-30-constraint-leash-constants.md</c>
/// §2/§3). <see cref="MotionInterpreter"/> only holds a
/// <c>PlayerMovementController.RearmConstraintLeashAtCurrentPosition</c>
/// for the local player — see
/// <c>docs/research/2026-07-30-constraint-leash-constants.md</c>
/// §2/§3; C5a, 2026-08-05 — cite by symbol, the old
/// <c>SetPosition</c>/<c>BlipPosition</c> pair this note named is gone).
/// <see cref="MotionInterpreter"/> only holds a
/// <see cref="PhysicsBody"/> reference (no host), so this property stays a
/// plain settable bool; the per-tick pump that already runs
/// <c>PositionManager.AdjustOffset</c> (<c>PlayerMovementController.Update</c>,

View file

@ -21,10 +21,15 @@ internal readonly record struct TerrainWalkableSample(
///
/// <para>
/// Landblocks are registered via <see cref="AddLandblock"/> with their
/// terrain, indoor cells, and world-space offsets. <see cref="Resolve"/>
/// takes a current position, the entity's current cell ID, a movement delta,
/// and a step-up height limit; it returns the validated new position, the
/// updated cell ID, and whether the entity is standing on a surface.
/// terrain, indoor cells, and world-space offsets.
/// <see cref="ResolveWithTransition"/> takes a current position, a target
/// position, the entity's current cell ID, and the mover's sphere/step
/// parameters; it returns the validated new position, the updated cell ID,
/// and whether the entity is standing on a surface. C5a (2026-08-05): the
/// legacy zero-delta <c>Resolve</c> snap this paragraph used to cite is
/// gone — canonical placement now flows through <c>SetPosition</c> (Core)
/// / <c>PreparePositionForCommit</c> + <c>ArmConstraintLeashAtCommittedPlacement</c>
/// (Runtime), both of which settle through this same sphere-sweep resolver.
/// </para>
/// </summary>
public sealed class PhysicsEngine
@ -1220,12 +1225,17 @@ public sealed class PhysicsEngine
/// <c>SmartBox::update_viewer</c> calls this to seat the camera sweep's start
/// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1
/// at the sought eye (:280078). The player snap path
/// (<c>SetPositionInternal</c> :283908 → our <see cref="Resolve"/>) calls it to
/// validate the server-restored (cell, position) pair before any physics runs —
/// the #107 indoor-login wedge was this validation missing: a poisoned save
/// (cell id from one building, position inside another) was trusted verbatim,
/// the player stood fake-grounded with no walkable floor, and the first movement
/// demoted them outdoor mid-building → 2.4 m fall under the cottage floor.
/// (<c>SetPositionInternal</c> :283908) originally called it through the
/// legacy <c>Resolve</c> wrapper (deleted C5a, zero production callers)
/// to validate the server-restored (cell, position) pair before any
/// physics runs; the sole production caller today is
/// <c>PhysicsCameraCollisionProbe</c> (camera collision cell resolve) —
/// the #107 indoor-login wedge this method fixed was the validation
/// missing from the (now-deleted) player snap path:
/// a poisoned save (cell id from one building, position inside another)
/// was trusted verbatim, the player stood fake-grounded with no walkable
/// floor, and the first movement demoted them outdoor mid-building →
/// 2.4 m fall under the cottage floor.
/// </para>
/// <para>
/// #107 (2026-06-10) completed the previously-deferred indoor
@ -1758,30 +1768,6 @@ public sealed class PhysicsEngine
return inside;
}
/// <summary>
/// #107: does any loaded landblock carry a <see cref="CellSurface"/> for
/// this cell id? Distinguishes "partially hydrated" (floor data present,
/// struct pending — the legacy floor-snap can ground the claim) from
/// "completely unknown" (the Resolve safety net demotes loudly).
/// </summary>
private bool HasCellSurface(uint cellId)
{
// Masked low-word compare (house norm in this file): production
// CellSurfaces carry full prefixed ids (GameWindow.cs:5923), test
// fixtures bare low words. A zero-prefix (bare, pre-#106 convention)
// claim matches any loaded landblock by low word — the legacy Resolve
// body below treats bare claims the same way.
uint low = cellId & 0xFFFFu;
uint prefix = cellId & 0xFFFF0000u;
foreach (var kvp in _landblocks)
{
if (prefix != 0u && (kvp.Key & 0xFFFF0000u) != prefix) continue;
foreach (var cell in kvp.Value.Cells)
if ((cell.CellId & 0xFFFFu) == low) return true;
}
return false;
}
/// <summary>
/// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the
/// server-claimed spawn cell is ready for <see cref="AdjustPosition"/> to
@ -1799,9 +1785,13 @@ public sealed class PhysicsEngine
/// gate-run regression: claim 0xA9B40172 committed raw → outdoor demote on
/// first movement → transparent interior). Claims that can NEVER hydrate
/// (id outside the landblock's NumCells range) are now filtered by the
/// caller against the dat, and <see cref="Resolve"/> carries a loud
/// outdoor-demote safety net for any unhydrated indoor claim that still
/// gets through.
/// caller against the dat. C5a (2026-08-05): the loud outdoor-demote
/// safety net this paragraph used to cite lived in the legacy
/// <c>Resolve</c>/<c>HasCellSurface</c> pair, deleted with zero production
/// callers — canonical <c>SetPosition</c> has no equivalent unhydrated-
/// claim demote, so a claim that fails this gate simply stays un-adjusted
/// until the streaming worker catches up, the same as any other caller
/// of <see cref="AdjustPosition"/> that gets <c>found = false</c>.
/// </para>
/// </summary>
public bool IsSpawnCellReady(uint cellId)
@ -1848,357 +1838,16 @@ public sealed class PhysicsEngine
return (seedCellId, false);
}
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
/// from the terrain or indoor cell floor beneath the candidate position.
///
/// <para>
/// Step-height enforcement rejects horizontal movement when the upward Z
/// change exceeds <paramref name="stepUpHeight"/>. Downhill movement is
/// always accepted. Returns <see cref="ResolveResult.IsOnGround"/> false
/// when no loaded landblock covers the candidate position.
/// </para>
/// </summary>
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
{
// #107 (2026-06-10): retail CPhysicsObj::SetPositionInternal (:283892)
// step 1 — AdjustPosition (:283908) validates/corrects the claimed cell
// from the position BEFORE any physics runs. This legacy Resolve is the
// player snap path (login entry + teleport arrival — the SetPosition
// shaped calls); both hand it a server-restored (cell, position) pair
// that can be poisoned (the #107 capture: cell id from one building,
// position inside another, 55 m apart). Retail validates at the foot-
// sphere CENTER (localtoglobal of sphere_path.local_sphere, :283903);
// the player's foot sphere is radius 0.48 m centred 0.48 m above the
// feet (PlayerMovementController body — capture input.sphereRadius).
const float FootSphereCenterLift = 0.48f;
var (adjustedCellId, adjustedFound) = AdjustPosition(
cellId, currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
if (adjustedFound && adjustedCellId != cellId)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[spawn-adjust] claimed cell 0x{cellId:X8} does not contain ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — corrected to 0x{adjustedCellId:X8} (retail AdjustPosition :280009)"));
cellId = adjustedCellId;
}
else if (!adjustedFound
&& (cellId & 0xFFFFu) >= 0x0100u
&& DataCache?.GetCellStruct(cellId) is null
&& !HasCellSurface(cellId))
{
// #107 safety net (2026-06-10 gate-run regression): an indoor claim
// the engine knows NOTHING about (no cell struct AND no CellSurface
// floor data) cannot be validated or grounded — committing it raw
// reproduces the fake-grounded wedge. Retail goes lost-cell here
// (GotoLostCell, :283418); our recoverable equivalent is the
// outdoor landcell under the point (documented divergence — we have
// no lost-cell machinery). When only the struct is missing but the
// CellSurface floor exists (partial hydration), the legacy indoor
// floor-snap below handles the claim — don't demote. The auto-entry
// hold should make this unreachable in practice; if the line fires,
// the hold has a gap.
var (outdoorCellId, outdoorFound) = AdjustPosition(
(cellId & 0xFFFF0000u) | 0x0001u,
currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
if (outdoorFound)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[spawn-adjust] UNHYDRATED indoor claim 0x{cellId:X8} at ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — demoted to outdoor 0x{outdoorCellId:X8} (lost-cell equivalent)"));
cellId = outdoorCellId;
}
}
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// #111 apparatus: one [snap] line per Resolve call (entry + teleport
// arrival only — low volume, permanent). The gate-3/4/5 runs committed
// ACE's restored pair VERBATIM through this method while every read
// path should have changed Z or cell — this line answers which branch
// actually ran. Remove or demote to env-gate once #111 closes.
bool snapDiag = (delta.X == 0f && delta.Y == 0f);
// 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;
uint lbPrefix = 0u;
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = candidatePos.X - lb.WorldOffsetX;
float localY = candidatePos.Y - lb.WorldOffsetY;
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
{
physics = lb;
lbPrefix = kvp.Key & 0xFFFF0000u;
break;
}
}
if (physics is null)
{
if (snapDiag && DiagnosticLog is { } noLandblockLog)
noLandblockLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim"));
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
}
float localCandX = candidatePos.X - physics.WorldOffsetX;
float localCandY = candidatePos.Y - physics.WorldOffsetY;
// #111 (2026-06-10): a VALIDATED indoor claim is AUTHORITATIVE for the
// cell — retail SetPositionInternal commits the AdjustPosition cell and
// only settles Z (CheckPositionInternal → find_valid_position, :283426);
// it never re-picks the cell from floor geometry. The legacy bestCell
// floor-pick below scans EVERY CellSurface in the landblock (123 at
// Holtburg) and breaks same-height ties by iteration order — on a live
// login it clobbered ACE's clean, validated claim 0xA9B40171 with
// 0xA9B4013F (issue111-snap1.log), putting the player in a wrong cell
// → outdoor demote on first movement → transparent interior (#111).
// Snap shape only (zero delta): ground Z onto the validated claim's own
// floor when it has one under this XY; cells without their own floor
// surface here (thresholds, stair lips) fall through to the legacy path.
if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u)
{
// Ground via the claim's PHYSICS WALKABLE polygons (normal.Z ≥
// PhysicsGlobals.FloorZ), NOT the CellSurface triangle soup — the
// soup includes ceiling/roof TOP faces whose first-hit (99.475
// over 0x171's 94.0 floor, issue111-verify2.log) and even
// nearest-to-reference (the poisoned reference SAT on the ceiling
// face, issue111-verify3.log) selections both land on non-floors.
// The walkable set contains only real floors (retail
// BSPTREE::find_walkable's polygon filter).
float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z);
if (claimFloorZ is not null)
{
if (DiagnosticLog is { } validatedLog)
{
validatedLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
}
// #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
// NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
// resident landblocks for one whose [0,192) local bounds contain
// the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE
// (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon
// landblock fails the localY>=0 bounds test, so the loop matches a
// neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping
// the validated claim 0x00070143 -> 0xA9B30143. The client then
// mis-resolves the player into the wrong landblock and spams ACE with
// rejected moves. The validated claim's prefix is AUTHORITATIVE; a
// position falling in a neighbouring resident landblock must not
// re-stamp it. Byte-identical for the login case (the position lies in
// the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000);
// diverges only — and correctly — in the far-teleport dungeon case.
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value),
cellId,
IsOnGround: true);
}
}
// Check if the candidate position falls on any indoor cell floor.
// Pick the cell whose floor Z is closest to the entity's current Z.
CellSurface? bestCell = null;
float? bestCellZ = null;
float bestZDist = float.MaxValue;
foreach (var cell in physics.Cells)
{
float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
if (floorZ is not null)
{
float dist = MathF.Abs(floorZ.Value - currentPos.Z);
if (dist < bestZDist)
{
bestCell = cell;
bestCellZ = floorZ;
bestZDist = dist;
}
}
}
// Determine target surface Z and cell.
float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
float targetZ;
uint targetCellId;
// Only the low 16 bits of cellId carry the cell index. Outdoor
// cells are 0x00010x0040; indoor (EnvCell) cells are 0x0100+.
// The full 32-bit cellId includes the landblock prefix in the
// high 16 bits (e.g., 0xA9B40001), so we MUST mask before
// comparing. Without the mask, every cell looks "indoor" because
// 0xA9B40001 >= 0x0100 → the engine always takes the "stay
// indoors" path and snaps Z to an EnvCell floor 28m below.
bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100;
if (currentlyIndoor)
{
// Check whether the player crosses a portal belonging to the current cell.
uint currentCellIndex = cellId & 0xFFFFu;
PortalPlane? crossedPortal = null;
foreach (var portal in physics.Portals)
{
// Only portals owned by the current cell are relevant when indoors.
if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue;
if (portal.IsCrossing(currentPos, candidatePos))
{
crossedPortal = portal;
break;
}
}
if (crossedPortal is not null)
{
if (crossedPortal.Value.TargetCellId == 0xFFFFu)
{
// Indoor → Outdoor exit.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
else
{
// Indoor → Indoor (room to room).
uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu;
CellSurface? nextCell = null;
foreach (var c in physics.Cells)
{
if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; }
}
float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
targetZ = nextFloorZ ?? terrainZ;
targetCellId = nextCellIndex;
}
}
else if (bestCellZ is not null)
{
// Staying in the same indoor cell.
targetZ = bestCellZ.Value;
targetCellId = bestCell!.CellId & 0xFFFFu;
}
else
{
// No cell floor found and no portal crossed — fall back to outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
else
{
// Outdoor player: check for a portal crossing into an indoor cell.
// Outside-facing portals have TargetCellId == 0xFFFF (they face the
// outdoor world); crossing one from the outdoor side enters the OwnerCellId.
PortalPlane? crossedPortal = null;
foreach (var portal in physics.Portals)
{
if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals
if (portal.IsCrossing(currentPos, candidatePos))
{
crossedPortal = portal;
break;
}
}
if (crossedPortal is not null)
{
// Outdoor → Indoor: enter the OwnerCellId IF the target cell
// actually contains the candidate position. Without CellBSP,
// we verify by checking that SampleFloorZ returns non-null
// (position is within the cell's floor polygon bounds) AND the
// floor Z is close to the player's current Z (not a basement
// 30m below). This prevents the wall-bounce bug where portal
// planes on upper floors captured outdoor positions.
uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu;
CellSurface? enterCell = null;
foreach (var c in physics.Cells)
{
if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; }
}
float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
// Validate: floor must exist AND be within step height of current Z.
// This rejects transitions to basements, upper floors, and cells
// whose floor polygon doesn't actually cover this position.
bool validTransition = enterFloorZ is not null
&& MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f;
if (validTransition)
{
targetZ = enterFloorZ!.Value;
targetCellId = enterCellIndex;
}
else
{
// Portal crossed but target cell doesn't contain us — stay outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
else
{
// Stay outdoors on terrain.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
// #126 (2026-06-11, RETAIL-CORRECTED same day): a zero-delta
// RESTORE commits the server's position — it does NOT
// re-derive Z. Retail CPhysicsObj::SetPositionInternal
// (0x00515bd0, pc:283892-283945) treats the supplied Position
// as INPUT: AdjustPosition resolves which cell CONTAINS it,
// CheckPositionInternal/find_valid_position VALIDATES it
// through the collision transition, and failure goes
// store_position + GotoLostCell — there is NO terrain/surface
// re-grounding anywhere in the restore path. Our previous
// shapes both diverged: grounding to terrainZ warped a
// roof-deck logout (ACE's authoritative z=127.2 on the AAB3
// tower) THROUGH the roof into the building volume → the
// transparent-interior spawn; the cell-walkable scan that
// replaced it missed shell-geometry decks entirely (no
// EnvCell owns the surface) and failed silently. Trust the
// claim's Z; the first physics tick validates/settles against
// the REAL collision world (BR-7 building channel included).
// max(terrain, z) stays as the under-terrain sanity bound —
// our recoverable stand-in for retail's lost-cell machinery
// (documented divergence, same as the #107 demote).
if (snapDiag
&& currentPos.Z > terrainZ)
{
if (DiagnosticLog is { } outdoorLog)
{
outdoorLog(System.FormattableString.Invariant(
$"[snap] OUTDOOR claim 0x{cellId:X8} z={currentPos.Z:F3} above terrain {terrainZ:F3} — committing the server Z (retail SetPositionInternal shape; physics settles on tick 1)"));
}
targetZ = currentPos.Z;
}
}
}
// Step-height enforcement: block upward movement that exceeds the limit.
float zDelta = targetZ - currentPos.Z;
if (snapDiag && DiagnosticLog is { } resultLog)
resultLog(System.FormattableString.Invariant(
$"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}"));
if (zDelta > stepUpHeight)
{
// Too steep to step up — reject horizontal movement.
return new ResolveResult(currentPos, cellId, IsOnGround: true);
}
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
lbPrefix | (targetCellId & 0xFFFFu),
IsOnGround: true);
}
/// <summary>
/// Resolve movement using the CTransition sphere-sweep system.
/// Subdivides movement into sphere-radius steps, tests terrain collision
/// at each step, handles step-down for ground contact.
/// Falls back to the simple <see cref="Resolve"/> if the transition fails.
/// at each step, handles step-down for ground contact. C5a (2026-08-05):
/// the legacy simple-snap fallback this method used to describe (the
/// zero-delta <c>Resolve</c> player-snap path) is gone — deleted with
/// zero production callers. This is now the sole movement-resolution
/// entry point; a failed transition returns its own
/// <see cref="ResolveResult.Ok"/> false rather than falling back to
/// anything.
///
/// <para>
/// <paramref name="body"/> is optional but highly recommended for movement
@ -2736,64 +2385,4 @@ public sealed class PhysicsEngine
ReturnTransition(transition);
}
}
/// <summary>
/// Runs retail's radius-aware placement-ring search after the host has
/// validated the server's cell and grounded the initial position. This is
/// the <c>CTransition::find_placement_pos</c> half of enter-world
/// <c>SetPosition</c>; unlike an ordinary zero-distance transition it tests
/// object occupancy and can seat a relogging player beside a creature that
/// now occupies the saved location.
/// </summary>
public ResolveResult ResolvePlacement(
Vector3 position,
uint cellId,
float sphereRadius,
float sphereHeight,
float stepUpHeight,
float stepDownHeight,
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
{
var transition = RentTransition();
try
{
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
transition.ObjectInfo.StepDown = true;
transition.ObjectInfo.SelfEntityId = movingEntityId;
transition.ObjectInfo.State = moverFlags;
transition.SpherePath.InitPath(
position, position, cellId, sphereRadius, sphereHeight);
transition.SpherePath.InsertType = InsertType.Placement;
bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
return new ResolveResult(
sp.CurPos,
sp.CurCellId != 0 ? sp.CurCellId : cellId,
onGround,
ci.CollisionNormalValid,
ci.CollisionNormal,
ok,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable,
ContactPlane: ci.ContactPlane,
ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater);
}
finally
{
ReturnTransition(transition);
}
}
}

View file

@ -3,7 +3,7 @@ using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
/// Result of <see cref="PhysicsEngine.ResolveWithTransition"/>: the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
///

View file

@ -791,9 +791,14 @@ internal sealed class HeadlessSessionWorldProjection
/// C4 route 3 (D-T6): the portal-arrival placement runs through the
/// SAME canonical Runtime portal arm the graphical host drives
/// (<see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>),
/// retiring the duplicate Resolve/ResolvePlacement/SetPosition authority
/// this method used to own directly (D2;
/// docs/research/2026-08-04-c4-route-3-contract.md D-T6).
/// retiring the duplicate placement authority this method used to own
/// directly (D2; docs/research/2026-08-04-c4-route-3-contract.md D-T6).
/// C5a (2026-08-05): that duplicate authority forecast here is now
/// closed for real — the legacy <c>PhysicsEngine.Resolve</c>/
/// <c>ResolvePlacement</c> pair this note used to name is deleted
/// outright, zero production callers; the sole canonical placement path
/// today is <c>PhysicsEngine.SetPosition</c> via
/// <c>RuntimeSetPositionState</c>.
///
/// <para>
/// A1/A3 review fix (2026-08-05): the first pass discarded the arm's

View file

@ -142,8 +142,11 @@ internal enum PlayerMovementControllerPublicationLifecycle
/// velocity clamping — all from the decompiled retail client.
/// - MotionInterpreter owns the motion state machine: walk/run/jump
/// validation, state tracking, speed constants from the retail dat.
/// - PhysicsEngine.Resolve is still used each frame to snap the player
/// to terrain/cell floor Z and detect ground contact.
/// - PhysicsEngine.ResolveWithTransition is still used each frame to snap
/// the player to terrain/cell floor Z and detect ground contact (C5a,
/// 2026-08-05: the legacy PhysicsEngine.Resolve this note used to name
/// is deleted, zero production callers; ResolveWithTransition's
/// sphere-sweep resolver is, and always was, the real per-frame path).
/// </summary>
public sealed class PlayerMovementController
{
@ -1743,21 +1746,19 @@ public sealed class PlayerMovementController
_physics.UpdatePlayerCurrCell(newCellId);
}
public void SetPosition(Vector3 pos, uint cellId)
// #145: tests + legacy callers run in the world==block-local frame (no
// streaming center), so the cell-local seed IS the world position. This
// makes the carried anchor (body.Position CellPosition.Origin) == (0,0,0),
// identical to the legacy Zero terrain-origin fallback → behaviour unchanged.
=> SetPosition(pos, cellId, pos);
/// <summary>
/// Server-snap / teleport placement. <paramref name="cellLocal"/> is the
/// LANDBLOCK-relative position (the wire's local, or world landblock origin)
/// which seeds the body's cell-relative <c>CellPosition</c> WITHOUT any streaming
/// center (#145). A teleport is a large jump, so this snaps the cell frame
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
/// C5a (2026-08-05): this seed exists ONLY to place a controller directly
/// in test fixtures. Production placement never calls it — the retail
/// server-snap / teleport / enter-world path commits through
/// <see cref="PreparePositionForCommit"/> followed by
/// <see cref="ArmConstraintLeashAtCommittedPlacement"/> (or, for the
/// already-live case, canonical <c>SetPositionCore</c> callers inside
/// this class). <paramref name="cellLocal"/> is the LANDBLOCK-relative
/// position (the wire's local, or world landblock origin) which seeds
/// the body's cell-relative <c>CellPosition</c> WITHOUT any streaming
/// center (#145).
/// </summary>
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
internal void SeedPlacementForTest(Vector3 pos, uint cellId, Vector3 cellLocal)
{
EnsurePublishedForRuntimeOperation();
SetPositionCore(
@ -1786,22 +1787,14 @@ public sealed class PlayerMovementController
publishSharedState: false);
}
internal void CommitPreparedPosition()
{
EnsurePublishedForRuntimeOperation();
_physics.UpdatePlayerCurrCell(CellId);
PositionManager?.UnStick();
// #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook
// teardown+rearm below — see that comment for the retail citation.
RearmConstraintLeashAtCurrentPosition();
}
/// <summary>
/// C3c-R1: arms the login-entry constraint leash from the Runtime
/// publication chain. The flip deleted the only login-path caller of
/// <see cref="RearmConstraintLeashAtCurrentPosition"/> (the App-side
/// <see cref="CommitPreparedPosition"/> call in the old
/// player-mode-entry commit); the dormant activation's final commit
/// <c>CommitPreparedPosition</c> call in the old
/// player-mode-entry commit, removed C5a — production placement now
/// arms exclusively here and at <see cref="SetPositionCore"/>'s
/// teleport_hook teardown+rearm); the dormant activation's final commit
/// (<c>RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit</c>)
/// is the accepted-position event that replaces it — retail arms at
/// every accepted-position event (<c>SmartBox::HandleReceivedPosition</c>
@ -1824,9 +1817,11 @@ public sealed class PlayerMovementController
/// immediately after <c>TeleportPlayer</c>'s teardown, anchored to the
/// RECEIVED position (here, the body's just-snapped current position).
/// Shared by the teleport path (after UnConstrain), the deferred
/// player-mode-entry commit path (<see cref="CommitPreparedPosition"/>),
/// which never ran UnConstrain because nothing could have armed the
/// leash before the controller had a <see cref="PositionManager"/>,
/// player-mode-entry commit path (formerly the App-side
/// <c>CommitPreparedPosition</c> caller, removed C5a; now
/// <see cref="ArmConstraintLeashAtCommittedPlacement"/>'s Runtime-owned
/// caller), which never ran UnConstrain because nothing could have armed
/// the leash before the controller had a <see cref="PositionManager"/>,
/// and the C3c first-entry placement commit
/// (<see cref="ArmConstraintLeashAtCommittedPlacement"/>).
/// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.

View file

@ -1318,6 +1318,16 @@ internal sealed class RuntimeSetPositionState : IDisposable
allowDirectUnsealed: true);
}
/// <summary>
/// C5a (2026-08-05): test seam. A pure pass-through to
/// <c>BeginAcceptedPlacementCore</c> (<c>captureMoverPreparationAuthority:
/// false</c>) kept ONLY so Runtime test fixtures can open a placement
/// operation directly; production reaches the core exclusively through
/// <see cref="Apply"/> and the authored sequence. The core it calls IS
/// the canonical path, so this wrapper is not a legacy path to delete —
/// deleting it would only buy broad mechanical test churn for zero
/// behaviour change (C5a scoping, disposition 3.7).
/// </summary>
internal RuntimeEntityPlacementToken BeginAcceptedPlacement(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
@ -1330,6 +1340,15 @@ internal sealed class RuntimeSetPositionState : IDisposable
portal,
captureMoverPreparationAuthority: false);
/// <summary>
/// C5a (2026-08-05): test seam. A pure pass-through to
/// <c>BeginAcceptedPlacementCore</c> (<c>captureMoverPreparationAuthority:
/// true</c>) kept ONLY so Runtime test fixtures can open an authored
/// placement operation directly; production reaches the core through the
/// authored sequence itself. See <see cref="BeginAcceptedPlacement"/> for
/// why this wrapper is a recorded deliberate exception, not a deletion
/// target.
/// </summary>
internal RuntimeEntityPlacementToken BeginAuthoredPlacement(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,

View file

@ -216,11 +216,11 @@ public sealed class RuntimeAcceptedPositionDriveController
/// not re-issue it. The converse does NOT hold: equality does not prove
/// the newest accepted event was this ForcePosition, because
/// <c>RuntimeEntityRecordTable.AdvancePositionAuthority</c> has four call
/// sites, not one — the ordinary accepted-Position merge
/// (<c>RuntimeEntityObjectLifetime.cs:1647</c>) plus
/// <c>TryApplyPickup</c> (<c>:1116</c>),
/// <c>CommitPositionChannelUpdate</c> (<c>:2041</c>) and
/// <c>AdvanceCreateAuthority</c> (<c>:2466</c>). The latter three are
/// sites, not one, all in <c>RuntimeEntityObjectLifetime</c> (C5a,
/// 2026-08-05 — cite by symbol, not line: these move) — the ordinary
/// accepted-Position merge plus <c>TryApplyPickup</c>,
/// <c>CommitPositionChannelUpdate</c>, and
/// <c>AdvanceCreateAuthority</c>. The latter three are
/// effectively unreachable for a live local player, but the funnel's
/// safety does not depend on that: an unnoticed advance can only make the
/// funnel decline a re-issue it might have made (register row

View file

@ -15,7 +15,7 @@ public sealed class LocalPlayerProjectionControllerTests
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f)
* Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f));
var movement = new PlayerMovementController(new PhysicsEngine());
movement.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
movement.SeedPlacementForTest(Vector3.Zero, cellId, Vector3.Zero);
movement.SetBodyOrientation(complete);
var entity = new WorldEntity
{
@ -53,7 +53,7 @@ public sealed class LocalPlayerProjectionControllerTests
{
const uint cellId = 0x02020001u;
var movement = new PlayerMovementController(new PhysicsEngine());
movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
movement.SeedPlacementForTest(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
var entity = new WorldEntity
{
Id = 8u,
@ -88,7 +88,7 @@ public sealed class LocalPlayerProjectionControllerTests
{
const uint cellId = 0x02020001u;
var movement = new PlayerMovementController(new PhysicsEngine());
movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
movement.SeedPlacementForTest(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
var entity = new WorldEntity
{
Id = 9u,

View file

@ -244,7 +244,7 @@ public sealed class MouseLookControllerTests
worldOffsetX: 0f,
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
return controller;
}

View file

@ -15,8 +15,47 @@ public sealed class PlayerMovementPlacementTransactionTests
private const uint PriorCell = 0x0101_0101u;
private const uint DestinationCell = 0x0101_0102u;
/// <summary>
/// C5a (2026-08-05) re-point: <c>PlayerMovementController.CommitPreparedPosition</c>
/// is deleted (zero production callers — production placement now arms
/// the login-entry leash via <c>ArmConstraintLeashAtCommittedPlacement</c>,
/// called from <c>RuntimeLocalPlayerPhysicsPublicationState.ArmFirstEntryConstraintLeash</c>
/// AFTER <c>RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit</c>
/// has already published the shared current cell — see that method's
/// doc comment). Architecture-review correction (A5, 2026-08-05): the two
/// deleted invariants did NOT relocate symmetrically.
/// <b>Render-root publish DID move</b> — it happens inside
/// <c>RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit</c>
/// itself, via <c>_physics.Engine.UpdatePlayerCurrCell(result.CellId)</c>
/// (<c>RuntimeSetPositionState.cs:2774</c>).
/// <b>Sticky-target release did NOT move anywhere</b> — <c>grep -rn
/// "UnStick" src/AcDream.Runtime/</c> returns zero call sites on this
/// path; the only local-player <c>UnStick</c> left is
/// <c>PlayerMovementController.SetPositionCore</c>'s
/// <c>if (publishSharedState) PositionManager?.UnStick();</c>, and
/// <c>PreparePositionForCommit</c> passes <c>publishSharedState: false</c>.
/// The unstick-at-first-entry-commit behaviour <c>CommitPreparedPosition</c>
/// used to perform is simply gone — it was dead code (the method had zero
/// production callers before this slice deleted it), so nothing regresses
/// today, but no layer pins the invariant "first-entry commit releases any
/// sticky target" any more. That is disposition 3.6's one real coverage
/// loss (see the C5a commit message).
/// This App-layer fixture (a bare <see cref="PlayerMovementController"/> +
/// <see cref="EntityPhysicsHost"/> pair, no Runtime activation pipeline)
/// cannot drive the Runtime final-commit transaction without
/// disproportionate fixture growth — recorded here as the explicit C5a
/// scope decision rather than silently dropped.
/// What THIS test still proves, unchanged: <see cref="PlayerMovementController.PreparePositionForCommit"/>
/// defers the render-root publish exactly as before (<c>controller.CellId</c>
/// updates immediately; <c>physics.DataCache.CellGraph.CurrCell</c> and
/// the sticky target stay untouched), and the surviving production
/// commit-arm, <see cref="PlayerMovementController.ArmConstraintLeashAtCommittedPlacement"/>,
/// arms the constraint leash without independently touching either —
/// neither the render-root publish (a DIFFERENT layer's job now) nor the
/// sticky release (nobody's job any more).
/// </summary>
[Fact]
public void PreparedPosition_DoesNotPublishRenderRootOrUnstickUntilCommit()
public void PreparedPosition_DefersRenderRootPublish_CommitArmsLeashOnly()
{
PhysicsEngine physics = PhysicsWithCells(PriorCell, DestinationCell);
physics.UpdatePlayerCurrCell(PriorCell);
@ -38,11 +77,19 @@ public sealed class PlayerMovementPlacementTransactionTests
Assert.Equal(DestinationCell, controller.CellId);
Assert.Equal(PriorCell, physics.DataCache!.CellGraph.CurrCell!.Id);
Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId());
Assert.False(player.PositionManager.IsFullyConstrained());
Assert.Null(player.PositionManager.Constraint);
controller.CommitPreparedPosition();
controller.ArmConstraintLeashAtCommittedPlacement();
Assert.Equal(DestinationCell, physics.DataCache.CellGraph.CurrCell!.Id);
Assert.Equal(0u, player.PositionManager.GetStickyObjectId());
// The leash is armed...
Assert.NotNull(player.PositionManager.Constraint);
Assert.True(player.PositionManager.Constraint!.IsConstrained);
// ...but the surviving App-layer commit does NOT publish the render
// root or release the sticky target — those now happen inside the
// Runtime dormant-activation final commit, before this method runs.
Assert.Equal(PriorCell, physics.DataCache.CellGraph.CurrCell!.Id);
Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId());
}
private static PhysicsEngine PhysicsWithCells(params uint[] cellIds)

View file

@ -343,7 +343,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
var clock = new RetailObjectQuantumClock();
var controller = new PlayerMovementController(engine, clock);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
Assert.True(clock.IsActive);
return controller;
}

View file

@ -32,7 +32,7 @@ public sealed class PlayerInteractionMovementSinkTests
MovementType expectedType)
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(Vector3.Zero, Cell);
controller.SeedPlacementForTest(Vector3.Zero, Cell, Vector3.Zero);
bool nonAutonomousAtTargetInstall = false;
int cancellations = 0;
double targetQuantum = 0d;

View file

@ -19,7 +19,7 @@ public sealed class PlayerMovementHiddenTests
const uint cellId = 0x01010001u;
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
controller.SeedPlacementForTest(Vector3.Zero, cellId, Vector3.Zero);
var hosts = new Dictionary<uint, IPhysicsObjHost>();
EntityPhysicsHost? playerHost = null;

View file

@ -158,7 +158,7 @@ public sealed class CameraFrameControllerTests
0f,
0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
return controller;
}

View file

@ -89,7 +89,7 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.Null(harness.Presentation.BeginProjection);
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
harness.Mode.Controller.SetPosition(
harness.Mode.Controller.SeedPlacementForTest(
Vector3.Zero,
0x20210001u,
Vector3.Zero);
@ -117,7 +117,7 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.Empty(harness.Streaming.Reservations);
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
harness.Mode.Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
harness.Mode.Controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
harness.Controller.Tick(0.016f);
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
@ -156,7 +156,7 @@ public sealed class LocalPlayerTeleportControllerTests
harness.Mode.RebuildOnEnter = () =>
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
return controller;
};
harness.Controller.Tick(0.016f);
@ -353,7 +353,7 @@ public sealed class LocalPlayerTeleportControllerTests
// AddSyntheticIndoorCell doc comment for why CommitLandblockCollision
// alone (terrain-only) cannot make it spawn-ready.
harness.AddSyntheticIndoorCell(0x30310100u);
harness.Mode.Controller!.SetPosition(
harness.Mode.Controller!.SeedPlacementForTest(
Vector3.Zero,
0x20210001u,
Vector3.Zero);
@ -649,7 +649,7 @@ public sealed class LocalPlayerTeleportControllerTests
// body/cell/orientation BEFORE this suffix runs. Reproduce that
// pre-state directly rather than re-resolving it here - Place() no
// longer resolves anything (D-T4).
controllerSlot.Controller.SetPosition(position, cell, position);
controllerSlot.Controller.SeedPlacementForTest(position, cell, position);
controllerSlot.Controller.SetBodyOrientation(rotation);
var cameras = new ChaseCameraInputState
{
@ -727,14 +727,14 @@ public sealed class LocalPlayerTeleportControllerTests
{
Controller = new PlayerMovementController(new PhysicsEngine()),
};
controllerSlot.Controller.SetPosition(
controllerSlot.Controller.SeedPlacementForTest(
Vector3.Zero,
sourceCell,
Vector3.Zero);
// C4 route 3: reproduce the canonical Runtime commit's pre-state
// (body/cell already resolved) - Place() no longer resolves it.
var destinationPosition = new Vector3(12f, 24f, 6f);
controllerSlot.Controller.SetPosition(
controllerSlot.Controller.SeedPlacementForTest(
destinationPosition,
destinationCell,
destinationPosition);
@ -1303,7 +1303,7 @@ public sealed class LocalPlayerTeleportControllerTests
{
_order = order;
Controller = new PlayerMovementController(new PhysicsEngine());
Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
Controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
}
public PlayerMovementController? Controller { get; set; }

View file

@ -139,7 +139,7 @@ public sealed class StreamingFrameControllerTests
var fixture = new Fixture(initializeOrigin: true);
fixture.Mode.IsPlayerMode = true;
var player = new PlayerMovementController(new PhysicsEngine());
player.SetPosition(
player.SeedPlacementForTest(
new Vector3(383f, -0.01f, 0f),
0x0A140001u,
new Vector3(1f, 1f, 0f));
@ -159,7 +159,7 @@ public sealed class StreamingFrameControllerTests
{
State = PlayerState.PortalSpace,
};
player.SetPosition(
player.SeedPlacementForTest(
new Vector3(1000f, -1000f, 0f),
0x0A140001u,
new Vector3(1f, 1f, 0f));
@ -179,7 +179,7 @@ public sealed class StreamingFrameControllerTests
var fixture = new Fixture(initializeOrigin: true);
fixture.Mode.IsPlayerMode = true;
var player = new PlayerMovementController(new PhysicsEngine());
player.SetPosition(
player.SeedPlacementForTest(
new Vector3(1000f, -1000f, 0f),
0x0A140001u,
new Vector3(1f, 1f, 0f));

View file

@ -86,7 +86,7 @@ public class DispatcherToMovementIntegrationTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // facing +X
var kb = new FakeKb();
@ -117,7 +117,7 @@ public class DispatcherToMovementIntegrationTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var kb = new FakeKb();
@ -165,12 +165,12 @@ public class DispatcherToMovementIntegrationTests
// frame's MouseDeltaX doesn't leak into the second run via Yaw.
var engineA = MakeFlatEngine();
var ctrlA = new PlayerMovementController(engineA);
ctrlA.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ctrlA.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ctrlA.Yaw = 0f;
var engineB = MakeFlatEngine();
var ctrlB = new PlayerMovementController(engineB);
ctrlB.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ctrlB.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ctrlB.Yaw = 0f;
var inputZero = new MovementInput(Forward: true, MouseDeltaX: 0f);

View file

@ -161,7 +161,7 @@ public class PlayerMoveToCutoverTests
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // heading 90 = facing +X
const uint selfGuid = 0x5000000Au;

View file

@ -108,7 +108,7 @@ public class W6EdgeDrivenMovementTests
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
return controller;
}

View file

@ -236,7 +236,7 @@ public class CellarUpTrajectoryReplayTests : IDisposable
{
var (engine, _) = BuildEngineWithCellarFixtures();
var controller = new PlayerMovementController(engine);
controller.SetPosition(InitialSphereWorld, CellarId);
controller.SeedPlacementForTest(InitialSphereWorld, CellarId, InitialSphereWorld);
var settled = controller.Update(1f / 60f, new MovementInput());
var basePos = settled.Position;

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
@ -6,6 +7,23 @@ namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Enter-world overlap conformance for retail
/// <c>CTransition::find_placement_pos</c> (0x0050BA50).
///
/// <para>
/// C5a (2026-08-05) re-point: the legacy <c>PhysicsEngine.ResolvePlacement</c>
/// this test originally drove is deleted (zero production callers). Canonical
/// <c>PhysicsEngine.SetPosition</c> reaches the SAME ring-search machinery —
/// <c>SetPositionInternal</c> sets <c>InsertType.Placement</c> before calling
/// <c>Transition.FindValidPosition</c>, which (for any non-<c>Transition</c>
/// insert type) dispatches to <c>FindPlacementPosition</c>
/// (<c>TransitionTypes.cs</c>), and THAT method calls this exact
/// <c>FindPlacementPos</c> ring search after its initial-placement insert —
/// so this is not a new code path, only a new entry point onto the one the
/// legacy method used directly. Sphere shape is reconstructed as the
/// two-sphere capsule the legacy scalar <c>(radius, height)</c> overload
/// built internally (origin (0,0,radius) + (0,0,height-radius), both
/// radius-sized), since canonical <c>SetPosition</c> takes a pre-built
/// sphere list rather than a radius/height pair.
/// </para>
/// </summary>
public sealed class InitialPlacementOverlapTests
{
@ -14,6 +32,7 @@ public sealed class InitialPlacementOverlapTests
private const uint PlayerId = 0x50000001u;
private const uint MonsterId = 0x50000002u;
private const float Radius = 0.48f;
private const float SphereHeight = 1.835f;
[Fact]
public void PlayerReloggingInsideMonster_SearchesOutToNearestClearRing()
@ -39,17 +58,30 @@ public sealed class InitialPlacementOverlapTests
RegisterSphere(engine, MonsterId, monsterCenter,
EntityCollisionFlags.IsCreature);
ResolveResult result = engine.ResolvePlacement(
savedFeet,
Cell,
sphereRadius: Radius,
sphereHeight: 1.835f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: PlayerId);
// The mover's own two-sphere capsule, matching the legacy scalar
// InitPath(sphereRadius: 0.48, sphereHeight: 1.835) reconstruction:
// a foot sphere at (0,0,radius) and a head sphere at
// (0,0,height-radius), both radius-sized.
ImmutableArray<FlatCollisionSphere> spheres = ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, Radius), Radius),
new FlatCollisionSphere(new Vector3(0f, 0f, SphereHeight - Radius), Radius));
Assert.True(result.Ok);
PhysicsSetPositionResult result = engine.SetPosition(
new PhysicsSetPositionRequest(
Position: savedFeet,
Orientation: Quaternion.Identity,
CellId: Cell,
CellLocalPosition: savedFeet,
Spheres: spheres,
Scale: 1f,
StepUpHeight: 0.4f,
StepDownHeight: 0.4f,
MoverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
MovingEntityId: PlayerId,
Flags: PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Slide));
Assert.True(result.IsCommitted);
Assert.True(result.InContact);
Assert.True(result.OnWalkable);
Assert.Equal(Cell, result.ContactPlaneCellId);

View file

@ -9,32 +9,49 @@ using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #133 (Bug A) — the validated-claim placement branch of
/// <see cref="PhysicsEngine.Resolve"/> must return the VALIDATED claim's own
/// full cell id, NOT <c>lbPrefix | (cellId &amp; 0xFFFF)</c>.
/// #133 (Bug A) — a validated dungeon claim's landblock prefix is
/// AUTHORITATIVE; a candidate world position that also falls inside a
/// neighbouring RESIDENT landblock's <c>[0,192)</c> local bounds must NOT
/// re-stamp the claim with that neighbour's prefix.
///
/// <para>
/// C5a (2026-08-05) re-point: the legacy <c>PhysicsEngine.Resolve</c> this
/// test originally drove is deleted (zero production callers — every
/// production placement writer reaches canonical
/// <c>PhysicsEngine.SetPosition</c> through <c>RuntimeSetPositionState</c>).
/// The defect this test pins lived entirely inside <c>Resolve</c>'s own
/// <c>lbPrefix</c> resident-landblock scan (find a resident block whose
/// <c>[0,192)</c> bounds contain the candidate XY, then stamp
/// <c>lbPrefix | (cellId &amp; 0xFFFF)</c> — the scan has no notion that
/// the claim's OWN landblock can legitimately fail its own bounds test,
/// which is exactly what a dungeon EnvCell's negative local Y does). The
/// canonical <c>SetPosition</c>/<c>SetPositionInternal</c> pipeline has no
/// equivalent lbPrefix scan: a validated claim (<c>AdjustSeedCell</c>
/// confirms the seed cell contains the point) is committed with ITS OWN
/// cell id, full stop — the defect class cannot recur there by
/// construction. This test now proves that positively: drive the SAME
/// geometry as the original #133 capture (dungeon claim
/// <c>0x00070143</c> at dungeon-local <c>(70,-60,0.01)</c>, with a
/// still-resident Holtburg neighbour block whose <c>[0,192)</c> bounds
/// contain the same world XY) through canonical
/// <c>PhysicsEngine.SetPosition</c> and assert the committed cell keeps
/// the <c>0x0007</c> prefix.
/// </para>
///
/// <para>
/// <c>lbPrefix</c> is found by scanning resident landblocks for one whose
/// <c>[0,192)</c> local bounds contain the candidate XY. A dungeon EnvCell's
/// local Y can be NEGATIVE relative to its own landblock (the live capture:
/// server teleport to dungeon cell <c>0x00070143</c> at local <c>(70,-60,0.01)</c>).
/// The dungeon landblock fails the <c>localY &gt;= 0</c> bounds test, so the loop
/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock
/// whose world bounds happen to contain the same XY) and sets
/// <c>lbPrefix = 0xA9B30000</c>. The old code then returned
/// The dungeon landblock fails the <c>localY &gt;= 0</c> bounds test, so the
/// legacy scan instead matched a still-resident NEIGHBOURING block (a
/// Holtburg landblock whose world bounds happen to contain the same XY) and
/// set <c>lbPrefix = 0xA9B30000</c>. The old code then returned
/// <c>0xA9B30000 | 0x0143 = 0xA9B30143</c>, re-stamping the validated dungeon
/// claim with the wrong landblock — the client mis-resolved the player into
/// Holtburg and spammed ACE with rejected moves
/// (<c>movement pre-validation failed from 00070143 to A9B30143</c>).
/// </para>
///
/// <para>
/// The validated claim's prefix is authoritative; a position falling in a
/// neighbouring resident landblock must not re-stamp it. This test reproduces
/// the exact geometry of the capture (dungeon claim in landblock <c>0x0007</c>,
/// candidate XY also inside resident Holtburg <c>0xA9B3</c>) and asserts the
/// returned cell keeps its <c>0x0007</c> prefix.
/// </para>
/// </summary>
public class Issue133DungeonTeleportPrefixTests
{
@ -53,13 +70,29 @@ public class Issue133DungeonTeleportPrefixTests
{
var engine = BuildEngine();
// Zero delta = the snap shape (teleport arrival). cellId is the dungeon
// claim; the candidate XY also falls inside the resident Holtburg block.
var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f);
// Zero-delta teleport arrival — canonical SetPosition's placement
// shape for a server-restored (cell, position) pair. The dungeon
// claim is the seed cell; the candidate XY also falls inside the
// resident Holtburg block's [0,192) bounds.
PhysicsSetPositionResult result = engine.SetPosition(
new PhysicsSetPositionRequest(
Position: SpawnPos,
Orientation: Quaternion.Identity,
CellId: DungeonCellId,
CellLocalPosition: SpawnPos,
Spheres: default,
Scale: 1f,
StepUpHeight: 0.4f,
StepDownHeight: 0.4f,
Flags: PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Teleport
| PhysicsSetPositionFlags.Slide));
Assert.True(result.IsOnGround);
Assert.True(result.IsCommitted);
// The validated claim's prefix is authoritative — high word stays 0x0007,
// NOT re-stamped to the neighbouring Holtburg 0xA9B3.
// NOT re-stamped to the neighbouring Holtburg 0xA9B3. Canonical
// SetPosition has no lbPrefix scan to get this wrong in the first
// place; this assertion is the positive proof of that.
Assert.Equal(DungeonCellId, result.CellId);
Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u);
}
@ -73,12 +106,13 @@ public class Issue133DungeonTeleportPrefixTests
// The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition
// validates the claim (returns it with found=true). Its Resolved set has
// one walkable floor polygon at z=0 under the spawn XY so the #111
// validated-claim branch grounds onto it.
// one walkable floor polygon at z=0 under the spawn XY so placement
// grounds onto it.
cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell());
// Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the
// candidate XY (70,70). This is the block the lbPrefix loop wrongly matched.
// candidate XY (70,70). This is the block the legacy lbPrefix loop wrongly
// matched.
engine.AddLandblock(
landblockId: HoltburgLandblock,
terrain: FlatTerrain(),
@ -89,7 +123,7 @@ public class Issue133DungeonTeleportPrefixTests
// The dungeon's own landblock, offset so the candidate XY produces a
// NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds
// test, which is exactly why the old code fell through to the Holtburg
// test, which is exactly why the legacy code fell through to the Holtburg
// prefix. Registered so the scenario is faithful (a resident dungeon block
// whose local bounds don't cover the EnvCell's negative-Y position).
engine.AddLandblock(

View file

@ -31,192 +31,6 @@ public class PhysicsEngineTests
return engine;
}
[Fact]
public void Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn()
{
var engine = new PhysicsEngine();
var diagnostics = new List<string>();
Assert.Null(engine.DiagnosticLog);
_ = engine.Resolve(
Vector3.Zero,
cellId: 0xA9B40001u,
delta: Vector3.Zero,
stepUpHeight: 0.5f);
engine.DiagnosticLog = diagnostics.Add;
_ = engine.Resolve(
Vector3.Zero,
cellId: 0xA9B40001u,
delta: Vector3.Zero,
stepUpHeight: 0.5f);
Assert.Single(diagnostics);
Assert.StartsWith(
"[snap] claim=0xA9B40001",
diagnostics[0],
StringComparison.Ordinal);
}
[Fact]
public void Resolve_FlatTerrain_ZMatchesTerrain()
{
var engine = MakeFlatEngine(terrainZ: 50f);
var result = engine.Resolve(
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f),
stepUpHeight: 2f);
Assert.Equal(50f, result.Position.Z, precision: 1);
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_WalkUpSmallSlope_Accepted()
{
// Heights slope from 50 to 52 across X — small enough for step height.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, LinearHeightTable());
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
var result = engine.Resolve(
new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
stepUpHeight: 5f);
Assert.True(result.IsOnGround);
Assert.True(result.Position.Z >= 50f); // moved uphill
}
[Fact]
public void Resolve_StepUpExceedsHeight_MovementBlocked()
{
// Heights jump sharply: left half = 50, right half = 100.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100);
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, LinearHeightTable());
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
// Try to walk from the low side to the high side.
var result = engine.Resolve(
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
stepUpHeight: 2f);
// Movement should be blocked — Z delta (50→100) exceeds step height (2).
Assert.Equal(96f, result.Position.X, precision: 1); // didn't move
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_OutdoorThroughPortal_TransitionsToIndoor()
{
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
// A CellSurface for the indoor cell with floor at Z=50.
var cellVerts = new Dictionary<ushort, Vector3>
{
[0] = new(40f, 40f, 50f),
[1] = new(60f, 40f, 50f),
[2] = new(60f, 60f, 50f),
[3] = new(40f, 60f, 50f),
};
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
// A portal plane at X=45 (vertical plane facing +X).
// OwnerCellId = 0x0100 (the indoor cell), TargetCellId = 0xFFFF (faces outdoor).
// From outside, walking through this portal enters OwnerCellId.
var portal = PortalPlane.FromVertices(
new Vector3(45f, 40f, 45f),
new Vector3(45f, 60f, 45f),
new Vector3(45f, 60f, 55f),
targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0);
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal },
worldOffsetX: 0f, worldOffsetY: 0f);
// Walk from X=40 (outdoor) through X=45 (portal) to X=50 (indoor).
var result = engine.Resolve(
new Vector3(40f, 50f, 50f), cellId: 0x0001, delta: new Vector3(10f, 0f, 0f),
stepUpHeight: 5f);
// Should have transitioned to indoor cell 0x0100.
Assert.Equal(0x0100u, result.CellId & 0xFFFFu);
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_IndoorThroughExitPortal_TransitionsToOutdoor()
{
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
var cellVerts = new Dictionary<ushort, Vector3>
{
[0] = new(40f, 40f, 50f),
[1] = new(60f, 40f, 50f),
[2] = new(60f, 60f, 50f),
[3] = new(40f, 60f, 50f),
};
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
// Same portal geometry — OwnerCellId = 0x0100, TargetCellId = 0xFFFF (outdoor exit).
var portal = PortalPlane.FromVertices(
new Vector3(45f, 40f, 45f),
new Vector3(45f, 60f, 45f),
new Vector3(45f, 60f, 55f),
targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0);
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal },
worldOffsetX: 0f, worldOffsetY: 0f);
// Walk from X=50 (indoor) through X=45 (portal) to X=40 (outdoor).
var result = engine.Resolve(
new Vector3(50f, 50f, 50f), cellId: 0x0100, delta: new Vector3(-10f, 0f, 0f),
stepUpHeight: 5f);
// Should have transitioned to outdoor.
Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
Assert.True(result.IsOnGround);
}
[Fact]
public void Resolve_LandblockBoundary_PicksAdjacentTerrain()
{
var engine = new PhysicsEngine();
// Landblock A: flat at Z=50, offset at X=0.
var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
// Landblock B: flat at Z=60, offset at X=192 (adjacent east).
var terrainB = new TerrainSurface(FlatHeightmap(60), LinearHeightTable());
engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 192f, worldOffsetY: 0f);
// Walk from X=190 (landblock A) across to X=194 (landblock B).
var result = engine.Resolve(
new Vector3(190f, 96f, 50f), cellId: 0x0001, delta: new Vector3(4f, 0f, 0f),
stepUpHeight: 15f);
// Should be at Z=60 (landblock B's terrain) and position X≈194.
Assert.Equal(60f, result.Position.Z, precision: 1);
Assert.True(result.Position.X > 192f);
}
[Fact]
public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId()
{
@ -368,102 +182,6 @@ public class PhysicsEngineTests
Assert.Equal(0xAAB40001u, result.CellId);
}
[Fact]
public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
{
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);
// Start inside the cell, walk out.
var result = engine.Resolve(
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
stepUpHeight: 10f);
// Should transition back to outdoor. (#106 follow-up: masked compare —
// 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.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]
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
{
var engine = new PhysicsEngine();
// No landblocks loaded — entity is floating in void.
var result = engine.Resolve(
new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero,
stepUpHeight: 2f);
Assert.False(result.IsOnGround);
}
/// <summary>
/// #42 lock — when the moving entity's own ShadowEntry is registered
/// in <see cref="ShadowObjectRegistry"/> at the body's exact position

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using AcDream.Core.Physics;
@ -176,57 +177,95 @@ public sealed class TransitionScratchDifferentialTests
Coverage: CoverageKind.Failure));
}
/// <summary>
/// C5a (2026-08-05) re-point: the legacy <c>PhysicsEngine.ResolvePlacement</c>
/// this arm originally drove is deleted (zero production callers).
/// Canonical <c>PhysicsEngine.SetPosition</c> reaches the exact same
/// <c>FindPlacementPos</c> ring search — <c>SetPositionInternal</c> sets
/// <c>InsertType.Placement</c> before <c>Transition.FindValidPosition</c>,
/// which dispatches to <c>FindPlacementPosition</c>, which calls
/// <c>FindPlacementPos</c> — so this is the same scratch-reuse surface
/// under a new entry point, not a new code path. The bitwise
/// fresh-vs-reused comparison and the second-identity leak check are both
/// preserved verbatim, now via <see cref="AssertSetPositionBitwise"/>
/// over <see cref="PhysicsSetPositionResult"/>.
/// </summary>
[Fact]
public void ReusedScratch_MatchesFreshPlacementSearch()
{
PhysicsEngine fresh = BuildPlacementEngine(reuse: false);
PhysicsEngine reused = BuildPlacementEngine(reuse: true);
ResolveResult expected = fresh.ResolvePlacement(
new Vector3(10f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u);
ResolveResult actual = reused.ResolvePlacement(
new Vector3(10f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u);
PhysicsSetPositionResult expected = fresh.SetPosition(
PlacementRequest(
new Vector3(10f, 10f, 0f),
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u));
PhysicsSetPositionResult actual = reused.SetPosition(
PlacementRequest(
new Vector3(10f, 10f, 0f),
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
0x50000101u));
AssertResolveBitwise(expected, actual, "placement");
AssertSetPositionBitwise(expected, actual, "placement");
// A6 (architecture review, 2026-08-05): the bitwise comparison alone
// proves fresh and reused AGREE, not that either actually placed
// anything — a future regression that makes SetPosition fail
// IDENTICALLY on both engines would leave this differential green
// while the scratch-reuse surface it exists to guard goes
// unexercised. Assert the positive fact the comparison itself can't:
// this placement committed.
Assert.True(expected.IsCommitted, "fresh engine did not commit the placement");
Assert.True(actual.IsCommitted, "reused engine did not commit the placement");
// A second placement with a different self identity proves that the
// previous mover and collision-GUID list cannot leak through the lease.
expected = fresh.ResolvePlacement(
new Vector3(11f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.EdgeSlide,
0x80000102u);
actual = reused.ResolvePlacement(
new Vector3(11f, 10f, 0f),
Cell,
0.48f,
1.835f,
0.40f,
0.40f,
ObjectInfoState.EdgeSlide,
0x80000102u);
expected = fresh.SetPosition(
PlacementRequest(
new Vector3(11f, 10f, 0f),
ObjectInfoState.EdgeSlide,
0x80000102u));
actual = reused.SetPosition(
PlacementRequest(
new Vector3(11f, 10f, 0f),
ObjectInfoState.EdgeSlide,
0x80000102u));
AssertResolveBitwise(expected, actual, "placement after hostile identity");
AssertSetPositionBitwise(expected, actual, "placement after hostile identity");
Assert.True(
expected.IsCommitted,
"fresh engine did not commit the second placement");
Assert.True(
actual.IsCommitted,
"reused engine did not commit the second placement");
}
/// <summary>
/// Builds the two-sphere capsule request the legacy scalar
/// <c>InitPath(sphereRadius: 0.48, sphereHeight: 1.835)</c> reconstructed
/// internally (origin (0,0,radius) + (0,0,height-radius), both
/// radius-sized), so canonical <c>SetPosition</c> sees the identical
/// mover shape the legacy <c>ResolvePlacement</c> call did.
/// </summary>
private static PhysicsSetPositionRequest PlacementRequest(
Vector3 position,
ObjectInfoState moverFlags,
uint movingEntityId) => new(
Position: position,
Orientation: Quaternion.Identity,
CellId: Cell,
CellLocalPosition: position,
Spheres: ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.48f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 1.835f - 0.48f), 0.48f)),
Scale: 1f,
StepUpHeight: 0.40f,
StepDownHeight: 0.40f,
MoverFlags: moverFlags,
MovingEntityId: movingEntityId,
Flags: PhysicsSetPositionFlags.Placement
| PhysicsSetPositionFlags.Slide);
private static void RunSequence(
Func<bool, PhysicsEngine> buildEngine,
params ResolveSpec[] specs)
@ -441,6 +480,66 @@ public sealed class TransitionScratchDifferentialTests
Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
}
/// <summary>
/// Bitwise sibling of <see cref="AssertResolveBitwise"/> for
/// <see cref="PhysicsSetPositionResult"/>, used by the re-pointed
/// <see cref="ReusedScratch_MatchesFreshPlacementSearch"/> (C5a). Every
/// field is compared — float-bearing members bitwise (a stale scratch
/// leaking a different -0.0/+0.0 sign bit would otherwise pass a
/// value-equality check), enum/bool/id members by value, and the
/// immutable id arrays by ordered sequence.
/// </summary>
private static void AssertSetPositionBitwise(
PhysicsSetPositionResult expected,
PhysicsSetPositionResult actual,
string context)
{
Assert.Equal(expected.Error, actual.Error);
Assert.Equal(expected.Residence, actual.Residence);
AssertVectorBitwise(expected.Position, actual.Position, context);
AssertQuaternionBitwise(expected.Orientation, actual.Orientation, context);
Assert.Equal(expected.CellId, actual.CellId);
AssertVectorBitwise(
expected.CellLocalPosition,
actual.CellLocalPosition,
$"{context}.CellLocalPosition");
Assert.Equal(expected.InContact, actual.InContact);
Assert.Equal(expected.OnWalkable, actual.OnWalkable);
AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context);
Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId);
Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
Assert.Equal(expected.SlidingNormalValid, actual.SlidingNormalValid);
AssertVectorBitwise(
expected.SlidingNormal,
actual.SlidingNormal,
$"{context}.SlidingNormal");
Assert.Equal(expected.CollisionNormalValid, actual.CollisionNormalValid);
AssertVectorBitwise(
expected.CollisionNormal,
actual.CollisionNormal,
$"{context}.CollisionNormal");
Assert.Equal(expected.FramesStationaryFall, actual.FramesStationaryFall);
Assert.Equal(expected.CollidedWithEnvironment, actual.CollidedWithEnvironment);
Assert.Equal(expected.CollisionHandlerResult, actual.CollisionHandlerResult);
Assert.Equal(expected.CellChanged, actual.CellChanged);
Assert.Equal(expected.ShadowAction, actual.ShadowAction);
// ImmutableArray<T>.Equals(ImmutableArray<T>) compares the BACKING
// ARRAY REFERENCE, not the elements — the fresh and reused engines
// never share a backing array even when the contents match, so
// Assert.Equal on the bare ImmutableArray would false-fail. Compare
// as plain arrays (regular array Equals/sequence comparison) instead.
Assert.Equal(ToArrayOrEmpty(expected.CrossCellIds), ToArrayOrEmpty(actual.CrossCellIds));
Assert.Equal(
ToArrayOrEmpty(expected.CollidedObjectIds),
ToArrayOrEmpty(actual.CollidedObjectIds));
Assert.Equal(
ToArrayOrEmpty(expected.QueriedCellIds),
ToArrayOrEmpty(actual.QueriedCellIds));
}
private static uint[] ToArrayOrEmpty(ImmutableArray<uint> array) =>
array.IsDefault ? Array.Empty<uint>() : array.ToArray();
private static void AssertCoverage(ResolveSpec spec, ResolveResult result)
{
switch (spec.Coverage)

View file

@ -402,9 +402,9 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@ -533,9 +533,9 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@ -727,9 +727,9 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@ -878,9 +878,9 @@ public sealed class HeadlessSessionHostTests
Assert.IsType<PlayerMovementController>(
runtime.MovementOwner.Controller);
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
0xA9B40001u);
0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@ -1583,7 +1583,7 @@ public sealed class HeadlessSessionHostTests
record.Snapshot,
replaceGeneration: false));
var controller = new PlayerMovementController(engine);
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(96f, 97f, 50f),
0xA9B40001u,
new Vector3(96f, 97f, 50f));

View file

@ -94,7 +94,7 @@ public sealed class LocalPlayerImmediatePositionTests
worldOffsetX: 0f,
worldOffsetY: 0f);
var player = new PlayerMovementController(engine);
player.SetPosition(
player.SeedPlacementForTest(
new Vector3(96f, 97f, 50f),
0xA9B40001u,
new Vector3(96f, 97f, 50f));

View file

@ -442,7 +442,7 @@ public sealed class PlayerMouseLookMovementTests
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
return controller;
}

View file

@ -53,7 +53,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
var result = controller.Update(0.016f, new MovementInput());
@ -89,7 +89,7 @@ public class PlayerMovementControllerTests
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var rest = new Vector3(96f, 96f, 50f);
controller.SetPosition(rest, 0x0001);
controller.SeedPlacementForTest(rest, 0x0001, rest);
// Settle one frame so the resolver establishes its rest state, then
// capture the baseline the body must hold.
@ -126,7 +126,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
// Walk forward ~0.5 s, then release.
@ -157,7 +157,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // facing +X
// L.5 physics-tick gate (235de33, 2026-04-30): Update() integrates
@ -181,7 +181,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((_, _) => { });
@ -199,7 +199,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
controller.ObjectScale = 2f;
controller.AttachAnimationRootMotionSource((_, frame) =>
@ -220,7 +220,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
@ -247,7 +247,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f; // body local +Y faces world +X
controller.AttachAnimationRootMotionSource((_, frame) =>
{
@ -273,7 +273,7 @@ public class PlayerMovementControllerTests
public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
controller.SetBodyOrientation(initial);
@ -297,7 +297,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
int sample = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
@ -331,7 +331,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
@ -356,8 +356,8 @@ public class PlayerMovementControllerTests
var combined = new PlayerMovementController(MakeFlatEngine());
var split = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
combined.SetPosition(start, 0x0001);
split.SetPosition(start, 0x0001);
combined.SeedPlacementForTest(start, 0x0001, start);
split.SeedPlacementForTest(start, 0x0001, start);
int combinedHooks = 0;
int splitHooks = 0;
@ -391,7 +391,7 @@ public class PlayerMovementControllerTests
public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
int advances = 0;
int hookPasses = 0;
controller.AttachAnimationRootMotionSource(
@ -412,7 +412,7 @@ public class PlayerMovementControllerTests
public void InvalidElapsed_VisibleFrameIsPurePresentationRead()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
float initialTime = controller.SimTimeSeconds;
float initialYaw = controller.Yaw;
Vector3 initialPosition = controller.Position;
@ -452,7 +452,7 @@ public class PlayerMovementControllerTests
public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
float initialTime = controller.SimTimeSeconds;
int targetPasses = 0;
@ -475,7 +475,7 @@ public class PlayerMovementControllerTests
public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Update(1f, new MovementInput(Jump: true));
Vector3 beforeRelease = controller.Position;
@ -506,7 +506,7 @@ public class PlayerMovementControllerTests
public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((dt, frame) =>
{
@ -531,7 +531,7 @@ public class PlayerMovementControllerTests
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true));
@ -554,14 +554,14 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
var snapped = new Vector3(120f, 80f, 50f);
controller.SetPosition(snapped, 0x0001);
controller.SeedPlacementForTest(snapped, 0x0001, snapped);
var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput());
Assert.Equal(snapped, result.Position);
@ -572,7 +572,7 @@ public class PlayerMovementControllerTests
public void CommitCanonicalForcePositionFrame_ReconcilesPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocity = controller.BodyVelocity;
@ -616,7 +616,7 @@ public class PlayerMovementControllerTests
var world = new Vector3(12f, 12f, 50f);
var wireLocal = new Vector3(12f, 12f, 50f);
controller.SetPosition(world, 0xA9B40031u, wireLocal);
controller.SeedPlacementForTest(world, 0xA9B40031u, wireLocal);
Assert.Equal(0xA9B40001u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
@ -628,7 +628,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
int animationAdvances = 0;
int hookPasses = 0;
@ -656,7 +656,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var result = controller.Update(
@ -680,14 +680,14 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
var runInput = new MovementInput { Forward = true, Run = true };
var runResult = controller.Update(1.0f, runInput);
@ -701,7 +701,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
float initialYaw = controller.Yaw;
var input = new MovementInput { TurnRight = true };
@ -715,7 +715,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// First frame: idle (no input).
controller.Update(0.016f, new MovementInput());
@ -732,7 +732,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Charged jump: hold for a full charge (1s dt), then release to fire.
// A full charge gives enough Vz that the player clears the 0.05-unit
@ -749,7 +749,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
Assert.True(controller.CanSendPositionEvent);
@ -765,7 +765,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
Assert.Equal(default, controller.JumpCharge);
@ -783,7 +783,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Charged jump: hold for a full charge, then release.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
@ -829,7 +829,7 @@ public class PlayerMovementControllerTests
// One step at walk speed will cross into the low region where terrain drops
// ~28 units — more than StepUpHeight=5, triggering the ledge-fall.
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(118f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(118f, 96f, 50f), 0x0001, new Vector3(118f, 96f, 50f));
controller.Yaw = 0f; // facing +X
// Single step — should trigger airborne state because terrain drops sharply.
@ -915,7 +915,7 @@ public class PlayerMovementControllerTests
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); // low16 < 0x0100 -> outdoor
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); // low16 < 0x0100 -> outdoor
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.True(cm.IsConstrained);
@ -935,9 +935,9 @@ public class PlayerMovementControllerTests
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(10f, 10f, 5f),
0x01000105u); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
0x01000105u, new Vector3(10f, 10f, 5f)); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.Equal(5.0f, cm.ConstraintDistanceStart);
@ -948,7 +948,7 @@ public class PlayerMovementControllerTests
public void SetPosition_Teleport_TearsDownAndRearmsAPreviouslyFullyConstrainedLeash()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ConstraintManager cm = controller.PositionManager!.Constraint!;
// Synthetically over-strain the leash with a tight band (production
@ -958,7 +958,7 @@ public class PlayerMovementControllerTests
cm.AdjustOffset(new MotionDeltaFrame { Origin = new Vector3(5f, 0f, 0f) }, quantum: 0.1);
Assert.True(controller.PositionManager.IsFullyConstrained());
controller.SetPosition(new Vector3(150f, 150f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(150f, 150f, 50f), 0x0001, new Vector3(150f, 150f, 50f));
// retail teleport_hook's UnConstrain, followed by the fresh re-arm at
// the new position, clears the stale over-strained state.
@ -971,7 +971,7 @@ public class PlayerMovementControllerTests
{
var (controller, _) = MakeControllerWithHost();
var initial = new Vector3(96f, 96f, 50f);
controller.SetPosition(initial, 0x0001);
controller.SeedPlacementForTest(initial, 0x0001, initial);
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocityBeforeCommit = controller.BodyVelocity;
Assert.NotEqual(Vector3.Zero, velocityBeforeCommit); // sanity: actually moving
@ -1002,7 +1002,7 @@ public class PlayerMovementControllerTests
public void Update_ConstraintArmedInBand_TapersALargeRootMotionOffsetOnTheSecondTick()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ConstraintManager cm = controller.PositionManager!.Constraint!;
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 10f);
@ -1034,7 +1034,7 @@ public class PlayerMovementControllerTests
public void Update_ConstraintOverstrained_PushesIsFullyConstrainedOntoBodyAndBlocksJump()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ConstraintManager cm = controller.PositionManager!.Constraint!;
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 2f);
@ -1069,7 +1069,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
// Fixed per-tick local-forward delta, matching
// Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale's
@ -1145,7 +1145,7 @@ public class PlayerMovementControllerTests
default));
Assert.Throws<InvalidOperationException>(() => candidate.TickHidden(
1f / 60f));
Assert.Throws<InvalidOperationException>(() => candidate.SetPosition(
Assert.Throws<InvalidOperationException>(() => candidate.SeedPlacementForTest(
Vector3.One,
0xA9B40021u,
Vector3.One));
@ -1155,7 +1155,12 @@ public class PlayerMovementControllerTests
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws<InvalidOperationException>(() =>
candidate.NoteMovementSent(1f));
Assert.Throws<InvalidOperationException>(candidate.CommitPreparedPosition);
// C5a (2026-08-05): CommitPreparedPosition is deleted (zero production
// callers); ArmConstraintLeashAtCommittedPlacement is its production
// replacement and carries the SAME EnsurePublishedForRuntimeOperation
// guard at its own entry, so this re-points rather than drops.
Assert.Throws<InvalidOperationException>(
candidate.ArmConstraintLeashAtCommittedPlacement);
Assert.Throws<InvalidOperationException>(() =>
candidate.ApplyPhysicsState(PhysicsStateFlags.Frozen));
Assert.Throws<InvalidOperationException>(() => candidate.LocalEntityId = 2u);
@ -1194,7 +1199,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.Motion.DefaultSink = new FakeAnimationDispatchSink();
// No root-motion displacement contributed -- isolates the residual

View file

@ -17,7 +17,7 @@ public sealed class PlayerOutboundPositionTests
// The render origin remains A9B1, so a point 22.56 m into A9B2 is
// represented at world Y=214.56. The wire frame must remain the
// carried A9B2-local 22.56, never the render-space 214.56.
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));
@ -42,7 +42,7 @@ public sealed class PlayerOutboundPositionTests
public void OutboundPosition_PreservesCompleteAuthoritativeQuaternion()
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));

View file

@ -13,7 +13,7 @@ public sealed class RuntimeLocalPlayerMovementStateTests
{
LocalEntityId = 0x50000001u,
};
controller.SetPosition(
controller.SeedPlacementForTest(
new Vector3(11f, 12f, 13f),
0xA9B40001u,
new Vector3(11f, 12f, 13f));
@ -140,8 +140,8 @@ public sealed class RuntimeLocalPlayerMovementStateTests
second.Controller = secondController;
first.Execute(RuntimeMovementCommand.ToggleRunLock);
firstController.SetPosition(Vector3.One, 0xA9B40001u, Vector3.One);
secondController.SetPosition(
firstController.SeedPlacementForTest(Vector3.One, 0xA9B40001u, Vector3.One);
secondController.SeedPlacementForTest(
new Vector3(2f),
0xA9B50001u,
new Vector3(2f));

View file

@ -2970,7 +2970,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
1f / 60f));
Assert.Throws<InvalidOperationException>(() =>
controller.SuspendObjectUpdate(1f / 60f));
Assert.Throws<InvalidOperationException>(() => controller.SetPosition(
Assert.Throws<InvalidOperationException>(() => controller.SeedPlacementForTest(
Vector3.One,
Cell,
Vector3.One));
@ -3003,8 +3003,12 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
controller.PrepareForAttackRequest());
Assert.Throws<InvalidOperationException>(() =>
controller.RequestPosture(MotionCommand.Ready));
// C5a (2026-08-05): CommitPreparedPosition is deleted (zero production
// callers); ArmConstraintLeashAtCommittedPlacement is its production
// replacement and carries the SAME EnsurePublishedForRuntimeOperation
// guard at its own entry, so this re-points rather than drops.
Assert.Throws<InvalidOperationException>(() =>
controller.CommitPreparedPosition());
controller.ArmConstraintLeashAtCommittedPlacement());
Assert.Throws<InvalidOperationException>(() =>
controller.PreparePositionForCommit(Vector3.One, Cell, Vector3.One));
Assert.Throws<InvalidOperationException>(() =>