fix(physics): TS-46 - seed the sweep from the Setup's own sphere list

Campaign P Slice P3 item 1. Retail CPhysicsObj::transition (0x00512dc0)
seeds the collision sweep from CPartArray::GetSphere (the Setup's own
<=2-sphere list, each origin+radius scaled by m_scale) via
SPHEREPATH::init_sphere (0x0050c670) -- not from a symmetric two-scalar
(radius, height) capsule reconstruction. The human Setup 0x02000001's
authored spheres are (0,0,0.475) r=.48 and (0,0,1.350) r=.48; the old
reconstruction from (0.48, 1.835) produced (0,0,0.48) + (0,0,1.355), a
5 mm head-center offset the TS-46 register row documented as a residual.

Port:
- SpherePath.InitPath gains a sphere-list overload (ImmutableArray<
  FlatCollisionSphere>, scale) sharing a new InitPathCore with the
  existing (radius, height) overload, which is now the degenerate
  2-scalar case of the same code -- byte-for-byte unchanged, so every
  captured-fixture replay (CellarUpTrajectoryReplayTests,
  DoorBugTrajectoryReplayTests, CellarLipWedgeTests) keeps passing
  unmodified.
- PhysicsEngine.ResolveWithTransition gains optional sphereList/
  sphereScale parameters; empty/default preserves the legacy scalar
  path for every pre-existing caller.
- LiveEntityMotionRuntimeController.GetSetupMoverShape is a new sibling
  of GetSetupCylinder (left untouched) that resolves the Setup's own
  sphere list plus Setup-derived step-up/step-down
  (CPartArray::GetStepUpHeight/GetStepDownHeight, 0x005180d0/0x005180f0,
  x ObjScale, 0.4 m fallback matching the pre-existing literal).
- Threaded through PlayerMovementController (both resolve call sites,
  new SphereList property set by PlayerModeController.ApplyStepHeights
  and the Headless world projection), RuntimeRemotePhysicsUpdater
  (Tick + TickHidden), and RuntimeOrdinaryPhysicsUpdater.TryBegin.
  Remote/ordinary step heights are now Setup-derived instead of a
  hardcoded 0.4f literal. Projectile and camera-probe sweeps are
  untouched (already single-sphere-exact).
- PlayerModeController.ApplyStepHeights also now applies the x ObjScale
  multiply to the player's own step heights (previously only the
  remote/ordinary paths did), closing an adjacent gap the P3 research
  flagged.

Ts46SphereListConformanceTests proves the sphere-list overload sees the
exact dat spheres (not the reconstruction), that the scalar overload is
unchanged, and that ResolveWithTransition's sphereList parameter
actually drives the sweep (a decoy-scalar control pair using a
head-height obstacle sphere).

Register: TS-46 retired (both residuals it named are closed); header
count corrected to 40 active TS rows.

dotnet build + dotnet test (Core.Tests 3991/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 09:05:44 +02:00
parent 3dc10accb0
commit dae5b1ea68
21 changed files with 648 additions and 50 deletions

View file

@ -234,7 +234,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) |
| AP-127 | Campaign P Slice P1's run/jump base-skill chain omits two minor retail additive/multiplier terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `EnchantSkill` runs (property `0x146` "> 0 → +5" bonus; property `0x158` "specialized skill" doubling of a PP-derived term), and reads the raw wire current-stamina value for the zero-skill gate rather than the retail-adjusted local copy (`EnchantAttribute2nd(ATTR2ND_STAMINA)` can apply a Stamina-buff to that check's own copy without changing the displayed vital) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`ApplySkillEnchantments`); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`InqRunRate`/`InqJumpVelocity` stamina==0 gate) | Bounded per the P1 plan's explicit scope ("port only what the run/jump query path needs... not a general effective-skill engine"); both terms are rare/small relative to the dominant formulaBonus+init+ranks+vitae chain, which IS fully ported | A character with the specific rare property set (0x146/0x158) or an active Stamina-buff at exactly 0 raw stamina predicts a slightly different run/jump skill than retail; low practical impact | `CACQualities::InqRunRate` 0x00592800 pc 413824 (0x146/0x158 reads); `CEnchantmentRegistry::EnchantAttribute2nd` 0x00594670 pc 416169 |
## 4. Temporary stopgap (TS) — 41 active rows (TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
## 4. Temporary stopgap (TS) — 40 active rows (TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port, see the AD-53/AD-54 rows for the two compensating branches it left registered; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -267,7 +267,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset``InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal rε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |

View file

