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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue