Implement proxy weapon animation sync for ranged guns

Milestone 1: Proxy actors now equip remote players' right-hand weapons and play armed idle/locomotion poses when weaponDrawn=true. Adds rightHand weapon slot to equippedItems protocol, weapon graph variable support (iSyncGunDown: 0=drawn, 1=holstered), and weapon FSM events (weaponDraw, readyStateEnter, gunDownStateEnter). Fixes two critical animation state churn bugs: frustum-visibility flicker no longer resets animation state on every update tick, and idle proxies no longer register as changed on every frame. Includes WeaponBehavior graph loading via AIProcess::RequestLoadAnimationsForWeaponChange() and manual weapon attachment fallback. New docs/weapon-animation-sync.md reference guide maps animation events and variables from F4-Animation-Research. Experimental alert-state reassertion for armed proxies to test NPC combat state gating of weapon-ready poses.
This commit is contained in:
2026-07-02 17:23:09 +12:00
parent a6d4cc2ba6
commit 89c3e82554
11 changed files with 837 additions and 41 deletions
+3
View File
@@ -33,6 +33,9 @@ namespace F4T::ProxyAnimationSync
inline constexpr const char* kSyncWalkRun = "iSyncWalkRun";
inline constexpr const char* kSyncLocomotionSpeed = "iSyncLocomotionSpeed";
inline constexpr const char* kSyncJumpState = "iSyncJumpState";
// Weapon animation graph variables (RaiderRootBehavior / WeaponBehavior)
inline constexpr const char* kSyncGunDown = "iSyncGunDown";
inline constexpr const char* kRifleDrawnStateID = "iRifleDrawnStateID";
}
// MTLocomotionBlend_SM sub-states: Walk(0), Jog(1), Run(2). Selected by graph events
@@ -46,6 +46,15 @@ namespace
"bInJumpState",
"WeaponDrawn",
"bWeaponDrawn",
// Weapon animation candidates (RaiderRootBehavior, WeaponBehavior)
"iSyncGunDown",
"iRifleDrawnStateID",
"RifleDrawnCurrentState",
"iAttackState",
"isFiring",
"iSyncFireState",
"IsAttackReady",
"GunGripPointer",
});
struct LocalAnimationGraphState
+253 -25
View File
@@ -136,6 +136,8 @@ namespace
constexpr auto kRuntimeProxyVisibilityNudgeInterval = 250ms;
constexpr float kRuntimeProxyForceVisibilityRange = 8192.0F;
constexpr auto kProxyNeutralizationInterval = 2s;
constexpr bool kEnableProxyWeaponAlertExperiment = true;
constexpr auto kProxyWeaponAlertReassertInterval = 750ms;
constexpr bool kEnableProxyAIMovementIntentSuppression = false;
bool g_preserveProxyAIForAnimationTesting = true;
// Stage 1 tested RE::Actor::SetSneaking(bool). It compiled and did not
@@ -2118,6 +2120,62 @@ namespace
nullptr);
}
bool UnequipSyncedWeapon(
RE::ActorEquipManager& a_equipManager,
RE::Actor& a_proxy)
{
RE::BGSEquipIndex rightHandIndex;
rightHandIndex.index = 0; // Right-hand equip slot
RE::BGSObjectInstance equippedItem{ nullptr, nullptr };
const auto* item = a_proxy.GetEquippedItem(&equippedItem, rightHandIndex);
if (!item || !item->object) {
return false;
}
return a_equipManager.UnequipObject(
std::addressof(a_proxy),
&equippedItem,
1,
nullptr,
0,
false,
true,
false,
true,
nullptr);
}
bool EquipSyncedWeapon(
RE::ActorEquipManager& a_equipManager,
RE::Actor& a_proxy,
RE::TESObjectWEAP& a_weapon)
{
// First, add the weapon to the proxy's inventory so EquipObject can find it.
// Use an empty ExtraDataList so the item has no special properties.
a_proxy.AddObjectToContainer(
&a_weapon,
RE::BSTSmartPointer<RE::ExtraDataList>{},
1,
nullptr,
RE::ITEM_REMOVE_REASON::kNone);
// Now equip it. The system should find it in the inventory we just added it to.
RE::BGSObjectInstance objectInstance{ &a_weapon, nullptr };
const bool equipSuccess = a_equipManager.EquipObject(
std::addressof(a_proxy),
objectInstance,
0,
1,
nullptr,
false,
true,
false,
true,
false);
return equipSuccess;
}
void ResetSlotEquipmentTracking(ProxyActorSlot& a_slot)
{
a_slot.hasAppliedEquipmentState = false;
@@ -2178,6 +2236,11 @@ namespace
changed = UnequipSyncedArmor(*equipManager, a_proxy, *bipedObject) || changed;
}
// Clear right-hand weapon
if (UnequipSyncedWeapon(*equipManager, a_proxy)) {
changed = true;
}
if (changed) {
RefreshProxyAfterEquipmentChange(a_slot, a_proxy);
LogInfoWithLocalPlayerPrefix(std::format(
@@ -2204,29 +2267,51 @@ namespace
return;
}
auto* equipManager = RE::ActorEquipManager::GetSingleton();
if (!equipManager) {
LogThrottledWarning(
"proxy_equipment_manager_missing_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Could not apply synced equipment for remote player {}; ActorEquipManager unavailable.",
a_remotePlayer.playerId));
return;
auto* equipManager = RE::ActorEquipManager::GetSingleton();
if (!equipManager) {
LogThrottledWarning(
"proxy_equipment_manager_missing_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Could not apply synced equipment for remote player {}; ActorEquipManager unavailable.",
a_remotePlayer.playerId));
return;
}
// Log received equippedItems for diagnostics
{
std::string itemsDebug;
for (const auto& item : a_remotePlayer.equippedItems) {
if (!itemsDebug.empty()) itemsDebug += ", ";
itemsDebug += std::format("{}={:08X}", item.slot, item.formId);
}
LogThrottledInfo(
"proxy_equip_received_items_" + std::to_string(a_remotePlayer.playerId),
std::format(
"ApplyProxyEquipmentFromRemoteState for remote player {}: received=[{}]",
a_remotePlayer.playerId,
itemsDebug),
1s);
}
bool changed = false;
bool weaponVisualAttached = false;
std::vector<std::uint32_t> targetFormIds;
for (const auto& item : a_remotePlayer.equippedItems) {
// Skip rightHand weapon slot - handle separately below
if (item.slot == "rightHand") {
continue;
}
bool changed = false;
std::vector<std::uint32_t> targetFormIds;
for (const auto& item : a_remotePlayer.equippedItems) {
const auto bipedObject = GetSyncedBipedObjectForSlot(item.slot);
if (!bipedObject) {
LogThrottledWarning(
"proxy_equipment_unknown_slot_" + item.slot,
std::format(
"Ignoring equipment slot '{}' for remote player {}; slot is not tracked.",
item.slot,
a_remotePlayer.playerId));
continue;
}
const auto bipedObject = GetSyncedBipedObjectForSlot(item.slot);
if (!bipedObject) {
LogThrottledWarning(
"proxy_equipment_unknown_slot_" + item.slot,
std::format(
"Ignoring equipment slot '{}' for remote player {}; slot is not tracked.",
item.slot,
a_remotePlayer.playerId));
continue;
}
auto* currentBipedObject = GetEquippedArmorBipedObject(a_proxy, *bipedObject);
const auto currentFormId =
@@ -2284,11 +2369,104 @@ namespace
}
}
// Handle right-hand weapon
std::uint32_t rightHandWeaponFormId = 0;
for (const auto& item : a_remotePlayer.equippedItems) {
if (item.slot == "rightHand") {
rightHandWeaponFormId = item.formId;
break;
}
}
// Check current right-hand weapon
RE::BGSEquipIndex rightHandIndex;
rightHandIndex.index = 0;
RE::BGSObjectInstance currentEquippedItem{ nullptr, nullptr };
const auto* currentItem = a_proxy.GetEquippedItem(&currentEquippedItem, rightHandIndex);
const auto currentWeaponFormId =
currentItem && currentItem->object && currentItem->object->Is(RE::ENUM_FORM_ID::kWEAP)
? currentItem->object->GetFormID()
: 0U;
// Unequip if different
if (currentWeaponFormId != 0 && currentWeaponFormId != rightHandWeaponFormId) {
if (UnequipSyncedWeapon(*equipManager, a_proxy)) {
changed = true;
// Revert animation graphs when unequipping weapon
if (a_proxy.currentProcess) {
a_proxy.currentProcess->RequestLoadAnimationsForWeaponChange(a_proxy);
}
} else {
LogThrottledWarning(
"proxy_weapon_unequip_failed_" + std::to_string(a_remotePlayer.playerId),
std::format(
"ActorEquipManager failed to unequip weapon {:08X} on proxy {:08X} for remote player {}.",
currentWeaponFormId,
a_proxy.GetFormID(),
a_remotePlayer.playerId));
}
}
// Equip new weapon if specified
if (rightHandWeaponFormId != 0 && currentWeaponFormId != rightHandWeaponFormId) {
auto* weapon = RE::TESForm::GetFormByID<RE::TESObjectWEAP>(rightHandWeaponFormId);
if (!weapon) {
LogThrottledWarning(
"proxy_weapon_form_missing_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(rightHandWeaponFormId),
std::format(
"Could not apply weapon form {:08X} for remote player {}; no local WEAP form resolved.",
rightHandWeaponFormId,
a_remotePlayer.playerId));
} else {
const bool equipSuccess = EquipSyncedWeapon(*equipManager, a_proxy, *weapon);
LogThrottledInfo(
"proxy_weapon_equip_attempt_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(rightHandWeaponFormId),
std::format(
"EquipSyncedWeapon for remote player {}: weaponFormId={:08X}, proxyActor={:08X}, result={}.",
a_remotePlayer.playerId,
rightHandWeaponFormId,
a_proxy.GetFormID(),
equipSuccess),
1s);
if (equipSuccess) {
changed = true;
RE::BGSEquipIndex equipIndex;
equipIndex.index = 0;
RE::BGSObjectInstanceT<RE::TESObjectWEAP> weaponInstance{ weapon, nullptr };
a_proxy.HandleItemEquip(false);
a_proxy.AttachWeapon(weaponInstance, equipIndex);
a_proxy.DoReparentWeapon(weapon, equipIndex, a_remotePlayer.weaponDrawn);
a_proxy.DrawWeaponMagicHands(a_remotePlayer.weaponDrawn);
a_proxy.Update3DPosition(true);
// Request load of WeaponBehavior animation graph for weapon events
if (a_proxy.currentProcess) {
a_proxy.currentProcess->RequestLoadAnimationsForWeaponChange(a_proxy);
}
weaponVisualAttached = true;
} else {
LogThrottledWarning(
"proxy_weapon_equip_failed_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(rightHandWeaponFormId),
std::format(
"ActorEquipManager failed to equip weapon form {:08X} on proxy {:08X} for remote player {}.",
rightHandWeaponFormId,
a_proxy.GetFormID(),
a_remotePlayer.playerId));
}
}
}
a_slot.hasAppliedEquipmentState = true;
a_slot.lastAppliedEquipmentItems = a_remotePlayer.equippedItems;
if (changed) {
RefreshProxyAfterEquipmentChange(a_slot, a_proxy);
if (weaponVisualAttached) {
F4T::ProxyAnimationSync::ResetAppliedAnimationState(a_slot.appliedAnimationState);
a_slot.hasLoggedInitialAnimationState = false;
} else {
RefreshProxyAfterEquipmentChange(a_slot, a_proxy);
}
LogInfoWithLocalPlayerPrefix(std::format(
"Applied synced equipment to runtime proxy actor {:08X} for remote player {}: slots={}, equips={}.",
a_proxy.GetFormID(),
@@ -2795,12 +2973,18 @@ namespace
}
if (a_slot.lastObservedProxyVisible.has_value() && !wasVisible) {
a_slot.restoredForCurrentState = false;
F4T::ProxyAnimationSync::ResetAppliedAnimationState(a_slot.appliedAnimationState);
a_slot.hasLoggedInitialAnimationState = false;
// NOTE: previously this also called ResetAppliedAnimationState() here, but
// IsVisible() toggles on frustum culling alone (no 3D/graph teardown), and it
// was flapping far more often than the throttled log below implied (the reset
// itself was NOT throttled). That forced every edge-triggered animation event,
// including weaponDraw/readyStateEnter/gunDownStateEnter, to be treated as a
// fresh "initial" application and re-fired on effectively every update tick.
// Genuine 3D teardown/reload is already handled separately by
// RecoverRuntimeProxyAfter3DReload, which owns resetting animation state.
LogThrottledInfo(
"runtime_proxy_visibility_regained_" + std::to_string(a_slot.remotePlayerId),
std::format(
"Runtime proxy actor {:08X} for remote player {} regained visibility after frustum cull; forcing animation re-apply.",
"Runtime proxy actor {:08X} for remote player {} regained visibility after frustum cull.",
a_proxy.GetFormID(),
a_slot.remotePlayerId),
kRuntimeProxyVisibilityDiagnosticLogInterval);
@@ -6666,6 +6850,48 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
}
}
void ApplyProxyWeaponAlertExperimentIfEnabled(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
const RemotePlayer& a_remotePlayer)
{
if constexpr (!kEnableProxyWeaponAlertExperiment) {
return;
}
const auto actorId = a_proxy.GetFormID();
const auto playerId = a_remotePlayer.playerId;
const auto alertValue = a_remotePlayer.weaponDrawn ? 1 : 0;
const auto alertStateChanged =
!a_slot.lastObservedWeaponDrawnState ||
*a_slot.lastObservedWeaponDrawnState != a_remotePlayer.weaponDrawn;
const auto commandKey = std::format(
"runtime_proxy_weapon_alert_command_{}_{}",
playerId,
actorId);
const auto shouldReassert =
a_remotePlayer.weaponDrawn &&
ShouldLog(commandKey, kProxyWeaponAlertReassertInterval);
if (!alertStateChanged && !shouldReassert) {
return;
}
const auto command = std::format("{:08X}.setalert {}", actorId, alertValue);
RE::Console::ExecuteCommand(command.c_str());
LogThrottledInfo(
"runtime_proxy_weapon_alert_experiment_" + std::to_string(playerId) + "_" + std::to_string(actorId),
std::format(
"Runtime proxy weapon alert experiment for remote player {}: actor={:08X}, weaponDrawn={}, command='{}'.",
playerId,
actorId,
a_remotePlayer.weaponDrawn,
command),
kProxyNeutralizationInterval);
}
void UpdateProxyAnimationStateDebug(
ProxyActorSlot& a_slot,
const RemotePlayer& a_remotePlayer,
@@ -6699,6 +6925,8 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_remotePlayer.movementType));
}
ApplyProxyWeaponAlertExperimentIfEnabled(a_slot, a_proxy, a_remotePlayer);
a_slot.hasLoggedInitialAnimationState = true;
a_slot.lastObservedMovingState = a_remotePlayer.isMoving;
a_slot.lastObservedSprintingState = a_remotePlayer.isSprinting;
+102 -5
View File
@@ -9,6 +9,7 @@
#include "RE/B/BGSAnimationSystemUtils.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include "RE/T/TESObjectWEAP.h"
#include "RE/T/TESObjectREFR.h"
#include <algorithm>
@@ -37,8 +38,8 @@ namespace
constexpr bool kEnableProxyJumpSync = true;
// Crouch is not a separate FO4 graph state; sneaking (iIsInSneak) covers it.
constexpr bool kEnableProxyCrouchSync = false;
// Weapon-drawn lives in the weapon wrapping behavior, not MTBehavior; needs more research.
constexpr bool kEnableProxyWeaponDrawnSync = false;
// Weapon-drawn now has protocol support; enable to drive WeaponBehavior when weapon equipped.
constexpr bool kEnableProxyWeaponDrawnSync = true;
constexpr float kIdleSpeedThreshold = 1.0F;
constexpr float kSpeedEpsilon = 0.5F;
@@ -172,6 +173,12 @@ namespace
inline constexpr const char* kJumpEnd = "jumpEnd";
inline constexpr const char* kCrouchStart = "CrouchStart";
inline constexpr const char* kCrouchStop = "CrouchStop";
// Weapon behavior events (from RaiderRootBehavior / WeaponBehavior)
inline constexpr const char* kWeaponDraw = "weaponDraw";
inline constexpr const char* kReadyStateEnter = "readyStateEnter";
inline constexpr const char* kGunDownStateEnter = "gunDownStateEnter";
inline constexpr const char* kGunDownStateExit = "gunDownStateExit";
inline constexpr const char* kFireSingle = "fireSingle";
}
bool TrySendGraphEvent(RE::Actor& a_proxy, const char* a_eventName)
@@ -190,6 +197,15 @@ namespace
return a_holder.SetGraphVariableBool(variableName, a_value);
}
bool TrySetGraphInt(
RE::IAnimationGraphManagerHolder& a_holder,
const char* a_variableName,
std::int32_t a_value)
{
const RE::BSFixedString variableName{ a_variableName };
return a_holder.SetGraphVariableInt(variableName, a_value);
}
bool TrySetGraphFloat(
RE::IAnimationGraphManagerHolder& a_holder,
const char* a_variableName,
@@ -333,13 +349,22 @@ namespace
const auto currentTier =
F4T::ProxyAnimationSync::ComputeLocomotionTier(a_desired.graphSpeed, a_desired.isSprinting);
// Tier only means something while actually moving. lastLocomotionTier is reset to
// nullopt whenever the proxy goes idle (see caller), so checking "!lastLocomotionTier"
// unconditionally here made every idle tick register as "changed" (constant log/graph
// churn and spurious re-firing of edge-triggered events like weaponDraw/gunDownStateEnter
// every single update while an idle proxy sat on screen).
const bool tierChanged =
a_desired.isMoving &&
(!a_state.lastLocomotionTier || *a_state.lastLocomotionTier != currentTier);
return a_state.lastIsMoving != a_desired.isMoving ||
a_state.lastIsSprinting != a_desired.isSprinting ||
a_state.lastIsSneaking != a_desired.isSneaking ||
a_state.lastIsJumping != a_desired.isJumping ||
a_state.lastIsCrouching != a_desired.isCrouching ||
a_state.lastWeaponDrawn != a_desired.weaponDrawn ||
!a_state.lastLocomotionTier || *a_state.lastLocomotionTier != currentTier ||
tierChanged ||
AreFloatsDifferent(a_state.lastGraphSpeed, a_desired.graphSpeed) ||
AreFloatsDifferent(a_state.lastDirection, a_desired.direction);
}
@@ -380,9 +405,15 @@ namespace
}
(void)kEnableProxySneakSync;
(void)kEnableProxyWeaponDrawnSync;
(void)a_desired.isSneaking;
(void)a_desired.weaponDrawn;
// Write weapon draw state if enabled
if constexpr (kEnableProxyWeaponDrawnSync) {
RE::IAnimationGraphManagerHolder* weaponGraphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
// iSyncGunDown: 0 = ready/drawn, 1 = holstered/gun-down
const std::int32_t gunDownValue = a_desired.weaponDrawn ? 0 : 1;
success &= TrySetGraphInt(*weaponGraphHolder, F4T::ProxyAnimationSync::GraphVar::kSyncGunDown, gunDownValue);
}
if (!success) {
static std::unordered_set<std::uint32_t> loggedWriteFailures;
@@ -427,6 +458,15 @@ namespace
return ApplyJumpStateToGraph(*graphHolder, true);
}
// The descriptor snapshot initializes every bool/int not explicitly assigned below
// to false/0. That is fine for unarmed locomotion, but while a weapon is drawn it
// clobbers weapon-state variables and the proxy drops back to default idle/holster
// immediately after the draw animation starts. Use targeted writes for armed states
// so unknown weapon graph state is preserved.
if (a_desired.weaponDrawn) {
return ApplyDesiredStateToGraph(a_proxy, a_desired, a_remotePlayerId);
}
auto& descriptor = F4T::AnimationDescriptor::F4AnimationDescriptor::GetHumanoidDescriptor();
F4T::AnimationDescriptor::AnimationVariableSnapshot snapshot;
@@ -600,6 +640,63 @@ namespace
}
}
// Handle weapon drawn state transitions
if constexpr (kEnableProxyWeaponDrawnSync) {
// Only fire weapon events if a weapon is actually equipped on the proxy
RE::BGSEquipIndex rightHandIndex;
rightHandIndex.index = 0;
RE::BGSObjectInstance equippedItem{ nullptr, nullptr };
const auto* equippedWeapon = a_proxy.GetEquippedItem(&equippedItem, rightHandIndex);
const bool hasWeaponEquipped = equippedWeapon && equippedWeapon->object && equippedWeapon->object->Is(RE::ENUM_FORM_ID::kWEAP);
// DIAGNOSTIC: weapon FSM events (weaponDraw/readyStateEnter/gunDownStateEnter) have
// been rejected (graphAccepted=false) on every observed attempt. Log the loaded
// behavior graph count once per proxy so we can tell whether WeaponBehavior is ever
// actually attached (root-only graphs won't recognize these event names at all).
if (hasWeaponEquipped) {
static std::unordered_set<std::uint32_t> loggedWeaponGraphDiag;
if (loggedWeaponGraphDiag.insert(a_remotePlayerId).second) {
RE::BSTSmartPointer<RE::BSAnimationGraphManager> graphManager;
if (static_cast<RE::IAnimationGraphManagerHolder&>(a_proxy).GetAnimationGraphManagerImpl(graphManager) && graphManager) {
LogInfoWithLocalPlayerPrefix(std::format(
"Runtime proxy weapon graph DIAGNOSTIC for remote player {}: actor={:08X}, weaponFormId={:08X}, loadedGraphCount={}, boundChannelCount={}.",
a_remotePlayerId,
actorId,
equippedWeapon->object->GetFormID(),
graphManager->graph.size(),
graphManager->boundChannel.size()));
}
}
}
if (hasWeaponEquipped && a_desired.weaponDrawn) {
RE::BGSEquipIndex equipIndex;
equipIndex.index = 0;
auto* weapon = static_cast<RE::TESObjectWEAP*>(equippedWeapon->object);
RE::BGSObjectInstanceT<RE::TESObjectWEAP> weaponInstance{ weapon, nullptr };
a_proxy.AttachWeapon(weaponInstance, equipIndex);
a_proxy.DoReparentWeapon(weapon, equipIndex, true);
a_proxy.DrawWeaponMagicHands(true);
}
if (hasWeaponEquipped && (!wasInitialized || a_previous.lastWeaponDrawn != a_desired.weaponDrawn)) {
RE::BGSEquipIndex equipIndex;
equipIndex.index = 0;
auto* weapon = static_cast<RE::TESObjectWEAP*>(equippedWeapon->object);
RE::BGSObjectInstanceT<RE::TESObjectWEAP> weaponInstance{ weapon, nullptr };
a_proxy.DoReparentWeapon(weapon, equipIndex, a_desired.weaponDrawn);
a_proxy.DrawWeaponMagicHands(a_desired.weaponDrawn);
if (a_desired.weaponDrawn) {
// Weapon is drawn - enter ready state
fireEvent(GraphEvent::kWeaponDraw);
fireEvent(GraphEvent::kReadyStateEnter);
} else {
// Weapon is holstered - enter gun-down state
fireEvent(GraphEvent::kGunDownStateEnter);
}
}
}
if constexpr (kEnableProxySneakSync) {
if (!wasInitialized || a_previous.lastIsSneaking != a_desired.isSneaking) {
fireEvent(a_desired.isSneaking ? GraphEvent::kSneakStart : GraphEvent::kSneakStop);
+28 -3
View File
@@ -215,18 +215,43 @@ namespace
return bipedObject->parent.object->GetFormID();
}
std::vector<RemoteEquippedItem> CaptureLocalEquippedItems(const RE::PlayerCharacter& a_player)
std::uint32_t GetEquippedWeaponFormId(RE::Actor& a_actor, RE::BGSEquipIndex a_equipIndex)
{
RE::BGSObjectInstance objectInstance{ nullptr, nullptr };
const auto* item = a_actor.GetEquippedItem(&objectInstance, a_equipIndex);
if (!item || !item->object) {
return 0;
}
if (!item->object->Is(RE::ENUM_FORM_ID::kWEAP)) {
return 0;
}
return item->object->GetFormID();
}
std::vector<RemoteEquippedItem> CaptureLocalEquippedItems(RE::PlayerCharacter& a_player)
{
std::vector<RemoteEquippedItem> equippedItems;
equippedItems.reserve(kSyncedEquipmentSlots.size());
equippedItems.reserve(kSyncedEquipmentSlots.size() + 1); // +1 for rightHand weapon
for (const auto& slot : kSyncedEquipmentSlots) {
equippedItems.push_back({
std::string{ slot.name },
GetEquippedArmorFormId(a_player, slot.bipedObject)
GetEquippedArmorFormId(const_cast<const RE::PlayerCharacter&>(a_player), slot.bipedObject)
});
}
// Capture right-hand weapon
RE::BGSEquipIndex rightHandEquipIndex;
rightHandEquipIndex.index = 0; // Right-hand equip index
const auto rightHandWeaponFormId = GetEquippedWeaponFormId(a_player, rightHandEquipIndex);
equippedItems.push_back({
"rightHand",
rightHandWeaponFormId
});
return equippedItems;
}