@ -481,7 +481,8 @@ internal sealed class LivePresentationCompositionPhase
static value => value.Dispose());
var ordinaryPhysicsUpdater = new LiveEntityOrdinaryPhysicsUpdater(
d.EntityObjects.Physics,
d.MotionBindings.GetSetupCylinder);
d.MotionBindings.GetSetupCylinder,
d.MotionBindings.GetSetupMoverShape);
var animationScheduler = new LiveEntityAnimationScheduler(
liveEntities,
d.PlayerIdentity,

View file

@ -1,3 +1,5 @@
using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.Net;
@ -370,7 +372,7 @@ internal sealed class PlayerModeController :
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
}
ApplyStepHeights(controller, playerEntity);
ApplyStepHeights(controller, playerEntity, playerGuid);
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
Action? drainPriorAnimationQueue = null;
@ -532,7 +534,8 @@ internal sealed class PlayerModeController :
private void ApplyStepHeights(
PlayerMovementController controller,
WorldEntity playerEntity)
WorldEntity playerEntity,
uint playerGuid)
{
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
@ -544,22 +547,48 @@ internal sealed class PlayerModeController :
_collisionAssets.CacheSetup(
playerEntity.SourceGfxObjOrSetupId,
setup);
// TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight
// (0x005180d0/0x005180f0) return setup->step_up_height * this->scale
// — apply the same ObjScale multiply the remote/ordinary paths now
// use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for
// parity on a non-1.0-scale player (a rare but real case — e.g. a
// disguise/size-changing effect). Human ObjScale is 1.0 in the
// overwhelming common case, so this is a no-op there.
float scale =
_liveEntities.Snapshots.TryGetValue(playerGuid, out var sp)
&& sp.ObjScale is { } objScale && objScale > 0f
? objScale
: (playerEntity.Scale > 0f ? playerEntity.Scale : 1f);
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight
? setup.StepUpHeight * scale
: 0.4f;
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
? setup.StepDownHeight
? setup.StepDownHeight * scale
: 0.4f;
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim —
// retail CPhysicsObj::transition (0x00512dc0) seeds the sweep
// from CPartArray::GetSphere, not a (radius, height) capsule
// reconstruction. Empty (no Setup, or a Setup with no sphere
// rows) leaves SphereList at its default empty value, which
// ResolveWithTransition treats as "use the legacy scalar
// reconstruction."
controller.SphereList = setup?.Spheres is { Count: > 0 } spheres
? spheres
.Select(s => new FlatCollisionSphere(s.Origin, s.Radius))
.ToImmutableArray()
: ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
$"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
+ $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
+ $"StepDown={controller.StepDownHeight:F3} m "
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3})");
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), "
+ $"Spheres={controller.SphereList.Length}");
return;
}
controller.StepUpHeight = 0.4f;
controller.StepDownHeight = 0.4f;
controller.SphereList = ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}

View file

