@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
@@ -1,10 +1,12 @@
1
1
  --!strict
2
- --!optimize 2
3
- -- Deserializes incoming buffers with MSB batch dispatch.
2
+ --!native
3
+ -- Inbound buffer parser. Returns false on malformed wire so the transport can drop its XOR baseline.
4
4
 
5
5
  local Baseline = require(script.Parent.Parent.internal.Baseline)
6
6
  local Channel = require(script.Parent.Parent.internal.Channel)
7
+ local Constants = require(script.Parent.Parent.util.Constants)
7
8
  local Gate = require(script.Parent.Parent.transport.Gate)
9
+ local Log = require(script.Parent.Parent.util.Log)
8
10
  local Middleware = require(script.Parent.Parent.internal.Middleware)
9
11
  local Registry = require(script.Parent.Parent.internal.Registry)
10
12
  local Types = require(script.Parent.Parent.Types)
@@ -20,12 +22,22 @@ local TS_FRAME = Channel.TS_FRAME
20
22
  local TS_OFFSET = Channel.TS_OFFSET
21
23
  local TS_FULL = Channel.TS_FULL
22
24
 
23
- local MAX_ITEMS_PER_FRAME = 0xFFFF
25
+ local FRAME_MSB = Constants.FRAME_MSB
26
+ local FRAME_ID_MASK = Constants.FRAME_ID_MASK
24
27
 
25
28
  -- Private ----------------------------------------------------------------
26
29
 
27
30
  local band = bit32.band
28
31
  local readu8 = buffer.readu8
32
+ local readu16 = buffer.readu16
33
+ local readf64 = buffer.readf64
34
+ local registryGet = Registry.get
35
+ local checkGlobal = Gate.checkGlobalRateLimit
36
+ local checkRate = Gate.checkRateLimit
37
+ local validateGate = Gate.validate
38
+ local fireDrop = Middleware.fireDrop
39
+ local runReceive = Middleware.runReceive
40
+ local varintRead = Varint.read
29
41
 
