Grab 1.1: held placement
Grab 1.1 adds three functions after the unchanged Grab 1.0 table. Include
ROCK/GrabV1_1.h, acquire rock::api::grab::v1_1::Api, and link the header-only
RPS::ROCKGrab target. The extension retains interface ID 4 and Core 1.0.
Old consumers using ROCK/Grab.h keep their original contract and table size.
These APIs are on ROCK's merged main at ea9855b1 and RPS SDK main at
df02e89. A downloaded ROCK DLL must still negotiate major 1, minimum minor 1
and sizeof(grab::v1_1::Api) before a consumer accesses the additions.
Behavior and ownership
An add-on selects one currently held loose world reference, accepts its placement gesture and renews its intent before ROCK updates. Both hands may hold the same reference; two different held references cannot be placed by this operation. The held object must touch an external static or keyframed surface. Held weapons qualify as loose world references; this does not place an equipped weapon.
ROCK owns the live contact evidence, reference/world checks, complete bounded body scan, release of grab constraints and the native keyframing/save operation. Genuinely static parts remain static while movable parts become keyframed. An invalid collision layer or an incomplete body set remains a rejection.
Decoration policy belongs to the optional ROCK Pimp my House add-on. Its installation enables the feature; ROCK has no built-in decoration toggle or decoration INI key. Another consumer can use the same provider operation while owning its own gesture and presentation.
Entry points
| Function | Permission | Thread | Meaning |
|---|---|---|---|
getHeldPlacementState | Read | ROCK frame owner | Read held-reference/contact flags, physical right-stick input, a submission token and the latest placement result. |
submitHeldPlacementIntent | Write | ROCK frame owner | Submit one update's candidate, click reservation and optional anchor request. RequestQueued acknowledges admission only. |
clearHeldPlacementIntent | Write | ROCK frame owner | Clear this owner's submitted intent, active reservation and pending placement. Completed placement is unchanged. |
Use a registered Core BeforeRock animation-phase callback. Acquire Core Read +
Callbacks (5) and Grab Read + Write (3). These live queries and writes are
not task-thread snapshot readers and must not run in a synchronous Grab callback.
Observations and intent
Initialize HeldPlacementState and HeldPlacementIntent with {}. Copy the
returned frameToken and sample unchanged into a submission in the same phase.
The token is opaque; it is not sample.frameIndex or a Core phase frame number.
The runtime checks the sample frame and world, skeleton, provider and collision
generations, and checks the token separately.
| Observation | Interpretation |
|---|---|
right, left | Held world-reference FormID and flags for each physical hand. A nonzero FormID alone does not establish eligibility. |
HeldPlacementFlag::LooseObject (1) | The hand holds a valid loose reference with a supported held-body count. |
HeldPlacementFlag::FixedSurfaceContact (2) | Recent qualifying contact with an external static/keyframed surface. ROCK rechecks eligibility at commitment. |
buttonAvailable, buttonHeld, buttonPressed, buttonAgeMilliseconds | Physical right Axis0 click, OpenVR button 32, observed without consuming it. Check availability and freshness before accepting a gesture. |
frameToken, sample | Submission identity for the next ROCK update; never serialize or invent either. |
formId in the intent is the add-on's eligible reference or zero. Set
PlacementIntentFlag::ReserveClick (1) while the gesture belongs to placement,
including its release after the object has been placed. Add
PlacementIntentFlag::Anchor (2) only for a newly accepted placement gesture;
it requires ReserveClick and a nonzero FormID. Unknown flags and a nonzero
reserved field are invalid.
Keep renewing with fresh state while a placement is pending, without repeating
Anchor. A flags-zero intent can observe/drain unused click edges when bipod is
disabled. An unreserved click remains available to bipod when bipod is enabled.
Gesture policy must also prevent an in-air press from becoming a new placement
merely because contact begins while that button remains held.
| Admission failure | Consumer response |
|---|---|
NotReady | Live placement state is unavailable; clear local gesture/pending tracking and wait for fresh readiness. |
GenerationMismatch | Discard the old token/sample and query again in the intended phase. |
Busy | Another owner submitted for the update, or the call attempted forbidden synchronous callback reentry. Do not take over native physics. |
InvalidArgument | Correct reserved fields, flags or the Anchor/ReserveClick/FormID combination. |
InvalidSize | Use the exact sized record from the negotiated public header. |
Owner and permission failures follow the shared runtime contract.
Frame lifetime and results
One owner may submit for each update. Another owner receives Busy; the same
owner may replace its submission before consumption. Each submission is consumed
once at the next ROCK update. Missing renewal, invalid runtime state or owner
loss cancels pending placement. World/skeleton/provider lifecycle changes require
fresh observations and local gesture rearming.
| Placement result | Meaning |
|---|---|
None | No retained result in this physics instance. |
Pending | Native preparation is still pending; renew the current candidate and reservation without another Anchor. |
Anchored | The native anchoring operation completed. |
Rejected | Placement did not complete; inspect fresh hand state before retrying. |
lastRequestToken, lastRequestFormId and lastResult describe the latest native
placement attempt. Match the original anchor token before accepting a result.
This is a single latest-result observation, not an owner-scoped command history
or the getInteractionCommandResultV1 queue. Read it on subsequent BeforeRock
callbacks. A missing/mismatched result or lifecycle reset is not permission to
replay a placement automatically.
Rejection can happen during preparation, validation, release or native anchoring; it does not guarantee that the object is still held. ROCK attempts to restore movable-body motion when native anchoring fails, but the consumer must reconcile fresh held state before issuing another action. Successful placement remains in the world after clearing or unregistering the requesting owner.
Submit from the consumer callback
After registration, acquire the extended table using
client.acquire(3, placementApi). Check that acquisition returns Status::Ok
before registering or running placement behavior. A provider without minor 1
returns UnsupportedMinor; do not cast its old table to the new type.
This helper constructs a submission from an observation read in the same
BeforeRock callback. The caller owns candidate selection, fresh-click/rearm
logic, pending-token tracking and reservation through release:
#include <ROCK/GrabV1_1.h>
rock::api::Status submitPlacement(
const rock::api::grab::v1_1::Api& api,
rock::api::OwnerToken owner,
const rock::api::grab::v1_1::HeldPlacementState& observed,
std::uint32_t candidateFormId,
bool reserveClick,
bool newAnchorGesture) noexcept
{
using namespace rock::api::grab::v1_1;
HeldPlacementIntent intent{};
intent.frameToken = observed.frameToken;
intent.sample = observed.sample;
intent.formId = candidateFormId;
if (reserveClick)
intent.flags |= static_cast<std::uint32_t>(PlacementIntentFlag::ReserveClick);
if (newAnchorGesture)
intent.flags |= static_cast<std::uint32_t>(PlacementIntentFlag::Anchor);
return api.submitHeldPlacementIntent(owner, &intent);
}
Submit Anchor once and remember that observation's token on RequestQueued.
Use later observations to resolve the result. Call clearHeldPlacementIntent
when the feature stops, then unregister callbacks/close the Core owner before
destroying consumer state. An inactive consumer should stop submitting intents.
Other input consumers
Menu and gesture consumers can separately acquire the read-only
Input 1.1 reservation getter.
Observe after ROCK processes its update, compare the returned sample with the
consumer's matching ROCK snapshot, and yield the complete press/release while
InputReserved is present. This reservation is separate from the existing
weapon-only BipodInputReserved flag.
Persistence and compatibility
ROCK writes native reference position/rotation and Havok save state. There is no add-on decoration registry or F4SE decoration co-save. Removing the add-on does not run an unfreeze pass. Ordinary grab and pull-to-grab retain the existing native-keyframed-object unlock behavior.
The table is 160 bytes on Windows x64: the unchanged 136-byte/17-call Grab 1.0
prefix followed by three new slots. HeldPlacementState is 96 bytes and
HeldPlacementIntent is 56 bytes. The new records do not repurpose old fields.
Builds and compatibility/policy tests passed for the implementation. Native placement, including valid static parts, was exercised before add-on extraction. The extracted DLL's first load and save/reload/cell-return persistence still need correlated runtime verification; source support alone does not qualify them.
Complete public declarations
Complete public declarations: ROCK/GrabV1_1.h
#pragma once
#include "Grab.h"
#include <cstddef>
#include <type_traits>
#pragma pack(push, 8)
namespace rock::api::grab::v1_1 {
inline constexpr InterfaceId kInterfaceId=grab::kInterfaceId;
inline constexpr std::uint32_t kMajor=1, kMinor=1;
enum class HeldPlacementFlag : std::uint32_t { LooseObject=1, FixedSurfaceContact=2 };
struct HeldPlacementHand { std::uint32_t formId{}, flags{}; };
enum class PlacementResult : std::uint32_t { None, Pending, Anchored, Rejected };
struct HeldPlacementState {
std::uint32_t size{sizeof(HeldPlacementState)}, buttonAvailable{};
std::uint64_t frameToken{};
SampleV1 sample{};
HeldPlacementHand right{}, left{};
std::uint32_t buttonHeld{}, buttonPressed{}, buttonAgeMilliseconds{}, reserved{};
std::uint64_t lastRequestToken{};
PlacementResult lastResult{};
std::uint32_t lastRequestFormId{};
};
enum class PlacementIntentFlag : std::uint32_t { ReserveClick=1, Anchor=2 };
struct HeldPlacementIntent {
std::uint32_t size{sizeof(HeldPlacementIntent)}, flags{};
std::uint64_t frameToken{};
SampleV1 sample{};
std::uint32_t formId{}, reserved{};
};
// Negotiate major 1/minor 1/sizeof(Api); Read for get, Write for submit/clear.
// Call on ROCK's frame thread, preferably Core's BeforeRock phase. Read
// the state and submit its unchanged token/sample in that same phase.
// Input is the physical right Axis0 click (32), sampled without consuming it.
// Intent expires on the next ROCK update; one owner per update, Busy on a
// competing owner. formId is the add-on's currently eligible held reference,
// or zero. ReserveClick covers the entire press/release gesture. Anchor
// requires ReserveClick and a nonzero formId. RequestQueued is acceptance,
// not completion; lastRequestToken/lastResult report the native operation.
// ROCK validates both hands, reference/world lifetime and the entire body
// set, then releases constraints without throwing and saves a native fixed
// placement. Static parts remain static. No engine pointers or body IDs
// cross this interface. Pending work cancels on lost intent, owner or world.
// Clearing/unregistering never thaws a completed placement. Normal grab
// and pull retain their existing semantics. No F4SE decoration co-save.
struct Api {
static constexpr InterfaceId interfaceId=kInterfaceId;
static constexpr std::uint32_t majorVersion=kMajor, minorVersion=kMinor;
grab::ApiV1 v1;
Status(ROCK_CALL* getHeldPlacementState)(OwnerToken,HeldPlacementState*) noexcept;
Status(ROCK_CALL* submitHeldPlacementIntent)(OwnerToken,const HeldPlacementIntent*) noexcept;
Status(ROCK_CALL* clearHeldPlacementIntent)(OwnerToken) noexcept;
};
static_assert(std::is_standard_layout_v<Api>);
static_assert(offsetof(Api,v1)==0);
static_assert(offsetof(Api,getHeldPlacementState)==sizeof(grab::ApiV1));
static_assert(sizeof(Api)==sizeof(grab::ApiV1)+3*sizeof(void*));
static_assert(sizeof(HeldPlacementState)==96 && sizeof(HeldPlacementIntent)==56);
}
#pragma pack(pop)