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