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