@axpecter/lync 1.5.2 → 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.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ```toml
15
15
  [dependencies]
16
- Lync = "axp3cter/lync@1.5.2"
16
+ Lync = "axp3cter/lync@2.0.0"
17
17
  ```
18
18
 
19
19
  **npm (roblox-ts)**
@@ -163,7 +163,7 @@ end
163
163
  | | What it does |
164
164
  |:---------|:------------|
165
165
  | `Lync.start()` | Sets up transport. Server creates remotes, client connects. Call once after all definitions. |
166
- | `Lync.VERSION` | `"1.5.2"` |
166
+ | `Lync.VERSION` | `"2.0.0"` |
167
167
 
168
168
  ## Packets
169
169
 
@@ -176,6 +176,7 @@ end
176
176
  | `rateLimit` | `{ maxPerSecond, burstAllowance? }` | No | Server-side token bucket. Burst defaults to maxPerSecond if you dont set it. |
177
177
  | `validate` | `(data, player) → (bool, string?)` | No | Server-side. Return `false, "reason"` to drop. Runs after NaN scan. |
178
178
  | `maxPayloadBytes` | number | No | Server-side. Max bytes a single batch of this packet can consume. Fires `onDrop` with reason `"size"` if exceeded. |
179
+ | `timestamp` | `"frame" \| "offset" \| "full"` | No | Prepends a timestamp to each item. `"frame"` = u8 wrapping counter (1B), `"offset"` = u16 ms (2B), `"full"` = f64 clock (8B). Listeners receive it as a third argument. |
179
180
 
180
181
  **Server, single `send` with targets:**
181
182
 
@@ -198,8 +199,8 @@ packet:send(data) -- send to server
198
199
 
199
200
  | Method | What it does |
200
201
  |:-------|:------------|
201
- | `packet:listen(fn(data, sender))` | Sender is `Player` on server, `nil` on client. Returns a Connection. |
202
- | `packet:once(fn(data, sender))` | Auto-disconnects after one fire. |
202
+ | `packet:listen(fn(data, sender, timestamp?))` | Sender is `Player` on server, `nil` on client. `timestamp` is present when the packet has a `timestamp` config. Returns a Connection. |
203
+ | `packet:once(fn(data, sender, timestamp?))` | Auto-disconnects after one fire. |
203
204
  | `packet:wait()` | Returns `(data, sender)`. |
204
205
  | `packet:disconnectAll()` | Kills all listeners on this packet. |
205
206
 
@@ -271,7 +272,7 @@ Access packets and queries by their short name on the returned object: `ns.Packe
271
272
 
272
273
  | Method | What it does |
273
274
  |:-------|:------------|
274
- | `ns:listenAll(fn(name, data, sender))` | Listens to every packet in the namespace. `name` is the short name without prefix. Returns a Connection. |
275
+ | `ns:listenAll(fn(name, data, sender, timestamp?))` | Listens to every packet in the namespace. `name` is the short name without prefix. Returns a Connection. |
275
276
  | `ns:onSend(fn(data, name, player) → data?)` | Send middleware that only runs for this namespace. Returns a remover. |
276
277
  | `ns:onReceive(fn(data, name, player) → data?)` | Receive middleware that only runs for this namespace. Returns a remover. |
277
278
  | `ns:disconnectAll()` | Kills all listeners made through `listenAll`. |
@@ -386,7 +387,7 @@ Used as the second argument to `packet:send()` on the server.
386
387
  | `Lync.f16` | 2 | ±65,504, roughly 3 digits of precision |
387
388
  | `Lync.f32` | 4 | IEEE 754 single |
388
389
  | `Lync.f64` | 8 | IEEE 754 double |
389
- | `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. |
390
391
 
391
392
  ### Datatypes
392
393
 
@@ -417,7 +418,7 @@ Used as the second argument to `packet:send()` on the server.
417
418
  | Constructor | What it does |
418
419
  |:------------|:------------|
419
420
  | `Lync.struct({ key = codec })` | Named fields. Bools get packed into bitfields automatically. |
