@axpecter/lync 2.2.1 → 2.3.1

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 (62) hide show
  1. package/README.md +95 -143
  2. package/package.json +1 -1
  3. package/src/Types.luau +22 -16
  4. package/src/api/Group.luau +40 -33
  5. package/src/api/Packet.luau +68 -54
  6. package/src/api/Query.luau +103 -65
  7. package/src/api/Scope.luau +26 -10
  8. package/src/api/Signal.luau +43 -52
  9. package/src/codec/Base.luau +21 -14
  10. package/src/codec/composite/Array.luau +56 -134
  11. package/src/codec/composite/Map.luau +103 -72
  12. package/src/codec/composite/Optional.luau +6 -2
  13. package/src/codec/composite/Shared.luau +160 -69
  14. package/src/codec/composite/Struct.luau +283 -42
  15. package/src/codec/composite/Tagged.luau +13 -16
  16. package/src/codec/composite/Tuple.luau +21 -15
  17. package/src/codec/datatype/Buffer.luau +6 -4
  18. package/src/codec/datatype/CFrame.luau +56 -44
  19. package/src/codec/datatype/Color.luau +10 -10
  20. package/src/codec/datatype/Instance.luau +15 -12
  21. package/src/codec/datatype/IntVector.luau +2 -2
  22. package/src/codec/datatype/NumberRange.luau +15 -8
  23. package/src/codec/datatype/Ray.luau +13 -14
  24. package/src/codec/datatype/Rect.luau +12 -12
  25. package/src/codec/datatype/Region.luau +13 -14
  26. package/src/codec/datatype/Sequence.luau +87 -65
  27. package/src/codec/datatype/String.luau +17 -10
  28. package/src/codec/datatype/UDim.luau +10 -8
  29. package/src/codec/datatype/Vector.luau +20 -59
  30. package/src/codec/meta/Auto.luau +94 -126
  31. package/src/codec/meta/Bitfield.luau +12 -14
  32. package/src/codec/meta/Custom.luau +3 -1
  33. package/src/codec/meta/Enum.luau +9 -9
  34. package/src/codec/meta/Float.luau +6 -28
  35. package/src/codec/meta/Nothing.luau +1 -1
  36. package/src/codec/meta/Unknown.luau +10 -7
  37. package/src/codec/primitive/Bool.luau +6 -4
  38. package/src/codec/primitive/Float16.luau +5 -2
  39. package/src/codec/primitive/Int.luau +5 -6
  40. package/src/codec/primitive/Number.luau +6 -4
  41. package/src/codec/primitive/Signed.luau +2 -2
  42. package/src/codec/primitive/Varint.luau +69 -38
  43. package/src/index.d.ts +161 -53
  44. package/src/init.luau +109 -103
  45. package/src/internal/Baseline.luau +9 -1
  46. package/src/internal/Channel.luau +153 -154
  47. package/src/internal/Middleware.luau +22 -6
  48. package/src/internal/Pool.luau +12 -4
  49. package/src/internal/Registry.luau +25 -16
  50. package/src/internal/Transport.luau +1 -1
  51. package/src/transport/Bridge.luau +47 -33
  52. package/src/transport/Client.luau +25 -21
  53. package/src/transport/Gate.luau +227 -172
  54. package/src/transport/Reader.luau +162 -105
  55. package/src/transport/Server.luau +46 -57
  56. package/src/util/Array.luau +18 -0
  57. package/src/util/Buffer.luau +92 -0
  58. package/src/util/Constants.luau +30 -0
  59. package/src/util/Log.luau +68 -0
  60. package/src/util/Player.luau +14 -0
  61. package/src/util/Quantize.luau +60 -0
  62. package/src/internal/Util.luau +0 -26
package/README.md CHANGED
@@ -9,18 +9,17 @@
9
9
  <a href="#benchmarks">Benchmarks</a>
10
10
  </p>
11
11
 
12
- Packets, queries, groups, validation, and rate limiting all batched into one buffer per player per frame. No code generation.
12
+ Schemas, packets, queries, groups, validation, rate limiting. Every send batches into one buffer per player per frame; identical frames XOR to ones already in flight; delta codecs collapse unchanged state to a single byte. No code generation.
13
13
 
14
14
  ## Install
15
15
 
16
- **Wally**
16
+ Wally — add to your `wally.toml`:
17
17
 
18
18
  ```toml
19
- [dependencies]
20
- Lync = "axp3cter/lync@2.2.1"
19
+ Lync = "axp3cter/lync@2.3.1"
21
20
  ```
22
21
 
23
- **npm (roblox-ts)**
22
+ npm (roblox-ts):
24
23
 
