feat(physics): port retail projectile integration

Add a pure Core projectile driver for retail clock quanta, final-state acceleration, 50-unit velocity clamping, world-space angular integration, full-3D AlignPath, and oriented Setup-local sphere sweeps. Preserve exact success versus set_frame-only failure semantics, independent Contact/OnWalkable state, and full-cell identity across all landblock directions.

Port OBJECTINFO::missile_ignore with explicit weenie/creature metadata, remove the invented transition-step cap, retain PathClipped without arming PerfectClip, and keep authoritative target identity optional. Add malformed-shape/clock, thin-wall, terrain, target-exclusion, frame-spike, failure-state, and boundary conformance coverage.

Retire TS-2 and synchronize the architecture, milestones, roadmap, research oracle, and durable physics memory.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 11:43:11 +02:00
parent 542dcfc384
commit f02b995e4f
21 changed files with 1669 additions and 263 deletions

View file

@ -146,6 +146,8 @@ src/
PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active) PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active)
ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active) ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active)
PhysicsEngine.cs -> ResolveWithTransition active player path 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) CellBsp.cs -> not a first-class runtime owner yet (L.2e)
World/ World/
GameEntity.cs -> target unified entity, not current reality GameEntity.cs -> target unified entity, not current reality
@ -216,7 +218,12 @@ InputDispatcher / PlayerMovementController
What exists and is active: What exists and is active:
- `PhysicsEngine.ResolveWithTransition` is the path used for local player - `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 - `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
the transition path. the transition path.
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, - `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,

View file

@ -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 | | # | 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-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-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-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) | | 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. 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):** **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), M2 combat must land TS-5 (CanJump gating), TS-23 (PK bits), TS-25
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions), (stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId). Membership Stage 2 must land TS-18 (BuildingCellId).

View file

@ -184,7 +184,8 @@ behavior.
catch #103). catch #103).
- **Camera-collision (shipped 2026-05-30, kept):** retail `SmartBox::update_viewer` - **Camera-collision (shipped 2026-05-30, kept):** retail `SmartBox::update_viewer`
swept-sphere spring arm (`CameraDiagnostics.CollideCamera`, `PhysicsCameraCollisionProbe`, 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). 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 **Today's pre-M1.5 baseline** (2026-05-20 — committed in this
@ -514,6 +515,8 @@ behavior. Estimated 1726 days focused work, 35 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. - **✓ 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. - ~~**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. **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. **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.

View file

@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade `PlayerDescription.Options1` preserves retail's player secure-trade
preference. See preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 05 - **M2/M3 missile, effect, and portal presentation campaign (Steps 06
implemented and independently reviewed 2026-07-14)** — named-retail projectile and implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and 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 and per-child pose buffers are reused. Rigid attachment/effect frames exclude
visual Setup scale, attachment withdrawal cascades through descendants, and visual Setup scale, attachment withdrawal cascades through descendants, and
later pose publication recovers the complete descendant chain parent-first; 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 light registration follows final projection visibility. Step 6 adds the pure
retired. The remaining steps port projectile motion and Core retail projectile driver: 0.2-second catch-up quanta, the 50-unit/second
sweeps, and Hidden/UnHide portal presentation. 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)** - **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 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 funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -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: 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 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) - What happens for very small dt? (micro-fragments at EPSILON are consumed; remainders at or below MinQuantum accumulate)
- How is `LastUpdateTime` advanced? (always to `PhysicsTimer::curr_time` after the loop) - 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) - 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). - `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 ───── // ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ─────
// BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure // 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. // is unmistakable (line 284031 do-while). Matching disassembly resolves 0.2.
if (dt > 0.0f /* MaxQuantum=0.1 */) { if (dt > 0.0f /* MaxQuantum=0.2 */) {
do { do {
PhysicsTimer::curr_time += /* MaxQuantum */ 0.1; PhysicsTimer::curr_time += /* MaxQuantum */ 0.2;
CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f); CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.2f);
dt -= /* MaxQuantum */ 0.1; dt -= /* MaxQuantum */ 0.2;
} while (dt > /* MaxQuantum */ 0.1); } while (dt > /* MaxQuantum */ 0.2);
} }
// ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ──── // ── 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 | | Symbol | Hex (float32) | Value | Meaning |
|---|---|---|---| |---|---|---|---|
| `EPSILON` | `0x3949A18A` | `0.000199999995f` ≈ 0.0002 s | "essentially zero" tolerance (visible in retail decomp line 283996) | | `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 | | `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) | | `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? ### `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 ### `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) { 00535db0 void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
Vector3 d = *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. // Note: AC's heading convention — angle from north (+Y) measured clockwise.
// 450 - atan2(x, y) normalizes to [0, 360). // 450 - atan2(x, y) normalizes to [0, 360).
double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323; double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323;
yawDeg = fmod(yawDeg, 360.0); 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 ### `Frame::rotate` (FUN_004525B0) — line 91477
Applies a small rotation increment in local space (used by omega integration): 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 dt = currentTime - LastUpdateTime
PhysicsTimer.current = LastUpdateTime
if (dt < EPSILON) return; // < 0.0002s too small, defer if (dt <= EPSILON): // <= 0.0002s — micro-fragment
if (dt > HugeQuantum) return; // > 2.0s — stale, discard, advance update_time 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 PhysicsTimer.curr_time += MaxQuantum
UpdateObjectInternal(MaxQuantum) UpdateObjectInternal(MaxQuantum)
dt -= MaxQuantum dt -= MaxQuantum
@ -781,38 +799,24 @@ LastUpdateTime = PhysicsTimer.curr_time
**Observations:** **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. 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. **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. 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: 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. 3. **Between MinQuantum and MaxQuantum**, retail executes one substep for the full elapsed time.
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). 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. 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. 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 ### 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 ## 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. 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.
**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.
--- ---
@ -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). - **`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`. - **`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. - **`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.

