@axpecter/lync 1.5.1 → 2.0.0

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.
@@ -12,20 +12,29 @@ local Gate = require(script.Parent.Gate)
12
12
  local Middleware = require(script.Parent.Parent.internal.Middleware)
13
13
  local Pool = require(script.Parent.Parent.internal.Pool)
14
14
  local Reader = require(script.Parent.Reader)
15
+ local Registry = require(script.Parent.Parent.internal.Registry)
15
16
  local Types = require(script.Parent.Parent.Types)
16
17
 
17
18
  type ChannelState = Types.ChannelState
18
19
  type Codec<T> = Types.Codec<T>
20
+ type Registration = Types.Registration
19
21
 
20
22
  local channelWriteBatch = Channel.writeBatch
21
23
  local channelWriteBatchRaw = Channel.writeBatchRaw
22
- local channelSetPacket = Channel.setCurrentPacket
24
+ local channelUpdateTimestamp = Channel.updateTimestamp
25
+ local channelStatsEnabled = Channel.statsEnabled
23
26
 
24
27
  -- State ---------------------------------------------------------------
25
28
 
29
+ type PlayerStats = {
30
+ bytesSent: number,
31
+ bytesReceived: number,
32
+ }
33
+
26
34
  type PlayerState = {
27
35
  reliable: ChannelState,
28
36
  unreliable: ChannelState,
37
+ stats: PlayerStats,
29
38
  }
30
39
 
31
40
  local _players = {} :: { [Player]: PlayerState }
@@ -33,13 +42,47 @@ local _prevRecv = {} :: { [Player]: buffer }
33
42
  local _isStarted = false
34
43
  local _broadScratch = Channel.create()
35
44
 
45
+ -- Flush control
46
+ local _flushRate = 60
47
+ local _flushInterval = 1 / 60
48
+ local _useAccumulator = false
49
+ local _flushAccum = 0
50
+ local _flushedThisFrame = false
51
+
52
+ -- Timestamp
53
+ local _frameCounter: number = 0
54
+
55
+ -- Bandwidth strike counter
56
+ local _strikes = {} :: { [Player]: number }
57
+ local _softLimit = 16384
58
+ local _maxStrikes = 10
59
+
60
+ -- Packet capture
61
+ local _captureEnabled = false
62
+ local _captureLabel = ""
63
+ local _captureFrame = 0
64
+ local _captures = {} :: { any }
65
+
66
+ local MAX_HEX = 512 -- cap hex dump to first 512 bytes to keep capture size sane
67
+
68
+ local function bufToHex(buf: buffer, len: number): string
69
+ local cap = math.min(len, MAX_HEX)
70
+ local parts = table.create(cap)
71
+ for i = 0, cap - 1 do
72
+ parts[i + 1] = string.format("%02x", buffer.readu8(buf, i))
73
+ end
74
+ return table.concat(parts)
75
+ end
76
+
36
77
  -- Private -------------------------------------------------------------
37
78
 
38
79
  local function onPlayerAdded(player: Player): ()
39
80
  _players[player] = {
40
81
  reliable = Pool.acquire(),
41
82
  unreliable = Pool.acquire(),
83
+ stats = { bytesSent = 0, bytesReceived = 0 },
42
84
  }
85
+ _strikes[player] = 0
43
86
  Gate.onPlayerAdded(player)
44
87
  end
45
88
 
@@ -53,6 +96,7 @@ local function onPlayerRemoving(player: Player): ()
53
96
  Pool.release(state.unreliable)
54
97
  _players[player] = nil
55
98
  _prevRecv[player] = nil
99
+ _strikes[player] = nil
56
100
 
57
101
  Gate.onPlayerRemoved(player)
58
102
  Baseline.clearSource(player)
@@ -64,6 +108,19 @@ local function receive(player: Player, data: any, refs: any, applyXor: boolean):
64
108
  return
65
109
  end
66
110
 
111
+ local len = buffer.len(buf)
112
+ local strikes = _strikes[player] or 0
113
+ if len > _softLimit then
114
+ strikes += 1
115
+ else
116
+ strikes = math.max(0, strikes - 1)
117
+ end
118
+ _strikes[player] = strikes
119
+ if strikes > _maxStrikes then
120
+ Gate.reportDrop(player, "bandwidth", "frame", nil)
121
+ return
122
+ end
123
+
67
124
  local raw: buffer
