Add appearance sync and late-join replay

Adds optional `appearance` snapshots to transform packets, applies supported appearance data to runtime proxies, and has the server replay stored transforms to newly connected clients so late joiners receive current visuals immediately.
This commit is contained in:
2026-06-29 16:46:50 +12:00
parent ad7f287899
commit 4b8f5246c4
15 changed files with 772 additions and 38 deletions
+4
View File
@@ -10,6 +10,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased] ## [Unreleased]
### Added ### Added
- Optional `appearance` transform snapshots for best-effort runtime proxy body and face visuals, including height, body morph weight, body tint, hair colors, head-part IDs, and morph slider values while leaving gender switching out.
- Game-thread proxy appearance application for supported `appearance` fields after runtime proxy 3D is ready, with race switching deferred for future proxy-base compatibility work.
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payloads for relayed `appearance` transform data.
- `COVault109` solo-cell handling in the plugin so first-time character-creation space suppresses outgoing transform packets and remote proxy representation. - `COVault109` solo-cell handling in the plugin so first-time character-creation space suppresses outgoing transform packets and remote proxy representation.
- `compile-papyrus.bat` to compile active Papyrus sources from `creation-kit\scripts\source` into `creation-kit\scripts\compiled` using the Fallout 4 Creation Kit Papyrus compiler, resolving the Fallout 4 install with the same path workflow as `deploy-all.bat`, using saved `.fallout4-path` values without re-prompting during builds, and skipping reference-only `CoSync*.psc` files. - `compile-papyrus.bat` to compile active Papyrus sources from `creation-kit\scripts\source` into `creation-kit\scripts\compiled` using the Fallout 4 Creation Kit Papyrus compiler, resolving the Fallout 4 install with the same path workflow as `deploy-all.bat`, using saved `.fallout4-path` values without re-prompting during builds, and skipping reference-only `CoSync*.psc` files.
- `build-all.bat` now compiles Papyrus scripts before staging the mod package, and still stages the repo build folder for inspection when Papyrus compilation fails. - `build-all.bat` now compiles Papyrus scripts before staging the mod package, and still stages the repo build folder for inspection when Papyrus compilation fails.
@@ -26,6 +29,7 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
- `server/fake_client.py` parsing and output for relayed `equippedItems` transform data. - `server/fake_client.py` parsing and output for relayed `equippedItems` transform data.
### Fixed ### Fixed
- New clients now receive existing players' last transform snapshots immediately after `welcome`, including `appearance`, so late joiners do not start with default proxy visuals while waiting for the next sender heartbeat.
- 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.
- Stale saved runtime `COPlayerProxy` actors are deleted before spawning a fresh PlaceAtMe proxy, preventing duplicate visible proxies when an old uncontrolled proxy persisted in a save. - Stale saved runtime `COPlayerProxy` actors are deleted before spawning a fresh PlaceAtMe proxy, preventing duplicate visible proxies when an old uncontrolled proxy persisted in a save.
- Runtime proxy promotion no longer waits for camera-frustum `IsVisible()` before assigning a newly spawned proxy, preventing remote players from appearing only after the local player turns toward them. - Runtime proxy promotion no longer waits for camera-frustum `IsVisible()` before assigning a newly spawned proxy, preventing remote players from appearing only after the local player turns toward them.
+38 -26
View File
@@ -12,9 +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 and captures a visible equipped 2. The plugin derives basic movement state and captures visible equipped
apparel snapshot on the game-thread polling path, then sends both as optional apparel and player appearance snapshots on the game-thread polling path, then
data on transform packets. sends them 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
@@ -24,11 +24,15 @@ both send and receive paths:
5. The plugin receives its `welcome` packet and stores its assigned `playerId`. 5. The plugin receives its `welcome` packet and stores its assigned `playerId`.
6. The server adds `playerId` and `serverTime` to transform packets. 6. The server adds `playerId` and `serverTime` to transform packets.
7. The server broadcasts transform packets to other connected clients. 7. The server broadcasts transform packets to other connected clients.
8. The plugin and `server/fake_client.py` store remote player state by `playerId`. 8. When a new client connects, the server immediately sends the last stored
9. The server broadcasts `disconnect` packets when clients disconnect. transform snapshot for each already-connected player to that new client so
10. The plugin and fake client remove disconnected players from their remote late joiners receive current position, equipment, and appearance state
without waiting for each sender's next heartbeat.
9. The plugin and `server/fake_client.py` store remote player state by `playerId`.
10. The server broadcasts `disconnect` packets when clients disconnect.
11. The plugin and fake client remove disconnected players from their remote
player tables. player tables.
11. The server tracks a world-state host (`worldStateHostPlayerId`). The first 12. The server tracks a world-state host (`worldStateHostPlayerId`). The first
connected client becomes host; when the host disconnects, the server reassigns connected client becomes host; when the host disconnects, the server reassigns
host to the lowest remaining `playerId` and broadcasts `worldStateHost`. The host to the lowest remaining `playerId` and broadcasts `worldStateHost`. The
host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional
@@ -38,29 +42,29 @@ both send and receive paths:
`serverWorldState` to all clients for admin weather (`fw`) and time `serverWorldState` to all clients for admin weather (`fw`) and time
(`set gamehour to HHmm`) commands. Promoted hosts continue with their (`set gamehour to HHmm`) commands. Promoted hosts continue with their
already-synced local time and weather so remaining clients should not see a jump. already-synced local time and weather so remaining clients should not see a jump.
12. The plugin treats `COVault109` as a solo cell for first-time character 13. The plugin treats `COVault109` as a solo cell for first-time character
creation. On entry it can send one location update so other clients hold any creation. On entry it can send one location update so other clients hold any
stale proxy, then outgoing transform packets are suppressed and remote proxy stale proxy, then outgoing transform packets are suppressed and remote proxy
representation is skipped while the local player remains inside. Looks and representation is skipped while the local player remains inside. Looks and
SPECIAL setup therefore stay visually single-player. The relay protocol is SPECIAL setup therefore stay visually single-player. The relay protocol is
unchanged; the rule is enforced client-side by the plugin. unchanged; the rule is enforced client-side by the plugin.
13. On the game-thread update path, the proxy controller reads a copied 14. On the game-thread update path, the proxy controller reads a copied
remote-player snapshot before attempting any actor work. The snapshot is remote-player snapshot before attempting any actor work. The snapshot is
sorted by `playerId`, and only remote players with valid transform data in sorted by `playerId`, and only remote players with valid transform data in
the same cell are eligible for runtime proxy representation. the same cell are eligible for runtime proxy representation.
14. For each eligible remote `playerId`, the controller maintains a 15. For each eligible remote `playerId`, the controller maintains a
`remotePlayerId -> runtime proxy slot` mapping. Each slot stores its `remotePlayerId -> runtime proxy slot` mapping. Each slot stores its
`ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state, `ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state,
movement smoothing state, and data-only animation debug state. Stage 4 caps runtime movement smoothing state, and data-only animation debug state. Stage 4 caps runtime
proxy slots at four actors for testing. proxy slots at four actors for testing.
15. Runtime proxies are demand-driven. A slot executes the Stage 3.7 16. Runtime proxies are demand-driven. A slot executes the Stage 3.7
PlaceAtMe-backed spawn flow only when that remote player needs a same-cell PlaceAtMe-backed spawn flow only when that remote player needs a same-cell
representation, and only one PlaceAtMe spawn attempt may run at a time so representation, and only one PlaceAtMe spawn attempt may run at a time so
candidate isolation remains unambiguous. Stage 4 captures the remote candidate isolation remains unambiguous. Stage 4 captures the remote
player's current valid transform before spawning and moves the isolated player's current valid transform before spawning and moves the isolated
runtime actor to that transform immediately, so normal remote representation runtime actor to that transform immediately, so normal remote representation
does not visually start at the local player offset. does not visually start at the local player offset.
16. Runtime proxies receive a small game-thread neutralization pass when a 17. Runtime proxies receive a small game-thread neutralization pass when a
PlaceAtMe-backed actor is promoted, when a reusable slot is reassigned, while PlaceAtMe-backed actor is promoted, when a reusable slot is reassigned, while
active on a throttle, and when moved or kept in holding. The pass only uses active on a throttle, and when moved or kept in holding. The pass only uses
confirmed CommonLibF4 actor APIs/fields to stop combat, clear combat target confirmed CommonLibF4 actor APIs/fields to stop combat, clear combat target
@@ -70,61 +74,62 @@ both send and receive paths:
alarm, bump, or package movement intent is observed. It does not disable, alarm, bump, or package movement intent is observed. It does not disable,
hide, delete, despawn, alpha-fade, or force animation graph events on the hide, delete, despawn, alpha-fade, or force animation graph events on the
actor. actor.
17. Proxy visual updates are independent per slot. Normal movement is smoothed 18. Proxy visual updates are tracked per slot. Normal movement is smoothed
toward that players latest target, while `cell_change`, `worldspace_change`, toward that players latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Curated locomotion animation graph variables and `teleport` snap directly. Curated locomotion animation graph variables
and optional equipped apparel snapshots are applied on the game thread after plus optional equipped apparel and appearance snapshots are applied on the
the proxy has loaded 3D. Visual crouch, full weapon model state, power armor, game thread after the proxy has loaded 3D. Gender switching, visual crouch,
and raw actor-state bitfield application remain outside the current sync path. full weapon model state, power armor, and raw actor-state bitfield
18. When a remote player leaves the cell or disconnects, only that players application remain outside the current sync path.
19. When a remote player leaves the cell or disconnects, only that players
runtime proxy is moved to the hidden holding position inside runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected `F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
players who leave the cell keep their slot reserved, while disconnected players who leave the cell keep their slot reserved, while disconnected
held slots become reusable only after the configured grace period. Runtime held slots become reusable only after the configured grace period. Runtime
slots use per-slot holding positions with spacing and a game-thread slots use per-slot holding positions with spacing and a game-thread
correction check so held actors do not stack or drift back into view. correction check so held actors do not stack or drift back into view.
19. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single 20. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single
backup representation. If runtime spawning fails for one deterministic backup representation. If runtime spawning fails for one deterministic
selected remote player, the fallback can represent that one player only; it selected remote player, the fallback can represent that one player only; it
is not used for multiple remote players. is not used for multiple remote players.
20. As part of the staged runtime proxy actor manager work, the same game-thread 21. As part of the staged runtime proxy actor manager work, the same game-thread
proxy controller also logs whether `Fallout4Together_Test.esp` is loaded and proxy controller also logs whether `Fallout4Together_Test.esp` is loaded and
whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID. whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID.
21. Stage 3.1 adds a temporary diagnostic layer around the single runtime proxy: 22. Stage 3.1 adds a temporary diagnostic layer around the single runtime proxy:
post-spawn visibility data, loaded-3D pointer checks where safe, console post-spawn visibility data, loaded-3D pointer checks where safe, console
helper logs, active source logs, and throttled post-movement diagnostics. helper logs, active source logs, and throttled post-movement diagnostics.
The diagnostic mode can force the runtime proxy near the local player to The diagnostic mode can force the runtime proxy near the local player to
prove whether the created actor is visibly renderable. prove whether the created actor is visibly renderable.
22. Stage 3.2 adds a temporary near-player visibility hold for that same single 23. Stage 3.2 adds a temporary near-player visibility hold for that same single
runtime proxy. During the hold, remote state is still selected and validated, runtime proxy. During the hold, remote state is still selected and validated,
but runtime proxy restore and remote movement are deferred long enough to but runtime proxy restore and remote movement are deferred long enough to
test whether a runtime-created actor ref can load visible 3D while kept near test whether a runtime-created actor ref can load visible 3D while kept near
the local player. the local player.
23. Stage 3.3 adds a temporary vanilla actor-base diagnostic layer. It attempts 24. Stage 3.3 adds a temporary vanilla actor-base diagnostic layer. It attempts
to resolve Codsworth as a visible vanilla `TESNPC` candidate, then passes to resolve Codsworth as a visible vanilla `TESNPC` candidate, then passes
that actor base into the same runtime spawn, handle validation, near-player that actor base into the same runtime spawn, handle validation, near-player
hold, and visibility diagnostic path. This isolates custom actor-base issues hold, and visibility diagnostic path. This isolates custom actor-base issues
from runtime-spawn/init issues without changing the final architecture. from runtime-spawn/init issues without changing the final architecture.
24. Stage 3.4 adds a temporary absolute FormID and existing-reference diagnostic 25. Stage 3.4 adds a temporary absolute FormID and existing-reference diagnostic
layer. It compares Codsworth base-form runtime spawning against moving the layer. It compares Codsworth base-form runtime spawning against moving the
existing placed Codsworth reference near the local player as a visibility existing placed Codsworth reference near the local player as a visibility
control. This separates runtime actor creation/init problems from vanilla control. This separates runtime actor creation/init problems from vanilla
actor-base or placed-reference movement problems and does not change the actor-base or placed-reference movement problems and does not change the
final architecture. final architecture.
25. Stage 3.5 adds a temporary PlaceAtMe-equivalent diagnostic after manual 26. Stage 3.5 adds a temporary PlaceAtMe-equivalent diagnostic after manual
console validation proved `player.placeatme 000179FF 1` can spawn visible console validation proved `player.placeatme 000179FF 1` can spawn visible
Codsworth in `F4TTestCell01`. The plugin executes that console placement path Codsworth in `F4TTestCell01`. The plugin executes that console placement path
from the local player, resolves the newly created Codsworth actor near the from the local player, resolves the newly created Codsworth actor near the
player, and holds/logs it separately from the `CreateReferenceAtLocation` player, and holds/logs it separately from the `CreateReferenceAtLocation`
runtime proxy path. runtime proxy path.
26. Stage 3.6 refines that temporary PlaceAtMe diagnostic layer. It snapshots 27. Stage 3.6 refines that temporary PlaceAtMe diagnostic layer. It snapshots
known Codsworth refs before executing `player.placeatme 000179FF 1`, accepts known Codsworth refs before executing `player.placeatme 000179FF 1`, accepts
only a newly isolated Codsworth ref for the primary diagnostic, and preserves only a newly isolated Codsworth ref for the primary diagnostic, and preserves
that actor at the original console-spawned location during a short settle that actor at the original console-spawned location during a short settle
window. This tests whether the actor becomes visible naturally or requires a window. This tests whether the actor becomes visible naturally or requires a
render/process refresh such as the manually observed pause-menu or alt-tab render/process refresh such as the manually observed pause-menu or alt-tab
behavior. Stage 3.6 does not change the final architecture. behavior. Stage 3.6 does not change the final architecture.
27. Stage 3.7 promotes the working PlaceAtMe-style spawn pattern into the 28. Stage 3.7 promotes the working PlaceAtMe-style spawn pattern into the
preferred single runtime proxy spawn path. The proxy controller uses the preferred single runtime proxy spawn path. The proxy controller uses the
resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls
back to Codsworth only for diagnostics, defers active proxy selection during back to Codsworth only for diagnostics, defers active proxy selection during
@@ -167,6 +172,9 @@ The native plugin is responsible for:
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 - Adding optional visible equipped apparel snapshots to transform packets
- Adding optional male-proxy-compatible appearance snapshots to transform
packets for body tint, height/body morph weight, hair colors, head parts, and
morph slider values
- 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
@@ -201,6 +209,9 @@ The native plugin is responsible for:
- Applying optional `equippedItems` apparel snapshots to runtime proxies after - Applying optional `equippedItems` apparel snapshots to runtime proxies after
proxy 3D is ready, including explicit tracked-slot unequips for removed proxy 3D is ready, including explicit tracked-slot unequips for removed
clothing, hats, and eyewear clothing, hats, and eyewear
- Applying optional `appearance` snapshots to runtime proxies after proxy 3D is
ready, without switching gender and with race switching deferred until proxy
base compatibility is available
- 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
@@ -235,6 +246,7 @@ The server is responsible for:
- Accepting client connections - Accepting client connections
- Assigning player IDs - Assigning player IDs
- Sending welcome packets - Sending welcome packets
- Sending existing players' last transform snapshots to newly connected clients
- Receiving player state - Receiving player state
- Broadcasting player state - Broadcasting player state
- Tracking disconnects - Tracking disconnects
+83
View File
@@ -9,6 +9,89 @@ failed experiments, successful tests, and next steps.
--- ---
## 2026-06-29 - Late-Join Appearance Snapshot Fix
### Summary
Fixed an asymmetric appearance case where the second client could see only the default proxy for an already-connected first client.
### Files Changed
- `server/server_core.py`
- `protocol/player-sync.md`
- `docs/architecture.md`
- `changelog.md`
- `docs/dev-log.md`
### Details
- The server already stored each client's last transform, but only broadcast future transform packets.
- Added an immediate replay of existing clients' last transform snapshots after a new client's `welcome` packet.
- The replay preserves optional transform fields such as `equippedItems` and `appearance`, so late joiners seed proxy visuals before the sender's next heartbeat.
### Testing
- Ran `python -m py_compile server\server_core.py server\fake_client.py server\fake_player.py`; syntax check passed.
- Recommended manual check: start Client 1, wait for a transform with appearance to reach the server, then connect Client 2 and confirm Client 2's proxy for Client 1 updates appearance immediately.
### Known Issues
- In-game confirmation is still needed.
### Next Steps
- Re-run the two-client appearance checklist in both join orders.
---
## 2026-06-29 - Proxy Appearance Sync
### Summary
Added a versioned optional `appearance` transform snapshot so runtime proxies can mirror supported remote player body and face appearance fields without introducing gender switching yet.
### 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`
- `server/fake_player.py`
- `protocol/packets.md`
- `protocol/player-sync.md`
- `docs/protocol.md`
- `docs/architecture.md`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Added optional `appearance` transform data with a payload version, race form ID for diagnostics/future use, height, body morph weight, body tint, hair/facial hair color forms, head-part IDs, and morph slider values.
- Captured local player appearance on the game-thread polling path and made appearance changes trigger an immediate transform send.
- Parsed remote appearance defensively on the networking thread so older packets without the field still work and malformed subfields do not reject the transform.
- Applied supported appearance fields on the game-thread proxy path after loaded 3D is available, while deferring race switching and omitting gender until female proxy support exists.
- Updated fake clients so relay tests can display and synthesize appearance payloads.
### Testing
- Ran `python -m py_compile server\fake_client.py server\fake_player.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 `appearance` 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 `appearance` after connect and after Looks/body changes.
- Verify the server receives and relays the field without dropping movement or `equippedItems`.
- Verify the receiving plugin parses remote appearance without warnings for valid vanilla forms.
- Verify the proxy updates supported body tint, hair color, weight/body morph, head-part, and morph slider fields where the receiver can resolve the forms.
- Check `CommonwealthOnline.log` for appearance apply, deferred race, missing-form, and proxy 3D refresh messages.
### Known Issues
- Gender switching is intentionally not included yet.
- Race switching is captured but deferred until proxy-base compatibility is validated.
- Head-part application only updates same-count head-part arrays in this first pass; mismatched counts are logged and skipped.
- In-game confirmation is still needed to verify all applied `TESNPC` appearance fields refresh reliably on runtime proxies.
### Next Steps
- Run the two-client in-game checklist.
- Add female proxy support and decide how to map race/gender/base compatibility before enabling runtime race switching.
- If same-count head-part replacement is too limited, add a safer per-proxy actor-base clone or validated head-part allocation path.
---
## 2026-06-29 - Clothing Sync ## 2026-06-29 - Clothing Sync
### Summary ### Summary
+3 -3
View File
@@ -60,7 +60,7 @@ If UDP discovery is unavailable, the plugin may fall back to probing
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 `transform` packets carry position, movement state, optional `equippedItems`
`equippedItems` apparel snapshots for visible clothing/armor proxy sync. The apparel snapshots, and optional `appearance` snapshots for best-effort proxy
canonical field list and compatibility rules live in body/face visuals. The canonical field list and compatibility rules live in
[`protocol/packets.md`](../protocol/packets.md). [`protocol/packets.md`](../protocol/packets.md).
+2 -1
View File
@@ -32,7 +32,8 @@ namespace F4T::Networking
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 = {}); std::span<const F4T::RemotePlayerState::RemoteEquippedItem> a_equippedItems = {},
const F4T::RemotePlayerState::RemoteAppearance* a_appearance = nullptr);
bool SendWorldStatePacket( bool SendWorldStatePacket(
float a_gameHour, float a_gameHour,
float a_gameDaysPassed, float a_gameDaysPassed,
+36
View File
@@ -28,6 +28,40 @@ namespace F4T::RemotePlayerState
std::uint32_t formId{}; std::uint32_t formId{};
}; };
struct RemoteAppearanceMorph
{
std::uint32_t id{};
float value{};
};
struct RemoteAppearanceTintColor
{
std::uint8_t r{};
std::uint8_t g{};
std::uint8_t b{};
std::uint8_t a{};
};
struct RemoteAppearanceMorphWeight
{
float thin{};
float muscular{};
float large{};
};
struct RemoteAppearance
{
std::uint32_t version{ 1 };
std::uint32_t raceFormId{};
float height{ 1.0F };
RemoteAppearanceMorphWeight morphWeight;
RemoteAppearanceTintColor bodyTintColor;
std::uint32_t hairColorFormId{};
std::uint32_t facialHairColorFormId{};
std::vector<std::uint32_t> headPartFormIds;
std::vector<RemoteAppearanceMorph> morphSliders;
};
struct RemotePlayerState struct RemotePlayerState
{ {
std::uint32_t playerId{}; std::uint32_t playerId{};
@@ -50,6 +84,8 @@ namespace F4T::RemotePlayerState
float animationGraphSpeed{ -1.0F }; float animationGraphSpeed{ -1.0F };
bool hasEquipmentUpdate{ false }; bool hasEquipmentUpdate{ false };
std::vector<RemoteEquippedItem> equippedItems; std::vector<RemoteEquippedItem> equippedItems;
bool hasAppearanceUpdate{ false };
RemoteAppearance appearance;
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)
+155 -1
View File
@@ -7,6 +7,7 @@
#include <WinSock2.h> #include <WinSock2.h>
#include <WS2tcpip.h> #include <WS2tcpip.h>
#include <algorithm>
#include <array> #include <array>
#include <atomic> #include <atomic>
#include <charconv> #include <charconv>
@@ -416,6 +417,115 @@ namespace
return equippedItems; return equippedItems;
} }
std::uint8_t ReadColorChannel(const Json& a_object, const char* a_fieldName, std::uint8_t a_default = 0)
{
const auto value = ReadDouble(a_object, a_fieldName);
if (!value || !std::isfinite(*value)) {
return a_default;
}
return static_cast<std::uint8_t>(std::clamp(*value, 0.0, 255.0));
}
std::uint32_t ReadOptionalHexFormId(const Json& a_object, const char* a_fieldName)
{
const auto text = ReadString(a_object, a_fieldName);
if (!text || text->empty()) {
return 0;
}
if (const auto formId = ParseHexFormId(*text)) {
return *formId;
}
LogThrottledWarning(
"transform_appearance_invalid_form_" + std::string(a_fieldName),
std::format("Ignoring appearance field '{}' with invalid formId '{}'.", a_fieldName, *text));
return 0;
}
F4T::RemotePlayerState::RemoteAppearance ReadAppearance(const Json& a_packet, bool& a_hasAppearanceUpdate)
{
a_hasAppearanceUpdate = false;
F4T::RemotePlayerState::RemoteAppearance appearance{};
const auto appearanceObject = a_packet.find("appearance");
if (appearanceObject == a_packet.end()) {
return appearance;
}
a_hasAppearanceUpdate = true;
if (!appearanceObject->is_object()) {
LogThrottledWarning(
"transform_appearance_not_object",
"Ignoring transform appearance field because it is not an object.");
return appearance;
}
if (const auto version = ReadDouble(*appearanceObject, "version")) {
if (std::isfinite(*version) && *version >= 1.0 && *version <= 1000.0) {
appearance.version = static_cast<std::uint32_t>(*version);
}
}
appearance.raceFormId = ReadOptionalHexFormId(*appearanceObject, "raceFormId");
appearance.hairColorFormId = ReadOptionalHexFormId(*appearanceObject, "hairColorFormId");
appearance.facialHairColorFormId = ReadOptionalHexFormId(*appearanceObject, "facialHairColorFormId");
if (const auto height = ReadDouble(*appearanceObject, "height")) {
if (std::isfinite(*height) && *height > 0.0 && *height < 10.0) {
appearance.height = static_cast<float>(*height);
}
}
if (const auto morphWeight = appearanceObject->find("morphWeight");
morphWeight != appearanceObject->end() && morphWeight->is_object()) {
appearance.morphWeight.thin = static_cast<float>(ReadDouble(*morphWeight, "thin").value_or(0.0));
appearance.morphWeight.muscular = static_cast<float>(ReadDouble(*morphWeight, "muscular").value_or(0.0));
appearance.morphWeight.large = static_cast<float>(ReadDouble(*morphWeight, "large").value_or(0.0));
}
if (const auto tintColor = appearanceObject->find("bodyTintColor");
tintColor != appearanceObject->end() && tintColor->is_object()) {
appearance.bodyTintColor.r = ReadColorChannel(*tintColor, "r");
appearance.bodyTintColor.g = ReadColorChannel(*tintColor, "g");
appearance.bodyTintColor.b = ReadColorChannel(*tintColor, "b");
appearance.bodyTintColor.a = ReadColorChannel(*tintColor, "a", 255);
}
if (const auto headParts = appearanceObject->find("headParts");
headParts != appearanceObject->end() && headParts->is_array()) {
for (const auto& headPart : *headParts) {
if (!headPart.is_string()) {
continue;
}
if (const auto formId = ParseHexFormId(headPart.get<std::string>())) {
appearance.headPartFormIds.push_back(*formId);
}
}
}
if (const auto morphs = appearanceObject->find("morphs");
morphs != appearanceObject->end() && morphs->is_array()) {
for (const auto& morph : *morphs) {
if (!morph.is_object()) {
continue;
}
const auto idText = ReadString(morph, "id");
const auto value = ReadDouble(morph, "value");
if (!idText || !value || !std::isfinite(*value)) {
continue;
}
if (const auto id = ParseHexFormId(*idText)) {
appearance.morphSliders.push_back({ *id, static_cast<float>(*value) });
}
}
}
return appearance;
}
void HandleServerWorldStatePacket(const Json& a_packet) void HandleServerWorldStatePacket(const Json& a_packet)
{ {
F4T::WorldStateSync::PendingServerWorldState pendingState{}; F4T::WorldStateSync::PendingServerWorldState pendingState{};
@@ -593,6 +703,8 @@ namespace
bool hasEquipmentUpdate = false; bool hasEquipmentUpdate = false;
auto equippedItems = ReadEquippedItems(a_packet, hasEquipmentUpdate); auto equippedItems = ReadEquippedItems(a_packet, hasEquipmentUpdate);
bool hasAppearanceUpdate = false;
auto appearance = ReadAppearance(a_packet, hasAppearanceUpdate);
F4T::RemotePlayerState::RemotePlayerState remoteState{ F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId, *playerId,
@@ -615,6 +727,8 @@ namespace
static_cast<float>(animationGraphSpeed), static_cast<float>(animationGraphSpeed),
hasEquipmentUpdate, hasEquipmentUpdate,
std::move(equippedItems), std::move(equippedItems),
hasAppearanceUpdate,
std::move(appearance),
std::chrono::steady_clock::now() std::chrono::steady_clock::now()
}; };
@@ -971,7 +1085,8 @@ namespace F4T::Networking
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) std::span<const F4T::RemotePlayerState::RemoteEquippedItem> a_equippedItems,
const F4T::RemotePlayerState::RemoteAppearance* a_appearance)
{ {
if (!IsConnectedToServer()) { if (!IsConnectedToServer()) {
return false; return false;
@@ -1020,6 +1135,45 @@ namespace F4T::Networking
packet["equippedItems"] = std::move(equippedItems); packet["equippedItems"] = std::move(equippedItems);
} }
if (a_appearance) {
auto appearance = nlohmann::json{
{ "version", a_appearance->version },
{ "raceFormId", a_appearance->raceFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->raceFormId) },
{ "height", a_appearance->height },
{ "morphWeight", {
{ "thin", a_appearance->morphWeight.thin },
{ "muscular", a_appearance->morphWeight.muscular },
{ "large", a_appearance->morphWeight.large }
} },
{ "bodyTintColor", {
{ "r", a_appearance->bodyTintColor.r },
{ "g", a_appearance->bodyTintColor.g },
{ "b", a_appearance->bodyTintColor.b },
{ "a", a_appearance->bodyTintColor.a }
} },
{ "hairColorFormId", a_appearance->hairColorFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->hairColorFormId) },
{ "facialHairColorFormId", a_appearance->facialHairColorFormId == 0 ? std::string{} : std::format("{:08X}", a_appearance->facialHairColorFormId) }
};
auto headParts = nlohmann::json::array();
for (const auto formId : a_appearance->headPartFormIds) {
if (formId != 0) {
headParts.push_back(std::format("{:08X}", formId));
}
}
appearance["headParts"] = std::move(headParts);
auto morphs = nlohmann::json::array();
for (const auto& morph : a_appearance->morphSliders) {
morphs.push_back({
{ "id", std::format("{:08X}", morph.id) },
{ "value", morph.value }
});
}
appearance["morphs"] = std::move(morphs);
packet["appearance"] = std::move(appearance);
}
auto packetText = packet.dump(); auto packetText = packet.dump();
packetText.push_back('\n'); packetText.push_back('\n');
+207
View File
@@ -146,6 +146,7 @@ namespace
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState; using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem; using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
struct SyncedEquipmentSlot struct SyncedEquipmentSlot
{ {
@@ -365,6 +366,8 @@ namespace
std::optional<bool> lastAppliedVisualSneakingState; std::optional<bool> lastAppliedVisualSneakingState;
bool hasAppliedEquipmentState{ false }; bool hasAppliedEquipmentState{ false };
std::vector<RemoteEquippedItem> lastAppliedEquipmentItems; std::vector<RemoteEquippedItem> lastAppliedEquipmentItems;
bool hasAppliedAppearanceState{ false };
RemoteAppearance lastAppliedAppearance;
std::optional<RemotePlayer> lastRemotePlayerSnapshot; std::optional<RemotePlayer> lastRemotePlayerSnapshot;
F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{}; F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{};
@@ -2034,6 +2037,35 @@ namespace
return true; return true;
} }
bool AreAppearanceStatesEqual(const RemoteAppearance& a_left, const RemoteAppearance& a_right)
{
if (a_left.version != a_right.version ||
a_left.raceFormId != a_right.raceFormId ||
a_left.height != a_right.height ||
a_left.morphWeight.thin != a_right.morphWeight.thin ||
a_left.morphWeight.muscular != a_right.morphWeight.muscular ||
a_left.morphWeight.large != a_right.morphWeight.large ||
a_left.bodyTintColor.r != a_right.bodyTintColor.r ||
a_left.bodyTintColor.g != a_right.bodyTintColor.g ||
a_left.bodyTintColor.b != a_right.bodyTintColor.b ||
a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
a_left.morphSliders.size() != a_right.morphSliders.size()) {
return false;
}
for (std::size_t index = 0; index < a_left.morphSliders.size(); ++index) {
if (a_left.morphSliders[index].id != a_right.morphSliders[index].id ||
a_left.morphSliders[index].value != a_right.morphSliders[index].value) {
return false;
}
}
return true;
}
RE::BIPOBJECT* GetEquippedArmorBipedObject(RE::Actor& a_actor, RE::BIPED_OBJECT a_bipedObject) RE::BIPOBJECT* GetEquippedArmorBipedObject(RE::Actor& a_actor, RE::BIPED_OBJECT a_bipedObject)
{ {
if (!a_actor.biped) { if (!a_actor.biped) {
@@ -2076,6 +2108,12 @@ namespace
a_slot.lastAppliedEquipmentItems.clear(); a_slot.lastAppliedEquipmentItems.clear();
} }
void ResetSlotAppearanceTracking(ProxyActorSlot& a_slot)
{
a_slot.hasAppliedAppearanceState = false;
a_slot.lastAppliedAppearance = {};
}
void RefreshProxyAfterEquipmentChange(ProxyActorSlot& a_slot, RE::Actor& a_proxy) void RefreshProxyAfterEquipmentChange(ProxyActorSlot& a_slot, RE::Actor& a_proxy)
{ {
QueueProxyUpdate3D(a_proxy, GetProxyVisibilityUpdate3DFlags()); QueueProxyUpdate3D(a_proxy, GetProxyVisibilityUpdate3DFlags());
@@ -2083,6 +2121,15 @@ namespace
a_slot.hasLoggedInitialAnimationState = false; a_slot.hasLoggedInitialAnimationState = false;
} }
void RefreshProxyAfterAppearanceChange(ProxyActorSlot& a_slot, RE::Actor& a_proxy)
{
QueueProxyUpdate3D(a_proxy, GetProxyVisibilityUpdate3DFlags());
a_slot.headMeshRefreshAttempted = false;
a_slot.headMeshPendingStable = true;
F4T::ProxyAnimationSync::ResetAppliedAnimationState(a_slot.appliedAnimationState);
a_slot.hasLoggedInitialAnimationState = false;
}
void ClearSlotEquipmentIfApplied( void ClearSlotEquipmentIfApplied(
ProxyActorSlot& a_slot, ProxyActorSlot& a_slot,
RE::Actor& a_proxy, RE::Actor& a_proxy,
@@ -2235,6 +2282,161 @@ namespace
} }
} }
void ApplyProxyAppearanceFromRemoteState(
ProxyActorSlot& a_slot,
RE::Actor& a_proxy,
const RemotePlayer& a_remotePlayer)
{
if (!a_remotePlayer.hasAppearanceUpdate) {
return;
}
if (a_slot.hasAppliedAppearanceState &&
AreAppearanceStatesEqual(a_slot.lastAppliedAppearance, a_remotePlayer.appearance)) {
return;
}
auto* npc = a_proxy.GetNPC();
if (!npc) {
LogThrottledWarning(
"proxy_appearance_npc_missing_" + std::to_string(a_remotePlayer.playerId),
std::format("Could not apply appearance for remote player {}; proxy has no NPC base.", a_remotePlayer.playerId));
return;
}
const auto& appearance = a_remotePlayer.appearance;
bool changed = false;
if (appearance.raceFormId != 0) {
const auto currentRaceFormId = npc->GetFormRace() ? npc->GetFormRace()->GetFormID() : 0U;
if (currentRaceFormId != appearance.raceFormId) {
LogThrottledWarning(
"proxy_appearance_race_deferred_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Appearance for remote player {} requested race {:08X}, but proxy race switching is deferred until gender/base compatibility is supported.",
a_remotePlayer.playerId,
appearance.raceFormId));
}
}
if (std::isfinite(appearance.height) && appearance.height > 0.0F && npc->height != appearance.height) {
npc->height = appearance.height;
changed = true;
}
if (npc->morphWeight.x != appearance.morphWeight.thin ||
npc->morphWeight.y != appearance.morphWeight.muscular ||
npc->morphWeight.z != appearance.morphWeight.large) {
npc->morphWeight.x = appearance.morphWeight.thin;
npc->morphWeight.y = appearance.morphWeight.muscular;
npc->morphWeight.z = appearance.morphWeight.large;
changed = true;
}
const auto tintR = static_cast<std::int8_t>(appearance.bodyTintColor.r);
const auto tintG = static_cast<std::int8_t>(appearance.bodyTintColor.g);
const auto tintB = static_cast<std::int8_t>(appearance.bodyTintColor.b);
const auto tintA = static_cast<std::int8_t>(appearance.bodyTintColor.a);
if (npc->bodyTintColorR != tintR ||
npc->bodyTintColorG != tintG ||
npc->bodyTintColorB != tintB ||
npc->bodyTintColorA != tintA) {
npc->bodyTintColorR = tintR;
npc->bodyTintColorG = tintG;
npc->bodyTintColorB = tintB;
npc->bodyTintColorA = tintA;
changed = true;
}
if (appearance.hairColorFormId != 0) {
if (auto* hairColor = RE::TESForm::GetFormByID<RE::BGSColorForm>(appearance.hairColorFormId)) {
if (!npc->headRelatedData || npc->headRelatedData->hairColor != hairColor) {
npc->SetHairColor(hairColor);
changed = true;
}
} else {
LogThrottledWarning(
"proxy_appearance_hair_color_missing_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Could not apply hair color {:08X} for remote player {}; no local CLFM form resolved.",
appearance.hairColorFormId,
a_remotePlayer.playerId));
}
}
if (npc->headRelatedData && appearance.facialHairColorFormId != 0) {
if (auto* facialHairColor = RE::TESForm::GetFormByID<RE::BGSColorForm>(appearance.facialHairColorFormId)) {
if (npc->headRelatedData->facialHairColor != facialHairColor) {
npc->headRelatedData->facialHairColor = facialHairColor;
changed = true;
}
} else {
LogThrottledWarning(
"proxy_appearance_facial_hair_color_missing_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Could not apply facial hair color {:08X} for remote player {}; no local CLFM form resolved.",
appearance.facialHairColorFormId,
a_remotePlayer.playerId));
}
}
if (!appearance.headPartFormIds.empty()) {
if (npc->headParts && npc->numHeadParts == static_cast<std::int8_t>(appearance.headPartFormIds.size())) {
for (std::size_t index = 0; index < appearance.headPartFormIds.size(); ++index) {
auto* headPart = RE::TESForm::GetFormByID<RE::BGSHeadPart>(appearance.headPartFormIds[index]);
if (!headPart) {
LogThrottledWarning(
"proxy_appearance_headpart_missing_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(index),
std::format(
"Could not apply head part {:08X} for remote player {}; no local HDPT form resolved.",
appearance.headPartFormIds[index],
a_remotePlayer.playerId));
continue;
}
if (npc->headParts[index] != headPart) {
npc->headParts[index] = headPart;
changed = true;
}
}
} else {
LogThrottledWarning(
"proxy_appearance_headpart_count_mismatch_" + std::to_string(a_remotePlayer.playerId),
std::format(
"Deferred head-part appearance for remote player {}; proxy count={} packet count={}.",
a_remotePlayer.playerId,
static_cast<int>(npc->numHeadParts),
appearance.headPartFormIds.size()));
}
}
if (!appearance.morphSliders.empty()) {
if (!npc->morphSliderValues) {
npc->morphSliderValues = new RE::BSTHashMap<std::uint32_t, float>();
}
npc->morphSliderValues->clear();
for (const auto& morph : appearance.morphSliders) {
if (std::isfinite(morph.value)) {
npc->morphSliderValues->insert({ morph.id, morph.value });
}
}
changed = true;
}
a_slot.hasAppliedAppearanceState = true;
a_slot.lastAppliedAppearance = appearance;
if (changed) {
RefreshProxyAfterAppearanceChange(a_slot, a_proxy);
LogInfoWithLocalPlayerPrefix(std::format(
"Applied synced appearance to runtime proxy actor {:08X} for remote player {}: headParts={}, morphs={}.",
a_proxy.GetFormID(),
a_remotePlayer.playerId,
appearance.headPartFormIds.size(),
appearance.morphSliders.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();
@@ -5764,6 +5966,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.spawnSucceeded = false; a_slot.spawnSucceeded = false;
a_slot.restoredForCurrentState = false; a_slot.restoredForCurrentState = false;
ResetSlotEquipmentTracking(a_slot); ResetSlotEquipmentTracking(a_slot);
ResetSlotAppearanceTracking(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;
@@ -6095,6 +6298,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
ResetSlotVisualSneakTracking(slot); ResetSlotVisualSneakTracking(slot);
ResetSlotAppliedAnimationState(slot); ResetSlotAppliedAnimationState(slot);
ResetSlotEquipmentTracking(slot); ResetSlotEquipmentTracking(slot);
ResetSlotAppearanceTracking(slot);
} }
slot.lifecycleState = ProxyLifecycleState::kReusable; slot.lifecycleState = ProxyLifecycleState::kReusable;
slot.holdReason.reset(); slot.holdReason.reset();
@@ -6446,6 +6650,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
ResetSlotVisualSneakTracking(slotValue); ResetSlotVisualSneakTracking(slotValue);
ResetSlotAppliedAnimationState(slotValue); ResetSlotAppliedAnimationState(slotValue);
ResetSlotEquipmentTracking(slotValue); ResetSlotEquipmentTracking(slotValue);
ResetSlotAppearanceTracking(slotValue);
} }
slotValue.previousRemotePlayerId = previousRemotePlayerId; slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId; slotValue.remotePlayerId = a_remotePlayer.playerId;
@@ -6596,6 +6801,8 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.hasLoggedInitialAnimationState = false; a_slot.hasLoggedInitialAnimationState = false;
} }
ApplyProxyAppearanceFromRemoteState(a_slot, *proxy, a_remotePlayer);
const bool needsSnapRestore = const bool needsSnapRestore =
ShouldSnapRemoteMovement(a_remotePlayer.movementType) || remoteCellChanged; ShouldSnapRemoteMovement(a_remotePlayer.movementType) || remoteCellChanged;
+98 -7
View File
@@ -64,6 +64,8 @@ namespace
}; };
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem; using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
struct SyncedEquipmentSlot struct SyncedEquipmentSlot
{ {
@@ -224,6 +226,54 @@ namespace
return equippedItems; return equippedItems;
} }
RemoteAppearance CaptureLocalAppearance(const RE::PlayerCharacter& a_player)
{
RemoteAppearance appearance{};
auto* npc = a_player.GetNPC();
if (!npc) {
return appearance;
}
if (const auto* race = npc->GetFormRace()) {
appearance.raceFormId = race->GetFormID();
}
appearance.height = npc->height;
appearance.morphWeight.thin = npc->morphWeight.x;
appearance.morphWeight.muscular = npc->morphWeight.y;
appearance.morphWeight.large = npc->morphWeight.z;
appearance.bodyTintColor.r = static_cast<std::uint8_t>(npc->bodyTintColorR);
appearance.bodyTintColor.g = static_cast<std::uint8_t>(npc->bodyTintColorG);
appearance.bodyTintColor.b = static_cast<std::uint8_t>(npc->bodyTintColorB);
appearance.bodyTintColor.a = static_cast<std::uint8_t>(npc->bodyTintColorA);
if (npc->headRelatedData) {
if (npc->headRelatedData->hairColor) {
appearance.hairColorFormId = npc->headRelatedData->hairColor->GetFormID();
}
if (npc->headRelatedData->facialHairColor) {
appearance.facialHairColorFormId = npc->headRelatedData->facialHairColor->GetFormID();
}
}
for (const auto* headPart : npc->GetHeadParts()) {
if (headPart) {
appearance.headPartFormIds.push_back(headPart->GetFormID());
}
}
if (npc->morphSliderValues) {
for (const auto& morph : *npc->morphSliderValues) {
if (std::isfinite(morph.second)) {
appearance.morphSliders.push_back(RemoteAppearanceMorph{ morph.first, morph.second });
}
}
}
return appearance;
}
bool AreEquipmentStatesEqual( bool AreEquipmentStatesEqual(
const std::vector<RemoteEquippedItem>& a_left, const std::vector<RemoteEquippedItem>& a_left,
const std::vector<RemoteEquippedItem>& a_right) const std::vector<RemoteEquippedItem>& a_right)
@@ -241,6 +291,35 @@ namespace
return true; return true;
} }
bool AreAppearanceStatesEqual(const RemoteAppearance& a_left, const RemoteAppearance& a_right)
{
if (a_left.version != a_right.version ||
a_left.raceFormId != a_right.raceFormId ||
a_left.height != a_right.height ||
a_left.morphWeight.thin != a_right.morphWeight.thin ||
a_left.morphWeight.muscular != a_right.morphWeight.muscular ||
a_left.morphWeight.large != a_right.morphWeight.large ||
a_left.bodyTintColor.r != a_right.bodyTintColor.r ||
a_left.bodyTintColor.g != a_right.bodyTintColor.g ||
a_left.bodyTintColor.b != a_right.bodyTintColor.b ||
a_left.bodyTintColor.a != a_right.bodyTintColor.a ||
a_left.hairColorFormId != a_right.hairColorFormId ||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
a_left.headPartFormIds != a_right.headPartFormIds ||
a_left.morphSliders.size() != a_right.morphSliders.size()) {
return false;
}
for (std::size_t index = 0; index < a_left.morphSliders.size(); ++index) {
if (a_left.morphSliders[index].id != a_right.morphSliders[index].id ||
a_left.morphSliders[index].value != a_right.morphSliders[index].value) {
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;
@@ -564,6 +643,7 @@ namespace
static PlayerLocation lastKnownLocation{}; static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{}; static PlayerMovementState lastSentMovementState{};
static std::vector<RemoteEquippedItem> lastSentEquipmentState; static std::vector<RemoteEquippedItem> lastSentEquipmentState;
static RemoteAppearance lastSentAppearanceState;
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{};
@@ -574,6 +654,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 currentEquipmentState = CaptureLocalEquippedItems(*player);
const auto currentAppearanceState = CaptureLocalAppearance(*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,
@@ -599,6 +680,7 @@ namespace
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSentMovementState = debugMovementState; lastSentMovementState = debugMovementState;
lastSentEquipmentState = currentEquipmentState; lastSentEquipmentState = currentEquipmentState;
lastSentAppearanceState = currentAppearanceState;
lastSendTime = currentTime; lastSendTime = currentTime;
lastLogTime = currentTime; lastLogTime = currentTime;
lastDebugSampleTime = currentTime; lastDebugSampleTime = currentTime;
@@ -646,13 +728,16 @@ namespace
debugMovementState.weaponDrawn, debugMovementState.weaponDrawn,
debugMovementState.movementSpeed, debugMovementState.movementSpeed,
-1.0F, -1.0F,
currentEquipmentState)) { currentEquipmentState,
std::addressof(currentAppearanceState))) {
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Initial transform sent with cellId={:08X}{}.", "Initial transform sent with cellId={:08X}{}, appearance headParts={}, morphs={}.",
currentLocation.hasCellId ? currentLocation.cellId : 0U, currentLocation.hasCellId ? currentLocation.cellId : 0U,
currentLocation.hasWorldspaceId ? currentLocation.hasWorldspaceId ?
std::format(", worldspaceId={:08X}", currentLocation.worldspaceId) : std::format(", worldspaceId={:08X}", currentLocation.worldspaceId) :
std::string{})); std::string{},
currentAppearanceState.headPartFormIds.size(),
currentAppearanceState.morphSliders.size()));
} }
if (currentLocation.isSoloCell) { if (currentLocation.isSoloCell) {
soloCellTransformSuppressed = true; soloCellTransformSuppressed = true;
@@ -677,16 +762,17 @@ namespace
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 hasEquipmentChanged = !AreEquipmentStatesEqual(lastSentEquipmentState, currentEquipmentState);
const auto hasAppearanceChanged = !AreAppearanceStatesEqual(lastSentAppearanceState, currentAppearanceState);
const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval; const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval;
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !idleHeartbeatDue) { if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !hasAppearanceChanged && !idleHeartbeatDue) {
lastDebugSampleTransform = currentTransform; lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime; lastDebugSampleTime = currentTime;
return; return;
} }
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged) { if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !hasAppearanceChanged) {
// 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 && !hasEquipmentChanged && currentTime - lastSendTime < kTransformSendInterval) { } else if (!isSpecialMovement && !hasEquipmentChanged && !hasAppearanceChanged && currentTime - lastSendTime < kTransformSendInterval) {
return; return;
} }
@@ -734,12 +820,14 @@ namespace
networkMovementState.weaponDrawn, networkMovementState.weaponDrawn,
networkMovementState.movementSpeed, networkMovementState.movementSpeed,
animationGraphSpeed, animationGraphSpeed,
currentEquipmentState); currentEquipmentState,
std::addressof(currentAppearanceState));
if (sentTransform) { if (sentTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastKnownLocation = currentLocation; lastKnownLocation = currentLocation;
lastSentMovementState = networkMovementState; lastSentMovementState = networkMovementState;
lastSentEquipmentState = currentEquipmentState; lastSentEquipmentState = currentEquipmentState;
lastSentAppearanceState = currentAppearanceState;
lastSendTime = currentTime; lastSendTime = currentTime;
if (isSpecialMovement) { if (isSpecialMovement) {
@@ -748,6 +836,9 @@ namespace
} else if (hasEquipmentChanged) { } else if (hasEquipmentChanged) {
LogInfoWithLocalPlayerPrefix("Detected local equipment change. Sending immediate transform update."); LogInfoWithLocalPlayerPrefix("Detected local equipment change. Sending immediate transform update.");
lastLogTime = currentTime; lastLogTime = currentTime;
} else if (hasAppearanceChanged) {
LogInfoWithLocalPlayerPrefix("Detected local appearance 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;
+32
View File
@@ -73,6 +73,17 @@ Example after server processing:
{ "slot": "body", "formId": "0001F66A" }, { "slot": "body", "formId": "0001F66A" },
{ "slot": "headband", "formId": "" } { "slot": "headband", "formId": "" }
], ],
"appearance": {
"version": 1,
"raceFormId": "00013746",
"height": 1.0,
"morphWeight": { "thin": 0.0, "muscular": 0.0, "large": 0.0 },
"bodyTintColor": { "r": 255, "g": 220, "b": 190, "a": 255 },
"hairColorFormId": "",
"facialHairColorFormId": "",
"headParts": [],
"morphs": []
},
"serverTime": 1780212128.3011043 "serverTime": 1780212128.3011043
} }
``` ```
@@ -97,6 +108,7 @@ 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. 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.
appearance Optional versioned player appearance snapshot for runtime proxy visuals.
serverTime Timestamp added by the server before broadcast. serverTime Timestamp added by the server before broadcast.
``` ```
@@ -143,6 +155,26 @@ empty strings for unequipped tracked slots. Power armor, weapon models,
condition, legendary instance data, tint/material overrides, and forms missing condition, legendary instance data, tint/material overrides, and forms missing
from the receiver load order are outside this packet extension. from the receiver load order are outside this packet extension.
Appearance fields are optional and additive. When `appearance` is missing,
receivers keep the proxy's existing/default body and face. When present, it is a
best-effort snapshot for male-proxy-compatible runtime visuals:
```text
version Appearance payload version. Current value is 1.
raceFormId Hex-string race form ID captured for diagnostics/future use; receivers do not switch proxy race yet.
height NPC height scalar.
morphWeight Body triangle weights: thin, muscular, and large.
bodyTintColor Body tint channels r, g, b, a as 0-255 integers.
hairColorFormId Hex-string `CLFM` form ID for hair color, or empty when unavailable.
facialHairColorFormId Hex-string `CLFM` form ID for facial hair color, or empty when unavailable.
headParts Hex-string `HDPT` form IDs for the sender's current head parts.
morphs Morph slider values as `{ "id": hex-string, "value": number }` entries.
```
Receivers must treat missing or malformed appearance subfields as non-fatal and
apply only the parts they can resolve locally. Gender is intentionally not part
of this payload yet; female proxy support can add it later.
### 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.
+17
View File
@@ -18,6 +18,12 @@ local movement logs: while the player is moving, the plugin targets roughly
server assigns a `playerId`, adds `serverTime`, and broadcasts transform packets server assigns a `playerId`, adds `serverTime`, and broadcasts transform packets
to other connected clients. to other connected clients.
When a new client connects, the server sends the last stored transform snapshot
for each already-connected player to that new client after `welcome`. These
snapshots include optional fields such as `equippedItems` and `appearance`, so a
late joiner can initialize proxy visuals before the existing players' next idle
heartbeat or movement packet.
The Fallout 4 plugin also receives server packets on a background networking The Fallout 4 plugin also receives server packets on a background networking
thread. It stores its assigned `playerId`, stores remote transform state by thread. It stores its assigned `playerId`, stores remote transform state by
remote `playerId`, and removes remote state when disconnect packets arrive. remote `playerId`, and removes remote state when disconnect packets arrive.
@@ -64,6 +70,7 @@ weaponDrawn
movementSpeed movementSpeed
animationGraphSpeed animationGraphSpeed
equippedItems equippedItems
appearance
lastReceivedLocalTime lastReceivedLocalTime
``` ```
@@ -79,6 +86,13 @@ tracked visible apparel slots with `{ slot, formId }` entries; empty `formId`
values mean that tracked slot is intentionally unequipped. Older clients can values mean that tracked slot is intentionally unequipped. Older clients can
omit the field and receivers keep the proxy's existing/default appearance. omit the field and receivers keep the proxy's existing/default appearance.
`appearance` is optional and versioned. It carries male-proxy-compatible body
and face data such as race form ID for diagnostics/future use, height, body
morph weight, body tint color, hair color, facial hair color, head-part form IDs,
and morph slider values. Receivers apply supported fields only after proxy 3D is
loaded. Gender switching is intentionally omitted until female proxy support is
available.
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
@@ -115,6 +129,9 @@ player exists.
- Transform packets can include optional `equippedItems` snapshots for visible - Transform packets can include optional `equippedItems` snapshots for visible
clothing, armor, hats, and eyewear slots. The game-thread proxy controller clothing, armor, hats, and eyewear slots. The game-thread proxy controller
applies those snapshots after proxy 3D is loaded. applies those snapshots after proxy 3D is loaded.
- Transform packets can include optional `appearance` snapshots for best-effort
body and face proxy visuals. The game-thread proxy controller applies supported
fields after proxy 3D is loaded and defers race/gender switching.
- 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
+51
View File
@@ -84,6 +84,38 @@ def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
return equipped_items return equipped_items
def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
value = packet.get("appearance")
if not isinstance(value, dict):
return None
appearance: dict[str, Any] = {
"version": get_optional_uint32(value, "version", 1),
"raceFormId": value.get("raceFormId", ""),
"height": get_optional_float(value, "height", 1.0),
"morphWeight": value.get("morphWeight", {}),
"bodyTintColor": value.get("bodyTintColor", {}),
"hairColorFormId": value.get("hairColorFormId", ""),
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
"headParts": [],
"morphs": [],
}
head_parts = value.get("headParts", [])
if isinstance(head_parts, list):
appearance["headParts"] = [form_id for form_id in head_parts if isinstance(form_id, str)]
morphs = value.get("morphs", [])
if isinstance(morphs, list):
appearance["morphs"] = [
{"id": morph.get("id", ""), "value": get_optional_float(morph, "value")}
for morph in morphs
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
return appearance
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
@@ -184,6 +216,7 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"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), "equippedItems": get_optional_equipped_items(packet),
"appearance": get_optional_appearance(packet),
"lastReceivedLocalTime": time.time(), "lastReceivedLocalTime": time.time(),
} }
@@ -200,6 +233,16 @@ def print_remote_player(player: dict[str, Any]) -> None:
if equipped_items if equipped_items
else "<not sent>" else "<not sent>"
) )
appearance = player.get("appearance")
appearance_text = (
(
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
f"morphs={len(appearance.get('morphs', []))}"
)
if isinstance(appearance, dict)
else "<not sent>"
)
log( log(
"\n".join( "\n".join(
@@ -215,6 +258,7 @@ def print_remote_player(player: dict[str, Any]) -> None:
), ),
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"Equipment: {equipment_text}",
f"Appearance: {appearance_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}",
@@ -238,6 +282,12 @@ def print_remote_players() -> None:
if player.get("equippedItems") if player.get("equippedItems")
else "<not sent>" else "<not sent>"
) )
appearance = player.get("appearance")
appearance_text = (
f"race={appearance.get('raceFormId') or '<empty>'}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}"
if isinstance(appearance, dict)
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}), "
@@ -247,6 +297,7 @@ def print_remote_players() -> None:
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"equipment={equipment_text}, "
f"appearance={appearance_text}, "
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, " f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}" f"serverTime={player['serverTime']}"
) )
+12
View File
@@ -43,6 +43,17 @@ IDLE_Z = 0.0
IDLE_ANGLE_Z = 3.93 IDLE_ANGLE_Z = 3.93
TELEPORT_TEST_OFFSET_X = 900.0 TELEPORT_TEST_OFFSET_X = 900.0
TELEPORT_TEST_OFFSET_Y = 450.0 TELEPORT_TEST_OFFSET_Y = 450.0
DEFAULT_APPEARANCE = {
"version": 1,
"raceFormId": "00013746",
"height": 1.0,
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
"hairColorFormId": "",
"facialHairColorFormId": "",
"headParts": [],
"morphs": [],
}
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]] ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
WalkTargetProvider = Callable[[int], dict[str, Any] | None] WalkTargetProvider = Callable[[int], dict[str, Any] | None]
@@ -567,6 +578,7 @@ class FakePlayerClient:
"isCrouching": crouching, "isCrouching": crouching,
"weaponDrawn": False, "weaponDrawn": False,
"movementSpeed": movement_speed, "movementSpeed": movement_speed,
"appearance": DEFAULT_APPEARANCE,
} }
if is_moving and movement_speed >= 1.0: if is_moving and movement_speed >= 1.0:
packet["animationGraphSpeed"] = round( packet["animationGraphSpeed"] = round(
+34
View File
@@ -304,6 +304,7 @@ class FalloutTogetherServer:
welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id
self._send_packet(client, welcome_packet) self._send_packet(client, welcome_packet)
self._send_existing_transforms_to_client(client)
while self.is_running(): while self.is_running():
chunk = connection.recv(4096) chunk = connection.recv(4096)
@@ -382,6 +383,39 @@ class FalloutTogetherServer:
if broadcast: if broadcast:
self._stats["packetsBroadcast"] += 1 self._stats["packetsBroadcast"] += 1
def _send_existing_transforms_to_client(self, new_client: ClientSession) -> None:
with self._lock:
snapshots = [
dict(client.last_transform)
for client in self._clients.values()
if client.connection != new_client.connection and client.last_transform is not None
]
if not snapshots:
return
successful_sends = 0
failed = False
for packet in snapshots:
packet["serverTime"] = time.time()
try:
self._send_packet(new_client, packet, broadcast=True)
successful_sends += 1
except OSError:
failed = True
break
with self._lock:
self._stats["transformPacketsBroadcast"] += successful_sends
if successful_sends:
self._log(
f"Sent {successful_sends} existing transform snapshot(s) to newly connected player {new_client.player_id}"
)
if failed:
self._disconnect_client(new_client)
def _disconnect_client(self, client: ClientSession) -> None: def _disconnect_client(self, client: ClientSession) -> None:
with self._lock: with self._lock:
was_world_state_host = client.player_id == self._world_state_host_player_id was_world_state_host = client.player_id == self._world_state_host_player_id