@ -1,6 +1,8 @@
using System.Collections.Immutable;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
@ -14,6 +16,11 @@ namespace AcDream.App.Physics;
internal interface ILiveEntityMotionRuntimeBindings
{
(float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity);
/// <summary>TS-46 sibling of <see cref="GetSetupCylinder"/> — see
/// <c>LiveEntityMotionRuntimeController.GetSetupMoverShape</c>.</summary>
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)
GetSetupMoverShape(uint serverGuid, WorldEntity entity);
bool RouteServerMoveTo(
MovementManager movement,
uint cellId,
@ -59,6 +66,10 @@ internal sealed class DeferredLiveEntityMotionRuntimeBindings
uint serverGuid,
WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity);
public (ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)
GetSetupMoverShape(uint serverGuid, WorldEntity entity) =>
Target.GetSetupMoverShape(serverGuid, entity);
public bool RouteServerMoveTo(
MovementManager movement,
uint cellId,

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.World;
@ -283,6 +284,45 @@ internal sealed class LiveEntityMotionRuntimeController
return (setup.Radius * scale, setup.Height * scale);
}
/// <summary>
/// TS-46 (2026-07-30) sibling of <see cref="GetSetupCylinder"/>: the
/// Setup's own ≤2-sphere list (retail <c>CPhysicsObj::transition</c>
/// 0x00512dc0 → <c>SPHEREPATH::init_sphere</c> 0x0050c670) plus
/// Setup-derived step-up/step-down (<c>CPartArray::GetStepUpHeight</c>/
/// <c>GetStepDownHeight</c>, 0x005180d0/0x005180f0 — both scaled by the
/// object's own ObjScale, matching the existing 0.4 m literal fallback
/// the remote/ordinary callers already carried). <see cref="GetSetupCylinder"/>
/// is deliberately UNTOUCHED — its callers want the single-radius/height
/// CYLINDER for sticky/moveto math, not the collision sweep shape.
/// Returns an empty sphere list (and the 0.4 m fallbacks) when the
/// entity has no resolvable/prepared Setup, or when the Setup carries no
/// sphere rows — <see cref="PhysicsEngine.ResolveWithTransition"/>'s
/// <c>sphereList</c> parameter treats empty as "use the legacy
/// two-scalar reconstruction", so this degrades gracefully rather than
/// degenerating the sweep.
/// </summary>
public (ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)
GetSetupMoverShape(uint serverGuid, AcDream.Core.World.WorldEntity entity)
{
FlatSetupCollision? setup =
_physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
if (setup is null)
return (ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f);
// Same scale resolution as GetSetupCylinder (see its own comment):
// the spawn record's ObjScale is authoritative for live spawns; a
// non-spawn entity (scenery) falls back to WorldEntity.Scale.
float scale =
_liveEntities.Snapshots.TryGetValue(serverGuid, out var sp)
&& sp.ObjScale is { } objScale && objScale > 0f
? objScale
: (entity.Scale > 0f ? entity.Scale : 1f);
float stepUp = setup.StepUpHeight > 0f ? setup.StepUpHeight * scale : 0.4f;
float stepDown = setup.StepDownHeight > 0f ? setup.StepDownHeight * scale : 0.4f;
return (setup.Spheres, scale, stepUp, stepDown);
}
// #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
// AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
// caller outside the DR tick; SyncRemoteShadowToBody is now called back via

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
@ -17,15 +18,23 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
private readonly RuntimeOrdinaryPhysicsUpdater _runtime;
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
_getSetupCylinder;
private readonly Func<uint, WorldEntity,
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)>
_getSetupMoverShape;
public LiveEntityOrdinaryPhysicsUpdater(
RuntimePhysicsState physics,
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder)
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder,
Func<uint, WorldEntity,
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)>
getSetupMoverShape)
{
_runtime = new RuntimeOrdinaryPhysicsUpdater(
physics ?? throw new ArgumentNullException(nameof(physics)));
_getSetupCylinder = getSetupCylinder
?? throw new ArgumentNullException(nameof(getSetupCylinder));
_getSetupMoverShape = getSetupMoverShape
?? throw new ArgumentNullException(nameof(getSetupMoverShape));
}
public bool Tick(
@ -49,6 +58,7 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
if (record.PhysicsBody is not { } body)
return false;
var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
var shape = _getSetupMoverShape(record.ServerGuid, entity);
bool ExternalOwnerValid() =>
IsCurrent(
runtime,
@ -68,7 +78,11 @@ internal sealed class LiveEntityOrdinaryPhysicsUpdater
sequencer,
captureAnimationHooks,
ExternalOwnerValid,
out RuntimeOrdinaryPhysicsCommit commit))
out RuntimeOrdinaryPhysicsCommit commit,
sphereList: shape.Spheres,
sphereScale: shape.Scale,
stepUpHeight: shape.StepUpHeight,
stepDownHeight: shape.StepDownHeight))
{
return false;
}

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.World;
@ -18,6 +19,9 @@ internal sealed class RemotePhysicsUpdater
private readonly RuntimeRemotePhysicsUpdater _runtime;
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
_getSetupCylinder;
private readonly Func<uint, WorldEntity,
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)>
_getSetupMoverShape;
private readonly Action<uint, LiveEntityAnimationState, RemoteMotion, Vector3>
_applyServerControlledVelocityCycle;
private readonly List<LiveEntityRecord> _spatialRemoteSnapshot = new();
@ -25,6 +29,9 @@ internal sealed class RemotePhysicsUpdater
internal RemotePhysicsUpdater(
RuntimePhysicsState physics,
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder,
Func<uint, WorldEntity,
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)>
getSetupMoverShape,
Action<uint, LiveEntityAnimationState, RemoteMotion, Vector3>
applyServerControlledVelocityCycle)
{
@ -32,6 +39,8 @@ internal sealed class RemotePhysicsUpdater
physics ?? throw new ArgumentNullException(nameof(physics)));
_getSetupCylinder = getSetupCylinder
?? throw new ArgumentNullException(nameof(getSetupCylinder));
_getSetupMoverShape = getSetupMoverShape
?? throw new ArgumentNullException(nameof(getSetupMoverShape));
_applyServerControlledVelocityCycle =
applyServerControlledVelocityCycle
?? throw new ArgumentNullException(
@ -189,6 +198,7 @@ internal sealed class RemotePhysicsUpdater
ownerClockEpoch);
var (radius, height) =
_getSetupCylinder(ownerRecord.ServerGuid, entity);
var shape = _getSetupMoverShape(ownerRecord.ServerGuid, entity);
Action<Vector3>? staleCycle =
animationForVelocityCycle is null
? null
@ -221,7 +231,11 @@ internal sealed class RemotePhysicsUpdater
entity.Rotation = snapshot.Orientation;
return OwnerValid();
},
OwnerValid);
OwnerValid,
sphereList: shape.Spheres,
sphereScale: shape.Scale,
stepUpHeight: shape.StepUpHeight,
stepDownHeight: shape.StepDownHeight);
}
public bool TickHidden(
@ -251,6 +265,7 @@ internal sealed class RemotePhysicsUpdater
ownerClockEpoch);
var (radius, height) =
_getSetupCylinder(ownerRecord.ServerGuid, entity);
var shape = _getSetupMoverShape(ownerRecord.ServerGuid, entity);
return _runtime.TickHidden(
ownerRecord.Canonical,
remote,
@ -270,7 +285,11 @@ internal sealed class RemotePhysicsUpdater
entity.Rotation = snapshot.Orientation;
return OwnerValid();
},
OwnerValid);
OwnerValid,
sphereList: shape.Spheres,
sphereScale: shape.Scale,
stepUpHeight: shape.StepUpHeight,
stepDownHeight: shape.StepDownHeight);
}
public void SyncRemoteShadowToBody(

View file

@ -652,6 +652,7 @@ public sealed class GameWindow :
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_runtimeEntityObjects.Physics,
_liveEntityMotionBindings.GetSetupCylinder,
_liveEntityMotionBindings.GetSetupMoverShape,
AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher(
(movement, cellId, update) =>

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
namespace AcDream.Core.Physics;
@ -1093,7 +1094,17 @@ public sealed class PhysicsEngine
Vector3? localSphereOrigin = null,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null,
uint designatedTargetId = 0)
uint designatedTargetId = 0,
// TS-46 (2026-07-30): the mover's own Setup ≤2-sphere list (retail
// CPhysicsObj::transition 0x00512dc0 → SPHEREPATH::init_sphere
// 0x0050c670), scaled by sphereScale (the object's own m_scale /
// wire ObjScale) exactly as init_sphere applies it per-sphere.
// Default/empty preserves the legacy sphereRadius/sphereHeight
// two-scalar reconstruction below — every pre-existing caller
// (camera probe, projectiles, captured-fixture replays) that omits
// this parameter is byte-for-byte unaffected.
ImmutableArray<FlatCollisionSphere> sphereList = default,
float sphereScale = 1f)
{
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
@ -1185,15 +1196,33 @@ public sealed class PhysicsEngine
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereRadius,
sphereHeight,
localSphereOrigin,
beginOrientation,
endOrientation);
if (!sphereList.IsDefaultOrEmpty)
{
// TS-46: the Setup's verbatim sphere list, not the two-scalar
// capsule reconstruction. localSphereOrigin has no meaning
// here — every sphere already carries its own dat-authored
// origin.
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereList,
sphereScale,
beginOrientation,
endOrientation);
}
else
{
transition.SpherePath.InitPath(
currentPos,
targetPos,
cellId,
sphereRadius,
sphereHeight,
localSphereOrigin,
beginOrientation,
endOrientation);
}
// #145: supply the carried cell-relative frame anchor to the outdoor
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Numerics;
using DatReaderWriter.Types;
@ -970,7 +971,16 @@ public sealed class SpherePath
}
/// <summary>
/// Initialize the path for a simple point-to-point movement.
/// Initialize the path for a simple point-to-point movement from a
/// (radius, height) capsule reconstruction. TS-46 (2026-07-30): this is
/// now the degenerate 2-scalar case of <see cref="InitPath(Vector3, Vector3, uint, ImmutableArray{FlatCollisionSphere}, float, Quaternion?, Quaternion?)"/> —
/// retail's own <c>CPhysicsObj::transition</c> (0x00512dc0) seeds the
/// sweep from the Setup's OWN sphere list (verbatim origin+radius per
/// sphere), not from a symmetric two-scalar capsule. Callers with a
/// resolved Setup should prefer the sphere-list overload; this scalar
/// overload remains for callers without one (camera probe, projectiles,
/// captured-fixture replays) and is unchanged byte-for-byte from the
/// pre-TS-46 behavior.
/// </summary>
public void InitPath(
Vector3 begin,
@ -981,6 +991,97 @@ public sealed class SpherePath
Vector3? localSphereOrigin = null,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null)
{
Vector3 origin0 = localSphereOrigin ?? new Vector3(0, 0, sphereRadius);
if (sphereHeight > 0)
{
InitPathCore(
begin, end, cellId, 2,
origin0, sphereRadius,
new Vector3(0, 0, sphereHeight - sphereRadius), sphereRadius,
beginOrientation, endOrientation);
}
else
{
InitPathCore(
begin, end, cellId, 1,
origin0, sphereRadius,
Vector3.Zero, 0f,
beginOrientation, endOrientation);
}
}
/// <summary>
/// TS-46 (2026-07-30): initialize the path from the Setup's OWN authored
/// sphere list — retail <c>CPhysicsObj::transition</c> (0x00512dc0) →
/// <c>SPHEREPATH::init_sphere</c> (0x0050c670): up to 2 spheres, each
/// origin AND radius independently scaled by the object's own
/// <c>m_scale</c> (wire ObjScale), matching <c>init_sphere(count, src,
/// scale)</c>'s exact signature. <paramref name="spheres"/> longer than 2
/// is capped, matching retail's hard <c>num_sphere = min(count, 2)</c>.
/// An empty list falls back to retail's own <c>numSphere == 0</c> arm
/// (transition() passes a single dummy sphere, scale 1.0) rather than
/// throwing, so a shapeless Setup degrades gracefully instead of
/// degenerating the sweep with a zero radius.
/// </summary>
public void InitPath(
Vector3 begin,
Vector3 end,
uint cellId,
ImmutableArray<FlatCollisionSphere> spheres,
float scale = 1f,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null)
{
if (spheres.IsDefaultOrEmpty)
{
InitPathCore(
begin, end, cellId, 1,
new Vector3(0, 0, PhysicsGlobals.DummySphereRadius), PhysicsGlobals.DummySphereRadius,
Vector3.Zero, 0f,
beginOrientation, endOrientation);
return;
}
int count = spheres.Length <= 2 ? spheres.Length : 2;
FlatCollisionSphere s0 = spheres[0];
if (count > 1)
{
FlatCollisionSphere s1 = spheres[1];
InitPathCore(
begin, end, cellId, 2,
s0.Origin * scale, s0.Radius * scale,
s1.Origin * scale, s1.Radius * scale,
beginOrientation, endOrientation);
}
else
{
InitPathCore(
begin, end, cellId, 1,
s0.Origin * scale, s0.Radius * scale,
Vector3.Zero, 0f,
beginOrientation, endOrientation);
}
}
/// <summary>
/// Shared tail of both <c>InitPath</c> overloads: seeds begin/end/cell,
/// orientation, the (≤2) local sphere slots, and the globalized
/// <see cref="GlobalCurrCenter"/> array. Extracted so the sphere-list
/// overload (TS-46) and the legacy scalar overload can never drift from
/// each other on anything but sphere-source geometry.
/// </summary>
private void InitPathCore(
Vector3 begin,
Vector3 end,
uint cellId,
int numSphere,
Vector3 origin0,
float radius0,
Vector3 origin1,
float radius1,
Quaternion? beginOrientation,
Quaternion? endOrientation)
{
BeginPos = begin;
EndPos = end;
@ -992,18 +1093,13 @@ public sealed class SpherePath
CurOrientation = BeginOrientation;
CheckOrientation = BeginOrientation;
LocalSphere[0].Origin = localSphereOrigin ?? new Vector3(0, 0, sphereRadius);
LocalSphere[0].Radius = sphereRadius;
if (sphereHeight > 0)
NumSphere = numSphere;
LocalSphere[0].Origin = origin0;
LocalSphere[0].Radius = radius0;
if (numSphere > 1)
{
NumSphere = 2;
LocalSphere[1].Origin = new Vector3(0, 0, sphereHeight - sphereRadius);
LocalSphere[1].Radius = sphereRadius;
}
else
{
NumSphere = 1;
LocalSphere[1].Origin = origin1;
LocalSphere[1].Radius = radius1;
}
SetCheckPos(begin, cellId);

View file

@ -461,5 +461,11 @@ internal sealed class HeadlessSessionWorldProjection
controller.StepDownHeight = setup.StepDownHeight > 0f
? setup.StepDownHeight
: 0.4f;
// TS-46 (2026-07-30): the prepared package already carries the
// Setup's verbatim sphere list — no raw-DAT read needed here (unlike
// the graphical PlayerModeController.ApplyStepHeights, which reads
// DatReaderWriter.DBObjs.Setup directly). Empty falls back to
// ResolveWithTransition's legacy scalar reconstruction.
controller.SphereList = setup.Spheres;
}
}

