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
+2 -1
View File
@@ -32,7 +32,8 @@ namespace F4T::Networking
bool a_weaponDrawn = false,
float a_movementSpeed = 0.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(
float a_gameHour,
float a_gameDaysPassed,
+36
View File
@@ -28,6 +28,40 @@ namespace F4T::RemotePlayerState
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
{
std::uint32_t playerId{};
@@ -50,6 +84,8 @@ namespace F4T::RemotePlayerState
float animationGraphSpeed{ -1.0F };
bool hasEquipmentUpdate{ false };
std::vector<RemoteEquippedItem> equippedItems;
bool hasAppearanceUpdate{ false };
RemoteAppearance appearance;
std::chrono::steady_clock::time_point lastReceivedLocalTime{};
// Phase 2: Actor state and action events (TiltedEvolution alignment)
+155 -1
View File
@@ -7,6 +7,7 @@
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <charconv>
@@ -416,6 +417,115 @@ namespace
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)
{
F4T::WorldStateSync::PendingServerWorldState pendingState{};
@@ -593,6 +703,8 @@ namespace
bool hasEquipmentUpdate = false;
auto equippedItems = ReadEquippedItems(a_packet, hasEquipmentUpdate);
bool hasAppearanceUpdate = false;
auto appearance = ReadAppearance(a_packet, hasAppearanceUpdate);
F4T::RemotePlayerState::RemotePlayerState remoteState{
*playerId,
@@ -615,6 +727,8 @@ namespace
static_cast<float>(animationGraphSpeed),
hasEquipmentUpdate,
std::move(equippedItems),
hasAppearanceUpdate,
std::move(appearance),
std::chrono::steady_clock::now()
};
@@ -971,7 +1085,8 @@ namespace F4T::Networking
bool a_weaponDrawn,
float a_movementSpeed,
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()) {
return false;
@@ -1020,6 +1135,45 @@ namespace F4T::Networking
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();
packetText.push_back('\n');
+207
View File
@@ -146,6 +146,7 @@ namespace
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
struct SyncedEquipmentSlot
{
@@ -365,6 +366,8 @@ namespace
std::optional<bool> lastAppliedVisualSneakingState;
bool hasAppliedEquipmentState{ false };
std::vector<RemoteEquippedItem> lastAppliedEquipmentItems;
bool hasAppliedAppearanceState{ false };
RemoteAppearance lastAppliedAppearance;
std::optional<RemotePlayer> lastRemotePlayerSnapshot;
F4T::ProxyAnimationSync::ProxyAppliedAnimationState appliedAnimationState{};
@@ -2034,6 +2037,35 @@ namespace
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)
{
if (!a_actor.biped) {
@@ -2076,6 +2108,12 @@ namespace
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)
{
QueueProxyUpdate3D(a_proxy, GetProxyVisibilityUpdate3DFlags());
@@ -2083,6 +2121,15 @@ namespace
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(
ProxyActorSlot& a_slot,
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)
{
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.restoredForCurrentState = false;
ResetSlotEquipmentTracking(a_slot);
ResetSlotAppearanceTracking(a_slot);
if (a_slot.lifecycleState != ProxyLifecycleState::kHeldDisconnected) {
a_slot.heldSinceTime = {};
a_slot.reusableLogged = false;
@@ -6095,6 +6298,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
ResetSlotVisualSneakTracking(slot);
ResetSlotAppliedAnimationState(slot);
ResetSlotEquipmentTracking(slot);
ResetSlotAppearanceTracking(slot);
}
slot.lifecycleState = ProxyLifecycleState::kReusable;
slot.holdReason.reset();
@@ -6446,6 +6650,7 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
ResetSlotVisualSneakTracking(slotValue);
ResetSlotAppliedAnimationState(slotValue);
ResetSlotEquipmentTracking(slotValue);
ResetSlotAppearanceTracking(slotValue);
}
slotValue.previousRemotePlayerId = previousRemotePlayerId;
slotValue.remotePlayerId = a_remotePlayer.playerId;
@@ -6596,6 +6801,8 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
a_slot.hasLoggedInitialAnimationState = false;
}
ApplyProxyAppearanceFromRemoteState(a_slot, *proxy, a_remotePlayer);
const bool needsSnapRestore =
ShouldSnapRemoteMovement(a_remotePlayer.movementType) || remoteCellChanged;
+98 -7
View File
@@ -64,6 +64,8 @@ namespace
};
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
struct SyncedEquipmentSlot
{
@@ -224,6 +226,54 @@ namespace
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(
const std::vector<RemoteEquippedItem>& a_left,
const std::vector<RemoteEquippedItem>& a_right)
@@ -241,6 +291,35 @@ namespace
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)
{
const auto deltaX = a_current.x - a_previous.x;
@@ -564,6 +643,7 @@ namespace
static PlayerLocation lastKnownLocation{};
static PlayerMovementState lastSentMovementState{};
static std::vector<RemoteEquippedItem> lastSentEquipmentState;
static RemoteAppearance lastSentAppearanceState;
static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = std::chrono::steady_clock::time_point{};
static auto lastDebugSampleTime = std::chrono::steady_clock::time_point{};
@@ -574,6 +654,7 @@ namespace
const auto currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player);
const auto currentEquipmentState = CaptureLocalEquippedItems(*player);
const auto currentAppearanceState = CaptureLocalAppearance(*player);
const auto currentTime = std::chrono::steady_clock::now();
const auto debugMovementState = GetPlayerMovementState(
*player,
@@ -599,6 +680,7 @@ namespace
lastKnownLocation = currentLocation;
lastSentMovementState = debugMovementState;
lastSentEquipmentState = currentEquipmentState;
lastSentAppearanceState = currentAppearanceState;
lastSendTime = currentTime;
lastLogTime = currentTime;
lastDebugSampleTime = currentTime;
@@ -646,13 +728,16 @@ namespace
debugMovementState.weaponDrawn,
debugMovementState.movementSpeed,
-1.0F,
currentEquipmentState)) {
currentEquipmentState,
std::addressof(currentAppearanceState))) {
LogInfoWithLocalPlayerPrefix(std::format(
"Initial transform sent with cellId={:08X}{}.",
"Initial transform sent with cellId={:08X}{}, appearance headParts={}, morphs={}.",
currentLocation.hasCellId ? currentLocation.cellId : 0U,
currentLocation.hasWorldspaceId ?
std::format(", worldspaceId={:08X}", currentLocation.worldspaceId) :
std::string{}));
std::string{},
currentAppearanceState.headPartFormIds.size(),
currentAppearanceState.morphSliders.size()));
}
if (currentLocation.isSoloCell) {
soloCellTransformSuppressed = true;
@@ -677,16 +762,17 @@ namespace
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState);
const auto hasEquipmentChanged = !AreEquipmentStatesEqual(lastSentEquipmentState, currentEquipmentState);
const auto hasAppearanceChanged = !AreAppearanceStatesEqual(lastSentAppearanceState, currentAppearanceState);
const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval;
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !idleHeartbeatDue) {
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !hasEquipmentChanged && !hasAppearanceChanged && !idleHeartbeatDue) {
lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime;
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.
} else if (!isSpecialMovement && !hasEquipmentChanged && currentTime - lastSendTime < kTransformSendInterval) {
} else if (!isSpecialMovement && !hasEquipmentChanged && !hasAppearanceChanged && currentTime - lastSendTime < kTransformSendInterval) {
return;
}
@@ -734,12 +820,14 @@ namespace
networkMovementState.weaponDrawn,
networkMovementState.movementSpeed,
animationGraphSpeed,
currentEquipmentState);
currentEquipmentState,
std::addressof(currentAppearanceState));
if (sentTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = networkMovementState;
lastSentEquipmentState = currentEquipmentState;
lastSentAppearanceState = currentAppearanceState;
lastSendTime = currentTime;
if (isSpecialMovement) {
@@ -748,6 +836,9 @@ namespace
} else if (hasEquipmentChanged) {
LogInfoWithLocalPlayerPrefix("Detected local equipment change. Sending immediate transform update.");
lastLogTime = currentTime;
} else if (hasAppearanceChanged) {
LogInfoWithLocalPlayerPrefix("Detected local appearance change. Sending immediate transform update.");
lastLogTime = currentTime;
} else if (currentTime - lastLogTime >= kTransformLogInterval) {
LogPlayerTransform(currentTransform);
lastLogTime = currentTime;