styly-netsync-server 0.11.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/PKG-INFO +9 -7
  2. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/README.md +8 -6
  3. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/pyproject.toml +1 -1
  4. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/binary_serializer.py +118 -32
  5. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/client.py +89 -19
  6. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/client_simulator.py +2 -0
  7. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/rest_bridge.py +74 -64
  8. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/server.py +239 -33
  9. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/PKG-INFO +9 -7
  10. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/SOURCES.txt +1 -0
  11. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_binary_serializer.py +196 -12
  12. styly_netsync_server-0.13.0/tests/test_client_variable_device_store.py +468 -0
  13. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/LICENSE +0 -0
  14. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/setup.cfg +0 -0
  15. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/__init__.py +0 -0
  16. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/__main__.py +0 -0
  17. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/adapters.py +0 -0
  18. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/cli.py +0 -0
  19. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/config.py +0 -0
  20. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/default.toml +0 -0
  21. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/events.py +0 -0
  22. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/logging_utils.py +0 -0
  23. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/network_utils.py +0 -0
  24. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/nv_sync.py +0 -0
  25. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync/types.py +0 -0
  26. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  27. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  28. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  29. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  30. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_all_run_methods.py +0 -0
  31. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_config.py +0 -0
  32. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_discovery_probe.py +0 -0
  33. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_logging_cli.py +0 -0
  34. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_multi_nic.py +0 -0
  35. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_nv_protocol.py +0 -0
  36. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_object_sync.py +0 -0
  37. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_port_error_message.py +0 -0
  38. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_python_client.py +0 -0
  39. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_reconnect_identity.py +0 -0
  40. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_rest_bridge.py +0 -0
  41. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_room_expiry.py +0 -0
  42. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_stealth_heartbeat.py +0 -0
  43. {styly_netsync_server-0.11.0 → styly_netsync_server-0.13.0}/tests/test_timing_monotonic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: styly-netsync-server
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences
5
5
  Author-email: "STYLY, Inc." <info@styly.inc>
6
6
  License-Expression: Apache-2.0
@@ -81,14 +81,16 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
81
81
 
82
82
  ## Wire protocol compatibility
83
83
 
84
- - Current transform wire protocol is `protocolVersion = 4`.
85
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V4 pose body.
86
- - v4 vs. v3: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes vs. v3's 6), so receivers can reconstruct the sender's rig-Y motion (e.g. elevators).
84
+ - Current transform wire protocol is `protocolVersion = 5`.
85
+ - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V5 pose body.
86
+ - v5 adds an optional `MovingFloorLocal` pose flag. Bound avatars send head, hands, and virtual transforms in the registered moving floor's local coordinates, and reuse the existing 8-byte physical slot as direct physical position/yaw.
87
+ - Unbound v5 poses keep the v4 `xrOriginDelta` semantics: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes vs. v3's 6), so receivers can reconstruct the sender's rig-Y motion.
87
88
  - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
88
89
  - Deploy Unity and Python updates together when changing transform protocol behavior.
89
- - Protocol v4 position quantization ranges:
90
+ - Protocol v5 position quantization ranges:
90
91
  - Absolute (`headPosAbs` only): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
91
- - XROrigin locomotion delta (`xrOriginDelta`, 4×`int16`: `dx, dy, dz, dyaw`): `0.01 m` per unit for translation, `0.1°` for yaw. Receivers reconstruct `physicalPos = invDeltaRot * (headPos − deltaPos)`; it is not on the wire as a separate absolute field.
92
+ - XROrigin locomotion delta for unbound poses (`xrOriginDelta`, 4×`int16`: `dx, dy, dz, dyaw`): `0.01 m` per unit for translation, `0.1°` for yaw. Receivers reconstruct `physicalPos = invDeltaRot * (headPos − deltaPos)`; it is not on the wire as a separate absolute field.
93
+ - Direct physical payload for moving-floor-local poses (`physical`, 4×`int16`: `x, y, z, yaw`): `0.01 m` per unit for translation, `0.1°` for yaw.
92
94
  - Head-relative (`right/left/virtual`): signed `int16` at `0.005 m` per unit, per-axis range `[-163.84 m, 163.835 m]`.