View file

@ -163,6 +163,21 @@ public sealed class PlayerMovementController
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
/// <summary>
/// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat
/// <c>CSphere</c> Origin+Radius), verbatim per retail
/// <c>CPhysicsObj::transition</c> (0x00512dc0) →
/// <c>SPHEREPATH::init_sphere</c> (0x0050c670). Set at world-entry by
/// <c>PlayerModeController.ApplyStepHeights</c> alongside
/// <see cref="StepUpHeight"/>/<see cref="StepDownHeight"/>. Default
/// (empty) falls back to <c>ResolveWithTransition</c>'s legacy
/// (0.48, 1.835) two-scalar capsule reconstruction — the human Setup
/// 0x02000001's authored spheres are (0,0,0.475) r=.48 and
/// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's
/// (0,0,0.48) + (0,0,1.355).
/// </summary>
public System.Collections.Immutable.ImmutableArray<FlatCollisionSphere> SphereList { get; set; }
/// <summary>
/// Retail <c>CPhysicsObj::m_scale</c>. Grounded CSequence root
/// displacement is multiplied by this value before PositionManager
@ -1404,7 +1419,12 @@ public sealed class PlayerMovementController
isOnGround: previousOnWalkable,
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: LocalEntityId);
movingEntityId: LocalEntityId,
// TS-46: the player's own Setup sphere list, scaled by
// ObjectScale. Empty falls back to the 0.48/1.835
// reconstruction above.
sphereList: SphereList,
sphereScale: ObjectScale);
_body.CommitTransitionPosition(resolved.CellId, resolved.Position);
PhysicsObjUpdate.CommitSetPositionTransition(
_body,
@ -1849,7 +1869,14 @@ public sealed class PlayerMovementController
// when the local player entity spawns (or stays 0 in tests, in
// which case there's no registered ShadowEntry to collide with
// anyway).
movingEntityId: LocalEntityId);
movingEntityId: LocalEntityId,
// TS-46 (2026-07-30): the player's own Setup sphere list
// (0x02000001: (0,0,0.475) r=.48 + (0,0,1.350) r=.48),
// scaled by ObjectScale. Empty (unset/no Setup resolved yet)
// falls back to the sphereRadius/sphereHeight reconstruction
// above.
sphereList: SphereList,
sphereScale: ObjectScale);
// L.4-diag (2026-04-30): trace position transitions so we can see
// whether the body is actually moving frame-to-frame on the steep

