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:
@@ -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']}"
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user