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.
|
- `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
|
||||||
|
|
||||||
|
|||||||
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:
|
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 player’s latest target, while `cell_change`, `worldspace_change`,
|
toward that player’s 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 player’s
|
18. When a remote player leaves the cell or disconnects, only that player’s
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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']}"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user