68
125
  if applyXor then
69
126
  raw = Channel.xorApply(buf, _prevRecv[player])
@@ -76,6 +133,15 @@ local function receive(player: Player, data: any, refs: any, applyXor: boolean):
76
133
  local ok, err = pcall(Reader.process, raw, safeRefs, player, 0)
77
134
  if not ok then
78
135
  warn(`[Lync] Read failed from {player.Name} ({player.UserId}): {err}`)
136
+ strikes = (_strikes[player] or 0) + 1
137
+ _strikes[player] = strikes
138
+ end
139
+
140
+ if channelStatsEnabled() then
141
+ local state = _players[player]
142
+ if state then
143
+ state.stats.bytesReceived += buffer.len(raw)
144
+ end
79
145
  end
80
146
  end
81
147
 
@@ -86,18 +152,44 @@ local function flushChannel(remote: any, ch: ChannelState, target: Player, apply
86
152
 
87
153
  local raw, refs = Channel.sealAndDump(ch)
88
154
 
155
+ local xored: buffer? = nil
89
156
  if applyXor then
90
- local xored = Channel.xorApply(raw, ch.prevDump)
157
+ xored = Channel.xorApply(raw, ch.prevDump)
91
158
  ch.prevDump = raw
92
159
  remote:FireClient(target, xored, refs)
93
160
  else
94
161
  remote:FireClient(target, raw, refs)
95
162
  end
96
163
 
164
+ if _captureEnabled then
165
+ _captureFrame += 1
166
+ local rawLen = buffer.len(raw)
167
+ table.insert(_captures, {
168
+ label = _captureLabel,
169
+ frame = _captureFrame,
170
+ raw = bufToHex(raw, rawLen),
171
+ xored = if xored then bufToHex(xored :: buffer, buffer.len(xored :: buffer)) else nil,
172
+ bytes = rawLen,
173
+ refs = #refs,
174
+ })
175
+ end
176
+
177
+ if channelStatsEnabled() then
178
+ local state = _players[target]
179
+ if state then
180
+ state.stats.bytesSent += buffer.len(raw)
181
+ end
182
+ end
183
+
97
184
  Channel.reset(ch)
98
185
  end
99
186
 
100
187
  local function flush(): ()
188
+ if Channel.hasTimestamps() then
189
+ _frameCounter = (_frameCounter + 1) % 256
190
+ channelUpdateTimestamp(_frameCounter, 0, os.clock())
191
+ end
192
+
101
193
  local reliable = Bridge.reliable
102
194
  local unreliable = Bridge.unreliable
103
195
 
@@ -116,31 +208,22 @@ local function flush(): ()
116
208
  end
117
209
  end
118
210
 
119
- -- Serialize data once into shared scratch, return raw bytes + refs snapshot.
120
- local function serializeBroadcast(
121
- name: string,
122
- codec: Codec<any>,
123
- data: any
124
- ): (number, { Instance }?)
211
+ -- No timestamp here: writeBatchRaw handles it in the batch header.
212
+ local function serializeBroadcast(reg: Registration, data: any): (number, { Instance }?)
125
213
  local scratch = _broadScratch
126
214
  scratch.cursor = 0
127
215
  table.clear(scratch.refs)
128
- channelSetPacket(name)
129
- codec.write(scratch, data)
216
+ Channel.setCurrentPacket(reg.name)
217
+ reg.codec.write(scratch, data)
130
218
 
131
219
  local snapRefs = if #scratch.refs > 0 then table.clone(scratch.refs) else nil
132
220
  return scratch.cursor, snapRefs
133
221
  end
134
222
 
135
- -- Run middleware and serialize once. Returns nil if middleware dropped.
136
- local function prepareBroadcast(
137
- name: string,
138
- codec: Codec<any>,
139
- data: any
140
- ): (any, number, { Instance }?)
223
+ local function prepareBroadcast(reg: Registration, data: any): (any, number, { Instance }?)
141
224
  local final: any
142
225
  if Middleware.hasSend then
143
- final = Middleware.runSend(data, name, nil)
226
+ final = Middleware.runSend(data, reg.name, nil)
144
227
  if final == nil then
145
228
  return nil, 0, nil
146
229
  end
@@ -148,81 +231,107 @@ local function prepareBroadcast(
148
231
  final = data
149
232
  end
150
233
 
151
- local snapLen, snapRefs = serializeBroadcast(name, codec, final)
234
+ local snapLen, snapRefs = serializeBroadcast(reg, final)
152
235
  return final, snapLen, snapRefs
153
236
  end
154
237
 
155
- -- Fan out to all connected players via _players map.
238
+ -- Broadcast fan-out: stats computed once from snapLen, not per-player (#7)
156
239
  local function broadcastToAll(
157
- id: number,
158
- name: string,
159
- codec: Codec<any>,
240
+ reg: Registration,
160
241
  data: any,
161
242
  isUnreliable: boolean,
162
243
  exceptSet: { [Player]: true }?
163
244
  ): ()
164
- local final, snapLen, snapRefs = prepareBroadcast(name, codec, data)
245
+ local final, snapLen, snapRefs = prepareBroadcast(reg, data)
165
246
  if final == nil and data ~= nil then
166
247
  return
167
248
  end
168
249
 
169
250
  local scratchBuff = _broadScratch.buff
251
+ local count = 0
170
252
  for player, state in _players do
171
253
  if exceptSet and exceptSet[player] then
172
254
  continue
173
255
  end
174
256
  local ch = if isUnreliable then state.unreliable else state.reliable
175
- channelWriteBatchRaw(ch, id, scratchBuff, snapLen, snapRefs)
257
+ channelWriteBatchRaw(ch, reg, scratchBuff, snapLen, snapRefs)
258
+ count += 1
259
+ end
260
+ if channelStatsEnabled() and count > 0 then
261
+ -- Header (3 bytes for id+count) + timestamp + payload per player
262
+ local tsBytes = if reg.timestampMode == 1
263
+ then 1
264
+ elseif reg.timestampMode == 2 then 2
265
+ elseif reg.timestampMode == 3 then 8
266
+ else 0
267
+ reg.bytesSent += (3 + tsBytes + snapLen) * count
268
+ reg.fires += count
176
269
  end
177
270
  end
178
271
 
179
- -- Fan out to a specific player list.
180
272
  local function broadcastToList(
181
273
  players: { Player },
182
- id: number,
183
- name: string,
184
- codec: Codec<any>,
274
+ reg: Registration,
185
275
  data: any,
186
276
  isUnreliable: boolean
187
277
  ): ()
188
- local final, snapLen, snapRefs = prepareBroadcast(name, codec, data)
278
+ local final, snapLen, snapRefs = prepareBroadcast(reg, data)
189
279
  if final == nil and data ~= nil then
190
280
  return
191
281
  end
192
282
 
193
283
  local scratchBuff = _broadScratch.buff
284
+ local count = 0
194
285
  for _, player in players do
195
286
  local state = _players[player]
196
287
  if not state then
197
288
  continue
198
289
  end
199
290
  local ch = if isUnreliable then state.unreliable else state.reliable
200
- channelWriteBatchRaw(ch, id, scratchBuff, snapLen, snapRefs)
291
+ channelWriteBatchRaw(ch, reg, scratchBuff, snapLen, snapRefs)
292
+ count += 1
293
+ end
294
+ if channelStatsEnabled() and count > 0 then
295
+ local tsBytes = if reg.timestampMode == 1
296
+ then 1
297
+ elseif reg.timestampMode == 2 then 2
298
+ elseif reg.timestampMode == 3 then 8
299
+ else 0
300
+ reg.bytesSent += (3 + tsBytes + snapLen) * count
301
+ reg.fires += count
201
302
  end
202
303
  end
203
304
 
204
- -- Fan out to a group set { [Player]: true }.
205
305
  local function broadcastToSet(
206
306
  set: { [Player]: true },
207
- id: number,
208
- name: string,
209
- codec: Codec<any>,
307
+ reg: Registration,
210
308
  data: any,
211
309
  isUnreliable: boolean
212
310
  ): ()
213
- local final, snapLen, snapRefs = prepareBroadcast(name, codec, data)
311
+ local final, snapLen, snapRefs = prepareBroadcast(reg, data)
214
312
  if final == nil and data ~= nil then
215
313
  return
216
314
  end
217
315
 
218
316
  local scratchBuff = _broadScratch.buff
317
+ local count = 0
219
318
  for player in set do
220
319
  local state = _players[player]
221
320
  if not state then
222
321
  continue
223
322
  end
224
323
  local ch = if isUnreliable then state.unreliable else state.reliable
225
- channelWriteBatchRaw(ch, id, scratchBuff, snapLen, snapRefs)
324
+ channelWriteBatchRaw(ch, reg, scratchBuff, snapLen, snapRefs)
325
+ count += 1
326
+ end
327
+ if channelStatsEnabled() and count > 0 then
328
+ local tsBytes = if reg.timestampMode == 1
329
+ then 1
330
+ elseif reg.timestampMode == 2 then 2
331
+ elseif reg.timestampMode == 3 then 8
332
+ else 0
333
+ reg.bytesSent += (3 + tsBytes + snapLen) * count
334
+ reg.fires += count
226
335
  end
227
336
  end
228
337
 
@@ -250,17 +359,32 @@ function Server.start(): ()
250
359
  Bridge.unreliable.OnServerEvent:Connect(function(player: Player, data: any, refs: any): ()
251
360
  receive(player, data, refs, false)
252
361
  end)
253
- RunService.Heartbeat:Connect(flush)
362
+
363
+ if _useAccumulator then
364
+ RunService.Heartbeat:Connect(function(dt: number)
365
+ if _flushedThisFrame then
366
+ _flushedThisFrame = false
367
+ return
368
+ end
369
+ _flushAccum += dt
370
+ if _flushAccum < _flushInterval then
371
+ return
372
+ end
373
+ _flushAccum -= _flushInterval
374
+ flush()
375
+ end)
376
+ else
377
+ RunService.Heartbeat:Connect(function()
378
+ if _flushedThisFrame then
379
+ _flushedThisFrame = false
380
+ return
381
+ end
382
+ flush()
383
+ end)
384
+ end
254
385
  end
255
386
 
256
- function Server.writeTo(
257
- player: Player,
258
- id: number,
259
- name: string,
260
- codec: Codec<any>,
261
- data: any,
262
- isUnreliable: boolean
263
- ): ()
387
+ function Server.writeTo(player: Player, reg: Registration, data: any, isUnreliable: boolean): ()
264
388
  local state = _players[player]
265
389
  if not state then
266
390
  return
@@ -268,7 +392,7 @@ function Server.writeTo(
268
392
 
269
393
  local final: any
270
394
  if Middleware.hasSend then
271
- final = Middleware.runSend(data, name, player)
395
+ final = Middleware.runSend(data, reg.name, player)
272
396
  if final == nil then
273
397
  return
274
398
  end
@@ -277,53 +401,47 @@ function Server.writeTo(
277
401
  end
278
402
 
279
403
  local ch = if isUnreliable then state.unreliable else state.reliable
280
- channelWriteBatch(ch, id, name, codec, final)
404
+ if channelStatsEnabled() then
405
+ local before = ch.cursor
406
+ channelWriteBatch(ch, reg, final)
407
+ reg.bytesSent += ch.cursor - before
408
+ reg.fires += 1
409
+ else
410
+ channelWriteBatch(ch, reg, final)
411
+ end
281
412
  end
282
413
 
283
- function Server.writeToAll(
284
- id: number,
285
- name: string,
286
- codec: Codec<any>,
287
- data: any,
288
- isUnreliable: boolean
289
- ): ()
290
- broadcastToAll(id, name, codec, data, isUnreliable, nil)
414
+ function Server.writeToAll(reg: Registration, data: any, isUnreliable: boolean): ()
415
+ broadcastToAll(reg, data, isUnreliable, nil)
291
416
  end
292
417
 
293
418
  function Server.writeToList(
294
419
  players: { Player },
295
- id: number,
296
- name: string,
297
- codec: Codec<any>,
420
+ reg: Registration,
298
421
  data: any,
299
422
  isUnreliable: boolean
300
423
  ): ()
301
- broadcastToList(players, id, name, codec, data, isUnreliable)
424
+ broadcastToList(players, reg, data, isUnreliable)
302
425
  end
303
426
 
304
427
  function Server.writeToAllExcept(
305
428
  exceptSet: { [Player]: true },
306
- id: number,
307
- name: string,
308
- codec: Codec<any>,
429
+ reg: Registration,
309
430
  data: any,
310
431
  isUnreliable: boolean
311
432
  ): ()
312
- broadcastToAll(id, name, codec, data, isUnreliable, exceptSet)
433
+ broadcastToAll(reg, data, isUnreliable, exceptSet)
313
434
  end
314
435
 
315
436
  function Server.writeToSet(
316
437
  set: { [Player]: true },
317
- id: number,
318
- name: string,
319
- codec: Codec<any>,
438
+ reg: Registration,
320
439
  data: any,
321
440
  isUnreliable: boolean
322
441
  ): ()
323
- broadcastToSet(set, id, name, codec, data, isUnreliable)
442
+ broadcastToSet(set, reg, data, isUnreliable)
324
443
  end
325
444
 
326
- -- Shared for both query requests and responses(identical wire format).
327
445
  function Server.writeQuery(
328
446
  player: Player,
329
447
  id: number,
@@ -359,4 +477,91 @@ function Server.writeQueryNil(player: Player, id: number, name: string, correlat
359
477
  Channel.writeQuery(state.reliable, id, name, correlationId, nil, nil)
360
478
  end
361
479
 
480
+ function Server.flush(): ()
481
+ _flushAccum = 0
482
+ _flushedThisFrame = true
483
+ flush()
484
+ end
485
+
486
+ function Server.setFlushRate(hz: number): ()
487
+ _flushRate = math.clamp(hz, 1, 60)
488
+ _flushInterval = 1 / _flushRate
489
+ _useAccumulator = _flushRate < 60
490
+ end
491
+
492
+ function Server.getFlushRate(): number
493
+ return _flushRate
494
+ end
495
+
496
+ function Server.setBandwidthLimit(softLimit: number, maxStrikes: number): ()
497
+ _softLimit = softLimit
498
+ _maxStrikes = maxStrikes
499
+ end
500
+
501
+ function Server.getPlayerStats(player: Player): PlayerStats?
502
+ local state = _players[player]
503
+ return if state then state.stats else nil
504
+ end
505
+
506
+ function Server.resetStats(): ()
507
+ for _, state in _players do
508
+ state.stats.bytesSent = 0
509
+ state.stats.bytesReceived = 0
510
+ end
511
+ Registry.forEach(function(reg: Registration)
512
+ reg.bytesSent = 0
513
+ reg.bytesReceived = 0
514
+ reg.fires = 0
515
+ reg.recvFires = 0
516
+ reg.drops = 0
517
+ end)
518
+ end
519
+
520
+ function Server.startCapture(label: string?): ()
521
+ _captureEnabled = true
522
+ _captureLabel = label or "unknown"
523
+ _captureFrame = 0
524
+ end
525
+
526
+ function Server.stopCapture(): ()
527
+ _captureEnabled = false
528
+ end
529
+
530
+ function Server.dumpCaptures(): ()
531
+ local json = game:GetService("HttpService"):JSONEncode(_captures)
532
+ local storage = game:GetService("ServerStorage")
533
+
534
+ -- Clean up previous captures
535
+ local old = storage:FindFirstChild("LyncCapture")
536
+ if old then
537
+ old:Destroy()
538
+ end
539
+
540
+ -- Split across StringValues if needed (200K limit per value)
541
+ local CHUNK = 199000
542
+ if #json <= CHUNK then
543
+ local sv = Instance.new("StringValue")
544
+ sv.Name = "LyncCapture"
545
+ sv.Value = json
546
+ sv.Parent = storage
547
+ else
548
+ local folder = Instance.new("Folder")
549
+ folder.Name = "LyncCapture"
550
+ local parts = math.ceil(#json / CHUNK)
551
+ for i = 1, parts do
552
+ local sv = Instance.new("StringValue")
553
+ sv.Name = `Part{i}`
554
+ sv.Value = string.sub(json, (i - 1) * CHUNK + 1, i * CHUNK)
555
+ sv.Parent = folder
556
+ end
557
+ folder.Parent = storage
558
+ end
559
+
560
+ local count = #_captures
561
+ table.clear(_captures)
562
+ print(
563
+ `[Lync] Capture saved: {count} entries, {#json} chars -> ServerStorage.LyncCapture (open in script editor, copy contents)`
564
+ )
565
+ end
566
+
362
567
  return table.freeze(Server)