Add appearance tint syncing (appearance v3)
Introduce tint syncing for skin/complection/makeup/beard by adding RemoteAppearanceTint and a tints vector, and bump appearance payload version to 3. Parse/emit tints in networking and update fake server/client test payloads. Capture live Player/NPC tintingData during appearance capture. Apply tints to proxies by updating existing entries or reconstructing missing ones via template resolution and MakeTintEntry (game allocator + vtable fixup); unresolved templates are skipped with throttled warnings. Update docs/changelog and add logging for applied tints. Backwards compatible with v1/v2 packets.
This commit is contained in:
@@ -580,6 +580,44 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto tints = appearanceObject->find("tints");
|
||||
tints != appearanceObject->end() && tints->is_array()) {
|
||||
for (const auto& tint : *tints) {
|
||||
if (!tint.is_object()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto id = ReadDouble(tint, "id");
|
||||
const auto type = ReadDouble(tint, "type");
|
||||
const auto value = ReadDouble(tint, "value");
|
||||
if (!id || !std::isfinite(*id) || *id < 0.0 || *id > 65535.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
F4T::RemotePlayerState::RemoteAppearanceTint entry{};
|
||||
entry.id = static_cast<std::uint16_t>(*id);
|
||||
entry.type = (type && std::isfinite(*type) && *type >= 0.0 && *type <= 3.0)
|
||||
? static_cast<std::uint32_t>(*type)
|
||||
: 0U;
|
||||
entry.value = (value && std::isfinite(*value))
|
||||
? static_cast<std::uint8_t>(std::clamp(*value, 0.0, 255.0))
|
||||
: 0U;
|
||||
|
||||
if (const auto colorText = ReadString(tint, "color"); colorText && !colorText->empty()) {
|
||||
if (const auto color = ParseHexFormId(*colorText)) {
|
||||
entry.color = *color;
|
||||
entry.hasColor = true;
|
||||
if (const auto swatch = ReadDouble(tint, "swatch");
|
||||
swatch && std::isfinite(*swatch) && *swatch >= 0.0 && *swatch <= 65535.0) {
|
||||
entry.swatch = static_cast<std::uint16_t>(*swatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appearance.tints.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return appearance;
|
||||
}
|
||||
|
||||
@@ -1246,6 +1284,21 @@ namespace F4T::Networking
|
||||
}
|
||||
appearance["facialBoneMorphs"] = std::move(facialBoneMorphs);
|
||||
|
||||
auto tints = nlohmann::json::array();
|
||||
for (const auto& tint : a_appearance->tints) {
|
||||
auto entry = nlohmann::json{
|
||||
{ "id", tint.id },
|
||||
{ "type", tint.type },
|
||||
{ "value", tint.value }
|
||||
};
|
||||
if (tint.hasColor) {
|
||||
entry["color"] = std::format("{:08X}", tint.color);
|
||||
entry["swatch"] = tint.swatch;
|
||||
}
|
||||
tints.push_back(std::move(entry));
|
||||
}
|
||||
appearance["tints"] = std::move(tints);
|
||||
|
||||
packet["appearance"] = std::move(appearance);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#include <mutex>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
@@ -2053,6 +2054,7 @@ namespace
|
||||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
|
||||
a_left.headPartFormIds != a_right.headPartFormIds ||
|
||||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||
a_left.tints != a_right.tints ||
|
||||
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||
return false;
|
||||
@@ -2295,6 +2297,71 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a race tint template entry by its uniqueID. Returns nullptr when the
|
||||
// race, its per-sex face data, or the template group list is unavailable, or
|
||||
// when no template matches. Used so reconstructed tints reference a valid
|
||||
// template the receiver's facegen can resolve.
|
||||
RE::BGSCharacterTint::Template::Entry* ResolveTintTemplateEntry(RE::TESNPC& a_npc, std::uint16_t a_uniqueId)
|
||||
{
|
||||
const auto* race = a_npc.GetFormRace();
|
||||
if (!race) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto sexIndex = a_npc.GetSex() == RE::SEX::kFemale ? 1 : 0;
|
||||
const auto* faceData = race->faceRelatedData[sexIndex];
|
||||
if (!faceData || !faceData->tintingTemplate) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (auto* group : faceData->tintingTemplate->groups) {
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
for (auto* entry : group->entries) {
|
||||
if (entry && entry->uniqueID == a_uniqueId) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Allocate a runtime tint entry of the requested type with the game allocator
|
||||
// and install the correct vtable pointer. These classes are declared novtable,
|
||||
// so a plain `new` would leave the vtable unset and crash facegen; poking the
|
||||
// resolved vtable address is the established commonlib pattern.
|
||||
RE::BGSCharacterTint::Entry* MakeTintEntry(RE::BGSCharacterTint::EntryType a_type)
|
||||
{
|
||||
std::size_t size = 0;
|
||||
std::uintptr_t vtable = 0;
|
||||
switch (a_type) {
|
||||
case RE::BGSCharacterTint::EntryType::kPalette:
|
||||
size = sizeof(RE::BGSCharacterTint::PaletteEntry);
|
||||
vtable = REL::Relocation<std::uintptr_t>{ RE::BGSCharacterTint::PaletteEntry::VTABLE[0] }.address();
|
||||
break;
|
||||
case RE::BGSCharacterTint::EntryType::kTexture:
|
||||
size = sizeof(RE::BGSCharacterTint::TextureSetEntry);
|
||||
vtable = REL::Relocation<std::uintptr_t>{ RE::BGSCharacterTint::TextureSetEntry::VTABLE[0] }.address();
|
||||
break;
|
||||
case RE::BGSCharacterTint::EntryType::kMask:
|
||||
default:
|
||||
size = sizeof(RE::BGSCharacterTint::MaskEntry);
|
||||
vtable = REL::Relocation<std::uintptr_t>{ RE::BGSCharacterTint::MaskEntry::VTABLE[0] }.address();
|
||||
break;
|
||||
}
|
||||
|
||||
auto* memory = RE::malloc(size);
|
||||
if (!memory) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::memset(memory, 0, size);
|
||||
*reinterpret_cast<std::uintptr_t*>(memory) = vtable;
|
||||
return static_cast<RE::BGSCharacterTint::Entry*>(memory);
|
||||
}
|
||||
|
||||
void ApplyProxyAppearanceFromRemoteState(
|
||||
ProxyActorSlot& a_slot,
|
||||
RE::Actor& a_proxy,
|
||||
@@ -2492,18 +2559,93 @@ namespace
|
||||
changed = true;
|
||||
}
|
||||
|
||||
std::size_t appliedTintCount = 0;
|
||||
if (!appearance.tints.empty()) {
|
||||
if (!npc->tintingData) {
|
||||
npc->tintingData = new RE::BGSCharacterTint::Entries();
|
||||
}
|
||||
|
||||
auto& tintEntries = npc->tintingData->entriesA;
|
||||
for (const auto& tint : appearance.tints) {
|
||||
const auto entryType = static_cast<RE::BGSCharacterTint::EntryType>(tint.type);
|
||||
|
||||
// Prefer updating an existing entry in place (safe: no allocation and
|
||||
// the game already wired up its template pointer and vtable).
|
||||
RE::BGSCharacterTint::Entry* existing = nullptr;
|
||||
for (auto* candidate : tintEntries) {
|
||||
if (candidate && candidate->idLink == tint.id) {
|
||||
existing = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing->tingingValue != static_cast<std::int8_t>(tint.value)) {
|
||||
existing->tingingValue = static_cast<std::int8_t>(tint.value);
|
||||
changed = true;
|
||||
}
|
||||
if (tint.hasColor && existing->GetType() == RE::BGSCharacterTint::EntryType::kPalette) {
|
||||
auto* palette = static_cast<RE::BGSCharacterTint::PaletteEntry*>(existing);
|
||||
if (palette->tintingColor != tint.color || palette->swatchID != tint.swatch) {
|
||||
palette->tintingColor = tint.color;
|
||||
palette->swatchID = tint.swatch;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
++appliedTintCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No matching entry on the proxy base: reconstruct one, but only when
|
||||
// the race template resolves so the new entry is never left dangling.
|
||||
auto* templateEntry = ResolveTintTemplateEntry(*npc, tint.id);
|
||||
if (!templateEntry) {
|
||||
LogThrottledWarning(
|
||||
"proxy_appearance_tint_template_missing_" + std::to_string(a_remotePlayer.playerId) + "_" + std::to_string(tint.id),
|
||||
std::format(
|
||||
"Skipped tint {} for remote player {}; no matching race tint template resolved.",
|
||||
tint.id,
|
||||
a_remotePlayer.playerId));
|
||||
continue;
|
||||
}
|
||||
|
||||
auto* entry = MakeTintEntry(entryType);
|
||||
if (!entry) {
|
||||
LogThrottledWarning(
|
||||
"proxy_appearance_tint_alloc_failed_" + std::to_string(a_remotePlayer.playerId),
|
||||
std::format("Could not allocate tint entry for remote player {}.", a_remotePlayer.playerId));
|
||||
continue;
|
||||
}
|
||||
|
||||
entry->templateEntry = reinterpret_cast<RE::BGSCharacterTint::Entry*>(templateEntry);
|
||||
entry->idLink = tint.id;
|
||||
entry->tingingValue = static_cast<std::int8_t>(tint.value);
|
||||
if (entryType == RE::BGSCharacterTint::EntryType::kPalette) {
|
||||
auto* palette = static_cast<RE::BGSCharacterTint::PaletteEntry*>(entry);
|
||||
palette->tintingColor = tint.color;
|
||||
palette->swatchID = tint.swatch;
|
||||
}
|
||||
|
||||
tintEntries.push_back(entry);
|
||||
++appliedTintCount;
|
||||
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={}, tints={}/{}.",
|
||||
a_proxy.GetFormID(),
|
||||
a_remotePlayer.playerId,
|
||||
appliedHeadPartCount,
|
||||
appearance.headPartFormIds.size(),
|
||||
appearance.morphSliders.size()));
|
||||
appearance.morphSliders.size(),
|
||||
appliedTintCount,
|
||||
appearance.tints.size()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ namespace
|
||||
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
|
||||
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
|
||||
using RemoteAppearanceFacialBoneMorph = F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph;
|
||||
using RemoteAppearanceTint = F4T::RemotePlayerState::RemoteAppearanceTint;
|
||||
|
||||
struct SyncedEquipmentSlot
|
||||
{
|
||||
@@ -297,6 +298,30 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
// Character tints (skin tone, complexion, makeup, beard shade). The live
|
||||
// chargen list lives on PlayerCharacter; fall back to the baked NPC list.
|
||||
auto* tintEntries = a_player.tintingData ? a_player.tintingData : npc->tintingData;
|
||||
if (tintEntries) {
|
||||
for (auto* entry : tintEntries->entriesA) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoteAppearanceTint tint{};
|
||||
tint.id = entry->idLink;
|
||||
tint.value = static_cast<std::uint8_t>(entry->tingingValue);
|
||||
const auto type = entry->GetType();
|
||||
tint.type = static_cast<std::uint32_t>(type);
|
||||
if (type == RE::BGSCharacterTint::EntryType::kPalette) {
|
||||
const auto* palette = static_cast<const RE::BGSCharacterTint::PaletteEntry*>(entry);
|
||||
tint.color = palette->tintingColor;
|
||||
tint.swatch = palette->swatchID;
|
||||
tint.hasColor = true;
|
||||
}
|
||||
appearance.tints.push_back(tint);
|
||||
}
|
||||
}
|
||||
|
||||
return appearance;
|
||||
}
|
||||
|
||||
@@ -333,6 +358,7 @@ namespace
|
||||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
|
||||
a_left.headPartFormIds != a_right.headPartFormIds ||
|
||||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||
a_left.tints != a_right.tints ||
|
||||
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user