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
+4
View File
@@ -21,6 +21,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
- `protocol/server-world-state.md` documenting the new packet type.
- Event-driven world time resync via optional `timeSync` field on `worldState` packets; the current world-state host pushes time after menus, loading screens, save/load, wait/sleep, and other time freezes.
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
- Optional `equippedItems` transform snapshots for visible apparel slots so proxies can mirror clothing, armor, hats, and eyewear changes.
- Game-thread proxy equipment application for synced apparel, including explicit tracked-slot unequips and cleanup when runtime proxy slots are held, reused, or reassigned.
- `server/fake_client.py` parsing and output for relayed `equippedItems` transform data.
### Fixed
- Runtime proxy candidate isolation now ignores actors flagged as deleted, so a freshly spawned proxy is controlled instead of the slot adopting a still-present stale proxy that was marked for deletion the same frame.
@@ -65,6 +68,7 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
- First-time Looks menu now narrows the camera FOV while open (restored on close) to magnify the face for easier facial-feature editing, since LooksMenu uses its own fixed camera that ignores third-person zoom offsets.
- First-time Looks menu now centers the player on the fixed LooksMenu camera's optical axis for the first few polls (preserving distance and facing the camera), fixing the off-center face caused by the FOV zoom; the player's original transform is restored when the menu closes.
- Added temporary diagnostic logging to LooksMenu centering (camera position/heading, target vs actual player position per poll) to confirm whether `SetPosition` takes effect and whether the menu camera is fixed or follows the player.
- Documented optional apparel equipment sync in protocol and architecture docs.
## [0.0.1] - 2026-06-23
+11 -5
View File
@@ -12,8 +12,9 @@ The current system is still a local prototype, but the Fallout 4 plugin now has
both send and receive paths:
1. The Fallout 4 plugin reads local player transform data.
2. The plugin derives basic movement state on the game-thread polling path and
sends it as optional data on transform packets.
2. The plugin derives basic movement state and captures a visible equipped
apparel snapshot on the game-thread polling path, then sends both as optional
data on transform packets.
3. The plugin sends transform packets to the Python relay server. The default
dev setup connects to `127.0.0.1:7777` on the same PC; the server itself
listens on `0.0.0.0:7777` so other machines can connect using the host
@@ -71,9 +72,10 @@ both send and receive paths:
actor.
17. Proxy visual updates are independent per slot. Normal movement is smoothed
toward that player’s latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Movement and animation-related remote state is
still observed and logged only; no visual crouch/sneak, jump, weapon drawn,
locomotion, animation graph, or actor-state application is attempted.
and `teleport` snap directly. Curated locomotion animation graph variables
and optional equipped apparel snapshots are applied on the game thread after
the proxy has loaded 3D. Visual crouch, full weapon model state, power armor,
and raw actor-state bitfield application remain outside the current sync path.
18. When a remote player leaves the cell or disconnects, only that player’s
runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
@@ -164,6 +166,7 @@ The native plugin is responsible for:
- Suppressing local transform sends and remote proxy representation while the
local player is in solo cells such as `COVault109`
- Adding basic data-only movement state to transform packets
- Adding optional visible equipped apparel snapshots to transform packets
- Throttling normal transform sends separately from readable local movement logs
- Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally
@@ -195,6 +198,9 @@ The native plugin is responsible for:
- Applying curated Havok animation graph variables to runtime proxies through
`F4TProxyAnimationSync` from the same remote movement state (writes on
transition and while moving; clears on hold, disconnect, reuse, and reassignment)
- Applying optional `equippedItems` apparel snapshots to runtime proxies after
proxy 3D is ready, including explicit tracked-slot unequips for removed
clothing, hats, and eyewear
- Suppressing exposed AI movement intent for runtime proxies by clearing safe
process target handles and using validated package interruption/do-nothing
calls when combat, pathing, flee/alarm, bump, or package movement state is
+51
View File
@@ -9,6 +9,57 @@ failed experiments, successful tests, and next steps.
---
## 2026-06-29 - Clothing Sync
### Summary
Added the first visible apparel sync path so transform packets can carry equipped clothing/armor slot snapshots and runtime proxies can mirror normal clothing, hat, and eyewear changes.
### Files Changed
- `plugin/include/F4TRemotePlayerState.h`
- `plugin/include/F4TNetworking.h`
- `plugin/src/F4TNetworking.cpp`
- `plugin/src/main.cpp`
- `plugin/src/F4TProxyActorController.cpp`
- `server/fake_client.py`
- `protocol/packets.md`
- `protocol/player-sync.md`
- `docs/protocol.md`
- `docs/architecture.md`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Added optional `equippedItems` transform data as a complete snapshot of tracked visible apparel biped slots. Empty `formId` values explicitly mean the remote player has nothing equipped in that tracked slot.
- Captured local equipment on the game-thread polling path and made equipment changes trigger a transform send even when the player is idle.
- Parsed remote equipment defensively on the networking thread, preserving compatibility with older clients that omit the field.
- Applied synced apparel on the proxy controller's game-thread path after proxy 3D is loaded, with throttled warnings for missing local ARMO forms.
- Cleared synced equipment when runtime proxy slots are held, reused, reassigned, or invalidated so a later remote player does not inherit the previous player's outfit.
### Testing
- Ran `python -m py_compile server\fake_client.py`; syntax check passed.
- Ran `build.bat`; plugin build succeeded. The build still reports pre-existing-style unreachable-code warnings in proxy/local animation debug code.
- In-game testing still needs to be run.
- Expected relay result: `server/fake_client.py` prints `equippedItems` from transform packets and old packets without the field still parse.
- Manual multiplayer checklist:
- Start the Python server and connect one Fallout 4 instance.
- Connect a second Fallout 4 instance in the same playable cell.
- Verify outgoing plugin transform packets include `equippedItems` after connect and after clothing changes.
- Verify the server receives and relays the field without dropping movement fields.
- Verify the receiving plugin parses remote equipment without warnings for valid vanilla apparel forms.
- Equip and unequip body clothing, a hat/helmet, and eyewear; then remove all clothing and confirm the proxy follows visually.
- Check `CommonwealthOnline.log` for equipment apply, missing-form, and proxy 3D recovery messages.
### Known Issues
- Power armor and weapon model visuals are not part of this milestone.
- Legendary instance data, condition, tint/material overrides, and receiver-missing modded forms are not replicated.
- In-game confirmation is still needed to verify `ActorEquipManager` plus 3D refresh reliably updates all target apparel pieces on runtime proxies.
### Next Steps
- Run the two-client in-game checklist.
- If some apparel pieces fail to appear, inspect whether they require equip-slot instance data or a different biped-slot coverage list.
---
## 2026-06-29 - Stale Runtime Proxy Adoption Fix
### Summary
+5
View File
@@ -59,3 +59,8 @@ If UDP discovery is unavailable, the plugin may fall back to probing
## Main Rule
The protocol should be testable outside Fallout 4 before it is used inside the F4SE plugin.
`transform` packets carry position, movement state, and optional
`equippedItems` apparel snapshots for visible clothing/armor proxy sync. The
canonical field list and compatibility rules live in
[`protocol/packets.md`](../protocol/packets.md).
+5 -1
View File
@@ -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,
+8
View File
@@ -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)
+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);
if (appended <= 0 || static_cast<std::size_t>(appended) >= remaining) {
LogWarningWithLocalPlayerPrefix("Could not format Commonwealth Online transform packet worldspace ID.");
return false;
packet["worldspaceId"] = std::format("{:08X}", a_worldspaceId);
}
packetSize += appended;
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) }
});
}
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;
+33
View File
@@ -69,6 +69,10 @@ Example after server processing:
"isJumping": false,
"weaponDrawn": false,
"movementSpeed": 186.4,
"equippedItems": [
{ "slot": "body", "formId": "0001F66A" },
{ "slot": "headband", "formId": "" }
],
"serverTime": 1780212128.3011043
}
```
@@ -92,6 +96,7 @@ isSneaking Optional movement-state flag; defaults to false when missing.
isJumping Optional movement-state flag; defaults to false when missing.
weaponDrawn Optional weapon drawn state; defaults to false when missing.
movementSpeed Optional derived movement speed in game units per second; defaults to 0.0 when missing.
equippedItems Optional full snapshot of tracked visible apparel slots. Each entry has a string `slot` and hex-string `formId`; an empty `formId` means the slot is intentionally unequipped.
serverTime Timestamp added by the server before broadcast.
```
@@ -110,6 +115,34 @@ teleport
Movement state fields are data-only for now. Receivers must treat them as
optional and must not reject older transform packets when they are absent.
Equipment fields are optional and additive. When `equippedItems` is missing,
receivers keep their existing/default proxy appearance. When present, it is a
complete snapshot for the tracked visible apparel slots:
```text
hairTop
hairLong
body
underTorso
underLeftArm
underRightArm
underLeftLeg
underRightLeg
aboveTorso
aboveLeftArm
aboveRightArm
aboveLeftLeg
aboveRightLeg
headband
eyes
scalp
```
The first equipment-sync milestone sends only resolved `ARMO` form IDs and
empty strings for unequipped tracked slots. Power armor, weapon models,
condition, legendary instance data, tint/material overrides, and forms missing
from the receiver load order are outside this packet extension.
### Disconnect Packet
Sent by the server to remaining connected clients when a client disconnects.
+13 -1
View File
@@ -59,8 +59,11 @@ isMoving
isSprinting
isSneaking
isJumping
isCrouching
weaponDrawn
movementSpeed
animationGraphSpeed
equippedItems
lastReceivedLocalTime
```
@@ -71,6 +74,11 @@ Movement state fields are also optional and default to not moving, not
sprinting, not sneaking, not jumping, weapon holstered, and speed `0.0` when
missing.
`equippedItems` is also optional. New clients send it as a complete snapshot of
tracked visible apparel slots with `{ slot, formId }` entries; empty `formId`
values mean that tracked slot is intentionally unequipped. Older clients can
omit the field and receivers keep the proxy's existing/default appearance.
This model proves the data shape and lifecycle before spawning remote actors.
For the current visual milestone, the controller represents only one remote
player and chooses the lowest available `playerId` if more than one remote
@@ -104,6 +112,9 @@ player exists.
resuming smoothing.
- Transform packets include movement state data (`isMoving`, `movementSpeed`,
`isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`).
- Transform packets can include optional `equippedItems` snapshots for visible
clothing, armor, hats, and eyewear slots. The game-thread proxy controller
applies those snapshots after proxy 3D is loaded.
- The plugin suppresses transform sends and proxy representation in the
`COVault109` solo cell.
- The game-thread proxy controller applies confirmed Havok animation graph
@@ -135,12 +146,13 @@ variable writes drive proxy locomotion visuals.
- Increase concurrent proxy cap beyond four players per client
- Cross-cell actor transfer without respawn (if respawn proves insufficient)
- Power armor and weapon model sync
## Not Required Yet
- Full animation graph sync
- Combat sync
- Inventory sync
- Full inventory sync beyond visible equipped apparel
- Quest sync
- Dialogue sync
- Settlement sync
+32
View File
@@ -66,6 +66,24 @@ def get_optional_game_time(packet: dict[str, Any], field_name: str, default: flo
return parsed_value
def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
value = packet.get("equippedItems", [])
if not isinstance(value, list):
return []
equipped_items: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
slot = item.get("slot")
form_id = item.get("formId", "")
if isinstance(slot, str) and isinstance(form_id, str):
equipped_items.append({"slot": slot, "formId": form_id})
return equipped_items
def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state
@@ -165,6 +183,7 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"movementSpeed": get_optional_float(packet, "movementSpeed"),
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
"equippedItems": get_optional_equipped_items(packet),
"lastReceivedLocalTime": time.time(),
}
@@ -175,6 +194,12 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
def print_remote_player(player: dict[str, Any]) -> None:
client_time = player["clientTime"] if player["clientTime"] is not None else "missing from packet"
server_time = player["serverTime"] if player["serverTime"] is not None else "missing from packet"
equipped_items = player.get("equippedItems", [])
equipment_text = (
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in equipped_items)
if equipped_items
else "<not sent>"
)
log(
"\n".join(
@@ -189,6 +214,7 @@ def print_remote_player(player: dict[str, Any]) -> None:
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
),
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
f"Equipment: {equipment_text}",
f"Cell: {player['cellId']}",
f"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}",
@@ -207,6 +233,11 @@ def print_remote_players() -> None:
log("Known remote players:")
for player_id in sorted(remote_players):
player = remote_players[player_id]
equipment_text = (
", ".join(f"{item['slot']}={item['formId'] or '<empty>'}" for item in player.get("equippedItems", []))
if player.get("equippedItems")
else "<not sent>"
)
log(
f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
@@ -215,6 +246,7 @@ def print_remote_players() -> None:
f"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
f"equipment={equipment_text}, "
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}"
)