View file

@ -49,7 +49,15 @@ internal sealed class RuntimeOrdinaryPhysicsUpdater
AnimationSequencer? sequencer,
Action<uint, AnimationSequencer> captureAnimationHooks,
Func<bool>? externalOwnerValid,
out RuntimeOrdinaryPhysicsCommit commit)
out RuntimeOrdinaryPhysicsCommit commit,
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived
// step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape).
// Default/empty preserves the pre-TS-46 0.4 m literal fallback below.
System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
sphereList = default,
float sphereScale = 1f,
float stepUpHeight = 0.4f,
float stepDownHeight = 0.4f)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rootFrame);
@ -130,14 +138,18 @@ internal sealed class RuntimeOrdinaryPhysicsUpdater
sourceCellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
isOnGround: previousOnWalkable,
body: body,
moverFlags: IsPlayerGuid(record.ServerGuid)
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
: ObjectInfoState.EdgeSlide,
movingEntityId: movingEntityId);
movingEntityId: movingEntityId,
// TS-46: the Setup's own sphere list, scaled by ObjScale.
// Empty falls back to the radius/height reconstruction above.
sphereList: sphereList,
sphereScale: sphereScale);
if (resolved.Ok)
{

View file

@ -75,7 +75,15 @@ internal sealed class RuntimeRemotePhysicsUpdater
System.Action<System.Numerics.Vector3>? applyStaleVelocityCycle = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? externalOwnerValid = null)
System.Func<bool>? externalOwnerValid = null,
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived
// step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape).
// Default/empty preserves the pre-TS-46 human-capsule fallback below.
System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere>
sphereList = default,
float sphereScale = 1f,
float stepUpHeight = 0.4f,
float stepDownHeight = 0.4f)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rm);
@ -344,8 +352,13 @@ internal sealed class RuntimeRemotePhysicsUpdater
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: deR,
sphereHeight: deH,
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
// TS-46: the Setup's own sphere list, scaled by the
// creature's own ObjScale. Empty falls back to the
// deR/deH two-scalar reconstruction above.
sphereList: sphereList,
sphereScale: sphereScale,
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
// airborne remotes must NOT pre-seed the
// ContactPlane, otherwise AdjustOffset's snap-to-plane
@ -629,7 +642,13 @@ internal sealed class RuntimeRemotePhysicsUpdater
AcDream.Core.Physics.AnimationSequencer? sequencer = null,
System.Func<RuntimeRemotePhysicsSnapshot, bool>?
acknowledgeProjection = null,
System.Func<bool>? externalOwnerValid = null)
System.Func<bool>? externalOwnerValid = null,
// TS-46 (2026-07-30): see the visible Tick's identical parameters.
System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere>
sphereList = default,
float sphereScale = 1f,
float stepUpHeight = 0.4f,
float stepDownHeight = 0.4f)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rm);
@ -700,15 +719,19 @@ internal sealed class RuntimeRemotePhysicsUpdater
rm.CellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
isOnGround: previousOnWalkable,
body: rm.Body,
moverFlags: IsPlayerGuid(record.ServerGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
movingEntityId: localEntityId);
movingEntityId: localEntityId,
// TS-46: the Setup's own sphere list, scaled by ObjScale.
// Empty falls back to the radius/height reconstruction above.
sphereList: sphereList,
sphereScale: sphereScale);
rm.Body.Position = resolved.Position;
if (resolved.CellId != 0)
committedCellId = resolved.CellId;

