@axpecter/lync 2.2.1 → 2.3.2

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 (65) hide show
  1. package/README.md +109 -147
  2. package/package.json +1 -1
  3. package/src/Types.luau +34 -22
  4. package/src/api/Group.luau +40 -33
  5. package/src/api/Packet.luau +140 -66
  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 +28 -14
  10. package/src/codec/composite/Array.luau +296 -115
  11. package/src/codec/composite/Map.luau +377 -57
  12. package/src/codec/composite/Optional.luau +10 -2
  13. package/src/codec/composite/Shared.luau +134 -64
  14. package/src/codec/composite/Struct.luau +294 -43
  15. package/src/codec/composite/Tagged.luau +21 -16
  16. package/src/codec/composite/Tuple.luau +32 -15
  17. package/src/codec/datatype/Buffer.luau +7 -7
  18. package/src/codec/datatype/CFrame.luau +34 -122
  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/DeltaScalar.luau +390 -0
  34. package/src/codec/meta/Enum.luau +9 -9
  35. package/src/codec/meta/Float.luau +6 -28
  36. package/src/codec/meta/Nothing.luau +1 -1
  37. package/src/codec/meta/Unknown.luau +10 -7
  38. package/src/codec/primitive/Bool.luau +6 -4
  39. package/src/codec/primitive/Float16.luau +5 -2
  40. package/src/codec/primitive/Int.luau +5 -6
  41. package/src/codec/primitive/Number.luau +6 -4
  42. package/src/codec/primitive/Signed.luau +2 -2
  43. package/src/codec/primitive/Varint.luau +76 -39
  44. package/src/codec/primitive/Zint.luau +99 -0
  45. package/src/index.d.ts +207 -53
  46. package/src/init.luau +116 -103
  47. package/src/internal/Baseline.luau +9 -1
  48. package/src/internal/Channel.luau +191 -157
  49. package/src/internal/Middleware.luau +22 -6
  50. package/src/internal/Pool.luau +12 -4
  51. package/src/internal/Registry.luau +25 -16
  52. package/src/internal/Transport.luau +1 -1
  53. package/src/transport/Bridge.luau +47 -33
  54. package/src/transport/Client.luau +25 -21
  55. package/src/transport/Gate.luau +227 -172
  56. package/src/transport/Reader.luau +174 -106
  57. package/src/transport/Server.luau +46 -57
  58. package/src/util/Array.luau +18 -0
  59. package/src/util/Buffer.luau +90 -0
  60. package/src/util/Constants.luau +30 -0
  61. package/src/util/Log.luau +68 -0
  62. package/src/util/Player.luau +14 -0
  63. package/src/util/Quantize.luau +84 -0
  64. package/src/util/Quat.luau +124 -0
  65. package/src/internal/Util.luau +0 -26
@@ -2,6 +2,10 @@
2
2
  --!native
3
3
  -- Per-channel buffer state with MSB batch framing, XOR delta, timestamps.
4
4
 
5
+ local Base = require(script.Parent.Parent.codec.Base)
6
+ local Buf = require(script.Parent.Parent.util.Buffer)
7
+ local Constants = require(script.Parent.Parent.util.Constants)
8
+ local Log = require(script.Parent.Parent.util.Log)
5
9
  local Types = require(script.Parent.Parent.Types)
6
10
  local Varint = require(script.Parent.Parent.codec.primitive.Varint)
7
11
 
@@ -12,16 +16,29 @@ local TS_FRAME = 1
12
16
  local TS_OFFSET = 2
13
17
  local TS_FULL = 3
14
18
 
15
- local DEFAULT_MAX = 262144
16
19
  local INITIAL_BUF = 1024
17
- local FRAME_MASK = 0xFF
18
- local OFFSET_WIN = 65.536
20
+ local HEADER_BYTE = 1
21
+ local FRAME_MSB = Constants.FRAME_MSB
22
+ local U8_MAX = Constants.U8_MAX
23
+ local U16_MAX = Constants.U16_MAX
24
+ local MAX_BATCH_ITEMS = Constants.MAX_BATCH_ITEMS
25
+
26
+ -- 65.535s window so round((clock % win) * 1000) cannot overflow u16.
27
+ local OFFSET_WIN = 65.535
28
+
29
+ local TS_BYTES = table.freeze({
30
+ [TS_NONE] = 0,
31
+ [TS_FRAME] = 1,
32
+ [TS_OFFSET] = 2,
33
+ [TS_FULL] = 8,
34
+ })
19
35
 