30
42
  local function recordDrop(
31
43
  reg: Types.Registration,
@@ -38,25 +50,60 @@ local function recordDrop(
38
50
  reg.drops += 1
39
51
  end
40
52
  if player then
41
- Middleware.fireDrop(player, reason, reg.name, value)
53
+ fireDrop(player, reason, reg.name, value)
42
54
  end
43
55
  end
44
56
 
57
+ --[[
58
+ Server gate pipeline. Returns true to deliver, false on rate-limit,
59
+ schema-validate, or custom-validate rejection. `runGlobal` lets the
60
+ response path skip the global per-player limit.
61
+ ]]
62
+ local function gateValue(
63
+ reg: Types.Registration,
64
+ regCodec: Types.InternalCodec<any>,
65
+ value: any,
66
+ player: Player,
67
+ statsEnabled: boolean,
68
+ runGlobal: boolean
69
+ ): boolean
70
+ -- Both global and per-packet drops use the "rate" reason; collapsed into one branch.
71
+ if (runGlobal and not checkGlobal(player)) or not checkRate(reg, player) then
72
+ recordDrop(reg, statsEnabled, player, "rate", value)
73
+ return false
74
+ end
75
+
76
+ local validateReason = validateGate(value, regCodec)
77
+ if validateReason then
78
+ recordDrop(reg, statsEnabled, player, `validation: {validateReason}`, value)
79
+ return false
80
+ end
81
+
82
+ if reg.validate then
83
+ local valid, validReason = reg.validate(value, player)
84
+ if not valid then
85
+ recordDrop(
86
+ reg,
87
+ statsEnabled,
88
+ player,
89
+ if validReason then `validate: {validReason}` else "validate",
90
+ value
91
+ )
92
+ return false
93
+ end
94
+ end
95
+ return true
96
+ end
97
+
45
98
  -- Public -----------------------------------------------------------------
46
99
 
47
100
  local Reader = {}
48
101
 
49
102
  --[[
50
- Sanity-check an inbound RemoteEvent payload. The size cap MUST NOT be
51
- tied to the local `Channel.maxSize()` sender and receiver run in
52
- separate Lua VMs with independent module state, so the receiver may
53
- not have called Lync.configure with the same channelMaxSize. Coupling
54
- them caused server frames larger than the receiver's local default to
55
- be silently dropped, which permanently desynced the XOR baseline.
56
-
57
- Roblox enforces a per-RemoteEvent payload ceiling around 1MB; if the
58
- payload reached us at all, it's already within Roblox's limit. Trust
59
- that and accept anything non-empty.
103
+ Sanity-check an inbound payload. Size cap is delegated to Roblox's ~1MB
104
+ RemoteEvent ceiling: the receiver may not have called Lync.configure
105
+ with the same channelMaxSize as the sender (independent VMs, independent
106
+ module state), so a local cap here would reject legitimate frames.
60
107
  ]]
61
108
  function Reader.decodeIncoming(data: any): buffer?
62
109
  if typeof(data) ~= "buffer" then
@@ -69,13 +116,19 @@ function Reader.decodeIncoming(data: any): buffer?
69
116
  return b
70
117
  end
71
118
 
119
+ --[[
120
+ Returns true on full success, false if a malformed frame aborted the
121
+ parse. Transport callers use this to decide whether to keep the per-peer
122
+ XOR baseline: false means the buffer is suspect and would permanently
123
+ desync the channel if reused as the next XOR mask.
124
+ ]]
72
125
  function Reader.process(
73
126
  incoming: buffer,
74
127
  incomingLen: number,
75
128
  refs: { Instance }?,
76
129
  player: Player?,
77
130
  isServer: boolean
78
- ): ()
131
+ ): boolean
79
132
  if Baseline.hasDelta() then
80
133
  Baseline.setReadKey(player or false)
81
134
  end
@@ -89,115 +142,121 @@ function Reader.process(
89
142
  local raw = readu8(incoming, pos)
90
143
  pos += 1
91
144
 
92
- local msb = band(raw, 0x80) ~= 0
93
- local id = band(raw, 0x7F)
145
+ local msb = band(raw, FRAME_MSB) ~= 0
146
+ local id = band(raw, FRAME_ID_MASK)
94
147
 
95
- local reg = Registry.get(id)
148
+ local reg = registryGet(id)
96
149
  if not reg then
97
- warn(`[Lync] Reader.process: unknown packet ID {id} at byte {pos - 1}`)
98
- return
150
+ Log.warn(`unknown packet ID {id} at byte {pos - 1}`)
151
+ return false
99
152
  end
100
153
 
101
- if reg.kind == KIND_PACKET then
154
+ local kind = reg.kind
155
+ if kind == KIND_PACKET then
102
156
  local count: number
103
157
  if msb then
104
158
  count = 1
105
159
  else
106
160
  if pos + 2 > incomingLen then
107
- warn("[Lync] Reader.process: truncated multi-frame header")
108
- return
161
+ Log.warn("truncated multi-frame header")
162
+ return false
109
163
  end
110
- count = buffer.readu16(incoming, pos)
164
+ count = readu16(incoming, pos)
111
165
  pos += 2
112
166
  end
113
167
 
114
168
  local tsMode = reg.timestampMode
115
169
  local tsValue: number? = nil
116
170
  if tsMode == TS_FRAME then
171
+ if pos + 1 > incomingLen then
172
+ Log.warn("truncated timestamp")
173
+ return false
174
+ end
117
175
  tsValue = readu8(incoming, pos)
118
176
  pos += 1
119
177
  elseif tsMode == TS_OFFSET then
120
- tsValue = buffer.readu16(incoming, pos)
178
+ if pos + 2 > incomingLen then
179
+ Log.warn("truncated timestamp")
180
+ return false
181
+ end
182
+ tsValue = readu16(incoming, pos)
121
183
  pos += 2
122
184
  elseif tsMode == TS_FULL then
123
- tsValue = buffer.readf64(incoming, pos)
185
+ if pos + 8 > incomingLen then
186
+ Log.warn("truncated timestamp")
187
+ return false
188
+ end
189
+ tsValue = readf64(incoming, pos)
124
190
  pos += 8
125
191
  end
126
192
 
127
- if count > MAX_ITEMS_PER_FRAME then
128
- count = MAX_ITEMS_PER_FRAME
129
- end
130
- local sizeMeta = reg.codec._size
131
- if sizeMeta then
132
- local maxBySize = (incomingLen - pos) // sizeMeta
133
- if count > maxBySize then
134
- count = maxBySize
135
- end
193
+ --[[
194
+ Fixed-size codec: a count exceeding remaining bytes is
195
+ unambiguously corrupt. Abort to keep the cursor on a frame
196
+ boundary so the transport can reset cleanly.
197
+ ]]
198
+ local regCodec = reg.codec
199
+ local sizeMeta = regCodec._size
200
+ if sizeMeta and count * sizeMeta > incomingLen - pos then
201
+ Log.warn(`"{reg.name}" count {count} exceeds remaining bytes; aborting`)
202
+ return false
136
203
  end
137
204
 
138
205
  local maxPayload = reg.maxPayloadBytes
139
206
  local payloadStart = pos
207
+ local doGate = isServer and reg.needsGate and player ~= nil
208
+ local regSignal = reg.signal
209
+ local regName = reg.name
140
210
 
141
211
  for _ = 1, count do
142
- local value, consumed = reg.codec.read(incoming, pos, refs)
212
+ if pos >= incomingLen then
213
+ Log.warn(`codec for "{regName}" ran past frame end; aborting`)
214
+ return false
215
+ end
216
+ local value, consumed = regCodec.read(incoming, pos, refs)
143
217
  if consumed == 0 then
144
- warn(
145
- `[Lync] Reader.process: codec for "{reg.name}" reported 0 bytes; aborting frame`
146
- )
147
- return
218
+ Log.warn(`codec for "{regName}" reported 0 bytes; aborting`)
219
+ return false
148
220
  end
149
221
  pos += consumed
222
+ if pos > incomingLen then
223
+ Log.warn(`codec for "{regName}" overran frame end; aborting`)
224
+ return false
225
+ end
150
226
 
151
227
  if maxPayload and (pos - payloadStart) > maxPayload then
152
228
  recordDrop(reg, statsEnabled, player, "maxPayloadBytes", value)
153
- return
229
+ return false
154
230
  end
155
231
 
156
- if isServer and reg.needsGate then
157
- if player and not Gate.checkGlobalRateLimit(player) then
158
- recordDrop(reg, statsEnabled, player, "rate", value)
159
- continue
160
- end
161
- if player and not Gate.checkRateLimit(reg, player) then
162
- recordDrop(reg, statsEnabled, player, "rate", value)
163
- continue
164
- end
165
-
166
- local ok = Gate.validate(value, reg.codec)
167
- if not ok then
168
- recordDrop(reg, statsEnabled, player, "validation", value)
169
- continue
170
- end
171
-
172
- if reg.validate and player then
173
- local valid, validReason = reg.validate(value, player)
174
- if not valid then
175
- recordDrop(reg, statsEnabled, player, validReason or "validate", value)
176
- continue
177
- end
178
- end
232
+ if
233
+ doGate
234
+ and not gateValue(reg, regCodec, value, player :: Player, statsEnabled, true)
235
+ then
236
+ continue
179
237
  end
180
238
 
181
239
  if hasRecvHooks then
182
- value = Middleware.runReceive(value, reg.name, player)
240
+ value = runReceive(value, regName, player)
183
241
  end
184
242
 
185
243
  if statsEnabled then
186
244
  reg.recvFires += 1
187
245
  end
188
246
 
189
- if tsValue ~= nil then
190
- reg.signal:fire(value, player, tsValue)
191
- else
192
- reg.signal:fire(value, player)
193
- end
247
+ -- tsValue is nil when mode == TS_NONE; pass through to skip a per-item branch.
248
+ regSignal:fire(value, player, tsValue)
194
249
  end
195
250
 
196
251
  if statsEnabled then
197
252
  reg.bytesReceived += (pos - frameStart)
198
253
  end
199
- elseif reg.kind == KIND_REQUEST or reg.kind == KIND_RESPONSE then
200
- local corrId, corrBytes = Varint.read(incoming, pos)
254
+ elseif kind == KIND_REQUEST or kind == KIND_RESPONSE then
255
+ local corrId, corrBytes = varintRead(incoming, pos)
256
+ if corrBytes == 0 then
257
+ Log.warn(`truncated correlation id for "{reg.name}"; aborting`)
258
+ return false
259
+ end
201
260
  pos += corrBytes
202
261
 
203
262
  if msb then
@@ -207,47 +266,41 @@ function Reader.process(
207
266
  end
208
267
  reg.signal:fire(nil, player, corrId)
209
268
  else
210
- local value, consumed = reg.codec.read(incoming, pos, refs)
269
+ if pos >= incomingLen then
270
+ Log.warn(`query "{reg.name}" body missing; aborting`)
271
+ return false
272
+ end
273
+ local regCodec = reg.codec
274
+ local value, consumed = regCodec.read(incoming, pos, refs)
211
275
  if consumed == 0 then
212
- warn(
213
- `[Lync] Reader.process: query codec for "{reg.name}" reported 0 bytes; aborting frame`
214
- )
215
- return
276
+ Log.warn(`query codec for "{reg.name}" reported 0 bytes; aborting`)
277
+ return false
216
278
  end
217
279
  pos += consumed
280
+ if pos > incomingLen then
281
+ Log.warn(`query "{reg.name}" overran frame end; aborting`)
282
+ return false
283
+ end
218
284
 
219
- if isServer and reg.needsGate and reg.kind == KIND_REQUEST then
220
- if player and not Gate.checkRateLimit(reg, player) then
221
- recordDrop(reg, statsEnabled, player, "rate", value)
222
- if statsEnabled then
223
- reg.bytesReceived += (pos - frameStart)
224
- end
225
- continue
226
- end
227
-
228
- local ok = Gate.validate(value, reg.codec)
229
- if not ok then
230
- recordDrop(reg, statsEnabled, player, "validation", value)
231
- if statsEnabled then
232
- reg.bytesReceived += (pos - frameStart)
233
- end
234
- continue
235
- end
236
-
237
- if reg.validate and player then
238
- local valid, validReason = reg.validate(value, player)
239
- if not valid then
240
- recordDrop(reg, statsEnabled, player, validReason or "validate", value)
241
- if statsEnabled then
242
- reg.bytesReceived += (pos - frameStart)
243
- end
244
- continue
245
- end
285
+ --[[
286
+ Server gates REQUEST and RESPONSE frames opted into
287
+ needsGate so a malicious client can't bypass via the
288
+ response channel.
289
+ ]]
290
+ if
291
+ isServer
292
+ and reg.needsGate
293
+ and player
294
+ and not gateValue(reg, regCodec, value, player, statsEnabled, false)
295
+ then
296
+ if statsEnabled then
297
+ reg.bytesReceived += (pos - frameStart)
246
298
  end
299
+ continue
247
300
  end
248
301
 
249
302
  if hasRecvHooks then
250
- value = Middleware.runReceive(value, reg.name, player)
303
+ value = runReceive(value, reg.name, player)
251
304
  end
252
305
 
253
306
  if statsEnabled then
@@ -258,6 +311,10 @@ function Reader.process(
258
311
  end
259
312
  end
260
313
  end
314
+ return true
261
315
  end
262
316
 
317
+ Reader.setSilent = Log.setSilent
318
+ Reader.reset = Log.reset
319
+
263
320
  return table.freeze(Reader)
@@ -1,7 +1,6 @@
1
1
  --!strict
2
2
  --!optimize 2
3
- -- Server-side transport: per-player channels, broadcast, flush.
4
- -- Decodes inbound XOR delta against a per-player baseline.
3
+ -- Server transport: per-player channels, broadcast, flush.
5
4
 
6
5
  local Players = game:GetService("Players")
7
6
  local ReplicatedStorage = game:GetService("ReplicatedStorage")
@@ -26,10 +25,15 @@ local _playerStats: { [Player]: Types.PlayerStats } = {}
26
25
  local _prevIncoming: { [Player]: buffer } = {}
27
26
  local _prevIncomingLen: { [Player]: number } = {}
28
27
 
29
- local _statsEnabled = false
30
-
31
28
  -- Private ----------------------------------------------------------------
32
29
 
30
+ local statsEnabled = Channel.statsEnabled
31
+ local xorApply = Channel.xorApply
32
+ local sealAndDump = Channel.sealAndDump
33
+ local resetCh = Channel.reset
34
+
35
+ type StatsField = "bytesSent" | "bytesReceived"
36
+
33
37
  local function getOrCreateChannel(
34
38
  map: { [Player]: Types.ChannelState },
35
39
  player: Player
@@ -42,26 +46,30 @@ local function getOrCreateChannel(
42
46
  return ch
43
47
  end
44
48
 
49
+ -- Skip the table read when stats are off; the hot path needs no branch.
50
+ local function addStat(player: Player, field: StatsField, byteLen: number): ()
51
+ if statsEnabled() then
52
+ local ps = _playerStats[player]
53
+ if ps then
54
+ ps[field] += byteLen
55
+ end
56
+ end
57
+ end
58
+
45
59
  local function flushReliable(player: Player): ()
46
60
  local rCh = _reliableChannels[player]
47
61
  if not rCh or rCh.cursor == 0 then
48
62
  return
49
63
  end
50
64
 
51
- local snapshot, refs, byteLen = Channel.sealAndDump(rCh)
52
- local xored = Channel.xorApply(snapshot, byteLen, rCh.prevDump, rCh.prevDumpLen)
65
+ local snapshot, refs, byteLen = sealAndDump(rCh)
66
+ local xored = xorApply(snapshot, byteLen, rCh.prevDump, rCh.prevDumpLen)
53
67
  rCh.prevDump = snapshot
54
68
  rCh.prevDumpLen = byteLen
55
69
 
56
70
  Bridge.fireClient(player, xored, refs)
57
- Channel.reset(rCh)
58
-
59
- if _statsEnabled then
60
- local ps = _playerStats[player]
61
- if ps then
62
- ps.bytesSent += byteLen
63
- end
64
- end
71
+ resetCh(rCh)
72
+ addStat(player, "bytesSent", byteLen)
65
73
  end
66
74
 
67
75
  local function flushUnreliable(player: Player): ()
@@ -70,16 +78,10 @@ local function flushUnreliable(player: Player): ()
70
78
  return
71
79
  end
72
80
 
73
- local snapshot, refs, byteLen = Channel.sealAndDump(uCh)
81
+ local snapshot, refs, byteLen = sealAndDump(uCh)
74
82
  Bridge.fireClientUnreliable(player, snapshot, refs)
75
- Channel.reset(uCh)
76
-
77
- if _statsEnabled then
78
- local ps = _playerStats[player]
79
- if ps then
80
- ps.bytesSent += byteLen
81
- end
82
- end
83
+ resetCh(uCh)
84
+ addStat(player, "bytesSent", byteLen)
83
85
  end
84
86
 
85
87
  local function flushPlayer(player: Player): ()
@@ -99,23 +101,22 @@ local function handleIncomingReliable(player: Player, data: any, refs: any?): ()
99
101
  end
100
102
 
101
103
  local prev = _prevIncoming[player]
102
- local decoded = if prev
103
- then Channel.xorApply(incoming, incomingLen, prev, _prevIncomingLen[player])
104
- else incoming
105
- _prevIncoming[player] = decoded
106
- _prevIncomingLen[player] = incomingLen
107
-
108
- local ok, err = pcall(Reader.process, decoded, incomingLen, refs, player, true)
109
- if not ok then
110
- warn(`[Lync] Server.receive: {player.Name}: {err}`)
111
- end
112
-
113
- if _statsEnabled then
114
- local ps = _playerStats[player]
115
- if ps then
116
- ps.bytesReceived += incomingLen
117
- end
118
- end
104
+ local decoded = xorApply(incoming, incomingLen, prev, _prevIncomingLen[player])
105
+
106
+ --[[
107
+ Only adopt `decoded` as the next XOR baseline on success. A failed
108
+ parse means the wire is suspect; reusing it would poison every
109
+ subsequent packet.
110
+ ]]
111
+ if Reader.process(decoded, incomingLen, refs, player, true) then
112
+ _prevIncoming[player] = decoded
113
+ _prevIncomingLen[player] = incomingLen
114
+ else
115
+ _prevIncoming[player] = nil
116
+ _prevIncomingLen[player] = nil
117
+ end
118
+
119
+ addStat(player, "bytesReceived", incomingLen)
119
120
  end
120
121
 
121
122
  local function handleIncomingUnreliable(player: Player, data: any, refs: any?): ()
@@ -125,17 +126,8 @@ local function handleIncomingUnreliable(player: Player, data: any, refs: any?):
125
126
  end
126
127
 
127
128
  local incomingLen = buffer.len(incoming)
128
- local ok, err = pcall(Reader.process, incoming, incomingLen, refs, player, true)
129
- if not ok then
130
- warn(`[Lync] Server.receiveUnreliable: {player.Name}: {err}`)
131
- end
132
-
133
- if _statsEnabled then
134
- local ps = _playerStats[player]
135
- if ps then
136
- ps.bytesReceived += incomingLen
137
- end
138
- end
129
+ Reader.process(incoming, incomingLen, refs, player, true)
130
+ addStat(player, "bytesReceived", incomingLen)
139
131
  end
140
132
 
141
133
  local function clearPlayerState(player: Player): ()
@@ -191,23 +183,20 @@ function Server.start(): ()
191
183
  Bridge.unreliable().OnServerEvent:Connect(handleIncomingUnreliable)
192
184
 
193
185
  Players.PlayerAdded:Connect(function(player: Player)
194
- if _statsEnabled then
186
+ if statsEnabled() then
195
187
  _playerStats[player] = { bytesSent = 0, bytesReceived = 0 }
196
188
  end
197
189
  end)
198
190
  Players.PlayerRemoving:Connect(clearPlayerState)
199
191
 
200
- if _statsEnabled then
192
+ -- Catch players who joined before Lync.start.
193
+ if statsEnabled() then
201
194
  for _, player in Players:GetPlayers() do
202
195
  _playerStats[player] = { bytesSent = 0, bytesReceived = 0 }
203
196
  end
204
197
  end
205
198
  end
206
199
 
207
- function Server.enableStats(): ()
208
- _statsEnabled = true
209
- end
210
-
211
200
  function Server.getPlayerStats(player: Player): Types.PlayerStats?
212
201
  return _playerStats[player]
213
202
  end
@@ -0,0 +1,18 @@
1
+ --!strict
2
+ --!optimize 2
3
+ -- O(1) array swap-remove. Breaks insertion order.
4
+
5
+ -- Public -----------------------------------------------------------------
6
+
7
+ local Array = {}
8
+
9
+ -- Reorders. Caller-tracked counts must decrement separately.
10
+ function Array.swapRemove<T>(arr: { T }, idx: number): ()
11
+ local last = #arr
12
+ if idx ~= last then
13
+ arr[idx] = arr[last]
14
+ end
15
+ arr[last] = nil
16
+ end
17
+
18
+ return table.freeze(Array)
@@ -0,0 +1,92 @@
1
+ --!strict
2
+ --!native
3
+ -- Word-aligned XOR and equality helpers for Channel and composite deltas.
4
+
5
+ -- Private ----------------------------------------------------------------
6
+
7
+ local band = bit32.band
8
+ local bxor = bit32.bxor
9
+ local readu8 = buffer.readu8
10
+ local readu32 = buffer.readu32
11
+ local writeu8 = buffer.writeu8
12
+ local writeu32 = buffer.writeu32
13
+ local bufCopy = buffer.copy
14
+ local bufCreate = buffer.create
15
+ local minN = math.min
16
+
17
+ -- Public -----------------------------------------------------------------
18
+
19
+ local Buf = {}
20
+
21
+ -- 8-byte unrolled compare; 4-byte step then byte tail for remainder.
22
+ function Buf.rangeEqual(a: buffer, offA: number, b: buffer, offB: number, len: number): boolean
23
+ local aligned8 = band(len, -8)
24
+ local i = 0
25
+ while i < aligned8 do
26
+ if
27
+ readu32(a, offA + i) ~= readu32(b, offB + i)
28
+ or readu32(a, offA + i + 4) ~= readu32(b, offB + i + 4)
29
+ then
30
+ return false
31
+ end
32
+ i += 8
33
+ end
34
+ if i + 4 <= len then
35
+ if readu32(a, offA + i) ~= readu32(b, offB + i) then
36
+ return false
37
+ end
38
+ i += 4
39
+ end
40
+ while i < len do
41
+ if readu8(a, offA + i) ~= readu8(b, offB + i) then
42
+ return false
43
+ end
44
+ i += 1
45
+ end
46
+ return true
47
+ end
48
+
49
+ function Buf.snapshot(src: buffer, len: number): buffer
50
+ local out = bufCreate(len)
51
+ if len > 0 then
52
+ bufCopy(out, 0, src, 0, len)
53
+ end
54
+ return out
55
+ end
56
+
57
+ --[[
58
+ XOR `current` against `previous` into a fresh curLen buffer. Returns
59
+ `current` unchanged when previous is nil so first-frame callers don't
60
+ branch. Overlap is 8-byte unrolled; tail is bulk-copied.
61
+ ]]
62
+ function Buf.xorApply(current: buffer, curLen: number, previous: buffer?, prevLen: number?): buffer
63
+ if not previous then
64
+ return current
65
+ end
66
+ local pLen = prevLen or 0
67
+
68
+ local result = bufCreate(curLen)
69
+ local overlap = minN(curLen, pLen)
70
+
71
+ local aligned8 = band(overlap, -8)
72
+ local i = 0
73
+ while i < aligned8 do
74
+ writeu32(result, i, bxor(readu32(current, i), readu32(previous, i)))
75
+ writeu32(result, i + 4, bxor(readu32(current, i + 4), readu32(previous, i + 4)))
76
+ i += 8
77
+ end
78
+ if i + 4 <= overlap then
79
+ writeu32(result, i, bxor(readu32(current, i), readu32(previous, i)))
80
+ i += 4
81
+ end
82
+ while i < overlap do
83
+ writeu8(result, i, bxor(readu8(current, i), readu8(previous, i)))
84
+ i += 1
85
+ end
86
+ if curLen > overlap then
87
+ bufCopy(result, overlap, current, overlap, curLen - overlap)
88
+ end
89
+ return result
90
+ end
91
+
92
+ return table.freeze(Buf)