420
- | `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). |
421
422
  | `Lync.map(keyCodec, valueCodec, maxCount?)` | Key-value pairs with varint count. Optional `maxCount` rejects on read if exceeded. |
422
423
  | `Lync.optional(codec)` | 1 byte flag, value only if present. |
423
424
  | `Lync.tuple(codec, codec, ...)` | Ordered positional values, no keys. |
@@ -502,17 +503,109 @@ Same data shapes and methodology as [Blink's benchmark suite](https://github.com
502
503
  > [!NOTE]
503
504
  > Lync benchmarks run on Ryzen 7 7800X3D, 32GB DDR5-4800. Other tool numbers are from [Blink's published benchmarks](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md) (v0.17.1, Ryzen 9 7900X, 34GB DDR5-4800). Different CPUs so FPS numbers arent directly comparable but bandwidth numbers are since Kbps is scaled by 60/FPS. Lync hits the 60 FPS frame cap in both tests.
504
505
 
506
+ ## Stats
507
+
508
+ Off by default. Call `Lync.enableStats()` before `Lync.start()` to activate. When disabled, zero overhead on send and receive paths.
509
+
510
+ Per-packet counters are available directly on the Packet object. Per-player counters are available via `Lync.getPlayerStats()`.
511
+
512
+ ```luau
513
+ Lync.enableStats()
514
+ Lync.start()
515
+
516
+ -- Per-packet (both sides)
517
+ print(Net.State:getBytesSent(), Net.State:getFires(), Net.State:getDrops())
518
+
519
+ -- Per-player (server only)
520
+ local stats = Lync.getPlayerStats(player)
521
+ if stats then
522
+ print(stats.bytesSent, stats.bytesReceived)
523
+ end
524
+
525
+ Lync.resetStats() -- zeros everything in-place
526
+ ```
527
+
528
+ | Function / Method | What it does |
529
+ |:-------|:----------------|
530
+ | `Lync.enableStats()` | Activates stat counters. Call before `start()`. |
531
+ | `packet:getBytesSent()` | Wire bytes produced (includes batch header overhead). |
532
+ | `packet:getBytesReceived()` | Wire bytes consumed on receive. |
533
+ | `packet:getFires()` | Send fire count. |
534
+ | `packet:getRecvFires()` | Receive fire count. |
535
+ | `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. |
538
+
539
+ ## Flush Control
540
+
541
+ By default Lync flushes at 60hz (every Heartbeat). You can change this at runtime.
542
+
543
+ ```luau
544
+ Lync.setFlushRate(30) -- flush every ~33ms instead of ~16ms
545
+ Lync.flush() -- force an immediate flush, resets the accumulator
546
+ ```
547
+
548
+ | Function | What it does |
549
+ |:---------|:------------|
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. |
552
+
553
+ ## Security
554
+
555
+ ### Bandwidth Throttle
556
+
557
+ Server-side per-player bandwidth strike counter. Counts consecutive oversized frames with decay. Protects against clients flooding the server.
558
+
559
+ ```luau
560
+ Lync.setBandwidthLimit(16384, 10) -- 16KB soft limit, 10 strikes before drop
561
+ ```
562
+
563
+ Fires `onDrop` with reason `"bandwidth"` when a player exceeds the threshold. Read failures (corrupted buffers, XOR desync) also count as strikes.
564
+
565
+ ### Unknown Codec Warning
566
+
567
+ 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.
568
+
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
+
505
595
  ## Limits & Configuration
506
596
 
507
- Call these before `Lync.start()`.
597
+ Call these before `Lync.start()` unless noted otherwise.
508
598
 
509
599
  | What | Default | How to change | Notes |
510
600
  |:-----|--------:|:--------------|:------|
511
601
  | Packet types | 255 | Cant change | u8 on the wire. Each query eats 2 IDs. |
512
602
  | Buffer per channel per frame | 256 KB | `Lync.setChannelMaxSize(n)` | 4 KB to 1 MB. |
513
- | Concurrent queries | 65,536 | Cant change | u16 correlation IDs. Freed on response or timeout. `Lync.queryPendingCount()` returns in-flight count. |
603
+ | 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. |
514
605
  | NaN/inf scan depth | 16 | `Lync.setValidationDepth(n)` | 4 to 32. |
515
606
  | Channel pool | 16 | `Lync.setPoolSize(n)` | 2 to 128. Extra gets GCd. |
607
+ | Flush rate | 60 hz | `Lync.setFlushRate(n)` | 1 to 60. Runtime-safe. |
608
+ | Bandwidth limit | 16 KB / 10 strikes | `Lync.setBandwidthLimit(n, m)` | Server-only. Per-player. |
516
609
  | Namespaces | 64 | Cant change | |
517
610
  | Delta + unreliable | Nope | Cant change | Errors at define time. |
