Ownership and physics epochs
RPS encodes several different native reference and synchronization domains. They are intentionally not interchangeable.
Ownership map
| Type or value | Meaning | Consumer responsibility |
|---|---|---|
Raw void* argument | Borrowed engine object | Keep it stable for one synchronous call; never assume RPS retained it. |
| Address in a snapshot/result | Copied identity or diagnostic | Compare or log it; do not dereference it later. |
ShapeHandle | One owned Havok shape reference | Move it; reset only when no native body/compound still needs it. |
GeneratedBody | Owned Bethesda/Havok body graph | Keep its GeneratedBodyService alive and retire through that service. |
OwnedConstraint | Owned live world constraint | Keep its ConstraintService alive; retire under the matching world write epoch. |
PositionMotor | Owned Havok motor reference | Release/reset only after every native constraint has detached from it. |
GraphManagerLease | One retained graph-manager reference | Hold it across graph access and release on the correct thread. |
NativeIntrusivePtr | One full-width native intrusive reference | Destroy/release on the underlying engine object's owning thread. |
NativeSoundHandle | One active audio registration | Call fadeOutAndRelease() before discarding it. |
PointLight | Light, renderer proxy, manager generation, and optional parent reference | Keep 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:
- Stop producing new requests, bodies, constraints, sounds, and lights.
- Quiesce callbacks/hooks that can access framework services.
- Release current path requests and graph-manager leases on their owning threads.
- Fade and release audio handles.
- Detach/reset point lights on their creation thread.
- Retire constraints under the matching write epoch and drain their queue.
- Retire generated bodies, observe the required completed steps, and release their deferred references.
- Destroy shapes and motors after their native users are gone.
- 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.