View file

@ -203,6 +203,10 @@ public sealed class LivePresentationCompositionTests
uint serverGuid,
WorldEntity entity) => (radius, 1f);
public (System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)
GetSetupMoverShape(uint serverGuid, WorldEntity entity) =>
(System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f);
public bool RouteServerMoveTo(
MovementManager movement,
uint cellId,

View file

@ -65,6 +65,9 @@ public sealed class DeferredLiveEntityMotionRuntimeBindingsTests
Calls.Add($"cylinder:{guid}");
return (2f, 3f);
}
public (System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)
GetSetupMoverShape(uint guid, WorldEntity entity) =>
(System.Collections.Immutable.ImmutableArray<AcDream.Core.Physics.FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f);
public bool RouteServerMoveTo(
MovementManager movement,
uint cellId,

View file

@ -57,7 +57,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
var updater = new LiveEntityOrdinaryPhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f));
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f));
var rootFrame = new Frame
{
Origin = new Vector3(2f, 0f, 0f),
@ -124,7 +125,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests
Vector3 retainedEntityPosition = entity.Position;
var updater = new LiveEntityOrdinaryPhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f));
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f));
Assert.False(updater.Tick(
live,

View file

@ -125,6 +125,7 @@ public sealed class ProjectileControllerTests
var updater = new RemotePhysicsUpdater(
fixture.Live.Physics,
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f),
(_, _, _, _) => { });
int publishedRoots = 0;

View file

@ -722,6 +722,7 @@ public sealed class RemotePhysicsUpdaterTests
var updater = new RemotePhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f),
(_, _, _, _) => { });
var published = new List<uint>();
var partPoseDirty = new List<uint>();
@ -804,6 +805,7 @@ public sealed class RemotePhysicsUpdaterTests
new RemotePhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f),
(_, _, _, _) => { }));
}
@ -995,6 +997,7 @@ public sealed class RemotePhysicsUpdaterTests
Updater = new RemotePhysicsUpdater(
Live.Physics,
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f),
(_, _, _, _) => { });
}

View file