20
36
  -- State ------------------------------------------------------------------
21
37
 
22
- local _maxSize = DEFAULT_MAX
23
- local _anyTimestamp = false
38
+ local _timestampsEnabled = false
24
39
  local _statsEnabled = false
40
+
41
+ -- Refreshed by updateTimestamp once per flush.
25
42
  local _frameCounter = 0
26
43
  local _offsetMs = 0
27
44
  local _clock = 0.0
@@ -30,51 +47,16 @@ local EMPTY_REFS = table.freeze({}) :: { Instance }
30
47
 
31
48
  -- Private ----------------------------------------------------------------
32
49
 
33
- local countlz = bit32.countlz
34
- local lshift = bit32.lshift
50
+ local alloc = Base.alloc
51
+ local ensure = Base.ensure
35
52
  local band = bit32.band
36
- local bxor = bit32.bxor
37
53
  local bor = bit32.bor
54
+ local bufCopy = buffer.copy
38
55
  local writeu8 = buffer.writeu8
39
56
  local writeu16 = buffer.writeu16
40
- local writeu32 = buffer.writeu32
41
57
  local writef64 = buffer.writef64
42
- local readu8 = buffer.readu8
43
- local readu32 = buffer.readu32
44
58
  local round = math.round
45
- local minN = math.min
46
-
47
- local function alloc(ch: Types.ChannelState, bytes: number): ()
48
- local needed = ch.cursor + bytes
49
- if needed <= ch.size then
50
- return
51
- end
52
- if needed > _maxSize then
53
- local name = ch.currentPacket
54
- if name then
55
- error(`[Lync] Channel.alloc("{name}"): buffer overflow ({needed}B, max {_maxSize}B)`)
56
- end
57
- error(`[Lync] Channel.alloc: buffer overflow ({needed}B, max {_maxSize}B)`)
58
- end
59
- local newSize = lshift(1, 32 - countlz(needed - 1))
60
- local newBuff = buffer.create(newSize)
61
- buffer.copy(newBuff, 0, ch.buff, 0, ch.cursor)
62
- ch.buff = newBuff
63
- ch.size = newSize
64
- end
65
-
66
- local function tsBytes(mode: number): number
67
- if mode == TS_NONE then
68
- return 0
69
- end
70
- if mode == TS_FRAME then
71
- return 1
72
- end
73
- if mode == TS_OFFSET then
74
- return 2
75
- end
76
- return 8
77
- end
59
+ local osClock = os.clock
78
60
 
79
61
  local function writeTimestamp(b: buffer, off: number, mode: number): ()
80
62
  if mode == TS_FRAME then
@@ -86,8 +68,103 @@ local function writeTimestamp(b: buffer, off: number, mode: number): ()
86
68
  end
87
69
  end
88
70
 
