@axpecter/lync 2.3.1 → 2.3.3

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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Schemas, packets, queries, groups, validation, rate limiting. Every send batches
16
16
  Wally — add to your `wally.toml`:
17
17
 
18
18
  ```toml
19
- Lync = "axp3cter/lync@2.3.1"
19
+ Lync = "axp3cter/lync@2.3.3"
20
20
  ```
21
21
 
22
22
  npm (roblox-ts):
@@ -250,8 +250,9 @@ Enable with `Lync.configure({ stats = true })`.
250
250
  | Codec | Bytes | Notes |
251
251
  |:---|---:|:---|
252
252
  | `int(min, max)` | 1 / 2 / 4 | Picks narrowest u8/u16/u32/i8/i16/i32. |
253
+ | `zint(min?, max?)` | 1 – 5 | Variable-length signed via zigzag varint. 1 byte for [-96, 95]. |
253
254
  | `f16` / `f32` / `f64` | 2 / 4 / 8 | `f16` ≈ ±65504, ~3 digits. |
254
- | `float(min, max, precision)` | 1 / 2 / 4 | Quantized; clamped to range. |
255
+ | `float(min, max, precision)` | 1 / 2 / 3 / 4 | Quantized; picks u8 / u16 / u24 / u32 wire form. |
255
256
  | `bool` | 1 | Auto-bitpacked inside `struct` and `array`. |
256
257
 
257
258
  ### Strings & buffers
@@ -284,8 +285,8 @@ Call as a function for compression.
284
285
 
285
286
  | Codec | Bytes | Notes |
286
287
  |:---|---:|:---|
287
- | `vec2(min, max, precision)` | 2 / 4 / 8 | Per-component quantization. |
288
- | `vec3(min, max, precision)` | 3 / 6 / 12 | Per-component quantization. |
288
+ | `vec2(min, max, precision)` | 2 / 4 / 6 / 8 | Per-component, narrowest fitting width. |
289
+ | `vec3(min, max, precision)` | 3 / 6 / 9 / 12 | Per-component, narrowest fitting width. |
289
290
  | `cframe()` | 16 | Smallest-three quaternion. ≤ 0.16° rotation error. |
290
291
 
291
292
  ### Composites
@@ -301,13 +302,20 @@ Call as a function for compression.
301
302
 
302
303
  ### Delta — reliable transport only
303
304
 
304
- Sends 1 byte when the value is byte-equal to the cached previous frame.
305
+ Tracks the previous frame's value and ships only what changed. Rejected on `unreliable = true`.
305
306
 
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. |
307
+ | Codec | Static | Mutation |
308
+ |:---|:---:|:---:|
309
+ | `deltaStruct(schema)` | 1 B | per-field |
310
+ | `deltaArray(c, max?)` | 1 B | per-changed-index |
311
+ | `deltaMap(k, v, max?)` | 1 B | per-changed-key |
312
+ | `deltaInt(min, max)` | 1 B | 1–5 B |
313
+ | `deltaFloat(min, max, precision)` | 1 B | 1–5 B |
314
+ | `deltaVec3(min, max, precision)` | 3 B | 3–15 B |
315
+ | `deltaCFrame(posMin, posMax, precision)` | 1 B | 4–13 B |
316
+
317
+ - `deltaArray` element / `deltaMap` key+value cannot themselves contain delta state. Use `deltaStruct` for per-field deltas inside.
318
+ - `deltaVec3` and `deltaCFrame` error on out-of-range components.
311
319
 
312
320
  ### Meta
313
321
 
