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:
parent
f8e55ba5e4
commit
6921a02744
34 changed files with 508 additions and 946 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 0x0001–0x0040; 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue