@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
@@ -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,130 @@ 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
210
+ -- Lync.nothing reads zero bytes; bypass the pos/consumed gates that assume forward progress.
211
+ local isZeroSize = sizeMeta == 0
140
212
 
141
213
  for _ = 1, count do
142
- local value, consumed = reg.codec.read(incoming, pos, refs)
143
- if consumed == 0 then
144
- warn(
145
- `[Lync] Reader.process: codec for "{reg.name}" reported 0 bytes; aborting frame`
214
+ if not isZeroSize and pos >= incomingLen then
215
+ Log.warn(`codec for "{regName}" ran past frame end; aborting`)
216
+ return false
217
+ end
218
+ local value, consumed = regCodec.read(incoming, pos, refs)
219
+ if not isZeroSize and consumed == 0 then
220
+ Log.warn(`codec for "{regName}" reported 0 bytes; aborting`)
221
+ return false
222
+ end
223
+ -- Fixed-size codecs MUST report consumed == _size or `pos` desyncs silently.
224
+ if sizeMeta and consumed ~= sizeMeta then
225
+ Log.warn(
226
+ `codec for "{regName}" consumed {consumed} bytes, expected {sizeMeta}; aborting`
146
227
  )
147
- return
228
+ return false
148
229
  end
149
230
  pos += consumed
231
+ if pos > incomingLen then
232
+ Log.warn(`codec for "{regName}" overran frame end; aborting`)
233
+ return false
234
+ end
150
235
 
151
236
  if maxPayload and (pos - payloadStart) > maxPayload then
152
237
  recordDrop(reg, statsEnabled, player, "maxPayloadBytes", value)
153
- return
238
+ return false
154
239
  end
155
240
 
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
241
+ if
242
+ doGate
243
+ and not gateValue(reg, regCodec, value, player :: Player, statsEnabled, true)
244
+ then
245
+ continue
179
246
  end
180
247
 
181
248
  if hasRecvHooks then
182
- value = Middleware.runReceive(value, reg.name, player)
249
+ value = runReceive(value, regName, player)
183
250
  end
184
251
 
185
252
  if statsEnabled then
186
253
  reg.recvFires += 1
187
254
  end
188
255
 
189
- if tsValue ~= nil then
190
- reg.signal:fire(value, player, tsValue)
191
- else
192
- reg.signal:fire(value, player)
193
- end
256
+ -- tsValue is nil when mode == TS_NONE; pass through to skip a per-item branch.
257
+ regSignal:fire(value, player, tsValue)
194
258
  end
195
259
 
196
260
  if statsEnabled then
197
261
  reg.bytesReceived += (pos - frameStart)
198
262
  end
199
- elseif reg.kind == KIND_REQUEST or reg.kind == KIND_RESPONSE then
200
- local corrId, corrBytes = Varint.read(incoming, pos)
263
+ elseif kind == KIND_REQUEST or kind == KIND_RESPONSE then
264
+ local corrId, corrBytes = varintRead(incoming, pos)
265
+ if corrBytes == 0 then
266
+ Log.warn(`truncated correlation id for "{reg.name}"; aborting`)
267
+ return false
268
+ end
201
269
  pos += corrBytes
202
270
 
203
271
  if msb then
@@ -207,47 +275,43 @@ function Reader.process(
207
275
  end
208
276
  reg.signal:fire(nil, player, corrId)
209
277
  else
210
- local value, consumed = reg.codec.read(incoming, pos, refs)
211
- if consumed == 0 then
212
- warn(
213
- `[Lync] Reader.process: query codec for "{reg.name}" reported 0 bytes; aborting frame`
214
- )
215
- return
278
+ local regCodec = reg.codec
279
+ -- Mirror the packet path: 0-byte response codecs (Lync.nothing) bypass the gates.
280
+ local qIsZeroSize = regCodec._size == 0
281
+ if not qIsZeroSize and pos >= incomingLen then
282
+ Log.warn(`query "{reg.name}" body missing; aborting`)
283
+ return false
284
+ end
285
+ local value, consumed = regCodec.read(incoming, pos, refs)
286
+ if not qIsZeroSize and consumed == 0 then
287
+ Log.warn(`query codec for "{reg.name}" reported 0 bytes; aborting`)
288
+ return false
216
289
  end
217
290
  pos += consumed
291
+ if pos > incomingLen then
292
+ Log.warn(`query "{reg.name}" overran frame end; aborting`)
293
+ return false
294
+ end
218
295
 
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
296
+ --[[
297
+ Server gates REQUEST and RESPONSE frames opted into
298
+ needsGate so a malicious client can't bypass via the
299
+ response channel.
300
+ ]]
301
+ if
302
+ isServer
303
+ and reg.needsGate
304
+ and player
305
+ and not gateValue(reg, regCodec, value, player, statsEnabled, false)
306
+ then
307
+ if statsEnabled then
308
+ reg.bytesReceived += (pos - frameStart)
246
309
  end
310
+ continue
247
311
  end
248
312
 
249
313
  if hasRecvHooks then
250
- value = Middleware.runReceive(value, reg.name, player)
314
+ value = runReceive(value, reg.name, player)
251
315
  end
252
316
 
253
317
  if statsEnabled then
@@ -258,6 +322,10 @@ function Reader.process(
258
322
  end
259
323
  end
260
324
  end
325
+ return true
261
326
  end
262
327
 
328
+ Reader.setSilent = Log.setSilent
329
+ Reader.reset = Log.reset
330
+
263
331
  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)