feat(physics): integrate live projectile runtime
Attach the retail projectile driver to canonical LiveEntityRecord ownership, sharing one PhysicsBody and full-cell identity with RemoteMotion regardless of creation order. Apply timestamp-gated state, vector, and position corrections in place, preserve active ordinary-body behavior when Missile clears, and keep renderer, effects, shadows, and spatial buckets synchronized across loaded/pending transitions. Validate malformed packets before canonical timestamp mutation, validate adopted bodies from their current frame rather than stale CreateObject data, serialize late Setup resolution with streaming DAT reads, and preserve classification across clock anomalies. Retain shadow registrations through temporary leave-world residence while logical teardown remains generation-scoped. Add 31 App lifecycle/adversarial tests plus Core shadow suspension coverage, and synchronize the retail pseudocode, architecture, milestones, roadmap, and durable physics memory. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
f02b995e4f
commit
a51ebc66e9
12 changed files with 2559 additions and 32 deletions
|
|
@ -179,6 +179,8 @@ src/
|
|||
ISelectionService.cs -> done
|
||||
|
||||
AcDream.App/ Layer 1 + Layer 4 wiring
|
||||
Physics/
|
||||
ProjectileController.cs -> live-record projectile orchestration/corrections
|
||||
Rendering/
|
||||
GameWindow.cs -> still owns too much runtime wiring
|
||||
TerrainRenderer.cs -> done
|
||||
|
|
@ -224,6 +226,28 @@ What exists and is active:
|
|||
AlignPath, Setup-sphere transition sweeps, and collision response. It owns no
|
||||
live entity, network, rendering, or App state; the App controller supplies
|
||||
those boundaries.
|
||||
- `ProjectileController` is that App boundary. It installs one body on the
|
||||
canonical `LiveEntityRecord`, advances it on the update/render thread,
|
||||
commits through `WorldEntity.SetPosition`, publishes the effect root,
|
||||
synchronizes the existing shadow, and calls `RebucketLiveEntity` only when
|
||||
the full cell changes. Pending/leave-world residence suspends the shadow's
|
||||
cell rows while retaining its registration for exact re-entry. Timestamp-
|
||||
gated State/Vector/Position/Movement packets correct or stop that same body;
|
||||
every production MovementManager delegates full-cell reads/writes to the
|
||||
incarnation-scoped live record, and projectile acquisition adopts its body
|
||||
without replaying vectors already installed when that body was created,
|
||||
regardless of component creation order. Accepted SetState flags reach that
|
||||
canonical body before optional projectile acquisition, so absent/unsupported
|
||||
DAT shape data cannot leave collision state stale; a non-finite local receipt
|
||||
clock uses the controller's last finite game-clock value and cannot consume
|
||||
first-time Missile classification. Shared-body acquisition validates the
|
||||
adopted body's current frame/vectors, not obsolete CreateObject inputs.
|
||||
Initial and late Setup resolution share GameWindow's `_datLock`, preserving
|
||||
the single-reader `DatCollection` invariant while streaming is active.
|
||||
Generic remote translation is suppressed while projectile integration
|
||||
owns the frame;
|
||||
delete, generation replacement, pickup/parent leave-world, and session reset
|
||||
use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map.
|
||||
- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
|
||||
the transition path.
|
||||
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
|
||||
|
|
|
|||
|
|
@ -516,6 +516,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- ~~**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.
|
||||
- **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion.
|
||||
|
||||
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
|
||||
|
||||
|
|
|
|||
|
|
@ -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–6
|
||||
- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–7
|
||||
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
|
||||
|
|
@ -515,9 +515,19 @@ include dungeons.
|
|||
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.
|
||||
TS-11, and TS-12 are retired; AP-83/AP-91 remain dormant. Step 7 attaches
|
||||
that primitive to the canonical `LiveEntityRecord`: arrows, bolts, and spell
|
||||
missiles share one body and one renderer identity across prediction,
|
||||
authoritative State/Vector/Position/Movement corrections, full-cell
|
||||
rebucketing, pending-landblock residence, delete, session reset, and GUID
|
||||
reuse. MovementManager presentation shares the same body and the canonical
|
||||
live-record cell regardless of which component was created first;
|
||||
pending/leave-world shadows retain their registration for exact re-entry even
|
||||
if Missile classification changes while pending; static missile effect
|
||||
roots follow every predicted frame, and materialized rehydration never uses a
|
||||
stale spawn cell. No impact, damage, effect, or delete event is synthesized
|
||||
client-side. The remaining steps port
|
||||
Hidden/UnHide portal presentation and run final hardening/visual gates.
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -872,3 +872,105 @@ observable ordering without exposing a half-committed pose to modern sinks.
|
|||
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.
|
||||
|
||||
## 16. Step 7 live integration map (2026-07-14)
|
||||
|
||||
The App integration preserves the same retail object across every spatial and
|
||||
network operation:
|
||||
|
||||
```text
|
||||
after materialized CreateObject:
|
||||
if final PhysicsState has never carried Missile: no projectile component
|
||||
if Setup does not have the audited one ordinary sphere: diagnose, no guess
|
||||
otherwise:
|
||||
construct one PhysicsBody, or adopt the same body already owned by the
|
||||
object's MovementManager
|
||||
validate the inbound frame/vectors, canonicalize the outdoor cell,
|
||||
retain valid friction in [0,1], and clamp elasticity to [0,0.1]
|
||||
apply velocity through set_velocity (immediate 50-unit/second clamp)
|
||||
bind it to LiveEntityRecord.ProjectileRuntime
|
||||
|
||||
when MovementManager and projectile classification arrive in either order:
|
||||
bind MovementManager cell reads/writes to this exact live-record incarnation
|
||||
share its PhysicsBody with ProjectileRuntime
|
||||
install CreateObject velocity/omega when the ordinary body is constructed
|
||||
if projectile classification arrives second, preserve that body's CURRENT
|
||||
vectors/Active state, validate that current body rather than stale
|
||||
CreateObject vectors, normalize its current cell frame, and translate
|
||||
its clock to the App game clock without replaying retained vectors;
|
||||
a safely ignored malformed CreateObject vector cannot veto a later
|
||||
finite body, while a non-finite current body cannot enter simulation
|
||||
|
||||
each frame:
|
||||
for each canonical live record with a projectile component:
|
||||
skip/clear Active through update_object when parented, cell-less,
|
||||
Frozen, Static, or out of world
|
||||
if the object has a PartArray, clear Active beyond the retail
|
||||
96-world-unit player-distance boundary; reactivation stamps now
|
||||
run ProjectilePhysicsStepper with local WorldEntity.Id for self-shadow
|
||||
exclusion and target id zero
|
||||
if this exact record generation still owns this exact component:
|
||||
WorldEntity.SetPosition(resolved body frame)
|
||||
update orientation and full parent cell
|
||||
RebucketLiveEntity only when the full cell changed
|
||||
move the existing collision shadow to the resolved frame
|
||||
publish the new root to the effect-pose owner
|
||||
if Missile is now clear and MovementManager exists: that ordinary owner
|
||||
advances the shared body instead
|
||||
if Missile is clear with no MovementManager: keep advancing the active
|
||||
retained CPhysicsObj; collision reads final state, so missile_ignore
|
||||
and Missile-derived PathClipped are disabled automatically
|
||||
|
||||
fresh State/Vector/Position/Movement packet:
|
||||
reject structurally invalid Position/Vector values before advancing the
|
||||
snapshot or timestamp, even if Missile classification has not arrived
|
||||
first pass the existing per-channel retail timestamp gate
|
||||
State: update the canonical body first, even when missile acquisition is
|
||||
impossible because Setup or sphere data is unavailable; the accepted
|
||||
SetState collision flags must never remain only on the live record.
|
||||
A non-finite local receipt clock does not discard first classification:
|
||||
bind against the controller's last finite game clock (the clock epoch
|
||||
is zero before the first frame), while keeping the wire state authoritative.
|
||||
Update this body without newly activating it; removing Missile
|
||||
disables missile classification but retains the component/body on this
|
||||
object. MovementManager becomes the update owner when present;
|
||||
otherwise the retained active CPhysicsObj continues ordinary physics.
|
||||
A later Missile state reuses that same current body and cell;
|
||||
align the projectile clock at that ownership transfer, preserve the
|
||||
existing Active bit, and never activate a body that was already inactive
|
||||
Vector: set_velocity + omega on this body; clamp immediately and stamp the
|
||||
receipt clock only when the packet reactivates an inactive object; if
|
||||
Missile is currently clear and MovementManager exists, its ordinary
|
||||
Airborne/LeaveGround funnel owns those writes on the shared body
|
||||
Position: hard-correct world frame plus canonical wire cell-local frame,
|
||||
shadow, spatial bucket, and effect pose; do not overwrite velocity
|
||||
Movement: unpack through the normal MovementManager/animation funnel while
|
||||
sharing this exact PhysicsBody and the live record's canonical cell;
|
||||
projectile stepping suppresses the generic remote translation tick,
|
||||
so there is still only one integrator and one spatial authority
|
||||
|
||||
leave_world (pickup/parent):
|
||||
clear InWorld and Active, withdraw collision shadows but retain their exact
|
||||
registration payload and the component with the incarnation
|
||||
fresh Position re-entry restores the same shadows, stamps the current
|
||||
physics clock, and never consumes the time spent outside the world
|
||||
|
||||
loaded/pending transition:
|
||||
the rebucket result is checked in the same physics quantum
|
||||
pending clears InWorld/Active and suspends shadows without teardown
|
||||
hydration restores body/shadow membership even if Missile was cleared while
|
||||
pending; projectile integration itself remains disabled
|
||||
landblock rehydration keys materialized movers by LiveEntityRecord.FullCellId,
|
||||
never by the retained create-time Snapshot.Position
|
||||
|
||||
DeleteObject / generation replacement / session reset:
|
||||
normal LiveEntityRuntime teardown destroys the owning record; the
|
||||
projectile controller has no second GUID dictionary to clear
|
||||
```
|
||||
|
||||
`ProjectileController` runs before animation advancement in the render/update
|
||||
phase and publishes its current root directly; an animated projectile then
|
||||
publishes final part poses later in the same frame. PhysicsScript/particle/light
|
||||
ticks therefore observe the projectile's current predicted root. The controller never
|
||||
creates impact effects, collision messages, damage, or deletion; ACE's later
|
||||
packets remain authoritative for every gameplay outcome.
|
||||
|
|
|
|||
744
src/AcDream.App/Physics/ProjectileController.cs
Normal file
744
src/AcDream.App/Physics/ProjectileController.cs
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread presentation owner for live retail missiles. Logical identity,
|
||||
/// generation, accepted network state, and teardown remain owned by
|
||||
/// <see cref="LiveEntityRuntime"/>; this controller only advances the
|
||||
/// <see cref="PhysicsBody"/> attached to that canonical record and commits its
|
||||
/// frame through the existing <see cref="WorldEntity"/> projection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail anchors are <c>CPhysicsObj::update_object</c> <c>0x00515D10</c>,
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c>, and
|
||||
/// <c>CPhysicsObj::SetPositionInternal</c> <c>0x00515330</c>. The pure
|
||||
/// integration/sweep is implemented by <see cref="ProjectilePhysicsStepper"/>.
|
||||
/// ACE remains authoritative for collision outcomes, damage, effects, and
|
||||
/// deletion; no such events are synthesized here.
|
||||
/// </remarks>
|
||||
internal sealed class ProjectileController
|
||||
{
|
||||
internal sealed class Runtime : ILiveEntityProjectileRuntime
|
||||
{
|
||||
internal Runtime(
|
||||
ushort generation,
|
||||
PhysicsBody body,
|
||||
ProjectileCollisionSphere collisionSphere,
|
||||
bool hasPartArray)
|
||||
{
|
||||
Generation = generation;
|
||||
Body = body;
|
||||
CollisionSphere = collisionSphere;
|
||||
HasPartArray = hasPartArray;
|
||||
}
|
||||
|
||||
internal ushort Generation { get; }
|
||||
public PhysicsBody Body { get; }
|
||||
internal ProjectileCollisionSphere CollisionSphere { get; }
|
||||
internal bool HasPartArray { get; }
|
||||
}
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ProjectilePhysicsStepper _stepper;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<uint, Setup?> _setupResolver;
|
||||
private readonly Action<WorldEntity> _publishRootPose;
|
||||
private double _lastFiniteGameTime;
|
||||
|
||||
internal ProjectileController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
PhysicsEngine physicsEngine,
|
||||
Func<uint, Setup?>? setupResolver = null,
|
||||
Action<WorldEntity>? publishRootPose = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
ArgumentNullException.ThrowIfNull(physicsEngine);
|
||||
_stepper = new ProjectilePhysicsStepper(physicsEngine);
|
||||
_shadows = physicsEngine.ShadowObjects;
|
||||
_setupResolver = setupResolver ?? (_ => null);
|
||||
_publishRootPose = publishRootPose ?? (_ => { });
|
||||
}
|
||||
|
||||
internal Action<string>? DiagnosticSink { get; set; }
|
||||
|
||||
internal int Count => _liveEntities.Records.Count(
|
||||
static record => record.ProjectileRuntime is Runtime);
|
||||
|
||||
internal bool CanAcceptVectorPayload(
|
||||
uint serverGuid,
|
||||
Vector3 velocity,
|
||||
Vector3 angularVelocity)
|
||||
{
|
||||
bool valid = IsFinite(velocity) && IsFinite(angularVelocity);
|
||||
if (!valid)
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Rejected invalid VectorUpdate for live object 0x{serverGuid:X8}.");
|
||||
return valid;
|
||||
}
|
||||
|
||||
internal bool CanAcceptPositionPayload(
|
||||
uint serverGuid,
|
||||
CreateObject.ServerPosition position,
|
||||
Vector3? velocity)
|
||||
{
|
||||
var origin = new Vector3(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ);
|
||||
var orientation = new Quaternion(
|
||||
position.RotationX,
|
||||
position.RotationY,
|
||||
position.RotationZ,
|
||||
position.RotationW);
|
||||
bool valid = IsFinite(origin)
|
||||
&& PositionFrameValidation.IsValid(
|
||||
position.LandblockId,
|
||||
origin,
|
||||
orientation)
|
||||
&& (velocity is null || IsFinite(velocity.Value));
|
||||
if (!valid)
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Rejected invalid PositionUpdate for live object 0x{serverGuid:X8}.");
|
||||
return valid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs a projectile body once, after the live record and render
|
||||
/// projection exist. Re-entry returns the same component and never replays
|
||||
/// logical registration or create-time effects.
|
||||
/// </summary>
|
||||
internal bool TryBind(
|
||||
LiveEntityRecord record,
|
||||
Setup setup,
|
||||
double currentTime,
|
||||
int liveCenterX = 0,
|
||||
int liveCenterY = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
return false;
|
||||
|
||||
if (record.ProjectileRuntime is Runtime retained)
|
||||
{
|
||||
retained.Body.State = record.FinalPhysicsState;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.WorldEntity is not { } entity
|
||||
|| record.Snapshot.Physics is not { } physics
|
||||
|| record.Snapshot.Position is not { } wirePosition)
|
||||
return false;
|
||||
|
||||
float scale = physics.Scale ?? record.Snapshot.ObjScale ?? 1f;
|
||||
if (!TryGetCollisionSphere(setup, scale, out ProjectileCollisionSphere sphere))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Missile 0x{record.ServerGuid:X8} Setup 0x{record.Snapshot.SetupTableId ?? 0u:X8} " +
|
||||
$"does not have the supported retail one-sphere collision shape.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(currentTime))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Missile 0x{record.ServerGuid:X8} has an invalid physics clock.");
|
||||
return false;
|
||||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body;
|
||||
if (record.RemoteMotionRuntime is { } remote)
|
||||
{
|
||||
// Retail owns one CPhysicsObj. If a non-missile incarnation
|
||||
// already created its MovementManager, classification adopts that
|
||||
// same body instead of replacing it.
|
||||
body = remote.Body;
|
||||
uint currentCellId = record.FullCellId;
|
||||
Vector3 currentCellLocal = CellLocalFromWorld(
|
||||
body.Position,
|
||||
currentCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
if (!IsFinite(body.Position)
|
||||
|| !IsFinite(body.Velocity)
|
||||
|| !IsFinite(body.Omega)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
currentCellId,
|
||||
currentCellLocal,
|
||||
body.Orientation))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Missile 0x{record.ServerGuid:X8} has an invalid current shared physics frame or vector.");
|
||||
return false;
|
||||
}
|
||||
body.State = record.FinalPhysicsState;
|
||||
body.Friction = NormalizeFriction(
|
||||
physics.Friction ?? record.Snapshot.Friction);
|
||||
body.Elasticity = NormalizeElasticity(
|
||||
physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f);
|
||||
|
||||
// The MovementManager can predate missile classification and may
|
||||
// already have advanced beyond the retained CreateObject frame.
|
||||
// Preserve its current velocity, omega, orientation, and Active
|
||||
// state exactly as retail SetState does. Only normalize the
|
||||
// carried cell frame and translate the legacy remote clock onto
|
||||
// the App projectile clock.
|
||||
body.SnapToCell(
|
||||
currentCellId,
|
||||
body.Position,
|
||||
currentCellLocal);
|
||||
body.LastUpdateTime = currentTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
Vector3 velocity = physics.Velocity ?? Vector3.Zero;
|
||||
Vector3 omega = physics.AngularVelocity ?? Vector3.Zero;
|
||||
if (!IsFinite(entity.Position)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
wirePosition.LandblockId,
|
||||
new Vector3(
|
||||
wirePosition.PositionX,
|
||||
wirePosition.PositionY,
|
||||
wirePosition.PositionZ),
|
||||
entity.Rotation)
|
||||
|| !IsFinite(velocity)
|
||||
|| !IsFinite(omega))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector.");
|
||||
return false;
|
||||
}
|
||||
body = new PhysicsBody
|
||||
{
|
||||
State = record.FinalPhysicsState,
|
||||
Friction = NormalizeFriction(
|
||||
physics.Friction ?? record.Snapshot.Friction),
|
||||
Elasticity = NormalizeElasticity(
|
||||
physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f),
|
||||
Orientation = entity.Rotation,
|
||||
LastUpdateTime = currentTime,
|
||||
};
|
||||
SetVelocity(body, velocity, currentTime);
|
||||
body.Omega = omega;
|
||||
body.SnapToCell(
|
||||
wirePosition.LandblockId,
|
||||
entity.Position,
|
||||
new Vector3(
|
||||
wirePosition.PositionX,
|
||||
wirePosition.PositionY,
|
||||
wirePosition.PositionZ));
|
||||
}
|
||||
|
||||
uint canonicalCellId = body.CellPosition.ObjCellId;
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId);
|
||||
|
||||
var runtime = new Runtime(
|
||||
record.Generation,
|
||||
body,
|
||||
sphere,
|
||||
entity.MeshRefs.Count > 0);
|
||||
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
||||
|
||||
if (HasVisibleCell(record))
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
canonicalCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
_publishRootPose(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool ApplyAuthoritativeVector(
|
||||
uint serverGuid,
|
||||
Vector3 velocity,
|
||||
Vector3 angularVelocity,
|
||||
double currentTime)
|
||||
{
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime))
|
||||
return false;
|
||||
|
||||
// Once Missile is cleared, an existing MovementManager owns ordinary
|
||||
// jump/vector side effects (Airborne, gravity, LeaveGround) on this
|
||||
// same body. Let that established funnel consume the packet. A
|
||||
// projectile-only object still needs the unconditional retail
|
||||
// set_velocity/set_omega writes here.
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|
||||
&& record.RemoteMotionRuntime is not null)
|
||||
return false;
|
||||
|
||||
if (!IsFinite(velocity)
|
||||
|| !IsFinite(angularVelocity)
|
||||
|| !double.IsFinite(currentTime))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Rejected invalid VectorUpdate for missile 0x{serverGuid:X8}.");
|
||||
return true;
|
||||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
SetVelocity(runtime.Body, velocity, currentTime);
|
||||
runtime.Body.Omega = angularVelocity;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid))
|
||||
Deactivate(runtime.Body);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool ApplyAuthoritativeState(
|
||||
uint serverGuid,
|
||||
PhysicsStateFlags state,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
|
||||
return false;
|
||||
|
||||
bool validClock = double.IsFinite(currentTime);
|
||||
double effectiveClock = validClock
|
||||
? currentTime
|
||||
: _lastFiniteGameTime;
|
||||
if (validClock)
|
||||
_lastFiniteGameTime = currentTime;
|
||||
if (!validClock)
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Ignored invalid State update clock for live object 0x{serverGuid:X8}; state remains authoritative.");
|
||||
|
||||
if (record.ProjectileRuntime is Runtime runtime)
|
||||
{
|
||||
bool wasMissile =
|
||||
(runtime.Body.State & PhysicsStateFlags.Missile) != 0;
|
||||
runtime.Body.State = state;
|
||||
if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile)
|
||||
{
|
||||
// Ownership transfers back from generic remote motion to the
|
||||
// absolute projectile clock. State alone must not activate,
|
||||
// but an already-active body keeps moving from now rather
|
||||
// than replaying the ordinary-motion interval. A malformed
|
||||
// local receipt clock is not retail state and cannot clear
|
||||
// Active; translate to the last finite App clock instead.
|
||||
runtime.Body.LastUpdateTime = effectiveClock;
|
||||
if (record.FullCellId != 0)
|
||||
{
|
||||
runtime.Body.SnapToCell(
|
||||
record.FullCellId,
|
||||
runtime.Body.Position,
|
||||
CellLocalFromWorld(
|
||||
runtime.Body.Position,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// SetState applies to the canonical CPhysicsObj independently of
|
||||
// whether this state also makes projectile-component acquisition
|
||||
// possible. Missing DAT/shape data must never leave the existing body
|
||||
// on stale collision flags.
|
||||
if (record.RemoteMotionRuntime is { } remote)
|
||||
remote.Body.State = state;
|
||||
|
||||
if ((state & PhysicsStateFlags.Missile) == 0
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
||||
// A newer State packet can make an already materialized object a
|
||||
// missile. TryBind adopts an existing MovementManager body so the
|
||||
// live record still represents retail's one CPhysicsObj.
|
||||
Setup? setup = _setupResolver(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null)
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Cannot activate missile 0x{serverGuid:X8}: Setup 0x{entity.SourceGfxObjOrSetupId:X8} is unavailable.");
|
||||
return true;
|
||||
}
|
||||
|
||||
TryBind(record, setup, effectiveClock, liveCenterX, liveCenterY);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a timestamp-gated server correction to the same predicted body.
|
||||
/// The caller supplies both render-world and wire cell-local coordinates so
|
||||
/// streaming origin changes never leak into the canonical cell frame.
|
||||
/// </summary>
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
uint serverGuid,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
||||
if (!double.IsFinite(currentTime)
|
||||
|| !IsFinite(worldPosition)
|
||||
|| !IsFinite(cellLocalPosition)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
fullCellId,
|
||||
cellLocalPosition,
|
||||
orientation))
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
$"Rejected invalid PositionUpdate for missile 0x{serverGuid:X8}.");
|
||||
return true;
|
||||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body = runtime.Body;
|
||||
bool wasInWorld = body.InWorld;
|
||||
body.Orientation = orientation;
|
||||
body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
|
||||
body.State = record.FinalPhysicsState;
|
||||
uint canonicalCellId = body.CellPosition.ObjCellId;
|
||||
|
||||
entity.SetPosition(worldPosition);
|
||||
entity.Rotation = orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
|
||||
if (HasVisibleCell(record))
|
||||
{
|
||||
if (!wasInWorld)
|
||||
Activate(body, currentTime);
|
||||
body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
worldPosition,
|
||||
orientation,
|
||||
canonicalCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
_publishRootPose(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement packets use the ordinary MovementManager/animation funnel on
|
||||
/// this same body. The generic remote translation tick consults this gate
|
||||
/// so only the projectile integrator commits its root frame.
|
||||
/// </summary>
|
||||
internal bool HandlesMovement(uint serverGuid) =>
|
||||
TryGetCurrent(serverGuid, out LiveEntityRecord record, out _)
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
||||
|
||||
internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
|
||||
{
|
||||
if (TryGetCurrent(serverGuid, out _, out Runtime runtime))
|
||||
{
|
||||
body = runtime.Body;
|
||||
return true;
|
||||
}
|
||||
body = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal bool LeaveWorld(uint serverGuid)
|
||||
{
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void Tick(
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Vector3? playerWorldPosition)
|
||||
{
|
||||
if (!double.IsFinite(currentTime))
|
||||
{
|
||||
DiagnosticSink?.Invoke("Rejected non-finite projectile update clock.");
|
||||
return;
|
||||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
foreach (LiveEntityRecord record in _liveEntities.Records.ToArray())
|
||||
{
|
||||
if (record.ProjectileRuntime is not Runtime runtime
|
||||
|| runtime.Generation != record.Generation
|
||||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
bool isMissile =
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
||||
if (!isMissile)
|
||||
{
|
||||
// World entry/exit is independent of Missile classification.
|
||||
// A projectile can enter a pending bucket, lose Missile, then
|
||||
// hydrate as an ordinary live object. Restore its retained
|
||||
// shadow/body membership without reactivating projectile
|
||||
// integration or consuming the out-of-world clock gap.
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
// A retained body without MovementManager still remains an
|
||||
// active CPhysicsObj after Missile is cleared. Continue the
|
||||
// same one-sphere update path; PhysicsEngine reads the final
|
||||
// state and therefore disables missile_ignore automatically.
|
||||
if (record.RemoteMotionRuntime is not null)
|
||||
continue;
|
||||
}
|
||||
|
||||
runtime.Body.State = record.FinalPhysicsState;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
else
|
||||
Deactivate(runtime.Body); // Frozen retains its cell/shadow.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
Activate(runtime.Body, currentTime);
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
// CPhysicsObj::update_object 0x00515D4D-0x00515DB2 keeps
|
||||
// rendered objects active only within 96 world units of the
|
||||
// player. Objects with no PartArray are exempt.
|
||||
if (runtime.HasPartArray && playerWorldPosition is { } playerPosition)
|
||||
{
|
||||
float playerDistance = Vector3.Distance(runtime.Body.Position, playerPosition);
|
||||
if (!float.IsFinite(playerDistance) || playerDistance > 96f)
|
||||
{
|
||||
Deactivate(runtime.Body);
|
||||
continue;
|
||||
}
|
||||
Activate(runtime.Body, currentTime);
|
||||
}
|
||||
|
||||
bool isParented = record.Snapshot.ParentGuid is not null
|
||||
|| record.Snapshot.Physics?.Parent is not null;
|
||||
ProjectileAdvanceResult result = _stepper.Advance(
|
||||
runtime.Body,
|
||||
currentTime,
|
||||
record.FullCellId,
|
||||
runtime.CollisionSphere,
|
||||
entity.Id,
|
||||
designatedTargetId: 0u,
|
||||
isParented);
|
||||
|
||||
// An owner can be deleted/replaced by a callback reached from a
|
||||
// future collision sink. Never commit a completed step into a new
|
||||
// incarnation that reused the same server GUID.
|
||||
if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime currentRuntime)
|
||||
|| !ReferenceEquals(runtime, currentRuntime)
|
||||
|| !ReferenceEquals(record, current)
|
||||
|| !result.Simulated)
|
||||
continue;
|
||||
|
||||
uint priorCellId = record.FullCellId;
|
||||
uint resolvedCellId = result.CellId != 0
|
||||
? result.CellId
|
||||
: priorCellId;
|
||||
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != priorCellId)
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, resolvedCellId);
|
||||
runtime.Body.SnapToCell(
|
||||
resolvedCellId,
|
||||
runtime.Body.Position,
|
||||
CellLocalFromWorld(
|
||||
runtime.Body.Position,
|
||||
resolvedCellId,
|
||||
liveCenterX,
|
||||
liveCenterY));
|
||||
|
||||
// A successful transition can cross into an unloaded landblock.
|
||||
// Rebucket first, then make the physics/shadow decision from the
|
||||
// resulting projection state in this same update quantum. Retail
|
||||
// never leaves a CPhysicsObj active in a cell that is outside the
|
||||
// current object-maintenance world.
|
||||
if (HasVisibleCell(record))
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasVisibleCell(LiveEntityRecord record) =>
|
||||
record.IsSpatiallyProjected
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0;
|
||||
|
||||
private void SuspendOutsideWorld(Runtime runtime, uint localEntityId)
|
||||
{
|
||||
runtime.Body.InWorld = false;
|
||||
Deactivate(runtime.Body);
|
||||
_shadows.Suspend(localEntityId);
|
||||
}
|
||||
|
||||
private static void Activate(PhysicsBody body, double currentTime)
|
||||
{
|
||||
if ((body.State & PhysicsStateFlags.Static) != 0)
|
||||
return;
|
||||
if ((body.TransientState & TransientStateFlags.Active) == 0)
|
||||
body.LastUpdateTime = currentTime;
|
||||
body.TransientState |= TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
private static void Deactivate(PhysicsBody body) =>
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
|
||||
private static void SetVelocity(
|
||||
PhysicsBody body,
|
||||
Vector3 velocity,
|
||||
double currentTime)
|
||||
{
|
||||
bool wasActive = (body.TransientState & TransientStateFlags.Active) != 0;
|
||||
body.set_velocity(velocity);
|
||||
if ((body.State & PhysicsStateFlags.Static) != 0)
|
||||
{
|
||||
Deactivate(body);
|
||||
return;
|
||||
}
|
||||
if (!wasActive && (body.State & PhysicsStateFlags.Static) == 0)
|
||||
body.LastUpdateTime = currentTime;
|
||||
}
|
||||
|
||||
private static float NormalizeFriction(float? value) =>
|
||||
value is >= 0f and <= 1f && float.IsFinite(value.Value)
|
||||
? value.Value
|
||||
: PhysicsBody.DefaultFriction;
|
||||
|
||||
private static float NormalizeElasticity(float value)
|
||||
{
|
||||
if (float.IsNaN(value) || value <= 0f)
|
||||
return 0f;
|
||||
return MathF.Min(value, 0.1f);
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
float.IsFinite(value.X)
|
||||
&& float.IsFinite(value.Y)
|
||||
&& float.IsFinite(value.Z);
|
||||
|
||||
private static Vector3 CellLocalFromWorld(
|
||||
Vector3 worldPosition,
|
||||
uint cellId,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
return worldPosition - new Vector3(
|
||||
(landblockX - liveCenterX) * 192f,
|
||||
(landblockY - liveCenterY) * 192f,
|
||||
0f);
|
||||
}
|
||||
|
||||
private bool TryGetCurrent(
|
||||
uint serverGuid,
|
||||
out LiveEntityRecord record,
|
||||
out Runtime runtime)
|
||||
{
|
||||
if (_liveEntities.TryGetRecord(serverGuid, out record!)
|
||||
&& record.ProjectileRuntime is Runtime found
|
||||
&& found.Generation == record.Generation)
|
||||
{
|
||||
runtime = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
record = null!;
|
||||
runtime = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetCollisionSphere(
|
||||
Setup setup,
|
||||
float scale,
|
||||
out ProjectileCollisionSphere sphere)
|
||||
{
|
||||
// Installed DAT audit in 2026-07-13-retail-projectile-vfx-pseudocode.md
|
||||
// pins every required arrow, bolt, and spell projectile to one ordinary
|
||||
// Setup sphere (including Force Bolt's non-centered origin). Do not
|
||||
// fabricate a mesh-derived radius for an unsupported Setup.
|
||||
if (setup.Spheres.Count == 1)
|
||||
{
|
||||
var source = setup.Spheres[0];
|
||||
sphere = new ProjectileCollisionSphere(source.Origin, source.Radius, scale);
|
||||
if (sphere.IsValid)
|
||||
return true;
|
||||
}
|
||||
|
||||
sphere = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
39
src/AcDream.App/Physics/RemotePhysicsBodyInitializer.cs
Normal file
39
src/AcDream.App/Physics/RemotePhysicsBodyInitializer.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Applies CreateObject physics vectors when the ordinary remote-motion body
|
||||
/// is first constructed. This keeps initialization at object/body creation;
|
||||
/// a later SetState classification change never replays retained vectors.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail <c>CPhysicsObj::set_description</c> <c>0x00514F40</c> installs the
|
||||
/// unpacked velocity/omega during object initialization. Retail
|
||||
/// <c>SmartBox::DoSetState</c> <c>0x004520D0</c> only calls
|
||||
/// <c>CPhysicsObj::set_state</c> and therefore preserves the current vectors.
|
||||
/// </remarks>
|
||||
internal static class RemotePhysicsBodyInitializer
|
||||
{
|
||||
internal static void Initialize(PhysicsBody body, LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
body.State = record.FinalPhysicsState;
|
||||
if (record.Snapshot.Physics is not { } physics)
|
||||
return;
|
||||
|
||||
if (physics.Velocity is { } velocity && IsFinite(velocity))
|
||||
body.set_velocity(velocity);
|
||||
if (physics.AngularVelocity is { } omega && IsFinite(omega))
|
||||
body.Omega = omega;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
float.IsFinite(value.X)
|
||||
&& float.IsFinite(value.Y)
|
||||
&& float.IsFinite(value.Z);
|
||||
}
|
||||
|
|
@ -159,6 +159,9 @@ public sealed class GameWindow : IDisposable
|
|||
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
||||
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
||||
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
||||
// Step 7 projectile presentation. The controller owns no identity map;
|
||||
// each runtime component is stored on the canonical LiveEntityRecord.
|
||||
private AcDream.App.Physics.ProjectileController? _projectileController;
|
||||
|
||||
// Step 4: portal-based interior cell visibility.
|
||||
private readonly CellVisibility _cellVisibility = new();
|
||||
|
|
@ -456,7 +459,9 @@ public sealed class GameWindow : IDisposable
|
|||
/// remote gets the same treatment as the local player.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion : AcDream.App.World.ILiveEntityRemoteMotionRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
internal sealed class RemoteMotion :
|
||||
AcDream.App.World.ILiveEntityRemoteMotionRuntime,
|
||||
AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body;
|
||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||
|
|
@ -586,7 +591,27 @@ public sealed class GameWindow : IDisposable
|
|||
/// UP lands, which disables the transition step for that frame
|
||||
/// (Euler alone, matching pre-wire behavior).
|
||||
/// </summary>
|
||||
public uint CellId;
|
||||
private uint _cellId;
|
||||
private Func<uint>? _canonicalCellReader;
|
||||
private Action<uint>? _canonicalCellWriter;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
|
||||
/// retain the local backing value. A MovementManager attached to an
|
||||
/// existing projectile delegates reads and writes to
|
||||
/// <see cref="LiveEntityRuntime"/>, so projectile prediction,
|
||||
/// Movement packets, and spatial rebucketing cannot develop competing
|
||||
/// cell identities.
|
||||
/// </summary>
|
||||
public uint CellId
|
||||
{
|
||||
get => _canonicalCellReader?.Invoke() ?? _cellId;
|
||||
set
|
||||
{
|
||||
_cellId = value;
|
||||
_canonicalCellWriter?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||||
|
|
@ -657,9 +682,9 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
public float MaxSeqSpeedSinceLastUP;
|
||||
|
||||
public RemoteMotion()
|
||||
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
|
||||
{
|
||||
Body = new AcDream.Core.Physics.PhysicsBody
|
||||
Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody
|
||||
{
|
||||
// Remotes don't simulate gravity — server owns Z. Force
|
||||
// Contact + OnWalkable + Active so apply_current_movement
|
||||
|
|
@ -692,6 +717,12 @@ public sealed class GameWindow : IDisposable
|
|||
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
|
||||
});
|
||||
}
|
||||
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
_canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read));
|
||||
_canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
|
||||
|
|
@ -2397,6 +2428,22 @@ public sealed class GameWindow : IDisposable
|
|||
if (record.WorldEntity is { } entity)
|
||||
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
|
||||
};
|
||||
_projectileController = new AcDream.App.Physics.ProjectileController(
|
||||
_liveEntities,
|
||||
_physicsEngine,
|
||||
setupId =>
|
||||
{
|
||||
// State can classify an already-live object as Missile
|
||||
// outside the spawn handler's lock. DatCollection shares
|
||||
// one mutable reader cursor with the streaming worker, so
|
||||
// this resolver must serialize both initial and late bind.
|
||||
lock (_datLock)
|
||||
return _dats?.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
||||
},
|
||||
entity => _effectPoses.UpdateRoot(entity))
|
||||
{
|
||||
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
|
||||
};
|
||||
|
||||
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
|
||||
_liveEntities,
|
||||
|
|
@ -3143,8 +3190,8 @@ public sealed class GameWindow : IDisposable
|
|||
uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
LiveEntityRecord[] records = _liveEntities.Records
|
||||
.Where(record => record.ServerGuid != _playerServerGuid
|
||||
&& record.Snapshot.Position is { } position
|
||||
&& ((position.LandblockId & 0xFFFF0000u) | 0xFFFFu) == canonical
|
||||
&& record.ProjectionCellId != 0
|
||||
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
|
||||
&& record.Snapshot.SetupTableId is not null)
|
||||
.ToArray();
|
||||
if (records.Length == 0) return;
|
||||
|
|
@ -3162,7 +3209,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (_liveEntities.RebucketLiveEntity(
|
||||
record.ServerGuid,
|
||||
record.Snapshot.Position!.Value.LandblockId))
|
||||
record.ProjectionCellId))
|
||||
projected++;
|
||||
}
|
||||
else
|
||||
|
|
@ -3906,6 +3953,13 @@ public sealed class GameWindow : IDisposable
|
|||
// falls through to OK_TS for any object with no collision
|
||||
// geometry (acclient_2013_pseudo_c.txt:276917,276987).
|
||||
RegisterLiveEntityCollision(entity, setup, spawn, origin);
|
||||
if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord))
|
||||
_projectileController?.TryBind(
|
||||
liveRecord,
|
||||
setup,
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
|
||||
// Phase B.2: capture the server-sent MotionTableId for our own
|
||||
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
|
||||
|
|
@ -4923,6 +4977,9 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
||||
LeaveWorldLiveEntityRuntimeComponents(record);
|
||||
// Logical teardown ends the retained shadow payload too. The weaker
|
||||
// pickup/parent path stops after Suspend so re-entry can restore it.
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
_liveEntityLights?.Forget(existingEntity.Id);
|
||||
}
|
||||
|
||||
|
|
@ -4938,8 +4995,11 @@ public sealed class GameWindow : IDisposable
|
|||
if (record.WorldEntity is not { } entity)
|
||||
return;
|
||||
|
||||
bool retainedProjectileShadow =
|
||||
_projectileController?.LeaveWorld(record.ServerGuid) == true;
|
||||
_worldGameState.RemoveById(entity.Id);
|
||||
_physicsEngine.ShadowObjects.Deregister(entity.Id);
|
||||
if (!retainedProjectileShadow)
|
||||
_physicsEngine.ShadowObjects.Deregister(entity.Id);
|
||||
if (record.ServerGuid == _playerServerGuid)
|
||||
_lastLocalPlayerShadow = null;
|
||||
|
||||
|
|
@ -5429,7 +5489,7 @@ public sealed class GameWindow : IDisposable
|
|||
// rm on first UM exactly like the UP handler does.
|
||||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||
{
|
||||
remoteMot = new RemoteMotion();
|
||||
remoteMot = CreateRemoteMotion(update.Guid);
|
||||
remoteMot.Body.Orientation = entity.Rotation;
|
||||
remoteMot.Body.Position = entity.Position;
|
||||
_remoteDeadReckon[update.Guid] = remoteMot;
|
||||
|
|
@ -5591,16 +5651,48 @@ public sealed class GameWindow : IDisposable
|
|||
/// LoginComplete so the server knows the client has loaded the destination.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate — ACE broadcasts
|
||||
/// this on remote-player JUMPS (Player.cs:954). The payload carries
|
||||
/// the world-space launch velocity. Without handling it, remote
|
||||
/// jumps render as a tiny lift-and-back because we never see the
|
||||
/// +Z velocity that would integrate into a proper arc.
|
||||
/// Creates the optional MovementManager companion for one live object.
|
||||
/// A retained projectile contributes the already-owned CPhysicsObj body
|
||||
/// and canonical live-record cell; an ordinary remote gets local storage.
|
||||
/// </summary>
|
||||
private RemoteMotion CreateRemoteMotion(uint serverGuid)
|
||||
{
|
||||
if (_projectileController?.TryGetBody(serverGuid, out var projectileBody) != true)
|
||||
{
|
||||
var remote = new RemoteMotion();
|
||||
if (_liveEntities?.TryGetRecord(serverGuid, out var record) == true)
|
||||
AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize(
|
||||
remote.Body,
|
||||
record);
|
||||
return remote;
|
||||
}
|
||||
|
||||
// Retail has one CPhysicsObj. When a MovementManager is added after a
|
||||
// missile body already exists, share both its body and its canonical
|
||||
// live-record cell rather than introducing a second cell owner.
|
||||
return new RemoteMotion(projectileBody);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps.
|
||||
/// The payload seeds the world-space launch velocity and angular velocity.
|
||||
/// </summary>
|
||||
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
|
||||
{
|
||||
if (_projectileController?.CanAcceptVectorPayload(
|
||||
update.Guid,
|
||||
update.Velocity,
|
||||
update.Omega) == false)
|
||||
return;
|
||||
if (!_liveEntities!.TryApplyVector(update, out _)) return;
|
||||
|
||||
if (_projectileController?.ApplyAuthoritativeVector(
|
||||
update.Guid,
|
||||
update.Velocity,
|
||||
update.Omega,
|
||||
_physicsScriptGameTime) == true)
|
||||
return;
|
||||
|
||||
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
|
||||
|
||||
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
|
||||
|
|
@ -5608,7 +5700,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// World-space velocity. Apply directly to the body — the per-tick
|
||||
// remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt².
|
||||
rm.Body.Velocity = update.Velocity;
|
||||
rm.Body.set_velocity(update.Velocity);
|
||||
|
||||
// L.3.1 Task 6: apply Omega too. Was parsed but ignored, leaving
|
||||
// remote jumping/turning arcs flat. Mirrors retail SmartBox::
|
||||
|
|
@ -5647,7 +5739,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
EnsureRemoteMotionBindings(rm, ae, update.Guid);
|
||||
rm.Motion.LeaveGround();
|
||||
rm.Body.Velocity = update.Velocity;
|
||||
rm.Body.set_velocity(update.Velocity);
|
||||
rm.Body.Omega = update.Omega;
|
||||
}
|
||||
}
|
||||
|
|
@ -5671,6 +5763,12 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
|
||||
|
||||
_projectileController?.ApplyAuthoritativeState(
|
||||
parsed.Guid,
|
||||
(AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState,
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||
|
|
@ -5803,6 +5901,11 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
if (_projectileController?.CanAcceptPositionPayload(
|
||||
update.Guid,
|
||||
update.Position,
|
||||
update.Velocity) == false)
|
||||
return;
|
||||
bool known = _liveEntities!.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: update.Guid == _playerServerGuid,
|
||||
|
|
@ -5892,6 +5995,24 @@ public sealed class GameWindow : IDisposable
|
|||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
DumpMovementTruthServerEcho(update, worldPos);
|
||||
|
||||
// Missiles reconcile the same predicted PhysicsBody in place. The
|
||||
// timestamp gate above already rejected stale corrections; returning
|
||||
// here prevents the generic remote locomotion path from allocating a
|
||||
// second body or interpolation owner for the projectile.
|
||||
if (_projectileController?.ApplyAuthoritativePosition(
|
||||
update.Guid,
|
||||
worldPos,
|
||||
new System.Numerics.Vector3(
|
||||
p.PositionX,
|
||||
p.PositionY,
|
||||
p.PositionZ),
|
||||
rot,
|
||||
p.LandblockId,
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
_liveCenterY) == true)
|
||||
return;
|
||||
|
||||
// Capture the pre-update render position for the soft-snap residual
|
||||
// calculation below. Assign entity.Position to the server truth up
|
||||
// front; if we then compute a snap residual, we restore the rendered
|
||||
|
|
@ -5956,7 +6077,7 @@ public sealed class GameWindow : IDisposable
|
|||
// dispatcher).
|
||||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
|
||||
{
|
||||
rmState = new RemoteMotion();
|
||||
rmState = CreateRemoteMotion(update.Guid);
|
||||
_remoteDeadReckon[update.Guid] = rmState;
|
||||
// Hard-snap orientation on first spawn so the per-tick
|
||||
// slerp doesn't visibly rotate from Identity to truth.
|
||||
|
|
@ -9153,6 +9274,13 @@ public sealed class GameWindow : IDisposable
|
|||
// Re-publish without advancing so an extra render between updates still
|
||||
// retains retail's animation-hook versus object-hook phase barrier.
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
_projectileController?.Tick(
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
_liveCenterY,
|
||||
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer)
|
||||
? projectileViewer.Position
|
||||
: null);
|
||||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
_equippedChildRenderer?.Tick();
|
||||
|
|
@ -10322,6 +10450,7 @@ public sealed class GameWindow : IDisposable
|
|||
&& serverGuid != 0
|
||||
&& serverGuid != _playerServerGuid
|
||||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
|
||||
&& _projectileController?.HandlesMovement(serverGuid) != true
|
||||
// R4-V5 door fix companion: rm entries now also exist for
|
||||
// static animated objects (doors/levers — created on first
|
||||
// UM so the funnel can play their On/Off cycles). Those
|
||||
|
|
|
|||
|
|
@ -21,8 +21,22 @@ public interface ILiveEntityRemoteMotionRuntime
|
|||
PhysicsBody Body { get; }
|
||||
}
|
||||
|
||||
/// <summary>Marker for the projectile runtime added by the projectile phase.</summary>
|
||||
public interface ILiveEntityProjectileRuntime { }
|
||||
/// <summary>
|
||||
/// Optional seam for a remote-motion component that consumes the canonical
|
||||
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
|
||||
/// binds this for every production remote, regardless of whether projectile
|
||||
/// classification arrives before or after the MovementManager.
|
||||
/// </summary>
|
||||
public interface ILiveEntityCanonicalCellConsumer
|
||||
{
|
||||
void BindCanonicalCell(Func<uint> read, Action<uint> write);
|
||||
}
|
||||
|
||||
/// <summary>Projectile physics state owned by a live object.</summary>
|
||||
public interface ILiveEntityProjectileRuntime
|
||||
{
|
||||
PhysicsBody Body { get; }
|
||||
}
|
||||
|
||||
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
|
||||
public interface ILiveEntityEffectProfile { }
|
||||
|
|
@ -96,6 +110,15 @@ public sealed class LiveEntityRecord
|
|||
public bool WorldSpawnPublished { get; internal set; }
|
||||
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cell used when a streamed landblock asks live objects to restore their
|
||||
/// spatial projection. Once materialized, the canonical runtime cell wins;
|
||||
/// the retained server snapshot is only a first-materialization source.
|
||||
/// </summary>
|
||||
public uint ProjectionCellId => WorldEntity is not null
|
||||
? FullCellId
|
||||
: Snapshot.Position?.LandblockId ?? FullCellId;
|
||||
|
||||
internal void RefreshDerivedState(bool refreshPosition = true)
|
||||
{
|
||||
if (refreshPosition && Snapshot.Position is { } position)
|
||||
|
|
@ -480,8 +503,38 @@ public sealed class LiveEntityRuntime
|
|||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
||||
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
||||
if (record.ProjectileRuntime is { } projectile
|
||||
&& !ReferenceEquals(projectile.Body, runtime.Body))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
|
||||
}
|
||||
record.RemoteMotionRuntime = runtime;
|
||||
record.PhysicsBody = runtime.Body;
|
||||
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
||||
{
|
||||
// Capture the incarnation, not only its GUID. A callback retained
|
||||
// by an old MovementManager must never mutate a replacement that
|
||||
// later reuses the same server GUID.
|
||||
LiveEntityRecord incarnation = record;
|
||||
cellConsumer.BindCanonicalCell(
|
||||
read: () =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out var current)
|
||||
&& ReferenceEquals(current, incarnation)
|
||||
&& ReferenceEquals(current.RemoteMotionRuntime, runtime)
|
||||
? current.FullCellId
|
||||
: 0u,
|
||||
write: cellId =>
|
||||
{
|
||||
if (cellId != 0
|
||||
&& _recordsByGuid.TryGetValue(serverGuid, out var current)
|
||||
&& ReferenceEquals(current, incarnation)
|
||||
&& ReferenceEquals(current.RemoteMotionRuntime, runtime))
|
||||
{
|
||||
RebucketLiveEntity(serverGuid, cellId);
|
||||
}
|
||||
});
|
||||
}
|
||||
_remoteMotionByGuid[serverGuid] = runtime;
|
||||
}
|
||||
|
||||
|
|
@ -495,7 +548,61 @@ public sealed class LiveEntityRuntime
|
|||
return false;
|
||||
_remoteMotionByGuid.Remove(serverGuid);
|
||||
record.RemoteMotionRuntime = null;
|
||||
record.PhysicsBody = null;
|
||||
if (record.ProjectileRuntime is null)
|
||||
record.PhysicsBody = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
||||
}
|
||||
if (record.RemoteMotionRuntime is { } remote
|
||||
&& !ReferenceEquals(remote.Body, runtime.Body))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body.");
|
||||
}
|
||||
if (record.ProjectileRuntime is { } projectile
|
||||
&& !ReferenceEquals(projectile.Body, runtime.Body))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
|
||||
}
|
||||
|
||||
record.ProjectileRuntime = runtime;
|
||||
record.PhysicsBody = runtime.Body;
|
||||
}
|
||||
|
||||
public bool TryGetProjectileRuntime(
|
||||
uint serverGuid,
|
||||
out ILiveEntityProjectileRuntime runtime)
|
||||
{
|
||||
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.ProjectileRuntime is { } found)
|
||||
{
|
||||
runtime = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
runtime = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ClearProjectileRuntime(uint serverGuid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.ProjectileRuntime is null)
|
||||
return false;
|
||||
|
||||
record.ProjectileRuntime = null;
|
||||
if (record.RemoteMotionRuntime is null)
|
||||
record.PhysicsBody = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,9 @@ public sealed class ProjectilePhysicsStepper
|
|||
body.Position = beginPosition;
|
||||
body.Orientation = beginOrientation;
|
||||
|
||||
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
|
||||
? ObjectInfoState.PathClipped
|
||||
: ObjectInfoState.None;
|
||||
var resolved = _physics.ResolveWithTransition(
|
||||
beginPosition,
|
||||
candidatePosition,
|
||||
|
|
@ -208,7 +211,7 @@ public sealed class ProjectilePhysicsStepper
|
|||
stepDownHeight: 0f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.PathClipped,
|
||||
moverFlags: moverFlags,
|
||||
movingEntityId: movingEntityId,
|
||||
localSphereOrigin: sphere.LocalOrigin,
|
||||
beginOrientation: beginOrientation,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public sealed class ShadowObjectRegistry
|
|||
{
|
||||
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
||||
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
||||
private readonly HashSet<uint> _suspendedEntities = new();
|
||||
|
||||
/// <summary>
|
||||
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
|
||||
|
|
@ -326,6 +327,33 @@ public sealed class ShadowObjectRegistry
|
|||
reg.State, reg.Flags, seedCellId, reg.IsStatic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entity from every cell collision list while retaining the
|
||||
/// exact registration and shape payload needed to restore it later.
|
||||
/// This is the registry counterpart of retail
|
||||
/// <c>CPhysicsObj::remove_shadows_from_cells</c> during temporary
|
||||
/// leave-world/pending-cell residence; it is deliberately not logical
|
||||
/// teardown.
|
||||
/// </summary>
|
||||
public bool Suspend(uint entityId)
|
||||
{
|
||||
if (!_entityReg.ContainsKey(entityId))
|
||||
return false;
|
||||
|
||||
if (_entityToCells.TryGetValue(entityId, out var cellIds))
|
||||
{
|
||||
foreach (uint cellId in cellIds)
|
||||
{
|
||||
if (_cells.TryGetValue(cellId, out var list))
|
||||
list.RemoveAll(entry => entry.EntityId == entityId);
|
||||
}
|
||||
_entityToCells.Remove(entityId);
|
||||
}
|
||||
|
||||
_suspendedEntities.Add(entityId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BR-7 streaming hook — re-run the flood for every entity whose seed
|
||||
/// cell or current cell set touches <paramref name="landblockId"/>'s
|
||||
|
|
@ -343,6 +371,9 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
foreach (var kvp in _entityReg)
|
||||
{
|
||||
if (_suspendedEntities.Contains(kvp.Key))
|
||||
continue;
|
||||
|
||||
if ((kvp.Value.SeedCellId & 0xFFFF0000u) == lbPrefix)
|
||||
{
|
||||
toReflood.Add(kvp.Key);
|
||||
|
|
@ -407,6 +438,11 @@ public sealed class ShadowObjectRegistry
|
|||
/// </summary>
|
||||
public void UpdatePhysicsState(uint entityId, uint newState)
|
||||
{
|
||||
// Suspended dynamic objects have no cell rows, but their retained
|
||||
// registration must still receive authoritative state changes.
|
||||
if (_entityReg.TryGetValue(entityId, out var retainedRegistration))
|
||||
_entityReg[entityId] = retainedRegistration with { State = newState };
|
||||
|
||||
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
|
||||
return; // not registered — no-op
|
||||
|
||||
|
|
@ -438,15 +474,17 @@ public sealed class ShadowObjectRegistry
|
|||
}
|
||||
_entityShapes.Remove(entityId);
|
||||
_entityReg.Remove(entityId);
|
||||
_suspendedEntities.Remove(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove all entities belonging to a landblock. With flood-driven
|
||||
/// registration an entity's cells can span landblock prefixes; entries
|
||||
/// under OTHER prefixes survive, and the entity is fully dropped only
|
||||
/// when no cells remain (its owner despawns it via
|
||||
/// <see cref="Deregister"/> in the normal path — retail's per-object
|
||||
/// remove_shadows_from_cells, Ghidra 0x00511230).
|
||||
/// registration an entity's cells can span landblock prefixes, so entries
|
||||
/// under other prefixes survive. A static registration ends when its final
|
||||
/// streamed cell disappears. A dynamic/server-live registration retains
|
||||
/// its exact payload with zero cell rows so a later load can reflood it;
|
||||
/// only its logical owner calls <see cref="Deregister"/> (retail's
|
||||
/// per-object remove_shadows_from_cells, Ghidra 0x00511230).
|
||||
/// </summary>
|
||||
public void RemoveLandblock(uint landblockId)
|
||||
{
|
||||
|
|
@ -473,8 +511,16 @@ public sealed class ShadowObjectRegistry
|
|||
foreach (var eid in entitiesToRemove)
|
||||
{
|
||||
_entityToCells.Remove(eid);
|
||||
_entityShapes.Remove(eid);
|
||||
_entityReg.Remove(eid);
|
||||
// A streamed-out server-live object is still logically alive.
|
||||
// Preserve dynamic registration/shape payload for the reload
|
||||
// reflood; static owners end with their landblock.
|
||||
if (!_entityReg.TryGetValue(eid, out var registration)
|
||||
|| registration.IsStatic)
|
||||
{
|
||||
_entityShapes.Remove(eid);
|
||||
_entityReg.Remove(eid);
|
||||
_suspendedEntities.Remove(eid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1228
tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
Normal file
1228
tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -93,6 +93,73 @@ public class ShadowObjectRegistryTests
|
|||
reg.Deregister(999u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspend_WithdrawsCellsAndUpdatePositionRestoresSameRegistration()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 23u;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000004u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId);
|
||||
|
||||
Assert.True(reg.Suspend(entityId));
|
||||
Assert.Empty(reg.GetObjectsInCell(LbId | 1u));
|
||||
Assert.Equal(0, reg.TotalRegistered);
|
||||
|
||||
reg.UpdatePosition(
|
||||
entityId,
|
||||
new Vector3(36f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
seedCellId: LbId | 9u);
|
||||
|
||||
ShadowEntry restored = Assert.Single(reg.GetObjectsInCell(LbId | 9u));
|
||||
Assert.Equal(entityId, restored.EntityId);
|
||||
Assert.Equal(new Vector3(36f, 12f, 50f), restored.Position);
|
||||
Assert.Equal(1, reg.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspend_StateChangeUpdatesRetainedRegistrationBeforeRestore()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 24u;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000004u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
state: 0x40u,
|
||||
seedCellId: LbId | 1u,
|
||||
isStatic: false);
|
||||
Assert.True(reg.Suspend(entityId));
|
||||
|
||||
reg.UpdatePhysicsState(entityId, 0x44u);
|
||||
reg.UpdatePosition(
|
||||
entityId,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
seedCellId: LbId | 1u);
|
||||
|
||||
ShadowEntry restored = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
|
||||
Assert.Equal(0x44u, restored.State);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RemoveLandblock
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -113,6 +180,33 @@ public class ShadowObjectRegistryTests
|
|||
Assert.Empty(reg.GetObjectsInCell(LbId | 1u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLandblock_DynamicRegistrationRefloodsAfterStreamingReload()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 31u;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000005u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
seedCellId: LbId | 1u,
|
||||
isStatic: false);
|
||||
|
||||
reg.RemoveLandblock(LbId);
|
||||
Assert.Empty(reg.GetObjectsInCell(LbId | 1u));
|
||||
|
||||
reg.RefloodLandblock(LbId);
|
||||
|
||||
Assert.Contains(
|
||||
reg.GetObjectsInCell(LbId | 1u),
|
||||
entry => entry.EntityId == entityId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
|
||||
// the query — retail CObjCell::find_obj_collisions iterates only the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue