diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 560a6e3a..b99a5c97 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -146,6 +146,8 @@ src/
PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active)
ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active)
PhysicsEngine.cs -> ResolveWithTransition active player path
+ ProjectilePhysicsStepper.cs -> pure retail projectile integration + sweep
+ RetailFrameMath.cs -> retail full-3D vector-heading frame construction
CellBsp.cs -> not a first-class runtime owner yet (L.2e)
World/
GameEntity.cs -> target unified entity, not current reality
@@ -216,7 +218,12 @@ InputDispatcher / PlayerMovementController
What exists and is active:
- `PhysicsEngine.ResolveWithTransition` is the path used for local player
- collision resolution.
+ collision resolution and the shared continuous object sweep.
+- `ProjectilePhysicsStepper` is the pure Core driver for retail projectile
+ clock quanta, final-state acceleration, velocity/angular integration,
+ AlignPath, Setup-sphere transition sweeps, and collision response. It owns no
+ live entity, network, rendering, or App state; the App controller supplies
+ those boundaries.
- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
the transition path.
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b0bbebd2..cac40e3e 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -220,7 +220,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
-| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
@@ -295,8 +294,8 @@ WITH that phase, not before.
11. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
-M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
-TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
+M2 combat must land TS-5 (CanJump gating), TS-23 (PK bits), TS-25
+(stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId).
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index d600ca2e..00f73346 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -184,7 +184,8 @@ behavior.
catch #103).
- **Camera-collision (shipped 2026-05-30, kept):** retail `SmartBox::update_viewer`
swept-sphere spring arm (`CameraDiagnostics.CollideCamera`, `PhysicsCameraCollisionProbe`,
- `RetailChaseCamera` integration) + viewer/sight bypass of the 30-step transition cap.
+ `RetailChaseCamera` integration); Step 6 later removed the non-retail 30-step
+ transition cap for every mover after real missile catch-up sweeps proved it invalid.
Specs: [`2026-05-29-a8f-camera-collision-design.md`](../superpowers/specs/2026-05-29-a8f-camera-collision-design.md).
**Today's pre-M1.5 baseline** (2026-05-20 — committed in this
@@ -514,6 +515,8 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
+- **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant.
+
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
**Acceptance:** chat messages display in a retail-style panel, health/stamina/mana orbs fill correctly, attributes panel shows player stats, inventory opens with drag-drop working, and sound plays on hit/footstep.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index a09784cf..b14247ed 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade
preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
-- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–5
+- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–6
implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
@@ -509,9 +509,15 @@ include dungeons.
and per-child pose buffers are reused. Rigid attachment/effect frames exclude
visual Setup scale, attachment withdrawal cascades through descendants, and
later pose publication recovers the complete descendant chain parent-first;
- light registration follows final projection visibility. AP-67, TS-10, TS-11, and TS-12 are
- retired. The remaining steps port projectile motion and
- sweeps, and Hidden/UnHide portal presentation.
+ light registration follows final projection visibility. Step 6 adds the pure
+ Core retail projectile driver: 0.2-second catch-up quanta, the 50-unit/second
+ clamp, final-state gravity, world-space omega, full-3D AlignPath, scaled
+ Setup-local sphere sweeps, exact missile target/ethereal exclusions,
+ self-shadow rejection, retail collision response, and full-cell landblock
+ rebasing. Missile adds PathClipped but never PerfectClip. AP-67, TS-2, TS-10,
+ TS-11, and TS-12 are retired; AP-83/AP-91 remain dormant. The remaining
+ steps integrate live projectile ownership/corrections and port Hidden/UnHide
+ portal presentation.
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
funnel and action-stamp gate, so ACE's server-selected melee/missile action
diff --git a/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md b/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md
index 76a68d9b..82d23ef5 100644
--- a/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md
+++ b/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md
@@ -5,12 +5,12 @@
This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers:
- What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder)
-- What happens for very small dt? (early-return at EPSILON, NOT MinQuantum — retail processes every frame)
-- How is `LastUpdateTime` advanced? (always to `PhysicsTimer::curr_time` after the loop)
+- What happens for very small dt? (micro-fragments at EPSILON are consumed; remainders at or below MinQuantum accumulate)
+- How is `LastUpdateTime` advanced? (to the consumed `PhysicsTimer::curr_time`, not unconditionally to the wall clock)
- What does `process_hooks` do? (iterates linked-list of `PhysicsObjHook`s + `anim_hooks` per frame, executing & removing finished ones)
- `Frame::combine` semantics: `out = a · b` — Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion product `a.q * b.q` for orientation).
-The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some immediate floats to `0.0`. The constants are recovered cleanly from ACE's `PhysicsGlobals.cs`, which itself is a faithful port of the same retail binary. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it's the early-exit tolerance distinct from `MinQuantum`.
+The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some global loads to `0.0`. Matching-client disassembly resolves them as 1/30 second, 0.2 seconds, and 2.0 seconds. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it is the micro-fragment tolerance distinct from `MinQuantum`.
---
@@ -72,13 +72,13 @@ The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible
// ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ─────
// BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure
- // is unmistakable (line 284031 do-while). ACE's port confirms MaxQuantum=0.1.
- if (dt > 0.0f /* MaxQuantum=0.1 */) {
+ // is unmistakable (line 284031 do-while). Matching disassembly resolves 0.2.
+ if (dt > 0.0f /* MaxQuantum=0.2 */) {
do {
- PhysicsTimer::curr_time += /* MaxQuantum */ 0.1;
- CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f);
- dt -= /* MaxQuantum */ 0.1;
- } while (dt > /* MaxQuantum */ 0.1);
+ PhysicsTimer::curr_time += /* MaxQuantum */ 0.2;
+ CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.2f);
+ dt -= /* MaxQuantum */ 0.2;
+ } while (dt > /* MaxQuantum */ 0.2);
}
// ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ────
@@ -94,18 +94,18 @@ The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible
}
```
-### Constants — recovered values
+### Constants — matching-client values
-From `references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9-43`:
+Recovered from the v11.4186 matching executable and cross-checked against the named control flow:
| Symbol | Hex (float32) | Value | Meaning |
|---|---|---|---|
| `EPSILON` | `0x3949A18A` | `0.000199999995f` ≈ 0.0002 s | "essentially zero" tolerance (visible in retail decomp line 283996) |
| `MinQuantum` | `0x3D088889` | `1.0f / 30.0f ≈ 0.03333` s (30 fps) | minimum simulation step |
-| `MaxQuantum` | `0x3DCCCCCD` | `0.1f` (10 fps) | substep cap — BN-corrupted to 0.0 in pseudo-C but confirmed via ACE |
+| `MaxQuantum` | `0x3E4CCCCD` | `0.2f` (5 fps) | catch-up substep cap — BN-corrupted to 0.0 in pseudo-C |
| `HugeQuantum` | `0x40000000` | `2.0f` (0.5 fps) | upper bound — beyond this, dt is discarded as stale (visible line 284009) |
-**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail clearly reads non-zero immediates from `.rdata`. The decompiler dropped the immediate during constant-folding when sourcing from a global. Cross-reference with ACE confirms these are MaxQuantum=0.1 in the loop body and 0.0333 in the final-remainder guard.
+**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail reads non-zero globals. Matching-client disassembly confirms these are MaxQuantum=0.2 in the loop body and MinQuantum=1/30 in the final-remainder guard. ACE's server-side 0.1-second value is not the retail-client oracle here.
### `update_object_server` — does it exist?
@@ -660,20 +660,33 @@ Sets heading from a yaw angle (degrees):
### `Frame::set_vector_heading` (FUN_00535DB0) — line 320030
-Sets heading to face a normalized 2D direction vector (rotation around Z-axis):
+Replaces the frame rotation so local forward (+Y) faces a full three-dimensional
+direction with zero roll. A zero/small vector leaves the existing rotation
+unchanged. The named lift preserves the X/Y compass calculation and Z-driven
+`asin` elevation path, although it mangles the `euler_set_rotate` argument list:
```
00535db0 void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
Vector3 d = *dir;
- if (AC1Legacy::Vector3::normalize_check_small(&d) != 0) return;
+ if (AC1Legacy::Vector3::normalize_check_small(&d) != 0)
+ return; // preserve the current frame
+
// Note: AC's heading convention — angle from north (+Y) measured clockwise.
// 450 - atan2(x, y) normalizes to [0, 360).
double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323;
yawDeg = fmod(yawDeg, 360.0);
- Frame::euler_set_rotate(this, ..., 0, ..., yawDeg * 0.017453292519943295);
+
+ // Full 3-D elevation; not a yaw-only rotation. The retail Euler convention
+ // places normalized d exactly on the resulting frame's local +Y axis.
+ double elevation = asin(d.z);
+ Frame::euler_set_rotate(this, zeroRoll, elevation, yawDeg * DEG_TO_RAD);
}
```
+The conformance contract is therefore `transform(UnitY, rotation) == normalize(dir)`
+for horizontal, elevated, downward, and near-vertical vectors. The exact-vertical
+case uses retail's deterministic zero-compass fallback.
+
### `Frame::rotate` (FUN_004525B0) — line 91477
Applies a small rotation increment in local space (used by omega integration):
@@ -763,11 +776,16 @@ Note: the BN decomp shows `result->z; result->y; result->x;` followed by `return
```
dt = currentTime - LastUpdateTime
+PhysicsTimer.current = LastUpdateTime
-if (dt < EPSILON) return; // < 0.0002s — too small, defer
-if (dt > HugeQuantum) return; // > 2.0s — stale, discard, advance update_time
+if (dt <= EPSILON): // <= 0.0002s — micro-fragment
+ LastUpdateTime = currentTime // consume without simulation
+ return
+if (dt > HugeQuantum): // > 2.0s — stale gap
+ LastUpdateTime = currentTime // discard without simulation
+ return
-while (dt > MaxQuantum): // 0.1 s
+while (dt > MaxQuantum): // 0.2 s
PhysicsTimer.curr_time += MaxQuantum
UpdateObjectInternal(MaxQuantum)
dt -= MaxQuantum
@@ -781,38 +799,24 @@ LastUpdateTime = PhysicsTimer.curr_time
**Observations:**
-1. **Retail processes every frame**, not just every 30 Hz. The first guard is `EPSILON`, not `MinQuantum`. ACE flipped this to `< TickRate` (= 1/30) for server CPU savings — that's a divergence, not retail-faithful.
-2. **dt below MinQuantum but above EPSILON → no simulation that frame, but `update_time` IS advanced** to current time (line 284004). That means the next frame's dt is small again — accumulation is implicit, not explicit. There is no carry-over residue.
-3. **Between MinQuantum and MaxQuantum: a single substep** for the full dt. (60-fps client => dt ≈ 0.0167 s — wait, that's BELOW MinQuantum.) **Actually at 60 fps the second guard (`> MinQuantum`) is also FALSE, so nothing runs.** This is consistent with retail running its physics tick at 30 Hz in `MainProc`. Retail's `MainProc` ticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through.
-4. **Between MaxQuantum and HugeQuantum: chunked substepping at fixed 0.1 s, then 1 final remainder if it exceeds MinQuantum.** This handles frame-stutter / lag spikes (e.g. world load).
+1. **The EPSILON branch and MinQuantum remainder are different.** A true micro-fragment is consumed immediately. A remainder above EPSILON but at or below 1/30 second runs no simulation and leaves `PhysicsTimer.current` at the prior update time, so it accumulates into the next call.
+2. **At a 60 Hz render rate**, the first ~0.0167-second call remains accumulated; the next call sees ~0.0334 seconds and executes one physics quantum. This yields the retail ~30 Hz physics cadence without an explicit accumulator field.
+3. **Between MinQuantum and MaxQuantum**, retail executes one substep for the full elapsed time.
+4. **Between MaxQuantum and HugeQuantum**, retail replays fixed 0.2-second catch-up quanta, then one final remainder only when it exceeds MinQuantum.
5. **`process_hooks` runs once per substep** (inside `UpdatePositionInternal`), so its rate scales with substep count — important for FPHook fade timers.
6. **The collision sweep (`transition`) runs once per substep**, which is why bypassing it (env-var path bug) caused the "staircase" effect on slopes.
### What this means for `LastUpdateTime` advancement
-After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. `PhysicsTimer.curr_time` was incremented inside the loop by `MaxQuantum` per substep + the final remainder. **It accumulates ONLY consumed time** — if the early-`< EPSILON` guard fires, `update_time` is set to `Timer::cur_time` directly, dropping any unconsumed micro-fragment.
+After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. It advances only by simulated quanta. The EPSILON and HugeQuantum guards instead assign `Timer::cur_time` directly because those fragments are deliberately consumed or discarded.
---
## 14 — Cross-check against acdream's port
-**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs:404-435`):**
+**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs`)** now carries the same clock contract: EPSILON/HugeQuantum guards consume wall-clock time, the 0.2-second loop advances a local physics clock, and a remainder at or below MinQuantum remains accumulated. `ProjectilePhysicsStepper` applies the same clock around the full integration-and-transition sequence.
-acdream uses the threshold values correctly (MinQuantum=1/30, MaxQuantum=0.1, HugeQuantum=2.0) and has the substep loop. **But it gates on `dt < MinQuantum` for the early return**, not `< EPSILON`. This matches ACE's port (which uses TickRate=1/30) but **diverges from retail**, which uses EPSILON=0.0002.
-
-**Practical effect of the divergence:**
-
-At 60 fps (dt = 0.0167 s), retail would: pass the EPSILON gate → fail the loop gate (0.0167 < MaxQuantum=0.1) → fail the remainder gate (0.0167 < MinQuantum=0.0333) → bump `update_time` and return. **No simulation ran, but time was consumed.** Next frame: dt = 0.0167 again. Retail effectively sub-samples to 30 Hz, but does it through threshold-based skipping rather than explicit accumulation. Net effect: physics ticks at ~30 Hz on a 60-Hz render loop.
-
-acdream's port: at 60 fps, dt = 0.0167 s, fails first gate (`< MinQuantum` = 0.0333), returns immediately WITHOUT updating `LastUpdateTime`. **Next frame: dt = 0.0334 s**, passes the first gate, runs `UpdatePhysicsInternal(0.0334)` once. Net effect: physics ticks at ~30 Hz, same outcome, but via accumulation. Equivalent functionally; minor structural divergence.
-
-Recommendation: the acdream port is fine as-is for acdream (no behavioral difference at 30 Hz target), but the comment at line 395 (`if dVar1 < MinQuantum → return`) should note that retail uses EPSILON; the change is intentional alignment with ACE's optimization.
-
-**`GameWindow.cs` per-tick remote motion path (line 6541-6553):**
-
-The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps gate" is **accurate about acdream's port** but **slightly misleading about retail's behavior**. Retail does NOT have a 30 Hz gate in `update_object`; it has a substep loop that effectively delivers 30 Hz simulation by way of the inner thresholds. The manual omega integration in GameWindow (line 6553-6559) is a workaround for a different reason — the body's quaternion-omega integration doesn't run when the body's update_object's loop body doesn't run, but acdream's render-tick path needs orientation continuity at 60 fps. This is a legitimate divergence forced by the env-var-path architecture; it's not a retail mismatch.
-
-**The real bug (Commit B fix at line 6190):** the env-var path was missing the `ResolveWithTransition` call (port of `find_transitional_position`) that retail runs once per substep. Restoring it (line 6220) brought the env-var path back in line with retail.
+The live projectile path does not use the older render-only remote-motion integration. It invokes a transition for every consumed quantum, matching `UpdateObjectInternal`, so a catch-up frame cannot tunnel merely because several quanta were required.
---
@@ -820,5 +824,4 @@ The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps
- **`Frame::set_origin`** and **`Frame::is_zero`** don't exist as named symbols. The conventions are: direct field write for origin, and `Vector3::is_zero` on `frame.m_fOrigin` for the test. Confirm acdream's port uses the same conventions (no need for these methods on the `Frame` type).
- **`update_object_server` does not exist.** ACE's distinction between client and server update is not present in retail. The retail client and the retail server (which acdream emulates) probably both run the same code path; if so, acdream needs only one `update_object`.
-- **The early-return gate divergence (EPSILON vs MinQuantum)** is functionally invisible at 30 Hz physics target but worth documenting in the port comment so future readers know it's an intentional ACE-style optimization, not a retail-faithful copy.
- **`process_hooks` linked-list (`PhysicsObjHook`)** is not yet ported in acdream. acdream has anim-hook routing (`AnimationHookRouter`) but no equivalent of FPHook / TranslucencyHook / VisibilityHook persistent linked-list framework. Phase that uses translucency animations or scale fades will need this.
diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
index 86744ce8..b7e44a47 100644
--- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
+++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
@@ -821,3 +821,54 @@ observable ordering without exposing a half-committed pose to modern sinks.
through normal owner teardown while retaining DAT-static/synthetic poses.
TS-11 and TS-12 are retired with the emitter-binding and
live-part mechanisms.
+
+## 15. Step 6 implementation map (2026-07-14)
+
+- `ProjectilePhysicsStepper` is a pure Core clock/integration/sweep owner. It
+ accepts an already-live `PhysicsBody`, full cell, Setup-local sphere, local
+ entity identity, and optional authoritative target identity; it owns no
+ renderer, network state, or logical lifetime. `ProjectileController` in the
+ next step will select records and apply authoritative corrections around it.
+- The shared integrator now uses retail's 0.2-second maximum catch-up quantum
+ and world-space angular rotation (`Frame::grotate`, quaternion delta
+ pre-multiplied). Velocity is clamped to 50 world units/second before
+ integration, acceleration is recalculated from the final PhysicsState each
+ quantum, and AlignPath replaces orientation from the complete three-axis
+ displacement through `RetailFrameMath.SetVectorHeading`.
+- Installed projectile Setup auditing proves the required arrow, bolt, and
+ representative spell set uses exactly one ordinary collision sphere. The
+ port retains the complete Setup-local origin and scales both origin and
+ radius; it therefore preserves Force Bolt's offset
+ `(0, -0.165, 0.1045)` and never derives collision size from the mesh.
+- `ResolveWithTransition` accepts the sphere's local origin and begin/end
+ orientations, carries the resulting orientation in `ResolveResult`, skips
+ the moving entity's own shadow, and automatically adds PathClipped for a
+ Missile body. It deliberately does not add PerfectClip.
+- `ObjectInfo.MissileIgnore` is the exact `0x0050CEB0` branch tree. Runtime
+ collision metadata now distinguishes “has a CWeenieObject” from “is a
+ creature”; server-created live entities set the former, while DAT landblock
+ geometry does not. The designated target remains zero until an
+ authoritative wire/runtime source supplies one.
+- Transition subdivision provides the continuous sweep. Conformance covers
+ maximum-speed thin sphere and BSP walls, terrain floors, elastic reflection,
+ inelastic stopping, missiles, ethereal weenies, designated and unknown
+ creature targets, self exclusion, and loaded landblock crossing. Retail's
+ transition loop has no arbitrary step-count cap; a 50-unit/second arrow with
+ a 0.1-unit sphere therefore completes all 100 sweep steps in one 0.2-second
+ catch-up quantum. On transition failure, the integrated candidate frame is
+ retained in the current cell and cached velocity is zeroed without running
+ `SetPositionInternal` or collision response; transition-local contact,
+ stationary, walkable-polygon, and sliding results are discarded. Successful
+ `SetPositionInternal` writeback keeps Contact distinct from OnWalkable: a
+ steep plane can be contact while only normals with Z at or above FloorZ are
+ walkable. Both facts commit before collision response, so a floor impact
+ settles on the following tick without turning a steep hit into ground.
+- The landblock-crossing test exposed and fixed a shared world-frame rebasing
+ defect: after the containing cell crosses a 192-metre block boundary, the
+ carried block origin must move by the same block delta before the next
+ substep. Retail gets this for free from cell-relative `Position.Frame`;
+ acdream now performs the equivalent rebase at the ordered containing-cell
+ retarget, preventing one extra landblock jump per remaining sweep substep.
+ Boundary conformance covers positive and negative X and Y crossings.
+- TS-2 is retired. AP-83 and AP-91 remain because ordinary missiles are
+ PathClipped and do not arm their dormant PerfectClip time-of-impact paths.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 4c787697..e4d5f862 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -4404,9 +4404,13 @@ public sealed class GameWindow : IDisposable
// Decode PvP / Player / Impenetrable from PWD._bitfield.
// IsCreature comes from the spawn's ItemType (server-known type).
- var flags = AcDream.Core.Physics.EntityCollisionFlags.None;
+ // Every server-spawned live entity is backed by a CWeenieObject.
+ // Static landblock geometry is registered through the separate path
+ // with HasWeenie clear. Retail OBJECTINFO::missile_ignore 0x0050CEB0
+ // needs this distinction for ethereal non-creatures such as doors.
+ var flags = AcDream.Core.Physics.EntityCollisionFlags.HasWeenie;
if (spawn.ObjectDescriptionFlags is { } odf)
- flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
+ flags |= AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
diff --git a/src/AcDream.Core/Physics/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
index 5ee86a85..f08e7d9f 100644
--- a/src/AcDream.Core/Physics/EntityCollisionFlags.cs
+++ b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
@@ -39,6 +39,14 @@ public enum EntityCollisionFlags : byte
IsPKLite = 0x08,
/// Set when BF_FREE_PKSTATUS (0x200000) is set (a.k.a. "Free" PK status — cannot be PKed).
IsImpenetrable = 0x10,
+ ///
+ /// Runtime collision metadata: the target has a retail
+ /// CWeenieObject. This is deliberately distinct from
+ /// because doors, portals, and items also have a
+ /// weenie. OBJECTINFO::missile_ignore at 0x0050CEB0
+ /// requires this distinction for ethereal targets.
+ ///
+ HasWeenie = 0x20,
}
/// Helpers to convert raw retail bitfields into .
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 07efb263..ab4d7c7c 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -106,7 +106,9 @@ public sealed class PhysicsBody
public const float SmallVelocitySquared = SmallVelocity * SmallVelocity;
public const float DefaultFriction = 0.95f;
public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s
- public const float MaxQuantum = 0.1f; // 10 fps lower bound
+ // Matching-client disassembly resolves the named lift's stripped global to
+ // 0.2 seconds. update_object consumes larger gaps as repeated 0.2 s quanta.
+ public const float MaxQuantum = 0.2f;
public const float HugeQuantum = 2.0f; // discard stale dt
// ── struct fields ──────────────────────────────────────────────────────
@@ -177,6 +179,27 @@ public sealed class PhysicsBody
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
+ ///
+ /// Commits only the frame while retaining the current cell identity. This
+ /// is retail CPhysicsObj::set_frame at 0x00514090, used by
+ /// UpdateObjectInternal when a transition fails: the integrated
+ /// candidate frame is kept, but Position.objcell_id is not changed
+ /// and no outdoor canonicalization is performed.
+ ///
+ public void SetFrameInCurrentCell(Vector3 worldPosition, Quaternion orientation)
+ {
+ Vector3 delta = worldPosition - _position;
+ _position = worldPosition;
+ Orientation = orientation;
+
+ if (CellPosition.ObjCellId != 0)
+ {
+ CellPosition = new Position(
+ CellPosition.ObjCellId,
+ new CellFrame(CellPosition.Frame.Origin + delta, orientation));
+ }
+ }
+
// Mirror a world-position translation into the cell-relative frame. Velocity is
// frame-invariant under translation, so the same delta applies to the local origin.
// AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m
@@ -552,32 +575,35 @@ public sealed class PhysicsBody
// velocity += acceleration * dt (done unconditionally in decompile)
Velocity += Acceleration * dt;
- // Angular integration: apply omega rotation.
- // omega * dt gives the angle-axis delta rotation.
- float omegaLen = Omega.Length();
- if (omegaLen > 1e-6f)
+ // Angular integration: Frame::grotate receives the WORLD-space
+ // rotation vector omega*dt and premultiplies its quaternion onto the
+ // frame (0x005357A0; called at UpdatePhysicsInternal 0x00510935).
+ // Its F_EPSILON gate is applied to the rotation vector, not omega.
+ Vector3 rotation = Omega * dt;
+ float rotationLenSq = rotation.LengthSquared();
+ if (rotationLenSq >= PhysicsGlobals.EpsilonSq)
{
- float angle = omegaLen * dt;
- Quaternion deltaRot = Quaternion.CreateFromAxisAngle(Omega / omegaLen, angle);
- Orientation = Quaternion.Normalize(Quaternion.Multiply(Orientation, deltaRot));
+ float angle = MathF.Sqrt(rotationLenSq);
+ Quaternion deltaRot = Quaternion.CreateFromAxisAngle(rotation / angle, angle);
+ Orientation = Quaternion.Normalize(Quaternion.Multiply(deltaRot, Orientation));
}
}
- // ── FUN_00515020 ───────────────────────────────────────────────────────
+ // ── CPhysicsObj::update_object 0x00515D10 ───────────────────────────────
///
/// Per-frame top-level driver. Computes dt from the wall clock versus
- /// LastUpdateTime, clamping to [MinQuantum, HugeQuantum], then calls
- /// calc_acceleration and UpdatePhysicsInternal.
+ /// LastUpdateTime, discards invalid gaps, and advances the consumed
+ /// PhysicsTimer time through fixed maximum quanta plus one remainder.
///
- /// Decompiled logic (FUN_00515020):
+ /// Decompiled logic (CPhysicsObj::update_object 0x00515D10):
/// if parent-attached (offset +0x40 != 0) → return
/// dVar1 = currentTime - LastUpdateTime
- /// if dVar1 < MinQuantum → return (too short — skip)
+ /// if dVar1 <= EPSILON → update timestamp and return
/// if dVar1 > HugeQuantum → update timestamp and return (stale — discard)
/// while dVar1 > MaxQuantum: simulate MaxQuantum step, subtract
/// if dVar1 > MinQuantum: simulate remainder
- /// LastUpdateTime = currentTime
+ /// LastUpdateTime = consumed PhysicsTimer time
///
/// The caller passes currentTime; the object does not read a global clock
/// directly in this port so tests can drive the clock explicitly.
@@ -586,9 +612,13 @@ public sealed class PhysicsBody
{
double deltaTime = currentTime - LastUpdateTime;
- // dt too small — nothing to simulate yet
- if (deltaTime < MinQuantum)
+ // Retail drops only a true micro-fragment. A larger remainder at or
+ // below MinQuantum is retained by leaving LastUpdateTime unchanged.
+ if (deltaTime <= PhysicsGlobals.EPSILON)
+ {
+ LastUpdateTime = currentTime;
return;
+ }
// Stale / first frame — just consume the time without simulating
if (deltaTime > HugeQuantum)
@@ -597,9 +627,12 @@ public sealed class PhysicsBody
return;
}
- // Sub-step: break large dt into MaxQuantum chunks
+ double physicsTime = LastUpdateTime;
+
+ // Sub-step: break large dt into MaxQuantum chunks.
while (deltaTime > MaxQuantum)
{
+ physicsTime += MaxQuantum;
calc_acceleration();
UpdatePhysicsInternal(MaxQuantum);
deltaTime -= MaxQuantum;
@@ -608,10 +641,11 @@ public sealed class PhysicsBody
// Simulate the remainder
if (deltaTime > MinQuantum)
{
+ physicsTime += deltaTime;
calc_acceleration();
UpdatePhysicsInternal((float)deltaTime);
}
- LastUpdateTime = currentTime;
+ LastUpdateTime = physicsTime;
}
}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 49a226e9..7efef4e1 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -937,7 +937,11 @@ public sealed class PhysicsEngine
bool isOnGround,
PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None,
- uint movingEntityId = 0)
+ uint movingEntityId = 0,
+ Vector3? localSphereOrigin = null,
+ Quaternion? beginOrientation = null,
+ Quaternion? endOrientation = null,
+ uint designatedTargetId = 0)
{
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
@@ -963,6 +967,8 @@ public sealed class PhysicsEngine
// this->object = arg2). The skip itself is at
// CObjCell::find_obj_collisions line 308931.
transition.ObjectInfo.SelfEntityId = movingEntityId;
+ transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
+ transition.ObjectInfo.TargetId = designatedTargetId;
// Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local
@@ -971,6 +977,11 @@ public sealed class PhysicsEngine
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
+ // CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
+ // PathClipped only. PerfectClip is deliberately not inferred.
+ if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
+ transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
+
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
// in ValidateTransition runs for gravity movers (the player) and not floating props.
@@ -1020,7 +1031,15 @@ public sealed class PhysicsEngine
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
}
- transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
+ 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
@@ -1071,65 +1090,69 @@ public sealed class PhysicsEngine
// when current is invalid (e.g., airborne this frame), matching retail.
if (body is not null)
{
- if (ci.ContactPlaneValid)
- {
- body.ContactPlaneValid = true;
- body.ContactPlane = ci.ContactPlane;
- body.ContactPlaneCellId = ci.ContactPlaneCellId;
- body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
- }
- else if (ci.LastKnownContactPlaneValid)
- {
- body.ContactPlaneValid = true;
- body.ContactPlane = ci.LastKnownContactPlane;
- body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
- body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
- }
- else
- {
- body.ContactPlaneValid = false;
- }
-
- // Publish frames_stationary_fall + carry it to the next frame via the Stationary*
- // transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
- // acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
- // register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
- // handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
- body.FramesStationaryFall = ci.FramesStationaryFall;
- body.TransientState &= ~(TransientStateFlags.StationaryFall
- | TransientStateFlags.StationaryStop
- | TransientStateFlags.StationaryStuck);
- body.TransientState |= ci.FramesStationaryFall switch
- {
- 1 => TransientStateFlags.StationaryFall,
- 2 => TransientStateFlags.StationaryStop,
- 3 => TransientStateFlags.StationaryStuck,
- _ => TransientStateFlags.None,
- };
-
- if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
- {
- body.WalkablePolygonValid = true;
- body.WalkablePlane = sp.LastWalkablePlane;
- body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
- body.WalkableUp = sp.LastWalkableUp;
- }
- else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
- {
- body.WalkablePolygonValid = false;
- body.WalkableVertices = null;
- }
-
- // Retail persists sliding state to the body ONLY on transition
- // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
- // 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
- // final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
- // is unreachable when find_valid_position fails (the transition is
- // discarded whole; the body keeps its prior state). #137 mechanism
- // 2: an unconditional writeback here could persist a normal retail
- // would discard.
+ // CPhysicsObj::transition 0x00512DC0 discards its CTransition when
+ // find_valid_position fails. Only SetPositionInternal 0x00515330
+ // publishes contact/fsf/walkable/sliding state, and that function
+ // is unreachable on the UpdateObjectInternal failure branch.
if (ok)
{
+ if (ci.ContactPlaneValid)
+ {
+ body.ContactPlaneValid = true;
+ body.ContactPlane = ci.ContactPlane;
+ body.ContactPlaneCellId = ci.ContactPlaneCellId;
+ body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
+ }
+ else if (ci.LastKnownContactPlaneValid)
+ {
+ body.ContactPlaneValid = true;
+ body.ContactPlane = ci.LastKnownContactPlane;
+ body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
+ body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
+ }
+ else
+ {
+ body.ContactPlaneValid = false;
+ }
+
+ // Publish frames_stationary_fall + carry it to the next frame via the Stationary*
+ // transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
+ // acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
+ // register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
+ // handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
+ body.FramesStationaryFall = ci.FramesStationaryFall;
+ body.TransientState &= ~(TransientStateFlags.StationaryFall
+ | TransientStateFlags.StationaryStop
+ | TransientStateFlags.StationaryStuck);
+ body.TransientState |= ci.FramesStationaryFall switch
+ {
+ 1 => TransientStateFlags.StationaryFall,
+ 2 => TransientStateFlags.StationaryStop,
+ 3 => TransientStateFlags.StationaryStuck,
+ _ => TransientStateFlags.None,
+ };
+
+ if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
+ {
+ body.WalkablePolygonValid = true;
+ body.WalkablePlane = sp.LastWalkablePlane;
+ body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
+ body.WalkableUp = sp.LastWalkableUp;
+ }
+ else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
+ {
+ body.WalkablePolygonValid = false;
+ body.WalkableVertices = null;
+ }
+
+ // Retail persists sliding state to the body ONLY on transition
+ // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
+ // 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
+ // final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
+ // is unreachable when find_valid_position fails (the transition is
+ // discarded whole; the body keeps its prior state). #137 mechanism
+ // 2: an unconditional writeback here could persist a normal retail
+ // would discard.
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
@@ -1254,7 +1277,11 @@ public sealed class PhysicsEngine
ResolveResult resolveResult;
if (ok)
{
- bool onGround = ci.ContactPlaneValid
+ bool inContact = ci.ContactPlaneValid;
+ bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
+ inContact,
+ ci.ContactPlane.Normal);
+ bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
resolveResult = new ResolveResult(
@@ -1268,7 +1295,10 @@ public sealed class PhysicsEngine
sp.CurCellId,
onGround,
collisionNormalValid,
- collisionNormal);
+ collisionNormal,
+ Orientation: sp.CurOrientation,
+ InContact: inContact,
+ OnWalkable: onWalkable);
}
else
{
@@ -1291,7 +1321,8 @@ public sealed class PhysicsEngine
partialOnGround,
collisionNormalValid,
collisionNormal,
- Ok: false); // Render Residual A — the sweep failed (find_valid_position == 0)
+ Ok: false,
+ Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
}
// A6.P3 #98 capture: emit one JSON Lines record per player call,
diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
index a1daa04d..4d9ef950 100644
--- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
+++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
@@ -13,6 +13,38 @@ namespace AcDream.Core.Physics;
///
public static class PhysicsObjUpdate
{
+ ///
+ /// Retail SetPositionInternal walkability comparison at
+ /// 0x00515465-0x0051548E. Equality with FloorZ is walkable.
+ ///
+ public static bool IsWalkableContact(bool inContact, Vector3 contactNormal)
+ => inContact && contactNormal.Z >= PhysicsGlobals.FloorZ;
+
+ ///
+ /// Applies the two independent transient facts written by retail
+ /// CPhysicsObj::SetPositionInternal at 0x00515430-0x0051549F.
+ /// Contact comes from contact-plane validity; OnWalkable additionally
+ /// requires a walkable plane normal. A steep contact therefore remains in
+ /// contact without becoming grounded.
+ ///
+ public static void ApplySetPositionContact(
+ PhysicsBody body,
+ bool inContact,
+ bool onWalkable)
+ {
+ if (inContact)
+ body.TransientState |= TransientStateFlags.Contact;
+ else
+ body.TransientState &= ~TransientStateFlags.Contact;
+
+ if (inContact && onWalkable)
+ body.TransientState |= TransientStateFlags.OnWalkable;
+ else
+ body.TransientState &= ~TransientStateFlags.OnWalkable;
+
+ body.calc_acceleration();
+ }
+
///
/// retail handle_all_collisions (0x00514780). Reflects or zeros the body's
/// (retail m_velocityVector) based on
diff --git a/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs b/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs
new file mode 100644
index 00000000..54c84ddb
--- /dev/null
+++ b/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs
@@ -0,0 +1,261 @@
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// One Setup-local collision sphere used by a retail projectile.
+/// Installed-DAT audit 2026-07-13 pins the required arrow, bolt, and spell
+/// projectile set to exactly one ordinary sphere, while preserving Force
+/// Bolt's non-centered origin. Both origin and radius scale with the object's
+/// live scale, matching CTransition::init_sphere from
+/// CPhysicsObj::transition at 0x00512DC0.
+///
+public readonly record struct ProjectileCollisionSphere(
+ Vector3 SetupLocalOrigin,
+ float SetupRadius,
+ float ObjectScale = 1f)
+{
+ public Vector3 LocalOrigin => SetupLocalOrigin * ObjectScale;
+ public float Radius => SetupRadius * ObjectScale;
+ public bool IsValid
+ {
+ get
+ {
+ Vector3 origin = LocalOrigin;
+ float radius = Radius;
+ return float.IsFinite(SetupRadius) && SetupRadius > 0f
+ && float.IsFinite(ObjectScale) && ObjectScale > 0f
+ && float.IsFinite(radius) && radius > PhysicsGlobals.EPSILON
+ && float.IsFinite(origin.X)
+ && float.IsFinite(origin.Y)
+ && float.IsFinite(origin.Z);
+ }
+ }
+}
+
+/// Outcome of one retail projectile clock advance.
+public readonly record struct ProjectileAdvanceResult(
+ uint CellId,
+ int QuantumCount,
+ bool Simulated,
+ bool CollisionNormalValid,
+ Vector3 CollisionNormal,
+ bool TransitionOk);
+
+///
+/// Core-only port of the retail live-projectile physics driver. It owns no
+/// renderer, network, or world-lifetime state: the App controller supplies a
+/// live , cell, Setup sphere, and identities, then
+/// commits the returned cell through the live-entity runtime.
+///
+/// Retail anchors:
+///
+/// - CPhysicsObj::update_object 0x00515D10 (clock gate and catch-up).
+/// - CPhysicsObj::UpdateObjectInternal 0x005156B0 (candidate, AlignPath, transition, cached velocity).
+/// - CPhysicsObj::UpdatePhysicsInternal 0x00510700 (linear/angular integration).
+/// - CPhysicsObj::get_object_info 0x00511CC0 (Missile adds PathClipped, never PerfectClip).
+///
+///
+public sealed class ProjectilePhysicsStepper
+{
+ private readonly PhysicsEngine _physics;
+
+ public ProjectilePhysicsStepper(PhysicsEngine physics)
+ => _physics = physics ?? throw new ArgumentNullException(nameof(physics));
+
+ ///
+ /// Advances a projectile to the supplied absolute game time. ACE remains
+ /// authoritative: this predicts only motion/collision presentation and
+ /// never invents impact effects, damage, deletion, or a target id.
+ ///
+ public ProjectileAdvanceResult Advance(
+ PhysicsBody body,
+ double currentTime,
+ uint cellId,
+ ProjectileCollisionSphere sphere,
+ uint movingEntityId,
+ uint designatedTargetId = 0,
+ bool isParented = false)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+
+ if (!double.IsFinite(currentTime))
+ throw new ArgumentOutOfRangeException(
+ nameof(currentTime), currentTime, "The game clock must be finite.");
+ if (!double.IsFinite(body.LastUpdateTime))
+ throw new InvalidOperationException(
+ "The projectile's prior update timestamp must be finite.");
+
+ if (!sphere.IsValid)
+ return new ProjectileAdvanceResult(cellId, 0, false, false, default, false);
+
+ // update_object 0x00515D10 excludes parented, cell-less, and Frozen
+ // objects and clears Active. Static projectiles are likewise not live
+ // update candidates; the App selector should never submit one.
+ if (isParented || !body.InWorld
+ || body.State.HasFlag(PhysicsStateFlags.Frozen)
+ || body.State.HasFlag(PhysicsStateFlags.Static)
+ || cellId == 0)
+ {
+ body.TransientState &= ~TransientStateFlags.Active;
+ return new ProjectileAdvanceResult(cellId, 0, false, false, default, true);
+ }
+
+ if (!body.IsActive)
+ return new ProjectileAdvanceResult(cellId, 0, false, false, default, true);
+
+ double elapsed = currentTime - body.LastUpdateTime;
+ if (elapsed <= PhysicsGlobals.EPSILON || elapsed > PhysicsBody.HugeQuantum)
+ {
+ body.LastUpdateTime = currentTime;
+ return new ProjectileAdvanceResult(cellId, 0, false, false, default, true);
+ }
+
+ int quantumCount = 0;
+ bool collisionNormalValid = false;
+ Vector3 collisionNormal = default;
+ bool transitionOk = true;
+ double physicsTime = body.LastUpdateTime;
+
+ while (elapsed > PhysicsBody.MaxQuantum)
+ {
+ physicsTime += PhysicsBody.MaxQuantum;
+ StepQuantum(
+ body, PhysicsBody.MaxQuantum, ref cellId, sphere,
+ movingEntityId, designatedTargetId,
+ ref collisionNormalValid, ref collisionNormal, ref transitionOk);
+ quantumCount++;
+ elapsed -= PhysicsBody.MaxQuantum;
+ }
+
+ if (elapsed > PhysicsBody.MinQuantum)
+ {
+ float quantum = (float)elapsed;
+ physicsTime += elapsed;
+ StepQuantum(
+ body, quantum, ref cellId, sphere,
+ movingEntityId, designatedTargetId,
+ ref collisionNormalValid, ref collisionNormal, ref transitionOk);
+ quantumCount++;
+ }
+
+ // A remainder at or below 1/30 second stays accumulated: retail writes
+ // update_time from PhysicsTimer, not unconditionally from Timer.
+ body.LastUpdateTime = physicsTime;
+
+ return new ProjectileAdvanceResult(
+ cellId,
+ quantumCount,
+ quantumCount != 0,
+ collisionNormalValid,
+ collisionNormal,
+ transitionOk);
+ }
+
+ private void StepQuantum(
+ PhysicsBody body,
+ float dt,
+ ref uint cellId,
+ ProjectileCollisionSphere sphere,
+ uint movingEntityId,
+ uint designatedTargetId,
+ ref bool collisionNormalValid,
+ ref Vector3 collisionNormal,
+ ref bool transitionOk)
+ {
+ Vector3 beginPosition = body.Position;
+ Quaternion beginOrientation = body.Orientation;
+ bool previousContact = body.InContact;
+ bool previousOnWalkable = body.OnWalkable;
+
+ // Final PhysicsState drives acceleration on every quantum. Network
+ // acceleration is retained by the protocol parser but not installed.
+ body.calc_acceleration();
+ body.UpdatePhysicsInternal(dt);
+
+ Vector3 candidatePosition = body.Position;
+ Quaternion candidateOrientation = body.Orientation;
+ Vector3 displacement = candidatePosition - beginPosition;
+ bool candidateMoved = displacement != Vector3.Zero;
+
+ if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
+ {
+ candidateOrientation = RetailFrameMath.SetVectorHeading(
+ candidateOrientation,
+ displacement);
+ }
+
+ if (!candidateMoved)
+ {
+ body.CachedVelocity = Vector3.Zero;
+ body.Orientation = candidateOrientation;
+ return;
+ }
+
+ // The integrator produces a candidate without committing the root.
+ // Restore the authoritative start frame before transition setup so
+ // carried cell-relative position and self-shadow identity remain exact.
+ body.Position = beginPosition;
+ body.Orientation = beginOrientation;
+
+ var resolved = _physics.ResolveWithTransition(
+ beginPosition,
+ candidatePosition,
+ cellId,
+ sphereRadius: sphere.Radius,
+ sphereHeight: 0f,
+ stepUpHeight: PhysicsGlobals.DefaultStepHeight,
+ stepDownHeight: 0f,
+ isOnGround: previousOnWalkable,
+ body: body,
+ moverFlags: ObjectInfoState.PathClipped,
+ movingEntityId: movingEntityId,
+ localSphereOrigin: sphere.LocalOrigin,
+ beginOrientation: beginOrientation,
+ endOrientation: candidateOrientation,
+ designatedTargetId: designatedTargetId);
+
+ if (!resolved.Ok)
+ {
+ // UpdateObjectInternal 0x005156B0: when transition() returns null,
+ // retail calls set_frame(candidate), zeros cached_velocity, and
+ // deliberately skips SetPositionInternal/handle_all_collisions.
+ // set_frame retains Position.objcell_id even when the candidate
+ // frame lies beyond the current outdoor cell's canonical range.
+ body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
+ body.CachedVelocity = Vector3.Zero;
+ transitionOk = false;
+ return;
+ }
+
+ body.CachedVelocity = (resolved.Position - beginPosition) / dt;
+ body.Position = resolved.Position;
+ body.Orientation = resolved.Orientation == default
+ ? candidateOrientation
+ : resolved.Orientation;
+ if (resolved.CellId != 0)
+ cellId = resolved.CellId;
+
+ // SetPositionInternal 0x00515330 commits Contact and OnWalkable as
+ // distinct facts before handle_all_collisions. A steep surface can be
+ // contact without becoming walkable ground.
+ PhysicsObjUpdate.ApplySetPositionContact(
+ body,
+ resolved.InContact,
+ resolved.OnWalkable);
+
+ PhysicsObjUpdate.HandleAllCollisions(
+ body,
+ resolved.CollisionNormalValid,
+ resolved.CollisionNormal,
+ previousContact,
+ previousOnWalkable,
+ nowOnWalkable: body.OnWalkable);
+
+ if (resolved.CollisionNormalValid)
+ {
+ collisionNormalValid = true;
+ collisionNormal = resolved.CollisionNormal;
+ }
+ }
+}
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index c9c6f2f1..da3f2e83 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -34,4 +34,21 @@ public readonly record struct ResolveResult(
/// start cell or was immediately stuck. The camera SweepEye reads this
/// to trigger SmartBox::update_viewer's fallbacks. Default true
/// so existing callers are unaffected.
- bool Ok = true);
+ bool Ok = true,
+ ///
+ /// Final transition orientation. Projectile callers consume the
+ /// interpolated frame committed before a PathClipped collision; legacy
+ /// point/capsule callers leave this at identity and ignore it.
+ ///
+ Quaternion Orientation = default,
+ ///
+ /// Exact retail SetPositionInternal contact-plane-valid result. This is
+ /// distinct from : a steep surface may be contact
+ /// without being ground.
+ ///
+ bool InContact = false,
+ ///
+ /// Exact retail walkability result after testing the contact-plane normal
+ /// against .
+ ///
+ bool OnWalkable = false);
diff --git a/src/AcDream.Core/Physics/RetailFrameMath.cs b/src/AcDream.Core/Physics/RetailFrameMath.cs
new file mode 100644
index 00000000..cc6952d1
--- /dev/null
+++ b/src/AcDream.Core/Physics/RetailFrameMath.cs
@@ -0,0 +1,63 @@
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Retail frame-orientation helpers used by physics presentation.
+///
+public static class RetailFrameMath
+{
+ ///
+ /// Points the frame's local +Y axis along a world-space direction with
+ /// zero roll. This is the observable contract of retail
+ /// Frame::set_vector_heading at 0x00535DB0: normalize the
+ /// full three-dimensional vector, derive compass yaw plus elevation, and
+ /// replace the frame rotation. A zero-length vector leaves the prior
+ /// orientation unchanged.
+ ///
+ public static Quaternion SetVectorHeading(
+ Quaternion currentOrientation,
+ Vector3 direction)
+ {
+ float lengthSquared = direction.LengthSquared();
+ if (lengthSquared < PhysicsGlobals.EpsilonSq || !float.IsFinite(lengthSquared))
+ return currentOrientation;
+
+ Vector3 forward = direction / MathF.Sqrt(lengthSquared);
+
+ // Retail's zero-roll Euler construction is equivalent to choosing a
+ // horizontal right axis from the compass heading, then deriving the
+ // remaining up axis. At a perfectly vertical heading atan2(0, 0)
+ // resolves to zero, so retain +X as the deterministic right axis.
+ Vector3 right;
+ if (forward.X == 0f && forward.Y == 0f)
+ {
+ right = Vector3.UnitX;
+ }
+ else
+ {
+ // Normalize the horizontal pair without squaring the original
+ // components. That preserves every non-zero compass component,
+ // including a nearly vertical vector below PhysicsGlobals.EPSILON,
+ // just as retail's atan2-based construction does.
+ float scale = MathF.Max(MathF.Abs(forward.X), MathF.Abs(forward.Y));
+ float x = forward.X / scale;
+ float y = forward.Y / scale;
+ float horizontalLength = MathF.Sqrt((x * x) + (y * y));
+ right = new Vector3(y / horizontalLength, -x / horizontalLength, 0f);
+ }
+
+ Vector3 up = Vector3.Normalize(Vector3.Cross(right, forward));
+
+ // System.Numerics uses row-vector transforms. Each row below is the
+ // world direction of one local basis axis: +X right, +Y heading,
+ // +Z up.
+ var rotation = new Matrix4x4(
+ right.X, right.Y, right.Z, 0f,
+ forward.X, forward.Y, forward.Z, 0f,
+ up.X, up.Y, up.Z, 0f,
+ 0f, 0f, 0f, 1f);
+
+ return Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(rotation));
+ }
+}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 3d6b7e1a..45f4e278 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -56,6 +56,15 @@ public sealed class ObjectInfo
public bool StepDown = true;
public float Scale = 1.0f;
+ /// The moving object's retail PhysicsState.
+ public PhysicsStateFlags MoverPhysicsState;
+
+ ///
+ /// Authoritative designated projectile target. Zero means unknown/no
+ /// target; it is never inferred from the player's selected UI target.
+ ///
+ public uint TargetId;
+
///
/// EntityId of the moving entity, used by FindObjCollisions to
/// skip the moving object's own ShadowEntry. Mirrors retail
@@ -88,6 +97,32 @@ public sealed class ObjectInfo
public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped);
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate);
+ ///
+ /// Verbatim decision tree from retail OBJECTINFO::missile_ignore
+ /// at 0x0050CEB0. A true result skips all target shapes.
+ ///
+ public bool MissileIgnore(
+ uint targetEntityId,
+ uint targetPhysicsState,
+ EntityCollisionFlags targetFlags)
+ {
+ var targetState = (PhysicsStateFlags)targetPhysicsState;
+ if (targetState.HasFlag(PhysicsStateFlags.Missile))
+ return true;
+ if (!MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
+ return false;
+ if (targetEntityId == TargetId)
+ return false;
+
+ bool hasWeenie = targetFlags.HasFlag(EntityCollisionFlags.HasWeenie);
+ if (targetState.HasFlag(PhysicsStateFlags.Ethereal) && hasWeenie)
+ return true;
+
+ return TargetId != 0
+ && hasWeenie
+ && targetFlags.HasFlag(EntityCollisionFlags.IsCreature);
+ }
+
///
/// Return the Z threshold for a walkable surface appropriate to the
/// current movement context.
@@ -446,7 +481,8 @@ public sealed class SpherePath
// Update global spheres to match new check position
for (int i = 0; i < NumSphere; i++)
{
- GlobalSphere[i].Origin = LocalSphere[i].Origin + pos;
+ GlobalSphere[i].Origin =
+ Vector3.Transform(LocalSphere[i].Origin, CheckOrientation) + pos;
GlobalSphere[i].Radius = LocalSphere[i].Radius;
}
}
@@ -580,15 +616,27 @@ public sealed class SpherePath
///
/// Initialize the path for a simple point-to-point movement.
///
- public void InitPath(Vector3 begin, Vector3 end, uint cellId,
- float sphereRadius, float sphereHeight = 0f)
+ public void InitPath(
+ Vector3 begin,
+ Vector3 end,
+ uint cellId,
+ float sphereRadius,
+ float sphereHeight = 0f,
+ Vector3? localSphereOrigin = null,
+ Quaternion? beginOrientation = null,
+ Quaternion? endOrientation = null)
{
BeginPos = begin;
EndPos = end;
CurPos = begin;
CurCellId = cellId;
- LocalSphere[0].Origin = new Vector3(0, 0, sphereRadius);
+ BeginOrientation = beginOrientation ?? Quaternion.Identity;
+ EndOrientation = endOrientation ?? BeginOrientation;
+ CurOrientation = BeginOrientation;
+ CheckOrientation = BeginOrientation;
+
+ LocalSphere[0].Origin = localSphereOrigin ?? new Vector3(0, 0, sphereRadius);
LocalSphere[0].Radius = sphereRadius;
if (sphereHeight > 0)
@@ -607,7 +655,8 @@ public sealed class SpherePath
// Also init CurCenter
for (int i = 0; i < NumSphere; i++)
{
- GlobalCurrCenter[i].Origin = LocalSphere[i].Origin + begin;
+ GlobalCurrCenter[i].Origin =
+ Vector3.Transform(LocalSphere[i].Origin, CurOrientation) + begin;
GlobalCurrCenter[i].Radius = LocalSphere[i].Radius;
}
}
@@ -627,7 +676,6 @@ public static class PhysicsGlobals
public const float Gravity = -9.8f;
public const float MaxVelocity = 50.0f;
public const float DummySphereRadius = 0.1f;
- public const int MaxTransitionSteps = 30; // retail uses 30, ACE uses 1000
}
///
@@ -653,54 +701,9 @@ public sealed class Transition
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";
// -----------------------------------------------------------------------
- // A6.P7 (2026-05-25) + W1 (2026-06-24) — retail-binary dispatch rule
+ // A6.P7 (2026-05-25) — retail-binary shape dispatch rule
// -----------------------------------------------------------------------
- ///
- /// W1: Named predicate for the PvP-exemption term in retail's
- /// CPhysicsObj::FindObjCollisions dispatch (pc:276861,
- /// acclient_2013_pseudo_c.txt:276808–276841).
- ///
- ///
- /// In retail ebp_1 is non-null when the mover is a player AND
- /// the target is not impenetrable AND PK/PKLite flags indicate a
- /// PvP-exempt pairing — i.e. the mover should pass through rather than
- /// collide. While this is non-null the dispatch takes the cyl+sphere
- /// path regardless of HAS_PHYSICS_BSP_PS, effectively making
- /// PvP-exempt players transparent to BSP-only objects.
- ///
- ///
- ///
- /// M1.5 scope has no PK. Returns false always.
- /// Wire through mover + target state when PK ships (M2+ / phase TBD).
- /// Retail oracle: pc:276808–276841.
- ///
- ///
- internal static bool PvpExempt() => false; // wire when PK ships (M2+) — pc:276808–276841
-
- ///
- /// W1: Named predicate for the OBJECTINFO::missile_ignore term
- /// in retail's CPhysicsObj::FindObjCollisions dispatch
- /// (pc:276861, acclient_2013_pseudo_c.txt:274385 + :276858–276861).
- ///
- ///
- /// In retail eax_12 = OBJECTINFO::missile_ignore(ebx, this)
- /// returns non-zero when either the TARGET has MISSILE_PS (0x40)
- /// set, or the mover has MISSILE_PS set and the target is a
- /// creature that is not the mover's designated target. When non-zero the
- /// dispatch takes the cyl+sphere path regardless of
- /// HAS_PHYSICS_BSP_PS — missiles pass through BSP-only objects.
- ///
- ///
- ///
- /// M1.5 scope has no missiles. Returns false always.
- /// Wire through mover OBJECTINFO + target state when missiles ship (F.3).
- /// Retail oracle: OBJECTINFO::missile_ignore pc:274385;
- /// dispatch pc:276858–276861.
- ///
- ///
- internal static bool MissileIgnore() => false; // wire when missiles ship (F.3) — pc:274385
-
///
/// A6.P7 retail-binary dispatch predicate. Returns true when an
/// entity's collision queries should go to its BSP exclusively,
@@ -716,24 +719,19 @@ public sealed class Transition
/// else
/// // BSP-only via CPartArray::FindObjCollisions
///
- /// where ebp_1 is the PvP-target-player flag (lines 276808–
- /// 276841, see ) and eax_12 is the
- /// OBJECTINFO::missile_ignore result (line 274385, see
- /// ). The flag is named
+ /// where ebp_1 is the PvP-target-player exemption and
+ /// eax_12 is OBJECTINFO::missile_ignore. Both are handled
+ /// as whole-object early exits before this shape-dispatch predicate. The
+ /// flag is named
/// HAS_PHYSICS_BSP_PS = 0x10000 in acclient.h:2833 and
/// PhysicsState.HasPhysicsBSP in ACE
/// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24).
///
///
///
- /// BSP-only iff:
- /// HAS_PHYSICS_BSP_PS is set on the entity
- /// AND the mover is not PvP-exempt for this target ( = false)
- /// AND the entity is not missile-ignore exempt ( = false).
- /// In M1.5 and both
- /// return false, so the predicate reduces to
- /// (state & HAS_PHYSICS_BSP_PS) != 0 — no behavior change
- /// from before W1.
+ /// Once an object reaches shape dispatch, it is BSP-only exactly when
+ /// HAS_PHYSICS_BSP_PS is set. The exemption gates have already
+ /// returned OK_TS for the complete target object.
///
///
///
@@ -744,14 +742,9 @@ public sealed class Transition
/// The collision target entity's raw
/// PhysicsState value (as stored on
/// ShadowEntry.State).
- /// True when retail would dispatch BSP-only — i.e. when
- /// the entity has HAS_PHYSICS_BSP_PS set AND neither the PvP
- /// exemption nor the missile-ignore exemption applies; cyl/sphere
- /// shapes must be skipped at the per-entry dispatch site.
+ /// True when retail dispatches exclusively to the target BSP.
public static bool BspOnlyDispatch(uint entityState)
- => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0
- && !PvpExempt()
- && !MissileIgnore();
+ => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0;
// -----------------------------------------------------------------------
// Public entry point
@@ -840,15 +833,10 @@ public sealed class Transition
}
}
- // Retail safety cap (30 steps). Viewer/sight objects bypass it, matching
- // retail: CTransition::find_transitional_position (acclient_2013_pseudo_c.txt
- // :273613) has no cap, and calc_num_steps (:272149) has a dedicated viewer
- // branch `if ((state & 4) != 0)` at :272181. The A8.F camera spring-arm
- // (IsViewer) sweeps the eye far past 30 steps; the zoom clamp (≤40 m / 0.3 m
- // radius ≈ 134 steps) bounds it. Non-viewers keep the safety net (players
- // never exceed it: 30 × 0.48 m ≈ 14 m/tick).
- if (numSteps > PhysicsGlobals.MaxTransitionSteps && !ObjectInfo.IsViewer)
- return false;
+ // No arbitrary step cap: retail find_transitional_position 0x0050BDF0
+ // iterates every step returned by calc_num_steps 0x0050A0B0. The old
+ // acdream-only 30-step bail truncated valid 0.2-second missile sweeps
+ // (50 world units/s with a 0.1-unit sphere requires 100 steps).
// Apply free rotation if requested.
if (ObjectInfo.FreeRotate)
@@ -1090,7 +1078,8 @@ public sealed class Transition
sp.CurOrientation = sp.CheckOrientation;
for (int i = 0; i < sp.NumSphere; i++)
{
- sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
+ sp.GlobalCurrCenter[i].Origin =
+ Vector3.Transform(sp.LocalSphere[i].Origin, sp.CurOrientation) + sp.CurPos;
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}
}
@@ -2586,10 +2575,29 @@ public sealed class Transition
return otherCellsState;
// Retarget the carried cell to the ordered-pick containing cell (retail check_cell =
- // var_4c, then adjust_check_pos). SetCheckPos mirrors that id swap + global-sphere
- // refresh without moving the sphere.
+ // var_4c, then adjust_check_pos). Our positions are world-space, so a carried
+ // outdoor frame must also rebase by 192 metres when the containing landblock
+ // changes. Retail gets this automatically because Position.Frame is relative to
+ // its current ObjCell. Keeping the old origin would make every remaining sweep
+ // substep advance the cell id by another entire landblock.
if (containingCellId != sp.CheckCellId)
+ {
+ if (sp.CarriedBlockOrigin is { } carriedOrigin
+ && (sp.CheckCellId & 0xFFFFu) is >= 1u and <= 0x40u
+ && (containingCellId & 0xFFFFu) is >= 1u and <= 0x40u)
+ {
+ int oldBlockX = (int)((sp.CheckCellId >> 24) & 0xFFu);
+ int oldBlockY = (int)((sp.CheckCellId >> 16) & 0xFFu);
+ int newBlockX = (int)((containingCellId >> 24) & 0xFFu);
+ int newBlockY = (int)((containingCellId >> 16) & 0xFFu);
+ sp.CarriedBlockOrigin = carriedOrigin + new Vector3(
+ (newBlockX - oldBlockX) * LandDefs.BlockLength,
+ (newBlockY - oldBlockY) * LandDefs.BlockLength,
+ 0f);
+ }
+
sp.SetCheckPos(sp.CheckPos, containingCellId);
+ }
return TransitionState.OK;
}
@@ -2763,6 +2771,12 @@ public sealed class Transition
if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId)
continue;
+ // OBJECTINFO::missile_ignore 0x0050CEB0 is an all-shapes
+ // exemption. Retail computes it before the BSP-vs-cyl/sphere
+ // dispatch; a true result reaches neither branch.
+ if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags))
+ continue;
+
// Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position;
float distToCurr;
@@ -2954,10 +2968,9 @@ public sealed class Transition
// door-a6p6-v2.utf8.log. See investigation at
// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md.
//
- // M1.5 scope: PvP exemption (ebp_1) and missile_ignore
- // (eax_12) are treated as false. Wire them through when
- // PK / missiles ship — matches retail's full predicate
- // at line 276861.
+ // The PvP-target exemption and OBJECTINFO::missile_ignore are
+ // evaluated as whole-object early exits above this per-shape
+ // dispatch, matching retail's full predicate at line 276861.
if (BspOnlyDispatch(obj.State))
{
if (PhysicsDiagnostics.ProbeBuildingEnabled)
@@ -4725,7 +4738,8 @@ public sealed class Transition
// Cache the current-center spheres at the new position.
for (int i = 0; i < sp.NumSphere; i++)
{
- sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
+ sp.GlobalCurrCenter[i].Origin =
+ Vector3.Transform(sp.LocalSphere[i].Origin, sp.CurOrientation) + sp.CurPos;
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}
diff --git a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs
index 94440fb3..c2646723 100644
--- a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs
@@ -15,7 +15,7 @@ namespace AcDream.Core.Tests.Physics;
/// as HasPhysicsBSP = 0x00010000.
///
///
-/// For non-PvP, non-missile movers (M1.5 scope — walking-vs-static), an
+/// After the whole-object PvP and missile exemption gates, an
/// entity with HAS_PHYSICS_BSP_PS in its state tests its BSP exclusively
/// — the foot cyl is NEVER tested. The closed cottage door (state
/// 0x10008 = STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP)
@@ -59,8 +59,8 @@ public class A6P7DispatchRulesTests
/// else
/// → BSP-only path
///
- /// For M1.5 scope ebp_1 (PvP-target-player) and eax_12
- /// (missile_ignore) are treated as false. The predicate reduces to:
+ /// The PvP and missile terms are whole-object early exits in the port.
+ /// Once shape dispatch is reached, the predicate is therefore:
/// "BSP-only iff HAS_PHYSICS_BSP_PS is set".
///
[Theory]
@@ -76,44 +76,28 @@ public class A6P7DispatchRulesTests
Assert.Equal(expected, Transition.BspOnlyDispatch(entityState));
}
- // -----------------------------------------------------------------------
- // W1 (2026-06-24) — named PvP/missile terms are false in M1.5
- // -----------------------------------------------------------------------
-
///
- /// W1: must return false in M1.5.
- /// When PK ships (M2+) this stub will accept mover + target state;
- /// the test pins the current named-false value as a guard.
- /// Retail oracle: CPhysicsObj::FindObjCollisions pc:276808–276841.
- ///
- [Fact]
- public void W1_PvpExempt_ReturnsFalseInM15Scope()
- {
- // PvP hasn't shipped (M1.5); the stub always returns false.
- Assert.False(Transition.PvpExempt());
- }
-
- ///
- /// W1: must return false in M1.5.
- /// When missiles ship (F.3) this stub will accept mover OBJECTINFO +
- /// target state; the test pins the current named-false value as a guard.
+ /// A non-missile mover does not trigger the retail missile exemption.
/// Retail oracle: OBJECTINFO::missile_ignore pc:274385;
/// dispatch use pc:276858–276861.
///
[Fact]
- public void W1_MissileIgnore_ReturnsFalseInM15Scope()
+ public void NonMissileMover_DoesNotIgnoreOrdinaryTarget()
{
- // Missiles haven't shipped (M1.5); the stub always returns false.
- Assert.False(Transition.MissileIgnore());
+ var mover = new ObjectInfo();
+ Assert.False(mover.MissileIgnore(
+ targetEntityId: 1u,
+ targetPhysicsState: 0u,
+ targetFlags: EntityCollisionFlags.HasWeenie));
}
///
- /// Guard: with both W1 terms false, a BSP-flagged entity still
- /// dispatches BSP-only — A6.P7 door behavior is unchanged after W1.
+ /// Guard: a BSP-flagged entity that reaches shape dispatch still uses
+ /// only its BSP — A6.P7 door behavior remains unchanged.
/// This protects the A6.P7 cottage-door / dungeon-wall fix.
///
[Fact]
- public void W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly()
+ public void BspOnlyDispatch_DoorStateStillDispatchesBspOnly()
{
// Cottage door state from A6.P7 investigation: 0x10008 = STATIC | REPORT | HAS_BSP
const uint doorState = 0x00010008u;
diff --git a/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs b/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs
index 9fdb8187..23bd3c64 100644
--- a/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs
@@ -19,6 +19,43 @@ public class HandleAllCollisionsTests
FramesStationaryFall = fsf,
};
+ [Fact]
+ public void SetPositionContact_SteepSurfaceIsContactButNotWalkable()
+ {
+ var body = Airborne(new Vector3(0f, 0f, -1f));
+ Vector3 steepNormal = Vector3.Normalize(new Vector3(1f, 0f, 0.5f));
+ bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
+ inContact: true,
+ contactNormal: steepNormal);
+
+ PhysicsObjUpdate.ApplySetPositionContact(
+ body,
+ inContact: true,
+ onWalkable: onWalkable);
+
+ Assert.True(body.InContact);
+ Assert.False(body.OnWalkable);
+ Assert.Equal(PhysicsBody.Gravity, body.Acceleration.Z);
+ }
+
+ [Fact]
+ public void SetPositionContact_WalkableSurfaceSetsBothFacts()
+ {
+ var body = Airborne(new Vector3(0f, 0f, -1f));
+ bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
+ inContact: true,
+ contactNormal: new Vector3(0f, 0f, PhysicsGlobals.FloorZ));
+
+ PhysicsObjUpdate.ApplySetPositionContact(
+ body,
+ inContact: true,
+ onWalkable: onWalkable);
+
+ Assert.True(body.InContact);
+ Assert.True(body.OnWalkable);
+ Assert.Equal(Vector3.Zero, body.Acceleration);
+ }
+
[Fact]
public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent()
{
diff --git a/tests/AcDream.Core.Tests/Physics/MissileCollisionRulesTests.cs b/tests/AcDream.Core.Tests/Physics/MissileCollisionRulesTests.cs
new file mode 100644
index 00000000..c28b9203
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/MissileCollisionRulesTests.cs
@@ -0,0 +1,94 @@
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance table for retail OBJECTINFO::missile_ignore
+/// (0x0050CEB0). These tests deliberately separate "has a weenie"
+/// from "is a creature" because the retail branches do.
+///
+public sealed class MissileCollisionRulesTests
+{
+ private const uint Target = 0x1111u;
+ private const uint Other = 0x2222u;
+
+ private static ObjectInfo Missile(uint targetId = 0) => new()
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ TargetId = targetId,
+ };
+
+ [Fact]
+ public void OtherMissile_IsAlwaysIgnored_EvenWhenDesignated()
+ {
+ var mover = Missile(Target);
+ Assert.True(mover.MissileIgnore(
+ Target,
+ (uint)PhysicsStateFlags.Missile,
+ EntityCollisionFlags.HasWeenie));
+ }
+
+ [Fact]
+ public void NonMissileMover_DoesNotUseMissileExemptions()
+ {
+ var mover = new ObjectInfo();
+ Assert.False(mover.MissileIgnore(
+ Other,
+ (uint)PhysicsStateFlags.Ethereal,
+ EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
+ }
+
+ [Fact]
+ public void DesignatedTarget_IsNeverIgnoredByLaterBranches()
+ {
+ var mover = Missile(Target);
+ Assert.False(mover.MissileIgnore(
+ Target,
+ (uint)PhysicsStateFlags.Ethereal,
+ EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
+ }
+
+ [Theory]
+ [InlineData(true, true)]
+ [InlineData(false, false)]
+ public void EtherealTarget_RequiresWeenieMetadata(bool hasWeenie, bool ignored)
+ {
+ var flags = hasWeenie
+ ? EntityCollisionFlags.HasWeenie
+ : EntityCollisionFlags.None;
+
+ Assert.Equal(ignored, Missile().MissileIgnore(
+ Other,
+ (uint)PhysicsStateFlags.Ethereal,
+ flags));
+ }
+
+ [Fact]
+ public void KnownTarget_IgnoresOtherCreatures()
+ {
+ var mover = Missile(Target);
+ Assert.True(mover.MissileIgnore(
+ Other,
+ 0u,
+ EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
+ }
+
+ [Fact]
+ public void UnknownTarget_DoesNotIgnoreCreatures()
+ {
+ Assert.False(Missile().MissileIgnore(
+ Other,
+ 0u,
+ EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature));
+ }
+
+ [Fact]
+ public void KnownTarget_DoesNotIgnoreNonCreatureWeenie()
+ {
+ Assert.False(Missile(Target).MissileIgnore(
+ Other,
+ 0u,
+ EntityCollisionFlags.HasWeenie));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
index 1c936d34..b4e55c73 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
@@ -459,7 +459,7 @@ public sealed class PhysicsBodyTests
// ════════════════════════════════════════════════════════════════════
[Fact]
- public void update_object_dt_below_min_quantum_does_not_advance()
+ public void update_object_dt_below_min_quantum_accumulates_without_advancing()
{
var body = MakeAirborne();
body.Velocity = new Vector3(1f, 0f, 0f);
@@ -470,6 +470,7 @@ public sealed class PhysicsBodyTests
body.update_object(PhysicsBody.MinQuantum * 0.5);
Assert.Equal(Vector3.Zero, body.Position);
+ Assert.Equal(0d, body.LastUpdateTime);
}
[Fact]
@@ -516,6 +517,17 @@ public sealed class PhysicsBodyTests
Assert.Equal(t, body.LastUpdateTime, precision: 10);
}
+ [Fact]
+ public void update_object_micro_fragment_is_consumed()
+ {
+ var body = MakeAirborne();
+
+ body.update_object(PhysicsGlobals.EPSILON * 0.5);
+
+ Assert.Equal(PhysicsGlobals.EPSILON * 0.5, body.LastUpdateTime, precision: 10);
+ Assert.Equal(Vector3.Zero, body.Position);
+ }
+
[Fact]
public void update_object_gravity_free_fall_accumulates_downward_velocity()
{
diff --git a/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs b/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs
new file mode 100644
index 00000000..e5dd47c5
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs
@@ -0,0 +1,750 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Pure-Core conformance and adversarial coverage for the retail projectile
+/// step (CPhysicsObj::update_object 0x00515D10 through
+/// UpdateObjectInternal 0x005156B0).
+///
+public sealed class ProjectilePhysicsStepperTests
+{
+ private const uint Landblock = 0xA9B40000u;
+ private const uint Cell = 0xA9B40001u;
+ private const uint ProjectileId = 0x7000u;
+ private static readonly ProjectileCollisionSphere ArrowSphere =
+ new(Vector3.Zero, 0.10f);
+
+ [Fact]
+ public void CollisionSphere_ScalesOffsetAndRadius_WithoutCenteringForceBolt()
+ {
+ var sphere = new ProjectileCollisionSphere(
+ new Vector3(0f, -0.165f, 0.1045f),
+ 0.102f,
+ 2f);
+
+ Assert.Equal(new Vector3(0f, -0.33f, 0.209f), sphere.LocalOrigin);
+ Assert.Equal(0.204f, sphere.Radius, 5);
+ Assert.True(sphere.IsValid);
+ }
+
+ [Fact]
+ public void CollisionSphere_RejectsInvalidComputedShape()
+ {
+ Assert.False(new ProjectileCollisionSphere(
+ Vector3.Zero, float.MaxValue, 2f).IsValid);
+ Assert.False(new ProjectileCollisionSphere(
+ new Vector3(float.MaxValue, 0f, 0f), 0.1f, 2f).IsValid);
+ Assert.False(new ProjectileCollisionSphere(
+ Vector3.Zero, 0.0003f, 0.5f).IsValid);
+ Assert.False(new ProjectileCollisionSphere(
+ Vector3.Zero, 0.1f, -1f).IsValid);
+ Assert.False(new ProjectileCollisionSphere(
+ Vector3.One, -0.1f, -1f).IsValid);
+ }
+
+ [Theory]
+ [InlineData(0f, 1f, 0f)]
+ [InlineData(1f, 0f, 0f)]
+ [InlineData(0f, 0f, 1f)]
+ [InlineData(0f, 0f, -1f)]
+ [InlineData(1f, 2f, 3f)]
+ public void SetVectorHeading_MapsLocalForwardToFull3dDirection(float x, float y, float z)
+ {
+ Vector3 direction = Vector3.Normalize(new Vector3(x, y, z));
+ Quaternion orientation = RetailFrameMath.SetVectorHeading(
+ Quaternion.Identity,
+ direction);
+
+ Vector3 actual = Vector3.Transform(Vector3.UnitY, orientation);
+ AssertVectorNear(direction, actual, 0.0001f);
+ }
+
+ [Fact]
+ public void SetVectorHeading_ZeroDirection_PreservesOrientation()
+ {
+ Quaternion original = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f);
+ Assert.Equal(original, RetailFrameMath.SetVectorHeading(original, Vector3.Zero));
+ }
+
+ [Theory]
+ [InlineData(0.0001f, 0f, 1f)]
+ [InlineData(0.000001f, -0.000002f, -1f)]
+ public void SetVectorHeading_NearlyVertical_PreservesNonzeroCompass(
+ float x, float y, float z)
+ {
+ Vector3 expected = Vector3.Normalize(new Vector3(x, y, z));
+ Quaternion orientation = RetailFrameMath.SetVectorHeading(
+ Quaternion.Identity,
+ expected);
+
+ AssertVectorNear(
+ expected,
+ Vector3.Transform(Vector3.UnitY, orientation),
+ 0.000001f);
+ }
+
+ [Fact]
+ public void Advance_ClampsVelocityToFiftyBeforeIntegrating()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), new Vector3(0f, 100f, 0f));
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.True(result.Simulated);
+ Assert.Equal(50f, body.Velocity.Length(), 4);
+ Assert.Equal(12f, body.Position.Y, 3); // 10 + 50 * 0.04
+ Assert.Equal(50f, body.CachedVelocity.Y, 3);
+ }
+
+ [Fact]
+ public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(
+ new Vector3(12f, 10f, 10f),
+ new Vector3(0f, 10f, 0f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Gravity);
+ body.Acceleration = new Vector3(100f, 200f, 300f); // wire value must not drive runtime
+
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(12f, body.Position.X, 4);
+ Assert.Equal(10.4f, body.Position.Y, 4);
+ Assert.Equal(10f + 0.5f * PhysicsBody.Gravity * 0.04f * 0.04f,
+ body.Position.Z, 4);
+ Assert.Equal(PhysicsBody.Gravity * 0.04f, body.Velocity.Z, 4);
+ }
+
+ [Fact]
+ public void Advance_NoGravity_ClearsStaleAcceleration()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 10f), new Vector3(0f, 10f, 0f));
+ body.Acceleration = new Vector3(100f, 200f, 300f);
+
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(Vector3.Zero, body.Acceleration);
+ Assert.Equal(10f, body.Position.Z, 4);
+ }
+
+ [Theory]
+ [InlineData(0f, 10f, 0f)]
+ [InlineData(10f, 0f, 0f)]
+ [InlineData(0f, 0f, 10f)]
+ [InlineData(3f, 4f, 5f)]
+ public void Advance_AlignPathUsesActualThreeDimensionalDisplacement(float x, float y, float z)
+ {
+ var (engine, _) = BuildEmptyEngine();
+ Vector3 velocity = new(x, y, z);
+ var body = MakeBody(
+ new Vector3(12f, 10f, 10f),
+ velocity,
+ PhysicsStateFlags.Missile | PhysicsStateFlags.AlignPath);
+
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Vector3 heading = Vector3.Transform(Vector3.UnitY, body.Orientation);
+ AssertVectorNear(Vector3.Normalize(velocity), heading, 0.0002f);
+ }
+
+ [Fact]
+ public void UpdatePhysicsInternal_AngularVelocityPremultipliesInWorldSpace()
+ {
+ var body = new PhysicsBody
+ {
+ Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.5f),
+ Omega = new Vector3(0f, 0f, 2f),
+ };
+ Quaternion worldDelta = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f);
+ Quaternion expected = Quaternion.Normalize(worldDelta * body.Orientation);
+
+ body.UpdatePhysicsInternal(0.1f);
+
+ AssertQuaternionEquivalent(expected, body.Orientation, 0.0001f);
+ }
+
+ [Fact]
+ public void Advance_RotatingProjectileWithoutAlignPathKeepsOmegaSpin()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 10f), new Vector3(0f, 2f, 0f));
+ body.Omega = new Vector3(0f, 0f, 4f);
+
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Quaternion expected = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.16f);
+ AssertQuaternionEquivalent(expected, body.Orientation, 0.0001f);
+ }
+
+ [Fact]
+ public void Advance_ExclusionsClearActiveAndDoNotMove()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var stepper = new ProjectilePhysicsStepper(engine);
+
+ var cases = new[]
+ {
+ (body: MakeBody(new(12f, 10f, 2f), Vector3.UnitY), parented: true),
+ (body: MakeBody(new(12f, 10f, 2f), Vector3.UnitY,
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Frozen), parented: false),
+ (body: MakeBody(new(12f, 10f, 2f), Vector3.UnitY,
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Static), parented: false),
+ };
+ cases[2].body.InWorld = true;
+
+ foreach (var item in cases)
+ {
+ Vector3 before = item.body.Position;
+ var result = stepper.Advance(
+ item.body, 0.04, Cell, ArrowSphere, ProjectileId,
+ isParented: item.parented);
+ Assert.False(result.Simulated);
+ Assert.False(item.body.IsActive);
+ Assert.Equal(before, item.body.Position);
+ }
+
+ var outOfWorld = MakeBody(new(12f, 10f, 2f), Vector3.UnitY);
+ outOfWorld.InWorld = false;
+ Assert.False(stepper.Advance(
+ outOfWorld, 0.04, Cell, ArrowSphere, ProjectileId).Simulated);
+ Assert.False(outOfWorld.IsActive);
+ }
+
+ [Fact]
+ public void Advance_AccumulatesSmallRemainderUntilRetailMinimumQuantum()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+ var stepper = new ProjectilePhysicsStepper(engine);
+
+ var first = stepper.Advance(body, 0.02, Cell, ArrowSphere, ProjectileId);
+ Assert.False(first.Simulated);
+ Assert.Equal(0d, body.LastUpdateTime, 8);
+
+ var second = stepper.Advance(body, 0.04, Cell, ArrowSphere, ProjectileId);
+ Assert.True(second.Simulated);
+ Assert.Equal(10.04f, body.Position.Y, 4);
+ Assert.Equal(0.04d, body.LastUpdateTime, 8);
+ }
+
+ [Fact]
+ public void Advance_ExactMinimumQuantum_RemainsAccumulated()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, PhysicsBody.MinQuantum, Cell, ArrowSphere, ProjectileId);
+
+ Assert.False(result.Simulated);
+ Assert.Equal(0d, body.LastUpdateTime, 8);
+ Assert.Equal(10f, body.Position.Y);
+ }
+
+ [Fact]
+ public void Advance_ExactMaximumQuantum_UsesOneQuantum()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, PhysicsBody.MaxQuantum, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(1, result.QuantumCount);
+ Assert.Equal(10.2f, body.Position.Y, 4);
+ Assert.Equal((double)PhysicsBody.MaxQuantum, body.LastUpdateTime, 7);
+ }
+
+ [Fact]
+ public void Advance_ExactHugeQuantum_IsStillSimulated()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, PhysicsBody.HugeQuantum, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(10, result.QuantumCount);
+ Assert.Equal(12f, body.Position.Y, 3);
+ Assert.Equal((double)PhysicsBody.HugeQuantum, body.LastUpdateTime, 7);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void Advance_NonFiniteClock_IsRejectedWithoutMutation(double currentTime)
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+ Vector3 before = body.Position;
+
+ Assert.Throws(() =>
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, currentTime, Cell, ArrowSphere, ProjectileId));
+
+ Assert.Equal(before, body.Position);
+ Assert.Equal(0d, body.LastUpdateTime);
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void Advance_NonFinitePriorClock_IsRejectedWithoutMutation(double priorTime)
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+ body.LastUpdateTime = priorTime;
+ Vector3 before = body.Position;
+
+ Assert.Throws(() =>
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 1d, Cell, ArrowSphere, ProjectileId));
+
+ Assert.Equal(before, body.Position);
+ Assert.Equal(priorTime, body.LastUpdateTime);
+ }
+
+ [Fact]
+ public void Advance_CatchupUsesPointTwoSecondQuantaAndRetainsNoLargeRemainder()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), new Vector3(0f, 50f, 0f));
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.45, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(3, result.QuantumCount); // 0.2 + 0.2 + 0.05
+ Assert.Equal(32.5f, body.Position.Y, 3);
+ Assert.Equal(0.45d, body.LastUpdateTime, 8);
+ }
+
+ [Fact]
+ public void Advance_GapAboveTwoSecondsIsDiscarded()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ var body = MakeBody(new Vector3(12f, 10f, 2f), Vector3.UnitY);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 2.01, Cell, ArrowSphere, ProjectileId);
+
+ Assert.False(result.Simulated);
+ Assert.Equal(new Vector3(12f, 10f, 2f), body.Position);
+ Assert.Equal(2.01d, body.LastUpdateTime, 8);
+ }
+
+ [Fact]
+ public void Sweep_AtMaximumSpeed_DoesNotTunnelThroughThinSphere()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ RegisterSphere(engine, 0x8100u, new Vector3(12f, 11f, 2f), 0.01f);
+ var body = MakeBody(
+ new Vector3(12f, 10f, 2f),
+ new Vector3(0f, 50f, 0f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.True(result.CollisionNormalValid);
+ Assert.True(body.Position.Y < 11.1f, $"Projectile tunneled to Y={body.Position.Y}");
+ Assert.Equal(Vector3.Zero, body.Velocity);
+ }
+
+ [Fact]
+ public void Sweep_ElasticProjectileReflectsFromObjectSurface()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ RegisterSphere(engine, 0x8101u, new Vector3(12f, 11f, 2f), 0.01f);
+ var body = MakeBody(
+ new Vector3(12f, 10f, 2f),
+ new Vector3(0f, 50f, 0f));
+ body.Elasticity = 0.5f;
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.True(result.CollisionNormalValid);
+ Assert.Equal(-25f, body.Velocity.Y, 2);
+ }
+
+ [Fact]
+ public void Sweep_DownwardProjectileCollidesWithTerrainFloor()
+ {
+ var engine = BuildTerrainEngine(0f);
+ var body = MakeBody(
+ new Vector3(12f, 10f, 0.3f),
+ new Vector3(0f, 0f, -10f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.True(result.CollisionNormalValid);
+ Assert.True(body.Position.Z >= 0.09f);
+ Assert.Equal(Vector3.Zero, body.Velocity);
+ Assert.True(body.InContact);
+ Assert.True(body.OnWalkable);
+
+ Vector3 settled = body.Position;
+ var settleTick = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.08, Cell, ArrowSphere, ProjectileId);
+ Assert.True(settleTick.Simulated);
+ Assert.Equal(settled, body.Position);
+ Assert.False(body.IsActive);
+
+ var inactiveTick = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.12, Cell, ArrowSphere, ProjectileId);
+ Assert.False(inactiveTick.Simulated);
+ Assert.Equal(settled, body.Position);
+ }
+
+ [Fact]
+ public void Sweep_FailedTransition_KeepsCandidateFrameAndCurrentCell()
+ {
+ var engine = BuildBoundaryEngine();
+ var body = MakeBody(
+ new Vector3(10f, 191f, 50f), new Vector3(0f, 50f, 0f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic,
+ cellId: 0xA9B40008u,
+ cellLocal: new Vector3(10f, 191f, 50f));
+ body.SlidingNormal = -Vector3.UnitY;
+ body.TransientState |=
+ TransientStateFlags.Sliding | TransientStateFlags.StationaryStop;
+ body.FramesStationaryFall = 2;
+ body.ContactPlaneValid = true;
+ body.ContactPlane = new Plane(Vector3.UnitZ, -50f);
+ body.ContactPlaneCellId = 0xA9B40008u;
+ body.ContactPlaneIsWater = true;
+ body.WalkablePolygonValid = true;
+ body.WalkablePlane = new Plane(Vector3.UnitZ, -50f);
+ body.WalkableVertices =
+ [
+ new Vector3(0f, 0f, 50f),
+ new Vector3(1f, 0f, 50f),
+ new Vector3(0f, 1f, 50f),
+ ];
+ body.WalkableUp = Vector3.UnitZ;
+ Plane priorContactPlane = body.ContactPlane;
+ Vector3[] priorWalkableVertices = (Vector3[])body.WalkableVertices.Clone();
+ TransientStateFlags priorTransient = body.TransientState;
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, 0xA9B40008u, ArrowSphere, ProjectileId);
+
+ Assert.False(result.TransitionOk);
+ Assert.Equal(0xA9B40008u, result.CellId);
+ Assert.Equal(193f, body.Position.Y, 3);
+ Assert.Equal(0xA9B40008u, body.CellPosition.ObjCellId);
+ Assert.Equal(193f, body.CellPosition.Frame.Origin.Y, 3);
+ Assert.Equal(Vector3.Zero, body.CachedVelocity);
+ Assert.Equal(50f, body.Velocity.Y);
+ Assert.True(body.ContactPlaneValid);
+ Assert.Equal(priorContactPlane, body.ContactPlane);
+ Assert.Equal(0xA9B40008u, body.ContactPlaneCellId);
+ Assert.True(body.ContactPlaneIsWater);
+ Assert.True(body.WalkablePolygonValid);
+ Assert.Equal(priorWalkableVertices, body.WalkableVertices);
+ Assert.Equal(Vector3.UnitZ, body.WalkableUp);
+ Assert.Equal(2, body.FramesStationaryFall);
+ Assert.Equal(priorTransient, body.TransientState);
+ }
+
+ [Fact]
+ public void Sweep_SelfShadowIsExcluded()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ RegisterSphere(engine, ProjectileId, new Vector3(12f, 11f, 2f), 0.01f);
+ var body = MakeBody(new(12f, 10f, 2f), new(0f, 50f, 0f));
+
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(12f, body.Position.Y, 3);
+ }
+
+ [Fact]
+ public void Sweep_OtherMissileIsIgnored()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ RegisterSphere(
+ engine, 0x8200u, new Vector3(12f, 11f, 2f), 0.01f,
+ (uint)PhysicsStateFlags.Missile,
+ EntityCollisionFlags.HasWeenie);
+ var body = MakeBody(new(12f, 10f, 2f), new(0f, 50f, 0f));
+
+ new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.Equal(12f, body.Position.Y, 3);
+ }
+
+ [Fact]
+ public void Sweep_EtherealWeenieIsIgnored()
+ {
+ var (ignoreEngine, _) = BuildEmptyEngine();
+ RegisterSphere(
+ ignoreEngine, 0x8300u, new Vector3(12f, 11f, 2f), 0.01f,
+ (uint)PhysicsStateFlags.Ethereal,
+ EntityCollisionFlags.HasWeenie);
+ var ignoredBody = MakeBody(new(12f, 10f, 2f), new(0f, 50f, 0f));
+ new ProjectilePhysicsStepper(ignoreEngine).Advance(
+ ignoredBody, 0.04, Cell, ArrowSphere, ProjectileId);
+ Assert.Equal(12f, ignoredBody.Position.Y, 3);
+ }
+
+ [Fact]
+ public void Sweep_WithKnownTarget_IgnoresOtherCreatureAndHitsDesignatedCreature()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ const uint targetId = 0x8401u;
+ var creature = EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature;
+ RegisterSphere(engine, 0x8400u, new(12f, 10.8f, 2f), 0.05f, 0u, creature);
+ RegisterSphere(engine, targetId, new(12f, 11.5f, 2f), 0.05f, 0u, creature);
+ var body = MakeBody(
+ new(12f, 10f, 2f), new(0f, 50f, 0f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId, targetId);
+
+ Assert.True(result.CollisionNormalValid);
+ Assert.True(body.Position.Y > 10.8f, "The non-designated creature blocked the missile");
+ Assert.True(body.Position.Y < 11.6f, "The designated creature was skipped");
+ }
+
+ [Fact]
+ public void Sweep_UnknownTargetCollidesWithCreature()
+ {
+ var (engine, _) = BuildEmptyEngine();
+ RegisterSphere(
+ engine, 0x8500u, new(12f, 11f, 2f), 0.05f, 0u,
+ EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsCreature);
+ var body = MakeBody(
+ new(12f, 10f, 2f), new(0f, 50f, 0f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.True(result.CollisionNormalValid);
+ }
+
+ [Fact]
+ public void Sweep_ThinBspWallStopsInelasticProjectile()
+ {
+ var (engine, cache) = BuildEmptyEngine();
+ RegisterThinBspWall(engine, cache, y: 11f);
+ var body = MakeBody(
+ new(12f, 10f, 2f), new(0f, 50f, 0f),
+ PhysicsStateFlags.Missile | PhysicsStateFlags.Inelastic);
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.04, Cell, ArrowSphere, ProjectileId);
+
+ Assert.True(result.CollisionNormalValid);
+ Assert.True(body.Position.Y < 11.1f);
+ Assert.Equal(Vector3.Zero, body.Velocity);
+ }
+
+ [Theory]
+ [InlineData(191f, 10f, 40f, 0f, 0xA9B40039u, 0xAAB40001u)]
+ [InlineData( 1f, 10f, -40f, 0f, 0xA9B40001u, 0xA8B40039u)]
+ [InlineData( 10f,191f, 0f, 40f, 0xA9B40008u, 0xA9B50001u)]
+ [InlineData( 10f, 1f, 0f, -40f, 0xA9B40001u, 0xA9B30008u)]
+ public void Sweep_CrossesLoadedLandblockBoundaryInEveryDirection(
+ float startX, float startY,
+ float velocityX, float velocityY,
+ uint startCell, uint expectedCell)
+ {
+ var engine = BuildBoundaryEngine();
+ var body = MakeBody(
+ new(startX, startY, 50f), new(velocityX, velocityY, 0f),
+ PhysicsStateFlags.Missile,
+ cellId: startCell,
+ cellLocal: new(startX, startY, 50f));
+
+ var result = new ProjectilePhysicsStepper(engine).Advance(
+ body, 0.05, startCell,
+ new ProjectileCollisionSphere(Vector3.Zero, 0.5f),
+ ProjectileId);
+
+ Assert.Equal(expectedCell, result.CellId);
+ Assert.Equal(startX + (velocityX * 0.05f), body.Position.X, 3);
+ Assert.Equal(startY + (velocityY * 0.05f), body.Position.Y, 3);
+ }
+
+ private static PhysicsBody MakeBody(
+ Vector3 position,
+ Vector3 velocity,
+ PhysicsStateFlags state = PhysicsStateFlags.Missile,
+ uint cellId = Cell,
+ Vector3? cellLocal = null)
+ {
+ var body = new PhysicsBody
+ {
+ State = state,
+ Position = position,
+ Orientation = Quaternion.Identity,
+ LastUpdateTime = 0d,
+ };
+ body.SnapToCell(cellId, position, cellLocal ?? position);
+ body.Velocity = velocity; // allow UpdatePhysicsInternal itself to prove the 50-unit clamp
+ body.TransientState = TransientStateFlags.Active;
+ return body;
+ }
+
+ private static (PhysicsEngine Engine, PhysicsDataCache Cache) BuildEmptyEngine()
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+ RegisterTerrain(engine, -1000f);
+ return (engine, cache);
+ }
+
+ private static PhysicsEngine BuildTerrainEngine(float height)
+ {
+ var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
+ RegisterTerrain(engine, height);
+ return engine;
+ }
+
+ private static void RegisterTerrain(PhysicsEngine engine, float height)
+ {
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < heightTable.Length; i++)
+ heightTable[i] = height;
+ engine.AddLandblock(
+ Landblock,
+ new TerrainSurface(heights, heightTable),
+ System.Array.Empty(),
+ System.Array.Empty(),
+ 0f,
+ 0f);
+ }
+
+ private static PhysicsEngine BuildBoundaryEngine()
+ {
+ static TerrainSurface EmptyTerrain()
+ {
+ var heights = new byte[81];
+ var table = new float[256];
+ for (int i = 0; i < table.Length; i++) table[i] = -1000f;
+ return new TerrainSurface(heights, table);
+ }
+
+ var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
+ engine.AddLandblock(0xA9B4FFFFu, EmptyTerrain(), System.Array.Empty(),
+ System.Array.Empty(), 0f, 0f);
+ engine.AddLandblock(0xA8B4FFFFu, EmptyTerrain(), System.Array.Empty(),
+ System.Array.Empty(), -192f, 0f);
+ engine.AddLandblock(0xAAB4FFFFu, EmptyTerrain(), System.Array.Empty(),
+ System.Array.Empty(), 192f, 0f);
+ engine.AddLandblock(0xA9B3FFFFu, EmptyTerrain(), System.Array.Empty(),
+ System.Array.Empty(), 0f, -192f);
+ engine.AddLandblock(0xA9B5FFFFu, EmptyTerrain(), System.Array.Empty(),
+ System.Array.Empty(), 0f, 192f);
+ return engine;
+ }
+
+ private static void RegisterSphere(
+ PhysicsEngine engine,
+ uint id,
+ Vector3 position,
+ float radius,
+ uint state = (uint)PhysicsStateFlags.Static,
+ EntityCollisionFlags flags = EntityCollisionFlags.None)
+ {
+ engine.ShadowObjects.Register(
+ id,
+ 0u,
+ position,
+ Quaternion.Identity,
+ radius,
+ 0f,
+ 0f,
+ Landblock,
+ ShadowCollisionType.Sphere,
+ state: state,
+ flags: flags,
+ isStatic: (state & (uint)PhysicsStateFlags.Static) != 0);
+ }
+
+ private static void RegisterThinBspWall(
+ PhysicsEngine engine,
+ PhysicsDataCache cache,
+ float y)
+ {
+ const uint gfxId = 0x0100F001u;
+ const uint entityId = 0x8600u;
+ var vertices = new[]
+ {
+ new Vector3(-2f, 0f, -2f),
+ new Vector3( 2f, 0f, -2f),
+ new Vector3( 2f, 0f, 2f),
+ new Vector3(-2f, 0f, 2f),
+ };
+ var polygon = new ResolvedPolygon
+ {
+ Vertices = vertices,
+ Plane = new Plane(new Vector3(0f, -1f, 0f), 0f),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+ var leaf = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 4f },
+ };
+ leaf.Polygons.Add(0);
+ cache.RegisterGfxObjForTest(gfxId, new GfxObjPhysics
+ {
+ BSP = new PhysicsBSPTree { Root = leaf },
+ PhysicsPolygons = new Dictionary(),
+ Vertices = new VertexArray(),
+ Resolved = new Dictionary { [0] = polygon },
+ BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 4f },
+ });
+ engine.ShadowObjects.Register(
+ entityId,
+ gfxId,
+ new Vector3(12f, y, 2f),
+ Quaternion.Identity,
+ 4f,
+ 0f,
+ 0f,
+ Landblock,
+ ShadowCollisionType.BSP,
+ state: (uint)(PhysicsStateFlags.Static | PhysicsStateFlags.HasPhysicsBsp),
+ isStatic: true);
+ }
+
+ private static void AssertVectorNear(Vector3 expected, Vector3 actual, float epsilon)
+ {
+ Assert.InRange(Vector3.Distance(expected, actual), 0f, epsilon);
+ }
+
+ private static void AssertQuaternionEquivalent(
+ Quaternion expected,
+ Quaternion actual,
+ float epsilon)
+ {
+ float dot = MathF.Abs(Quaternion.Dot(expected, actual));
+ Assert.InRange(dot, 1f - epsilon, 1f + epsilon);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/TransitionTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionTests.cs
index 7feb991b..a4ea2069 100644
--- a/tests/AcDream.Core.Tests/Physics/TransitionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TransitionTests.cs
@@ -217,8 +217,7 @@ public class TransitionTests
// Arrange: flat terrain at Z=10. The sphere starts in contact with the
// surface and moves horizontally. Because the terrain stays flat the
// Contact flag should persist and no step-down is needed.
- // Movement distance is kept < MaxTransitionSteps * radius to avoid the
- // retail 30-step safety cap. With radius=1.0 and 15 units: 15 steps < 30.
+ // A 15-unit move at radius 1.0 requires 15 continuous-sweep steps.
const float groundZ = 10f;
var terrain = FlatTerrain(groundZ);
var engine = MakeEngine(terrain);
@@ -284,31 +283,28 @@ public class TransitionTests
}
[Fact]
- public void FindTransitionalPosition_LongSweep_ViewerBypassesStepCap()
+ public void FindTransitionalPosition_LongSweep_HasNoInventedStepCap()
{
- // A8.F: the 30-step safety cap bailed long sweeps. Retail's
- // find_transitional_position has no cap and handles viewers specially
- // (calc_num_steps `state & 4` branch, acclient :272181). The camera
- // spring arm (IsViewer) must not bail. radius 0.3, dist 12 → 40 steps > 30.
+ // Retail find_transitional_position 0x0050BDF0 iterates every step
+ // returned by calc_num_steps. radius 0.3, dist 12 => 40 steps.
const float groundZ = 10f;
var engine = MakeEngine(FlatTerrain(groundZ));
Vector3 from = new(50f, 50f, groundZ);
Vector3 to = new(62f, 50f, groundZ); // 12 units → 40 steps at r=0.3 (>30 cap)
- // Normal mover: hits the 30-step cap, bails without moving.
var normal = MakeTransition(from, to, sphereRadius: 0.3f);
bool normalOk = normal.FindTransitionalPosition(engine);
- Assert.False(normalOk);
+ Assert.True(normalOk);
+ Assert.True(normal.SpherePath.CurPos.X > from.X);
- // Viewer: cap bypassed → sweep proceeds the full distance over flat ground.
+ // Viewer uses calc_num_steps' dedicated path and likewise completes.
var viewer = MakeTransition(from, to, sphereRadius: 0.3f);
viewer.ObjectInfo.State |= ObjectInfoState.IsViewer;
bool viewerOk = viewer.FindTransitionalPosition(engine);
Assert.True(viewerOk);
- // Position must have advanced toward `to` (key invariant: viewer proceeds).
Assert.True(viewer.SpherePath.CurPos.X > from.X,
- "Viewer sphere should have advanced in +X past the step cap");
+ "Viewer sphere should have advanced in +X through the full sweep");
}
[Fact]