View file

@ -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. through normal owner teardown while retaining DAT-static/synthetic poses.
TS-11 and TS-12 are retired with the emitter-binding and TS-11 and TS-12 are retired with the emitter-binding and
live-part mechanisms. 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.

View file

@ -4404,9 +4404,13 @@ public sealed class GameWindow : IDisposable
// Decode PvP / Player / Impenetrable from PWD._bitfield. // Decode PvP / Player / Impenetrable from PWD._bitfield.
// IsCreature comes from the spawn's ItemType (server-known type). // 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) 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) if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature; flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;

View file

@ -39,6 +39,14 @@ public enum EntityCollisionFlags : byte
IsPKLite = 0x08, IsPKLite = 0x08,
/// <summary>Set when <c>BF_FREE_PKSTATUS (0x200000)</c> is set (a.k.a. "Free" PK status — cannot be PKed).</summary> /// <summary>Set when <c>BF_FREE_PKSTATUS (0x200000)</c> is set (a.k.a. "Free" PK status — cannot be PKed).</summary>
IsImpenetrable = 0x10, IsImpenetrable = 0x10,
/// <summary>
/// Runtime collision metadata: the target has a retail
/// <c>CWeenieObject</c>. This is deliberately distinct from
/// <see cref="IsCreature"/> because doors, portals, and items also have a
/// weenie. <c>OBJECTINFO::missile_ignore</c> at <c>0x0050CEB0</c>
/// requires this distinction for ethereal targets.
/// </summary>
HasWeenie = 0x20,
} }
/// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary> /// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary>

View file

