styly-netsync-server 0.11.0__tar.gz → 0.12.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 (42) hide show
  1. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/PKG-INFO +9 -7
  2. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/README.md +8 -6
  3. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/pyproject.toml +1 -1
  4. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/binary_serializer.py +84 -28
  5. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/server.py +2 -2
  6. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync_server.egg-info/PKG-INFO +9 -7
  7. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_binary_serializer.py +182 -12
  8. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/LICENSE +0 -0
  9. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/setup.cfg +0 -0
  10. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/__init__.py +0 -0
  11. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/__main__.py +0 -0
  12. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/adapters.py +0 -0
  13. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/cli.py +0 -0
  14. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/client.py +0 -0
  15. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/client_simulator.py +0 -0
  16. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/config.py +0 -0
  17. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/default.toml +0 -0
  18. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/events.py +0 -0
  19. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/logging_utils.py +0 -0
  20. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/network_utils.py +0 -0
  21. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/nv_sync.py +0 -0
  22. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/rest_bridge.py +0 -0
  23. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync/types.py +0 -0
  24. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync_server.egg-info/SOURCES.txt +0 -0
  25. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  26. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  27. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  28. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  29. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_all_run_methods.py +0 -0
  30. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_config.py +0 -0
  31. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_discovery_probe.py +0 -0
  32. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_logging_cli.py +0 -0
  33. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_multi_nic.py +0 -0
  34. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_nv_protocol.py +0 -0
  35. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_object_sync.py +0 -0
  36. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_port_error_message.py +0 -0
  37. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_python_client.py +0 -0
  38. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_reconnect_identity.py +0 -0
  39. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_rest_bridge.py +0 -0
  40. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_room_expiry.py +0 -0
  41. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.0}/tests/test_stealth_heartbeat.py +0 -0
  42. {styly_netsync_server-0.11.0 → styly_netsync_server-0.12.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.12.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.12.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
@@ -32,7 +32,7 @@ MSG_OBJECT_OWNERSHIP_REJECTED = 17 # Server → Client (ROUTER): request reject
32
32
  _max_virtual_transforms = 50
33
33
  MAX_VIRTUAL_TRANSFORMS = _max_virtual_transforms # Legacy alias for backward compat
34
34
 
35
- # Protocol v4 transform encoding constants
35
+ # Protocol v5 transform encoding constants
36
36
  ABS_POS_SCALE = 0.01
37
37
  LOCO_POS_SCALE = 0.01
38
38
  REL_POS_SCALE = 0.005
@@ -69,6 +69,15 @@ POSE_FLAG_HEAD_VALID = 1 << 2
69
69
  POSE_FLAG_RIGHT_VALID = 1 << 3
70
70
  POSE_FLAG_LEFT_VALID = 1 << 4
71
71
  POSE_FLAG_VIRTUALS_VALID = 1 << 5
72
+ POSE_FLAG_MOVING_FLOOR_LOCAL = 1 << 6
73
+
74
+
75
+ def _compute_encoding_flags(flags: int) -> int:
76
+ """Return pose encoding flags for the sanitized pose flags."""
77
+ encoding_flags = ENCODING_FLAGS_DEFAULT
78
+ if flags & POSE_FLAG_MOVING_FLOOR_LOCAL:
79
+ encoding_flags &= ~ENCODING_PHYSICAL_IS_XRORIGIN_DELTA
80
+ return encoding_flags & 0xFF
72
81
 
73
82
 
74
83
  def get_max_virtual_transforms() -> int:
@@ -372,7 +381,7 @@ def _create_transform_dict(
372
381
 
373
382
 
374
383
  def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
375
- """Serialize a client body in protocol v4 compact format."""
384
+ """Serialize a client body in protocol v5 compact format."""
376
385
  pose_seq = int(client.get("poseSeq", 0)) & 0xFFFF
377
386
  head = client.get("head", {}) or {}
378
387
  right = client.get("rightHand", {}) or {}
@@ -411,34 +420,51 @@ def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
411
420
  POSE_FLAG_RIGHT_VALID | POSE_FLAG_LEFT_VALID | POSE_FLAG_VIRTUALS_VALID
412
421
  )
413
422
 
423
+ encoding_flags = _compute_encoding_flags(flags)
414
424
  buffer.extend(struct.pack("<H", pose_seq))
415
425
  buffer.append(flags)
416
- buffer.append(ENCODING_FLAGS_DEFAULT)
426
+ buffer.append(encoding_flags)
417
427
 
418
428
  physical_valid = bool(flags & POSE_FLAG_PHYSICAL_VALID)
419
429
  head_valid = bool(flags & POSE_FLAG_HEAD_VALID)
420
430
  right_valid = head_valid and bool(flags & POSE_FLAG_RIGHT_VALID)
421
431
  left_valid = head_valid and bool(flags & POSE_FLAG_LEFT_VALID)
422
432
  virtual_valid = head_valid and bool(flags & POSE_FLAG_VIRTUALS_VALID)
433
+ moving_floor_local = bool(flags & POSE_FLAG_MOVING_FLOOR_LOCAL)
423
434
 
424
435
  xr_origin_delta_x = float(client.get("xrOriginDeltaX", 0.0))
425
436
  xr_origin_delta_y = float(client.get("xrOriginDeltaY", 0.0))
426
437
  xr_origin_delta_z = float(client.get("xrOriginDeltaZ", 0.0))
427
438
  xr_origin_delta_yaw = float(client.get("xrOriginDeltaYaw", 0.0))
439
+ physical = client.get("physical", {}) or {}
428
440
  head_pos = _transform_get_position(head)
429
441
  head_rot = _transform_get_quaternion(head)
430
442
  head_rot_n = _normalize_quaternion(*head_rot)
431
443
 
432
444
  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),
