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
+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]]]