Skip to content

Commit eed00db

Browse files
committed
ye
1 parent fc95d68 commit eed00db

13 files changed

Lines changed: 477 additions & 104 deletions

File tree

README.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ Used as the second argument to `packet:send()` on the server.
387387
| `Lync.f16` | 2 | ±65,504, roughly 3 digits of precision |
388388
| `Lync.f32` | 4 | IEEE 754 single |
389389
| `Lync.f64` | 8 | IEEE 754 double |
390-
| `Lync.bool` | 1 | true/false. Gets packed into bitfields when inside structs. |
390+
| `Lync.bool` | 1 | true/false. Gets packed into bitfields when inside structs, and 8-per-byte when inside arrays. |
391391

392392
### Datatypes
393393

@@ -418,7 +418,7 @@ Used as the second argument to `packet:send()` on the server.
418418
| Constructor | What it does |
419419
|:------------|:------------|
420420
| `Lync.struct({ key = codec })` | Named fields. Bools get packed into bitfields automatically. |
421-
| `Lync.array(codec, maxCount?)` | Variable length list with varint count. Optional `maxCount` rejects on read if exceeded. |
421+
| `Lync.array(codec, maxCount?)` | Variable length list with varint count. Optional `maxCount` rejects on read if exceeded. Bool arrays are bitpacked (8 per byte). |
422422
| `Lync.map(keyCodec, valueCodec, maxCount?)` | Key-value pairs with varint count. Optional `maxCount` rejects on read if exceeded. |
423423
| `Lync.optional(codec)` | 1 byte flag, value only if present. |
424424
| `Lync.tuple(codec, codec, ...)` | Ordered positional values, no keys. |
@@ -505,9 +505,14 @@ Same data shapes and methodology as [Blink's benchmark suite](https://github.com
505505
506506
## Stats
507507

508+
Off by default. Call `Lync.enableStats()` before `Lync.start()` to activate. When disabled, zero overhead on send and receive paths.
509+
508510
Per-packet counters are available directly on the Packet object. Per-player counters are available via `Lync.getPlayerStats()`.
509511

510512
```luau
513+
Lync.enableStats()
514+
Lync.start()
515+
511516
-- Per-packet (both sides)
512517
print(Net.State:getBytesSent(), Net.State:getFires(), Net.State:getDrops())
513518
@@ -520,13 +525,16 @@ end
520525
Lync.resetStats() -- zeros everything in-place
521526
```
522527

523-
| Method | What it returns |
528+
| Function / Method | What it does |
524529
|:-------|:----------------|
530+
| `Lync.enableStats()` | Activates stat counters. Call before `start()`. |
525531
| `packet:getBytesSent()` | Wire bytes produced (includes batch header overhead). |
526532
| `packet:getBytesReceived()` | Wire bytes consumed on receive. |
527533
| `packet:getFires()` | Send fire count. |
528534
| `packet:getRecvFires()` | Receive fire count. |
529535
| `packet:getDrops()` | Gate rejections (rate limit, NaN, validate). |
536+
| `Lync.getPlayerStats(player)` | Returns `{ bytesSent, bytesReceived }` or nil. Server only. |
537+
| `Lync.resetStats()` | Zeros all counters in-place. |
530538

531539
## Flush Control
532540

@@ -539,8 +547,8 @@ Lync.flush() -- force an immediate flush, resets the accumulator
539547

540548
| Function | What it does |
541549
|:---------|:------------|
542-
| `Lync.setFlushRate(hz)` | 1 to 60. Default 60. Callable at runtime. |
543-
| `Lync.flush()` | Immediate flush. Resets the timer so you dont double-send at frame end. |
550+
| `Lync.setFlushRate(hz)` | 1 to 60. Default 60. Callable at runtime. At 60hz, flushes every Heartbeat directly (no accumulator). Below 60, uses an elapsed-time accumulator with drift correction. |
551+
| `Lync.flush()` | Immediate flush. Skips the next scheduled Heartbeat flush to prevent double-sending and XOR chain desync. |
544552

545553
## Security
546554

@@ -558,6 +566,32 @@ Fires `onDrop` with reason `"bandwidth"` when a player exceeds the threshold. Re
558566

559567
If a packet uses `Lync.unknown` anywhere in its codec tree without a `validate` callback, Lync prints a warning at define time. The `unknown` codec bypasses schema validation entirely; client data goes through Roblox's sidecar without type checking. Adding `validate` suppresses the warning.
560568

569+
## Packet Capture
570+
571+
Server-only debug tool. Records raw and XOR'd buffer hex for analysis.
572+
573+
```luau
574+
Lync.startCapture("My test")
575+
-- fire packets...
576+
Lync.flush()
577+
Lync.stopCapture()
578+
579+
Lync.startCapture("Another test")
580+
-- fire packets...
581+
Lync.flush()
582+
Lync.stopCapture()
583+
584+
Lync.dumpCaptures() -- writes JSON to ServerStorage.LyncCapture
585+
```
586+
587+
Each entry contains the label, frame number, raw hex (pre-XOR), XOR'd hex (post-XOR, nil for unreliable), byte count, and refs count. Hex is capped at 512 bytes per buffer to keep the output manageable.
588+
589+
| Function | What it does |
590+
|:---------|:------------|
591+
| `Lync.startCapture(label?)` | Start recording. Tags entries with the label. |
592+
| `Lync.stopCapture()` | Stop recording. |
593+
| `Lync.dumpCaptures()` | Writes all entries as JSON to a StringValue in ServerStorage, then clears. |
594+
561595
## Limits & Configuration
562596

563597
Call these before `Lync.start()` unless noted otherwise.
@@ -567,6 +601,7 @@ Call these before `Lync.start()` unless noted otherwise.
567601
| Packet types | 255 | Cant change | u8 on the wire. Each query eats 2 IDs. |
568602
| Buffer per channel per frame | 256 KB | `Lync.setChannelMaxSize(n)` | 4 KB to 1 MB. |
569603
| Concurrent queries | 65,536 | Cant change | Varint correlation IDs. Freed on response or timeout. `Lync.queryPendingCount()` returns in-flight count. Queries default to 30/s rate limit. |
604+
| Stats | Off | `Lync.enableStats()` | Zero overhead when off. Counters on packets and players. |
570605
| NaN/inf scan depth | 16 | `Lync.setValidationDepth(n)` | 4 to 32. |
571606
| Channel pool | 16 | `Lync.setPoolSize(n)` | 2 to 128. Extra gets GCd. |
572607
| Flush rate | 60 hz | `Lync.setFlushRate(n)` | 1 to 60. Runtime-safe. |

bench/Run.server.luau

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,73 @@ end
194194
local player = getPlayer()
195195
Scenarios.Ready:wait()
196196

197+
-- Capture helper: wraps a test with startCapture/stopCapture
198+
local CAPTURE_FRAMES = 5
199+
local CAPTURE_FIRES = 100 -- keep frames under MAX_INCOMING (65536 bytes)
200+
201+
local function captureTest(label: string, packet: any, getData: () -> any, target: Player): ()
202+
Lync.startCapture(label)
203+
for _ = 1, CAPTURE_FRAMES do
204+
for _ = 1, CAPTURE_FIRES do
205+
packet:send(getData(), target)
206+
end
207+
Lync.flush()
208+
end
209+
Lync.stopCapture()
210+
end
211+
197212
-- Section 1: Lync-specific benchmarks
198213
print(`\n Lync Bench {FIRES_PER_FRAME}/frame {DURATION}s per test\n`)
199214

215+
-- Capture a few frames of each test before the full run
216+
captureTest("Static booleans", Scenarios.Booleans, function(): any
217+
return true
218+
end, player)
219+
220+
captureTest("Static entities", Scenarios.Entities, function(): any
221+
return Scenarios.STATIC_ENTITY
222+
end, player)
223+
224+
local capFrame = 0
225+
captureTest("Moving entities", Scenarios.Entities, function(): any
226+
capFrame += 1
227+
return {
228+
position = Vector3.new(100.5 + capFrame * 0.1, 50.25, -200.75 + capFrame * 0.05),
229+
velocity = Vector3.new(10.1, 0, -5.3),
230+
health = 87.5,
231+
shield = 42.0,
232+
alive = true,
233+
team = 2,
234+
}
235+
end, player)
236+
237+
local captureRandom = Random.new(42)
238+
captureTest("Chaotic entities", Scenarios.Entities, function(): any
239+
return {
240+
position = Vector3.new(
241+
captureRandom:NextNumber(-500, 500),
242+
captureRandom:NextNumber(0, 200),
243+
captureRandom:NextNumber(-500, 500)
244+
),
245+
velocity = Vector3.new(
246+
captureRandom:NextNumber(-50, 50),
247+
captureRandom:NextNumber(-10, 10),
248+
captureRandom:NextNumber(-50, 50)
249+
),
250+
health = captureRandom:NextNumber(0, 100),
251+
shield = captureRandom:NextNumber(0, 100),
252+
alive = captureRandom:NextNumber(0, 1) > 0.5,
253+
team = captureRandom:NextInteger(1, 4),
254+
}
255+
end, player)
256+
257+
-- Blink tests skipped from capture: 1000 fires produce ~600KB frames
258+
-- which exceed MAX_INCOMING (65536) on the client and break XOR chain.
259+
260+
Lync.dumpCaptures()
261+
print(" [Capture saved to ServerStorage.LyncCapture]\n")
262+
263+
-- Full benchmarks
200264
runTest({
201265
name = "Static booleans",
202266
player = player,

src/Types.luau

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export type Registration = {
7878
needsGate: boolean,
7979
maxPayloadBytes: number?,
8080
timestampMode: number,
81+
_openFn: (ch: ChannelState, id: number, name: string) -> (), -- resolved at define time (#2)
8182

8283
-- Stats counters (mutable, incremented at runtime)
8384
bytesSent: number,

src/api/Packet.luau

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ function PacketModule.define(name: string, config: PacketConfig<any>): Packet
214214
timestampMode
215215
)
216216

217+
-- Resolve batch opener at define time: zero branching on hot path (#2)
218+
reg._openFn = Channel.resolveOpenFn(reg.timestampMode)
219+
217220
local isDelta = (config.value :: InternalCodec<any>)._isDelta == true
218221

219222
local fields: PacketFields = {

src/api/Query.luau

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,8 @@ function QueryModule.define(name: string, config: QueryConfig<any, any>): Query
318318

319319
local rateLimit = config.rateLimit or { maxPerSecond = 30 }
320320

321+
local Channel = require(script.Parent.Parent.internal.Channel)
322+
321323
local reqReg, respReg = Registry.registerQueryPair(
322324
name,
323325
config.request,
@@ -328,6 +330,11 @@ function QueryModule.define(name: string, config: QueryConfig<any, any>): Query
328330
config.validate
329331
)
330332

333+
-- Queries use writeQuery, not writeBatch, but _openFn must be set
334+
local openFn = Channel.resolveOpenFn(0)
335+
reqReg._openFn = openFn
336+
respReg._openFn = openFn
337+
331338
respSignal:connect(function(resp: any, source: Player?, correlation: number): ()
332339
completeQuery(correlation, resp, source)
333340
end)

src/codec/composite/Array.luau

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,75 @@ function Array.array(element: Codec<any>, maxCount: number?): Codec<{ any }>
206206
local directWrite = internal._directWrite
207207
local directRead = internal._directRead
208208

209+
local band = bit32.band
210+
local bor = bit32.bor
211+
local lshift = bit32.lshift
212+
local rshift = bit32.rshift
213+
local ceil = math.ceil
214+
215+
-- Bitpacked bool array: 8 bools per byte
216+
if internal._isBool then
217+
return table.freeze({
218+
_hasUnknown = nil,
219+
write = function(ch: ChannelState, value: { any }): ()
220+
local len = #value
221+
Varint.write(ch, len)
222+
if len == 0 then
223+
return
224+
end
225+
226+
local byteCount = ceil(len / 8)
227+
local c = ch.cursor
228+
if c + byteCount > ch.size then
229+
alloc(ch, byteCount)
230+
end
231+
232+
local b = ch.buff
233+
local byteIdx = 0
234+
local acc = 0
235+
for i = 1, len do
236+
local bit = (i - 1) % 8
237+
if value[i] then
238+
acc = bor(acc, lshift(1, bit))
239+
end
240+
if bit == 7 then
241+
buffer.writeu8(b, c + byteIdx, acc)
242+
byteIdx += 1
243+
acc = 0
244+
end
245+
end
246+
-- Flush remaining bits
247+
if len % 8 ~= 0 then
248+
buffer.writeu8(b, c + byteIdx, acc)
249+
end
250+
ch.cursor = c + byteCount
251+
end,
252+
read = function(src: buffer, pos: number, _refs: { Instance }?): ({ boolean }, number)
253+
local len, lenBytes = Varint.read(src, pos)
254+
255+
if maxCount and len > maxCount then
256+
error(`[Lync] Array count {len} exceeds max {maxCount}`)
257+
end
258+
259+
local result = table.create(len)
260+
if len == 0 then
261+
return result, lenBytes
262+
end
263+
264+
local byteCount = ceil(len / 8)
265+
local c = pos + lenBytes
266+
for i = 1, len do
267+
local byteIdx = rshift(i - 1, 3)
268+
local bit = (i - 1) % 8
269+
local byte = buffer.readu8(src, c + byteIdx)
270+
result[i] = band(byte, lshift(1, bit)) ~= 0
271+
end
272+
273+
return result, lenBytes + byteCount
274+
end,
275+
})
276+
end
277+
209278
if elementSize and directWrite and directRead then
210279
return table.freeze({
211280
_hasUnknown = (element :: any)._hasUnknown or nil,

src/index.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,8 @@ declare namespace Lync {
327327
export function flush(): void;
328328
export function setFlushRate(hz: number): void;
329329

330-
// Stats (server-only for per-player)
330+
// Stats (default off; call enableStats() to activate)
331+
export function enableStats(): void;
331332
export function getPlayerStats(player: Player): PlayerStats | undefined;
332333
export function resetStats(): void;
333334

@@ -336,6 +337,11 @@ declare namespace Lync {
336337

337338
// Introspection
338339
export function queryPendingCount(): number;
340+
341+
// Packet capture (server-only, debug)
342+
export function startCapture(label?: string): void;
343+
export function stopCapture(): void;
344+
export function dumpCaptures(): void;
339345
}
340346

341347
export default Lync;

src/init.luau

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ local Lync = {
226226
flush = flush,
227227
setFlushRate = setFlushRate,
228228

229-
-- Stats (server-only for per-player)
229+
-- Stats (default off; call enableStats() to activate)
230+
enableStats = Channel.enableStats,
230231
getPlayerStats = getPlayerStats,
231232
resetStats = resetStats,
232233

@@ -235,6 +236,11 @@ local Lync = {
235236

236237
-- Introspection
237238
queryPendingCount = QueryModule.pendingCount,
239+
240+
-- Packet capture (server-only, debug)
241+
startCapture = if IS_SERVER then Server.startCapture else nil,
242+
stopCapture = if IS_SERVER then Server.stopCapture else nil,
243+
dumpCaptures = if IS_SERVER then Server.dumpCaptures else nil,
238244
}
239245

240246
Lync.default = Lync

0 commit comments

Comments
 (0)