25
24
  ```bash
26
25
  npm install @axpecter/lync
@@ -30,10 +29,7 @@ npm install @axpecter/lync
30
29
  import Lync from "@axpecter/lync";
31
30
  ```
32
31
 
33
- Or grab the `.rbxm` from [Releases](https://github.com/Axp3cter/Lync/releases/latest).
34
-
35
- > [!IMPORTANT]
36
- > Define all packets, queries, and groups before calling `Lync.start()`.
32
+ **Important.** Define every packet, query, and group before `Lync.start()`. Definitions assign sequential 7-bit IDs that both peers must agree on; defining late on one side desyncs the wire.
37
33
 
38
34
  ## Example
39
35
 
@@ -72,7 +68,7 @@ local Players = game:GetService("Players")
72
68
  local alive = Lync.group("alive")
73
69
  Players.PlayerAdded:Connect(function(p) alive:add(p) end)
74
70
 
75
- Net.Hit:on(function(data, sender) -- ... end)
71
+ Net.Hit:on(function(data, sender) end)
76
72
  Net.Ping:handle(function() return os.clock() end)
77
73
 
78
74
  Lync.start()
@@ -91,7 +87,7 @@ local Net = require(game.ReplicatedStorage.Net)
91
87
  Lync.start()
92
88
 
93
89
  local scope = Lync.scope()
94
- scope:on(Net.State, function(state) -- ... end)
90
+ scope:on(Net.State, function(state) end)
95
91
 
96
92
  Net.Hit:send({ targetId = 123, damage = 45 })
97
93
  local serverTime = Net.Ping:request(nil)
@@ -103,18 +99,19 @@ local serverTime = Net.Ping:request(nil)
103
99
 
104
100
  | Function | Description |
105
101
  |:---|:---|
106
- | `Lync.configure(opts)` | Set options. Must precede `start()`. |
102
+ | `Lync.configure(opts)` | Apply options. Must precede `start()`. |
107
103
  | `Lync.start()` | Initialize transport. Call once. |
108
104
  | `Lync.isStarted()` | `true` after `start()`. |
109
105
  | `Lync.flush()` | Force an immediate send. |
110
- | `Lync.flushRate(hz)` | 1–60. Default 60. |
106
+ | `Lync.flushRate(hz)` | 1–60 Hz. Default 60. |
107
+ | `Lync.reset()` | Restore module state to post-require defaults. For tests / hot reload. |
111
108
 
112
109
  ### Configure options
113
110
 
114
111
  | Option | Default | Range | Description |
115
112
  |:---|---:|:---|:---|
116
- | `channelMaxSize` | 262144 | 4 KB – 1 MB | Max buffer bytes per frame. |
117
- | `validationDepth` | 16 | 4–32 | Max recursion depth for input validation. |
113
+ | `channelMaxSize` | 262144 | 4 KB – 1 MB | Per-frame buffer cap. |
114
+ | `validationDepth` | 16 | 4–32 | Schema-walk recursion limit. |
118
115
  | `poolSize` | 16 | 2–128 | Reusable channel-state pool. |
119
116
  | `bandwidthLimit` | none | — | `{ softLimit, maxStrikes }` per-player throttle. |
120
117
  | `globalRateLimit` | none | — | `{ maxPerSecond }` across all packets per player. |
@@ -128,44 +125,44 @@ local serverTime = Net.Ping:request(nil)
128
125
  -- Server
129
126
  packet:send(data, player)
130
127
  packet:send(data, Lync.all)
131
- packet:send(data, Lync.except(p1, p2))
128
+ packet:send(data, Lync.except(p1, group1))
132
129
  packet:send(data, { p1, p2, p3 })
133
130
  packet:send(data, group)
134
131
 
135
132
  -- Client
136
133
  packet:send(data)
137
134
 
138
- -- Both
139
- packet:on(function(data, sender, timestamp?) end) -- returns Connection
135
+ -- Both sides
136
+ local conn = packet:on(function(data, sender, timestamp) end)
140
137
  packet:once(fn)
141
- packet:wait() -- yields, returns data, sender, timestamp?
138
+ local data, sender, timestamp = packet:wait()
142
139
  packet:name()
143
- packet:stats() -- requires stats=true
140
+ packet:stats() -- requires stats=true
144
141
  ```
145
142
 
146
143
  | Option | Type | Description |
147
144
  |:---|:---|:---|
148
- | `unreliable` | boolean | Use `UnreliableRemoteEvent`. Disallowed with delta codecs. |
149
- | `rateLimit` | `RateLimitConfig` | Server-side per-player limit. |
150
- | `validate` | `(data, player) → (bool, string?)` | Drop on `false`. |
151
- | `maxPayloadBytes` | number | Reject oversize incoming payloads. |
152
- | `timestamp` | `"frame"` / `"offset"` / `"full"` | Append 1B / 2B / 8B timestamp. Read as third arg. |
145
+ | `unreliable` | boolean | Use `UnreliableRemoteEvent`. Rejected with delta codecs (a dropped frame would desync the baseline). |
146
+ | `rateLimit` | `RateLimitConfig` | Server-side per-player. |
147
+ | `validate` | `(data, player) → (bool, string?)` | Drop on `false`. Reason is forwarded to `onDrop`. |
148
+ | `maxPayloadBytes` | number | Reject oversize incoming payloads early. |
149
+ | `timestamp` | `"frame"`, `"offset"`, `"full"` | Append 1B / 2B / 8B timestamp. Read as the third arg. |
153
150
 
154
151
  ### Queries
155
152
 
156
153
  `Lync.query(name, requestCodec, responseCodec, options?)`
157
154
 
158
- Request-response on top of two packet IDs.
155
+ Request-response on top of two paired registrations. Single-target requests yield until reply or timeout; multi-target requests gather a partial map.
159
156
 
160
157
  ```luau
161
158
  -- Server
162
159
  query:handle(function(data, player) return response end)
163
- query:request(data, player) -- response?
164
- query:request(data, target) -- { [Player]: response? }
160
+ local resp = query:request(data, player) -- response?
161
+ local map = query:request(data, group) -- { [Player]: response? }
165
162
 
166
163
  -- Client
167
164
  query:handle(function(data) return response end)
168
- query:request(data) -- yields, → response? (nil on timeout)
165
+ local resp = query:request(data) -- yields; nil on timeout
169
166
  ```
170
167
 
171
168
  | Option | Default | Description |
@@ -180,14 +177,14 @@ query:request(data) -- yields, → response? (nil on timeout
180
177
 
181
178
  | Method | Returns | Description |
182
179
  |:---|:---|:---|
183
- | `group:add(p)` / `:remove(p)` | boolean | `true` if changed. |
184
- | `group:has(p)` | boolean | Membership. |
185
- | `group:count()` | number | |
186
- | `group:destroy()` | — | Clear and free name. |
180
+ | `:add(p)` / `:remove(p)` | `boolean` | `true` if membership changed. |
181
+ | `:has(p)` | `boolean` | |
182
+ | `:count()` | `number` | |
183
+ | `:destroy()` | — | Clear members and free the name. |
187
184
 
188
185
  ### Scope
189
186
 
190
- `Lync.scope()` — batches connections for cleanup.
187
+ `Lync.scope()` — batches connections for a single `:destroy()`.
191
188
 
192
189
  ```luau
193
190
  local scope = Lync.scope()
@@ -205,43 +202,46 @@ Server-side `:send` second arg.
205
202
  |:---|:---|
206
203
  | `Player` | One player. |
207
204
  | `Lync.all` | All connected. |
208
- | `Lync.except(...)` | Everyone except given players or groups. |
205
+ | `Lync.except(...)` | Everyone except given Players or Groups. |
209
206
  | `{ p1, p2 }` | Array of players. |
210
207
  | `group` | All members. |
211
208
 
212
209
  ### Middleware
213
210
 
214
211
  ```luau
215
- Lync.onSend(function(data, name, player) return data end) -- return Lync.DROP to discard
212
+ -- Return Lync.DROP from onSend to discard a packet.
213
+ Lync.onSend(function(data, name, player) return data end)
216
214
  Lync.onReceive(function(data, name, player) return data end)
217
215
  Lync.onDrop(function(player, reason, name, data) end)
218
216
  ```
219
217
 
220
- All return a `Connection`.
218
+ All return a `Connection`. A throwing hook surfaces to the caller and aborts the chain at that point.
221
219
 
222
220
  ### Connection
223
221
 
224
222
  | | |
225
223
  |:---|:---|
226
- | `c.connected` | boolean |
224
+ | `c.connected` | `boolean` |
227
225
  | `c:disconnect()` | Idempotent. |
228
226
 
229
227
  ### Stats
230
228
 
231
- `Lync.configure({ stats = true })`.
229
+ Enable with `Lync.configure({ stats = true })`.
232
230
 
233
231
  | Function | Description |
234
232
  |:---|:---|
235
233
  | `Lync.stats.player(p)` | `{ bytesSent, bytesReceived }`. Server only. |
236
234
  | `Lync.stats.reset()` | Zero all counters. |
237
- | `packet:stats()` | `{ bytesSent, bytesReceived, fires, recvFires, drops }` |
235
+ | `packet:stats()` | `{ bytesSent, bytesReceived, fires, recvFires, drops }`. Aggregated across the request + response registrations on queries. |
238
236
 
239
237
  ### Debug
240
238
 
241
239
  | Function | Description |
242
240
  |:---|:---|
243
- | `Lync.debug.pending()` | In-flight query requests. |
244
- | `Lync.debug.registrations()` | Frozen array of `{ name, id, kind, isUnreliable }`. |
241
+ | `Lync.debug.pending()` | In-flight query correlation IDs. |
242
+ | `Lync.debug.registrations()` | Frozen `{ name, id, kind, isUnreliable }` per registration. |
243
+
244
+ `capture` / `stop` / `dump` are reserved no-ops for capture/replay tooling.
245
245
 
246
246
  ## Codecs
247
247
 
@@ -249,9 +249,9 @@ All return a `Connection`.
249
249
 
250
250
  | Codec | Bytes | Notes |
251
251
  |:---|---:|:---|
252
- | `int(min, max)` | 1 / 2 / 4 | Picks smallest u8/u16/u32/i8/i16/i32. |
253
- | `f16` / `f32` / `f64` | 2 / 4 / 8 | f16: ±65504, ~3 digits. |
254
- | `float(min, max, precision)` | 14 | Quantized. Clamped. |
252
+ | `int(min, max)` | 1 / 2 / 4 | Picks narrowest u8/u16/u32/i8/i16/i32. |
253
+ | `f16` / `f32` / `f64` | 2 / 4 / 8 | `f16` ±65504, ~3 digits. |
254
+ | `float(min, max, precision)` | 1 / 2 / 4 | Quantized; clamped to range. |
255
255
  | `bool` | 1 | Auto-bitpacked inside `struct` and `array`. |
256
256
 
257
257
  ### Strings & buffers
@@ -260,7 +260,7 @@ All return a `Connection`.
260
260
  |:---|:---|
261
261
  | `string` | Variable length. Binary-safe. |
262
262
  | `string(maxLength)` | Bounded. Rejects on read if exceeded. |
263
- | `buff` | Variable-length buffer. |
263
+ | `buff` | Variable-length raw `buffer`. |
264
264
 
265
265
  ### Roblox types
266
266
 
@@ -269,7 +269,7 @@ All return a `Connection`.
269
269
  | `vec2` / `vec3` | 8 / 12 |
270
270
  | `cframe` | 24 |
271
271
  | `color3` | 3 |
272
- | `inst` | 2 |
272
+ | `inst` | 2 (sidecar ref index) |
273
273
  | `udim` / `udim2` | 8 / 16 |
274
274
  | `numberRange` | 8 |
275
275
  | `rect` | 16 |
@@ -284,136 +284,88 @@ Call as a function for compression.
284
284
 
285
285
  | Codec | Bytes | Notes |
286
286
  |:---|---:|:---|
287
- | `vec2(min, max, precision)` | 28 | Per-component. |
288
- | `vec3(min, max, precision)` | 312 | Per-component. |
289
- | `cframe()` | 16 | Smallest-three quaternion. ≤0.16° rotation error. |
287
+ | `vec2(min, max, precision)` | 2 / 4 / 8 | Per-component quantization. |
288
+ | `vec3(min, max, precision)` | 3 / 6 / 12 | Per-component quantization. |
289
+ | `cframe()` | 16 | Smallest-three quaternion. ≤ 0.16° rotation error. |
290
290
 
291
291
  ### Composites
292
292
 
293
293
  | Codec | Notes |
294
294
  |:---|:---|
295
- | `struct({k = c})` | Named fields. Bools auto-bitpacked. |
296
- | `array(c, max?)` | List. Bool arrays bitpacked. |
297
- | `map(k, v, max?)` | Key-value pairs. |
298
- | `optional(c)` | 1B nil flag + value. |
299
- | `tuple(...)` | Positional. |
300
- | `tagged(field, {name = c})` | Discriminated union. 1B tag. 256 variants. |
295
+ | `struct({k = c})` | Named fields. Bools auto-bitpacked into a tail block. |
296
+ | `array(c, max?)` | List. Bool arrays bitpacked. Direct path for fixed-size elements. |
297
+ | `map(k, v, max?)` | Key-value pairs; keys sorted at encode for stable wire bytes. |
298
+ | `optional(c)` | 1B presence flag + value. |
299
+ | `tuple(...)` | Positional. All-direct fast path when every element is fixed-size. |
300
+ | `tagged(field, {name = c})` | Discriminated union. 1B tag. Up to 256 variants. |
301
301
 
302
- ### Delta — reliable only
302
+ ### Delta — reliable transport only
303
303
 
304
- Sends 1 byte when unchanged.
304
+ Sends 1 byte when the value is byte-equal to the cached previous frame.
305
305
 
306
- | Codec |
307
- |:---|
308
- | `deltaStruct(schema)` |
309
- | `deltaArray(c, max?)` |
310
- | `deltaMap(k, v, max?)` |
306
+ | Codec | Notes |
307
+ |:---|:---|
308
+ | `deltaStruct(schema)` | Per-segment dirty bitmap; single-segment fast path. |
309
+ | `deltaArray(c, max?)` | Whole-array byte-equality. |
310
+ | `deltaMap(k, v, max?)` | Whole-map byte-equality. |
311
311
 
312
312
  ### Meta
313
313
 
314
314
  | Codec | Notes |
315
315
  |:---|:---|
316
- | `enum(...)` | String enum. ≤256 variants. 1B. |
317
- | `bitfield(schema)` | 1–32 bits. Sub-byte packing. |
318
- | `custom(size, write, read, typeCheck?)` | User-defined fixed-size. |
319
- | `nothing` | 0 bytes. Reads `nil`. |
320
- | `unknown` | Bypass serialization. Use with `validate`. |
321
- | `auto` | Self-describing. nil/bool/numbers/strings/buffers/Roblox types. |
316
+ | `enum(...)` | String enum. ≤ 256 variants. 1B u8 index. |
317
+ | `bitfield(schema)` | 1–32 bits total. `{ type = "bool" }`, `{ type = "uint", width }`, `{ type = "int", width }`. |
318
+ | `custom(size, write, read, typeCheck?)` | User-defined fixed-size codec. |
319
+ | `nothing` | 0 bytes; reads `nil`. For fire-and-forget signals. |
320
+ | `unknown` | Bypasses serialization through the channel sidecar. Must be paired with `validate`. |
321
+ | `auto` | Self-describing: nil / bool / numbers / strings / buffers / Roblox datatypes. 1B type tag + payload. |
322
322
 
323
323
  ## Rate limiting
324
324
 
325
325
  Per-packet, pick one mode:
326
326
 
327
- ```luau
328
- { maxPerSecond = N, burst = M } -- token bucket
329
- { cooldown = seconds } -- cooldown
330
- ```
327
+ - Token bucket: `{ maxPerSecond = N, burst = M }`
328
+ - Cooldown: `{ cooldown = seconds }`
331
329
 
332
- Global per-player: `Lync.configure({ globalRateLimit = { maxPerSecond = N } })`.
330
+ Global per-player cap: `Lync.configure({ globalRateLimit = { maxPerSecond = N } })`.
333
331
 
334
332
  ## Limits
335
333
 
336
334
  | | |
337
335
  |:---|---:|
338
- | Packet + query IDs | 127 |
336
+ | Packet + query IDs (combined) | 127 |
339
337
  | Buffer per frame | 1 MB max |
340
- | In-flight queries | 65,536 |
338
+ | In-flight queries | 65,535 |
341
339
  | Enum / tagged variants | 256 |
342
340
  | Bitfield total bits | 32 |
341
+ | Sidecar refs per frame | 65,535 |
343
342
 
344
343
  ## Benchmarks
345
344
 
346
345
  `rojo serve bench.project.json` with one server + one client.
347
346
 
348
- CPU benches run a fixed 1000 iterations per case.
349
-
350
- ### Codec throughput
351
-
352
- | Codec | Encode | Decode | RT/s |
353
- |:---|---:|---:|---:|
354
- | `bool` | 43 ns | 28 ns | 14.1 M |
355
- | `int(0, 255)` | 41 ns | 25 ns | 15.2 M |
356
- | `int(0, 65535)` | 40 ns | 25 ns | 15.3 M |
357
- | `f16` | 60 ns | 42 ns | 9.7 M |
358
- | `f32` | 41 ns | 26 ns | 14.9 M |
359
- | `f64` | 41 ns | 25 ns | 15.3 M |
360
- | `string` (10 chars) | 45 ns | 73 ns | 8.4 M |
361
- | `string` (1000 chars) | 74 ns | 250 ns | 3.1 M |
362
- | `vec3` | 56 ns | 27 ns | 12.1 M |
363
- | `vec3` quantized | 121 ns | 85 ns | 4.9 M |
364
- | `cframe` | 88 ns | 186 ns | 3.6 M |
365
- | `cframe()` | 118 ns | 214 ns | 3.0 M |
366
- | entity struct (6 fields) | 234 ns | 476 ns | 1.4 M |
367
- | 100× entities | 15.3 µs | 34.6 µs | 20 K |
368
- | 1000× bools (bitpacked) | 4.3 µs | 5.3 µs | 104 K |
369
-
370
- ### Wire sizes
347
+ ### Cross-library 1000 fires/frame, 10 s
371
348
 
372
- | Codec | Bytes |
349
+ [Blink's methodology](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md): same payload reused every frame, identical entity / bool shapes. Other tools from Blink v0.17.1.
350
+
351
+ | Tool | `array<entity>[100]` | `array<bool>[1000]` |
352
+ |:---|:---|:---|
353
+ | roblox | 16 fps · 559,364 Kbps | 21 fps · 353,107 Kbps |
354
+ | **lync** | **60 fps · 3.44 Kbps** | **60 fps · 2.46 Kbps** |
355
+ | blink | 42 fps · 41.81 Kbps | 97 fps · 7.91 Kbps |
356
+ | zap | 39 fps · 41.71 Kbps | 52 fps · 8.10 Kbps |
357
+ | bytenet | 32 fps · 41.64 Kbps | 35 fps · 8.11 Kbps |
358
+
359
+ ### Network bandwidth — 100 fires/frame, 8 s
360
+
361
+ | Workload | Kbps |
373
362
  |:---|---:|
374
- | entity struct (6 fields, lossless) | 34 |
375
- | entity compact (quantized) | 13 |
376
- | 100× entities | 601 |
377
- | 1000× bools (bitpacked) | 127 |
378
- | bitfield flags | 2 |
379
- | `tuple(u8, vec3, bool)` | 14 |
380
-
381
- ### Delta savings
382
-
383
- | Codec | Full | Unchanged |
384
- |:---|---:|---:|
385
- | `deltaStruct` (entity) | 35 B | 1 B |
386
- | `deltaStruct` (compact) | 14 B | 1 B |
387
- | `deltaArray` (100× entity) | 602 B | 1 B |
388
- | `deltaArray` (1000× bool) | 128 B | 1 B |
389
- | `deltaMap` (string → u8) | 19 B | 1 B |
390
-
391
- ### Cross-library comparison
392
-
393
- Same methodology as [Blink](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md): 1000 fires/frame, identical data, 10 s. Other-tool numbers from Blink v0.17.1.
394
-
395
- > [!NOTE]
396
- > Lync batches all sends into one buffer per frame and bitpacks bools (1000 = 127 B vs ~1002 B). Delta compression isn't exercised here.
397
-
398
- **100× struct(6× u8) entities**
399
-
400
- | Tool | FPS | Kbps |
401
- |:---|---:|---:|
402
- | roblox | 16 | 559,364 |
403
- | **lync** | **60** | **3.47** |
404
- | blink | 42 | 41.81 |
405
- | zap | 39 | 41.71 |
406
- | bytenet | 32 | 41.64 |
407
-
408
- **1000× bool**
409
-
410
- | Tool | FPS | Kbps |
411
- |:---|---:|---:|
412
- | roblox | 21 | 353,107 |
413
- | **lync** | **60** | **2.33** |
414
- | blink | 97 | 7.91 |
415
- | zap | 52 | 8.10 |
416
- | bytenet | 35 | 8.11 |
363
+ | `array<entity>[100]` randomised | 3,608 |
364
+ | `array<entity>[100]` reused | **2.4** |
365
+ | `array<bool>[1000]` randomised | 763 |
366
+ | `array<bool>[1000]` 1 bit flipped | **20.5** |
367
+ | `struct(state)` randomised | 201 |
368
+ | `deltaStruct(state)` 1 field mutated | **29.2** |
417
369
 
418
370
  ## License
419
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axpecter/lync",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security",
5
5
  "main": "src/init.luau",
6
6
  "types": "src/index.d.ts",
package/src/Types.luau CHANGED
@@ -29,9 +29,8 @@ export type Codec<T> = {
29
29
  }
30
30
 
31
31
  --[[
32
- InternalCodec carries optional metadata used by Gate (validation),
33
- Reader (max-item bound), Auto (type dispatch), and composite codecs
34
- (direct write/read fast path).
32
+ Optional metadata used by Gate (validation), Reader (max-item bound),
33
+ Auto (type dispatch), and composite codecs (direct write/read fast path).
35
34
  ]]
36
35
  export type InternalCodec<T> = {
37
36
  write: (ch: ChannelState, value: T) -> (),
@@ -45,27 +44,28 @@ export type InternalCodec<T> = {
45
44
  _min: number?,
46
45
  _max: number?,
47
46
  _isInteger: boolean?,
48
- _schema: { [string]: InternalCodec<any> }?,
47
+ _schema: { [string]: InternalCodec<T> }?,
49
48
  _typeCheck: string?,
50
49
  --[[
51
- Composite-codec metadata. Gate uses these to recurse element /
52
- variant validation rather than fall through to a generic NaN scan.
50
+ Composite metadata: lets Gate recurse element/variant validation
51
+ instead of falling through to the generic NaN/inf scan.
53
52
  ]]
54
53
  _isOptional: boolean?,
55
- _inner: InternalCodec<any>?,
54
+ _inner: InternalCodec<T>?,
56
55
  _isTagged: boolean?,
57
- _variants: { [string]: InternalCodec<any> }?,
56
+ _variants: { [string]: InternalCodec<T> }?,
58
57
  _tagField: string?,
59
58
  _isArray: boolean?,
60
- _element: InternalCodec<any>?,
59
+ _element: InternalCodec<T>?,
61
60
  _maxCount: number?,
62
61
  _isMap: boolean?,
63
- _keyCodec: InternalCodec<any>?,
64
- _valueCodec: InternalCodec<any>?,
62
+ _keyCodec: InternalCodec<T>?,
63
+ _valueCodec: InternalCodec<T>?,
65
64
  _isTuple: boolean?,
66
- _elements: { InternalCodec<any> }?,
65
+ _elements: { InternalCodec<T> }?,
67
66
  _isEnum: boolean?,
68
67
  _enumValues: { [string]: number }?,
68
+ _maxStringLength: number?,
69
69
  }
70
70
 
71
71
  export type RateLimitState = {
@@ -76,7 +76,7 @@ export type RateLimitState = {
76
76
 
77
77
  --[[
78
78
  Token-bucket (maxPerSecond + optional burst) or cooldown (cooldown
79
- seconds between accepts). Mixing both forms is undefined.
79
+ seconds between accepts). Mixing both forms is undefined behavior.
80
80
  ]]
81
81
  export type RateLimitConfig = {
82
82
  maxPerSecond: number?,
@@ -84,6 +84,10 @@ export type RateLimitConfig = {
84
84
  cooldown: number?,
85
85
  }
86
86
 
87
+ --[[
88
+ User-supplied per-packet validator. Returns (true, nil) on accept;
89
+ (false, reason) on reject — the reason is forwarded to onDrop hooks.
90
+ ]]
87
91
  export type ValidateFn = (data: any, player: Player) -> (boolean, string?)
88
92
 
89
93
  export type PacketOptions = {
@@ -106,6 +110,7 @@ export type Registration = {
106
110
  kind: number,
107
111
  codec: InternalCodec<any>,
108
112
  isUnreliable: boolean,
113
+ -- Response registration id; set on REQUEST regs only.
109
114
  partner: number?,
110
115
  signal: SignalLike,
111
116
  rateLimit: RateLimitConfig?,
@@ -160,9 +165,10 @@ export type PlayerStats = {
160
165
  }
161
166
 
162
167
  --[[
163
- Server keys baselines per Player; client uses `false` since booleans
164
- are valid table keys but Players never collide with that.
168
+ Server keys baselines per Player; client uses `false` since booleans are
169
+ valid table keys but Players never collide with that.
165
170
  ]]
166
171
  export type BaselineKey = Player | false
167
172
 
168
- return nil
173
+ -- Type-only module: runtime table is empty so re-requires are cheap.
174
+ return table.freeze({})
@@ -1,10 +1,11 @@
1
1
  --!strict
2
2
  --!optimize 2
3
- -- Named player sets with __iter, auto-cleanup on PlayerRemoving.
3
+ -- Named player sets with __iter and auto-cleanup on PlayerRemoving.
4
4
 
5
5
  local Players = game:GetService("Players")
6
6
 
7
- local Util = require(script.Parent.Parent.internal.Util)
7
+ local Array = require(script.Parent.Parent.util.Array)
8
+ local Log = require(script.Parent.Parent.util.Log)
8
9
 
9
10
  -- Public types -----------------------------------------------------------
10
11
 
@@ -14,22 +15,24 @@ export type GroupHandle = {
14
15
  has: (self: GroupHandle, player: Player) -> boolean,
15
16
  count: (self: GroupHandle) -> number,
16
17
  destroy: (self: GroupHandle) -> (),
17
- _lyncGroup: boolean,
18
+ _lyncKind: "group",
18
19
  _members: { [Player]: boolean },
19
20
  }
20
21
 
21
- type Group = GroupHandle & { _count: number, _name: string }
22
+ type GroupInternal = GroupHandle & { _count: number, _name: string }
22
23
 
23
24
  -- State ------------------------------------------------------------------
24
25
 
25
- local _groups: { [string]: Group } = {}
26
+ local _groups: { [string]: GroupInternal } = {}
27
+ -- Reverse index used by PlayerRemoving so we don't scan every group on disconnect.
26
28
  local _playerCleanup: { [Player]: { Group } } = {}
27
29
  local _cleanupConnected = false
28
30
 
29
31
  -- Private ----------------------------------------------------------------
30
32
 
31
- local swapRemove = Util.swapRemove
33
+ local swapRemove = Array.swapRemove
32
34
 
35
+ -- Connect once on the first Group.create; subsequent creates reuse the same connection.
33
36
  local function ensureCleanup(): ()
34
37
  if _cleanupConnected then
35
38
  return
@@ -49,6 +52,17 @@ local function ensureCleanup(): ()
49
52
  end)
50
53
  end
51
54
 
55
+ local function untrackCleanup(player: Player, group: GroupInternal): ()
56
+ local cleanup = _playerCleanup[player]
57
+ if not cleanup then
58
+ return
59
+ end
60
+ local idx = table.find(cleanup, group)
61
+ if idx then
62
+ swapRemove(cleanup, idx)
63
+ end
64
+ end
65
+
52
66
  -- Public -----------------------------------------------------------------
53
67
 
54
68
  local Group = {}
@@ -56,15 +70,12 @@ local Group = {}
56
70
  local GroupMeta = {}
57
71
  GroupMeta.__index = GroupMeta
58
72
 
59
- --[[
60
- __iter returns the standard 3-tuple iterator so `for player in group do`
61
- uses Luau's builtin path (luaH_next) with zero closure allocation.
62
- ]]
63
- function GroupMeta.__iter(self: Group): (any, any, any)
73
+ -- __iter lets `for player in group do` skip the explicit :members() call.
74
+ function GroupMeta.__iter(self: GroupInternal): (any, any, any)
64
75
  return next, self._members, nil
65
76
  end
66
77
 
67
- function GroupMeta.add(self: Group, player: Player): boolean
78
+ function GroupMeta.add(self: GroupInternal, player: Player): boolean
68
79
  if self._members[player] then
69
80
  return false
70
81
  end
@@ -81,45 +92,32 @@ function GroupMeta.add(self: Group, player: Player): boolean
81
92
  return true
82
93
  end
83
94
 
84
- function GroupMeta.remove(self: Group, player: Player): boolean
95
+ function GroupMeta.remove(self: GroupInternal, player: Player): boolean
85
96
  if not self._members[player] then
86
97
  return false
87
98
  end
88
99
 
89
100
  self._members[player] = nil
90
101
  self._count -= 1
91
-
92
- local cleanup = _playerCleanup[player]
93
- if cleanup then
94
- local idx = table.find(cleanup, self)
95
- if idx then
96
- swapRemove(cleanup, idx)
97
- end
98
- end
102
+ untrackCleanup(player, self)
99
103
  return true
100
104
  end
101
105
 
102
- function GroupMeta.has(self: Group, player: Player): boolean
106
+ function GroupMeta.has(self: GroupInternal, player: Player): boolean
103
107
  return self._members[player] == true
104
108
  end
105
109
 
106
- function GroupMeta.count(self: Group): number
110
+ function GroupMeta.count(self: GroupInternal): number
107
111
  return self._count
108
112
  end
109
113
 
110
- function GroupMeta.destroy(self: Group): ()
114
+ function GroupMeta.destroy(self: GroupInternal): ()
111
115
  if _groups[self._name] == self then
112
116
  _groups[self._name] = nil
113
117
  end
114
118
 
115
119
  for player in self._members do
116
- local cleanup = _playerCleanup[player]
117
- if cleanup then
118
- local idx = table.find(cleanup, self)
119
- if idx then
120
- swapRemove(cleanup, idx)
121
- end
122
- end
120
+ untrackCleanup(player, self)
123
121
  end
124
122
 
125
123
  table.clear(self._members)
@@ -130,13 +128,13 @@ table.freeze(GroupMeta)
130
128
 
131
129
  function Group.create(name: string): GroupHandle
132
130
  if _groups[name] then
133
- error(`[Lync] Lync.group: "{name}" already exists`)
131
+ Log.error(`"{name}" already exists`)
134
132
  end
135
133
 
136
134
  ensureCleanup()
137
135
 
138
136
  local group = setmetatable({
139
- _lyncGroup = true,
137
+ _lyncKind = "group" :: "group",
140
138
  _members = {} :: { [Player]: boolean },
141
139
  _count = 0,
142
140
  _name = name,
@@ -146,4 +144,13 @@ function Group.create(name: string): GroupHandle
146
144
  return group
147
145
  end
148
146
 
147
+ function Group.reset(): ()
148
+ for _, group in _groups do
149
+ table.clear(group._members)
150
+ group._count = 0
151
+ end
152
+ table.clear(_groups)
153
+ table.clear(_playerCleanup)
154
+ end
155
+
149
156
  return table.freeze(Group)