445
+ if moving_floor_local:
446
+ physical_pos = _transform_get_position(physical)
447
+ physical_rot = _transform_get_quaternion(physical)
448
+ physical_yaw = _quaternion_to_yaw_degrees(*physical_rot)
449
+ buffer.extend(
450
+ struct.pack(
451
+ "<hhhh",
452
+ _quantize_signed(physical_pos[0], LOCO_POS_SCALE),
453
+ _quantize_signed(physical_pos[1], LOCO_POS_SCALE),
454
+ _quantize_signed(physical_pos[2], LOCO_POS_SCALE),
455
+ _quantize_signed(physical_yaw, PHYSICAL_YAW_SCALE),
456
+ )
457
+ )
458
+ else:
459
+ buffer.extend(
460
+ struct.pack(
461
+ "<hhhh",
462
+ _quantize_signed(xr_origin_delta_x, LOCO_POS_SCALE),
463
+ _quantize_signed(xr_origin_delta_y, LOCO_POS_SCALE),
464
+ _quantize_signed(xr_origin_delta_z, LOCO_POS_SCALE),
465
+ _quantize_signed(xr_origin_delta_yaw, PHYSICAL_YAW_SCALE),
466
+ )
440
467
  )
441
- )
442
468
 
443
469
  if head_valid:
444
470
  _pack_int24_le(buffer, _quantize_signed_int24(head_pos[0], ABS_POS_SCALE))
@@ -852,7 +878,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
852
878
 
853
879
 
854
880
  def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any], int]:
855
- """Deserialize protocol v4 compact pose body."""
881
+ """Deserialize protocol v5 compact pose body."""
856
882
  result: dict[str, Any] = {}
857
883
  result["poseSeq"] = struct.unpack("<H", data[offset : offset + 2])[0]
858
884
  offset += 2
@@ -868,11 +894,18 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
868
894
  right_valid = head_valid and bool(flags & POSE_FLAG_RIGHT_VALID)
869
895
  left_valid = head_valid and bool(flags & POSE_FLAG_LEFT_VALID)
870
896
  virtual_valid = head_valid and bool(flags & POSE_FLAG_VIRTUALS_VALID)
897
+ moving_floor_local = bool(flags & POSE_FLAG_MOVING_FLOOR_LOCAL)
871
898
 
872
899
  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)
900
+ head = _create_transform_dict(
901
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
902
+ )
903
+ right = _create_transform_dict(
904
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
905
+ )
906
+ left = _create_transform_dict(
907
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, moving_floor_local
908
+ )
876
909
 
877
910
  head_pos = (0.0, 0.0, 0.0)
878
911
  head_rot = (0.0, 0.0, 0.0, 1.0)
@@ -882,15 +915,19 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
882
915
  xr_origin_delta_yaw = 0.0
883
916
 
884
917
  if physical_valid:
