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
+3
View File
@@ -10,6 +10,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased]
### Added
- Appearance protocol version 3 with an optional `tints` field (character tint layers: skin tone, complexion, makeup, beard shade) so proxies can mirror the sender's skin colour and facial detail.
- Capture of player/NPC `tintingData` in `CaptureLocalAppearance`, plus game-thread application of tints onto runtime proxies: existing entries are updated in place by template ID, and missing entries are reconstructed against the proxy race's tint templates (skipped when no template resolves).
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payload for the new `tints` appearance field.
- Appearance protocol version 2 with optional `morphRegions` (region morph slider values) and `facialBoneMorphs` (bone-based facial morph transforms) fields so proxies receive advanced face-shape morphs that previously did not sync.
- Capture of `TESNPC::morphRegionSliderValues` and `TESNPC::facialBoneRegionSliderValues` in `CaptureLocalAppearance`, plus game-thread application of both onto runtime proxies in `ApplyProxyAppearanceFromRemoteState`.
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payloads for the new `morphRegions` and `facialBoneMorphs` appearance fields.
+38
View File
@@ -9,6 +9,44 @@ failed experiments, successful tests, and next steps.
---
## 2026-06-30 - Skin Colour / Tint Appearance Sync (Appearance v3, Phase 2)
### Summary
Phase 2 of the appearance fix: sync character tints so proxies mirror skin tone, complexion, makeup, dirt, scars, and beard shade. These live in `tintingData` (a `BGSCharacterTint::Entries` list of polymorphic entries), which was never captured or applied, so skin colour stayed default. Bumped the appearance payload to version 3.
### Files Changed
- `plugin/include/F4TRemotePlayerState.h`
- `plugin/src/main.cpp`
- `plugin/src/F4TNetworking.cpp`
- `plugin/src/F4TProxyActorController.cpp`
- `protocol/packets.md`
- `server/fake_client.py`
- `server/fake_player.py`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Added `RemoteAppearanceTint { id, type, value, color, swatch, hasColor }` and a `tints` vector to `RemoteAppearance`; default `version` is now 3.
- Capture reads the live chargen tint list from `PlayerCharacter::tintingData` (falling back to the baked `TESNPC::tintingData`), recording each entry's `idLink`, `tingingValue`, and `EntryType`, plus `tintingColor`/`swatchID` for palette entries.
- Apply prefers updating an existing proxy tint entry in place when `idLink` matches (no allocation, vtable/template already valid). When no entry matches, it resolves the proxy race's tint template by `uniqueID` via `TESRace::FaceRelatedData::tintingTemplate` and reconstructs the entry.
- Reconstruction uses the game allocator (`RE::malloc`) and pokes the correct vtable from the `BGSCharacterTint::*Entry::VTABLE` relocation IDs, because the entry classes are `__declspec(novtable)` and a plain `new` would crash facegen. Unresolved templates are skipped with a throttled warning rather than creating a dangling entry.
- The apply success log now also reports `tints=applied/sent`.
### Testing
- Static review and linter only; `python -m py_compile` passes for the harness files. No in-game run yet.
- Recommended manual check: run the Python server, connect two Fallout 4 instances with visibly different skin tones, and confirm each proxy's skin colour/complexion now matches the remote player, with a log line like `tints=NN/NN` and no `Skipped tint` template warnings.
- Backwards compatibility: confirm version 1/2 packets (without `tints`) still apply without error.
### Known Issues
- Tint rendering depends on facegen re-reading `tintingData` during the existing head/face 3D refresh; if skin tone does not visibly change despite `tints=applied`, a dedicated facegen/complexion regen step may be needed.
- Reconstructed entries are appended and not removed if the sender later drops a tint; tint idLinks are stable in practice, so this is cosmetic.
- Appearance data is still written to the proxy's shared base `TESNPC`, so 3+ proxies per client could clobber (pre-existing limitation).
### Next Steps
- Verify skin colour in-game; if facegen does not pick up tints from the standard refresh, add a targeted tint/complexion regeneration call.
---
## 2026-06-30 - Head-Part Count Mismatch Fix (Hair Sync)
### Summary
+16 -1
View File
@@ -59,9 +59,23 @@ namespace F4T::RemotePlayerState
float scale[3]{ 1.0F, 1.0F, 1.0F };
};
// Character tint layer (TESNPC/PlayerCharacter tintingData entry).
// Drives skin tone, complexion, makeup, dirt, scars, and beard shade.
struct RemoteAppearanceTint
{
std::uint16_t id{}; // idLink, matches a race tint template uniqueID
std::uint32_t type{}; // BGSCharacterTint::EntryType (mask/palette/texture)
std::uint8_t value{}; // tingingValue intensity (0-255)
std::uint32_t color{}; // ARGB packed color (palette entries only)
std::uint16_t swatch{}; // swatchID (palette entries only)
bool hasColor{ false }; // whether color/swatch are meaningful
bool operator==(const RemoteAppearanceTint&) const = default;
};
struct RemoteAppearance
{
std::uint32_t version{ 2 };
std::uint32_t version{ 3 };
std::uint32_t raceFormId{};
float height{ 1.0F };
RemoteAppearanceMorphWeight morphWeight;
@@ -72,6 +86,7 @@ namespace F4T::RemotePlayerState
std::vector<RemoteAppearanceMorph> morphSliders;
std::vector<float> morphRegionSliders;
std::vector<RemoteAppearanceFacialBoneMorph> facialBoneMorphs;
std::vector<RemoteAppearanceTint> tints;
};
struct RemotePlayerState
+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;
+11 -3
View File
@@ -74,7 +74,7 @@ Example after server processing:
{ "slot": "headband", "formId": "" }
],
"appearance": {
"version": 2,
"version": 3,
"raceFormId": "00013746",
"height": 1.0,
"morphWeight": { "thin": 0.0, "muscular": 0.0, "large": 0.0 },
@@ -84,7 +84,8 @@ Example after server processing:
"headParts": [],
"morphs": [],
"morphRegions": [],
"facialBoneMorphs": []
"facialBoneMorphs": [],
"tints": []
},
"serverTime": 1780212128.3011043
}
@@ -162,7 +163,7 @@ receivers keep the proxy's existing/default body and face. When present, it is a
best-effort snapshot for male-proxy-compatible runtime visuals:
```text
version Appearance payload version. Current value is 2.
version Appearance payload version. Current value is 3.
raceFormId Hex-string race form ID captured for diagnostics/future use; receivers do not switch proxy race yet.
height NPC height scalar.
morphWeight Body triangle weights: thin, muscular, and large.
@@ -173,12 +174,19 @@ headParts Hex-string `HDPT` form IDs for the sender's current head p
morphs Morph slider values as `{ "id": hex-string, "value": number }` entries.
morphRegions Region morph slider values as a flat array of numbers (added in version 2).
facialBoneMorphs Bone-based facial morphs as `{ "id": hex-string, "position": [x,y,z], "rotation": [x,y,z], "scale": [x,y,z] }` entries (added in version 2).
tints Character tint layers (skin tone, complexion, makeup, beard shade) as `{ "id": number, "type": number, "value": number, "color": hex-string, "swatch": number }` entries (added in version 3). `id` is the race tint-template uniqueID, `type` is the tint entry type (0 mask, 1 palette, 2 texture), and `value` is the 0-255 intensity. `color` (ARGB hex) and `swatch` are present only for palette entries.
```
`morphRegions` and `facialBoneMorphs` are optional and additive. Version 1
senders omit them; receivers must treat their absence as "no change" and keep
existing proxy morph data.
`tints` is optional and additive (added in version 3). Earlier senders omit it;
receivers must treat its absence as "no change". Receivers apply a tint by
matching `id` against an existing proxy tint entry (updating intensity/color in
place) and only reconstruct a missing entry when the proxy race exposes a
matching tint template, skipping unresolved tints rather than failing.
Receivers must treat missing or malformed appearance subfields as non-fatal and
apply only the parts they can resolve locally. Gender is intentionally not part
of this payload yet; female proxy support can add it later.
+24 -2
View File
@@ -101,6 +101,7 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
"morphs": [],
"morphRegions": [],
"facialBoneMorphs": [],
"tints": [],
}
head_parts = value.get("headParts", [])
@@ -137,6 +138,26 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
# Added in appearance version 3; palette-only color/swatch are preserved when present.
tints = value.get("tints", [])
if isinstance(tints, list):
parsed_tints: list[dict[str, Any]] = []
for tint in tints:
if not isinstance(tint, dict) or not isinstance(tint.get("id"), (int, float)):
continue
entry: dict[str, Any] = {
"id": int(tint.get("id", 0)),
"type": int(tint["type"]) if isinstance(tint.get("type"), (int, float)) else 0,
"value": int(tint["value"]) if isinstance(tint.get("value"), (int, float)) else 0,
}
color = tint.get("color")
if isinstance(color, str) and color:
entry["color"] = color
if isinstance(tint.get("swatch"), (int, float)):
entry["swatch"] = int(tint["swatch"])
parsed_tints.append(entry)
appearance["tints"] = parsed_tints
return appearance
@@ -274,7 +295,8 @@ def print_remote_player(player: dict[str, Any]) -> None:
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
f"morphs={len(appearance.get('morphs', []))}, "
f"morphRegions={len(appearance.get('morphRegions', []))}, "
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}"
f"facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, "
f"tints={len(appearance.get('tints', []))}"
)
if isinstance(appearance, dict)
else "<not sent>"
@@ -320,7 +342,7 @@ def print_remote_players() -> None:
)
appearance = player.get("appearance")
appearance_text = (
f"race={appearance.get('raceFormId') or '<empty>'}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}"
f"race={appearance.get('raceFormId') or '<empty>'}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}, morphRegions={len(appearance.get('morphRegions', []))}, facialBoneMorphs={len(appearance.get('facialBoneMorphs', []))}, tints={len(appearance.get('tints', []))}"
if isinstance(appearance, dict)
else "<not sent>"
)
+2 -1
View File
@@ -44,7 +44,7 @@ IDLE_ANGLE_Z = 3.93
TELEPORT_TEST_OFFSET_X = 900.0
TELEPORT_TEST_OFFSET_Y = 450.0
DEFAULT_APPEARANCE = {
"version": 2,
"version": 3,
"raceFormId": "00013746",
"height": 1.0,
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
@@ -55,6 +55,7 @@ DEFAULT_APPEARANCE = {
"morphs": [],
"morphRegions": [],
"facialBoneMorphs": [],
"tints": [],
}
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]