@@ -351,21 +359,23 @@ Global per-player cap: `Lync.configure({ globalRateLimit = { maxPerSecond = N }
351
359
  | Tool | `array<entity>[100]` | `array<bool>[1000]` |
352
360
  |:---|:---|:---|
353
361
  | roblox | 16 fps · 559,364 Kbps | 21 fps · 353,107 Kbps |
354
- | **lync** | **60 fps · 3.44 Kbps** | **60 fps · 2.46 Kbps** |
362
+ | **lync** | **59 fps · 3.37 Kbps** | **61 fps · 2.45 Kbps** |
355
363
  | blink | 42 fps · 41.81 Kbps | 97 fps · 7.91 Kbps |
356
364
  | zap | 39 fps · 41.71 Kbps | 52 fps · 8.10 Kbps |
357
365
  | bytenet | 32 fps · 41.64 Kbps | 35 fps · 8.11 Kbps |
358
366
 
359
367
  ### Network bandwidth — 100 fires/frame, 8 s
360
368
 
361
- | Workload | Kbps |
362
- |:---|---:|
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** |
369
+ | Workload | Naive Kbps | Optimized | Savings |
370
+ |:---|---:|:---|---:|
371
+ | `array<entity>[100]` random | 3,607 | `deltaArray` 3 of 100 mutated | **154** (–96%) |
372
+ | `array<entity>[100]` reused | 3,607 | XOR baseline (identical frames) | **2.4** (–99.9%) |
373
+ | `array<bool>[1000]` random | 762 | XOR baseline (1 bit flipped) | **20.4** (–97%) |
374
+ | `struct(state)` random | 201 | `deltaStruct` 1 field mutated | **29.0** (–86%) |
375
+ | `map<id, vec3>[200]` 5 keys mutated | 657 | `deltaMap` 5 keys mutated | **393** (–40%) |
376
+ | `array<cframe>[50]` random | 4,585 | | — |
377
+ | `vec3` walking motion (continuous diff) | — | `deltaVec3` | **19.5** |
378
+ | `CFrame` walking pose (pos + rot) | — | `deltaCFrame` | **41.1** |
369
379
 
370
380
  ## License
371
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axpecter/lync",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
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
@@ -12,17 +12,18 @@ export type ChannelState = {
12
12
  itemCount: number,
13
13
  singleMode: boolean,
14
14
  singlePos: number,
15
- deltas: { [number]: DeltaCacheEntry },
15
+ --[[
16
+ Per-codec write-side delta state, keyed by deltaId. Shape is opaque
17
+ and codec-internal: deltaStruct stores `{raw, len, segOff, segLen}`,
18
+ deltaArray stores `{perIdx, length}`, deltaMap stores `{perKey}`,
19
+ delta scalars store quantized previous values.
20
+ ]]
21
+ deltas: { [number]: any },
16
22
  prevDump: buffer?,
17
23
  prevDumpLen: number,
18
24
  currentPacket: string?,
19
25
  }
20
26
 
21
- export type DeltaCacheEntry = {
22
- raw: buffer,
23
- len: number,
24
- }
25
-
26
27
  export type Codec<T> = {
27
28
  write: (ch: ChannelState, value: T) -> (),
28
29
  read: (src: buffer, pos: number, refs: { Instance }?) -> (T, number),
@@ -39,6 +40,11 @@ export type InternalCodec<T> = {
39
40
  _directWrite: ((b: buffer, offset: number, value: T) -> ())?,
40
41
  _directRead: ((b: buffer, offset: number) -> T)?,
41
42
  _isDelta: boolean?,
43
+ --[[
44
+ True if any nested codec is delta. Lets the broadcast hot path skip
45
+ encode-once when delta state would diverge per-receiver.
46
+ ]]
47
+ _hasDelta: boolean?,
42
48
  _isBool: boolean?,
43
49
  _hasUnknown: boolean?,
44
50
  _min: number?,
@@ -11,6 +11,7 @@ local Log = require(script.Parent.Parent.util.Log)
11
11
  local Middleware = require(script.Parent.Parent.internal.Middleware)
12
12
  local Player = require(script.Parent.Parent.util.Player)
13
13
  local Registry = require(script.Parent.Parent.internal.Registry)
14
+ local Shared = require(script.Parent.Parent.codec.composite.Shared)
14
15
  local Transport = require(script.Parent.Parent.internal.Transport)
15
16
  local Types = require(script.Parent.Parent.Types)
16
17
 
@@ -129,14 +130,100 @@ function Packet.define<T>(
129
130
  return false, sendData
130
131
  end
131
132
 
132
- -- Caller has already run send middleware once for the broadcast.
133
+ -- Non-delta codecs are stateless wire-encoders, so broadcast can encode
134
+ -- once and bufCopy the payload to every player. Falls through if the
135
+ -- encoded payload includes ref indices (the indices would need rewriting
136
+ -- per channel, which no codec exposes).
137
+ local canEncodeOnce = not codec._isDelta and not codec._hasDelta
138
+
133
139
  local function broadcast(players: { Player }, data: any): ()
134
- for _, p in players do
135
- sendToPlayer(p, data)
140
+ local count = #players
141
+ if count == 0 then
142
+ bumpFires()
143
+ return
144
+ end
145
+
146
+ if canEncodeOnce then
147
+ local scratch = Shared.acquireScratch()
148
+ scratch.currentPacket = name
149
+ codec.write(scratch, data)
150
+ scratch.currentPacket = nil
151
+ if scratch.refCount == 0 then
152
+ local payload = scratch.buff
153
+ local payloadLen = scratch.cursor
154
+ local server = Transport.server()
155
+ local getChannel = if isUnreliable
156
+ then server.getUnreliableChannel
157
+ else server.getReliableChannel
158
+ -- Channel.writeBatchEncoded is hot-swapped by enableStats; re-read per call.
159
+ local writeBatchEncoded = Channel.writeBatchEncoded
160
+ for i = 1, count do
161
+ writeBatchEncoded(getChannel(players[i]), reg, payload, payloadLen)
162
+ end
163
+ Shared.releaseScratch()
164
+ bumpFires()
165
+ return
166
+ end
167
+ Shared.releaseScratch()
168
+ end
169
+
170
+ for i = 1, count do
171
+ sendToPlayer(players[i], data)
136
172
  end
137
173
  bumpFires()
138
174
  end
139
175
 
176
+ --[[
177
+ Resolve a target table into a { Player } list. Returns nil for unknown
178
+ sentinel kinds; the caller errors with the original target type.
179
+ ]]
180
+ local function resolveAudience(target: any): { Player }?
181
+ local kind = target._lyncKind
182
+ if kind == "all" then
183
+ return Players:GetPlayers()
184
+ end
185
+
186
+ if kind == "except" then
187
+ local excluded = target._excluded :: { [Player]: boolean }
188
+ local all = Players:GetPlayers()
189
+ local list = table.create(#all)
190
+ local n = 0
191
+ for _, player in all do
192
+ if not excluded[player] then
193
+ n += 1
194
+ list[n] = player
195
+ end
196
+ end
197
+ return list
198
+ end
199
+
200
+ if kind == "group" then
201
+ local members = target._members :: { [Player]: boolean }
202
+ local list: { Player } = {}
203
+ local n = 0
204
+ for player in members do
205
+ n += 1
206
+ list[n] = player
207
+ end
208
+ return list
209
+ end
210
+
211
+ if kind == nil then
212
+ -- Plain { Player } array. Validate entries on the way to a clean list.
213
+ local list: { Player } = {}
214
+ local n = 0
215
+ for _, player in target do
216
+ if isPlayer(player) then
217
+ n += 1
218
+ list[n] = player :: Player
219
+ end
220
+ end
221
+ return list
222
+ end
223
+
224
+ return nil
225
+ end
226
+
140
227
  local function sendToTarget(data: any, target: any): ()
141
228
  if isPlayer(target) then
142
229
  local dropped, sendData = applySend(data, target :: Player)
@@ -153,40 +240,11 @@ function Packet.define<T>(
153
240
  if dropped then
154
241
  return
155
242
  end
156
-
157
- local kind = target._lyncKind
158
- if kind == "all" then
159
- broadcast(Players:GetPlayers(), sendData)
160
- return
161
- end
162
-
163
- if kind == "except" then
164
- local excluded = target._excluded :: { [Player]: boolean }
165
- for _, p in Players:GetPlayers() do
166
- if not excluded[p] then
167
- sendToPlayer(p, sendData)
168
- end
169
- end
170
- bumpFires()
171
- return
172
- end
173
-
174
- if kind == "group" then
175
- for p in target._members :: { [Player]: boolean } do
176
- sendToPlayer(p, sendData)
177
- end
178
- bumpFires()
243
+ local audience = resolveAudience(target)
244
+ if audience then
245
+ broadcast(audience, sendData)
179
246
  return
180
247
  end
181
-
182
- -- Plain { Player } array.
183
- for _, p in target do
184
- if isPlayer(p) then
185
- sendToPlayer(p :: Player, sendData)
186
- end
187
- end
188
- bumpFires()
189
- return
190
248
  end
191
249
 
192
250
  Log.error(`"{name}" expected Player, table, Group, or Lync.all, got {typeof(target)}`)
@@ -44,10 +44,10 @@ local function writeQueryTracked(
44
44
  ): ()
45
45
  if Channel.statsEnabled() then
46
46
  local before = ch.cursor
47
- Channel.writeQuery(ch, reg.id, corrId, codec, data)
47
+ Channel.writeQuery(ch, reg.id, corrId, codec, data, reg.name)
48
48
  reg.bytesSent += ch.cursor - before
49
49
  else
50
- Channel.writeQuery(ch, reg.id, corrId, codec, data)
50
+ Channel.writeQuery(ch, reg.id, corrId, codec, data, reg.name)
51
51
  end
52
52
  end
53
53
 
@@ -17,11 +17,16 @@ local _freeThread: thread? = nil
17
17
 
18
18
  -- Private ----------------------------------------------------------------
19
19
 
20
+ -- pcall wrapper: a handler error must not consume the recycled thread, otherwise
21
+ -- every subsequent fire pays a coroutine.create until process restart.
20
22
  local function passer(fn: (...any) -> (), ...): ()
21
23
  local thread = _freeThread
22
24
  _freeThread = nil
23
- fn(...)
25
+ local ok, err = pcall(fn, ...)
24
26
  _freeThread = thread
27
+ if not ok then
28
+ task.spawn(error, err)
29
+ end
25
30
  end
26
31
 
27
32
  local function yielder(): ()
@@ -7,7 +7,7 @@ local Types = require(script.Parent.Parent.Types)
7
7
 
8
8
  -- Constants --------------------------------------------------------------
9
9
 
10
- local DEFAULT_MAX = 262144
10
+ local DEFAULT_MAX = 1048576
11
11
  local INITIAL_BUF = 1024
12
12
 
13
13
  -- State ------------------------------------------------------------------
@@ -56,6 +56,13 @@ local Base = {}
56
56
  Base.INITIAL_BUF = INITIAL_BUF
57
57
  Base.alloc = alloc
58
58
 
59
+ -- Cheap inline guard hoisted into every codec that grows a channel directly.
60
+ function Base.ensure(ch: Types.ChannelState, n: number): ()
61
+ if ch.cursor + n > ch.size then
62
+ alloc(ch, n)
63
+ end
64
+ end
65
+
59
66
  -- Floor is INITIAL_BUF: anything smaller errors on the very first write.
60
67
  function Base.setMaxSize(bytes: number): ()
61
68
  if bytes < INITIAL_BUF then