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:
@@ -10,6 +10,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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`.
|
- 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.
|
- `server/fake_client.py` parsing/output and `server/fake_player.py` sample payloads for the new `morphRegions` and `facialBoneMorphs` appearance fields.
|
||||||
|
|||||||
Binary file not shown.
@@ -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)
|
## 2026-06-30 - Head-Part Count Mismatch Fix (Hair Sync)
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -59,9 +59,23 @@ namespace F4T::RemotePlayerState
|
|||||||
float scale[3]{ 1.0F, 1.0F, 1.0F };
|
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
|
struct RemoteAppearance
|
||||||
{
|
{
|
||||||
std::uint32_t version{ 2 };
|
std::uint32_t version{ 3 };
|
||||||
std::uint32_t raceFormId{};
|
std::uint32_t raceFormId{};
|
||||||
float height{ 1.0F };
|
float height{ 1.0F };
|
||||||
RemoteAppearanceMorphWeight morphWeight;
|
RemoteAppearanceMorphWeight morphWeight;
|
||||||
@@ -72,6 +86,7 @@ namespace F4T::RemotePlayerState
|
|||||||
std::vector<RemoteAppearanceMorph> morphSliders;
|
std::vector<RemoteAppearanceMorph> morphSliders;
|
||||||
std::vector<float> morphRegionSliders;
|
std::vector<float> morphRegionSliders;
|
||||||
std::vector<RemoteAppearanceFacialBoneMorph> facialBoneMorphs;
|
std::vector<RemoteAppearanceFacialBoneMorph> facialBoneMorphs;
|
||||||
|
std::vector<RemoteAppearanceTint> tints;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RemotePlayerState
|
struct RemotePlayerState
|
||||||
|
|||||||
@@ -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;
|
return appearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1246,6 +1284,21 @@ namespace F4T::Networking
|
|||||||
}
|
}
|
||||||
appearance["facialBoneMorphs"] = std::move(facialBoneMorphs);
|
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);
|
packet["appearance"] = std::move(appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
#include <format>
|
#include <format>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -2053,6 +2054,7 @@ namespace
|
|||||||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
|
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
|
||||||
a_left.headPartFormIds != a_right.headPartFormIds ||
|
a_left.headPartFormIds != a_right.headPartFormIds ||
|
||||||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||||
|
a_left.tints != a_right.tints ||
|
||||||
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||||
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||||
return false;
|
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(
|
void ApplyProxyAppearanceFromRemoteState(
|
||||||
ProxyActorSlot& a_slot,
|
ProxyActorSlot& a_slot,
|
||||||
RE::Actor& a_proxy,
|
RE::Actor& a_proxy,
|
||||||
@@ -2492,18 +2559,93 @@ namespace
|
|||||||
changed = true;
|
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.hasAppliedAppearanceState = true;
|
||||||
a_slot.lastAppliedAppearance = appearance;
|
a_slot.lastAppliedAppearance = appearance;
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
RefreshProxyAfterAppearanceChange(a_slot, a_proxy);
|
RefreshProxyAfterAppearanceChange(a_slot, a_proxy);
|
||||||
LogInfoWithLocalPlayerPrefix(std::format(
|
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_proxy.GetFormID(),
|
||||||
a_remotePlayer.playerId,
|
a_remotePlayer.playerId,
|
||||||
appliedHeadPartCount,
|
appliedHeadPartCount,
|
||||||
appearance.headPartFormIds.size(),
|
appearance.headPartFormIds.size(),
|
||||||
appearance.morphSliders.size()));
|
appearance.morphSliders.size(),
|
||||||
|
appliedTintCount,
|
||||||
|
appearance.tints.size()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ namespace
|
|||||||
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
|
using RemoteAppearance = F4T::RemotePlayerState::RemoteAppearance;
|
||||||
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
|
using RemoteAppearanceMorph = F4T::RemotePlayerState::RemoteAppearanceMorph;
|
||||||
using RemoteAppearanceFacialBoneMorph = F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph;
|
using RemoteAppearanceFacialBoneMorph = F4T::RemotePlayerState::RemoteAppearanceFacialBoneMorph;
|
||||||
|
using RemoteAppearanceTint = F4T::RemotePlayerState::RemoteAppearanceTint;
|
||||||
|
|
||||||
struct SyncedEquipmentSlot
|
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;
|
return appearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +358,7 @@ namespace
|
|||||||
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
|
a_left.facialHairColorFormId != a_right.facialHairColorFormId ||
|
||||||
a_left.headPartFormIds != a_right.headPartFormIds ||
|
a_left.headPartFormIds != a_right.headPartFormIds ||
|
||||||
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
a_left.morphRegionSliders != a_right.morphRegionSliders ||
|
||||||
|
a_left.tints != a_right.tints ||
|
||||||
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
a_left.morphSliders.size() != a_right.morphSliders.size() ||
|
||||||
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
a_left.facialBoneMorphs.size() != a_right.facialBoneMorphs.size()) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+11
-3
@@ -74,7 +74,7 @@ Example after server processing:
|
|||||||
{ "slot": "headband", "formId": "" }
|
{ "slot": "headband", "formId": "" }
|
||||||
],
|
],
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"raceFormId": "00013746",
|
"raceFormId": "00013746",
|
||||||
"height": 1.0,
|
"height": 1.0,
|
||||||
"morphWeight": { "thin": 0.0, "muscular": 0.0, "large": 0.0 },
|
"morphWeight": { "thin": 0.0, "muscular": 0.0, "large": 0.0 },
|
||||||
@@ -84,7 +84,8 @@ Example after server processing:
|
|||||||
"headParts": [],
|
"headParts": [],
|
||||||
"morphs": [],
|
"morphs": [],
|
||||||
"morphRegions": [],
|
"morphRegions": [],
|
||||||
"facialBoneMorphs": []
|
"facialBoneMorphs": [],
|
||||||
|
"tints": []
|
||||||
},
|
},
|
||||||
"serverTime": 1780212128.3011043
|
"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:
|
best-effort snapshot for male-proxy-compatible runtime visuals:
|
||||||
|
|
||||||
```text
|
```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.
|
raceFormId Hex-string race form ID captured for diagnostics/future use; receivers do not switch proxy race yet.
|
||||||
height NPC height scalar.
|
height NPC height scalar.
|
||||||
morphWeight Body triangle weights: thin, muscular, and large.
|
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.
|
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).
|
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).
|
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
|
`morphRegions` and `facialBoneMorphs` are optional and additive. Version 1
|
||||||
senders omit them; receivers must treat their absence as "no change" and keep
|
senders omit them; receivers must treat their absence as "no change" and keep
|
||||||
existing proxy morph data.
|
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
|
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
|
apply only the parts they can resolve locally. Gender is intentionally not part
|
||||||
of this payload yet; female proxy support can add it later.
|
of this payload yet; female proxy support can add it later.
|
||||||
|
|||||||
+24
-2
@@ -101,6 +101,7 @@ def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
"morphs": [],
|
"morphs": [],
|
||||||
"morphRegions": [],
|
"morphRegions": [],
|
||||||
"facialBoneMorphs": [],
|
"facialBoneMorphs": [],
|
||||||
|
"tints": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
head_parts = value.get("headParts", [])
|
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)
|
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
|
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"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
|
||||||
f"morphs={len(appearance.get('morphs', []))}, "
|
f"morphs={len(appearance.get('morphs', []))}, "
|
||||||
f"morphRegions={len(appearance.get('morphRegions', []))}, "
|
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)
|
if isinstance(appearance, dict)
|
||||||
else "<not sent>"
|
else "<not sent>"
|
||||||
@@ -320,7 +342,7 @@ def print_remote_players() -> None:
|
|||||||
)
|
)
|
||||||
appearance = player.get("appearance")
|
appearance = player.get("appearance")
|
||||||
appearance_text = (
|
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)
|
if isinstance(appearance, dict)
|
||||||
else "<not sent>"
|
else "<not sent>"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ IDLE_ANGLE_Z = 3.93
|
|||||||
TELEPORT_TEST_OFFSET_X = 900.0
|
TELEPORT_TEST_OFFSET_X = 900.0
|
||||||
TELEPORT_TEST_OFFSET_Y = 450.0
|
TELEPORT_TEST_OFFSET_Y = 450.0
|
||||||
DEFAULT_APPEARANCE = {
|
DEFAULT_APPEARANCE = {
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"raceFormId": "00013746",
|
"raceFormId": "00013746",
|
||||||
"height": 1.0,
|
"height": 1.0,
|
||||||
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
|
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
|
||||||
@@ -55,6 +55,7 @@ DEFAULT_APPEARANCE = {
|
|||||||
"morphs": [],
|
"morphs": [],
|
||||||
"morphRegions": [],
|
"morphRegions": [],
|
||||||
"facialBoneMorphs": [],
|
"facialBoneMorphs": [],
|
||||||
|
"tints": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
|
||||||
|
|||||||
Reference in New Issue
Block a user