Add optional apparel sync (equippedItems)

Introduce an optional equippedItems transform extension to snapshot tracked visible apparel slots. Plugin: capture local equipped ARMO form IDs on the game-thread, include equippedItems in transform packets, parse equippedItems on the networking thread, and apply/clear synced apparel on runtime proxies (with defensive logging for missing forms). Server: fake_client parses and prints equippedItems. Docs and protocol updated; changelog and dev-log entry added. Backwards-compatible: equippedItems is optional and empty formId denotes intentional unequip.
This commit is contained in:
2026-06-29 16:02:03 +12:00
parent d0649667f7
commit ad7f287899
13 changed files with 635 additions and 82 deletions
+97 -70
View File
@@ -19,10 +19,12 @@
#include <memory>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
#include <vector>
#include <nlohmann/json.hpp>
@@ -359,6 +361,61 @@ namespace
return value;
}
std::vector<F4T::RemotePlayerState::RemoteEquippedItem> ReadEquippedItems(
const Json& a_packet,
bool& a_hasEquipmentUpdate)
{
a_hasEquipmentUpdate = false;
std::vector<F4T::RemotePlayerState::RemoteEquippedItem> equippedItems;
const auto items = a_packet.find("equippedItems");
if (items == a_packet.end()) {
return equippedItems;
}
a_hasEquipmentUpdate = true;
if (!items->is_array()) {
LogThrottledWarning(
"transform_equippedItems_not_array",
"Ignoring transform equippedItems field because it is not an array.");
return equippedItems;
}
for (const auto& item : *items) {
if (!item.is_object()) {
LogThrottledWarning(
"transform_equippedItem_not_object",
"Ignoring malformed equippedItems entry because it is not an object.");
continue;
}
const auto slot = ReadString(item, "slot");
const auto formIdText = ReadString(item, "formId");
if (!slot || slot->empty() || !formIdText) {
LogThrottledWarning(
"transform_equippedItem_missing_fields",
"Ignoring equippedItems entry missing slot or formId.");
continue;
}
std::uint32_t formId = 0;
if (!formIdText->empty()) {
const auto parsedFormId = ParseHexFormId(*formIdText);
if (!parsedFormId) {
LogThrottledWarning(
"transform_equippedItem_invalid_formId_" + *slot,
std::format("Ignoring equippedItems entry for slot '{}' with invalid formId '{}'.", *slot, *formIdText));
continue;
}
formId = *parsedFormId;
}
equippedItems.push_back({ *slot, formId });
}
return equippedItems;
}
void HandleServerWorldStatePacket(const Json& a_packet)
{
F4T::WorldStateSync::PendingServerWorldState pendingState{};
@@ -534,6 +591,9 @@ namespace
actorStateFlags2 = static_cast<std::uint32_t>(*flags2);
}
bool hasEquipmentUpdate = false;
auto equippedItems = ReadEquippedItems(a_packet, hasEquipmentUpdate);
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
static_cast<float>(*x),
@@ -553,6 +613,8 @@ namespace
ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed),
static_cast<float>(animationGraphSpeed),
hasEquipmentUpdate,
std::move(equippedItems),
std::chrono::steady_clock::now()
};
@@ -908,7 +970,8 @@ namespace F4T::Networking
bool a_isCrouching,
bool a_weaponDrawn,
float a_movementSpeed,
float a_animationGraphSpeed)
float a_animationGraphSpeed,
std::span<const F4T::RemotePlayerState::RemoteEquippedItem> a_equippedItems)
{
if (!IsConnectedToServer()) {
return false;
@@ -917,84 +980,48 @@ namespace F4T::Networking
const auto clientTime =
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
std::array<char, 512> packet{};
auto packetSize = std::snprintf(
packet.data(),
packet.size(),
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f,"
"\"isMoving\":%s,\"isSprinting\":%s,\"isSneaking\":%s,\"isJumping\":%s,\"isCrouching\":%s,\"weaponDrawn\":%s,\"movementSpeed\":%.1f",
a_x,
a_y,
a_z,
a_angleZ,
a_movementType ? a_movementType : "normal",
clientTime,
a_isMoving ? "true" : "false",
a_isSprinting ? "true" : "false",
a_isSneaking ? "true" : "false",
a_isJumping ? "true" : "false",
a_isCrouching ? "true" : "false",
a_weaponDrawn ? "true" : "false",
a_movementSpeed);
nlohmann::json packet{
{ "type", "transform" },
{ "x", a_x },
{ "y", a_y },
{ "z", a_z },
{ "angleZ", a_angleZ },
{ "movementType", a_movementType ? a_movementType : "normal" },
{ "clientTime", clientTime },
{ "isMoving", a_isMoving },
{ "isSprinting", a_isSprinting },
{ "isSneaking", a_isSneaking },
{ "isJumping", a_isJumping },
{ "isCrouching", a_isCrouching },
{ "weaponDrawn", a_weaponDrawn },
{ "movementSpeed", a_movementSpeed }
};
if (packetSize > 0 && a_animationGraphSpeed >= 0.0F && std::isfinite(a_animationGraphSpeed)) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize);
const auto appended = std::snprintf(
packet.data() + packetSize,
remaining,
",\"animationGraphSpeed\":%.1f",
a_animationGraphSpeed);
if (appended > 0 && static_cast<std::size_t>(appended) < remaining) {
packetSize += appended;
}
}
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Commonwealth Online transform packet.");
return false;
if (a_animationGraphSpeed >= 0.0F && std::isfinite(a_animationGraphSpeed)) {
packet["animationGraphSpeed"] = a_animationGraphSpeed;
}
if (a_hasCellId) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize);
const auto appended = std::snprintf(
packet.data() + packetSize,
remaining,
",\"cellId\":\"%08X\"",
a_cellId);
if (appended <= 0 || static_cast<std::size_t>(appended) >= remaining) {
LogWarningWithLocalPlayerPrefix("Could not format Commonwealth Online transform packet cell ID.");
return false;
}
packetSize += appended;
packet["cellId"] = std::format("{:08X}", a_cellId);
}
if (a_hasWorldspaceId) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize);
const auto appended = std::snprintf(
packet.data() + packetSize,
remaining,
",\"worldspaceId\":\"%08X\"",
a_worldspaceId);
packet["worldspaceId"] = std::format("{:08X}", a_worldspaceId);
}
if (appended <= 0 || static_cast<std::size_t>(appended) >= remaining) {
LogWarningWithLocalPlayerPrefix("Could not format Commonwealth Online transform packet worldspace ID.");
return false;
if (!a_equippedItems.empty()) {
auto equippedItems = nlohmann::json::array();
for (const auto& item : a_equippedItems) {
equippedItems.push_back({
{ "slot", item.slot },
{ "formId", item.formId == 0 ? std::string{} : std::format("{:08X}", item.formId) }
});
}
packetSize += appended;
packet["equippedItems"] = std::move(equippedItems);
}
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize);
const auto appended = std::snprintf(packet.data() + packetSize, remaining, "}\n");
if (appended <= 0 || static_cast<std::size_t>(appended) >= remaining) {
LogWarningWithLocalPlayerPrefix("Could not finish Commonwealth Online transform packet.");
return false;
}
packetSize += appended;
auto packetText = packet.dump();
packetText.push_back('\n');
int bytesSent = 0;
{
@@ -1003,7 +1030,7 @@ namespace F4T::Networking
return false;
}
bytesSent = send(g_socket, packet.data(), packetSize, 0);
bytesSent = send(g_socket, packetText.data(), static_cast<int>(packetText.size()), 0);
}
if (bytesSent == SOCKET_ERROR) {
@@ -1016,7 +1043,7 @@ namespace F4T::Networking
return false;
}
if (bytesSent != packetSize) {
if (bytesSent != static_cast<int>(packetText.size())) {
LogWarningWithLocalPlayerPrefix("Could not send full Commonwealth Online transform packet.");
CloseSocket();
return false;
+277
View File
@@ -12,8 +12,12 @@
#include "F4TSoloCell.h"
#include "RE/A/ACTOR_STANCE.h"
#include "RE/A/ActorEquipManager.h"
#include "RE/A/ActorValue.h"
#include "RE/B/bhkCharacterController.h"
#include "RE/B/BIPED_OBJECT.h"
#include "RE/B/BipedAnim.h"
#include "RE/B/BGSObjectInstance.h"
#include "RE/A/AIProcess.h"
#include "RE/C/COMMAND_TYPE.h"
#include "RE/C/Console.h"
@@ -24,9 +28,11 @@
#include "RE/P/PTYPE.h"
#include "RE/R/RESET_3D_FLAGS.h"
#include "RE/T/TESDataHandler.h"
#include "RE/T/TESObjectARMO.h"
#include "RE/T/TaskQueueInterface.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <mutex>
#include <cmath>
@@ -139,6 +145,34 @@ namespace
constexpr auto kNotInTestCellLogInterval = 30s;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
struct SyncedEquipmentSlot
{
std::string_view name;
RE::BIPED_OBJECT bipedObject;
};
constexpr std::array<SyncedEquipmentSlot, 16> kSyncedEquipmentSlots{
{
{ "hairTop", RE::BIPED_OBJECT::kHairTop },
{ "hairLong", RE::BIPED_OBJECT::kHairLong },
{ "body", RE::BIPED_OBJECT::kBody },
{ "underTorso", RE::BIPED_OBJECT::kUnderTorso },
{ "underLeftArm", RE::BIPED_OBJECT::kUnderLeftArm },
{ "underRightArm", RE::BIPED_OBJECT::kUnderRightArm },
{ "underLeftLeg", RE::BIPED_OBJECT::kUnderLeftLeg },
{ "underRightLeg", RE::BIPED_OBJECT::kUnderRightLeg },
{ "aboveTorso", RE::BIPED_OBJECT::kAboveTorso },
{ "aboveLeftArm", RE::BIPED_OBJECT::kAboveLeftArm },
{ "aboveRightArm", RE::BIPED_OBJECT::kAboveRightArm },
{ "aboveLeftLeg", RE::BIPED_OBJECT::kAboveLeftLeg },
{ "aboveRightLeg", RE::BIPED_OBJECT::kAboveRightLeg },
{ "headband", RE::BIPED_OBJECT::kHeadband },
{ "eyes", RE::BIPED_OBJECT::kEyes },
{ "scalp", RE::BIPED_OBJECT::kScalp }
}
};
enum class ProxyMovementMode
{
@@ -329,6 +363,8 @@ namespace
std::optional<std::string> lastObservedRemoteCellId;
std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket;
std::optional<bool> lastAppliedVisualSneakingState;
bool hasAppliedEquipmentState{ false };
std::vector<RemoteEquippedItem> lastAppliedEquipmentItems;
std::optional<RemotePlayer> lastRemotePlayerSnapshot;
F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{};
@@ -1970,6 +2006,235 @@ namespace
}
}
std::optional<RE::BIPED_OBJECT> GetSyncedBipedObjectForSlot(std::string_view a_slotName)
{
for (const auto& slot : kSyncedEquipmentSlots) {
if (slot.name == a_slotName) {
return slot.bipedObject;
}
}
return std::nullopt;
}
bool AreEquipmentStatesEqual(
const std::vector<RemoteEquippedItem>& a_left,
const std::vector<RemoteEquippedItem>& a_right)
{
if (a_left.size() != a_right.size()) {
return false;
}
for (std::size_t index = 0; index < a_left.size(); ++index) {
if (a_left[index].slot != a_right[index].slot || a_left[index].formId != a_right[index].formId) {
return false;
}
}
return true;
}
RE::BIPOBJECT* GetEquippedArmorBipedObject(RE::Actor& a_actor, RE::BIPED_OBJECT a_bipedObject)
{
if (!a_actor.biped) {
return nullptr;
}
auto* bipedObject = a_actor.biped->GetBipObject(a_bipedObject);
if (!bipedObject || !bipedObject->parent.object) {
return nullptr;
}
if (!bipedObject->parent.object->Is(RE::ENUM_FORM_ID::kARMO)) {
return nullptr;
}
return bipedObject;
}
bool UnequipSyncedArmor(
RE::ActorEquipManager& a_equipManager,
RE::Actor& a_proxy,
RE::BIPOBJECT& a_bipedObject)
{
return a_equipManager.UnequipObject(
std::addressof(a_proxy),
std::addressof(a_bipedObject.parent),
1,
nullptr,
0,
false,
true,
false,
true,
nullptr);
}
void ResetSlotEquipmentTracking(ProxyActorSlot& a_slot)
{
a_slot.hasAppliedEquipmentState = false;
a_slot.lastAppliedEquipmentItems.clear();
}
void RefreshProxyAfterEquipmentChange(ProxyActorSlot& a_slot, RE::Actor& a_proxy)
{
QueueProxyUpdate3D(a_proxy, GetProxyVisibilityUpdate3DFlags());
F4T::ProxyAnimationSync::ResetAppliedAnimationState(a_slot.appliedAnimationState);
a_slot.hasLoggedInitialAnimationState = false;
}
void ClearSlotEquipmentIfApplied(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
std::string_view a_reason)
{
if (!a_slot.hasAppliedEquipmentState) {
ResetSlotEquipmentTracking(a_slot);
return;
}
auto* equipManager = RE::ActorEquipManager::GetSingleton();
if (!equipManager) {
LogThrottledWarning(
"proxy_equipment_clear_manager_missing_" + std::to_string(a_slot.remotePlayerId),
std::format(
"Could not clear synced equipment for remote player {} during {}; ActorEquipManager unavailable.",
a_slot.remotePlayerId,
a_reason));
ResetSlotEquipmentTracking(a_slot);
return;
}
bool changed = false;
for (const auto& slot : kSyncedEquipmentSlots) {
auto* bipedObject = GetEquippedArmorBipedObject(a_proxy, slot.bipedObject);
if (!bipedObject) {
continue;
}
changed = UnequipSyncedArmor(*equipManager, a_proxy, *bipedObject) || changed;
}
if (changed) {
RefreshProxyAfterEquipmentChange(a_slot, a_proxy);
LogInfoWithLocalPlayerPrefix(std::format(
"Cleared synced equipment from runtime proxy actor {:08X} for remote player {} during {}.",
a_proxy.GetFormID(),
a_slot.remotePlayerId,
a_reason));
}
ResetSlotEquipmentTracking(a_slot);
}
void ApplyProxyEquipmentFromRemoteState(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
const RemotePlayer& a_remotePlayer)
{
if (!a_remotePlayer.hasEquipmentUpdate) {
return;
}
if (a_slot.hasAppliedEquipmentState &&
AreEquipmentStatesEqual(a_slot.lastAppliedEquipmentItems, a_remotePlayer.equippedItems)) {
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;
}
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;
}
auto* currentBipedObject = GetEquippedArmorBipedObject(a_proxy, *bipedObject);
const auto currentFormId =
currentBipedObject && currentBipedObject->parent.object ? currentBipedObject->parent.object->GetFormID() : 0U;
if (currentFormId != 0 && currentFormId != item.formId) {
changed = UnequipSyncedArmor(*equipManager, a_proxy, *currentBipedObject) || changed;
}
if (item.formId != 0 && currentFormId != item.formId &&
std::find(targetFormIds.begin(), targetFormIds.end(), item.formId) == targetFormIds.end()) {
targetFormIds.push_back(item.formId);
}
}
for (const auto formId : targetFormIds) {
auto* armor = RE::TESForm::GetFormByID<RE::TESObjectARMO>(formId);
if (!armor) {
LogThrottledWarning(
"proxy_equipment_form_missing_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(formId),
std::format(
"Could not apply equipment form {:08X} for remote player {}; no local ARMO form resolved.",
formId,
a_remotePlayer.playerId));
continue;
}
RE::BGSObjectInstance objectInstance{ armor, nullptr };
a_proxy.AddObjectToContainer(
armor,
RE::BSTSmartPointer<RE::ExtraDataList>{},
1,
nullptr,
RE::ITEM_REMOVE_REASON::kNone);
if (equipManager->EquipObject(
std::addressof(a_proxy),
objectInstance,
0,
1,
nullptr,
false,
true,
false,
true,
false)) {
changed = true;
} else {
LogThrottledWarning(
"proxy_equipment_equip_failed_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(formId),
std::format(
"ActorEquipManager failed to equip form {:08X} on proxy {:08X} for remote player {}.",
formId,
a_proxy.GetFormID(),
a_remotePlayer.playerId));
}
}
a_slot.hasAppliedEquipmentState = true;
a_slot.lastAppliedEquipmentItems = a_remotePlayer.equippedItems;
if (changed) {
RefreshProxyAfterEquipmentChange(a_slot, a_proxy);
LogInfoWithLocalPlayerPrefix(std::format(
"Applied synced equipment to runtime proxy actor {:08X} for remote player {}: slots={}, equips={}.",
a_proxy.GetFormID(),
a_remotePlayer.playerId,
a_remotePlayer.equippedItems.size(),
targetFormIds.size()));
}
}
bool ProxyHeadNodesMissing(const RE::Actor& a_proxy)
{
const auto* npc = a_proxy.GetNPC();
@@ -5498,6 +5763,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.spawnAttemptInProgress = false;
a_slot.spawnSucceeded = false;
a_slot.restoredForCurrentState = false;
ResetSlotEquipmentTracking(a_slot);
if (a_slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected) {
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
@@ -5678,6 +5944,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const auto releasedFormId = proxy->GetFormID();
ClearSlotVisualSneakIfApplied(slot, *proxy, "local cell change");
ClearSlotProxyAnimationIfApplied(slot, *proxy, "local cell change");
ClearSlotEquipmentIfApplied(slot, *proxy, "local cell change");
NeutralizeProxyActor(slot, *proxy, a_player, ProxyNeutralizationReason::kHolding);
if constexpr (kEnablePrePlacedProxyPool) {
MoveProxyToHoldingPosition(
@@ -5744,6 +6011,10 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot,
a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
ClearSlotEquipmentIfApplied(
a_slot,
a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy,
GetSlotHoldingPosition(a_slot),
@@ -5819,9 +6090,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
ClearSlotVisualSneakIfApplied(slot, *proxy, "reuse");
ClearSlotProxyAnimationIfApplied(slot, *proxy, "reuse");
ClearSlotEquipmentIfApplied(slot, *proxy, "reuse");
} else {
ResetSlotVisualSneakTracking(slot);
ResetSlotAppliedAnimationState(slot);
ResetSlotEquipmentTracking(slot);
}
slot.lifecycleState = ProxyLifecycleState::kReusable;
slot.holdReason.reset();
@@ -5893,6 +6166,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z));
ApplyProxyEquipmentFromRemoteState(a_slot, a_proxy, a_remotePlayer);
UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy);
LogProxyMovementDiagnostic(
a_proxy,
@@ -6074,6 +6348,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
ApplyProxyEquipmentFromRemoteState(a_slot, a_proxy, a_remotePlayer);
UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy);
if (shouldSnap) {
@@ -6166,9 +6441,11 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
if (auto* proxy = TryResolveSlotProxy(slotValue, a_parentCell, a_player)) {
ClearSlotVisualSneakIfApplied(slotValue, *proxy, "reassignment");
ClearSlotProxyAnimationIfApplied(slotValue, *proxy, "reassignment");
ClearSlotEquipmentIfApplied(slotValue, *proxy, "reassignment");
} else {
ResetSlotVisualSneakTracking(slotValue);
ResetSlotAppliedAnimationState(slotValue);
ResetSlotEquipmentTracking(slotValue);
}
slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId;
+99 -5
View File
@@ -10,14 +10,19 @@
#include "F4TWorldStateSync.h"
#include "RE/A/ACTOR_STANCE.h"
#include "RE/B/BIPED_OBJECT.h"
#include "RE/B/BipedAnim.h"
#include "RE/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h"
#include <array>
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <format>
#include <string>
#include <string_view>
#include <vector>
namespace
{
@@ -58,6 +63,14 @@ namespace
bool finalIsSneaking;
};
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
struct SyncedEquipmentSlot
{
std::string_view name;
RE::BIPED_OBJECT bipedObject;
};
constexpr auto kTransformSendInterval = 100ms;
constexpr auto kIdleTransformHeartbeatInterval = 2s;
constexpr auto kTransformLogInterval = 1s;
@@ -73,6 +86,27 @@ namespace
constexpr auto kJumpDiagnosticLogInterval = 1s;
constexpr auto kSneakDiagnosticLogInterval = 2s;
constexpr std::array<SyncedEquipmentSlot, 16> kSyncedEquipmentSlots{
{
{ "hairTop", RE::BIPED_OBJECT::kHairTop },
{ "hairLong", RE::BIPED_OBJECT::kHairLong },
{ "body", RE::BIPED_OBJECT::kBody },
{ "underTorso", RE::BIPED_OBJECT::kUnderTorso },
{ "underLeftArm", RE::BIPED_OBJECT::kUnderLeftArm },
{ "underRightArm", RE::BIPED_OBJECT::kUnderRightArm },
{ "underLeftLeg", RE::BIPED_OBJECT::kUnderLeftLeg },
{ "underRightLeg", RE::BIPED_OBJECT::kUnderRightLeg },
{ "aboveTorso", RE::BIPED_OBJECT::kAboveTorso },
{ "aboveLeftArm", RE::BIPED_OBJECT::kAboveLeftArm },
{ "aboveRightArm", RE::BIPED_OBJECT::kAboveRightArm },
{ "aboveLeftLeg", RE::BIPED_OBJECT::kAboveLeftLeg },
{ "aboveRightLeg", RE::BIPED_OBJECT::kAboveRightLeg },
{ "headband", RE::BIPED_OBJECT::kHeadband },
{ "eyes", RE::BIPED_OBJECT::kEyes },
{ "scalp", RE::BIPED_OBJECT::kScalp }
}
};
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
@@ -157,6 +191,56 @@ namespace
return location;
}
std::uint32_t GetEquippedArmorFormId(const RE::Actor& a_actor, RE::BIPED_OBJECT a_bipedObject)
{
if (!a_actor.biped) {
return 0;
}
const auto* bipedObject = a_actor.biped->GetBipObject(a_bipedObject);
if (!bipedObject || !bipedObject->parent.object) {
return 0;
}
if (!bipedObject->parent.object->Is(RE::ENUM_FORM_ID::kARMO)) {
return 0;
}
return bipedObject->parent.object->GetFormID();
}
std::vector<RemoteEquippedItem> CaptureLocalEquippedItems(const RE::PlayerCharacter& a_player)
{
std::vector<RemoteEquippedItem> equippedItems;
equippedItems.reserve(kSyncedEquipmentSlots.size());
for (const auto& slot : kSyncedEquipmentSlots) {
equippedItems.push_back({
std::string{ slot.name },
GetEquippedArmorFormId(a_player, slot.bipedObject)
});
}
return equippedItems;
}
bool AreEquipmentStatesEqual(
const std::vector<RemoteEquippedItem>& a_left,
const std::vector<RemoteEquippedItem>& a_right)
{
if (a_left.size() != a_right.size()) {
return false;
}
for (std::size_t index = 0; index < a_left.size(); ++index) {
if (a_left[index].slot != a_right[index].slot || a_left[index].formId != a_right[index].formId) {
return false;
}
}
return true;
}
float GetSquaredPositionDistance(const PlayerTransform& a_previous, const PlayerTransform& a_current)
{
const auto deltaX = a_current.x - a_previous.x;
@@ -479,6 +563,7 @@ namespace
static PlayerTransform lastDebugSampleTransform{};
static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{};
static std::vector<RemoteEquippedItem> lastSentEquipmentState;
static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = std::chrono::steady_clock::time_point{};
static auto lastDebugSampleTime = std::chrono::steady_clock::time_point{};
@@ -488,6 +573,7 @@ namespace
const auto currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player);
const auto currentEquipmentState = CaptureLocalEquippedItems(*player);
const auto currentTime = std::chrono::steady_clock::now();
const auto debugMovementState = GetPlayerMovementState(
*player,
@@ -512,6 +598,7 @@ namespace
lastDebugSampleTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = debugMovementState;
lastSentEquipmentState = currentEquipmentState;
lastSendTime = currentTime;
lastLogTime = currentTime;
lastDebugSampleTime = currentTime;
@@ -558,7 +645,8 @@ namespace
debugMovementState.isCrouching,
debugMovementState.weaponDrawn,
debugMovementState.movementSpeed,
-1.0F)) {
-1.0F,
currentEquipmentState)) {
LogInfoWithLocalPlayerPrefix(std::format(
"Initial transform sent with cellId={:08X}{}.",
currentLocation.hasCellId ? currentLocation.cellId : 0U,
@@ -588,16 +676,17 @@ namespace
const auto isSpecialMovement = movementType != std::string_view{ "normal" };
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState);
const auto hasEquipmentChanged = !AreEquipmentStatesEqual(lastSentEquipmentState, currentEquipmentState);
const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval;
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !idleHeartbeatDue) {
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !idleHeartbeatDue) {
lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime;
return;
}
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) {
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged) {
// Idle heartbeat: keep remote clients updated even when both players are standing still.
} else if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
} else if (!isSpecialMovement && !hasEquipmentChanged && currentTime - lastSendTime < kTransformSendInterval) {
return;
}
@@ -644,16 +733,21 @@ namespace
networkMovementState.isCrouching,
networkMovementState.weaponDrawn,
networkMovementState.movementSpeed,
animationGraphSpeed);
animationGraphSpeed,
currentEquipmentState);
if (sentTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = networkMovementState;
lastSentEquipmentState = currentEquipmentState;
lastSendTime = currentTime;
if (isSpecialMovement) {
LogSpecialMovement(movementType);
lastLogTime = currentTime;
} else if (hasEquipmentChanged) {
LogInfoWithLocalPlayerPrefix("Detected local equipment change. Sending immediate transform update.");
lastLogTime = currentTime;
} else if (currentTime - lastLogTime >= kTransformLogInterval) {
LogPlayerTransform(currentTransform);
lastLogTime = currentTime;