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. - `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. - 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. - `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 ### 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. - 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 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. - 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. - 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 ## [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: both send and receive paths:
1. The Fallout 4 plugin reads local player transform data. 1. The Fallout 4 plugin reads local player transform data.
2. The plugin derives basic movement state on the game-thread polling path and 2. The plugin derives basic movement state and captures a visible equipped
sends it as optional data on transform packets. 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 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 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 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. actor.
17. Proxy visual updates are independent per slot. Normal movement is smoothed 17. Proxy visual updates are independent per slot. Normal movement is smoothed
toward that players latest target, while `cell_change`, `worldspace_change`, toward that players latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Movement and animation-related remote state is and `teleport` snap directly. Curated locomotion animation graph variables
still observed and logged only; no visual crouch/sneak, jump, weapon drawn, and optional equipped apparel snapshots are applied on the game thread after
locomotion, animation graph, or actor-state application is attempted. 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 players 18. When a remote player leaves the cell or disconnects, only that players
runtime proxy is moved to the hidden holding position inside runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected `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 - Suppressing local transform sends and remote proxy representation while the
local player is in solo cells such as `COVault109` local player is in solo cells such as `COVault109`
- Adding basic data-only movement state to transform packets - 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 - Throttling normal transform sends separately from readable local movement logs
- Receiving server packets on a background thread - Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally - 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 - Applying curated Havok animation graph variables to runtime proxies through
`F4TProxyAnimationSync` from the same remote movement state (writes on `F4TProxyAnimationSync` from the same remote movement state (writes on
transition and while moving; clears on hold, disconnect, reuse, and reassignment) 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 - Suppressing exposed AI movement intent for runtime proxies by clearing safe
process target handles and using validated package interruption/do-nothing process target handles and using validated package interruption/do-nothing
calls when combat, pathing, flee/alarm, bump, or package movement state is 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 ## 2026-06-29 - Stale Runtime Proxy Adoption Fix
### Summary ### Summary
+5
View File
@@ -59,3 +59,8 @@ If UDP discovery is unavailable, the plugin may fall back to probing
## Main Rule ## Main Rule
The protocol should be testable outside Fallout 4 before it is used inside the F4SE plugin. 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 #pragma once
#include "F4TRemotePlayerState.h"
#include <cstdint> #include <cstdint>
#include <span>
#include <string> #include <string>
namespace F4T::Networking namespace F4T::Networking
@@ -28,7 +31,8 @@ namespace F4T::Networking
bool a_isCrouching = false, bool a_isCrouching = false,
bool a_weaponDrawn = false, bool a_weaponDrawn = false,
float a_movementSpeed = 0.0F, 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( bool SendWorldStatePacket(
float a_gameHour, float a_gameHour,
float a_gameDaysPassed, float a_gameDaysPassed,
+8
View File
@@ -22,6 +22,12 @@ namespace F4T::RemotePlayerState
std::chrono::steady_clock::time_point captureTime{}; std::chrono::steady_clock::time_point captureTime{};
}; };
struct RemoteEquippedItem
{
std::string slot;
std::uint32_t formId{};
};
struct RemotePlayerState struct RemotePlayerState
{ {
std::uint32_t playerId{}; std::uint32_t playerId{};
@@ -42,6 +48,8 @@ namespace F4T::RemotePlayerState
bool weaponDrawn{ false }; bool weaponDrawn{ false };
float movementSpeed{ 0.0F }; float movementSpeed{ 0.0F };
float animationGraphSpeed{ -1.0F }; float animationGraphSpeed{ -1.0F };
bool hasEquipmentUpdate{ false };
std::vector<RemoteEquippedItem> equippedItems;
std::chrono::steady_clock::time_point lastReceivedLocalTime{}; std::chrono::steady_clock::time_point lastReceivedLocalTime{};
// Phase 2: Actor state and action events (TiltedEvolution alignment) // Phase 2: Actor state and action events (TiltedEvolution alignment)
+97 -70
View File
@@ -19,10 +19,12 @@
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <thread> #include <thread>
#include <unordered_map> #include <unordered_map>
#include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -359,6 +361,61 @@ namespace
return value; 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) void HandleServerWorldStatePacket(const Json& a_packet)
{ {
F4T::WorldStateSync::PendingServerWorldState pendingState{}; F4T::WorldStateSync::PendingServerWorldState pendingState{};
@@ -534,6 +591,9 @@ namespace
actorStateFlags2 = static_cast<std::uint32_t>(*flags2); actorStateFlags2 = static_cast<std::uint32_t>(*flags2);
} }
bool hasEquipmentUpdate = false;
auto equippedItems = ReadEquippedItems(a_packet, hasEquipmentUpdate);
F4T::RemotePlayerState::RemotePlayerState remoteState{ F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId, *playerId,
static_cast<float>(*x), static_cast<float>(*x),
@@ -553,6 +613,8 @@ namespace
ReadBool(a_packet, "weaponDrawn").value_or(false), ReadBool(a_packet, "weaponDrawn").value_or(false),
static_cast<float>(movementSpeed), static_cast<float>(movementSpeed),
static_cast<float>(animationGraphSpeed), static_cast<float>(animationGraphSpeed),
hasEquipmentUpdate,
std::move(equippedItems),
std::chrono::steady_clock::now() std::chrono::steady_clock::now()
}; };
@@ -908,7 +970,8 @@ namespace F4T::Networking
bool a_isCrouching, bool a_isCrouching,
bool a_weaponDrawn, bool a_weaponDrawn,
float a_movementSpeed, float a_movementSpeed,
float a_animationGraphSpeed) float a_animationGraphSpeed,
std::span<const F4T::RemotePlayerState::RemoteEquippedItem> a_equippedItems)
{ {
if (!IsConnectedToServer()) { if (!IsConnectedToServer()) {
return false; return false;
@@ -917,84 +980,48 @@ namespace F4T::Networking
const auto clientTime = const auto clientTime =
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
std::array<char, 512> packet{}; nlohmann::json packet{
auto packetSize = std::snprintf( { "type", "transform" },
packet.data(), { "x", a_x },
packet.size(), { "y", a_y },
"{\"type\":\"transform\",\"x\":%.2f,\"y\":%.2f,\"z\":%.2f,\"angleZ\":%.2f,\"movementType\":\"%s\",\"clientTime\":%.3f," { "z", a_z },
"\"isMoving\":%s,\"isSprinting\":%s,\"isSneaking\":%s,\"isJumping\":%s,\"isCrouching\":%s,\"weaponDrawn\":%s,\"movementSpeed\":%.1f", { "angleZ", a_angleZ },
a_x, { "movementType", a_movementType ? a_movementType : "normal" },
a_y, { "clientTime", clientTime },
a_z, { "isMoving", a_isMoving },
a_angleZ, { "isSprinting", a_isSprinting },
a_movementType ? a_movementType : "normal", { "isSneaking", a_isSneaking },
clientTime, { "isJumping", a_isJumping },
a_isMoving ? "true" : "false", { "isCrouching", a_isCrouching },
a_isSprinting ? "true" : "false", { "weaponDrawn", a_weaponDrawn },
a_isSneaking ? "true" : "false", { "movementSpeed", a_movementSpeed }
a_isJumping ? "true" : "false", };
a_isCrouching ? "true" : "false",
a_weaponDrawn ? "true" : "false",
a_movementSpeed);
if (packetSize > 0 && a_animationGraphSpeed >= 0.0F && std::isfinite(a_animationGraphSpeed)) { if (a_animationGraphSpeed >= 0.0F && std::isfinite(a_animationGraphSpeed)) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize); packet["animationGraphSpeed"] = a_animationGraphSpeed;
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_hasCellId) { if (a_hasCellId) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize); packet["cellId"] = std::format("{:08X}", a_cellId);
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;
} }
if (a_hasWorldspaceId) { if (a_hasWorldspaceId) {
const auto remaining = packet.size() - static_cast<std::size_t>(packetSize); packet["worldspaceId"] = std::format("{:08X}", a_worldspaceId);
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;
} }
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); auto packetText = packet.dump();
const auto appended = std::snprintf(packet.data() + packetSize, remaining, "}\n"); packetText.push_back('\n');
if (appended <= 0 || static_cast<std::size_t>(appended) >= remaining) {
LogWarningWithLocalPlayerPrefix("Could not finish Commonwealth Online transform packet.");
return false;
}
packetSize += appended;
int bytesSent = 0; int bytesSent = 0;
{ {
@@ -1003,7 +1030,7 @@ namespace F4T::Networking
return false; 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) { if (bytesSent == SOCKET_ERROR) {
@@ -1016,7 +1043,7 @@ namespace F4T::Networking
return false; return false;
} }
if (bytesSent != packetSize) { if (bytesSent != static_cast<int>(packetText.size())) {
LogWarningWithLocalPlayerPrefix("Could not send full Commonwealth Online transform packet."); LogWarningWithLocalPlayerPrefix("Could not send full Commonwealth Online transform packet.");
CloseSocket(); CloseSocket();
return false; return false;
+277
View File
@@ -12,8 +12,12 @@
#include "F4TSoloCell.h" #include "F4TSoloCell.h"
#include "RE/A/ACTOR_STANCE.h" #include "RE/A/ACTOR_STANCE.h"
#include "RE/A/ActorEquipManager.h"
#include "RE/A/ActorValue.h" #include "RE/A/ActorValue.h"
#include "RE/B/bhkCharacterController.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/A/AIProcess.h"
#include "RE/C/COMMAND_TYPE.h" #include "RE/C/COMMAND_TYPE.h"
#include "RE/C/Console.h" #include "RE/C/Console.h"
@@ -24,9 +28,11 @@
#include "RE/P/PTYPE.h" #include "RE/P/PTYPE.h"
#include "RE/R/RESET_3D_FLAGS.h" #include "RE/R/RESET_3D_FLAGS.h"
#include "RE/T/TESDataHandler.h" #include "RE/T/TESDataHandler.h"
#include "RE/T/TESObjectARMO.h"
#include "RE/T/TaskQueueInterface.h" #include "RE/T/TaskQueueInterface.h"
#include <algorithm> #include <algorithm>
#include <array>
#include <charconv> #include <charconv>
#include <mutex> #include <mutex>
#include <cmath> #include <cmath>
@@ -139,6 +145,34 @@ namespace
constexpr auto kNotInTestCellLogInterval = 30s; constexpr auto kNotInTestCellLogInterval = 30s;
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState; 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 enum class ProxyMovementMode
{ {
@@ -329,6 +363,8 @@ namespace
std::optional<std::string> lastObservedRemoteCellId; std::optional<std::string> lastObservedRemoteCellId;
std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket; std::optional<ProxyAnimationSpeedBucket> lastObservedMovementSpeedBucket;
std::optional<bool> lastAppliedVisualSneakingState; std::optional<bool> lastAppliedVisualSneakingState;
bool hasAppliedEquipmentState{ false };
std::vector<RemoteEquippedItem> lastAppliedEquipmentItems;
std::optional<RemotePlayer> lastRemotePlayerSnapshot; std::optional<RemotePlayer> lastRemotePlayerSnapshot;
F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{}; 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) bool ProxyHeadNodesMissing(const RE::Actor& a_proxy)
{ {
const auto* npc = a_proxy.GetNPC(); 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.spawnAttemptInProgress = false;
a_slot.spawnSucceeded = false; a_slot.spawnSucceeded = false;
a_slot.restoredForCurrentState = false; a_slot.restoredForCurrentState = false;
ResetSlotEquipmentTracking(a_slot);
if (a_slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected) { if (a_slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected) {
a_slot.heldSinceTime = {}; a_slot.heldSinceTime = {};
a_slot.reusableLogged = false; a_slot.reusableLogged = false;
@@ -5678,6 +5944,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const auto releasedFormId = proxy->GetFormID(); const auto releasedFormId = proxy->GetFormID();
ClearSlotVisualSneakIfApplied(slot, *proxy, "local cell change"); ClearSlotVisualSneakIfApplied(slot, *proxy, "local cell change");
ClearSlotProxyAnimationIfApplied(slot, *proxy, "local cell change"); ClearSlotProxyAnimationIfApplied(slot, *proxy, "local cell change");
ClearSlotEquipmentIfApplied(slot, *proxy, "local cell change");
NeutralizeProxyActor(slot, *proxy, a_player, ProxyNeutralizationReason::kHolding); NeutralizeProxyActor(slot, *proxy, a_player, ProxyNeutralizationReason::kHolding);
if constexpr (kEnablePrePlacedProxyPool) { if constexpr (kEnablePrePlacedProxyPool) {
MoveProxyToHoldingPosition( MoveProxyToHoldingPosition(
@@ -5744,6 +6011,10 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot, a_slot,
a_proxy, a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold"); a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
ClearSlotEquipmentIfApplied(
a_slot,
a_proxy,
a_reason == ProxyLifecycleState::kHeldDisconnected ? "disconnect hold" : "hold");
F4T::ProxyGameAPI::PositionRemoteActor( F4T::ProxyGameAPI::PositionRemoteActor(
a_proxy, a_proxy,
GetSlotHoldingPosition(a_slot), 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)) { if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
ClearSlotVisualSneakIfApplied(slot, *proxy, "reuse"); ClearSlotVisualSneakIfApplied(slot, *proxy, "reuse");
ClearSlotProxyAnimationIfApplied(slot, *proxy, "reuse"); ClearSlotProxyAnimationIfApplied(slot, *proxy, "reuse");
ClearSlotEquipmentIfApplied(slot, *proxy, "reuse");
} else { } else {
ResetSlotVisualSneakTracking(slot); ResetSlotVisualSneakTracking(slot);
ResetSlotAppliedAnimationState(slot); ResetSlotAppliedAnimationState(slot);
ResetSlotEquipmentTracking(slot);
} }
slot.lifecycleState = ProxyLifecycleState::kReusable; slot.lifecycleState = ProxyLifecycleState::kReusable;
slot.holdReason.reset(); slot.holdReason.reset();
@@ -5893,6 +6166,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_remotePlayer.x, a_remotePlayer.x,
a_remotePlayer.y, a_remotePlayer.y,
a_remotePlayer.z)); a_remotePlayer.z));
ApplyProxyEquipmentFromRemoteState(a_slot, a_proxy, a_remotePlayer);
UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy); UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy);
LogProxyMovementDiagnostic( LogProxyMovementDiagnostic(
a_proxy, a_proxy,
@@ -6074,6 +6348,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.holdReason.reset(); a_slot.holdReason.reset();
a_slot.heldSinceTime = {}; a_slot.heldSinceTime = {};
a_slot.reusableLogged = false; a_slot.reusableLogged = false;
ApplyProxyEquipmentFromRemoteState(a_slot, a_proxy, a_remotePlayer);
UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy); UpdateProxyAnimationStateDebug(a_slot, a_remotePlayer, a_proxy);
if (shouldSnap) { 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)) { if (auto* proxy = TryResolveSlotProxy(slotValue, a_parentCell, a_player)) {
ClearSlotVisualSneakIfApplied(slotValue, *proxy, "reassignment"); ClearSlotVisualSneakIfApplied(slotValue, *proxy, "reassignment");
ClearSlotProxyAnimationIfApplied(slotValue, *proxy, "reassignment"); ClearSlotProxyAnimationIfApplied(slotValue, *proxy, "reassignment");
ClearSlotEquipmentIfApplied(slotValue, *proxy, "reassignment");
} else { } else {
ResetSlotVisualSneakTracking(slotValue); ResetSlotVisualSneakTracking(slotValue);
ResetSlotAppliedAnimationState(slotValue); ResetSlotAppliedAnimationState(slotValue);
ResetSlotEquipmentTracking(slotValue);
} }
slotValue.previousRemotePlayerId = previousRemotePlayerId; slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId; slotValue.remotePlayerId = a_remotePlayer.playerId;
+99 -5
View File
@@ -10,14 +10,19 @@
#include "F4TWorldStateSync.h" #include "F4TWorldStateSync.h"
#include "RE/A/ACTOR_STANCE.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/B/BSFixedString.h"
#include "RE/I/IAnimationGraphManagerHolder.h" #include "RE/I/IAnimationGraphManagerHolder.h"
#include <array>
#include <cmath> #include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstdint> #include <cstdint>
#include <format> #include <format>
#include <string>
#include <string_view> #include <string_view>
#include <vector>
namespace namespace
{ {
@@ -58,6 +63,14 @@ namespace
bool finalIsSneaking; bool finalIsSneaking;
}; };
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
struct SyncedEquipmentSlot
{
std::string_view name;
RE::BIPED_OBJECT bipedObject;
};
constexpr auto kTransformSendInterval = 100ms; constexpr auto kTransformSendInterval = 100ms;
constexpr auto kIdleTransformHeartbeatInterval = 2s; constexpr auto kIdleTransformHeartbeatInterval = 2s;
constexpr auto kTransformLogInterval = 1s; constexpr auto kTransformLogInterval = 1s;
@@ -73,6 +86,27 @@ namespace
constexpr auto kJumpDiagnosticLogInterval = 1s; constexpr auto kJumpDiagnosticLogInterval = 1s;
constexpr auto kSneakDiagnosticLogInterval = 2s; 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) std::string GetLocalPlayerLogMessage(std::string_view a_message)
{ {
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message); return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
@@ -157,6 +191,56 @@ namespace
return location; 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) float GetSquaredPositionDistance(const PlayerTransform& a_previous, const PlayerTransform& a_current)
{ {
const auto deltaX = a_current.x - a_previous.x; const auto deltaX = a_current.x - a_previous.x;
@@ -479,6 +563,7 @@ namespace
static PlayerTransform lastDebugSampleTransform{}; static PlayerTransform lastDebugSampleTransform{};
static PlayerLocation lastKnownLocation{}; static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{}; static PlayerMovementState lastSentMovementState{};
static std::vector<RemoteEquippedItem> lastSentEquipmentState;
static auto lastSendTime = std::chrono::steady_clock::time_point{}; static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = std::chrono::steady_clock::time_point{}; static auto lastLogTime = std::chrono::steady_clock::time_point{};
static auto lastDebugSampleTime = 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 currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player); const auto currentLocation = GetPlayerLocation(*player);
const auto currentEquipmentState = CaptureLocalEquippedItems(*player);
const auto currentTime = std::chrono::steady_clock::now(); const auto currentTime = std::chrono::steady_clock::now();
const auto debugMovementState = GetPlayerMovementState( const auto debugMovementState = GetPlayerMovementState(
*player, *player,
@@ -512,6 +598,7 @@ namespace
lastDebugSampleTransform = currentTransform; lastDebugSampleTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSentMovementState = debugMovementState; lastSentMovementState = debugMovementState;
lastSentEquipmentState = currentEquipmentState;
lastSendTime = currentTime; lastSendTime = currentTime;
lastLogTime = currentTime; lastLogTime = currentTime;
lastDebugSampleTime = currentTime; lastDebugSampleTime = currentTime;
@@ -558,7 +645,8 @@ namespace
debugMovementState.isCrouching, debugMovementState.isCrouching,
debugMovementState.weaponDrawn, debugMovementState.weaponDrawn,
debugMovementState.movementSpeed, debugMovementState.movementSpeed,
-1.0F)) { -1.0F,
currentEquipmentState)) {
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Initial transform sent with cellId={:08X}{}.", "Initial transform sent with cellId={:08X}{}.",
currentLocation.hasCellId ? currentLocation.cellId : 0U, currentLocation.hasCellId ? currentLocation.cellId : 0U,
@@ -588,16 +676,17 @@ namespace
const auto isSpecialMovement = movementType != std::string_view{ "normal" }; const auto isSpecialMovement = movementType != std::string_view{ "normal" };
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform); const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState); const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState);
const auto hasEquipmentChanged = !AreEquipmentStatesEqual(lastSentEquipmentState, currentEquipmentState);
const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval; const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval;
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !idleHeartbeatDue) { if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !idleHeartbeatDue) {
lastDebugSampleTransform = currentTransform; lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime; lastDebugSampleTime = currentTime;
return; return;
} }
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) { if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged) {
// Idle heartbeat: keep remote clients updated even when both players are standing still. // 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; return;
} }
@@ -644,16 +733,21 @@ namespace
networkMovementState.isCrouching, networkMovementState.isCrouching,
networkMovementState.weaponDrawn, networkMovementState.weaponDrawn,
networkMovementState.movementSpeed, networkMovementState.movementSpeed,
animationGraphSpeed); animationGraphSpeed,
currentEquipmentState);
if (sentTransform) { if (sentTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSentMovementState = networkMovementState; lastSentMovementState = networkMovementState;
lastSentEquipmentState = currentEquipmentState;
lastSendTime = currentTime; lastSendTime = currentTime;
if (isSpecialMovement) { if (isSpecialMovement) {
LogSpecialMovement(movementType); LogSpecialMovement(movementType);
lastLogTime = currentTime; lastLogTime = currentTime;
} else if (hasEquipmentChanged) {
LogInfoWithLocalPlayerPrefix("Detected local equipment change. Sending immediate transform update.");
lastLogTime = currentTime;
} else if (currentTime - lastLogTime >= kTransformLogInterval) { } else if (currentTime - lastLogTime >= kTransformLogInterval) {
LogPlayerTransform(currentTransform); LogPlayerTransform(currentTransform);
lastLogTime = currentTime; lastLogTime = currentTime;
+33
View File
@@ -69,6 +69,10 @@ Example after server processing:
"isJumping": false, "isJumping": false,
"weaponDrawn": false, "weaponDrawn": false,
"movementSpeed": 186.4, "movementSpeed": 186.4,
"equippedItems": [
{ "slot": "body", "formId": "0001F66A" },
{ "slot": "headband", "formId": "" }
],
"serverTime": 1780212128.3011043 "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. isJumping Optional movement-state flag; defaults to false when missing.
weaponDrawn Optional weapon drawn state; 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. 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. 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 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. 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 ### Disconnect Packet
Sent by the server to remaining connected clients when a client disconnects. Sent by the server to remaining connected clients when a client disconnects.
+13 -1
View File
@@ -59,8 +59,11 @@ isMoving
isSprinting isSprinting
isSneaking isSneaking
isJumping isJumping
isCrouching
weaponDrawn weaponDrawn
movementSpeed movementSpeed
animationGraphSpeed
equippedItems
lastReceivedLocalTime 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 sprinting, not sneaking, not jumping, weapon holstered, and speed `0.0` when
missing. 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. This model proves the data shape and lifecycle before spawning remote actors.
For the current visual milestone, the controller represents only one remote For the current visual milestone, the controller represents only one remote
player and chooses the lowest available `playerId` if more than one remote player and chooses the lowest available `playerId` if more than one remote
@@ -104,6 +112,9 @@ player exists.
resuming smoothing. resuming smoothing.
- Transform packets include movement state data (`isMoving`, `movementSpeed`, - Transform packets include movement state data (`isMoving`, `movementSpeed`,
`isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`). `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 - The plugin suppresses transform sends and proxy representation in the
`COVault109` solo cell. `COVault109` solo cell.
- The game-thread proxy controller applies confirmed Havok animation graph - 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 - Increase concurrent proxy cap beyond four players per client
- Cross-cell actor transfer without respawn (if respawn proves insufficient) - Cross-cell actor transfer without respawn (if respawn proves insufficient)
- Power armor and weapon model sync
## Not Required Yet ## Not Required Yet
- Full animation graph sync - Full animation graph sync
- Combat sync - Combat sync
- Inventory sync - Full inventory sync beyond visible equipped apparel
- Quest sync - Quest sync
- Dialogue sync - Dialogue sync
- Settlement 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 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: def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state global host_world_state
@@ -165,6 +183,7 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"movementSpeed": get_optional_float(packet, "movementSpeed"), "movementSpeed": get_optional_float(packet, "movementSpeed"),
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"), "actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"), "actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
"equippedItems": get_optional_equipped_items(packet),
"lastReceivedLocalTime": time.time(), "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: def print_remote_player(player: dict[str, Any]) -> None:
client_time = player["clientTime"] if player["clientTime"] is not None else "missing from packet" 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" 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( log(
"\n".join( "\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"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}"
), ),
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}", f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
f"Equipment: {equipment_text}",
f"Cell: {player['cellId']}", f"Cell: {player['cellId']}",
f"Worldspace: {player['worldspaceId']}", f"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}", f"Client Time: {client_time}",
@@ -207,6 +233,11 @@ def print_remote_players() -> None:
log("Known remote players:") log("Known remote players:")
for player_id in sorted(remote_players): for player_id in sorted(remote_players):
player = remote_players[player_id] 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( log(
f"- Player {player_id}: " f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), " 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"sneaking={player['isSneaking']}, jumping={player['isJumping']}, "
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, " f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, " f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
f"equipment={equipment_text}, "
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, " f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}" f"serverTime={player['serverTime']}"
) )