71
+ --[[
72
+ Single -> multi upgrade. Patches the previously-emitted single-item
73
+ header, splices a 2-byte count cell after the timestamp, and shifts the
74
+ payload right. Hot only when a packet fires twice in a row to the same
75
+ channel without an intervening flush.
76
+ ]]
77
+ local function upgradeSingleToMulti(ch: Types.ChannelState, regId: number, headerSize: number): ()
78
+ local headerPos = ch.singlePos
79
+ writeu8(ch.buff, headerPos, regId)
80
+
81
+ local afterTs = headerPos + headerSize
82
+ local cursor = ch.cursor
83
+ local payloadLen = cursor - afterTs
84
+ ensure(ch, 2)
85
+ -- Re-bind: ensure() may have realloced the buffer.
86
+ local b = ch.buff
87
+ bufCopy(b, afterTs + 2, b, afterTs, payloadLen)
88
+
89
+ ch.countPos = afterTs
90
+ writeu16(b, afterTs, 0)
91
+ ch.cursor = cursor + 2
92
+ ch.singleMode = false
93
+ end
94
+
95
+ -- itemCount bump is left to the caller, AFTER the body write succeeds.
96
+ local function openOrUpgrade(ch: Types.ChannelState, reg: Types.Registration): ()
97
+ if reg.id ~= ch.lastId or ch.itemCount >= MAX_BATCH_ITEMS then
98
+ reg._openFn(ch, reg.id)
99
+ elseif ch.singleMode then
100
+ upgradeSingleToMulti(ch, reg.id, reg.headerSize)
101
+ end
102
+ end
103
+
104
+ local function spliceEncoded(ch: Types.ChannelState, payload: buffer, payloadLen: number): ()
105
+ local cursor = ch.cursor
106
+ if cursor + payloadLen > ch.size then
107
+ alloc(ch, payloadLen)
108
+ end
109
+ bufCopy(ch.buff, cursor, payload, 0, payloadLen)
110
+ ch.cursor = cursor + payloadLen
111
+ end
112
+
113
+ --[[
114
+ Hot-swapped by enableStats() so the per-item dispatch avoids a runtime
115
+ branch on the stats flag. itemCount bumps AFTER codec.write so a
116
+ throwing codec leaves the count consistent with the buffer.
117
+ ]]
118
+ local writeBatchPlain: (Types.ChannelState, Types.Registration, any) -> ()
119
+ local writeBatchStats: (Types.ChannelState, Types.Registration, any) -> ()
120
+
121
+ writeBatchPlain = function(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
122
+ openOrUpgrade(ch, reg)
123
+ reg.codec.write(ch, data)
124
+ ch.itemCount += 1
125
+ end
126
+
127
+ writeBatchStats = function(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
128
+ openOrUpgrade(ch, reg)
129
+ local before = ch.cursor
130
+ reg.codec.write(ch, data)
131
+ ch.itemCount += 1
132
+ reg.bytesSent += ch.cursor - before
133
+ end
134
+
135
+ --[[
136
+ Splice a pre-encoded payload into the channel. Used by Packet.broadcast
137
+ to encode the codec output once per fanout instead of N times.
138
+ ]]
139
+ local writeBatchEncodedPlain: (Types.ChannelState, Types.Registration, buffer, number) -> ()
140
+ local writeBatchEncodedStats: (Types.ChannelState, Types.Registration, buffer, number) -> ()
141
+
142
+ writeBatchEncodedPlain = function(
143
+ ch: Types.ChannelState,
144
+ reg: Types.Registration,
145
+ payload: buffer,
146
+ payloadLen: number
147
+ ): ()
148
+ openOrUpgrade(ch, reg)
149
+ spliceEncoded(ch, payload, payloadLen)
150
+ ch.itemCount += 1
151
+ end
152
+
153
+ writeBatchEncodedStats = function(
154
+ ch: Types.ChannelState,
155
+ reg: Types.Registration,
156
+ payload: buffer,
157
+ payloadLen: number
158
+ ): ()
159
+ openOrUpgrade(ch, reg)
160
+ spliceEncoded(ch, payload, payloadLen)
161
+ ch.itemCount += 1
162
+ reg.bytesSent += payloadLen
163
+ end
164
+
89
165
  -- Public -----------------------------------------------------------------
90
166
 
167
+ -- Module table is intentionally unfrozen: enableStats() hot-swaps writeBatch.
91
168
  local Channel = {}
92
169
 
93
170
  Channel.TS_NONE = TS_NONE
@@ -114,36 +191,19 @@ function Channel.create(): Types.ChannelState
114
191
  }
115
192
  end
116
193
 
117
- function Channel.alloc(ch: Types.ChannelState, bytes: number): ()
118
- if bytes > 0 then
119
- alloc(ch, bytes)
120
- end
121
- end
122
-
123
- --[[
124
- Write a single byte at the cursor and advance. Centralizes the
125
- ubiquitous `if cursor + 1 > size then alloc(ch, 1) end; writeu8(...);
126
- cursor += 1` pattern.
127
- ]]
128
194
  function Channel.writeByte(ch: Types.ChannelState, byte: number): ()
129
195
  local cursor = ch.cursor
130
- if cursor + 1 > ch.size then
131
- alloc(ch, 1)
196
+ if cursor + HEADER_BYTE > ch.size then
197
+ alloc(ch, HEADER_BYTE)
132
198
  end
133
199
  writeu8(ch.buff, cursor, byte)
134
- ch.cursor = cursor + 1
200
+ ch.cursor = cursor + HEADER_BYTE
135
201
  end
136
202
 
137
- --[[
138
- Push `value` into the channel's sidecar refs array, write its
139
- 1-based index as a u16 to the buffer, advance cursor. Returns the
140
- index. Used by the Instance and Unknown codecs.
141
- ]]
142
- function Channel.pushRef(ch: Types.ChannelState, value: any): number
203
+ -- Append `value` to the sidecar refs, write its 1-based u16 index.
204
+ function Channel.pushRef(ch: Types.ChannelState, value: any): ()
143
205
  local idx = ch.refCount + 1
144
- if idx > 0xFFFF then
145
- error("[Lync] Channel.pushRef: ref overflow (>65535)")
146
- end
206
+ Log.assert(idx <= U16_MAX, "ref overflow, max 65535")
147
207
  ch.refs[idx] = value
148
208
  ch.refCount = idx
149
209
 
@@ -153,9 +213,12 @@ function Channel.pushRef(ch: Types.ChannelState, value: any): number
153
213
  end
154
214
  writeu16(ch.buff, cursor, idx)
155
215
  ch.cursor = cursor + 2
156
- return idx
157
216
  end
158
217
 
218
+ --[[
219
+ Per-flush reset. Preserves prevDump/prevDumpLen (XOR baseline) and
220
+ ch.deltas (delta caches): both must outlive flushes for the same owner.
221
+ ]]
159
222
  function Channel.reset(ch: Types.ChannelState): ()
160
223
  ch.cursor = 0
161
224
  ch.lastId = -1
@@ -170,20 +233,34 @@ function Channel.reset(ch: Types.ChannelState): ()
170
233
  end
171
234
  end
172
235
 
236
+ --[[
237
+ Full reset including XOR baseline and delta caches. Pool.acquire calls
238
+ this so a recycled state cannot leak the previous owner's baseline.
239
+ ]]
240
+ function Channel.fullReset(ch: Types.ChannelState): ()
241
+ Channel.reset(ch)
242
+ ch.prevDump = nil
243
+ ch.prevDumpLen = 0
244
+ table.clear(ch.deltas)
245
+ end
246
+
173
247
  --[[
174
248
  Resolve the batch-open closure for a given timestamp mode and packet
175
- name. The returned function:
176
- - seals the previous batch (writing the deferred multi-mode count),
177
- - writes the single-mode header [0x80 | id] + optional timestamp,
178
- - records cursor position so writeBatch can upgrade single->multi later.
249
+ name. Returned function seals the prior batch, writes the single-mode
250
+ header [0x80 | id] + optional timestamp, and records the cursor so
251
+ writeBatch can upgrade single -> multi later.
179
252
  ]]
180
253
  function Channel.resolveOpenFn(
181
254
  timestampMode: number,
182
255
  name: string
183
256
  ): (ch: Types.ChannelState, id: number) -> ()
184
- local headerLen = 1 + tsBytes(timestampMode)
257
+ local headerLen = HEADER_BYTE + TS_BYTES[timestampMode]
185
258
 
186
259
  return function(ch: Types.ChannelState, id: number): ()
260
+ --[[
261
+ Inline sealCount: this is the only call site that can take the
262
+ multi-mode branch on a non-fresh channel.
263
+ ]]
187
264
  if ch.lastId >= 0 and not ch.singleMode then
188
265
  writeu16(ch.buff, ch.countPos, ch.itemCount)
189
266
  end
@@ -197,7 +274,7 @@ function Channel.resolveOpenFn(
197
274
  alloc(ch, headerLen)
198
275
  end
199
276
  local b = ch.buff
200
- writeu8(b, cursor, bor(0x80, id))
277
+ writeu8(b, cursor, bor(FRAME_MSB, id))
201
278
  writeTimestamp(b, cursor + 1, timestampMode)
202
279
 
203
280
  ch.singleMode = true
@@ -206,95 +283,32 @@ function Channel.resolveOpenFn(
206
283
  end
207
284
  end
208
285
 
286
+ Channel.writeBatch = writeBatchPlain
287
+ Channel.writeBatchEncoded = writeBatchEncodedPlain
288
+
209
289
  --[[
210
- Write one item into the current batch. Handles single->multi mode
211
- upgrade when a second item lands on the same packet ID: clears the
212
- header byte's MSB, then memmoves payload right by 2 to insert the
213
- deferred u16 count between the timestamp and first payload.
290
+ Every caller (Server.flushReliable / flushUnreliable, Client.flush)
291
+ pre-checks `ch.cursor > 0`, so cursor here is always positive.
214
292
  ]]
215
- function Channel.writeBatch(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
216
- if reg.id ~= ch.lastId or ch.itemCount >= 0xFFFF then
217
- reg._openFn(ch, reg.id)
218
- elseif ch.singleMode then
219
- local headerPos = ch.singlePos
220
- local b = ch.buff
221
- writeu8(b, headerPos, reg.id)
222
-
223
- local afterTs = headerPos + reg.headerSize
224
- local cursor = ch.cursor
225
- local payloadLen = cursor - afterTs
226
- if cursor + 2 > ch.size then
227
- alloc(ch, 2)
228
- end
229
- local b2 = ch.buff
230
-
231
- if payloadLen > 0 then
232
- buffer.copy(b2, afterTs + 2, b2, afterTs, payloadLen)
233
- end
234
-
235
- ch.countPos = afterTs
236
- writeu16(b2, afterTs, 0)
237
- ch.cursor = cursor + 2
238
- ch.singleMode = false
239
- end
240
-
241
- ch.itemCount += 1
242
- reg.codec.write(ch, data)
243
- end
244
-
245
293
  function Channel.sealAndDump(ch: Types.ChannelState): (buffer, { Instance }, number, number)
246
- if ch.lastId >= 0 and not ch.singleMode and ch.countPos >= 0 then
294
+ if ch.lastId >= 0 and not ch.singleMode then
247
295
  writeu16(ch.buff, ch.countPos, ch.itemCount)
248
296
  end
249
297
 
250
298
  local cursor = ch.cursor
251
299
  local snapshot = buffer.create(cursor)
252
- if cursor > 0 then
253
- buffer.copy(snapshot, 0, ch.buff, 0, cursor)
254
- end
300
+ bufCopy(snapshot, 0, ch.buff, 0, cursor)
255
301
 
256
302
  local refCount = ch.refCount
257
303
  local refs = if refCount > 0 then table.clone(ch.refs) else EMPTY_REFS
258
304
  return snapshot, refs, cursor, refCount
259
305
  end
260
306
 
261
- --[[
262
- XOR `current` against `previous`, returning a fresh buffer of curLen
263
- bytes. When `previous` is nil, returns `current` unchanged.
264
- ]]
265
- function Channel.xorApply(
266
- current: buffer,
267
- curLen: number,
268
- previous: buffer?,
269
- prevLen: number
270
- ): buffer
271
- if not previous then
272
- return current
273
- end
274
-
275
- local result = buffer.create(curLen)
276
- local overlap = minN(curLen, prevLen)
277
- local prev = previous
278
-
279
- local aligned = band(overlap, -4)
280
- local i = 0
281
- while i < aligned do
282
- writeu32(result, i, bxor(readu32(current, i), readu32(prev, i)))
283
- i += 4
284
- end
285
- while i < overlap do
286
- writeu8(result, i, bxor(readu8(current, i), readu8(prev, i)))
287
- i += 1
288
- end
289
- if curLen > overlap then
290
- buffer.copy(result, overlap, current, overlap, curLen - overlap)
291
- end
292
- return result
293
- end
307
+ Channel.xorApply = Buf.xorApply
294
308
 
295
309
  --[[
296
- Write a query frame. MSB on the id byte signals nil response;
297
- cleared means a payload follows. Correlation IDs are varint-encoded.
310
+ Query frame. MSB on the id byte = nil response; cleared = payload follows.
311
+ Correlation IDs are varint-encoded.
298
312
  ]]
299
313
  function Channel.writeQuery(
300
314
  ch: Types.ChannelState,
@@ -303,14 +317,19 @@ function Channel.writeQuery(
303
317
  codec: Types.InternalCodec<any>?,
304
318
  data: any
305
319
  ): ()
306
- if ch.lastId >= 0 and not ch.singleMode and ch.countPos >= 0 then
320
+ if ch.lastId >= 0 and not ch.singleMode then
307
321
  writeu16(ch.buff, ch.countPos, ch.itemCount)
308
322
  end
323
+ --[[
324
+ lastId = -1 forces the next writeBatch to call _openFn (which resets
325
+ singleMode). Every other reader of singleMode (writeQuery,
326
+ sealAndDump) is gated by `lastId >= 0` first, so stamping singleMode
327
+ here would never be observed before the next reset overwrites it.
328
+ ]]
309
329
  ch.lastId = -1
310
- ch.singleMode = false
311
330
 
312
331
  local hasPayload = codec ~= nil and data ~= nil
313
- local headerByte = if hasPayload then id else bor(0x80, id)
332
+ local headerByte = if hasPayload then id else bor(FRAME_MSB, id)
314
333
 
315
334
  local cursor = ch.cursor
316
335
  if cursor + 1 > ch.size then
@@ -328,29 +347,44 @@ end
328
347
  -- Configuration ----------------------------------------------------------
329
348
 
330
349
  function Channel.setMaxSize(bytes: number): ()
331
- _maxSize = bytes
350
+ Base.setMaxSize(bytes)
332
351
  end
333
352
 
334
353
  function Channel.updateTimestamp(): ()
335
- _frameCounter = band(_frameCounter + 1, FRAME_MASK)
336
- _offsetMs = round((os.clock() % OFFSET_WIN) * 1000)
337
- _clock = os.clock()
354
+ _frameCounter = band(_frameCounter + 1, U8_MAX)
355
+ local now = osClock()
356
+ _offsetMs = round((now % OFFSET_WIN) * 1000)
357
+ _clock = now
338
358
  end
339
359
 
340
360
  function Channel.enableTimestamps(): ()
341
- _anyTimestamp = true
361
+ _timestampsEnabled = true
342
362
  end
343
363
 
344
364
  function Channel.hasTimestamps(): boolean
345
- return _anyTimestamp
365
+ return _timestampsEnabled
346
366
  end
347
367
 
348
368
  function Channel.enableStats(): ()
349
369
  _statsEnabled = true
370
+ Channel.writeBatch = writeBatchStats
371
+ Channel.writeBatchEncoded = writeBatchEncodedStats
350
372
  end
351
373
 
352
374
  function Channel.statsEnabled(): boolean
353
375
  return _statsEnabled
354
376
  end
355
377
 
356
- return table.freeze(Channel)
378
+ -- Module-level config + timestamp counters. Distinct from Channel.reset(ch).
379
+ function Channel.resetGlobals(): ()
380
+ Base.reset()
381
+ _timestampsEnabled = false
382
+ _statsEnabled = false
383
+ _frameCounter = 0
384
+ _offsetMs = 0
385
+ _clock = 0.0
386
+ Channel.writeBatch = writeBatchPlain
387
+ Channel.writeBatchEncoded = writeBatchEncodedPlain
388
+ end
389
+
390
+ return Channel
@@ -13,13 +13,21 @@ local _dropSignal = Signal.create()
13
13
 
14
14
  -- Private ----------------------------------------------------------------
15
15
 
16
+ -- Mirrors Signal's STATE_DISCONNECTED. Entries in this state are mid-fire disconnects.
17
+ local STATE_DISCONNECTED = 0
18
+
16
19
  --[[
17
- Walk a transform chain in order. Each callback returns a transformed
18
- value or nil to keep the previous value. A fresh snapshot guards
19
- against disconnect-during-fire reentry.
20
+ Snapshot before iterating: a handler can disconnect itself or others,
21
+ and a fresh chain pull would skip or double-fire entries depending on
22
+ swap direction. Snapshotting locks the per-frame view.
20
23
  ]]
21
- local function runChain(signal: any, value: any, name: string, player: Player?): any
22
- local count = signal._count[1]
24
+ local function runChain(
25
+ signal: { _count: number, _entries: { any } },
26
+ value: any,
27
+ name: string,
28
+ player: Player?
29
+ ): any
30
+ local count = signal._count
23
31
  if count == 0 then
24
32
  return value
25
33
  end
@@ -31,8 +39,9 @@ local function runChain(signal: any, value: any, name: string, player: Player?):
31
39
  local result = value
32
40
  for i = 1, count do
33
41
  local entry = snapshot[i]
34
- if entry._state ~= 0 then
42
+ if entry._state ~= STATE_DISCONNECTED then
35
43
  local returned = entry.fn(result, name, player)
44
+ -- Hooks return non-nil to replace the value; nil keeps the prior result.
36
45
  if returned ~= nil then
37
46
  result = returned
38
47
  end
@@ -73,6 +82,7 @@ function Middleware.fireDrop(player: Player, reason: string, name: string, data:
73
82
  _dropSignal:fireSync(player, reason, name, data)
74
83
  end
75
84
 
85
+ -- Hot-path callers gate on these to skip runChain's snapshot allocation.
76
86
  function Middleware.hasSendHooks(): boolean
77
87
  return _sendSignal:hasListeners()
78
88
  end
@@ -81,4 +91,10 @@ function Middleware.hasReceiveHooks(): boolean
81
91
  return _receiveSignal:hasListeners()
82
92
  end
83
93
 
94
+ function Middleware.reset(): ()
95
+ _sendSignal = Signal.create()
96
+ _receiveSignal = Signal.create()
97
+ _dropSignal = Signal.create()
98
+ end
99
+
84
100
  return table.freeze(Middleware)
@@ -1,9 +1,9 @@
1
1
  --!strict
2
2
  --!optimize 2
3
- -- Stack-based ChannelState pool. Reset-on-acquire keeps released
4
- -- channels reusable without per-release reset cost.
3
+ -- ChannelState pool. Acquire fullResets to wipe prior owner state.
5
4
 
6
5
  local Channel = require(script.Parent.Channel)
6
+ local Log = require(script.Parent.Parent.util.Log)
7
7
  local Types = require(script.Parent.Parent.Types)
8
8
 
9
9
  -- Constants --------------------------------------------------------------
@@ -20,17 +20,19 @@ local _maxSize = DEFAULT_SIZE
20
20
 
21
21
  local Pool = {}
22
22
 
23
+ -- fullReset wipes the prior owner's XOR baseline and delta caches.
23
24
  function Pool.acquire(): Types.ChannelState
24
25
  if _depth > 0 then
25
26
  local ch = _stack[_depth]
26
27
  _stack[_depth] = nil
27
28
  _depth -= 1
28
- Channel.reset(ch)
29
+ Channel.fullReset(ch)
29
30
  return ch
30
31
  end
31
32
  return Channel.create()
32
33
  end
33
34
 
35
+ -- Above the cap, drop on the floor; the GC reclaims the buffer.
34
36
  function Pool.release(ch: Types.ChannelState): ()
35
37
  if _depth >= _maxSize then
36
38
  return
@@ -41,7 +43,7 @@ end
41
43
 
42
44
  function Pool.setMaxSize(size: number): ()
43
45
  if size < 0 then
44
- error(`[Lync] Pool.setMaxSize: size must be >= 0, got {size}`)
46
+ Log.error(`size must be non-negative, got {size}`)
45
47
  end
46
48
  _maxSize = size
47
49
  while _depth > _maxSize do
@@ -54,4 +56,10 @@ function Pool.count(): number
54
56
  return _depth
55
57
  end
56
58
 
59
+ function Pool.reset(): ()
60
+ table.clear(_stack)
61
+ _depth = 0
62
+ _maxSize = DEFAULT_SIZE
63
+ end
64
+
57
65
  return table.freeze(Pool)
@@ -1,7 +1,8 @@
1
1
  --!strict
2
2
  --!optimize 2
3
- -- Deterministic packet/query ID assignment. Sequential 7-bit IDs.
3
+ -- Sequential 7-bit packet/query ID assignment.
4
4
 
5
+ local Log = require(script.Parent.Parent.util.Log)
5
6
  local Signal = require(script.Parent.Parent.api.Signal)
6
7
  local Types = require(script.Parent.Parent.Types)
7
8
 
@@ -10,13 +11,15 @@ local Types = require(script.Parent.Parent.Types)
10
11
  local KIND_PACKET = 0
11
12
  local KIND_REQUEST = 1
12
13
  local KIND_RESPONSE = 2
14
+
15
+ -- 7-bit id leaves the MSB free for the frame single/multi flag (see Constants.FRAME_MSB).
13
16
  local MAX_ID = 127
14
17
 
15
18
  -- State ------------------------------------------------------------------
16
19
 
17
20
  local _nextId = 0
18
- local _byId: { [number]: Types.Registration } = {}
19
- local _byName: { [string]: Types.Registration } = {}
21
+ local _registrationsById: { [number]: Types.Registration } = {}
22
+ local _registrationsByName: { [string]: Types.Registration } = {}
20
23
 
21
24
  -- Public -----------------------------------------------------------------
22
25
 
@@ -45,10 +48,14 @@ export type RegisterOptions = {
45
48
  function Registry.register(opts: RegisterOptions): Types.Registration
46
49
  local nextCandidate = _nextId + 1
47
50
  if nextCandidate > MAX_ID then
48
- error(`[Lync] Registry.register: ID limit exceeded ({MAX_ID})`)
51
+ Log.error(`ID limit ({MAX_ID}) exceeded; cannot register "{opts.name}"`)
49
52
  end
50
- if opts.kind ~= KIND_RESPONSE and _byName[opts.name] then
51
- error(`[Lync] Registry.register: duplicate name "{opts.name}"`)
53
+ --[[
54
+ RESPONSE entries share their request's name with a "\0resp" suffix; only
55
+ request/packet names need uniqueness in the by-name index.
56
+ ]]
57
+ if opts.kind ~= KIND_RESPONSE and _registrationsByName[opts.name] ~= nil then
58
+ Log.error(`duplicate name "{opts.name}"`)
52
59
  end
53
60
 
54
61
  _nextId = nextCandidate
@@ -75,36 +82,38 @@ function Registry.register(opts: RegisterOptions): Types.Registration
75
82
  drops = 0,
76
83
  }
77
84
 
78
- _byId[nextCandidate] = reg
85
+ _registrationsById[nextCandidate] = reg
79
86
  if opts.kind ~= KIND_RESPONSE then
80
- _byName[opts.name] = reg
87
+ _registrationsByName[opts.name] = reg
81
88
  end
82
89
  return reg
83
90
  end
84
91
 
85
92
  function Registry.get(id: number): Types.Registration?
86
- return _byId[id]
93
+ return _registrationsById[id]
87
94
  end
88
95
 
89
96
  function Registry.getByName(name: string): Types.Registration?
90
- return _byName[name]
97
+ return _registrationsByName[name]
91
98
  end
92
99
 
93
100
  function Registry.count(): number
94
101
  return _nextId
95
102
  end
96
103
 
104
+ -- IDs are sequential and never freed, so the table has no holes.
97
105
  function Registry.all(): { Types.Registration }
98
106
  local result = table.create(_nextId)
99
- local n = 0
100
107
  for i = 1, _nextId do
101
- local reg = _byId[i]
102
- if reg then
103
- n += 1
104
- result[n] = reg
105
- end
108
+ result[i] = _registrationsById[i]
106
109
  end
107
110
  return result
108
111
  end
109
112
 
113
+ function Registry.reset(): ()
114
+ table.clear(_registrationsById)
115
+ table.clear(_registrationsByName)
116
+ _nextId = 0
117
+ end
118
+
110
119
  return table.freeze(Registry)
@@ -1,6 +1,6 @@
1
1
  --!strict
2
2
  --!optimize 2
3
- -- Lazy-require Server/Client to break the api -> transport import cycle.
3
+ -- Lazy Server/Client require to break the api -> transport import cycle.
4
4
 
5
5
  -- State ------------------------------------------------------------------
6
6