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:
2026-06-30 22:07:49 +12:00
parent 5b54db64d4
commit 76fd8772bd
10 changed files with 317 additions and 9 deletions
+53
View File
@@ -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);
}
+144 -2
View File
@@ -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()));
}
}
+26
View File
@@ -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;