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]
|
||||
|
||||
### 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.
|
||||
|
||||
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)
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-3
@@ -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
@@ -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>"
|
||||
)
|
||||
|
||||
@@ -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]]]
|
||||
|
||||
Reference in New Issue
Block a user