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:
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "F4TRemotePlayerState.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
|
||||
namespace F4T::Networking
|
||||
@@ -28,7 +31,8 @@ namespace F4T::Networking
|
||||
bool a_isCrouching = false,
|
||||
bool a_weaponDrawn = false,
|
||||
float a_movementSpeed = 0.0F,
|
||||
float a_animationGraphSpeed = -1.0F);
|
||||
float a_animationGraphSpeed = -1.0F,
|
||||
std::span<const F4T::RemotePlayerState::RemoteEquippedItem> a_equippedItems = {});
|
||||
bool SendWorldStatePacket(
|
||||
float a_gameHour,
|
||||
float a_gameDaysPassed,
|
||||
|
||||
@@ -22,6 +22,12 @@ namespace F4T::RemotePlayerState
|
||||
std::chrono::steady_clock::time_point captureTime{};
|
||||
};
|
||||
|
||||
struct RemoteEquippedItem
|
||||
{
|
||||
std::string slot;
|
||||
std::uint32_t formId{};
|
||||
};
|
||||
|
||||
struct RemotePlayerState
|
||||
{
|
||||
std::uint32_t playerId{};
|
||||
@@ -42,6 +48,8 @@ namespace F4T::RemotePlayerState
|
||||
bool weaponDrawn{ false };
|
||||
float movementSpeed{ 0.0F };
|
||||
float animationGraphSpeed{ -1.0F };
|
||||
bool hasEquipmentUpdate{ false };
|
||||
std::vector<RemoteEquippedItem> equippedItems;
|
||||
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
|
||||
|
||||
// Phase 2: Actor state and action events (TiltedEvolution alignment)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user