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