Scene, actors, audio, and lighting
These APIs run on the owning game/frame thread outside the physics step unless a function explicitly documents another domain. They borrow actors and scene objects for each call and recheck identity where a native call can replace the underlying object.
Ni transform math
RPS::Runtime::Scene::Transform matches the native uniform-scale Ni transform
layout. Conversion helpers validate finite proper rotations and non-degenerate
scales:
#include <RPS/Runtime/Scene.h>
using namespace RPS::Runtime::Scene;
const TransformResult local = worldToParentLocal(
parentWorld,
desiredWorld);
if (!local) {
logOnce(toString(local.status));
return;
}
const TransformResult reconstructed = parentLocalToWorld(
parentWorld,
local.value);
Skew, reflection, NaN, infinity, and zero/near-zero scale fail before RPS uses transpose-as-inverse rotation math.
Borrowed scene-object commands
ObjectApi object{module, niAvObject};
(void)object.setMaterialNeedsUpdate(true);
(void)object.setAppCulled(false);
(void)object.updateWorldBound();
The object and vtable are checked for each synchronous virtual call. These commands do not retain the object or renderer proxy.
For hierarchy mutation, the caller must already own live Bethesda/Ni references to both parent and child:
HierarchyApi hierarchy{module};
const auto attached = hierarchy.attachChild(parentNode, childObject);
if (!attached) {
logOnce(toString(attached.status));
}
RPS takes temporary references across the native call and verifies the exact child-parent postcondition. It does not implicitly reparent a child attached to another parent.
For a complete multi-child prop, use the native recursive physics boundary:
#include <RPS/Runtime/ScenePhysics.h>
RecursiveMotionRequest request{};
request.preset = MotionPreset::Dynamic;
request.recursive = true;
request.activate = true;
const auto changed = setMotionRecursive(module, sceneRoot, request);
Changing only one collision object is not equivalent to updating a scene subtree.
Movement controller
#include <RPS/Runtime/MovementController.h>
using namespace RPS::Runtime::Character;
MovementControllerApi movement{module, actor};
const auto before = movement.inspect();
if (!before.complete()) {
return;
}
const auto transitioned = movement.transition(
MovementTransition::PlannerDirectControl);
if (!transitioned) {
logOnce(toString(transitioned.status));
return;
}
(void)movement.setPlannerTargetYaw(targetYawRadians);
RPS changes modes only through native transitions; it never writes the mode field directly. After every call it re-resolves the actor's controller so a replacement cannot be mistaken for success on the original generation.
Character controller
#include <RPS/Runtime/CharacterController.h>
CharacterControllerApi controller{module, actor};
const auto current = controller.resolve();
const auto added = controller.addToWorld(bhkWorld);
if (!added.completed()) {
logOnce(toString(added.status));
}
addToWorld() calls the engine's self-locking, deduplicating wrapper. Do not
hold an hknp write guard around it. NoFreshInsertion means the native call
returned false—possibly because the controller was already present, possibly
because it rejected insertion. It is not proof of absence.
Corrected FO4VR actor state
#include <RPS/Runtime/ActorState.h>
ActorStateApi state{module, actor};
const auto inspected = state.inspect();
if (!inspected) {
return;
}
const auto knock = state.readKnockState();
The snapshot uses the corrected VR life/knock bit positions and exposes copied movement/process authority state. Narrow mutation calls can clear native knock state, the actor ragdoll-movement bit, or high-process ragdoll flags.
Those clears do not arbitrate ownership. Call them only when your mod has established that it owns the corresponding actor transition and cleanup.
Actor pathing queries
#include <RPS/Runtime/ActorPathing.h>
ActorPathingApi pathing{module, actor};
const auto state = pathing.queryState();
const auto direct = pathing.queryDirectMovement();
auto currentRequest = pathing.queryCurrentRequest();
queryCurrentRequest() adopts the one retained full-width intrusive reference
returned by the engine. Its result is move-only. Destroy it on the engine-owning
thread or transfer ownership explicitly with detach(); never add a second
manual release path.
RPS currently exposes pathing queries, not request construction/submission or controller repair policy.
Followed audio
#include <RPS/Runtime/Audio.h>
using namespace RPS::Runtime::Audio;
AudioApi audio{module};
NativeSoundHandle sound{};
const auto started = audio.playFollowingDescriptor(
sound,
descriptor,
sceneObject,
worldPosition);
if (!started) {
return;
}
(void)audio.setVolume(sound, 0.75f);
(void)audio.fadeInPlay(sound, 150);
// Required final ownership action.
(void)audio.fadeOutAndRelease(sound, 200);
The handle owns one active engine playback registration. RPS refuses to overwrite an active handle. Keep start, volume/fade commands, and final release on the owning game thread. Descriptor selection, replacement, looping, volume curves, and effect policy remain consumer-owned.
Owned point light
#include <RPS/Runtime/PointLight.h>
using namespace RPS::Runtime::Rendering;
PointLightSettings settings{};
settings.diffuse = {1.0f, 0.25f, 0.1f};
settings.specular = settings.diffuse;
settings.dimmer = 1.0f;
PointLightApi lights{module};
auto created = lights.create(settings);
if (!created) {
return;
}
PointLight light = std::move(created.light);
if (!light.attach(worldRoot)) {
(void)light.reset();
return;
}
RPS::Runtime::Scene::Transform world{};
world.translate = {x, y, z};
if (!light.placeWorld(world)) {
(void)light.reset();
return;
}
RPS::Runtime::Scene::ObjectApi lightObject{module, light.object()};
(void)lightObject.setAppCulled(false);
// On the creation thread, outside physics:
const auto reset = light.reset();
The move-only light owns the native light, renderer proxy, manager generation, and optional parent reference. Normal teardown order is detach, unregister, proxy release, then light release.
Inspect reset failures. If native unregister faults after renderer ownership becomes unknown, RPS deliberately avoids a speculative retry or release that could cause a double unregister or use-after-free. Unsafe-context destruction likewise leaks retained state rather than guessing.