Character Appearance Phase 1 Done
Bump appearance payloads to version 2 and add `morphRegions` and `facialBoneMorphs` capture, serialization, and proxy application. Also fix head-part syncing when sender and proxy counts differ by reallocating the proxy array instead of deferring the whole list, and update the Python harness and protocol docs to match.
This commit is contained in:
@@ -427,6 +427,26 @@ namespace
|
||||
return static_cast<std::uint8_t>(std::clamp(*value, 0.0, 255.0));
|
||||
}
|
||||
|
||||
void ReadFloat3(const Json& a_object, const char* a_fieldName, float (&a_out)[3])
|
||||
{
|
||||
const auto field = a_object.find(a_fieldName);
|
||||
if (field == a_object.end() || !field->is_array()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto count = std::min<std::size_t>(field->size(), 3);
|
||||
for (std::size_t index = 0; index < count; ++index) {
|
||||
const auto& element = (*field)[index];
|
||||
if (!element.is_number()) {
|
||||
continue;
|
||||
}
|
||||
const auto number = element.get<double>();
|
||||
if (std::isfinite(number)) {
|
||||
a_out[index] = static_cast<float>(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t ReadOptionalHexFormId(const Json& a_object, const char* a_fieldName)
|
||||
{
|
||||
const auto text = ReadString(a_object, a_fieldName);
|
||||
@@ -523,6 +543,43 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto morphRegions = appearanceObject->find("morphRegions");
|
||||
morphRegions != appearanceObject->end() && morphRegions->is_array()) {
|
||||
for (const auto& value : *morphRegions) {
|
||||
if (!value.is_number()) {
|
||||
continue;
|
||||
}
|
||||
const auto number = value.get<double>();
|
||||
appearance.morphRegionSliders.push_back(std::isfinite(number) ? static_cast<float>(number) : 0.0F);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto facialBoneMorphs = appearanceObject->find("facialBoneMorphs");
|
||||
facialBoneMorphs != appearanceObject->end() && facialBoneMorphs->is_array()) {
|
||||
for (const auto& morph : *facialBoneMorphs) {
|
||||
if (!morph.is_object()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto idText = ReadString(morph, "id");
|
||||
if (!idText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto id = ParseHexFormId(*idText);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph boneMorph{};
|
||||
boneMorph.id = *id;
|
||||
ReadFloat3(morph, "position", boneMorph.position);
|
||||
ReadFloat3(morph, "rotation", boneMorph.rotation);
|
||||
ReadFloat3(morph, "scale", boneMorph.scale);
|
||||
appearance.facialBoneMorphs.push_back(boneMorph);
|
||||
}
|
||||
}
|
||||
|
||||
return appearance;
|
||||
}
|
||||
|
||||
@@ -1171,6 +1228,24 @@ namespace F4T::Networking
|
||||
});
|
||||
}
|
||||
appearance["morphs"] = std::move(morphs);
|
||||
|
||||
auto morphRegions = nlohmann::json::array();
|
||||
for (const auto value : a_appearance->morphRegionSliders) {
|
||||
morphRegions.push_back(value);
|
||||
}
|
||||
appearance["morphRegions"] = std::move(morphRegions);
|
||||
|
||||
auto facialBoneMorphs = nlohmann::json::array();
|
||||
for (const auto& morph : a_appearance->facialBoneMorphs) {
|
||||
facialBoneMorphs.push_back({
|
||||
{ "id", std::format("{:08X}", morph.id) },
|
||||
{ "position", { morph.position[0], morph.position[1], morph.position[2] } },
|
||||
{ "rotation", { morph.rotation[0], morph.rotation[1], morph.rotation[2] } },
|
||||
{ "scale", { morph.scale[0], morph.scale[1], morph.scale[2] } }
|
||||
});
|
||||
}
|
||||
appearance["facialBoneMorphs"] = std::move(facialBoneMorphs);
|
||||
|
||||
packet["appearance"] = std::move(appearance);
|
||||
}
|
||||
|
||||
|
||||
@@ -2052,7 +2052,9 @@ namespace
|
||||
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()) {
|
||||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2063,6 +2065,17 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t index = 0; index < a_left.facialBoneMorphs.size(); ++index) {
|
||||
const auto& left = a_left.facialBoneMorphs[index];
|
||||
const auto& right = a_right.facialBoneMorphs[index];
|
||||
if (left.id != right.id ||
|
||||
!std::equal(std::begin(left.position), std::end(left.position), std::begin(right.position)) ||
|
||||
!std::equal(std::begin(left.rotation), std::end(left.rotation), std::begin(right.rotation)) ||
|
||||
!std::equal(std::begin(left.scale), std::end(left.scale), std::begin(right.scale))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2380,33 +2393,63 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t appliedHeadPartCount = 0;
|
||||
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;
|
||||
}
|
||||
// Resolve the sender's head parts to local forms first; skip any that the
|
||||
// receiver cannot resolve so a missing form never aborts the whole list.
|
||||
std::vector<RE::BGSHeadPart*> resolvedHeadParts;
|
||||
resolvedHeadParts.reserve(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;
|
||||
}
|
||||
resolvedHeadParts.push_back(headPart);
|
||||
}
|
||||
|
||||
if (npc->headParts[index] != headPart) {
|
||||
npc->headParts[index] = headPart;
|
||||
if (!resolvedHeadParts.empty()) {
|
||||
const auto desiredCount = resolvedHeadParts.size();
|
||||
const bool countMatches =
|
||||
npc->headParts && static_cast<std::size_t>(npc->numHeadParts) == desiredCount;
|
||||
|
||||
if (countMatches) {
|
||||
for (std::size_t index = 0; index < desiredCount; ++index) {
|
||||
if (npc->headParts[index] != resolvedHeadParts[index]) {
|
||||
npc->headParts[index] = resolvedHeadParts[index];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
appliedHeadPartCount = desiredCount;
|
||||
} else {
|
||||
// Counts differ from the proxy base, so reallocate the head-part
|
||||
// array with the game allocator (the array is freed by the game's
|
||||
// MemoryManager). The previous array is intentionally left for the
|
||||
// game to manage rather than freed here, to avoid touching memory
|
||||
// that may be shared with the base template or an alternate list.
|
||||
auto* newHeadParts = RE::calloc<RE::BGSHeadPart*>(desiredCount);
|
||||
if (newHeadParts) {
|
||||
for (std::size_t index = 0; index < desiredCount; ++index) {
|
||||
newHeadParts[index] = resolvedHeadParts[index];
|
||||
}
|
||||
npc->headParts = newHeadParts;
|
||||
npc->numHeadParts = static_cast<std::int8_t>(desiredCount);
|
||||
appliedHeadPartCount = desiredCount;
|
||||
changed = true;
|
||||
} else {
|
||||
LogThrottledWarning(
|
||||
"proxy_appearance_headpart_alloc_failed_" + std::to_string(a_remotePlayer.playerId),
|
||||
std::format(
|
||||
"Could not resize head-part array for remote player {}; allocation of {} entries failed.",
|
||||
a_remotePlayer.playerId,
|
||||
desiredCount));
|
||||
}
|
||||
}
|
||||
} 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2423,15 +2466,42 @@ namespace
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!appearance.morphRegionSliders.empty()) {
|
||||
if (!npc->morphRegionSliderValues) {
|
||||
npc->morphRegionSliderValues = new RE::BSTArray<float>();
|
||||
}
|
||||
npc->morphRegionSliderValues->clear();
|
||||
for (const auto value : appearance.morphRegionSliders) {
|
||||
npc->morphRegionSliderValues->push_back(std::isfinite(value) ? value : 0.0F);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!appearance.facialBoneMorphs.empty()) {
|
||||
if (!npc->facialBoneRegionSliderValues) {
|
||||
npc->facialBoneRegionSliderValues = new RE::BSTHashMap<std::uint32_t, RE::BGSCharacterMorph::Transform>();
|
||||
}
|
||||
npc->facialBoneRegionSliderValues->clear();
|
||||
for (const auto& morph : appearance.facialBoneMorphs) {
|
||||
RE::BGSCharacterMorph::Transform transform{};
|
||||
transform.position = { morph.position[0], morph.position[1], morph.position[2] };
|
||||
transform.rotation = { morph.rotation[0], morph.rotation[1], morph.rotation[2] };
|
||||
transform.scale = { morph.scale[0], morph.scale[1], morph.scale[2] };
|
||||
npc->facialBoneRegionSliderValues->insert({ morph.id, transform });
|
||||
}
|
||||
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={}.",
|
||||
"Applied synced appearance to runtime proxy actor {:08X} for remote player {}: headParts={}/{}, morphs={}.",
|
||||
a_proxy.GetFormID(),
|
||||
a_remotePlayer.playerId,
|
||||
appliedHeadPartCount,
|
||||
appearance.headPartFormIds.size(),
|
||||
appearance.morphSliders.size()));
|
||||
}
|
||||
|
||||
+40
-1
@@ -15,6 +15,7 @@
|
||||
#include "RE/B/BSFixedString.h"
|
||||
#include "RE/I/IAnimationGraphManagerHolder.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
@@ -66,6 +67,7 @@ namespace
|
||||
using RemoteEquippedItem = F4T::RemotePlayerState::RemoteEquippedItem;
|
||||
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
|
||||
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
|
||||
using RemoteAppearanceFacialBoneMorph = F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph;
|
||||
|
||||
struct SyncedEquipmentSlot
|
||||
{
|
||||
@@ -271,6 +273,30 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
if (npc->morphRegionSliderValues) {
|
||||
for (const auto& value : *npc->morphRegionSliderValues) {
|
||||
appearance.morphRegionSliders.push_back(std::isfinite(value) ? value : 0.0F);
|
||||
}
|
||||
}
|
||||
|
||||
if (npc->facialBoneRegionSliderValues) {
|
||||
for (const auto& region : *npc->facialBoneRegionSliderValues) {
|
||||
const auto& transform = region.second;
|
||||
RemoteAppearanceFacialBoneMorph morph{};
|
||||
morph.id = region.first;
|
||||
morph.position[0] = transform.position.x;
|
||||
morph.position[1] = transform.position.y;
|
||||
morph.position[2] = transform.position.z;
|
||||
morph.rotation[0] = transform.rotation.x;
|
||||
morph.rotation[1] = transform.rotation.y;
|
||||
morph.rotation[2] = transform.rotation.z;
|
||||
morph.scale[0] = transform.scale.x;
|
||||
morph.scale[1] = transform.scale.y;
|
||||
morph.scale[2] = transform.scale.z;
|
||||
appearance.facialBoneMorphs.push_back(morph);
|
||||
}
|
||||
}
|
||||
|
||||
return appearance;
|
||||
}
|
||||
|
||||
@@ -306,7 +332,9 @@ namespace
|
||||
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()) {
|
||||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -317,6 +345,17 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t index = 0; index < a_left.facialBoneMorphs.size(); ++index) {
|
||||
const auto& left = a_left.facialBoneMorphs[index];
|
||||
const auto& right = a_right.facialBoneMorphs[index];
|
||||
if (left.id != right.id ||
|
||||
!std::equal(std::begin(left.position), std::end(left.position), std::begin(right.position)) ||
|
||||
!std::equal(std::begin(left.rotation), std::end(left.rotation), std::begin(right.rotation)) ||
|
||||
!std::equal(std::begin(left.scale), std::end(left.scale), std::begin(right.scale))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user