styly-netsync-server 0.10.4__tar.gz → 0.11.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.10.4 → styly_netsync_server-0.11.0}/PKG-INFO +69 -17
  2. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/README.md +68 -16
  3. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/pyproject.toml +1 -1
  4. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/binary_serializer.py +16 -10
  5. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/rest_bridge.py +67 -9
  6. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/server.py +2 -2
  7. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync_server.egg-info/PKG-INFO +69 -17
  8. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_binary_serializer.py +54 -13
  9. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_rest_bridge.py +153 -7
  10. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/LICENSE +0 -0
  11. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/setup.cfg +0 -0
  12. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/__init__.py +0 -0
  13. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/__main__.py +0 -0
  14. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/adapters.py +0 -0
  15. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/cli.py +0 -0
  16. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/client.py +0 -0
  17. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/client_simulator.py +0 -0
  18. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/config.py +0 -0
  19. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/default.toml +0 -0
  20. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/events.py +0 -0
  21. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/logging_utils.py +0 -0
  22. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/network_utils.py +0 -0
  23. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/nv_sync.py +0 -0
  24. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync/types.py +0 -0
  25. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync_server.egg-info/SOURCES.txt +0 -0
  26. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync_server.egg-info/dependency_links.txt +0 -0
  27. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync_server.egg-info/entry_points.txt +0 -0
  28. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync_server.egg-info/requires.txt +0 -0
  29. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/src/styly_netsync_server.egg-info/top_level.txt +0 -0
  30. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_all_run_methods.py +0 -0
  31. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_config.py +0 -0
  32. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_discovery_probe.py +0 -0
  33. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_logging_cli.py +0 -0
  34. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_multi_nic.py +0 -0
  35. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_nv_protocol.py +0 -0
  36. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_object_sync.py +0 -0
  37. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_port_error_message.py +0 -0
  38. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_python_client.py +0 -0
  39. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_reconnect_identity.py +0 -0
  40. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_room_expiry.py +0 -0
  41. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.0}/tests/test_stealth_heartbeat.py +0 -0
  42. {styly_netsync_server-0.10.4 → styly_netsync_server-0.11.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.10.4
3
+ Version: 0.11.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,12 +81,14 @@ 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 = 3`.
85
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V3 pose body.
86
- - Legacy transform protocol v2 and JSON transform fallback are not supported.
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).
87
+ - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
87
88
  - Deploy Unity and Python updates together when changing transform protocol behavior.
88
- - Protocol v3 position quantization ranges:
89
- - Absolute (`physicalPos`, `headPosAbs`): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
89
+ - Protocol v4 position quantization ranges:
90
+ - 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.
90
92
  - Head-relative (`right/left/virtual`): signed `int16` at `0.005 m` per unit, per-axis range `[-163.84 m, 163.835 m]`.
91
93
  - 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.
92
94
 
@@ -94,18 +96,18 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
94
96
 
95
97
  The following options summarize trade-offs when expanding absolute-position range.
96
98
 
97
- Assumed baseline (`protocolVersion=3`):
98
- - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `49 bytes`
99
- - Room per-client entry (`clientNo + poseTime + clientBody`): `59 bytes`
99
+ Assumed baseline (`protocolVersion=4`):
100
+ - 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
+ - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
100
102
 
101
103
  | Option | Absolute Position Encoding | Per-axis Range | Client Body Delta | Room Per-client Delta |
102
104
  |---|---|---:|---:|---:|
103
- | A. Coarser scale (current integer width) | `int24 @ 0.02m` | `[-167,772.16m, 167,772.14m]` | `+0B` (`49 -> 49`) | `+0B` (`59 -> 59`) |
104
- | B. Cell + local | `cell(i16, 256m) + local(int24 @ 0.01m)` | `[-8,472,494.08m, 8,472,238.07m]` | `+6B` (`49 -> 55`, `+12.2%`) | `+6B` (`59 -> 65`, `+10.2%`) |
105
- | C. Cell + local (large cell) | `cell(i16, 1024m) + local(int24 @ 0.01m)` | `[-33,638,318.08m, 33,637,294.07m]` | `+6B` (`49 -> 55`, `+12.2%`) | `+6B` (`59 -> 65`, `+10.2%`) |
105
+ | A. Coarser scale (current integer width) | `int24 @ 0.02m` | `[-167,772.16m, 167,772.14m]` | `+0B` (`46 -> 46`) | `+0B` (`56 -> 56`) |
106
+ | B. Cell + local | `cell(i16, 256m) + local(int24 @ 0.01m)` | `[-8,472,494.08m, 8,472,238.07m]` | `+6B` (`46 -> 52`, `+13.0%`) | `+6B` (`56 -> 62`, `+10.7%`) |
107
+ | C. Cell + local (large cell) | `cell(i16, 1024m) + local(int24 @ 0.01m)` | `[-33,638,318.08m, 33,637,294.07m]` | `+6B` (`46 -> 52`, `+13.0%`) | `+6B` (`56 -> 62`, `+10.7%`) |
106
108
 
107
109
  Notes:
108
- - Option B/C deltas assume both absolute transforms are present (`physicalPos` and `headPosAbs`).
110
+ - Only `headPosAbs` is on the wire as an absolute field; `physicalPos` is reconstructed from `headPosAbs + xrOriginDelta`. Option B/C deltas therefore apply to `headPosAbs` only.
109
111
  - Option B/C can reduce average overhead if `cell` is transmitted only when changed, but that requires extra state and flags in the wire format.
110
112
 
111
113
  ## Configuration
@@ -160,7 +162,7 @@ The server launches an embedded FastAPI application that exposes REST endpoints
160
162
 
161
163
  ```json
162
164
  {
163
- "vars": {
165
+ "variables": {
164
166
  "name": "Jack",
165
167
  "lang": "EN"
166
168
  }
@@ -179,11 +181,35 @@ The server launches an embedded FastAPI application that exposes REST endpoints
179
181
  ```bash
180
182
  curl -sS -X POST "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables" \
181
183
  -H "Content-Type: application/json" \
182
- -d '{"vars":{"name":"Jack","lang":"EN"}}'
184
+ -d '{"variables":{"name":"Jack","lang":"EN"}}'
183
185
  ```
184
186
 
185
187
  The response includes the current mapping status (`clientNo` or `null`) and whether each key was `"applied"` or `"queued"`.
186
188
 
189
+ - Read endpoints:
190
+ - `GET /v1/rooms/{roomId}/devices/{deviceId}/client-variables` — returns all client variables for the device.
191
+
192
+ ```json
193
+ {"clientNo": 7, "variables": {"name": "Jack", "lang": "EN"}}
194
+ ```
195
+
196
+ If the device has no `clientNo` mapping yet, returns `{"clientNo": null, "variables": {}}`.
197
+
198
+ - `GET /v1/rooms/{roomId}/devices/{deviceId}/client-variables/{name}` — returns a single variable.
199
+
200
+ ```json
201
+ {"clientNo": 7, "value": "Jack"}
202
+ ```
203
+
204
+ Returns `404` if the device is unmapped or the variable is not set.
205
+
206
+ - Example:
207
+
208
+ ```bash
209
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables"
210
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables/name"
211
+ ```
212
+
187
213
  ### Global variables
188
214
 
189
215
  - Endpoint: `POST /v1/rooms/{roomId}/global-variables`
@@ -191,7 +217,7 @@ The response includes the current mapping status (`clientNo` or `null`) and whet
191
217
 
192
218
  ```json
193
219
  {
194
- "vars": {
220
+ "variables": {
195
221
  "score": "42",
196
222
  "stage": "lobby"
197
223
  }
@@ -210,7 +236,33 @@ The response includes the current mapping status (`clientNo` or `null`) and whet
210
236
  ```bash
211
237
  curl -sS -X POST "http://127.0.0.1:8800/v1/rooms/default_room/global-variables" \
212
238
  -H "Content-Type: application/json" \
213
- -d '{"vars":{"score":"42","stage":"lobby"}}'
239
+ -d '{"variables":{"score":"42","stage":"lobby"}}'
214
240
  ```
215
241
 
216
242
  The response includes the room ID and whether each key was `"applied"`, `"queued"`, or `"failed"`.
243
+
244
+ - Read endpoints:
245
+ - `GET /v1/rooms/{roomId}/global-variables` — returns all global variables for the room.
246
+
247
+ ```json
248
+ {"variables": {"score": "42", "stage": "lobby"}}
249
+ ```
250
+
251
+ - `GET /v1/rooms/{roomId}/global-variables/{name}` — returns a single variable.
252
+
253
+ ```json
254
+ {"value": "42"}
255
+ ```
256
+
257
+ Returns `404` if the variable is not set.
258
+
259
+ - Example:
260
+
261
+ ```bash
262
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/global-variables"
263
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/global-variables/score"
264
+ ```
265
+
266
+ ### Read consistency
267
+
268
+ GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by PUB-SUB broadcasts from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial broadcasts arrive — retry after a short delay if needed.
@@ -42,12 +42,14 @@ 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 = 3`.
46
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V3 pose body.
47
- - Legacy transform protocol v2 and JSON transform fallback are not supported.
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).
48
+ - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
48
49
  - Deploy Unity and Python updates together when changing transform protocol behavior.
49
- - Protocol v3 position quantization ranges:
50
- - Absolute (`physicalPos`, `headPosAbs`): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
50
+ - Protocol v4 position quantization ranges:
51
+ - 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.
51
53
  - Head-relative (`right/left/virtual`): signed `int16` at `0.005 m` per unit, per-axis range `[-163.84 m, 163.835 m]`.
52
54
  - 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.
53
55
 
@@ -55,18 +57,18 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
55
57
 
56
58
  The following options summarize trade-offs when expanding absolute-position range.
57
59
 
58
- Assumed baseline (`protocolVersion=3`):
59
- - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `49 bytes`
60
- - Room per-client entry (`clientNo + poseTime + clientBody`): `59 bytes`
60
+ Assumed baseline (`protocolVersion=4`):
61
+ - 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
+ - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
61
63
 
62
64
  | Option | Absolute Position Encoding | Per-axis Range | Client Body Delta | Room Per-client Delta |
63
65
  |---|---|---:|---:|---:|
64
- | A. Coarser scale (current integer width) | `int24 @ 0.02m` | `[-167,772.16m, 167,772.14m]` | `+0B` (`49 -> 49`) | `+0B` (`59 -> 59`) |
65
- | B. Cell + local | `cell(i16, 256m) + local(int24 @ 0.01m)` | `[-8,472,494.08m, 8,472,238.07m]` | `+6B` (`49 -> 55`, `+12.2%`) | `+6B` (`59 -> 65`, `+10.2%`) |
66
- | C. Cell + local (large cell) | `cell(i16, 1024m) + local(int24 @ 0.01m)` | `[-33,638,318.08m, 33,637,294.07m]` | `+6B` (`49 -> 55`, `+12.2%`) | `+6B` (`59 -> 65`, `+10.2%`) |
66
+ | A. Coarser scale (current integer width) | `int24 @ 0.02m` | `[-167,772.16m, 167,772.14m]` | `+0B` (`46 -> 46`) | `+0B` (`56 -> 56`) |
67
+ | B. Cell + local | `cell(i16, 256m) + local(int24 @ 0.01m)` | `[-8,472,494.08m, 8,472,238.07m]` | `+6B` (`46 -> 52`, `+13.0%`) | `+6B` (`56 -> 62`, `+10.7%`) |
68
+ | C. Cell + local (large cell) | `cell(i16, 1024m) + local(int24 @ 0.01m)` | `[-33,638,318.08m, 33,637,294.07m]` | `+6B` (`46 -> 52`, `+13.0%`) | `+6B` (`56 -> 62`, `+10.7%`) |
67
69
 
68
70
  Notes:
69
- - Option B/C deltas assume both absolute transforms are present (`physicalPos` and `headPosAbs`).
71
+ - Only `headPosAbs` is on the wire as an absolute field; `physicalPos` is reconstructed from `headPosAbs + xrOriginDelta`. Option B/C deltas therefore apply to `headPosAbs` only.
70
72
  - Option B/C can reduce average overhead if `cell` is transmitted only when changed, but that requires extra state and flags in the wire format.
71
73
 
72
74
  ## Configuration
@@ -121,7 +123,7 @@ The server launches an embedded FastAPI application that exposes REST endpoints
121
123
 
122
124
  ```json
123
125
  {
124
- "vars": {
126
+ "variables": {
125
127
  "name": "Jack",
126
128
  "lang": "EN"
127
129
  }
@@ -140,11 +142,35 @@ The server launches an embedded FastAPI application that exposes REST endpoints
140
142
  ```bash
141
143
  curl -sS -X POST "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables" \
142
144
  -H "Content-Type: application/json" \
143
- -d '{"vars":{"name":"Jack","lang":"EN"}}'
145
+ -d '{"variables":{"name":"Jack","lang":"EN"}}'
144
146
  ```
145
147
 
146
148
  The response includes the current mapping status (`clientNo` or `null`) and whether each key was `"applied"` or `"queued"`.
147
149
 
150
+ - Read endpoints:
151
+ - `GET /v1/rooms/{roomId}/devices/{deviceId}/client-variables` — returns all client variables for the device.
152
+
153
+ ```json
154
+ {"clientNo": 7, "variables": {"name": "Jack", "lang": "EN"}}
155
+ ```
156
+
157
+ If the device has no `clientNo` mapping yet, returns `{"clientNo": null, "variables": {}}`.
158
+
159
+ - `GET /v1/rooms/{roomId}/devices/{deviceId}/client-variables/{name}` — returns a single variable.
160
+
161
+ ```json
162
+ {"clientNo": 7, "value": "Jack"}
163
+ ```
164
+
165
+ Returns `404` if the device is unmapped or the variable is not set.
166
+
167
+ - Example:
168
+
169
+ ```bash
170
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables"
171
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables/name"
172
+ ```
173
+
148
174
  ### Global variables
149
175
 
150
176
  - Endpoint: `POST /v1/rooms/{roomId}/global-variables`
@@ -152,7 +178,7 @@ The response includes the current mapping status (`clientNo` or `null`) and whet
152
178
 
153
179
  ```json
154
180
  {
155
- "vars": {
181
+ "variables": {
156
182
  "score": "42",
157
183
  "stage": "lobby"
158
184
  }
@@ -171,7 +197,33 @@ The response includes the current mapping status (`clientNo` or `null`) and whet
171
197
  ```bash
172
198
  curl -sS -X POST "http://127.0.0.1:8800/v1/rooms/default_room/global-variables" \
173
199
  -H "Content-Type: application/json" \
174
- -d '{"vars":{"score":"42","stage":"lobby"}}'
200
+ -d '{"variables":{"score":"42","stage":"lobby"}}'
175
201
  ```
176
202
 
177
203
  The response includes the room ID and whether each key was `"applied"`, `"queued"`, or `"failed"`.
204
+
205
+ - Read endpoints:
206
+ - `GET /v1/rooms/{roomId}/global-variables` — returns all global variables for the room.
207
+
208
+ ```json
209
+ {"variables": {"score": "42", "stage": "lobby"}}
210
+ ```
211
+
212
+ - `GET /v1/rooms/{roomId}/global-variables/{name}` — returns a single variable.
213
+
214
+ ```json
215
+ {"value": "42"}
216
+ ```
217
+
218
+ Returns `404` if the variable is not set.
219
+
220
+ - Example:
221
+
222
+ ```bash
223
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/global-variables"
224
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/global-variables/score"
225
+ ```
226
+
227
+ ### Read consistency
228
+
229
+ GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by PUB-SUB broadcasts from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial broadcasts arrive — retry after a short delay if needed.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "styly-netsync-server"
7
- version = "0.10.4"
7
+ version = "0.11.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 = 3
9
+ PROTOCOL_VERSION = 4
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 v3 transform encoding constants
35
+ # Protocol v4 transform encoding constants
36
36
  ABS_POS_SCALE = 0.01
37
37
  LOCO_POS_SCALE = 0.01
38
38
  REL_POS_SCALE = 0.005
@@ -372,7 +372,7 @@ def _create_transform_dict(
372
372
 
373
373
 
374
374
  def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
375
- """Serialize a client body in protocol v3 compact format."""
375
+ """Serialize a client body in protocol v4 compact format."""
376
376
  pose_seq = int(client.get("poseSeq", 0)) & 0xFFFF
377
377
  head = client.get("head", {}) or {}
378
378
  right = client.get("rightHand", {}) or {}
@@ -380,6 +380,7 @@ def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
380
380
  virtuals = client.get("virtuals", []) or []
381
381
  has_xr_origin_delta = (
382
382
  "xrOriginDeltaX" in client
383
+ or "xrOriginDeltaY" in client
383
384
  or "xrOriginDeltaZ" in client
384
385
  or "xrOriginDeltaYaw" in client
385
386
  )
@@ -421,6 +422,7 @@ def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
421
422
  virtual_valid = head_valid and bool(flags & POSE_FLAG_VIRTUALS_VALID)
422
423
 
423
424
  xr_origin_delta_x = float(client.get("xrOriginDeltaX", 0.0))
425
+ xr_origin_delta_y = float(client.get("xrOriginDeltaY", 0.0))
424
426
  xr_origin_delta_z = float(client.get("xrOriginDeltaZ", 0.0))
425
427
  xr_origin_delta_yaw = float(client.get("xrOriginDeltaYaw", 0.0))
426
428
  head_pos = _transform_get_position(head)
@@ -430,8 +432,9 @@ def _serialize_client_body(buffer: bytearray, client: dict[str, Any]) -> None:
430
432
  if physical_valid:
431
433
  buffer.extend(
432
434
  struct.pack(
433
- "<hhh",
435
+ "<hhhh",
434
436
  _quantize_signed(xr_origin_delta_x, LOCO_POS_SCALE),
437
+ _quantize_signed(xr_origin_delta_y, LOCO_POS_SCALE),
435
438
  _quantize_signed(xr_origin_delta_z, LOCO_POS_SCALE),
436
439
  _quantize_signed(xr_origin_delta_yaw, PHYSICAL_YAW_SCALE),
437
440
  )
@@ -849,7 +852,7 @@ def deserialize(data: bytes) -> tuple[int, dict[str, Any] | None, bytes]:
849
852
 
850
853
 
851
854
  def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any], int]:
852
- """Deserialize protocol v3 compact pose body."""
855
+ """Deserialize protocol v4 compact pose body."""
853
856
  result: dict[str, Any] = {}
854
857
  result["poseSeq"] = struct.unpack("<H", data[offset : offset + 2])[0]
855
858
  offset += 2
@@ -874,6 +877,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
874
877
  head_pos = (0.0, 0.0, 0.0)
875
878
  head_rot = (0.0, 0.0, 0.0, 1.0)
876
879
  xr_origin_delta_x = 0.0
880
+ xr_origin_delta_y = 0.0
877
881
  xr_origin_delta_z = 0.0
878
882
  xr_origin_delta_yaw = 0.0
879
883
 
@@ -882,11 +886,12 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
882
886
  raise ValueError(
883
887
  "PhysicalValid set but XROrigin delta encoding flag is missing"
884
888
  )
885
- dx_q, dz_q, dyaw_q = struct.unpack("<hhh", data[offset : offset + 6])
889
+ dx_q, dy_q, dz_q, dyaw_q = struct.unpack("<hhhh", data[offset : offset + 8])
886
890
  xr_origin_delta_x = _dequantize_signed(dx_q, LOCO_POS_SCALE)
891
+ xr_origin_delta_y = _dequantize_signed(dy_q, LOCO_POS_SCALE)
887
892
  xr_origin_delta_z = _dequantize_signed(dz_q, LOCO_POS_SCALE)
888
893
  xr_origin_delta_yaw = _dequantize_signed(dyaw_q, PHYSICAL_YAW_SCALE)
889
- offset += 6
894
+ offset += 8
890
895
 
891
896
  if head_valid:
892
897
  hx_q, offset = _unpack_int24_le(data, offset)
@@ -913,7 +918,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
913
918
 
914
919
  if physical_valid and head_valid:
915
920
  translated_x = head_pos[0] - xr_origin_delta_x
916
- translated_y = head_pos[1]
921
+ translated_y = head_pos[1] - xr_origin_delta_y
917
922
  translated_z = head_pos[2] - xr_origin_delta_z
918
923
  physical_pos = _rotate_yaw_vector(
919
924
  translated_x,
@@ -1041,6 +1046,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
1041
1046
  )
1042
1047
 
1043
1048
  result["xrOriginDeltaX"] = xr_origin_delta_x
1049
+ result["xrOriginDeltaY"] = xr_origin_delta_y
1044
1050
  result["xrOriginDeltaZ"] = xr_origin_delta_z
1045
1051
  result["xrOriginDeltaYaw"] = xr_origin_delta_yaw
1046
1052
  result["physical"] = physical
@@ -1052,7 +1058,7 @@ def _deserialize_client_body(data: bytes, offset: int) -> tuple[dict[str, Any],
1052
1058
 
1053
1059
 
1054
1060
  def _deserialize_client_transform(data: bytes, offset: int) -> dict[str, Any]:
1055
- """Deserialize client pose (v3) from binary data."""
1061
+ """Deserialize client pose (v4) from binary data."""
1056
1062
  result: dict[str, Any] = {}
1057
1063
 
1058
1064
  protocol_version = data[offset]
@@ -1090,7 +1096,7 @@ def _deserialize_rpc_message(data: bytes, offset: int) -> dict[str, Any]:
1090
1096
 
1091
1097
 
1092
1098
  def _deserialize_room_transform(data: bytes, offset: int) -> dict[str, Any]:
1093
- """Deserialize room pose (v3) with client numbers only."""
1099
+ """Deserialize room pose (v4) with client numbers only."""
1094
1100
  result: dict[str, Any] = {}
1095
1101
 
1096
1102
  protocol_version = data[offset]
@@ -29,7 +29,7 @@ VarValue = Annotated[str, StringConstraints(max_length=MAX_VALUE)]
29
29
  class UpsertBody(BaseModel):
30
30
  """Request body for client or global variable upsert."""
31
31
 
32
- vars: dict[VarName, VarValue] = Field(default_factory=dict)
32
+ variables: dict[VarName, VarValue] = Field(default_factory=dict)
33
33
 
34
34
 
35
35
  class PreseedStore:
@@ -187,6 +187,25 @@ class RoomBridge:
187
187
  logger.debug("Failed to lookup client number for %s: %s", device_id, exc)
188
188
  return None
189
189
 
190
+ def get_global_variables(self) -> dict[str, str]:
191
+ """Return a snapshot of cached global variables for this room."""
192
+ try:
193
+ return self._manager.get_all_global_variables()
194
+ except Exception as exc: # pragma: no cover - defensive
195
+ logger.debug("get_all_global_variables failed: %s", exc)
196
+ return {}
197
+
198
+ def get_client_variables(self, device_id: str) -> tuple[int | None, dict[str, str]]:
199
+ """Return (client_no, snapshot) for device; client_no is None if unmapped."""
200
+ client_no = self.get_client_no(device_id)
201
+ if client_no is None:
202
+ return None, {}
203
+ try:
204
+ return client_no, self._manager.get_all_client_variables(client_no)
205
+ except Exception as exc: # pragma: no cover - defensive
206
+ logger.debug("get_all_client_variables failed: %s", exc)
207
+ return client_no, {}
208
+
190
209
  def _apply_to_client(self, client_no: int, kvs: dict[str, str]) -> set[str]:
191
210
  """Apply stored variables to the target client via set_client_variable."""
192
211
  applied: set[str] = set()
@@ -308,15 +327,15 @@ def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
308
327
 
309
328
  @app.post("/v1/rooms/{room_id}/devices/{device_id}/client-variables")
310
329
  def upsert(room_id: str, device_id: str, body: UpsertBody) -> dict[str, object]:
311
- if not body.vars:
312
- raise HTTPException(status_code=400, detail="vars must not be empty")
330
+ if not body.variables:
331
+ raise HTTPException(status_code=400, detail="variables must not be empty")
313
332
  try:
314
- store.upsert(room_id, device_id, body.vars)
333
+ store.upsert(room_id, device_id, body.variables)
315
334
  except ValueError as exc:
316
335
  raise HTTPException(status_code=409, detail=str(exc)) from exc
317
336
 
318
337
  bridge = manager.get(room_id)
319
- statuses = bridge.apply_now_or_queue(device_id, body.vars)
338
+ statuses = bridge.apply_now_or_queue(device_id, body.variables)
320
339
  client_no = bridge.get_client_no(device_id)
321
340
 
322
341
  return {
@@ -328,15 +347,15 @@ def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
328
347
 
329
348
  @app.post("/v1/rooms/{room_id}/global-variables")
330
349
  def upsert_global(room_id: str, body: UpsertBody) -> dict[str, object]:
331
- if not body.vars:
332
- raise HTTPException(status_code=400, detail="vars must not be empty")
350
+ if not body.variables:
351
+ raise HTTPException(status_code=400, detail="variables must not be empty")
333
352
 
334
353
  bridge = manager.get(room_id)
335
- statuses = bridge.apply_global_now_or_queue(body.vars)
354
+ statuses = bridge.apply_global_now_or_queue(body.variables)
336
355
 
337
356
  # Only store variables that were queued (not applied immediately)
338
357
  queued = {
339
- name: body.vars[name]
358
+ name: body.variables[name]
340
359
  for name, state in statuses.items()
341
360
  if state != "applied"
342
361
  }
@@ -351,6 +370,45 @@ def create_app(server_addr: str, dealer_port: int, sub_port: int) -> FastAPI:
351
370
  "result": {name: {"state": state} for name, state in statuses.items()},
352
371
  }
353
372
 
373
+ @app.get("/v1/rooms/{room_id}/global-variables")
374
+ def get_global_variables(room_id: str) -> dict[str, object]:
375
+ bridge = manager.get(room_id)
376
+ return {"variables": bridge.get_global_variables()}
377
+
378
+ @app.get("/v1/rooms/{room_id}/global-variables/{name}")
379
+ def get_global_variable(room_id: str, name: VarName) -> dict[str, object]:
380
+ bridge = manager.get(room_id)
381
+ variables = bridge.get_global_variables()
382
+ if name not in variables:
383
+ raise HTTPException(
384
+ status_code=404, detail=f"Global variable '{name}' not found"
385
+ )
386
+ return {"value": variables[name]}
387
+
388
+ @app.get("/v1/rooms/{room_id}/devices/{device_id}/client-variables")
389
+ def get_client_variables(room_id: str, device_id: str) -> dict[str, object]:
390
+ bridge = manager.get(room_id)
391
+ client_no, variables = bridge.get_client_variables(device_id)
392
+ return {"clientNo": client_no, "variables": variables}
393
+
394
+ @app.get("/v1/rooms/{room_id}/devices/{device_id}/client-variables/{name}")
395
+ def get_client_variable(
396
+ room_id: str, device_id: str, name: VarName
397
+ ) -> dict[str, object]:
398
+ bridge = manager.get(room_id)
399
+ client_no, variables = bridge.get_client_variables(device_id)
400
+ if client_no is None:
401
+ raise HTTPException(
402
+ status_code=404,
403
+ detail=f"Device '{device_id}' has no client mapping",
404
+ )
405
+ if name not in variables:
406
+ raise HTTPException(
407
+ status_code=404,
408
+ detail=f"Client variable '{name}' not found for device '{device_id}'",
409
+ )
410
+ return {"clientNo": client_no, "value": variables[name]}
411
+
354
412
  return app
355
413
 
356
414
 
@@ -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 v3 binary-only handling (no JSON fallback)
1059
+ # Protocol v4 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 v3 message from room %s: %s",
1130
+ "Failed to decode protocol v4 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.10.4
3
+ Version: 0.11.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,12 +81,14 @@ 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 = 3`.
85
- - Transform messages use `MSG_CLIENT_POSE` (11) and `MSG_ROOM_POSE` (12) with the compact V3 pose body.
86
- - Legacy transform protocol v2 and JSON transform fallback are not supported.
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).
87
+ - Legacy transform protocols (v2/v3) and JSON transform fallback are not supported.
87
88
  - Deploy Unity and Python updates together when changing transform protocol behavior.
88
- - Protocol v3 position quantization ranges:
89
- - Absolute (`physicalPos`, `headPosAbs`): signed `int24` at `0.01 m` per unit, per-axis range `[-83,886.08 m, 83,886.07 m]`.
89
+ - Protocol v4 position quantization ranges:
90
+ - 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.
90
92
  - Head-relative (`right/left/virtual`): signed `int16` at `0.005 m` per unit, per-axis range `[-163.84 m, 163.835 m]`.
91
93
  - 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.
92
94
 
@@ -94,18 +96,18 @@ styly-netsync-simulator --server tcp://localhost --room my_room --clients 50
94
96
 
95
97
  The following options summarize trade-offs when expanding absolute-position range.
96
98
 
97
- Assumed baseline (`protocolVersion=3`):
98
- - Client pose body with `Physical+Head+Right+Left` valid and `virtualCount=0`: `49 bytes`
99
- - Room per-client entry (`clientNo + poseTime + clientBody`): `59 bytes`
99
+ Assumed baseline (`protocolVersion=4`):
100
+ - 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
+ - Room per-client entry (`clientNo + poseTime + clientBody`): `56 bytes`.
100
102
 
101
103
  | Option | Absolute Position Encoding | Per-axis Range | Client Body Delta | Room Per-client Delta |
102
104
  |---|---|---:|---:|---:|
103
- | A. Coarser scale (current integer width) | `int24 @ 0.02m` | `[-167,772.16m, 167,772.14m]` | `+0B` (`49 -> 49`) | `+0B` (`59 -> 59`) |
104
- | B. Cell + local | `cell(i16, 256m) + local(int24 @ 0.01m)` | `[-8,472,494.08m, 8,472,238.07m]` | `+6B` (`49 -> 55`, `+12.2%`) | `+6B` (`59 -> 65`, `+10.2%`) |
105
- | C. Cell + local (large cell) | `cell(i16, 1024m) + local(int24 @ 0.01m)` | `[-33,638,318.08m, 33,637,294.07m]` | `+6B` (`49 -> 55`, `+12.2%`) | `+6B` (`59 -> 65`, `+10.2%`) |
105
+ | A. Coarser scale (current integer width) | `int24 @ 0.02m` | `[-167,772.16m, 167,772.14m]` | `+0B` (`46 -> 46`) | `+0B` (`56 -> 56`) |
106
+ | B. Cell + local | `cell(i16, 256m) + local(int24 @ 0.01m)` | `[-8,472,494.08m, 8,472,238.07m]` | `+6B` (`46 -> 52`, `+13.0%`) | `+6B` (`56 -> 62`, `+10.7%`) |
107
+ | C. Cell + local (large cell) | `cell(i16, 1024m) + local(int24 @ 0.01m)` | `[-33,638,318.08m, 33,637,294.07m]` | `+6B` (`46 -> 52`, `+13.0%`) | `+6B` (`56 -> 62`, `+10.7%`) |
106
108
 
107
109
  Notes:
108
- - Option B/C deltas assume both absolute transforms are present (`physicalPos` and `headPosAbs`).
110
+ - Only `headPosAbs` is on the wire as an absolute field; `physicalPos` is reconstructed from `headPosAbs + xrOriginDelta`. Option B/C deltas therefore apply to `headPosAbs` only.
109
111
  - Option B/C can reduce average overhead if `cell` is transmitted only when changed, but that requires extra state and flags in the wire format.
110
112
 
111
113
  ## Configuration
@@ -160,7 +162,7 @@ The server launches an embedded FastAPI application that exposes REST endpoints
160
162
 
161
163
  ```json
162
164
  {
163
- "vars": {
165
+ "variables": {
164
166
  "name": "Jack",
165
167
  "lang": "EN"
166
168
  }
@@ -179,11 +181,35 @@ The server launches an embedded FastAPI application that exposes REST endpoints
179
181
  ```bash
180
182
  curl -sS -X POST "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables" \
181
183
  -H "Content-Type: application/json" \
182
- -d '{"vars":{"name":"Jack","lang":"EN"}}'
184
+ -d '{"variables":{"name":"Jack","lang":"EN"}}'
183
185
  ```
184
186
 
185
187
  The response includes the current mapping status (`clientNo` or `null`) and whether each key was `"applied"` or `"queued"`.
186
188
 
189
+ - Read endpoints:
190
+ - `GET /v1/rooms/{roomId}/devices/{deviceId}/client-variables` — returns all client variables for the device.
191
+
192
+ ```json
193
+ {"clientNo": 7, "variables": {"name": "Jack", "lang": "EN"}}
194
+ ```
195
+
196
+ If the device has no `clientNo` mapping yet, returns `{"clientNo": null, "variables": {}}`.
197
+
198
+ - `GET /v1/rooms/{roomId}/devices/{deviceId}/client-variables/{name}` — returns a single variable.
199
+
200
+ ```json
201
+ {"clientNo": 7, "value": "Jack"}
202
+ ```
203
+
204
+ Returns `404` if the device is unmapped or the variable is not set.
205
+
206
+ - Example:
207
+
208
+ ```bash
209
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables"
210
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/devices/00000000-0000-0000-0000-000000000000/client-variables/name"
211
+ ```
212
+
187
213
  ### Global variables
188
214
 
189
215
  - Endpoint: `POST /v1/rooms/{roomId}/global-variables`
@@ -191,7 +217,7 @@ The response includes the current mapping status (`clientNo` or `null`) and whet
191
217
 
192
218
  ```json
193
219
  {
194
- "vars": {
220
+ "variables": {
195
221
  "score": "42",
196
222
  "stage": "lobby"
197
223
  }
@@ -210,7 +236,33 @@ The response includes the current mapping status (`clientNo` or `null`) and whet
210
236
  ```bash
211
237
  curl -sS -X POST "http://127.0.0.1:8800/v1/rooms/default_room/global-variables" \
212
238
  -H "Content-Type: application/json" \
213
- -d '{"vars":{"score":"42","stage":"lobby"}}'
239
+ -d '{"variables":{"score":"42","stage":"lobby"}}'
214
240
  ```
215
241
 
216
242
  The response includes the room ID and whether each key was `"applied"`, `"queued"`, or `"failed"`.
243
+
244
+ - Read endpoints:
245
+ - `GET /v1/rooms/{roomId}/global-variables` — returns all global variables for the room.
246
+
247
+ ```json
248
+ {"variables": {"score": "42", "stage": "lobby"}}
249
+ ```
250
+
251
+ - `GET /v1/rooms/{roomId}/global-variables/{name}` — returns a single variable.
252
+
253
+ ```json
254
+ {"value": "42"}
255
+ ```
256
+
257
+ Returns `404` if the variable is not set.
258
+
259
+ - Example:
260
+
261
+ ```bash
262
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/global-variables"
263
+ curl -sS "http://127.0.0.1:8800/v1/rooms/default_room/global-variables/score"
264
+ ```
265
+
266
+ ### Read consistency
267
+
268
+ GET endpoints return a snapshot of the REST bridge's in-process cache, which is populated by PUB-SUB broadcasts from the server. The first request to a room lazily creates a bridge and may return an empty snapshot until the initial broadcasts arrive — retry after a short delay if needed.
@@ -220,6 +220,7 @@ def _build_random_client_pose(
220
220
  )
221
221
  head_rot = _random_unit_quaternion(rng)
222
222
  xr_origin_delta_x = rng.uniform(-20.0, 20.0)
223
+ xr_origin_delta_y = rng.uniform(-5.0, 5.0)
223
224
  xr_origin_delta_z = rng.uniform(-20.0, 20.0)
224
225
  xr_origin_delta_yaw = rng.uniform(-180.0, 180.0)
225
226
 
@@ -249,6 +250,7 @@ def _build_random_client_pose(
249
250
  "poseSeq": rng.randint(0, 65535),
250
251
  "flags": 0x3E, # Physical + Head + Right + Left + Virtuals
251
252
  "xrOriginDeltaX": xr_origin_delta_x,
253
+ "xrOriginDeltaY": xr_origin_delta_y,
252
254
  "xrOriginDeltaZ": xr_origin_delta_z,
253
255
  "xrOriginDeltaYaw": xr_origin_delta_yaw,
254
256
  "head": _build_transform(head_pos, head_rot),
@@ -259,11 +261,15 @@ def _build_random_client_pose(
259
261
 
260
262
 
261
263
  def _reconstruct_physical_from_head_and_delta(
262
- head: dict[str, float], delta_x: float, delta_z: float, delta_yaw: float
264
+ head: dict[str, float],
265
+ delta_x: float,
266
+ delta_y: float,
267
+ delta_z: float,
268
+ delta_yaw: float,
263
269
  ) -> tuple[tuple[float, float, float], tuple[float, float, float, float]]:
264
270
  """Reconstruct yaw-only physical pose from head pose and XROrigin delta."""
265
271
  tx = head["posX"] - delta_x
266
- ty = head["posY"]
272
+ ty = head["posY"] - delta_y
267
273
  tz = head["posZ"] - delta_z
268
274
 
269
275
  inv_yaw_rad = math.radians(-delta_yaw)
@@ -282,12 +288,12 @@ def _reconstruct_physical_from_head_and_delta(
282
288
  return (px, ty, pz), physical_rot
283
289
 
284
290
 
285
- class TestTransformSerializationV3:
286
- """Tests for protocol v3 transform compact serialization."""
291
+ class TestTransformSerializationV4:
292
+ """Tests for protocol v4 transform compact serialization."""
287
293
 
288
- def test_protocol_version_is_v3(self) -> None:
289
- """Protocol version constant should be updated to v3."""
290
- assert binary_serializer.PROTOCOL_VERSION == 3
294
+ def test_protocol_version_is_v4(self) -> None:
295
+ """Protocol version constant should be at v4."""
296
+ assert binary_serializer.PROTOCOL_VERSION == 4
291
297
 
292
298
  def test_client_roundtrip_without_flags_infers_valid_bits(self) -> None:
293
299
  """Serializer should infer valid bits when flags are omitted."""
@@ -295,6 +301,7 @@ class TestTransformSerializationV3:
295
301
  "deviceId": "infer-flags",
296
302
  "poseSeq": 77,
297
303
  "xrOriginDeltaX": 1.25,
304
+ "xrOriginDeltaY": 0.4,
298
305
  "xrOriginDeltaZ": -2.5,
299
306
  "xrOriginDeltaYaw": 33.3,
300
307
  "head": _build_transform(
@@ -331,6 +338,7 @@ class TestTransformSerializationV3:
331
338
  assert abs(decoded["head"]["posY"] - 1.6) <= 0.01
332
339
  assert abs(decoded["head"]["posZ"] + 2.3) <= 0.01
333
340
  assert abs(decoded["xrOriginDeltaX"] - 1.25) <= 0.01
341
+ assert abs(decoded["xrOriginDeltaY"] - 0.4) <= 0.01
334
342
  assert abs(decoded["xrOriginDeltaZ"] + 2.5) <= 0.01
335
343
  assert abs(decoded["xrOriginDeltaYaw"] - 33.3) <= 0.1
336
344
  assert len(decoded["virtuals"]) == 1
@@ -349,6 +357,7 @@ class TestTransformSerializationV3:
349
357
  | binary_serializer.POSE_FLAG_VIRTUALS_VALID
350
358
  ),
351
359
  "xrOriginDeltaX": 9.0,
360
+ "xrOriginDeltaY": 9.0,
352
361
  "xrOriginDeltaZ": 9.0,
353
362
  "xrOriginDeltaYaw": 9.0,
354
363
  "head": _build_transform(
@@ -409,7 +418,7 @@ class TestTransformSerializationV3:
409
418
 
410
419
  assert msg_type == binary_serializer.MSG_CLIENT_POSE
411
420
  assert decoded is not None
412
- assert decoded["protocolVersion"] == 3
421
+ assert decoded["protocolVersion"] == 4
413
422
  assert len(raw) > 0
414
423
 
415
424
  o_head = original["head"]
@@ -466,15 +475,18 @@ class TestTransformSerializationV3:
466
475
  assert left_err <= 1.0
467
476
 
468
477
  o_dx = original["xrOriginDeltaX"]
478
+ o_dy = original["xrOriginDeltaY"]
469
479
  o_dz = original["xrOriginDeltaZ"]
470
480
  o_dyaw = original["xrOriginDeltaYaw"]
471
481
  assert abs(o_dx - decoded["xrOriginDeltaX"]) <= 0.01
482
+ assert abs(o_dy - decoded["xrOriginDeltaY"]) <= 0.01
472
483
  assert abs(o_dz - decoded["xrOriginDeltaZ"]) <= 0.01
473
484
  assert abs(o_dyaw - decoded["xrOriginDeltaYaw"]) <= 0.1
474
485
 
475
486
  expected_pos, expected_rot = _reconstruct_physical_from_head_and_delta(
476
487
  decoded["head"],
477
488
  decoded["xrOriginDeltaX"],
489
+ decoded["xrOriginDeltaY"],
478
490
  decoded["xrOriginDeltaZ"],
479
491
  decoded["xrOriginDeltaYaw"],
480
492
  )
@@ -518,6 +530,7 @@ class TestTransformSerializationV3:
518
530
  "poseSeq": 10,
519
531
  "flags": 0x3E,
520
532
  "xrOriginDeltaX": 9999.0,
533
+ "xrOriginDeltaY": 9999.0,
521
534
  "xrOriginDeltaZ": -9999.0,
522
535
  "xrOriginDeltaYaw": 9999.0,
523
536
  "head": _build_transform(
@@ -551,6 +564,7 @@ class TestTransformSerializationV3:
551
564
  assert min_abs <= decoded["head"]["posY"] <= max_abs
552
565
  assert min_abs <= decoded["head"]["posZ"] <= max_abs
553
566
  assert min_loco <= decoded["xrOriginDeltaX"] <= max_loco
567
+ assert min_loco <= decoded["xrOriginDeltaY"] <= max_loco
554
568
  assert min_loco <= decoded["xrOriginDeltaZ"] <= max_loco
555
569
  assert (
556
570
  binary_serializer.INT16_MIN * binary_serializer.PHYSICAL_YAW_SCALE
@@ -574,6 +588,7 @@ class TestTransformSerializationV3:
574
588
  "poseSeq": 33,
575
589
  "flags": 0x3E,
576
590
  "xrOriginDeltaX": 250.12,
591
+ "xrOriginDeltaY": -42.5,
577
592
  "xrOriginDeltaZ": -125.34,
578
593
  "xrOriginDeltaYaw": 179.9,
579
594
  "head": _build_transform(
@@ -595,16 +610,18 @@ class TestTransformSerializationV3:
595
610
  assert abs(decoded["head"]["posY"] + 5000.9) <= 0.01
596
611
  assert abs(decoded["head"]["posZ"] - 4321.01) <= 0.01
597
612
  assert abs(decoded["xrOriginDeltaX"] - 250.12) <= 0.01
613
+ assert abs(decoded["xrOriginDeltaY"] + 42.5) <= 0.01
598
614
  assert abs(decoded["xrOriginDeltaZ"] + 125.34) <= 0.01
599
615
  assert abs(decoded["xrOriginDeltaYaw"] - 179.9) <= 0.1
600
616
 
601
617
  def test_client_body_size_with_full_pose_no_virtuals(self) -> None:
602
- """Full pose body (no virtuals) should match current protocol v3 byte size."""
618
+ """Full pose body (no virtuals) should match current protocol v4 byte size."""
603
619
  payload = {
604
620
  "deviceId": "size-check",
605
621
  "poseSeq": 1,
606
622
  "flags": 0x1E, # Physical + Head + Right + Left
607
623
  "xrOriginDeltaX": 1.0,
624
+ "xrOriginDeltaY": 0.5,
608
625
  "xrOriginDeltaZ": 2.0,
609
626
  "xrOriginDeltaYaw": 10.0,
610
627
  "head": _build_transform(
@@ -621,7 +638,8 @@ class TestTransformSerializationV3:
621
638
  _, _, raw = binary_serializer.deserialize(
622
639
  binary_serializer.serialize_client_transform(payload)
623
640
  )
624
- assert len(raw) == 44
641
+ # v4 added a Y component to xrOriginDelta (+2 bytes vs. v3's 44).
642
+ assert len(raw) == 46
625
643
 
626
644
  def test_physical_requires_delta_encoding_flag(self) -> None:
627
645
  """PhysicalValid frames must carry the XROrigin-delta encoding bit."""
@@ -633,6 +651,7 @@ class TestTransformSerializationV3:
633
651
  | binary_serializer.POSE_FLAG_HEAD_VALID
634
652
  ),
635
653
  "xrOriginDeltaX": 1.0,
654
+ "xrOriginDeltaY": 0.3,
636
655
  "xrOriginDeltaZ": -2.0,
637
656
  "xrOriginDeltaYaw": 30.0,
638
657
  "head": _build_transform(
@@ -663,7 +682,7 @@ class TestTransformSerializationV3:
663
682
  c2["poseTime"] = 223.456
664
683
 
665
684
  room_payload = {
666
- "roomId": "room-v3",
685
+ "roomId": "room-v4",
667
686
  "broadcastTime": 999.123,
668
687
  "clients": [c1, c2],
669
688
  }
@@ -672,8 +691,8 @@ class TestTransformSerializationV3:
672
691
 
673
692
  assert msg_type == binary_serializer.MSG_ROOM_POSE
674
693
  assert decoded is not None
675
- assert decoded["protocolVersion"] == 3
676
- assert decoded["roomId"] == "room-v3"
694
+ assert decoded["protocolVersion"] == 4
695
+ assert decoded["roomId"] == "room-v4"
677
696
  assert len(decoded["clients"]) == 2
678
697
 
679
698
  for src, dst in zip(room_payload["clients"], decoded["clients"], strict=True):
@@ -686,6 +705,28 @@ class TestTransformSerializationV3:
686
705
  assert abs(src_head["posY"] - dst_head["posY"]) <= 0.01
687
706
  assert abs(src_head["posZ"] - dst_head["posZ"]) <= 0.01
688
707
 
708
+ def test_foot_invariant_head_minus_physical_equals_delta_y(self) -> None:
709
+ """head.y - reconstructed physical.y must equal xrOriginDeltaY.
710
+
711
+ Follows by construction from `_deserialize_client_body`
712
+ (`translated_y = head_pos[1] - xr_origin_delta_y`) and the yaw-only
713
+ rotation that preserves Y. Pinning this protocol-shape invariant means
714
+ any future change that decouples the two (e.g. dropping delta_y from
715
+ the reconstruction, or adding extra Y transforms) breaks loudly here
716
+ rather than silently on the wire.
717
+ """
718
+ rng = random.Random(20260425)
719
+ for _ in range(200):
720
+ payload = _build_random_client_pose(rng, virtual_count=0)
721
+ _, decoded, _ = binary_serializer.deserialize(
722
+ binary_serializer.serialize_client_transform(payload)
723
+ )
724
+ assert decoded is not None
725
+ d_head = decoded["head"]
726
+ d_phys = decoded["physical"]
727
+ d_dy = decoded["xrOriginDeltaY"]
728
+ assert abs((d_head["posY"] - d_phys["posY"]) - d_dy) <= 1e-6
729
+
689
730
  def test_denormalized_quaternion_roundtrip(self) -> None:
690
731
  """Non-unit quaternions should be normalized and survive roundtrip."""
691
732
  # magnitude = 2.0
@@ -177,14 +177,14 @@ class TestGlobalVariablesEndpoint:
177
177
  def test_post_returns_200(self, client: TestClient) -> None:
178
178
  resp = client.post(
179
179
  "/v1/rooms/room1/global-variables",
180
- json={"vars": {"score": "42"}},
180
+ json={"variables": {"score": "42"}},
181
181
  )
182
182
  assert resp.status_code == 200
183
183
 
184
184
  def test_post_response_structure(self, client: TestClient) -> None:
185
185
  resp = client.post(
186
186
  "/v1/rooms/room1/global-variables",
187
- json={"vars": {"score": "42"}},
187
+ json={"variables": {"score": "42"}},
188
188
  )
189
189
  body = resp.json()
190
190
  assert body["roomId"] == "room1"
@@ -194,7 +194,7 @@ class TestGlobalVariablesEndpoint:
194
194
  def test_post_empty_vars_returns_400(self, client: TestClient) -> None:
195
195
  resp = client.post(
196
196
  "/v1/rooms/room1/global-variables",
197
- json={"vars": {}},
197
+ json={"variables": {}},
198
198
  )
199
199
  assert resp.status_code == 400
200
200
 
@@ -206,14 +206,14 @@ class TestGlobalVariablesEndpoint:
206
206
  long_name = "x" * 65
207
207
  resp = client.post(
208
208
  "/v1/rooms/room1/global-variables",
209
- json={"vars": {long_name: "v"}},
209
+ json={"variables": {long_name: "v"}},
210
210
  )
211
211
  assert resp.status_code == 422
212
212
 
213
213
  def test_post_var_value_too_long_returns_422(self, client: TestClient) -> None:
214
214
  resp = client.post(
215
215
  "/v1/rooms/room1/global-variables",
216
- json={"vars": {"k": "x" * 1025}},
216
+ json={"variables": {"k": "x" * 1025}},
217
217
  )
218
218
  assert resp.status_code == 422
219
219
 
@@ -240,7 +240,7 @@ class TestGlobalVariablesEndpoint:
240
240
  tc = TestClient(app)
241
241
  resp = tc.post(
242
242
  "/v1/rooms/room_full/global-variables",
243
- json={"vars": {"overflow": "x"}},
243
+ json={"variables": {"overflow": "x"}},
244
244
  )
245
245
  assert resp.status_code == 409
246
246
  finally:
@@ -263,10 +263,156 @@ class TestGlobalVariablesEndpoint:
263
263
  tc = TestClient(app)
264
264
  resp = tc.post(
265
265
  "/v1/rooms/room1/global-variables",
266
- json={"vars": {"k": "v"}},
266
+ json={"variables": {"k": "v"}},
267
267
  )
268
268
  assert resp.status_code == 200
269
269
  # Store should remain empty since the var was applied
270
270
  assert rest_bridge.global_store.get("room1") == {}
271
271
  finally:
272
272
  rest_bridge.global_store = original_store
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # RoomBridge read method tests
277
+ # ---------------------------------------------------------------------------
278
+
279
+
280
+ class TestRoomBridgeReadMethods:
281
+ def test_get_global_variables_delegates_to_manager(self) -> None:
282
+ bridge = _make_bridge(client_no=1)
283
+ bridge._manager.get_all_global_variables.return_value = {"a": "1", "b": "2"}
284
+ result = bridge.get_global_variables()
285
+ assert result == {"a": "1", "b": "2"}
286
+ bridge._manager.get_all_global_variables.assert_called_once_with()
287
+
288
+ def test_get_global_variables_swallows_exceptions(self) -> None:
289
+ bridge = _make_bridge(client_no=1)
290
+ bridge._manager.get_all_global_variables.side_effect = RuntimeError("boom")
291
+ assert bridge.get_global_variables() == {}
292
+
293
+ def test_get_client_variables_unmapped_returns_none_and_empty(self) -> None:
294
+ bridge = _make_bridge(client_no=1)
295
+ bridge._manager.get_client_no.return_value = None
296
+ client_no, variables = bridge.get_client_variables("device-x")
297
+ assert client_no is None
298
+ assert variables == {}
299
+ bridge._manager.get_all_client_variables.assert_not_called()
300
+
301
+ def test_get_client_variables_mapped_returns_snapshot(self) -> None:
302
+ bridge = _make_bridge(client_no=1)
303
+ bridge._manager.get_client_no.return_value = 7
304
+ bridge._manager.get_all_client_variables.return_value = {"name": "alice"}
305
+ client_no, variables = bridge.get_client_variables("device-x")
306
+ assert client_no == 7
307
+ assert variables == {"name": "alice"}
308
+ bridge._manager.get_all_client_variables.assert_called_once_with(7)
309
+
310
+ def test_get_client_variables_swallows_exceptions(self) -> None:
311
+ bridge = _make_bridge(client_no=1)
312
+ bridge._manager.get_client_no.return_value = 7
313
+ bridge._manager.get_all_client_variables.side_effect = RuntimeError("boom")
314
+ client_no, variables = bridge.get_client_variables("device-x")
315
+ assert client_no == 7
316
+ assert variables == {}
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # GET endpoint tests via TestClient
321
+ # ---------------------------------------------------------------------------
322
+
323
+
324
+ def _make_get_client(mock_bridge: MagicMock) -> TestClient:
325
+ """Build a TestClient whose BridgeManager always returns the given bridge."""
326
+ with patch("styly_netsync.rest_bridge.BridgeManager") as MockBM:
327
+ MockBM.return_value.get.return_value = mock_bridge
328
+ app = create_app("localhost", 5555, 5556)
329
+ return TestClient(app)
330
+
331
+
332
+ class TestGlobalVariablesGetEndpoint:
333
+ def test_get_all_returns_snapshot(self) -> None:
334
+ mock_bridge = MagicMock()
335
+ mock_bridge.get_global_variables.return_value = {"score": "10", "level": "3"}
336
+ tc = _make_get_client(mock_bridge)
337
+ resp = tc.get("/v1/rooms/room1/global-variables")
338
+ assert resp.status_code == 200
339
+ assert resp.json() == {"variables": {"score": "10", "level": "3"}}
340
+
341
+ def test_get_all_returns_empty_when_cache_empty(self) -> None:
342
+ mock_bridge = MagicMock()
343
+ mock_bridge.get_global_variables.return_value = {}
344
+ tc = _make_get_client(mock_bridge)
345
+ resp = tc.get("/v1/rooms/room1/global-variables")
346
+ assert resp.status_code == 200
347
+ assert resp.json() == {"variables": {}}
348
+
349
+ def test_get_single_returns_value(self) -> None:
350
+ mock_bridge = MagicMock()
351
+ mock_bridge.get_global_variables.return_value = {"score": "10"}
352
+ tc = _make_get_client(mock_bridge)
353
+ resp = tc.get("/v1/rooms/room1/global-variables/score")
354
+ assert resp.status_code == 200
355
+ assert resp.json() == {"value": "10"}
356
+
357
+ def test_get_single_missing_returns_404(self) -> None:
358
+ mock_bridge = MagicMock()
359
+ mock_bridge.get_global_variables.return_value = {}
360
+ tc = _make_get_client(mock_bridge)
361
+ resp = tc.get("/v1/rooms/room1/global-variables/missing")
362
+ assert resp.status_code == 404
363
+
364
+ def test_get_single_name_too_long_returns_422(self) -> None:
365
+ mock_bridge = MagicMock()
366
+ mock_bridge.get_global_variables.return_value = {}
367
+ tc = _make_get_client(mock_bridge)
368
+ long_name = "x" * 65
369
+ resp = tc.get(f"/v1/rooms/room1/global-variables/{long_name}")
370
+ assert resp.status_code == 422
371
+
372
+
373
+ class TestClientVariablesGetEndpoint:
374
+ def test_get_all_unknown_device_returns_null_mapping_and_empty(self) -> None:
375
+ mock_bridge = MagicMock()
376
+ mock_bridge.get_client_variables.return_value = (None, {})
377
+ tc = _make_get_client(mock_bridge)
378
+ resp = tc.get("/v1/rooms/room1/devices/device-x/client-variables")
379
+ assert resp.status_code == 200
380
+ assert resp.json() == {"clientNo": None, "variables": {}}
381
+
382
+ def test_get_all_mapped_device_returns_snapshot(self) -> None:
383
+ mock_bridge = MagicMock()
384
+ mock_bridge.get_client_variables.return_value = (7, {"name": "alice"})
385
+ tc = _make_get_client(mock_bridge)
386
+ resp = tc.get("/v1/rooms/room1/devices/device-x/client-variables")
387
+ assert resp.status_code == 200
388
+ assert resp.json() == {"clientNo": 7, "variables": {"name": "alice"}}
389
+
390
+ def test_get_single_returns_value(self) -> None:
391
+ mock_bridge = MagicMock()
392
+ mock_bridge.get_client_variables.return_value = (7, {"name": "alice"})
393
+ tc = _make_get_client(mock_bridge)
394
+ resp = tc.get("/v1/rooms/room1/devices/device-x/client-variables/name")
395
+ assert resp.status_code == 200
396
+ assert resp.json() == {"clientNo": 7, "value": "alice"}
397
+
398
+ def test_get_single_unknown_device_returns_404(self) -> None:
399
+ mock_bridge = MagicMock()
400
+ mock_bridge.get_client_variables.return_value = (None, {})
401
+ tc = _make_get_client(mock_bridge)
402
+ resp = tc.get("/v1/rooms/room1/devices/device-x/client-variables/name")
403
+ assert resp.status_code == 404
404
+
405
+ def test_get_single_missing_variable_returns_404(self) -> None:
406
+ mock_bridge = MagicMock()
407
+ mock_bridge.get_client_variables.return_value = (7, {})
408
+ tc = _make_get_client(mock_bridge)
409
+ resp = tc.get("/v1/rooms/room1/devices/device-x/client-variables/name")
410
+ assert resp.status_code == 404
411
+
412
+ def test_get_single_name_too_long_returns_422(self) -> None:
413
+ mock_bridge = MagicMock()
414
+ mock_bridge.get_client_variables.return_value = (7, {})
415
+ tc = _make_get_client(mock_bridge)
416
+ long_name = "x" * 65
417
+ resp = tc.get(f"/v1/rooms/room1/devices/device-x/client-variables/{long_name}")
418
+ assert resp.status_code == 422