Skip to main content

Ownership and physics epochs

RPS encodes several different native reference and synchronization domains. They are intentionally not interchangeable.

Ownership map

Type or valueMeaningConsumer responsibility
Raw void* argumentBorrowed engine objectKeep it stable for one synchronous call; never assume RPS retained it.
Address in a snapshot/resultCopied identity or diagnosticCompare or log it; do not dereference it later.
ShapeHandleOne owned Havok shape referenceMove it; reset only when no native body/compound still needs it.
GeneratedBodyOwned Bethesda/Havok body graphKeep its GeneratedBodyService alive and retire through that service.
OwnedConstraintOwned live world constraintKeep its ConstraintService alive; retire under the matching world write epoch.
PositionMotorOwned Havok motor referenceRelease/reset only after every native constraint has detached from it.
GraphManagerLeaseOne retained graph-manager referenceHold it across graph access and release on the correct thread.
NativeIntrusivePtrOne full-width native intrusive referenceDestroy/release on the underlying engine object's owning thread.
NativeSoundHandleOne active audio registrationCall fadeOutAndRelease() before discarding it.
PointLightLight, renderer proxy, manager generation, and optional parent referenceKeep every command and final reset on its creation thread.

Move-only owners prevent accidental copies, but they cannot infer that the thread, engine phase, or external object generation is still safe at destruction. Arrange explicit shutdown before the containing service or DLL is destroyed.

World guards

Use WorldReadGuard for a complete copied read and WorldWriteGuard for an hknp mutation:

using namespace RPS::Runtime::Physics;

WorldReadGuard read{module, hknpWorld};
if (!read.active() || !read.owns(hknpWorld)) {
return;
}

Api physics{module, hknpWorld};
const auto body = physics.snapshot(read, bodyId);

The guard checks thread-local physics state. When the current thread already owns the physics-step epoch, its mode records that ownership. Outside physics, it acquires/releases the native read lock or write marker. When TLS state is unreadable, it fails closed instead of guessing.

Guards are non-copyable and non-movable. Keep their lexical scope narrow; do not store them in a service or carry them across callbacks.

A guard must match the exact world

Every guarded API verifies guard.owns(world). A write epoch on world A does not authorize a body or constraint operation on world B. This matters across load transitions and stale cached worlds.

WorldWriteGuard write{module, hknpWorld};
if (!write.owns(hknpWorld)) {
return;
}

ConstraintService constraints{module, hknpWorld};
auto created = constraints.createBallAndSocket(write, info);

Self-locking native calls are exceptions

Some Bethesda functions acquire their own lock. Do not wrap these in an external WorldWriteGuard:

  • BodyGravityApi::setBodyFactor();
  • CharacterControllerApi::addToWorld();
  • Bethesda closest-ray WorldQueryApi::castClosestRayGame().

These wrappers instead reject an active or unreadable physics-step context and perform their documented internal synchronization. Direct hknp shape casts do require WorldReadGuard.

Generated-body retirement

GeneratedBodyService is constructed on its scene-owner thread. A body may be moved elsewhere, but destruction from another thread only queues retirement. The owner thread must service that fixed queue:

(void)bodies.serviceOwnerThreadRetirements();

After native world removal, RPS holds the object graph for eight completed post-solve physics steps so stale broadphase readers cannot observe freed memory:

// Call from the integration's real post-solve completion path.
(void)bodies.serviceCompletedPhysicsSteps(1);

Do not call it once per render frame or once per attempted step. On world loss, use shutdownAfterWorldLoss() only after no later physics reader can run.

Constraint retirement

ConstraintService has a fixed cross-thread pending queue. Service it on the constructing thread while holding the matching world write guard:

WorldWriteGuard write{module, hknpWorld};
if (write.active()) {
(void)constraints.servicePendingRetirements(write);
}

If a bounded queue cannot safely represent a retirement, RPS prefers a deliberate leak over destroying a live engine object in an unsafe epoch. That is abnormal-loss containment, not the normal cleanup path.

Shutdown order

A typical integration shuts down in this order:

  1. Stop producing new requests, bodies, constraints, sounds, and lights.
  2. Quiesce callbacks/hooks that can access framework services.
  3. Release current path requests and graph-manager leases on their owning threads.
  4. Fade and release audio handles.
  5. Detach/reset point lights on their creation thread.
  6. Retire constraints under the matching write epoch and drain their queue.
  7. Retire generated bodies, observe the required completed steps, and release their deferred references.
  8. Destroy shapes and motors after their native users are gone.
  9. Invalidate borrowed actor/world/scene pointers and the runtime service.

World-loss cleanup is a separate emergency path; do not use it to skip normal retirement while physics can still read the world.