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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user