885
- if (encoding_flags & ENCODING_PHYSICAL_IS_XRORIGIN_DELTA) == 0:
918
+ if (
919
+ not moving_floor_local
920
+ and (encoding_flags & ENCODING_PHYSICAL_IS_XRORIGIN_DELTA) == 0
921
+ ):
886
922
  raise ValueError(
887
923
  "PhysicalValid set but XROrigin delta encoding flag is missing"
888
924
  )
889
925
  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)
926
+ if not moving_floor_local:
927
+ xr_origin_delta_x = _dequantize_signed(dx_q, LOCO_POS_SCALE)
928
+ xr_origin_delta_y = _dequantize_signed(dy_q, LOCO_POS_SCALE)
929
+ xr_origin_delta_z = _dequantize_signed(dz_q, LOCO_POS_SCALE)
930
+ xr_origin_delta_yaw = _dequantize_signed(dyaw_q, PHYSICAL_YAW_SCALE)
894
931
  offset += 8
895
932
 
896
933
  if head_valid:
@@ -913,10 +950,29 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
913
950
  head_rot[1],
914
951
  head_rot[2],
915
952
  head_rot[3],
916
- False,
953
+ moving_floor_local,
917
954
  )
918
955
 
919
- if physical_valid and head_valid:
956
+ if physical_valid and moving_floor_local:
957
+ physical_pos = (
958
+ _dequantize_signed(dx_q, LOCO_POS_SCALE),
959
+ _dequantize_signed(dy_q, LOCO_POS_SCALE),
960
+ _dequantize_signed(dz_q, LOCO_POS_SCALE),
961
+ )
962
+ physical_rot = _yaw_degrees_to_quaternion(
963
+ _dequantize_signed(dyaw_q, PHYSICAL_YAW_SCALE)
964
+ )
965
+ physical = _create_transform_dict(
966
+ physical_pos[0],
967
+ physical_pos[1],
968
+ physical_pos[2],
969
+ physical_rot[0],
970
+ physical_rot[1],
971
+ physical_rot[2],
972
+ physical_rot[3],
973
+ True,
974
+ )
975
+ elif physical_valid and head_valid:
920
976
  translated_x = head_pos[0] - xr_origin_delta_x
921
977
  translated_y = head_pos[1] - xr_origin_delta_y
922
978
  translated_z = head_pos[2] - xr_origin_delta_z
@@ -970,7 +1026,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
970
1026
  abs_rot[1],
971
1027
  abs_rot[2],
972
1028
  abs_rot[3],
973
- False,
1029
+ moving_floor_local,
974
1030
  )
975
1031
 
976
1032
  if left_valid:
@@ -999,7 +1055,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
999
1055
  abs_rot[1],
1000
1056
  abs_rot[2],
1001
1057
  abs_rot[3],
1002
- False,
1058
+ moving_floor_local,
1003
1059
  )
1004
1060
 
1005
1061
  virtual_count = data[offset]
@@ -1041,7 +1097,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
1041
1097
  abs_rot[1],
1042
1098
  abs_rot[2],
1043
1099
  abs_rot[3],
1044
- False,
1100
+ moving_floor_local,
1045
1101
  )
1046
1102
  )
1047
1103
 
@@ -1058,7 +1114,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
1058
1114
 
1059
1115
 
1060
1116
  def _deserialize_client_transform(data: bytes, offset: int) -> dict[str, Any]:
1061
- """Deserialize client pose (v4) from binary data."""
1117
+ """Deserialize client pose (v5) from binary data."""
1062
1118
  result: dict[str, Any] = {}
1063
1119
 
1064
1120
  protocol_version = data[offset]
@@ -1096,7 +1152,7 @@ def _deserialize_rpc_message(data: bytes, offset: int) -> dict[str, Any]:
1096
1152
 
1097
1153
 
1098
1154
  def _deserialize_room_transform(data: bytes, offset: int) -> dict[str, Any]:
1099
- """Deserialize room pose (v4) with client numbers only."""
1155
+ """Deserialize room pose (v5) with client numbers only."""
1100
1156
  result: dict[str, Any] = {}
1101
1157
 
1102
1158
  protocol_version = data[offset]
@@ -1056,7 +1056,7 @@ class NetSyncServer:
1056
1056
  except UnicodeDecodeError as e:
1057
1057
  logger.error(f"Failed to decode room ID: {e}")
