Add appearance sync and late-join replay

Adds optional `appearance` snapshots to transform packets, applies supported appearance data to runtime proxies, and has the server replay stored transforms to newly connected clients so late joiners receive current visuals immediately.
This commit is contained in:
2026-06-29 16:46:50 +12:00
parent ad7f287899
commit 4b8f5246c4
15 changed files with 772 additions and 38 deletions
+51
View File
@@ -84,6 +84,38 @@ def get_optional_equipped_items(packet: dict[str, Any]) -> list[dict[str, str]]:
return equipped_items
def get_optional_appearance(packet: dict[str, Any]) -> dict[str, Any] | None:
value = packet.get("appearance")
if not isinstance(value, dict):
return None
appearance: dict[str, Any] = {
"version": get_optional_uint32(value, "version", 1),
"raceFormId": value.get("raceFormId", ""),
"height": get_optional_float(value, "height", 1.0),
"morphWeight": value.get("morphWeight", {}),
"bodyTintColor": value.get("bodyTintColor", {}),
"hairColorFormId": value.get("hairColorFormId", ""),
"facialHairColorFormId": value.get("facialHairColorFormId", ""),
"headParts": [],
"morphs": [],
}
head_parts = value.get("headParts", [])
if isinstance(head_parts, list):
appearance["headParts"] = [form_id for form_id in head_parts if isinstance(form_id, str)]
morphs = value.get("morphs", [])
if isinstance(morphs, list):
appearance["morphs"] = [
{"id": morph.get("id", ""), "value": get_optional_float(morph, "value")}
for morph in morphs
if isinstance(morph, dict) and isinstance(morph.get("id", ""), str)
]
return appearance
def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state
@@ -184,6 +216,7 @@ def update_remote_player_state(packet: dict[str, Any]) -> None:
"actorStateFlags1": get_optional_uint32(packet, "actorStateFlags1"),
"actorStateFlags2": get_optional_uint32(packet, "actorStateFlags2"),
"equippedItems": get_optional_equipped_items(packet),
"appearance": get_optional_appearance(packet),
"lastReceivedLocalTime": time.time(),
}
@@ -200,6 +233,16 @@ def print_remote_player(player: dict[str, Any]) -> None:
if equipped_items
else "<not sent>"
)
appearance = player.get("appearance")
appearance_text = (
(
f"version={appearance.get('version')}, race={appearance.get('raceFormId') or '<empty>'}, "
f"height={appearance.get('height')}, headParts={len(appearance.get('headParts', []))}, "
f"morphs={len(appearance.get('morphs', []))}"
)
if isinstance(appearance, dict)
else "<not sent>"
)
log(
"\n".join(
@@ -215,6 +258,7 @@ def print_remote_player(player: dict[str, Any]) -> None:
),
f"Actor State: flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}",
f"Equipment: {equipment_text}",
f"Appearance: {appearance_text}",
f"Cell: {player['cellId']}",
f"Worldspace: {player['worldspaceId']}",
f"Client Time: {client_time}",
@@ -238,6 +282,12 @@ def print_remote_players() -> None:
if player.get("equippedItems")
else "<not sent>"
)
appearance = player.get("appearance")
appearance_text = (
f"race={appearance.get('raceFormId') or '<empty>'}, headParts={len(appearance.get('headParts', []))}, morphs={len(appearance.get('morphs', []))}"
if isinstance(appearance, dict)
else "<not sent>"
)
log(
f"- Player {player_id}: "
f"pos=({player['x']:.2f}, {player['y']:.2f}, {player['z']:.2f}), "
@@ -247,6 +297,7 @@ def print_remote_players() -> None:
f"crouching={player['isCrouching']}, weaponDrawn={player['weaponDrawn']}, speed={player['movementSpeed']:.1f}, "
f"flags1={player['actorStateFlags1']:08X}, flags2={player['actorStateFlags2']:08X}, "
f"equipment={equipment_text}, "
f"appearance={appearance_text}, "
f"cell={player['cellId']}, worldspace={player['worldspaceId']}, "
f"serverTime={player['serverTime']}"
)
+12
View File
@@ -43,6 +43,17 @@ IDLE_Z = 0.0
IDLE_ANGLE_Z = 3.93
TELEPORT_TEST_OFFSET_X = 900.0
TELEPORT_TEST_OFFSET_Y = 450.0
DEFAULT_APPEARANCE = {
"version": 1,
"raceFormId": "00013746",
"height": 1.0,
"morphWeight": {"thin": 0.0, "muscular": 0.0, "large": 0.0},
"bodyTintColor": {"r": 255, "g": 220, "b": 190, "a": 255},
"hairColorFormId": "",
"facialHairColorFormId": "",
"headParts": [],
"morphs": [],
}
ClientSnapshotProvider = Callable[[], list[dict[str, Any]]]
WalkTargetProvider = Callable[[int], dict[str, Any] | None]
@@ -567,6 +578,7 @@ class FakePlayerClient:
"isCrouching": crouching,
"weaponDrawn": False,
"movementSpeed": movement_speed,
"appearance": DEFAULT_APPEARANCE,
}
if is_moving and movement_speed >= 1.0:
packet["animationGraphSpeed"] = round(
+34
View File
@@ -304,6 +304,7 @@ class FalloutTogetherServer:
welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id
self._send_packet(client, welcome_packet)
self._send_existing_transforms_to_client(client)
while self.is_running():
chunk = connection.recv(4096)
@@ -382,6 +383,39 @@ class FalloutTogetherServer:
if broadcast:
self._stats["packetsBroadcast"] += 1
def _send_existing_transforms_to_client(self, new_client: ClientSession) -> None:
with self._lock:
snapshots = [
dict(client.last_transform)
for client in self._clients.values()
if client.connection != new_client.connection and client.last_transform is not None
]
if not snapshots:
return
successful_sends = 0
failed = False
for packet in snapshots:
packet["serverTime"] = time.time()
try:
self._send_packet(new_client, packet, broadcast=True)
successful_sends += 1
except OSError:
failed = True
break
with self._lock:
self._stats["transformPacketsBroadcast"] += successful_sends
if successful_sends:
self._log(
f"Sent {successful_sends} existing transform snapshot(s) to newly connected player {new_client.player_id}"
)
if failed:
self._disconnect_client(new_client)
def _disconnect_client(self, client: ClientSession) -> None:
with self._lock:
was_world_state_host = client.player_id == self._world_state_host_player_id