@ -106,7 +106,9 @@ public sealed class PhysicsBody
public const float SmallVelocitySquared = SmallVelocity * SmallVelocity; public const float SmallVelocitySquared = SmallVelocity * SmallVelocity;
public const float DefaultFriction = 0.95f; public const float DefaultFriction = 0.95f;
public const float MinQuantum = 1.0f / 30.0f; // ~0.0333 s 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 public const float HugeQuantum = 2.0f; // discard stale dt
// ── struct fields ────────────────────────────────────────────────────── // ── struct fields ──────────────────────────────────────────────────────
@ -177,6 +179,27 @@ public sealed class PhysicsBody
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
} }
/// <summary>
/// Commits only the frame while retaining the current cell identity. This
/// is retail <c>CPhysicsObj::set_frame</c> at <c>0x00514090</c>, used by
/// <c>UpdateObjectInternal</c> when a transition fails: the integrated
/// candidate frame is kept, but <c>Position.objcell_id</c> is not changed
/// and no outdoor canonicalization is performed.
/// </summary>
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 // 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. // 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 // 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 (done unconditionally in decompile)
Velocity += Acceleration * dt; Velocity += Acceleration * dt;
// Angular integration: apply omega rotation. // Angular integration: Frame::grotate receives the WORLD-space
// omega * dt gives the angle-axis delta rotation. // rotation vector omega*dt and premultiplies its quaternion onto the
float omegaLen = Omega.Length(); // frame (0x005357A0; called at UpdatePhysicsInternal 0x00510935).
if (omegaLen > 1e-6f) // 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; float angle = MathF.Sqrt(rotationLenSq);
Quaternion deltaRot = Quaternion.CreateFromAxisAngle(Omega / omegaLen, angle); Quaternion deltaRot = Quaternion.CreateFromAxisAngle(rotation / angle, angle);
Orientation = Quaternion.Normalize(Quaternion.Multiply(Orientation, deltaRot)); Orientation = Quaternion.Normalize(Quaternion.Multiply(deltaRot, Orientation));
} }
} }
// ── FUN_00515020 ─────────────────────────────────────────────────────── // ── CPhysicsObj::update_object 0x00515D10 ───────────────────────────────
/// <summary> /// <summary>
/// Per-frame top-level driver. Computes dt from the wall clock versus /// Per-frame top-level driver. Computes dt from the wall clock versus
/// LastUpdateTime, clamping to [MinQuantum, HugeQuantum], then calls /// LastUpdateTime, discards invalid gaps, and advances the consumed
/// calc_acceleration and UpdatePhysicsInternal. /// PhysicsTimer time through fixed maximum quanta plus one remainder.
/// ///
/// Decompiled logic (FUN_00515020): /// Decompiled logic (<c>CPhysicsObj::update_object</c> 0x00515D10):
/// if parent-attached (offset +0x40 != 0) → return /// if parent-attached (offset +0x40 != 0) → return
/// dVar1 = currentTime - LastUpdateTime /// dVar1 = currentTime - LastUpdateTime
/// if dVar1 &lt; MinQuantum → return (too short — skip) /// if dVar1 &lt;= EPSILON → update timestamp and return
/// if dVar1 > HugeQuantum → update timestamp and return (stale — discard) /// if dVar1 > HugeQuantum → update timestamp and return (stale — discard)
/// while dVar1 > MaxQuantum: simulate MaxQuantum step, subtract /// while dVar1 > MaxQuantum: simulate MaxQuantum step, subtract
/// if dVar1 > MinQuantum: simulate remainder /// if dVar1 > MinQuantum: simulate remainder
/// LastUpdateTime = currentTime /// LastUpdateTime = consumed PhysicsTimer time
/// ///
/// The caller passes currentTime; the object does not read a global clock /// The caller passes currentTime; the object does not read a global clock
/// directly in this port so tests can drive the clock explicitly. /// directly in this port so tests can drive the clock explicitly.
@ -586,9 +612,13 @@ public sealed class PhysicsBody
{ {
double deltaTime = currentTime - LastUpdateTime; double deltaTime = currentTime - LastUpdateTime;
// dt too small — nothing to simulate yet // Retail drops only a true micro-fragment. A larger remainder at or
if (deltaTime < MinQuantum) // below MinQuantum is retained by leaving LastUpdateTime unchanged.
if (deltaTime <= PhysicsGlobals.EPSILON)
{
LastUpdateTime = currentTime;
return; return;
}
// Stale / first frame — just consume the time without simulating // Stale / first frame — just consume the time without simulating
if (deltaTime > HugeQuantum) if (deltaTime > HugeQuantum)
@ -597,9 +627,12 @@ public sealed class PhysicsBody
return; return;
} }
// Sub-step: break large dt into MaxQuantum chunks double physicsTime = LastUpdateTime;
// Sub-step: break large dt into MaxQuantum chunks.
while (deltaTime > MaxQuantum) while (deltaTime > MaxQuantum)
{ {
physicsTime += MaxQuantum;
calc_acceleration(); calc_acceleration();
UpdatePhysicsInternal(MaxQuantum); UpdatePhysicsInternal(MaxQuantum);
deltaTime -= MaxQuantum; deltaTime -= MaxQuantum;
@ -608,10 +641,11 @@ public sealed class PhysicsBody
// Simulate the remainder // Simulate the remainder
if (deltaTime > MinQuantum) if (deltaTime > MinQuantum)
{ {
physicsTime += deltaTime;
calc_acceleration(); calc_acceleration();
UpdatePhysicsInternal((float)deltaTime); UpdatePhysicsInternal((float)deltaTime);
} }
LastUpdateTime = currentTime; LastUpdateTime = physicsTime;
} }
} }

View file