93
95
  - These are encoding limits, not a hard world-size cap. Worlds can be larger, but encoded axis values are clamped if they exceed the representable range.
94
96
 
@@ -96,7 +98,7 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
96
98
 
97
99
  The following options summarize trade-offs when expanding absolute-position range.
98
100
 
99
- Assumed baseline (`protocolVersion=4`):
101
+ Assumed unbound baseline (`protocolVersion=5`, `MovingFloorLocal` off):
100
102
  - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `46 bytes` (matches `test_client_body_size_with_full_pose_no_virtuals`).
101
103
  - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
102
104
 
@@ -42,14 +42,16 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
42
42
 
43
43
  ## Wire protocol compatibility
44
44
 
45
- - Current transform wire protocol is `protocolVersion = 4`.
46
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V4 pose body.
47
- - v4 vs. v3: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes vs. v3's 6), so receivers can reconstruct the sender's rig-Y motion (e.g. elevators).
45
+ - Current transform wire protocol is `protocolVersion = 5`.
46
+ - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V5 pose body.
47
+ - v5 adds an optional `MovingFloorLocal` pose flag. Bound avatars send head, hands, and virtual transforms in the registered moving floor's local coordinates, and reuse the existing 8-byte physical slot as direct physical position/yaw.
48
+ - Unbound v5 poses keep the v4 `xrOriginDelta` semantics: `xrOriginDelta` carries a Y component as a 4th `int16` (`dx, dy, dz, dyaw` = 8 bytes vs. v3's 6), so receivers can reconstruct the sender's rig-Y motion.
48
49
  - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
49
50
  - Deploy Unity and Python updates together when changing transform protocol behavior.
50
- - Protocol v4 position quantization ranges:
51
+ - Protocol v5 position quantization ranges:
51
52
  - Absolute (`headPosAbs` only): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
52
- - XROrigin locomotion delta (`xrOriginDelta`, 4×`int16`: `dx, dy, dz, dyaw`): `0.01 m` per unit for translation, `0.1°` for yaw. Receivers reconstruct `physicalPos = invDeltaRot * (headPos − deltaPos)`; it is not on the wire as a separate absolute field.
53
+ - XROrigin locomotion delta for unbound poses (`xrOriginDelta`, 4×`int16`: `dx, dy, dz, dyaw`): `0.01 m` per unit for translation, `0.1°` for yaw. Receivers reconstruct `physicalPos = invDeltaRot * (headPos − deltaPos)`; it is not on the wire as a separate absolute field.
54
+ - Direct physical payload for moving-floor-local poses (`physical`, 4×`int16`: `x, y, z, yaw`): `0.01 m` per unit for translation, `0.1°` for yaw.
53
55
  - Head-relative (`right/left/virtual`): signed `int16` at `0.005 m` per unit, per-axis range `[-163.84 m, 163.835 m]`.
54
56
  - These are encoding limits, not a hard world-size cap. Worlds can be larger, but encoded axis values are clamped if they exceed the representable range.
55
57
 
@@ -57,7 +59,7 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
57
59
 
58
60
  The following options summarize trade-offs when expanding absolute-position range.
59
61
 
60
- Assumed baseline (`protocolVersion=4`):
62
+ Assumed unbound baseline (`protocolVersion=5`, `MovingFloorLocal` off):
61
63
  - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `46 bytes` (matches `test_client_body_size_with_full_pose_no_virtuals`).
62
64
  - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
63
65
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "styly-netsync-server"
7
- version = "0.11.0"
7
+ version = "0.13.0"
8
8
  description = "STYLY NetSync Server - Multiplayer framework for Location-Based Entertainment VR/MR experiences"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -6,7 +6,7 @@ from typing import Any
6
6
  logger = logging.getLogger(__name__)
7
7
 
8
8
  # Message type identifiers
9
- PROTOCOL_VERSION = 4
9
+ PROTOCOL_VERSION = 5
10
10
  MSG_CLIENT_TRANSFORM = 1
11
11
  MSG_ROOM_TRANSFORM = 2 # Legacy room transform with short IDs only
12
12
  MSG_RPC = 3 # Remote procedure call
@@ -24,6 +24,7 @@ MSG_ROOM_OBJECTS = 14 # Server → Clients (PUB): room object states
24
24
  MSG_OBJECT_OWNERSHIP_REQUEST = 15 # Client → Server: RequestOwnership/ReleaseOwnership
25
25
  MSG_OBJECT_OWNERSHIP_CHANGED = 16 # Server → Clients (ROUTER): ownership changed
26
26
  MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request rejected
27
+ MSG_CLIENT_VAR_CLEAR = 18 # Clear all client variables for the sender
27
28
 
28
29
  # Transform data type identifiers (deprecated - kept for reference)
29
30
 
@@ -32,7 +33,7 @@ MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request reject
32
33
  _max_virtual_transforms = 50
33
34
  MAX_VIRTUAL_TRANSFORMS = _max_virtual_transforms # Legacy alias for backward compat
34
35
 
35
- # Protocol v4 transform encoding constants
36
+ # Protocol v5 transform encoding constants
36
37
  ABS_POS_SCALE = 0.01
37
38
  LOCO_POS_SCALE = 0.01
38
39
  REL_POS_SCALE = 0.005
@@ -69,6 +70,15 @@ POSE_FLAG_HEAD_VALID = 1 << 2
69
70
  POSE_FLAG_RIGHT_VALID = 1 << 3
70
71
  POSE_FLAG_LEFT_VALID = 1 << 4
71
72
  POSE_FLAG_VIRTUALS_VALID = 1 << 5
73
+ POSE_FLAG_MOVING_FLOOR_LOCAL = 1 << 6
74
+
75
+
76
+ def _compute_encoding_flags(flags: int) -> int:
77
+ """Return pose encoding flags for the sanitized pose flags."""
78
+ encoding_flags = ENCODING_FLAGS_DEFAULT
79
+ if flags & POSE_FLAG_MOVING_FLOOR_LOCAL:
80
+ encoding_flags &= ~ENCODING_PHYSICAL_IS_XRORIGIN_DELTA
81
+ return encoding_flags & 0xFF
72
82
 
73
83
 
74
84
  def get_max_virtual_transforms() -> int:
@@ -372,7 +382,7 @@ def _create_transform_dict(
372
382
 
373
383
 
374
384
  def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
375
- """Serialize a client body in protocol v4 compact format."""
385
+ """Serialize a client body in protocol v5 compact format."""
376
386
  pose_seq = int(client.get("poseSeq", 0)) & 0xFFFF
377
387
  head = client.get("head", {}) or {}
378
388
  right = client.get("rightHand", {}) or {}
@@ -411,34 +421,51 @@ def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
411
421
  POSE_FLAG_RIGHT_VALID | POSE_FLAG_LEFT_VALID | POSE_FLAG_VIRTUALS_VALID
412
422
  )
413
423
 
424
+ encoding_flags = _compute_encoding_flags(flags)
414
425
  buffer.extend(struct.pack("<H", pose_seq))
415
426
  buffer.append(flags)
416
- buffer.append(ENCODING_FLAGS_DEFAULT)
427
+ buffer.append(encoding_flags)
417
428
 
418
429
  physical_valid = bool(flags & POSE_FLAG_PHYSICAL_VALID)
419
430
  head_valid = bool(flags & POSE_FLAG_HEAD_VALID)
420
431
  right_valid = head_valid and bool(flags & POSE_FLAG_RIGHT_VALID)
421
432
  left_valid = head_valid and bool(flags & POSE_FLAG_LEFT_VALID)
422
433
  virtual_valid = head_valid and bool(flags & POSE_FLAG_VIRTUALS_VALID)
434
+ moving_floor_local = bool(flags & POSE_FLAG_MOVING_FLOOR_LOCAL)
423
435
 
424
436
  xr_origin_delta_x = float(client.get("xrOriginDeltaX", 0.0))
425
437
  xr_origin_delta_y = float(client.get("xrOriginDeltaY", 0.0))
426
438
  xr_origin_delta_z = float(client.get("xrOriginDeltaZ", 0.0))
427
439
  xr_origin_delta_yaw = float(client.get("xrOriginDeltaYaw", 0.0))
440
+ physical = client.get("physical", {}) or {}
428
441
  head_pos = _transform_get_position(head)
429
442
  head_rot = _transform_get_quaternion(head)
430
443
  head_rot_n = _normalize_quaternion(*head_rot)
431
444
 
432
445
  if physical_valid:
433
- buffer.extend(
434
- struct.pack(
435
- "<hhhh",
436
- _quantize_signed(xr_origin_delta_x, LOCO_POS_SCALE),
437
- _quantize_signed(xr_origin_delta_y, LOCO_POS_SCALE),
438
- _quantize_signed(xr_origin_delta_z, LOCO_POS_SCALE),
439
- _quantize_signed(xr_origin_delta_yaw, PHYSICAL_YAW_SCALE),
446
+ if moving_floor_local:
447
+ physical_pos = _transform_get_position(physical)
448
+ physical_rot = _transform_get_quaternion(physical)
449
+ physical_yaw = _quaternion_to_yaw_degrees(*physical_rot)
450
+ buffer.extend(
451
+ struct.pack(
452
+ "<hhhh",
453
+ _quantize_signed(physical_pos[0], LOCO_POS_SCALE),
454
+ _quantize_signed(physical_pos[1], LOCO_POS_SCALE),
455
+ _quantize_signed(physical_pos[2], LOCO_POS_SCALE),
456
+ _quantize_signed(physical_yaw, PHYSICAL_YAW_SCALE),
457
+ )
458
+ )
459
+ else:
460
+ buffer.extend(
461
+ struct.pack(
462
+ "<hhhh",
463
+ _quantize_signed(xr_origin_delta_x, LOCO_POS_SCALE),
464
+ _quantize_signed(xr_origin_delta_y, LOCO_POS_SCALE),
465
+ _quantize_signed(xr_origin_delta_z, LOCO_POS_SCALE),
466
+ _quantize_signed(xr_origin_delta_yaw, PHYSICAL_YAW_SCALE),
467
+ )
440
468
  )
441
- )
442
469
 
443
470
  if head_valid:
444
471
  _pack_int24_le(buffer, _quantize_signed_int24(head_pos[0], ABS_POS_SCALE))
@@ -739,6 +766,26 @@ def serialize_client_var_set(data: dict[str, Any]) -> bytes:
739
766
  return bytes(buffer)
740
767
 
741
768
 
769
+ def serialize_client_var_clear(data: dict[str, Any]) -> bytes:
770
+ """Serialize client variable clear message.
771
+
772
+ Args:
773
+ data: Dictionary with senderClientNo and timestamp.
774
+ """
775
+ buffer = bytearray()
776
+
777
+ # Message type
778
+ buffer.append(MSG_CLIENT_VAR_CLEAR)
779
+
780
+ # Sender client number (2 bytes)
781
+ buffer.extend(struct.pack("<H", data.get("senderClientNo", 0)))
782
+
783
+ # Timestamp (8 bytes double)
784
+ buffer.extend(struct.pack("<d", data.get("timestamp", 0.0)))
785
+
786
+ return bytes(buffer)
787
+
788
+
742
789
  def serialize_client_var_sync(data: dict[str, Any]) -> bytes:
743
790
  """Serialize client variable sync message
744
791
 
@@ -785,10 +832,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
785
832
  offset += 1
786
833
 
787
834
  # Validate message type is within valid range
788
- if (
789
- message_type < MSG_CLIENT_TRANSFORM
790
- or message_type > MSG_OBJECT_OWNERSHIP_REJECTED
791
- ):
835
+ if message_type < MSG_CLIENT_TRANSFORM or message_type > MSG_CLIENT_VAR_CLEAR:
792
836
  # Return invalid message type with None data instead of raising exception
793
837
  return message_type, None, b""
794
838
 
@@ -817,6 +861,8 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
817
861
  return message_type, _deserialize_client_var_set(data, offset), b""
818
862
  elif message_type == MSG_CLIENT_VAR_SYNC:
819
863
  return message_type, _deserialize_client_var_sync(data, offset), b""
864
+ elif message_type == MSG_CLIENT_VAR_CLEAR:
865
+ return message_type, _deserialize_client_var_clear(data, offset), b""
820
866
  elif message_type == MSG_OBJECT_POSE:
821
867
  return message_type, _deserialize_object_pose(data, offset), b""
822
868
  elif message_type == MSG_ROOM_OBJECTS:
@@ -852,7 +898,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
852
898
 
853
899
 
854
900
  def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any], int]:
855
- """Deserialize protocol v4 compact pose body."""
901
+ """Deserialize protocol v5 compact pose body."""
856
902
  result: dict[str, Any] = {}
857
903
  result["poseSeq"] = struct.unpack("<H", data[offset : offset + 2])[0]
858
904
  offset += 2
@@ -868,11 +914,14 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
868
914
  right_valid = head_valid and bool(flags & POSE_FLAG_RIGHT_VALID)
869
915
  left_valid = head_valid and bool(flags & POSE_FLAG_LEFT_VALID)
870
916
  virtual_valid = head_valid and bool(flags & POSE_FLAG_VIRTUALS_VALID)
917
+ moving_floor_local = bool(flags & POSE_FLAG_MOVING_FLOOR_LOCAL)
871
918
 
872
919
  physical = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, True)
873
- head = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, False)
874
- right = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, False)
875
- left = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, False)
920
+ head = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local)
921
+ right = _create_transform_dict(
922
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
923
+ )
924
+ left = _create_transform_dict(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local)
876
925
 
877
926
  head_pos = (0.0, 0.0, 0.0)
878
927
  head_rot = (0.0, 0.0, 0.0, 1.0)
@@ -882,15 +931,19 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
882
931
  xr_origin_delta_yaw = 0.0
883
932
 
884
933
  if physical_valid:
885
- if (encoding_flags & ENCODING_PHYSICAL_IS_XRORIGIN_DELTA) == 0:
934
+ if (
935
+ not moving_floor_local
936
+ and (encoding_flags & ENCODING_PHYSICAL_IS_XRORIGIN_DELTA) == 0
937
+ ):
886
938
  raise ValueError(
887
939
  "PhysicalValid set but XROrigin delta encoding flag is missing"
888
940
  )
889
941
  dx_q, dy_q, dz_q, dyaw_q = struct.unpack("<hhhh", data[offset : offset + 8])
890
- xr_origin_delta_x = _dequantize_signed(dx_q, LOCO_POS_SCALE)
891
- xr_origin_delta_y = _dequantize_signed(dy_q, LOCO_POS_SCALE)
892
- xr_origin_delta_z = _dequantize_signed(dz_q, LOCO_POS_SCALE)
893
- xr_origin_delta_yaw = _dequantize_signed(dyaw_q, PHYSICAL_YAW_SCALE)
942
+ if not moving_floor_local:
943
+ xr_origin_delta_x = _dequantize_signed(dx_q, LOCO_POS_SCALE)
944
+ xr_origin_delta_y = _dequantize_signed(dy_q, LOCO_POS_SCALE)
945
+ xr_origin_delta_z = _dequantize_signed(dz_q, LOCO_POS_SCALE)
946
+ xr_origin_delta_yaw = _dequantize_signed(dyaw_q, PHYSICAL_YAW_SCALE)
894
947
  offset += 8
895
948
 
896
949
  if head_valid:
@@ -913,10 +966,29 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
913
966
  head_rot[1],
914
967
  head_rot[2],
915
968
  head_rot[3],
916
- False,
969
+ moving_floor_local,
917
970
  )
918
971
 
919
- if physical_valid and head_valid:
972
+ if physical_valid and moving_floor_local:
973
+ physical_pos = (
974
+ _dequantize_signed(dx_q, LOCO_POS_SCALE),
975
+ _dequantize_signed(dy_q, LOCO_POS_SCALE),
976
+ _dequantize_signed(dz_q, LOCO_POS_SCALE),
977
+ )
978
+ physical_rot = _yaw_degrees_to_quaternion(
979
+ _dequantize_signed(dyaw_q, PHYSICAL_YAW_SCALE)
980
+ )
981
+ physical = _create_transform_dict(
982
+ physical_pos[0],
983
+ physical_pos[1],
984
+ physical_pos[2],
985
+ physical_rot[0],
986
+ physical_rot[1],
987
+ physical_rot[2],
988
+ physical_rot[3],
989
+ True,
990
+ )
991
+ elif physical_valid and head_valid:
920
992
  translated_x = head_pos[0] - xr_origin_delta_x
921
993
  translated_y = head_pos[1] - xr_origin_delta_y
922
994
  translated_z = head_pos[2] - xr_origin_delta_z
@@ -970,7 +1042,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
970
1042
  abs_rot[1],
971
1043
  abs_rot[2],
972
1044
  abs_rot[3],
973
- False,
1045
+ moving_floor_local,
974
1046
  )
975
1047
 
976
1048
  if left_valid:
@@ -999,7 +1071,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
999
1071
  abs_rot[1],
1000
1072
  abs_rot[2],
1001
1073
  abs_rot[3],
1002
- False,
1074
+ moving_floor_local,
1003
1075
  )
1004
1076
 
1005
1077
  virtual_count = data[offset]
@@ -1041,7 +1113,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
1041
1113
  abs_rot[1],
1042
1114
  abs_rot[2],
1043
1115
  abs_rot[3],
1044
- False,
1116
+ moving_floor_local,
1045
1117
  )
1046
1118
  )
1047
1119
 
@@ -1058,7 +1130,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
1058
1130
 
1059
1131
 
1060
1132
  def _deserialize_client_transform(data: bytes, offset: int) -> dict[str, Any]:
1061
- """Deserialize client pose (v4) from binary data."""
1133
+ """Deserialize client pose (v5) from binary data."""
1062
1134
  result: dict[str, Any] = {}
1063
1135
 
1064
1136
  protocol_version = data[offset]
@@ -1096,7 +1168,7 @@ def _deserialize_rpc_message(data: bytes, offset: int) -> dict[str, Any]:
1096
1168
 
1097
1169
 
1098
1170
  def _deserialize_room_transform(data: bytes, offset: int) -> dict[str, Any]:
1099
- """Deserialize room pose (v4) with client numbers only."""
1171
+ """Deserialize room pose (v5) with client numbers only."""
1100
1172
  result: dict[str, Any] = {}
1101
1173
 
1102
1174
  protocol_version = data[offset]
@@ -1229,6 +1301,20 @@ def _deserialize_client_var_set(data: bytes, offset: int) -> dict[str, Any]:
1229
1301
  return result
1230
1302
 
1231
1303
 
1304
+ def _deserialize_client_var_clear(data: bytes, offset: int) -> dict[str, Any]:
1305
+ """Deserialize client variable clear message."""
1306
+ result: dict[str, Any] = {}
1307
+
1308
+ # Sender client number (2 bytes)
1309
+ result["senderClientNo"] = struct.unpack("<H", data[offset : offset + 2])[0]
1310
+ offset += 2
1311
+
1312
+ # Timestamp (8 bytes double)
1313
+ result["timestamp"] = struct.unpack("<d", data[offset : offset + 8])[0]
1314
+
1315
+ return result
1316
+
1317
+
1232
1318
  def _deserialize_client_var_sync(data: bytes, offset: int) -> dict[str, Any]:
1233
1319
  """Deserialize client variable sync message"""
1234
1320
  result: dict[str, Any] = {"clientVariables": {}}
@@ -1027,7 +1027,11 @@ class net_sync_manager:
1027
1027
  logger.error(f"Error processing global var sync: {e}")
1028
1028
 
1029
1029
  def _process_client_var_sync(self, msg_data: dict[str, Any]) -> None:
1030
- """Process client variable sync."""
1030
+ """Process client variable sync.
1031
+
1032
+ Each included client number is a full authoritative snapshot. Missing
1033
+ keys are removed from the local cache for that client.
1034
+ """
1031
1035
  try:
1032
1036
  client_variables = msg_data.get("clientVariables", {})
1033
1037
 
@@ -1037,34 +1041,50 @@ class net_sync_manager:
1037
1041
  except ValueError:
1038
1042
  continue
1039
1043
 
1040
- if client_no not in self._client_variables:
1041
- self._client_variables[client_no] = {}
1044
+ old_vars = self._client_variables.get(client_no, {})
1045
+ new_vars: dict[str, str] = {}
1042
1046
 
1043
1047
  for var in variables:
1044
1048
  name = var.get("name", "")
1045
1049
  value = var.get("value", "")
1046
- old_value = self._client_variables[client_no].get(name)
1050
+ if name:
1051
+ new_vars[name] = value
1047
1052
 
1048
- self._client_variables[client_no][name] = value
1053
+ changed_events: list[tuple[int, str, str | None, str | None]] = []
1054
+ for name in set(old_vars) - set(new_vars):
1055
+ changed_events.append((client_no, name, old_vars.get(name), None))
1049
1056
 
1057
+ for name, value in new_vars.items():
1058
+ old_value = old_vars.get(name)
1050
1059
  if old_value != value:
1051
- with self._lock:
1052
- self._stats["nv_updates"] += 1
1060
+ changed_events.append((client_no, name, old_value, value))
1053
1061
 
1054
- event = ("client", client_no, name, old_value, value)
1055
- if self._auto_dispatch:
1056
- self.on_client_variable_changed.invoke(
1057
- client_no, name, old_value, value
1058
- )
1059
- else:
1062
+ self._client_variables[client_no] = new_vars
1063
+
1064
+ for event_client_no, name, old_value, new_value in changed_events:
1065
+ with self._lock:
1066
+ self._stats["nv_updates"] += 1
1067
+
1068
+ event: tuple[str, int, str, str | None, str | None] = (
1069
+ "client",
1070
+ event_client_no,
1071
+ name,
1072
+ old_value,
1073
+ new_value,
1074
+ )
1075
+ if self._auto_dispatch:
1076
+ self.on_client_variable_changed.invoke(
1077
+ event_client_no, name, old_value, new_value
1078
+ )
1079
+ else:
1080
+ try:
1081
+ self._nv_queue.put_nowait(event)
1082
+ except Full:
1060
1083
  try:
1084
+ self._nv_queue.get_nowait()
1061
1085
  self._nv_queue.put_nowait(event)
1062
- except Full:
1063
- try:
1064
- self._nv_queue.get_nowait()
1065
- self._nv_queue.put_nowait(event)
1066
- except Empty:
1067
- pass
1086
+ except Empty:
1087
+ pass
1068
1088
 
1069
1089
  except Exception as e:
1070
1090
  logger.error(f"Error processing client var sync: {e}")
@@ -1235,6 +1255,28 @@ class net_sync_manager:
1235
1255
  logger.error(f"Error queueing client variable: {e}")
1236
1256
  return False
1237
1257
 
1258
+ def clear_my_client_variables(self) -> bool:
1259
+ """Queue a request to clear this client's variables on the server."""
1260
+ if not self._running or not self._dealer_socket or self._client_no is None:
1261
+ return False
1262
+
1263
+ try:
1264
+ clear_data = {
1265
+ "senderClientNo": self._client_no,
1266
+ "timestamp": time.time(),
1267
+ }
1268
+ message = binary_serializer.serialize_client_var_clear(clear_data)
1269
+ sent = self._enqueue_control(
1270
+ self._room, message, msg_type="client_variable_clear"
1271
+ )
1272
+ if sent:
1273
+ self._clear_local_client_variables(self._client_no)
1274
+ return sent
1275
+
1276
+ except Exception as e:
1277
+ logger.error(f"Error queueing client variable clear: {e}")
1278
+ return False
1279
+
1238
1280
  def _enqueue_control(
1239
1281
  self, room_id: str, payload: bytes, msg_type: str = "control"
1240
1282
  ) -> bool:
@@ -1294,6 +1336,34 @@ class net_sync_manager:
1294
1336
  """Return a copy of all variables for the given client."""
1295
1337
  return self._client_variables.get(client_no, {}).copy()
1296
1338
 
1339
+ def _clear_local_client_variables(self, client_no: int) -> None:
1340
+ """Clear local client-variable cache for a client and emit events."""
1341
+ old_vars = self._client_variables.get(client_no, {}).copy()
1342
+ self._client_variables[client_no] = {}
1343
+
1344
+ for name, old_value in old_vars.items():
1345
+ with self._lock:
1346
+ self._stats["nv_updates"] += 1
1347
+
1348
+ event: tuple[str, int, str, str | None, str | None] = (
1349
+ "client",
1350
+ client_no,
1351
+ name,
1352
+ old_value,
1353
+ None,
1354
+ )
1355
+ if self._auto_dispatch:
1356
+ self.on_client_variable_changed.invoke(client_no, name, old_value, None)
1357
+ else:
1358
+ try:
1359
+ self._nv_queue.put_nowait(event)
1360
+ except Full:
1361
+ try:
1362
+ self._nv_queue.get_nowait()
1363
+ self._nv_queue.put_nowait(event)
1364
+ except Empty:
1365
+ pass
1366
+
1297
1367
  def is_client_stealth_mode(self, client_no: int) -> bool:
1298
1368
  """Check if the client is in stealth mode."""
1299
1369
  return self._client_stealth_flags.get(client_no, False)
@@ -46,6 +46,7 @@ import zmq
46
46
  # Import public APIs from styly_netsync module
47
47
  from styly_netsync.binary_serializer import (
48
48
  MSG_CLIENT_POSE,
49
+ MSG_CLIENT_VAR_CLEAR,
49
50
  MSG_CLIENT_VAR_SET,
50
51
  MSG_CLIENT_VAR_SYNC,
51
52
  MSG_DEVICE_ID_MAPPING,
@@ -95,6 +96,7 @@ MESSAGE_TYPE_NAMES: dict[int, str] = {
95
96
  MSG_GLOBAL_VAR_SYNC: "GLOBAL_VAR_SYNC",
96
97
  MSG_CLIENT_VAR_SET: "CLIENT_VAR_SET",
97
98
  MSG_CLIENT_VAR_SYNC: "CLIENT_VAR_SYNC",
99
+ MSG_CLIENT_VAR_CLEAR: "CLIENT_VAR_CLEAR",
98
100
  MSG_OBJECT_POSE: "OBJECT_POSE",
99
101
  MSG_ROOM_OBJECTS: "ROOM_OBJECTS",
100
102
  MSG_OBJECT_OWNERSHIP_REQUEST: "OBJECT_OWNERSHIP_REQUEST",