Contacts, colliders, and external bodies
ROCK already observes the player's hands, held objects, equipped weapon, body colliders, and their contact stream. External-body registration lets another plugin add its own Havok body IDs to that semantic world without sharing raw engine objects across the API boundary.
This surface supports physical reload sockets, active-ragdoll damage, wearable objects, body-aware effects, haptics, and collision diagnostics.
Choose the right contact surface
| Need | API |
|---|---|
| Current player-body contact snapshot | getBodyContactSnapshotV1 |
| Current contacts for a legacy owner registration | getExternalContactSnapshotForOwnerV1 |
| Durable, cursor-based contacts split by child scope | copyExternalContactsSinceV1 |
| Finger/hand semantic contact state | copySemanticHandContactsV1 |
| Current player collision geometry | copyPlayerColliderDescriptorsV1 |
| Whether one hand can physically collide right now | getHandCollisionAvailabilityV1 |
Player body-contact snapshot
getBodyContactSnapshotV1 · slot 14
std::uint32_t getBodyContactSnapshotV1(
RockProviderBodyContactV1* outContacts,
std::uint32_t maxContacts);
Copies the current bounded player-body contact snapshot and returns the number of rows copied. The public maximum is 128. It returns zero for an unavailable provider, null buffer, zero capacity, or no contacts.
Each RockProviderBodyContactV1 identifies both sides by body ID, collision
layer, body zone, side, semantic role, descriptor index, and power-armor state.
targetKind distinguishes a hand, player body, held object, weapon, external
body, and other target categories. Read contactPointGame only when
hasContactPointGame != 0.
This is a snapshot, not an event queue. Compare identities across frames if you need begin/continue/end transitions, or use semantic contacts where possible.
External-body identity
An addon registers numeric Havok bodyId values that it already owns. ROCK
does not take ownership of those bodies and the API does not expose a writable
Havok pointer.
Every RockProviderExternalBodyRegistration contains:
| Field | Contract |
|---|---|
size | Must equal the current structure size. |
bodyId | Concrete body ID; 0x7FFF'FFFF is invalid. |
ownerToken | Legacy: the ROCK owner token. Scoped: the child scopeToken. |
generation | Addon-defined generation used to reject stale contact identity. |
role | Semantic role of the external body. |
contactPolicy | Bitmask controlling what ROCK reports or suppresses. |
ownerHand | Optional associated physical hand. |
Roles currently include ReloadMobile, ReloadSocket, ReloadAction,
ReloadVisualProxy, and ActorRagdollBone. Unknown/custom bodies may use
Unknown while still relying on their own scope and generation.
Contact policy flags:
ReportHandContactsasks ROCK to record hand contact with the body;ReportAllSourceKindsalso allows weapon and held-object sources;SuppressRockDynamicPushprevents ROCK's own dynamic-push response for that registered body while the registration exists.
Policy flags are a bitmask. Register only body IDs whose lifetime you control, and clear them before destroying or recycling the underlying bodies.
Legacy owner-wide registration
registerExternalBodiesV1 · slot 9
bool registerExternalBodiesV1(
std::uint64_t ownerToken,
const RockProviderExternalBodyRegistration* bodies,
std::uint32_t bodyCount);
Requires ExternalBodies. The call atomically replaces the owner's complete
legacy registration. Set every row's ownerToken to the same ROCK owner token.
The global maximum is 2,048 bodies. Duplicate IDs, invalid IDs, bad sizes, or a
body already owned by another scope reject the replacement.
clearExternalBodies · slot 7
void clearExternalBodies(std::uint64_t ownerToken);
Clears all external bodies and retained external contacts for that owner. The
function is intentionally void; invalid or unauthorized owners simply have
nothing cleared. Prefer the scoped API for new multi-session consumers because
it reports errors and isolates cleanup.
getExternalContactSnapshotForOwnerV1 · slot 22
std::uint32_t getExternalContactSnapshotForOwnerV1(
std::uint64_t ownerToken,
RockProviderExternalContactV1* outContacts,
std::uint32_t maxContacts);
Requires ExternalContacts. Copies the owner's currently retained legacy
contact rows, up to the public limit of 512, and returns the number copied.
The legacy row includes source body/hand/kind, target role/body/generation, contact sequence and frame, source velocity, point and normal, and semantic source-part roles. Arrays in this structure are in Havok space.
:::warning Weight is not impulse
contactPointWeightSum is Bethesda's aggregate contact-point weight. The
deprecated union alias aggregateImpulseMagnitude exists for ABI continuity,
but the value is not a physical impulse magnitude.
:::
Scoped registration
One plugin often owns several actors, reload sessions, or tools. A child
scopeToken separates those lifetimes while the parent ROCK owner token still
defines capability and cleanup authority.
registerExternalBodiesForScopeV1 · slot 61
RockProviderResultV1 registerExternalBodiesForScopeV1(
std::uint64_t ownerToken,
std::uint64_t scopeToken,
const RockProviderExternalBodyRegistration* bodies,
std::uint32_t bodyCount);
Requires ExternalBodyScopes. ownerToken and scopeToken must both be
nonzero. Every registration row must set ownerToken == scopeToken.
The call transactionally replaces only that child scope's bodies. A successful refresh preserves already-emitted contact evidence for the scope. A zero-count registration is valid and leaves an empty registered scope; use explicit clear when the scope itself has ended.
Current provider limits are 256 scopes and 2,048 bodies across all scopes. Typical errors are:
| Result | Meaning |
|---|---|
InvalidArgument | Invalid token/buffer/body, duplicate ID, bad size, or row owner does not equal the scope. |
CapacityFull | The body or scope capacity would be exceeded. |
OwnerConflict | The body ID is already registered under another owner/scope. |
PermissionDenied | The consumer was not granted ExternalBodyScopes. |
clearExternalBodiesForScopeV1 · slot 62
RockProviderResultV1 clearExternalBodiesForScopeV1(
std::uint64_t ownerToken,
std::uint64_t scopeToken);
Clears that scope's bodies and retained contacts. Returns TargetUnavailable
if the scope did not exist. Unregistering the parent consumer still clears all
of its scopes, but explicit scope cleanup keeps lifetimes deterministic.
Cursor-based external contacts
copyExternalContactsSinceV1 · slot 63
RockProviderResultV1 copyExternalContactsSinceV1(
std::uint64_t ownerToken,
std::uint64_t scopeToken,
std::uint64_t afterSequence,
RockProviderExternalContactRecordV1* outContacts,
std::uint32_t maxContacts,
RockProviderExternalContactStreamStateV1* outStreamState);
Requires both ExternalBodyScopes and ExternalContacts.
- Pass a specific nonzero
scopeTokento read one child scope. - Pass
scopeToken == 0to merge every scope belonging to the parent owner. - Pass
afterSequence == 0for the retained history currently available. - A null data buffer is valid only with
maxContacts == 0; the stream-state structure is always required and must advertise at least its current size.
The returned record adds the parent owner, child scope, all four generation values, and the same enriched contact fields as the legacy snapshot. Flags tell you which optional values are valid and whether a transition was suppressed.
RockProviderExternalContactQuality progresses from BodyPairOnly to
AggregateImpulse to RawPoint. Use the quality plus the individual validity
flags; never infer that a point or normal exists from a nonzero vector alone.
Handling ring-buffer gaps
outStreamState reports the oldest retained and latest emitted sequences,
first/last copied sequence, copied count, overwrite count, and flags:
GapBeforeFirstCopiedmeans requested history is no longer complete;RingOverwroteRecordsmeans this filtered stream has suffered overwrites.
When records are copied, advance your cursor to lastCopiedSequence. If a gap
is reported, rebuild any derived continuous state from current snapshots and
treat the next record as a new baseline.
std::array<RockProviderExternalContactRecordV1, 64> records{};
RockProviderExternalContactStreamStateV1 stream{};
const auto result = RockProviderApi::inst->copyExternalContactsSinceV1(
ownerToken,
actorScope,
contactCursor,
records.data(),
records.size(),
&stream);
if (result == RockProviderResultV1::Ok) {
const auto gap = hasFlag(
stream.flags,
RockProviderExternalContactStreamFlagV1::GapBeforeFirstCopied);
if (gap) {
rebuildActorContactBaseline(actorScope);
}
for (std::uint32_t i = 0; i < stream.copiedCount; ++i) {
consume(records[i]);
}
if (stream.copiedCount != 0) {
contactCursor = stream.lastCopiedSequence;
}
}
Semantic hand contacts
copySemanticHandContactsV1 · slot 72
RockProviderResultV1 copySemanticHandContactsV1(
std::uint64_t ownerToken,
RockProviderHand hand,
std::uint32_t maxFramesSinceContact,
RockProviderSemanticHandContactV1* outContacts,
std::uint32_t maxContacts,
std::uint32_t* outContactCount);
Requires SemanticHandContacts and must run inside an owner callback. It copies
up to 20 semantic finger/hand rows for the selected physical hand. The age
filter lets you ask only for recent contacts; zero means current-frame rows.
Each row reports:
- hand role, finger, and segment;
- hand/target body IDs and optional target form ID;
Begin,Continued, orEndstate;- frames since contact and contact sequence;
- optional game-space point and normal;
- collision generation and relation flags.
Use TargetFormResolved, ContactPointValid, ContactNormalValid, and the
other flags before consuming their corresponding fields.
Player collider descriptions
copyPlayerColliderDescriptorsV1 · slot 73
RockProviderResultV1 copyPlayerColliderDescriptorsV1(
std::uint64_t ownerToken,
RockProviderPlayerColliderDescriptorV1* outDescriptors,
std::uint32_t maxDescriptors,
std::uint32_t* outDescriptorCount);
Requires PlayerColliderDescriptors, is callback-only, and copies up to 96
hand/body descriptors. Rows provide kind, hand, body ID, semantic role/zone,
descriptor index, length, radius, transform, and collision generation.
Valid, Enabled, TransformValid, PrimaryPalmAnchor, and InPowerArmor
are independent flags. Do not build geometry from a disabled row or use its
transform without TransformValid.
This is the safe way to draw or reason about ROCK's current player collision shape. It is not permission to mutate those bodies.
getHandCollisionAvailabilityV1 · slot 74
RockProviderResultV1 getHandCollisionAvailabilityV1(
std::uint64_t ownerToken,
RockProviderHand hand,
RockProviderHandCollisionAvailabilityV1* outState);
Requires PlayerColliderDescriptors and is callback-only. It reports whether
hand bodies and dynamic twins exist, whether physics writes are allowed, and
whether collision is currently available or suppressed by a menu, transition,
or disabled hand.
Use this state to gate contact-driven effects. A missing contact during
TransitionSuppressed is not evidence that the player moved away.
Complete pattern: one actor ragdoll scope
using namespace rock::provider;
RockProviderExternalBodyRegistration body{};
body.bodyId = pelvisBodyId;
body.ownerToken = actorScope; // child scope, not the parent owner token
body.generation = ragdollGeneration;
body.role = RockProviderExternalBodyRole::ActorRagdollBone;
body.contactPolicy = static_cast<RockProviderExternalBodyContactPolicy>(
static_cast<std::uint32_t>(
RockProviderExternalBodyContactPolicy::ReportHandContacts) |
static_cast<std::uint32_t>(
RockProviderExternalBodyContactPolicy::ReportAllSourceKinds));
const auto registered = RockProviderApi::inst->registerExternalBodiesForScopeV1(
ownerToken, actorScope, &body, 1);
if (registered != RockProviderResultV1::Ok) {
disableContactDamageFor(actorScope, registered);
}
// Later, before the body is destroyed or its ID is recycled:
RockProviderApi::inst->clearExternalBodiesForScopeV1(ownerToken, actorScope);
Safety checklist
- Treat body IDs as generation-bound identities, not immortal object handles.
- Never register another plugin's body without a shared lifetime contract.
- Never dereference scene/physics pointers inferred from numeric IDs.
- Keep contact processing bounded; both snapshots and streams have public caps.
- Separate measured, estimated, and unavailable point data using flags.
- Clear registrations before body destruction and always on session teardown.
- On stream gaps, fail closed or rebuild—do not fabricate missed impulses.