@ -937,7 +937,11 @@ public sealed class PhysicsEngine
bool isOnGround, bool isOnGround,
PhysicsBody? body = null, PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None, 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 / // A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot // 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 // this->object = arg2). The skip itself is at
// CObjCell::find_obj_collisions line 308931. // CObjCell::find_obj_collisions line 308931.
transition.ObjectInfo.SelfEntityId = movingEntityId; 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 // Commit C 2026-04-29 — caller-supplied mover flags drive the
// retail PvP exemption block in FindObjCollisions. The local // retail PvP exemption block in FindObjCollisions. The local
@ -971,6 +977,11 @@ public sealed class PhysicsEngine
// (matches non-player movement, all targets collide). // (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags; 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 // 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 // (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. // 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.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 // #145: supply the carried cell-relative frame anchor to the outdoor
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE // membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
@ -1070,6 +1089,12 @@ public sealed class PhysicsEngine
// next frame's transition can seed from it. Uses LastKnownContactPlane // next frame's transition can seed from it. Uses LastKnownContactPlane
// when current is invalid (e.g., airborne this frame), matching retail. // when current is invalid (e.g., airborne this frame), matching retail.
if (body is not null) if (body is not null)
{
// 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) if (ci.ContactPlaneValid)
{ {
@ -1128,8 +1153,6 @@ public sealed class PhysicsEngine
// discarded whole; the body keeps its prior state). #137 mechanism // discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail // 2: an unconditional writeback here could persist a normal retail
// would discard. // would discard.
if (ok)
{
if (ci.SlidingNormalValid if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{ {
@ -1254,7 +1277,11 @@ public sealed class PhysicsEngine
ResolveResult resolveResult; ResolveResult resolveResult;
if (ok) 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); || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
resolveResult = new ResolveResult( resolveResult = new ResolveResult(
@ -1268,7 +1295,10 @@ public sealed class PhysicsEngine
sp.CurCellId, sp.CurCellId,
onGround, onGround,
collisionNormalValid, collisionNormalValid,
collisionNormal); collisionNormal,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable);
} }
else else
{ {
@ -1291,7 +1321,8 @@ public sealed class PhysicsEngine
partialOnGround, partialOnGround,
collisionNormalValid, collisionNormalValid,
collisionNormal, 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, // A6.P3 #98 capture: emit one JSON Lines record per player call,

View file

@ -13,6 +13,38 @@ namespace AcDream.Core.Physics;
/// </summary> /// </summary>
public static class PhysicsObjUpdate public static class PhysicsObjUpdate
{ {
/// <summary>
/// Retail <c>SetPositionInternal</c> walkability comparison at
/// <c>0x00515465-0x0051548E</c>. Equality with FloorZ is walkable.
/// </summary>
public static bool IsWalkableContact(bool inContact, Vector3 contactNormal)
=> inContact && contactNormal.Z >= PhysicsGlobals.FloorZ;
/// <summary>
/// Applies the two independent transient facts written by retail
/// <c>CPhysicsObj::SetPositionInternal</c> at <c>0x00515430-0x0051549F</c>.
/// Contact comes from contact-plane validity; OnWalkable additionally
/// requires a walkable plane normal. A steep contact therefore remains in
/// contact without becoming grounded.
/// </summary>
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();
}
/// <summary> /// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's /// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on /// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on

View file

@ -0,0 +1,261 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// 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 <c>CTransition::init_sphere</c> from
/// <c>CPhysicsObj::transition</c> at <c>0x00512DC0</c>.
/// </summary>
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);
}
}
}
/// <summary>Outcome of one retail projectile clock advance.</summary>
public readonly record struct ProjectileAdvanceResult(
uint CellId,
int QuantumCount,
bool Simulated,
bool CollisionNormalValid,
Vector3 CollisionNormal,
bool TransitionOk);
/// <summary>
/// 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 <see cref="PhysicsBody"/>, cell, Setup sphere, and identities, then
/// commits the returned cell through the live-entity runtime.
///
/// <para>Retail anchors:</para>
/// <list type="bullet">
/// <item><c>CPhysicsObj::update_object</c> <c>0x00515D10</c> (clock gate and catch-up).</item>
/// <item><c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c> (candidate, AlignPath, transition, cached velocity).</item>
/// <item><c>CPhysicsObj::UpdatePhysicsInternal</c> <c>0x00510700</c> (linear/angular integration).</item>
/// <item><c>CPhysicsObj::get_object_info</c> <c>0x00511CC0</c> (Missile adds PathClipped, never PerfectClip).</item>
/// </list>
/// </summary>
public sealed class ProjectilePhysicsStepper
{
private readonly PhysicsEngine _physics;
public ProjectilePhysicsStepper(PhysicsEngine physics)
=> _physics = physics ?? throw new ArgumentNullException(nameof(physics));
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View file

@ -34,4 +34,21 @@ public readonly record struct ResolveResult(
/// start cell or was immediately stuck. The camera <c>SweepEye</c> reads this /// start cell or was immediately stuck. The camera <c>SweepEye</c> reads this
/// to trigger <c>SmartBox::update_viewer</c>'s fallbacks. Default <c>true</c> /// to trigger <c>SmartBox::update_viewer</c>'s fallbacks. Default <c>true</c>
/// so existing callers are unaffected.</summary> /// so existing callers are unaffected.</summary>
bool Ok = true); bool Ok = true,
/// <summary>
/// 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.
/// </summary>
Quaternion Orientation = default,
/// <summary>
/// Exact retail SetPositionInternal contact-plane-valid result. This is
/// distinct from <see cref="OnWalkable"/>: a steep surface may be contact
/// without being ground.
/// </summary>
bool InContact = false,
/// <summary>
/// Exact retail walkability result after testing the contact-plane normal
/// against <see cref="PhysicsGlobals.FloorZ"/>.
/// </summary>
bool OnWalkable = false);

View file

@ -0,0 +1,63 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail frame-orientation helpers used by physics presentation.
/// </summary>
public static class RetailFrameMath
{
/// <summary>
/// Points the frame's local +Y axis along a world-space direction with
/// zero roll. This is the observable contract of retail
/// <c>Frame::set_vector_heading</c> at <c>0x00535DB0</c>: 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.
/// </summary>
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));
}
}

View file