518
611
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@axpecter/lync",
3
- "version": "1.5.2",
4
- "description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security.",
3
+ "version": "2.0.0",
4
+ "description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security",
5
5
  "main": "src/init.luau",
6
6
  "types": "src/index.d.ts",
7
7
  "module": "commonjs",
package/src/Types.luau CHANGED
@@ -41,6 +41,7 @@ export type PacketConfig<T> = {
41
41
  rateLimit: RateLimitConfig?,
42
42
  validate: ((data: T, player: Player) -> (boolean, string?))?,
43
43
  maxPayloadBytes: number?,
44
+ timestamp: ("frame" | "offset" | "full")?,
44
45
  }
45
46
 
46
47
  export type QueryConfig<Req, Resp> = {
@@ -76,6 +77,15 @@ export type Registration = {
76
77
  validate: ((data: any, player: Player) -> (boolean, string?))?,
77
78
  needsGate: boolean,
78
79
  maxPayloadBytes: number?,
80
+ timestampMode: number,
81
+ _openFn: (ch: ChannelState, id: number, name: string) -> (), -- resolved at define time (#2)
82
+
83
+ -- Stats counters (mutable, incremented at runtime)
84
+ bytesSent: number,
85
+ bytesReceived: number,
86
+ fires: number,
87
+ recvFires: number,
88
+ drops: number,
79
89
  }
80
90
 
81
91
  export type Connection = {
@@ -62,7 +62,12 @@ export type Namespace = typeof(setmetatable({} :: NamespaceFields, NamespaceImpl
62
62
  -- Listens on every packet in this namespace. Returns a Connection.
63
63
  function NamespaceImpl.listenAll(
64
64
  self: Namespace,
65
- callback: (name: string, data: any, sender: Player?) -> ()
65
+ callback: (
66
+ name: string,
67
+ data: any,
68
+ sender: Player?,
69
+ timestamp: number?
70
+ ) -> ()
66
71
  ): Connection
67
72
  local connections = {} :: { Connection }
68
73
  local prefix = self._name .. "."
@@ -70,8 +75,8 @@ function NamespaceImpl.listenAll(
70
75
 
71
76
  for fullName, packet in self._packets do
72
77
  local shortName = string.sub(fullName, prefixLen + 1)
73
- local conn = packet:listen(function(data: any, sender: Player?): ()
74
- callback(shortName, data, sender)
78
+ local conn = packet:listen(function(data: any, sender: Player?, timestamp: number?): ()
79
+ callback(shortName, data, sender, timestamp)
75
80
  end)
76
81
  table.insert(connections, conn)
77
82
  table.insert(self._tracked, conn)
@@ -4,6 +4,7 @@
4
4
 
5
5
  local RunService = game:GetService("RunService")
6
6
 
7
+ local Channel = require(script.Parent.Parent.internal.Channel)
7
8
  local Client = require(script.Parent.Parent.transport.Client)
8
9
  local Registry = require(script.Parent.Parent.internal.Registry)
9
10
  local Server = require(script.Parent.Parent.transport.Server)
@@ -14,34 +15,44 @@ type Connection = Types.Connection
14
15
  type Codec<T> = Types.Codec<T>
15
16
  type InternalCodec<T> = Types.InternalCodec<T>
16
17
  type PacketConfig<T> = Types.PacketConfig<T>
18
+ type Registration = Types.Registration
17
19
 
18
20
  -- Constants -----------------------------------------------------------
19
21
 
20
22
  local IS_SERVER = RunService:IsServer()
21
23
 
22
- local serverWriteTo = Server.writeTo
23
- local serverWriteToAll = Server.writeToAll
24
- local serverWriteToList = Server.writeToList
25
- local serverWriteToAllExcept = Server.writeToAllExcept
26
- local serverWriteToSet = Server.writeToSet
27
- local clientWrite = Client.write
28
-
29
24
  local DELTA_TARGETED = 1
30
25
  local DELTA_BROADCAST = 2
31
26
 
27
+ local TS_LOOKUP = {
28
+ frame = Channel.TS_FRAME,
29
+ offset = Channel.TS_OFFSET,
30
+ full = Channel.TS_FULL,
31
+ }
32
+
32
33
  -- Private -------------------------------------------------------------
33
34
 
34
35
  type PacketFields = {
35
- _id: number,
36
- _name: string,
37
- _codec: Codec<any>,
36
+ _reg: Registration,
38
37
  _isUnreliable: boolean,
39
38
  _isDelta: boolean,
40
39
  _deltaMode: number,
41
40
  _signal: Signal.Signal,
42
41
  }
43
42
 
44
- -- Server-only metatable. Single send() dispatches on target type.
43
+ local function checkDeltaMode(self: any, mode: number): ()
44
+ if not self._isDelta then
45
+ return
46
+ end
47
+ local current = self._deltaMode
48
+ if current == 0 then
49
+ self._deltaMode = mode
50
+ elseif current ~= mode then
51
+ error(`[Lync] Delta packet cannot mix targeted and broadcast: "{self._reg.name}"`)
52
+ end
53
+ end
54
+
55
+ -- Server-only metatable
45
56
  local ServerImpl = {}
46
57
  ServerImpl.__index = ServerImpl
47
58
 
@@ -63,67 +74,68 @@ function ServerImpl.disconnectAll(self: Packet): ()
63
74
  self._signal:disconnectAll()
64
75
  end
65
76
 
66
- local function checkDeltaMode(self: Packet, mode: number): ()
67
- if not self._isDelta then
68
- return
69
- end
70
- local current = self._deltaMode
71
- if current == 0 then
72
- self._deltaMode = mode
73
- elseif current ~= mode then
74
- error(`[Lync] Delta packet cannot mix targeted and broadcast: "{self._name}"`)
75
- end
76
- end
77
-
78
77
  function ServerImpl.send(self: Packet, data: any, target: any?): ()
79
78
  if target == nil then
80
79
  error("[Lync] Server packet:send requires a target")
81
80
  end
82
81
 
83
- local id = self._id
84
- local name = self._name
85
- local codec = self._codec
82
+ local reg = self._reg
86
83
  local isUnreliable = self._isUnreliable
87
84
 
88
- -- Single player
89
85
  if typeof(target) == "Instance" then
90
86
  checkDeltaMode(self, DELTA_TARGETED)
91
- serverWriteTo(target :: Player, id, name, codec, data, isUnreliable)
87
+ Server.writeTo(target :: Player, reg, data, isUnreliable)
92
88
  return
93
89
  end
94
90
 
95
- -- Must be a table from here
96
91
  local tag = target._tag
97
92
 
98
- -- Lync.all sentinel
99
93
  if tag == "all" then
100
94
  checkDeltaMode(self, DELTA_BROADCAST)
101
- serverWriteToAll(id, name, codec, data, isUnreliable)
95
+ Server.writeToAll(reg, data, isUnreliable)
102
96
  return
103
97
  end
104
98
 
105
- -- Lync.except(player, ...) descriptor
106
99
  if tag == "except" then
107
100
  checkDeltaMode(self, DELTA_BROADCAST)
108
- serverWriteToAllExcept(target._set, id, name, codec, data, isUnreliable)
101
+ Server.writeToAllExcept(target._set, reg, data, isUnreliable)
109
102
  return
110
103
  end
111
104
 
112
- -- Group object
113
105
  if tag == "group" then
114
106
  checkDeltaMode(self, DELTA_BROADCAST)
115
- serverWriteToSet(target:getSet(), id, name, codec, data, isUnreliable)
107
+ Server.writeToSet(target:getSet(), reg, data, isUnreliable)
116
108
  return
117
109
  end
118
110
 
119
- -- Player list(array)
120
111
  checkDeltaMode(self, DELTA_TARGETED)
121
- serverWriteToList(target :: { Player }, id, name, codec, data, isUnreliable)
112
+ Server.writeToList(target :: { Player }, reg, data, isUnreliable)
113
+ end
114
+
115
+ -- Stats: read directly from registration
116
+ function ServerImpl.getBytesSent(self: Packet): number
117
+ return self._reg.bytesSent
118
+ end
119
+
120
+ function ServerImpl.getBytesReceived(self: Packet): number
121
+ return self._reg.bytesReceived
122
+ end
123
+
124
+ function ServerImpl.getFires(self: Packet): number
125
+ return self._reg.fires
126
+ end
127
+
128
+ function ServerImpl.getRecvFires(self: Packet): number
129
+ return self._reg.recvFires
130
+ end
131
+
132
+ function ServerImpl.getDrops(self: Packet): number
133
+ return self._reg.drops
122
134
  end
123
135
 
124
136
  table.freeze(ServerImpl)
125
137
 
126
- -- Client-only metatable. Has send(), no target needed.
138
+ -- Client-only metatable
127
139
  local ClientImpl = {}
128
140
  ClientImpl.__index = ClientImpl
129
141
 
@@ -144,9 +156,15 @@ function ClientImpl.disconnectAll(self: Packet): ()
144
156
  end
145
157
 
146
158
  function ClientImpl.send(self: Packet, data: any): ()
147
- clientWrite(self._id, self._name, self._codec, data, self._isUnreliable)
159
+ Client.write(self._reg, data, self._isUnreliable)
148
160
  end
149
161
 
162
+ ClientImpl.getBytesSent = ServerImpl.getBytesSent
163
+ ClientImpl.getBytesReceived = ServerImpl.getBytesReceived
164
+ ClientImpl.getFires = ServerImpl.getFires
165
+ ClientImpl.getRecvFires = ServerImpl.getRecvFires
166
+ ClientImpl.getDrops = ServerImpl.getDrops
167
+
150
168
  table.freeze(ClientImpl)
151
169
 
152
170
  -- Public --------------------------------------------------------------
@@ -167,6 +185,22 @@ function PacketModule.define(name: string, config: PacketConfig<any>): Packet
167
185
  error(`[Lync] Delta codec requires reliable delivery: "{name}"`)
168
186
  end
169
187
 
188
+ local timestampMode = if config.timestamp then TS_LOOKUP[config.timestamp] else nil
189
+ if config.timestamp and not timestampMode then
190
+ error(`[Lync] Invalid timestamp mode: "{config.timestamp}" on packet "{name}"`)
191
+ end
192
+
193
+ if timestampMode then
194
+ Channel.enableTimestamps()
195
+ end
196
+
197
+ local hasUnknown = (config.value :: any)._hasUnknown
198
+ if hasUnknown and not config.validate then
199
+ warn(
200
+ `[Lync] Packet "{name}" uses Lync.unknown: client data bypasses schema validation. Add a validate callback.`
201
+ )
202
+ end
203
+
170
204
  local signal = Signal.create()
171
205
 
172
206
  local reg = Registry.register(
@@ -176,15 +210,14 @@ function PacketModule.define(name: string, config: PacketConfig<any>): Packet
176
210
  signal,
177
211
  config.rateLimit,
178
212
  config.validate,
179
- config.maxPayloadBytes
213
+ config.maxPayloadBytes,
214
+ timestampMode
180
215
  )
181
216
 
182
217
  local isDelta = (config.value :: InternalCodec<any>)._isDelta == true
183
218
 
184
219
  local fields: PacketFields = {
185
- _id = reg.id,
186
- _name = reg.name,
187
- _codec = reg.codec,
220
+ _reg = reg,
188
221
  _isUnreliable = isUnreliable,
189
222
  _isDelta = isDelta,
190
223
  _deltaMode = 0,
@@ -316,13 +316,15 @@ function QueryModule.define(name: string, config: QueryConfig<any, any>): Query
316
316
  local reqSignal = Signal.create()
317
317
  local respSignal = Signal.create()
318
318
 
319
+ local rateLimit = config.rateLimit or { maxPerSecond = 30 }
320
+
319
321
  local reqReg, respReg = Registry.registerQueryPair(
320
322
  name,
321
323
  config.request,
322
324
  config.response,
323
325
  reqSignal,
324
326
  respSignal,
325
- config.rateLimit,
327
+ rateLimit,
326
328
  config.validate
327
329
  )
328
330
 
@@ -38,6 +38,7 @@ function Array.deltaArray(element: Codec<any>, maxCount: number?): Codec<{ any }
38
38
 
39
39
  return table.freeze({
40
40
  _isDelta = true,
41
+ _hasUnknown = (element :: any)._hasUnknown or nil,
41
42
 
42
43
  write = function(ch: ChannelState, value: { any }): ()
43
44
  local len = #value
@@ -110,12 +111,14 @@ function Array.deltaArray(element: Codec<any>, maxCount: number?): Codec<{ any }
110
111
  ch.cursor += 1
111
112
  Varint.write(ch, dirtyN)
112
113
 
114
+ local prevIdx = 0
113
115
  for j = 1, dirtyN do
114
116
  local i = dirty[j]
115
117
  local newOff = bounds[i]
116
118
  local newLen = bounds[i + 1] - newOff
117
119
 
118
- Varint.write(ch, i - 1)
120
+ Varint.write(ch, (i - 1) - prevIdx)
121
+ prevIdx = i - 1
119
122
  alloc(ch, newLen)
120
123
  buffer.copy(ch.buff, ch.cursor, scratch.buff, newOff, newLen)
121
124
  ch.cursor += newLen
@@ -167,21 +170,26 @@ function Array.deltaArray(element: Codec<any>, maxCount: number?): Codec<{ any }
167
170
  end
168
171
 
169
172
  local cache = Baseline.getCache(deltaId) :: { any }?
170
- local result = if cache then table.clone(cache) else {}
173
+ if not cache then
174
+ error("[Lync] Delta frame without baseline")
175
+ end
176
+ local result = table.clone(cache)
171
177
 
172
178
  local dirtyN, dnBytes = Varint.read(src, absPos)
173
179
  absPos += dnBytes
174
180
 
181
+ local runningIdx = 0
175
182
  for _ = 1, dirtyN do
176
- local idx, idxBytes = Varint.read(src, absPos)
177
- absPos += idxBytes
183
+ local delta, deltaBytes = Varint.read(src, absPos)
184
+ absPos += deltaBytes
185
+ runningIdx += delta
178
186
 
179
187
  if elemRead and elemSize then
180
- result[idx + 1] = elemRead(src, absPos)
188
+ result[runningIdx + 1] = elemRead(src, absPos)
181
189
  absPos += elemSize
182
190
  else
183
191
  local val, n = readElement(src, absPos, refs)
184
- result[idx + 1] = val
192
+ result[runningIdx + 1] = val
185
193
  absPos += n
186
194
  end
187
195
  end
@@ -198,8 +206,78 @@ function Array.array(element: Codec<any>, maxCount: number?): Codec<{ any }>
198
206
  local directWrite = internal._directWrite
199
207
  local directRead = internal._directRead
200
208
 
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
+
201
278
  if elementSize and directWrite and directRead then
202
279
  return table.freeze({
280
+ _hasUnknown = (element :: any)._hasUnknown or nil,
203
281
  write = function(ch: ChannelState, value: { any }): ()
204
282
  local len = #value
205
283
  Varint.write(ch, len)
@@ -244,6 +322,7 @@ function Array.array(element: Codec<any>, maxCount: number?): Codec<{ any }>
244
322
  local readElement = element.read
245
323
 
246
324
  return table.freeze({
325
+ _hasUnknown = (element :: any)._hasUnknown or nil,
247
326
  write = function(ch: ChannelState, value: { any }): ()
248
327
  local len = #value
249
328
  Varint.write(ch, len)
@@ -44,6 +44,7 @@ function Map.map(
44
44
  local entrySize = keySize + valSize
45
45
 
46
46
  return table.freeze({
47
+ _hasUnknown = (keyCodec :: any)._hasUnknown or (valueCodec :: any)._hasUnknown or nil,
47
48
  write = function(ch: ChannelState, value: { [any]: any }): ()
48
49
  local count = 0
49
50
  for _ in value do
@@ -100,6 +101,7 @@ function Map.map(
100
101
  local readVal = valueCodec.read
101
102
 
102
103
  return table.freeze({
104
+ _hasUnknown = (keyCodec :: any)._hasUnknown or (valueCodec :: any)._hasUnknown or nil,
103
105
  write = function(ch: ChannelState, value: { [any]: any }): ()
104
106
  local count = 0
105
107
  for _ in value do
@@ -185,6 +187,7 @@ function Map.deltaMap(
185
187
 
186
188
  return table.freeze({
187
189
  _isDelta = true,
190
+ _hasUnknown = (keyCodec :: any)._hasUnknown or (valueCodec :: any)._hasUnknown or nil,
188
191
 
189
192
  write = function(ch: ChannelState, value: { [any]: any }): ()
190
193
  local scratch = acquireScratch()
@@ -378,7 +381,10 @@ function Map.deltaMap(
378
381
  end
379
382
 
380
383
  local cache = Baseline.getCache(deltaId) :: { [any]: any }?
381
- local result = if cache then table.clone(cache) else {}
384
+ if not cache then
385
+ error("[Lync] Delta frame without baseline")
386
+ end
387
+ local result = table.clone(cache)
382
388
 
383
389
  local upsertN, unBytes = Varint.read(src, absPos)
384
390
  absPos += unBytes
@@ -19,6 +19,7 @@ function Optional.optional(inner: Codec<any>): Codec<any>
19
19
  local innerRead = inner.read
20
20
 
21
21
  return table.freeze({
22
+ _hasUnknown = (inner :: any)._hasUnknown or nil,
22
23
  write = function(ch: ChannelState, value: any): ()
23
24
  local c = ch.cursor
24
25
  if c + 1 > ch.size then