Skip to main content

Physics, collision, and queries

The physics surface separates copied inspection, guarded hknp mutation, self-locking Bethesda calls, and pure collision policy. Keep those boundaries visible in your integration.

Read a complete body snapshot

#include <RPS/Runtime/BodyAccess.h>
#include <RPS/Runtime/PhysicsApi.h>
#include <RPS/Runtime/WorldAccess.h>

using namespace RPS::Runtime;
using namespace RPS::Runtime::Physics;

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

Api physics{module, hknpWorld};
const BodySnapshot snapshot = physics.snapshot(read, bodyId);
if (!snapshot.valid) {
return;
}

const Transform transform = snapshot.body.transform;
const Vector4 linearVelocity = snapshot.motion.linearVelocity;
const std::uint32_t filter = snapshot.body.collisionFilterInfo;

The snapshot verifies the body slot, high-water mark, body ID, motion index, motion record, and finite copied values. Its address fields are diagnostics, not retained engine pointers.

Resolve a collision object to its current body

When a plugin owns a current scene object and its borrowed bhkNPCollisionObject, resolve the body with both identity witnesses:

const auto resolved = resolveCollisionObjectBody(
collisionObject,
expectedSceneOwner,
expectedHknpWorld);

if (!resolved) {
logOnce(toString(resolved.status));
return;
}

const BodyId bodyId = resolved.bodyId;

The resolver copies the complete collision-object, physics-system, physics instance, and body-ID table chain twice. A world, owner, body table, index, or ID generation change fails closed.

Mutate under a write epoch

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

Api physics{module, hknpWorld};

const bool filterChanged = physics.setCollisionFilterInfo(
write,
bodyId,
newFilterInfo,
1);

const bool moved = physics.setTransformDeferred(
write,
bodyId,
targetTransform);

const bool velocityChanged = physics.setVelocityDeferred(
write,
bodyId,
linearVelocity,
angularVelocity);

const bool awake = physics.activate(write, bodyId);

Other guarded operations include setting keyframed motion, enabling/disabling body flags, rebuilding motion mass properties, applying a point impulse, and computing hard-keyframe velocities. Every operation validates that the guard, API, and body refer to the same live world.

Do not group unrelated mutations under a long-lived guard. Acquire the epoch, perform one coherent operation, establish its final state, and release it.

Body gravity uses its own lock

BodyGravityApi::setBodyFactor() calls Bethesda's self-locking water-gravity boundary. Do not hold a WorldWriteGuard around it:

#include <RPS/Runtime/BodyGravity.h>

BodyGravityApi gravity{module, hknpWorld};
const auto changed = gravity.setBodyFactor(bodyId, 0.25f);
if (!changed) {
logOnce(toString(changed.status));
}

The wrapper requires the game/frame thread outside physics and verifies the body/motion identity and resulting factor under separate read epochs.

Scale and timing

Use current snapshots rather than scattering 70.0f or render delta through physics code:

#include <RPS/Runtime/PhysicsScale.h>
#include <RPS/Runtime/PhysicsTiming.h>

const auto scale = readScaleSnapshot(module);
const auto timing = readTimingSample(module);

const Vector4 pointHavok = scale.toHavokPoint(pointGame);
const float driveDt = timing.driveDeltaSeconds();

Inspect runtimeBacked, valid, and usedFallback before using the values for layout- or behavior-sensitive work. Physics drive code should use the actual substep phase/timestep rather than assuming one physics step per render frame.

Collision filters

Collision::FilterInfo decodes and replaces the layer/group portions of a 32-bit filter value without losing unrelated bits:

#include <RPS/Runtime/CollisionFilter.h>

using namespace RPS::Runtime::Collision;

FilterInfo original{rawFilter};
const auto layer = original.layer();
const auto group = original.group();
const auto changed = original.withLayer(RockBodyLayer).withGroup(myGroup);

Collision::Matrix is a pure 64×64 view over caller-owned rows. It maintains symmetric pairs when setPair() is used. RPS does not take ownership of the game's global collision matrix or restore it for you.

CollisionPairPolicy publishes up to 256 immutable suppression rules for a consumer-owned compare hook. Physics readers are allocation-free and lock-free; an unstable publication fails open to the original vanilla result. The policy can suppress a vanilla collision but cannot create one.

:::warning Hook owner required

CollisionPairPolicy does not install the engine detour. One process component must validate the compare entry, call the original first, own the hook and policy lifetime, and quiesce every reader before shutdown.

:::

Closest raycast

Bethesda's closest-ray path synchronizes internally and uses game units:

#include <RPS/Runtime/WorldQuery.h>

WorldQueryApi queries{module, bhkWorld, hknpWorld};

RayRequest ray{};
ray.startGame = start;
ray.directionGame = direction;
ray.maxDistanceGame = 2048.0f;
ray.collisionFilterInfo = filterInfo;

const auto result = queries.castClosestRayGame(ray);
if (!result) {
return; // Query itself failed.
}
if (!result.hit) {
return; // Successful miss.
}

const QueryHit hit = result.closest;

A successful miss is not an error. Check the result first, then hit.

Shape cast

Direct hknp shape casts require a read epoch and caller-owned bounded output:

#include <array>

std::array<QueryHit, 32> hits{};

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

ShapeCastRequest request{};
request.startGame = start;
request.directionGame = direction;
request.shape = queryShape.get();
request.distanceGame = 256.0f;
request.collisionFilterInfo = filterInfo;

const auto result = queries.castShapeGame(read, request, hits);
if (!result) {
return;
}

const auto validHits = std::span{hits}.first(result.hitCount);

Inspect droppedHitCount and invalidHitCount; never assume the output buffer captured every native result.

Impact damage and hit delivery

calculateImpactDamage() calls Bethesda's native impact-damage function from validated mass and game-space speed. deliverPhysicsHit() builds and destroys native HitData around Actor::HitMe.

Deliver a hit only on the owning game thread, never directly from the physics contact callback. Copy the contact into bounded consumer state, then deliver it at the safe lifecycle point. If PhysicsHitResult::applied is true, the damage side effect occurred even if later cleanup reports an error.