@ -629,10 +629,12 @@ public sealed class LiveEntityAnimationSchedulerTests
var remotePhysics = new RemotePhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f),
(_, _, _, _) => { });
var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater(
live.Physics,
(_, _) => (0.48f, 1.835f));
(_, _) => (0.48f, 1.835f),
(_, _) => (System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>.Empty, 1f, 0.4f, 0.4f));
var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid };
var poses = new EntityEffectPoseRegistry();
foreach (LiveEntityRecord record in live.MaterializedRecords)

View file

@ -0,0 +1,276 @@
using System;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for Campaign P Slice P3's TS-46 port: retail
/// <c>CPhysicsObj::transition</c> (0x00512dc0) seeds the sweep from the
/// Setup's OWN sphere list via <c>SPHEREPATH::init_sphere</c> (0x0050c670),
/// not from a symmetric two-scalar (radius, height) capsule reconstruction.
/// The dat human Setup 0x02000001's authored spheres are <c>(0,0,0.475)
/// r=.48</c> and <c>(0,0,1.350) r=.48</c> (Height = 1.835); the pre-TS-46
/// reconstruction from (0.48, 1.835) produced a foot sphere at
/// <c>(0,0,0.48)</c> and a head sphere at <c>(0,0,1.355)</c> — a 5 mm
/// head-center offset that TS-46's own register row (retail-divergence-
/// register.md) documented as a residual. These tests prove the sphere-list
/// overload now sees the EXACT dat spheres, that the pre-existing scalar
/// overload is byte-for-byte unchanged (so captured-fixture replays that
/// use it stay green), and that <see cref="PhysicsEngine.ResolveWithTransition"/>
/// actually routes the sphere list into the sweep rather than silently
/// preferring the legacy scalars when both are supplied.
/// </summary>
public class Ts46SphereListConformanceTests
{
private readonly ITestOutputHelper _out;
public Ts46SphereListConformanceTests(ITestOutputHelper output) => _out = output;
private const uint TestLandblockId = 0xA9C50000u;
private const uint TestCellId = TestLandblockId | 0x0001u;
// Human Setup 0x02000001's verbatim authored sphere list.
private static readonly ImmutableArray<FlatCollisionSphere> HumanSetupSpheres =
ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.48f));
[Fact]
public void SphereListInitPath_MatchesDatSpheresExactly_NotTheReconstruction()
{
var sp = new SpherePath();
sp.InitPath(
begin: Vector3.Zero,
end: new Vector3(1f, 0f, 0f),
cellId: TestCellId,
spheres: HumanSetupSpheres,
scale: 1f);
Assert.Equal(2, sp.NumSphere);
// Foot sphere: dat-exact (0,0,0.475) r=.48 — NOT the reconstruction's
// (0,0,0.48) (the reconstruction and the dat happen to agree on the
// RADIUS-as-Z-origin coincidence for the foot sphere, but the true
// origin is 0.475, not 0.48 — assert the dat value, not the coincidence).
Assert.Equal(new Vector3(0f, 0f, 0.475f), sp.LocalSphere[0].Origin);
Assert.Equal(0.48f, sp.LocalSphere[0].Radius);
// Head sphere: dat-exact (0,0,1.350) — NOT the reconstruction's
// (0,0,1.355). This is the load-bearing assertion: TS-46's register
// row documents this exact 5 mm gap as the residual being retired.
Assert.Equal(new Vector3(0f, 0f, 1.350f), sp.LocalSphere[1].Origin);
Assert.Equal(0.48f, sp.LocalSphere[1].Radius);
Assert.NotEqual(1.355f, sp.LocalSphere[1].Origin.Z);
}
[Fact]
public void SphereListInitPath_AppliesScaleToOriginAndRadius()
{
var sp = new SpherePath();
sp.InitPath(
begin: Vector3.Zero,
end: Vector3.Zero,
cellId: TestCellId,
spheres: HumanSetupSpheres,
scale: 2f);
// Retail SPHEREPATH::init_sphere multiplies EACH sphere's origin AND
// radius by the object's own m_scale (wire ObjScale) — not just the
// radius.
Assert.Equal(new Vector3(0f, 0f, 0.95f), sp.LocalSphere[0].Origin);
Assert.Equal(0.96f, sp.LocalSphere[0].Radius);
Assert.Equal(new Vector3(0f, 0f, 2.70f), sp.LocalSphere[1].Origin);
Assert.Equal(0.96f, sp.LocalSphere[1].Radius);
}
[Fact]
public void SphereListInitPath_CapsAtTwoSpheres_MatchingRetailHardCap()
{
// SPHEREPATH::init_sphere (0x0050c670): num_sphere = min(count, 2).
var threeSpheres = ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 2.0f), 0.20f));
var sp = new SpherePath();
sp.InitPath(Vector3.Zero, Vector3.Zero, TestCellId, threeSpheres, scale: 1f);
Assert.Equal(2, sp.NumSphere);
Assert.Equal(1.350f, sp.LocalSphere[1].Origin.Z);
}
[Fact]
public void SphereListInitPath_EmptyList_FallsBackToDummySphere()
{
// Retail transition()'s numSphere==0 arm: a single dummy sphere,
// scale 1.0 — not a degenerate zero-radius sweep.
var sp = new SpherePath();
sp.InitPath(Vector3.Zero, Vector3.Zero, TestCellId, ImmutableArray<FlatCollisionSphere>.Empty);
Assert.Equal(1, sp.NumSphere);
Assert.Equal(PhysicsGlobals.DummySphereRadius, sp.LocalSphere[0].Radius);
}
[Fact]
public void ScalarInitPath_IsByteForByteUnchanged_PreservingCapturedFixtureReplays()
{
// The pre-TS-46 scalar overload is now the degenerate 2-scalar case
// of the sphere-list overload — its OUTPUT must be identical to the
// pre-port behavior (the reconstructed 5 mm-off pair), because every
// captured-fixture replay test (CellarUpTrajectoryReplayTests,
// DoorBugTrajectoryReplayTests, CellarLipWedgeTests) calls this exact
// overload with recorded scalar values and must keep passing
// unmodified.
var sp = new SpherePath();
sp.InitPath(
begin: Vector3.Zero,
end: Vector3.Zero,
cellId: TestCellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f);
Assert.Equal(2, sp.NumSphere);
Assert.Equal(new Vector3(0f, 0f, 0.48f), sp.LocalSphere[0].Origin);
Assert.Equal(0.48f, sp.LocalSphere[0].Radius);
// The reconstruction's own arithmetic: height radius = 1.835 0.48 = 1.355.
Assert.Equal(new Vector3(0f, 0f, 1.355f), sp.LocalSphere[1].Origin);
Assert.Equal(0.48f, sp.LocalSphere[1].Radius);
// And this is genuinely DIFFERENT from the dat-exact sphere list —
// documents the 5 mm residual the TS-46 register row described.
Assert.NotEqual(1.350f, sp.LocalSphere[1].Origin.Z);
}
/// <summary>
/// Integration-level proof that <see cref="PhysicsEngine.ResolveWithTransition"/>'s
/// <c>sphereList</c> parameter actually drives the sweep, not just
/// <see cref="SpherePath.InitPath"/> in isolation. A tiny DECOY
/// (sphereRadius, sphereHeight) capsule that could never reach a
/// head-height obstacle is passed ALONGSIDE the real human sphere list;
/// if the engine silently preferred the decoy scalars over the supplied
/// list, the mover would sail through untouched.
/// </summary>
[Fact]
public void ResolveWithTransition_HonorsSphereListOverScalarDecoyWhenBothSupplied()
{
var engine = BuildEngine();
const float HeadZ = 1.350f; // human Setup 0x02000001 Spheres[1].Origin.Z
RegisterObstacleSphere(engine, 0xD0D0u, x: 12f, y: 11.0f, z: HeadZ, radius: 0.30f);
Vector3 pos = new(12f, 10f, 0f);
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f);
for (int tick = 0; tick < 30; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
// DECOY scalars: a 5 cm radius / 10 cm capsule cannot reach
// Z=1.35 no matter what — if the engine used THESE, the
// mover would pass straight through the obstacle.
sphereRadius: 0.05f,
sphereHeight: 0.10f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: grounded,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
sphereList: HumanSetupSpheres,
sphereScale: 1f);
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
// Surface contact at Y = 11.0 (0.48 + 0.30) = 10.22.
Assert.True(pos.Y < 10.6f,
"Supplying the real human sphere list must block the mover's head "
+ $"sphere at the head-height obstacle, proving sphereList (not the "
+ $"decoy scalar) drove the sweep; got Y={pos.Y:F3}");
Assert.True(pos.Y > 9.9f,
$"The mover must actually reach the obstacle, not stop early; got Y={pos.Y:F3}");
}
/// <summary>
/// Control for the test above: the SAME decoy scalars, but with
/// <c>sphereList</c> omitted (empty/default). The decoy capsule can
/// never reach the head-height obstacle, so the mover must sail through
/// untouched — proving the block above genuinely comes from the
/// supplied sphere list, not some unrelated artifact of the obstacle
/// registration.
/// </summary>
[Fact]
public void ResolveWithTransition_EmptySphereList_FallsBackToScalarReconstruction()
{
var engine = BuildEngine();
const float HeadZ = 1.350f;
RegisterObstacleSphere(engine, 0xD0D1u, x: 12f, y: 11.0f, z: HeadZ, radius: 0.30f);
Vector3 pos = new(12f, 10f, 0f);
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f);
for (int tick = 0; tick < 30; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
sphereRadius: 0.05f,
sphereHeight: 0.10f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: grounded,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
// sphereList omitted — default empty, legacy scalar reconstruction.
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
Assert.True(pos.Y > 11.9f,
"With no sphereList supplied, the decoy capsule (too short to reach "
+ $"the head-height obstacle) must sail through untouched; got Y={pos.Y:F3}");
}
private static PhysicsEngine BuildEngine()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var heights = new byte[81];
var heightTable = new float[256]; // all zero → terrain Z = 0
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
private static void RegisterObstacleSphere(
PhysicsEngine engine, uint entityId, float x, float y, float z, float radius)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
new Vector3(x, y, z), Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 0f, scale: 1f,
state: 0u,
flags: EntityCollisionFlags.IsCreature,
isStatic: false);
}
}