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] ## [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.
+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) ## 2026-06-30 - Head-Part Count Mismatch Fix (Hair Sync)
### Summary ### Summary
+16 -1
View File
@@ -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
+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; 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);
} }
+144 -2
View File
@@ -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()));
} }
} }
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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>"
) )
+2 -1
View File
@@ -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]]]