@ -56,6 +56,15 @@ public sealed class ObjectInfo
public bool StepDown = true; public bool StepDown = true;
public float Scale = 1.0f; public float Scale = 1.0f;
/// <summary>The moving object's retail PhysicsState.</summary>
public PhysicsStateFlags MoverPhysicsState;
/// <summary>
/// Authoritative designated projectile target. Zero means unknown/no
/// target; it is never inferred from the player's selected UI target.
/// </summary>
public uint TargetId;
/// <summary> /// <summary>
/// EntityId of the moving entity, used by <c>FindObjCollisions</c> to /// EntityId of the moving entity, used by <c>FindObjCollisions</c> to
/// skip the moving object's own ShadowEntry. Mirrors retail /// 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 PathClipped => State.HasFlag(ObjectInfoState.PathClipped);
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate); public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate);
/// <summary>
/// Verbatim decision tree from retail <c>OBJECTINFO::missile_ignore</c>
/// at <c>0x0050CEB0</c>. A true result skips all target shapes.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Return the Z threshold for a walkable surface appropriate to the /// Return the Z threshold for a walkable surface appropriate to the
/// current movement context. /// current movement context.
@ -446,7 +481,8 @@ public sealed class SpherePath
// Update global spheres to match new check position // Update global spheres to match new check position
for (int i = 0; i < NumSphere; i++) 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; GlobalSphere[i].Radius = LocalSphere[i].Radius;
} }
} }
@ -580,15 +616,27 @@ public sealed class SpherePath
/// <summary> /// <summary>
/// Initialize the path for a simple point-to-point movement. /// Initialize the path for a simple point-to-point movement.
/// </summary> /// </summary>
public void InitPath(Vector3 begin, Vector3 end, uint cellId, public void InitPath(
float sphereRadius, float sphereHeight = 0f) Vector3 begin,
Vector3 end,
uint cellId,
float sphereRadius,
float sphereHeight = 0f,
Vector3? localSphereOrigin = null,
Quaternion? beginOrientation = null,
Quaternion? endOrientation = null)
{ {
BeginPos = begin; BeginPos = begin;
EndPos = end; EndPos = end;
CurPos = begin; CurPos = begin;
CurCellId = cellId; 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; LocalSphere[0].Radius = sphereRadius;
if (sphereHeight > 0) if (sphereHeight > 0)
@ -607,7 +655,8 @@ public sealed class SpherePath
// Also init CurCenter // Also init CurCenter
for (int i = 0; i < NumSphere; i++) 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; GlobalCurrCenter[i].Radius = LocalSphere[i].Radius;
} }
} }
@ -627,7 +676,6 @@ public static class PhysicsGlobals
public const float Gravity = -9.8f; public const float Gravity = -9.8f;
public const float MaxVelocity = 50.0f; public const float MaxVelocity = 50.0f;
public const float DummySphereRadius = 0.1f; public const float DummySphereRadius = 0.1f;
public const int MaxTransitionSteps = 30; // retail uses 30, ACE uses 1000
} }
/// <summary> /// <summary>
@ -653,54 +701,9 @@ public sealed class Transition
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; 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
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary>
/// W1: Named predicate for the PvP-exemption term in retail's
/// <c>CPhysicsObj::FindObjCollisions</c> dispatch (pc:276861,
/// <c>acclient_2013_pseudo_c.txt:276808276841</c>).
///
/// <para>
/// In retail <c>ebp_1</c> 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 <c>HAS_PHYSICS_BSP_PS</c>, effectively making
/// PvP-exempt players transparent to BSP-only objects.
/// </para>
///
/// <para>
/// M1.5 scope has no PK. Returns <c>false</c> always.
/// Wire through mover + target state when PK ships (M2+ / phase TBD).
/// Retail oracle: pc:276808276841.
/// </para>
/// </summary>
internal static bool PvpExempt() => false; // wire when PK ships (M2+) — pc:276808276841
/// <summary>
/// W1: Named predicate for the <c>OBJECTINFO::missile_ignore</c> term
/// in retail's <c>CPhysicsObj::FindObjCollisions</c> dispatch
/// (pc:276861, <c>acclient_2013_pseudo_c.txt:274385 + :276858276861</c>).
///
/// <para>
/// In retail <c>eax_12 = OBJECTINFO::missile_ignore(ebx, this)</c>
/// returns non-zero when either the TARGET has <c>MISSILE_PS (0x40)</c>
/// set, or the mover has <c>MISSILE_PS</c> 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
/// <c>HAS_PHYSICS_BSP_PS</c> — missiles pass through BSP-only objects.
/// </para>
///
/// <para>
/// M1.5 scope has no missiles. Returns <c>false</c> always.
/// Wire through mover OBJECTINFO + target state when missiles ship (F.3).
/// Retail oracle: <c>OBJECTINFO::missile_ignore</c> pc:274385;
/// dispatch pc:276858276861.
/// </para>
/// </summary>
internal static bool MissileIgnore() => false; // wire when missiles ship (F.3) — pc:274385
/// <summary> /// <summary>
/// A6.P7 retail-binary dispatch predicate. Returns true when an /// A6.P7 retail-binary dispatch predicate. Returns true when an
/// entity's collision queries should go to its BSP exclusively, /// entity's collision queries should go to its BSP exclusively,
@ -716,24 +719,19 @@ public sealed class Transition
/// else /// else
/// // BSP-only via CPartArray::FindObjCollisions /// // BSP-only via CPartArray::FindObjCollisions
/// </code> /// </code>
/// where <c>ebp_1</c> is the PvP-target-player flag (lines 276808 /// where <c>ebp_1</c> is the PvP-target-player exemption and
/// 276841, see <see cref="PvpExempt"/>) and <c>eax_12</c> is the /// <c>eax_12</c> is <c>OBJECTINFO::missile_ignore</c>. Both are handled
/// <c>OBJECTINFO::missile_ignore</c> result (line 274385, see /// as whole-object early exits before this shape-dispatch predicate. The
/// <see cref="MissileIgnore"/>). The flag is named /// flag is named
/// <c>HAS_PHYSICS_BSP_PS = 0x10000</c> in acclient.h:2833 and /// <c>HAS_PHYSICS_BSP_PS = 0x10000</c> in acclient.h:2833 and
/// <c>PhysicsState.HasPhysicsBSP</c> in ACE /// <c>PhysicsState.HasPhysicsBSP</c> in ACE
/// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24). /// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24).
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// BSP-only iff: /// Once an object reaches shape dispatch, it is BSP-only exactly when
/// <c>HAS_PHYSICS_BSP_PS</c> is set on the entity /// <c>HAS_PHYSICS_BSP_PS</c> is set. The exemption gates have already
/// AND the mover is not PvP-exempt for this target (<see cref="PvpExempt"/> = false) /// returned <c>OK_TS</c> for the complete target object.
/// AND the entity is not missile-ignore exempt (<see cref="MissileIgnore"/> = false).
/// In M1.5 <see cref="PvpExempt"/> and <see cref="MissileIgnore"/> both
/// return false, so the predicate reduces to
/// <c>(state &amp; HAS_PHYSICS_BSP_PS) != 0</c> — no behavior change
/// from before W1.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -744,14 +742,9 @@ public sealed class Transition
/// <param name="entityState">The collision target entity's raw /// <param name="entityState">The collision target entity's raw
/// <c>PhysicsState</c> value (as stored on /// <c>PhysicsState</c> value (as stored on
/// <c>ShadowEntry.State</c>).</param> /// <c>ShadowEntry.State</c>).</param>
/// <returns>True when retail would dispatch BSP-only — i.e. when /// <returns>True when retail dispatches exclusively to the target BSP.</returns>
/// the entity has <c>HAS_PHYSICS_BSP_PS</c> set AND neither the PvP
/// exemption nor the missile-ignore exemption applies; cyl/sphere
/// shapes must be skipped at the per-entry dispatch site.</returns>
public static bool BspOnlyDispatch(uint entityState) public static bool BspOnlyDispatch(uint entityState)
=> (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0 => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0;
&& !PvpExempt()
&& !MissileIgnore();
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Public entry point // Public entry point
@ -840,15 +833,10 @@ public sealed class Transition
} }
} }
// Retail safety cap (30 steps). Viewer/sight objects bypass it, matching // No arbitrary step cap: retail find_transitional_position 0x0050BDF0
// retail: CTransition::find_transitional_position (acclient_2013_pseudo_c.txt // iterates every step returned by calc_num_steps 0x0050A0B0. The old
// :273613) has no cap, and calc_num_steps (:272149) has a dedicated viewer // acdream-only 30-step bail truncated valid 0.2-second missile sweeps
// branch `if ((state & 4) != 0)` at :272181. The A8.F camera spring-arm // (50 world units/s with a 0.1-unit sphere requires 100 steps).
// (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;
// Apply free rotation if requested. // Apply free rotation if requested.
if (ObjectInfo.FreeRotate) if (ObjectInfo.FreeRotate)
@ -1090,7 +1078,8 @@ public sealed class Transition
sp.CurOrientation = sp.CheckOrientation; sp.CurOrientation = sp.CheckOrientation;
for (int i = 0; i < sp.NumSphere; i++) 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; sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
} }
} }
@ -2586,10 +2575,29 @@ public sealed class Transition
return otherCellsState; return otherCellsState;
// Retarget the carried cell to the ordered-pick containing cell (retail check_cell = // 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 // var_4c, then adjust_check_pos). Our positions are world-space, so a carried
// refresh without moving the sphere. // 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 (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); sp.SetCheckPos(sp.CheckPos, containingCellId);
}
return TransitionState.OK; return TransitionState.OK;
} }
@ -2763,6 +2771,12 @@ public sealed class Transition
if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId) if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId)
continue; 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? // Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position; Vector3 deltaToCurr = currPos - obj.Position;
float distToCurr; float distToCurr;
@ -2954,10 +2968,9 @@ public sealed class Transition
// door-a6p6-v2.utf8.log. See investigation at // door-a6p6-v2.utf8.log. See investigation at
// docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md. // docs/research/2026-05-25-a6-door-cyl-retail-dispatch-investigation.md.
// //
// M1.5 scope: PvP exemption (ebp_1) and missile_ignore // The PvP-target exemption and OBJECTINFO::missile_ignore are
// (eax_12) are treated as false. Wire them through when // evaluated as whole-object early exits above this per-shape
// PK / missiles ship — matches retail's full predicate // dispatch, matching retail's full predicate at line 276861.
// at line 276861.
if (BspOnlyDispatch(obj.State)) if (BspOnlyDispatch(obj.State))
{ {
if (PhysicsDiagnostics.ProbeBuildingEnabled) if (PhysicsDiagnostics.ProbeBuildingEnabled)
@ -4725,7 +4738,8 @@ public sealed class Transition
// Cache the current-center spheres at the new position. // Cache the current-center spheres at the new position.
for (int i = 0; i < sp.NumSphere; i++) 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; sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
} }

