Skip to main content

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

NeedAPI
Current player-body contact snapshotgetBodyContactSnapshotV1
Current contacts for a legacy owner registrationgetExternalContactSnapshotForOwnerV1
Durable, cursor-based contacts split by child scopecopyExternalContactsSinceV1
Finger/hand semantic contact statecopySemanticHandContactsV1
Current player collision geometrycopyPlayerColliderDescriptorsV1
Whether one hand can physically collide right nowgetHandCollisionAvailabilityV1

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:

FieldContract
sizeMust equal the current structure size.
bodyIdConcrete body ID; 0x7FFF'FFFF is invalid.
ownerTokenLegacy: the ROCK owner token. Scoped: the child scopeToken.
generationAddon-defined generation used to reject stale contact identity.
roleSemantic role of the external body.
contactPolicyBitmask controlling what ROCK reports or suppresses.
ownerHandOptional 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:

  • ReportHandContacts asks ROCK to record hand contact with the body;
  • ReportAllSourceKinds also allows weapon and held-object sources;
  • SuppressRockDynamicPush prevents 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:

ResultMeaning
InvalidArgumentInvalid token/buffer/body, duplicate ID, bad size, or row owner does not equal the scope.
CapacityFullThe body or scope capacity would be exceeded.
OwnerConflictThe body ID is already registered under another owner/scope.
PermissionDeniedThe 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 scopeToken to read one child scope.
  • Pass scopeToken == 0 to merge every scope belonging to the parent owner.
  • Pass afterSequence == 0 for 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:

  • GapBeforeFirstCopied means requested history is no longer complete;
  • RingOverwroteRecords means 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, or End state;
  • 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.