1058
1058
  continue
1059
- # Protocol v4 binary-only handling (no JSON fallback)
1059
+ # Protocol v5 binary-only handling (no JSON fallback)
1060
1060
  try:
1061
1061
  msg_type, data, raw_payload = binary_serializer.deserialize(
1062
1062
  message_bytes
@@ -1127,7 +1127,7 @@ class NetSyncServer:
1127
1127
  logger.warning(f"Unknown binary msg_type: {msg_type}")
1128
1128
  except Exception as e:
1129
1129
  logger.warning(
1130
- "Failed to decode protocol v4 message from room %s: %s",
1130
+ "Failed to decode protocol v5 message from room %s: %s",
1131
1131
  room_id,
1132
1132
  e,
1133
1133
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: styly-netsync-server
3
- Version: 0.11.0
3
+ Version: 0.12.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
 
@@ -288,12 +288,12 @@ def _reconstruct_physical_from_head_and_delta(
288
288
  return (px, ty, pz), physical_rot
289
289
 
290
290
 
291
- class TestTransformSerializationV4:
292
- """Tests for protocol v4 transform compact serialization."""
291
+ class TestTransformSerializationV5:
292
+ """Tests for protocol v5 transform compact serialization."""
293
293
 
294
- def test_protocol_version_is_v4(self) -> None:
295
- """Protocol version constant should be at v4."""
296
- assert binary_serializer.PROTOCOL_VERSION == 4
294
+ def test_protocol_version_is_v5(self) -> None:
295
+ """Protocol version constant should be at v5."""
296
+ assert binary_serializer.PROTOCOL_VERSION == 5
297
297
 
298
298
  def test_client_roundtrip_without_flags_infers_valid_bits(self) -> None:
299
299
  """Serializer should infer valid bits when flags are omitted."""
@@ -418,7 +418,7 @@ class TestTransformSerializationV4:
418
418
 
419
419
  assert msg_type == binary_serializer.MSG_CLIENT_POSE
420
420
  assert decoded is not None
421
- assert decoded["protocolVersion"] == 4
421
+ assert decoded["protocolVersion"] == 5
422
422
  assert len(raw) > 0
423
423
 
424
424
  o_head = original["head"]
@@ -615,7 +615,7 @@ class TestTransformSerializationV4:
615
615
  assert abs(decoded["xrOriginDeltaYaw"] - 179.9) <= 0.1
616
616
 
617
617
  def test_client_body_size_with_full_pose_no_virtuals(self) -> None:
618
- """Full pose body (no virtuals) should match current protocol v4 byte size."""
618
+ """Full pose body (no virtuals) should match current protocol byte size."""
619
619
  payload = {
620
620
  "deviceId": "size-check",
621
621
  "poseSeq": 1,
@@ -638,11 +638,142 @@ class TestTransformSerializationV4:
638
638
  _, _, raw = binary_serializer.deserialize(
639
639
  binary_serializer.serialize_client_transform(payload)
640
640
  )
641
- # v4 added a Y component to xrOriginDelta (+2 bytes vs. v3's 44).
642
641
  assert len(raw) == 46
643
642
 
643
+ def test_moving_floor_local_body_size_matches_unbound(self) -> None:
644
+ """Moving-floor-local pose should not add bytes to the pose body."""
645
+ rng = random.Random(4242)
646
+ unbound = _build_random_client_pose(rng, virtual_count=0)
647
+ unbound["flags"] = (
648
+ binary_serializer.POSE_FLAG_PHYSICAL_VALID
649
+ | binary_serializer.POSE_FLAG_HEAD_VALID
650
+ | binary_serializer.POSE_FLAG_RIGHT_VALID
651
+ | binary_serializer.POSE_FLAG_LEFT_VALID
652
+ )
653
+ bound = dict(unbound)
654
+ bound["flags"] = (
655
+ unbound["flags"] | binary_serializer.POSE_FLAG_MOVING_FLOOR_LOCAL
656
+ )
657
+ bound["physical"] = _build_transform(
658
+ (0.15, 1.62, -0.08),
659
+ (
660
+ 0.0,
661
+ math.sin(math.radians(35.0) * 0.5),
662
+ 0.0,
663
+ math.cos(math.radians(35.0) * 0.5),
664
+ ),
665
+ )
666
+
667
+ _, _, unbound_raw = binary_serializer.deserialize(
668
+ binary_serializer.serialize_client_transform(unbound)
669
+ )
670
+ _, _, bound_raw = binary_serializer.deserialize(
671
+ binary_serializer.serialize_client_transform(bound)
672
+ )
673
+
674
+ assert len(unbound_raw) == 46
675
+ assert len(bound_raw) == len(unbound_raw)
676
+
677
+ def test_moving_floor_local_roundtrip_uses_direct_physical(self) -> None:
678
+ """Bound poses use direct physical pose and keep head-relative channels intact."""
679
+ physical_yaw = 42.0
680
+ physical_rot = (
681
+ 0.0,
682
+ math.sin(math.radians(physical_yaw) * 0.5),
683
+ 0.0,
684
+ math.cos(math.radians(physical_yaw) * 0.5),
685
+ )
686
+ payload = {
687
+ "deviceId": "floor-local",
688
+ "poseSeq": 313,
689
+ "flags": (
690
+ binary_serializer.POSE_FLAG_PHYSICAL_VALID
691
+ | binary_serializer.POSE_FLAG_HEAD_VALID
692
+ | binary_serializer.POSE_FLAG_RIGHT_VALID
693
+ | binary_serializer.POSE_FLAG_LEFT_VALID
694
+ | binary_serializer.POSE_FLAG_VIRTUALS_VALID
695
+ | binary_serializer.POSE_FLAG_MOVING_FLOOR_LOCAL
696
+ ),
697
+ "xrOriginDeltaX": 99.0,
698
+ "xrOriginDeltaY": 99.0,
699
+ "xrOriginDeltaZ": 99.0,
700
+ "xrOriginDeltaYaw": 99.0,
701
+ "physical": _build_transform((0.25, 1.7, -0.1), physical_rot),
702
+ "head": _build_transform(
703
+ (2.0, 3.0, 4.0), _random_unit_quaternion(random.Random(501))
704
+ ),
705
+ "rightHand": _build_transform(
706
+ (2.35, 2.8, 4.1), _random_unit_quaternion(random.Random(502))
707
+ ),
708
+ "leftHand": _build_transform(
709
+ (1.65, 2.8, 3.9), _random_unit_quaternion(random.Random(503))
710
+ ),
711
+ "virtuals": [
712
+ _build_transform(
713
+ (2.0, 3.3, 4.7), _random_unit_quaternion(random.Random(504))
714
+ )
715
+ ],
716
+ }
717
+
718
+ _, decoded, _ = binary_serializer.deserialize(
719
+ binary_serializer.serialize_client_transform(payload)
720
+ )
721
+ assert decoded is not None
722
+ assert decoded["flags"] & binary_serializer.POSE_FLAG_MOVING_FLOOR_LOCAL
723
+ assert (
724
+ decoded["encodingFlags"]
725
+ & binary_serializer.ENCODING_PHYSICAL_IS_XRORIGIN_DELTA
726
+ ) == 0
727
+ assert decoded["xrOriginDeltaX"] == 0.0
728
+ assert decoded["xrOriginDeltaY"] == 0.0
729
+ assert decoded["xrOriginDeltaZ"] == 0.0
730
+ assert decoded["xrOriginDeltaYaw"] == 0.0
731
+
732
+ assert abs(decoded["physical"]["posX"] - 0.25) <= 0.01
733
+ assert abs(decoded["physical"]["posY"] - 1.7) <= 0.01
734
+ assert abs(decoded["physical"]["posZ"] + 0.1) <= 0.01
735
+ decoded_physical_yaw = _yaw_deg_from_quaternion(
736
+ (
737
+ decoded["physical"]["rotX"],
738
+ decoded["physical"]["rotY"],
739
+ decoded["physical"]["rotZ"],
740
+ decoded["physical"]["rotW"],
741
+ )
742
+ )
743
+ assert abs(decoded_physical_yaw - physical_yaw) <= 0.1
744
+
745
+ src_head = payload["head"]
746
+ dst_head = decoded["head"]
747
+ assert abs(dst_head["posX"] - src_head["posX"]) <= 0.01
748
+ assert abs(dst_head["posY"] - src_head["posY"]) <= 0.01
749
+ assert abs(dst_head["posZ"] - src_head["posZ"]) <= 0.01
750
+
751
+ src_right = payload["rightHand"]
752
+ dst_right = decoded["rightHand"]
753
+ assert (
754
+ abs(
755
+ (dst_right["posX"] - dst_head["posX"])
756
+ - (src_right["posX"] - src_head["posX"])
757
+ )
758
+ <= 0.005
759
+ )
760
+ assert (
761
+ abs(
762
+ (dst_right["posY"] - dst_head["posY"])
763
+ - (src_right["posY"] - src_head["posY"])
764
+ )
765
+ <= 0.005
766
+ )
767
+ assert (
768
+ abs(
769
+ (dst_right["posZ"] - dst_head["posZ"])
770
+ - (src_right["posZ"] - src_head["posZ"])
771
+ )
772
+ <= 0.005
773
+ )
774
+
644
775
  def test_physical_requires_delta_encoding_flag(self) -> None:
645
- """PhysicalValid frames must carry the XROrigin-delta encoding bit."""
776
+ """Unbound PhysicalValid frames must carry the XROrigin-delta encoding bit."""
646
777
  payload = {
647
778
  "deviceId": "missing-delta-flag",
648
779
  "poseSeq": 7,
@@ -682,7 +813,7 @@ class TestTransformSerializationV4:
682
813
  c2["poseTime"] = 223.456
683
814
 
684
815
  room_payload = {
685
- "roomId": "room-v4",
816
+ "roomId": "room-v5",
686
817
  "broadcastTime": 999.123,
687
818
  "clients": [c1, c2],
688
819
  }
@@ -691,8 +822,8 @@ class TestTransformSerializationV4:
691
822
 
692
823
  assert msg_type == binary_serializer.MSG_ROOM_POSE
693
824
  assert decoded is not None
694
- assert decoded["protocolVersion"] == 4
695
- assert decoded["roomId"] == "room-v4"
825
+ assert decoded["protocolVersion"] == 5
826
+ assert decoded["roomId"] == "room-v5"
696
827
  assert len(decoded["clients"]) == 2
697
828
 
698
829
  for src, dst in zip(room_payload["clients"], decoded["clients"], strict=True):
@@ -705,6 +836,45 @@ class TestTransformSerializationV4:
705
836
  assert abs(src_head["posY"] - dst_head["posY"]) <= 0.01
706
837
  assert abs(src_head["posZ"] - dst_head["posZ"]) <= 0.01
707
838
 
839
+ def test_room_relay_preserves_moving_floor_local_flag(self) -> None:
840
+ """Room serialization should preserve moving-floor-local pose bodies."""
841
+ rng = random.Random(2027)
842
+ bound = _build_random_client_pose(rng, virtual_count=1)
843
+ bound["clientNo"] = 303
844
+ bound["poseTime"] = 333.456
845
+ bound["flags"] = (
846
+ int(bound["flags"]) | binary_serializer.POSE_FLAG_MOVING_FLOOR_LOCAL
847
+ )
848
+ bound["physical"] = _build_transform(
849
+ (0.2, 1.65, 0.05),
850
+ (
851
+ 0.0,
852
+ math.sin(math.radians(-15.0) * 0.5),
853
+ 0.0,
854
+ math.cos(math.radians(-15.0) * 0.5),
855
+ ),
856
+ )
857
+
858
+ encoded = binary_serializer.serialize_room_transform(
859
+ {
860
+ "roomId": "room-floor-local",
861
+ "broadcastTime": 1000.0,
862
+ "clients": [bound],
863
+ }
864
+ )
865
+ msg_type, decoded, _ = binary_serializer.deserialize(encoded)
866
+
867
+ assert msg_type == binary_serializer.MSG_ROOM_POSE
868
+ assert decoded is not None
869
+ assert len(decoded["clients"]) == 1
870
+ client = decoded["clients"][0]
871
+ assert client["flags"] & binary_serializer.POSE_FLAG_MOVING_FLOOR_LOCAL
872
+ assert (
873
+ client["encodingFlags"]
874
+ & binary_serializer.ENCODING_PHYSICAL_IS_XRORIGIN_DELTA
875
+ ) == 0
876
+ assert abs(client["physical"]["posY"] - 1.65) <= 0.01
877
+
708
878
  def test_foot_invariant_head_minus_physical_equals_delta_y(self) -> None:
709
879
  """head.y - reconstructed physical.y must equal xrOriginDeltaY.
710
880