View file

@ -15,7 +15,7 @@ namespace AcDream.Core.Tests.Physics;
/// as <c>HasPhysicsBSP = 0x00010000</c>. /// as <c>HasPhysicsBSP = 0x00010000</c>.
/// ///
/// <para> /// <para>
/// 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 /// entity with HAS_PHYSICS_BSP_PS in its state tests its BSP exclusively
/// — the foot cyl is NEVER tested. The closed cottage door (state /// — the foot cyl is NEVER tested. The closed cottage door (state
/// <c>0x10008</c> = <c>STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP</c>) /// <c>0x10008</c> = <c>STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP</c>)
@ -59,8 +59,8 @@ public class A6P7DispatchRulesTests
/// else /// else
/// → BSP-only path /// → BSP-only path
/// </code> /// </code>
/// For M1.5 scope <c>ebp_1</c> (PvP-target-player) and <c>eax_12</c> /// The PvP and missile terms are whole-object early exits in the port.
/// (missile_ignore) are treated as false. The predicate reduces to: /// Once shape dispatch is reached, the predicate is therefore:
/// "BSP-only iff <c>HAS_PHYSICS_BSP_PS</c> is set". /// "BSP-only iff <c>HAS_PHYSICS_BSP_PS</c> is set".
/// </summary> /// </summary>
[Theory] [Theory]
@ -76,44 +76,28 @@ public class A6P7DispatchRulesTests
Assert.Equal(expected, Transition.BspOnlyDispatch(entityState)); Assert.Equal(expected, Transition.BspOnlyDispatch(entityState));
} }
// -----------------------------------------------------------------------
// W1 (2026-06-24) — named PvP/missile terms are false in M1.5
// -----------------------------------------------------------------------
/// <summary> /// <summary>
/// W1: <see cref="Transition.PvpExempt"/> must return false in M1.5. /// A non-missile mover does not trigger the retail missile exemption.
/// When PK ships (M2+) this stub will accept mover + target state;
/// the test pins the current named-false value as a guard.
/// Retail oracle: <c>CPhysicsObj::FindObjCollisions</c> pc:276808276841.
/// </summary>
[Fact]
public void W1_PvpExempt_ReturnsFalseInM15Scope()
{
// PvP hasn't shipped (M1.5); the stub always returns false.
Assert.False(Transition.PvpExempt());
}
/// <summary>
/// W1: <see cref="Transition.MissileIgnore"/> 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.
/// Retail oracle: <c>OBJECTINFO::missile_ignore</c> pc:274385; /// Retail oracle: <c>OBJECTINFO::missile_ignore</c> pc:274385;
/// dispatch use pc:276858276861. /// dispatch use pc:276858276861.
/// </summary> /// </summary>
[Fact] [Fact]
public void W1_MissileIgnore_ReturnsFalseInM15Scope() public void NonMissileMover_DoesNotIgnoreOrdinaryTarget()
{ {
// Missiles haven't shipped (M1.5); the stub always returns false. var mover = new ObjectInfo();
Assert.False(Transition.MissileIgnore()); Assert.False(mover.MissileIgnore(
targetEntityId: 1u,
targetPhysicsState: 0u,
targetFlags: EntityCollisionFlags.HasWeenie));
} }
/// <summary> /// <summary>
/// Guard: with both W1 terms false, a BSP-flagged entity still /// Guard: a BSP-flagged entity that reaches shape dispatch still uses
/// dispatches BSP-only — A6.P7 door behavior is unchanged after W1. /// only its BSP — A6.P7 door behavior remains unchanged.
/// This protects the A6.P7 cottage-door / dungeon-wall fix. /// This protects the A6.P7 cottage-door / dungeon-wall fix.
/// </summary> /// </summary>
[Fact] [Fact]
public void W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly() public void BspOnlyDispatch_DoorStateStillDispatchesBspOnly()
{ {
// Cottage door state from A6.P7 investigation: 0x10008 = STATIC | REPORT | HAS_BSP // Cottage door state from A6.P7 investigation: 0x10008 = STATIC | REPORT | HAS_BSP
const uint doorState = 0x00010008u; const uint doorState = 0x00010008u;

View file

@ -19,6 +19,43 @@ public class HandleAllCollisionsTests
FramesStationaryFall = fsf, 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] [Fact]
public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent() public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent()
{ {

View file

@ -0,0 +1,94 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance table for retail <c>OBJECTINFO::missile_ignore</c>
/// (<c>0x0050CEB0</c>). These tests deliberately separate "has a weenie"
/// from "is a creature" because the retail branches do.
/// </summary>
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));
}
}

View file

@ -459,7 +459,7 @@ public sealed class PhysicsBodyTests
// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════
[Fact] [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(); var body = MakeAirborne();
body.Velocity = new Vector3(1f, 0f, 0f); body.Velocity = new Vector3(1f, 0f, 0f);
@ -470,6 +470,7 @@ public sealed class PhysicsBodyTests
body.update_object(PhysicsBody.MinQuantum * 0.5); body.update_object(PhysicsBody.MinQuantum * 0.5);
Assert.Equal(Vector3.Zero, body.Position); Assert.Equal(Vector3.Zero, body.Position);
Assert.Equal(0d, body.LastUpdateTime);
} }
[Fact] [Fact]
@ -516,6 +517,17 @@ public sealed class PhysicsBodyTests
Assert.Equal(t, body.LastUpdateTime, precision: 10); 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] [Fact]
public void update_object_gravity_free_fall_accumulates_downward_velocity() public void update_object_gravity_free_fall_accumulates_downward_velocity()
{ {

View file

@ -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;
/// <summary>
/// Pure-Core conformance and adversarial coverage for the retail projectile
/// step (<c>CPhysicsObj::update_object</c> 0x00515D10 through
/// <c>UpdateObjectInternal</c> 0x005156B0).
/// </summary>
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<ArgumentOutOfRangeException>(() =>
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<InvalidOperationException>(() =>
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<CellSurface>(),
System.Array.Empty<PortalPlane>(),
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<CellSurface>(),
System.Array.Empty<PortalPlane>(), 0f, 0f);
engine.AddLandblock(0xA8B4FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), -192f, 0f);
engine.AddLandblock(0xAAB4FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 192f, 0f);
engine.AddLandblock(0xA9B3FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 0f, -192f);
engine.AddLandblock(0xA9B5FFFFu, EmptyTerrain(), System.Array.Empty<CellSurface>(),
System.Array.Empty<PortalPlane>(), 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<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon> { [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);
}
}

View file

@ -217,8 +217,7 @@ public class TransitionTests
// Arrange: flat terrain at Z=10. The sphere starts in contact with the // Arrange: flat terrain at Z=10. The sphere starts in contact with the
// surface and moves horizontally. Because the terrain stays flat the // surface and moves horizontally. Because the terrain stays flat the
// Contact flag should persist and no step-down is needed. // Contact flag should persist and no step-down is needed.
// Movement distance is kept < MaxTransitionSteps * radius to avoid the // A 15-unit move at radius 1.0 requires 15 continuous-sweep steps.
// retail 30-step safety cap. With radius=1.0 and 15 units: 15 steps < 30.
const float groundZ = 10f; const float groundZ = 10f;
var terrain = FlatTerrain(groundZ); var terrain = FlatTerrain(groundZ);
var engine = MakeEngine(terrain); var engine = MakeEngine(terrain);
@ -284,31 +283,28 @@ public class TransitionTests
} }
[Fact] [Fact]
public void FindTransitionalPosition_LongSweep_ViewerBypassesStepCap() public void FindTransitionalPosition_LongSweep_HasNoInventedStepCap()
{ {
// A8.F: the 30-step safety cap bailed long sweeps. Retail's // Retail find_transitional_position 0x0050BDF0 iterates every step
// find_transitional_position has no cap and handles viewers specially // returned by calc_num_steps. radius 0.3, dist 12 => 40 steps.
// (calc_num_steps `state & 4` branch, acclient :272181). The camera
// spring arm (IsViewer) must not bail. radius 0.3, dist 12 → 40 steps > 30.
const float groundZ = 10f; const float groundZ = 10f;
var engine = MakeEngine(FlatTerrain(groundZ)); var engine = MakeEngine(FlatTerrain(groundZ));
Vector3 from = new(50f, 50f, groundZ); Vector3 from = new(50f, 50f, groundZ);
Vector3 to = new(62f, 50f, groundZ); // 12 units → 40 steps at r=0.3 (>30 cap) 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); var normal = MakeTransition(from, to, sphereRadius: 0.3f);
bool normalOk = normal.FindTransitionalPosition(engine); 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); var viewer = MakeTransition(from, to, sphereRadius: 0.3f);
viewer.ObjectInfo.State |= ObjectInfoState.IsViewer; viewer.ObjectInfo.State |= ObjectInfoState.IsViewer;
bool viewerOk = viewer.FindTransitionalPosition(engine); bool viewerOk = viewer.FindTransitionalPosition(engine);
Assert.True(viewerOk); Assert.True(viewerOk);
// Position must have advanced toward `to` (key invariant: viewer proceeds).
Assert.True(viewer.SpherePath.CurPos.X > from.X, 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] [Fact]