@axpecter/lync 2.3.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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Schemas, packets, queries, groups, validation, rate limiting. Every send batches
16
16
  Wally — add to your `wally.toml`:
17
17
 
18
18
  ```toml
19
- Lync = "axp3cter/lync@2.3.1"
19
+ Lync = "axp3cter/lync@2.3.2"
20
20
  ```
21
21
 
22
22
  npm (roblox-ts):
@@ -250,8 +250,9 @@ Enable with `Lync.configure({ stats = true })`.
250
250
  | Codec | Bytes | Notes |
251
251
  |:---|---:|:---|
252
252
  | `int(min, max)` | 1 / 2 / 4 | Picks narrowest u8/u16/u32/i8/i16/i32. |
253
+ | `zint(min?, max?)` | 1 – 5 | Variable-length signed via zigzag varint. 1 byte for [-96, 95]. |
253
254
  | `f16` / `f32` / `f64` | 2 / 4 / 8 | `f16` ≈ ±65504, ~3 digits. |
254
- | `float(min, max, precision)` | 1 / 2 / 4 | Quantized; clamped to range. |
255
+ | `float(min, max, precision)` | 1 / 2 / 3 / 4 | Quantized; picks u8 / u16 / u24 / u32 wire form. |
255
256
  | `bool` | 1 | Auto-bitpacked inside `struct` and `array`. |
256
257
 
257
258
  ### Strings & buffers
@@ -284,8 +285,8 @@ Call as a function for compression.
284
285
 
285
286
  | Codec | Bytes | Notes |
286
287
  |:---|---:|:---|
287
- | `vec2(min, max, precision)` | 2 / 4 / 8 | Per-component quantization. |
288
- | `vec3(min, max, precision)` | 3 / 6 / 12 | Per-component quantization. |
288
+ | `vec2(min, max, precision)` | 2 / 4 / 6 / 8 | Per-component, narrowest fitting width. |
289
+ | `vec3(min, max, precision)` | 3 / 6 / 9 / 12 | Per-component, narrowest fitting width. |
289
290
  | `cframe()` | 16 | Smallest-three quaternion. ≤ 0.16° rotation error. |
290
291
 
291
292
  ### Composites
@@ -301,13 +302,20 @@ Call as a function for compression.
301
302
 
302
303
  ### Delta — reliable transport only
303
304
 
304
- Sends 1 byte when the value is byte-equal to the cached previous frame.
305
+ Tracks the previous frame's value and ships only what changed. Rejected on `unreliable = true`.
305
306
 
306
- | Codec | Notes |
307
- |:---|:---|
308
- | `deltaStruct(schema)` | Per-segment dirty bitmap; single-segment fast path. |
309
- | `deltaArray(c, max?)` | Whole-array byte-equality. |
310
- | `deltaMap(k, v, max?)` | Whole-map byte-equality. |
307
+ | Codec | Static | Mutation |
308
+ |:---|:---:|:---:|
309
+ | `deltaStruct(schema)` | 1 B | per-field |
310
+ | `deltaArray(c, max?)` | 1 B | per-changed-index |
311
+ | `deltaMap(k, v, max?)` | 1 B | per-changed-key |
312
+ | `deltaInt(min, max)` | 1 B | 1–5 B |
313
+ | `deltaFloat(min, max, precision)` | 1 B | 1–5 B |
314
+ | `deltaVec3(min, max, precision)` | 3 B | 3–15 B |
315
+ | `deltaCFrame(posMin, posMax, precision)` | 1 B | 4–13 B |
316
+
317
+ - `deltaArray` element / `deltaMap` key+value cannot themselves contain delta state. Use `deltaStruct` for per-field deltas inside.
318
+ - `deltaVec3` and `deltaCFrame` error on out-of-range components.
311
319
 
312
320
  ### Meta
313
321
 
@@ -351,21 +359,23 @@ Global per-player cap: `Lync.configure({ globalRateLimit = { maxPerSecond = N }
351
359
  | Tool | `array<entity>[100]` | `array<bool>[1000]` |
352
360
  |:---|:---|:---|
353
361
  | roblox | 16 fps · 559,364 Kbps | 21 fps · 353,107 Kbps |
354
- | **lync** | **60 fps · 3.44 Kbps** | **60 fps · 2.46 Kbps** |
362
+ | **lync** | **59 fps · 3.37 Kbps** | **61 fps · 2.45 Kbps** |
355
363
  | blink | 42 fps · 41.81 Kbps | 97 fps · 7.91 Kbps |
356
364
  | zap | 39 fps · 41.71 Kbps | 52 fps · 8.10 Kbps |
357
365
  | bytenet | 32 fps · 41.64 Kbps | 35 fps · 8.11 Kbps |
358
366
 
359
367
  ### Network bandwidth — 100 fires/frame, 8 s
360
368
 
361
- | Workload | Kbps |
362
- |:---|---:|
363
- | `array<entity>[100]` randomised | 3,608 |
364
- | `array<entity>[100]` reused | **2.4** |
365
- | `array<bool>[1000]` randomised | 763 |
366
- | `array<bool>[1000]` 1 bit flipped | **20.5** |
367
- | `struct(state)` randomised | 201 |
368
- | `deltaStruct(state)` 1 field mutated | **29.2** |
369
+ | Workload | Naive Kbps | Optimized | Savings |
370
+ |:---|---:|:---|---:|
371
+ | `array<entity>[100]` random | 3,607 | `deltaArray` 3 of 100 mutated | **154** (–96%) |
372
+ | `array<entity>[100]` reused | 3,607 | XOR baseline (identical frames) | **2.4** (–99.9%) |
373
+ | `array<bool>[1000]` random | 762 | XOR baseline (1 bit flipped) | **20.4** (–97%) |
374
+ | `struct(state)` random | 201 | `deltaStruct` 1 field mutated | **29.0** (–86%) |
375
+ | `map<id, vec3>[200]` 5 keys mutated | 657 | `deltaMap` 5 keys mutated | **393** (–40%) |
376
+ | `array<cframe>[50]` random | 4,585 | | — |
377
+ | `vec3` walking motion (continuous diff) | — | `deltaVec3` | **19.5** |
378
+ | `CFrame` walking pose (pos + rot) | — | `deltaCFrame` | **41.1** |
369
379
 
370
380
  ## License
371
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axpecter/lync",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
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",
package/src/Types.luau CHANGED
@@ -12,17 +12,18 @@ export type ChannelState = {
12
12
  itemCount: number,
13
13
  singleMode: boolean,
14
14
  singlePos: number,
15
- deltas: { [number]: DeltaCacheEntry },
15
+ --[[
16
+ Per-codec write-side delta state, keyed by deltaId. Shape is opaque
17
+ and codec-internal: deltaStruct stores `{raw, len, segOff, segLen}`,
18
+ deltaArray stores `{perIdx, length}`, deltaMap stores `{perKey}`,
19
+ delta scalars store quantized previous values.
20
+ ]]
21
+ deltas: { [number]: any },
16
22
  prevDump: buffer?,
17
23
  prevDumpLen: number,
18
24
  currentPacket: string?,
19
25
  }
20
26
 
21
- export type DeltaCacheEntry = {
22
- raw: buffer,
23
- len: number,
24
- }
25
-
26
27
  export type Codec<T> = {
27
28
  write: (ch: ChannelState, value: T) -> (),
28
29
  read: (src: buffer, pos: number, refs: { Instance }?) -> (T, number),
@@ -39,6 +40,11 @@ export type InternalCodec<T> = {
39
40
  _directWrite: ((b: buffer, offset: number, value: T) -> ())?,
40
41
  _directRead: ((b: buffer, offset: number) -> T)?,
41
42
  _isDelta: boolean?,
43
+ --[[
44
+ True if any nested codec is delta. Lets the broadcast hot path skip
45
+ encode-once when delta state would diverge per-receiver.
46
+ ]]
47
+ _hasDelta: boolean?,
42
48
  _isBool: boolean?,
43
49
  _hasUnknown: boolean?,
44
50
  _min: number?,
@@ -11,6 +11,7 @@ local Log = require(script.Parent.Parent.util.Log)
11
11
  local Middleware = require(script.Parent.Parent.internal.Middleware)
12
12
  local Player = require(script.Parent.Parent.util.Player)
13
13
  local Registry = require(script.Parent.Parent.internal.Registry)
14
+ local Shared = require(script.Parent.Parent.codec.composite.Shared)
14
15
  local Transport = require(script.Parent.Parent.internal.Transport)
15
16
  local Types = require(script.Parent.Parent.Types)
16
17
 
@@ -129,14 +130,102 @@ function Packet.define<T>(
129
130
  return false, sendData
130
131
  end
131
132
 
132
- -- Caller has already run send middleware once for the broadcast.
133
+ -- Non-delta codecs are stateless wire-encoders, so broadcast can encode
134
+ -- once and bufCopy the payload to every player. Falls through if the
135
+ -- encoded payload includes ref indices (the indices would need rewriting
136
+ -- per channel, which no codec exposes).
137
+ local canEncodeOnce = not codec._isDelta and not codec._hasDelta
138
+
133
139
  local function broadcast(players: { Player }, data: any): ()
134
- for _, p in players do
135
- sendToPlayer(p, data)
140
+ local count = #players
141
+ if count == 0 then
142
+ bumpFires()
143
+ return
144
+ end
145
+
146
+ if canEncodeOnce then
147
+ local scratch = Shared.acquireScratch()
148
+ scratch.currentPacket = name
149
+ codec.write(scratch, data)
150
+ scratch.currentPacket = nil
151
+ if scratch.refCount == 0 then
152
+ local payload = scratch.buff
153
+ local payloadLen = scratch.cursor
154
+ local server = Transport.server()
155
+ -- Channel.writeBatchEncoded is hot-swapped by enableStats; re-read per call.
156
+ local writeBatchEncoded = Channel.writeBatchEncoded
157
+ for i = 1, count do
158
+ writeBatchEncoded(
159
+ server.getChannel(players[i], isUnreliable),
160
+ reg,
161
+ payload,
162
+ payloadLen
163
+ )
164
+ end
165
+ Shared.releaseScratch()
166
+ bumpFires()
167
+ return
168
+ end
169
+ Shared.releaseScratch()
170
+ end
171
+
172
+ for i = 1, count do
173
+ sendToPlayer(players[i], data)
136
174
  end
137
175
  bumpFires()
138
176
  end
139
177
 
178
+ --[[
179
+ Resolve a target table into a { Player } list. Returns nil for unknown
180
+ sentinel kinds; the caller errors with the original target type.
181
+ ]]
182
+ local function resolveAudience(target: any): { Player }?
183
+ local kind = target._lyncKind
184
+ if kind == "all" then
185
+ return Players:GetPlayers()
186
+ end
187
+
188
+ if kind == "except" then
189
+ local excluded = target._excluded :: { [Player]: boolean }
190
+ local all = Players:GetPlayers()
191
+ local list = table.create(#all)
192
+ local n = 0
193
+ for _, player in all do
194
+ if not excluded[player] then
195
+ n += 1
196
+ list[n] = player
197
+ end
198
+ end
199
+ return list
200
+ end
201
+
202
+ if kind == "group" then
203
+ local members = target._members :: { [Player]: boolean }
204
+ local list: { Player } = {}
205
+ local n = 0
206
+ for player in members do
207
+ n += 1
208
+ list[n] = player
209
+ end
210
+ return list
211
+ end
212
+
213
+ if kind == nil then
214
+ -- Plain { Player } array. Validate entries on the way to a clean list.
215
+ local list: { Player } = {}
216
+ local n = 0
217
+ for _, player in target do
218
+ if isPlayer(player) then
219
+ n += 1
220
+ list[n] = player :: Player
221
+ end
222
+ end
223
+ return list
224
+ end
225
+
226
+ return nil
227
+ end
228
+
140
229
  local function sendToTarget(data: any, target: any): ()
141
230
  if isPlayer(target) then
142
231
  local dropped, sendData = applySend(data, target :: Player)
@@ -153,40 +242,11 @@ function Packet.define<T>(
153
242
  if dropped then
154
243
  return
155
244
  end
156
-
157
- local kind = target._lyncKind
158
- if kind == "all" then
159
- broadcast(Players:GetPlayers(), sendData)
160
- return
161
- end
162
-
163
- if kind == "except" then
164
- local excluded = target._excluded :: { [Player]: boolean }
165
- for _, p in Players:GetPlayers() do
166
- if not excluded[p] then
167
- sendToPlayer(p, sendData)
168
- end
169
- end
170
- bumpFires()
171
- return
172
- end
173
-
174
- if kind == "group" then
175
- for p in target._members :: { [Player]: boolean } do
176
- sendToPlayer(p, sendData)
177
- end
178
- bumpFires()
245
+ local audience = resolveAudience(target)
246
+ if audience then
247
+ broadcast(audience, sendData)
179
248
  return
180
249
  end
181
-
182
- -- Plain { Player } array.
183
- for _, p in target do
184
- if isPlayer(p) then
185
- sendToPlayer(p :: Player, sendData)
186
- end
187
- end
188
- bumpFires()
189
- return
190
250
  end
191
251
 
192
252
  Log.error(`"{name}" expected Player, table, Group, or Lync.all, got {typeof(target)}`)
@@ -56,6 +56,13 @@ local Base = {}
56
56
  Base.INITIAL_BUF = INITIAL_BUF
57
57
  Base.alloc = alloc
58
58
 
59
+ -- Cheap inline guard hoisted into every codec that grows a channel directly.
60
+ function Base.ensure(ch: Types.ChannelState, n: number): ()
61
+ if ch.cursor + n > ch.size then
62
+ alloc(ch, n)
63
+ end
64
+ end
65
+
59
66
  -- Floor is INITIAL_BUF: anything smaller errors on the very first write.
60
67
  function Base.setMaxSize(bytes: number): ()
61
68
  if bytes < INITIAL_BUF then
@@ -9,15 +9,36 @@ local Shared = require(script.Parent.Parent.composite.Shared)
9
9
  local Types = require(script.Parent.Parent.Parent.Types)
10
10
  local Varint = require(script.Parent.Parent.primitive.Varint)
11
11
 
12
+ -- Constants --------------------------------------------------------------
13
+
14
+ -- deltaArray wire flags. Distinct from Shared's struct/map flag spaces.
15
+ local DA_UNCHANGED = 0
16
+ local DA_FULL = 1
17
+ local DA_PATCH = 2
18
+
12
19
  -- Private ----------------------------------------------------------------
13
20
 
14
21
  local alloc = Base.alloc
22
+ local ensure = Base.ensure
15
23
  local allocDeltaId = Shared.allocDeltaId
24
+ local acquireScratch = Shared.acquireScratch
25
+ local releaseScratch = Shared.releaseScratch
26
+ local rangeEqual = Shared.rangeEqual
27
+ local copyToBuf = Shared.copyToBuf
28
+ local containsDelta = Shared.containsDelta
29
+ local enforceMaxCount = Shared.enforceMaxCount
16
30
  local boolBytes = Shared.boolBytes
17
31
  local packBoolArray = Shared.packBoolArray
18
32
  local unpackBoolArray = Shared.unpackBoolArray
19
33
  local varintRead = Varint.read
20
34
  local varintWrite = Varint.write
35
+ local varintLength = Varint.length
36
+ local writeu8 = buffer.writeu8
37
+ local readu8 = buffer.readu8
38
+ local bufCopy = buffer.copy
39
+ local bufLen = buffer.len
40
+
41
+ type DeltaArrayEntry = { eBuf: buffer, eLen: number }
21
42
 
22
43
  -- Bit-packed bool element fast path: 1 bit per entry instead of 1 byte.
23
44
  local function makeBoolArray(
@@ -37,16 +58,14 @@ local function makeBoolArray(
37
58
 
38
59
  read = function(src: buffer, pos: number, _refs: { Instance }?): ({ boolean }, number)
39
60
  local len, lenBytes = varintRead(src, pos)
40
- if maxCount and len > maxCount then
41
- Log.error(`count {len} exceeds max {maxCount}`)
42
- end
61
+ enforceMaxCount(len, maxCount)
43
62
  if len == 0 then
44
63
  return {}, lenBytes
45
64
  end
46
65
 
47
66
  local byteCount = boolBytes(len)
48
67
  local dataStart = pos + lenBytes
49
- if dataStart + byteCount > buffer.len(src) then
68
+ if dataStart + byteCount > bufLen(src) then
50
69
  Log.error(`payload {byteCount}B exceeds remaining buffer`)
51
70
  end
52
71
  local result = table.create(len, false)
@@ -92,16 +111,14 @@ local function makeDirectArray(
92
111
 
93
112
  read = function(src: buffer, pos: number, _refs: { Instance }?): ({ any }, number)
94
113
  local len, lenBytes = varintRead(src, pos)
95
- if maxCount and len > maxCount then
96
- Log.error(`count {len} exceeds max {maxCount}`)
97
- end
114
+ enforceMaxCount(len, maxCount)
98
115
  if len == 0 then
99
116
  return {}, lenBytes
100
117
  end
101
118
 
102
119
  local payloadBytes = len * elemSize
103
120
  local dataStart = pos + lenBytes
104
- if payloadBytes > buffer.len(src) - dataStart then
121
+ if payloadBytes > bufLen(src) - dataStart then
105
122
  Log.error(`payload {payloadBytes}B exceeds remaining buffer`)
106
123
  end
107
124
 
@@ -136,9 +153,7 @@ local function makeGenericArray(
136
153
 
137
154
  read = function(src: buffer, pos: number, refs: { Instance }?): ({ any }, number)
138
155
  local len, lenBytes = varintRead(src, pos)
139
- if maxCount and len > maxCount then
140
- Log.error(`count {len} exceeds max {maxCount}`)
141
- end
156
+ enforceMaxCount(len, maxCount)
142
157
  if len == 0 then
143
158
  return {}, lenBytes
144
159
  end
@@ -179,19 +194,263 @@ function Array.array(element: Types.InternalCodec<any>, maxCount: number?): Type
179
194
  if element._hasUnknown then
180
195
  codec._hasUnknown = true
181
196
  end
197
+ if containsDelta(element) then
198
+ codec._hasDelta = true
199
+ end
182
200
  return table.freeze(codec)
183
201
  end
184
202
 
185
203
  --[[
186
- UNCHANGED-or-FULL delta. Useful only when contents AND order are stable
187
- across frames; for unstable lists, plain array() avoids a 1-byte tax on
188
- every frame the byte-equality cache misses.
204
+ Per-index deltaArray. Wire:
205
+ [0] UNCHANGED (1 byte)
206
+ [1] FULL <count varint> <element bytes ...>
207
+ [2] PATCH <newLen varint> <changeCount varint>
208
+ <(idx varint, element bytes) ...>
209
+
210
+ Length shrinkage is implicit in newLen — entries past newLen drop from
211
+ the cached map. Length growth: appended indices show up as changes.
212
+ Reordered lists tend to mark every index changed; the writer falls back
213
+ to FULL when changeCount * 2 >= newLen since PATCH index varints eat
214
+ any savings past that ratio.
189
215
  ]]
190
216
  function Array.deltaArray(
191
217
  element: Types.InternalCodec<any>,
192
218
  maxCount: number?
193
219
  ): Types.InternalCodec<any>
194
- return Shared.makeUnchangedOrFullDelta(Array.array(element, maxCount), allocDeltaId(), Baseline)
220
+ -- Inner delta would chain-diff across elements within a frame, not across frames.
221
+ if containsDelta(element) then
222
+ Log.error("deltaArray element cannot contain delta state")
223
+ end
224
+
225
+ local deltaId = allocDeltaId()
226
+ local inner = Array.array(element, maxCount)
227
+
228
+ --[[
229
+ Encode each element into `scratch` and capture per-index byte ranges.
230
+ Returns the array length plus parallel arrays of offset/length so the
231
+ diff path can compare and emit individual entries without re-encoding.
232
+ ]]
233
+ local function encodePerIdx(
234
+ scratch: Types.ChannelState,
235
+ value: { any }
236
+ ): (number, { number }, { number })
237
+ local len = #value
238
+ enforceMaxCount(len, maxCount)
239
+ local off = table.create(len)
240
+ local elen = table.create(len)
241
+ for i = 1, len do
242
+ local before = scratch.cursor
243
+ element.write(scratch, value[i])
244
+ off[i] = before
245
+ elen[i] = scratch.cursor - before
246
+ end
247
+ return len, off, elen
248
+ end
249
+
250
+ return table.freeze({
251
+ _isDelta = true,
252
+ _isArray = true,
253
+ _element = element,
254
+ _maxCount = maxCount,
255
+
256
+ write = function(ch: Types.ChannelState, value: { any }): ()
257
+ if typeof(value) ~= "table" then
258
+ Log.error(`expected table, got {typeof(value)}`)
259
+ end
260
+
261
+ local cache = ch.deltas[deltaId] :: any
262
+ local scratch = acquireScratch()
263
+ local len, off, elen = encodePerIdx(scratch, value)
264
+ local scratchBuf = scratch.buff
265
+
266
+ -- First frame: emit FULL using the per-index scratch bytes.
267
+ if not cache then
268
+ ensure(ch, 1)
269
+ writeu8(ch.buff, ch.cursor, DA_FULL)
270
+ ch.cursor += 1
271
+ varintWrite(ch, len)
272
+ local payloadLen = scratch.cursor
273
+ ensure(ch, payloadLen)
274
+ bufCopy(ch.buff, ch.cursor, scratchBuf, 0, payloadLen)
275
+ ch.cursor += payloadLen
276
+
277
+ local perIdx: { [number]: DeltaArrayEntry } = {}
278
+ for i = 1, len do
279
+ local n = elen[i]
280
+ perIdx[i] = { eBuf = copyToBuf(nil, scratchBuf, off[i], n), eLen = n }
281
+ end
282
+ ch.deltas[deltaId] = { perIdx = perIdx, length = len } :: any
283
+ releaseScratch()
284
+ return
285
+ end
286
+
287
+ local cachedPerIdx = cache.perIdx :: { [number]: DeltaArrayEntry }
288
+ local cachedLen = cache.length :: number
289
+
290
+ -- Find changed indices in the overlap; appended indices count as changed.
291
+ local changed: { number } = {}
292
+ local changedCount = 0
293
+ local overlap = if len < cachedLen then len else cachedLen
294
+ for i = 1, overlap do
295
+ local entry = cachedPerIdx[i]
296
+ local n = elen[i]
297
+ if
298
+ not entry
299
+ or n ~= entry.eLen
300
+ or not rangeEqual(scratchBuf, off[i], entry.eBuf, 0, n)
301
+ then
302
+ changedCount += 1
303
+ changed[changedCount] = i
304
+ end
305
+ end
306
+ for i = overlap + 1, len do
307
+ changedCount += 1
308
+ changed[changedCount] = i
309
+ end
310
+
311
+ if changedCount == 0 and len == cachedLen then
312
+ ensure(ch, 1)
313
+ writeu8(ch.buff, ch.cursor, DA_UNCHANGED)
314
+ ch.cursor += 1
315
+ releaseScratch()
316
+ return
317
+ end
318
+
319
+ -- Pick FULL vs PATCH on actual byte cost. Both share `1 + varintLength(len)`
320
+ -- so it cancels: compare PATCH's index-header overhead + changed payload
321
+ -- against FULL's full payload.
322
+ local fullPayload = scratch.cursor
323
+ local patchPayload = varintLength(changedCount)
324
+ for i = 1, changedCount do
325
+ local idx = changed[i]
326
+ patchPayload += varintLength(idx) + elen[idx]
327
+ end
328
+
329
+ if patchPayload < fullPayload then
330
+ ensure(ch, 1)
331
+ writeu8(ch.buff, ch.cursor, DA_PATCH)
332
+ ch.cursor += 1
333
+ varintWrite(ch, len)
334
+ varintWrite(ch, changedCount)
335
+ for i = 1, changedCount do
336
+ local idx = changed[i]
337
+ varintWrite(ch, idx)
338
+ local n = elen[idx]
339
+ ensure(ch, n)
340
+ bufCopy(ch.buff, ch.cursor, scratchBuf, off[idx], n)
341
+ ch.cursor += n
342
+ end
343
+ else
344
+ ensure(ch, 1)
345
+ writeu8(ch.buff, ch.cursor, DA_FULL)
346
+ ch.cursor += 1
347
+ varintWrite(ch, len)
348
+ ensure(ch, fullPayload)
349
+ bufCopy(ch.buff, ch.cursor, scratchBuf, 0, fullPayload)
350
+ ch.cursor += fullPayload
351
+ end
352
+
353
+ -- Update cache: refresh changed (and appended), drop truncated.
354
+ for i = 1, changedCount do
355
+ local idx = changed[i]
356
+ local n = elen[idx]
357
+ local entry = cachedPerIdx[idx]
358
+ if entry then
359
+ entry.eBuf = copyToBuf(entry.eBuf, scratchBuf, off[idx], n)
360
+ entry.eLen = n
361
+ else
362
+ cachedPerIdx[idx] = { eBuf = copyToBuf(nil, scratchBuf, off[idx], n), eLen = n }
363
+ end
364
+ end
365
+ for i = len + 1, cachedLen do
366
+ cachedPerIdx[i] = nil
367
+ end
368
+ cache.length = len
369
+
370
+ releaseScratch()
371
+ end,
372
+
373
+ read = function(src: buffer, pos: number, refs: { Instance }?): ({ any }, number)
374
+ if pos >= bufLen(src) then
375
+ Log.error("truncated deltaArray header")
376
+ end
377
+ local flag = readu8(src, pos)
378
+
379
+ if flag == DA_UNCHANGED then
380
+ local cached = Baseline.getCache(deltaId)
381
+ if cached == nil then
382
+ Log.error("UNCHANGED flag before any FULL frame; baseline missing")
383
+ end
384
+ return cached, 1
385
+ end
386
+
387
+ if flag == DA_FULL then
388
+ local value, consumed = inner.read(src, pos + 1, refs)
389
+ Baseline.setCache(deltaId, value)
390
+ return value, 1 + consumed
391
+ end
392
+
393
+ if flag == DA_PATCH then
394
+ local cached = Baseline.getCache(deltaId)
395
+ if cached == nil then
396
+ Log.error("deltaArray PATCH before any FULL frame; baseline missing")
397
+ end
398
+ --[[
399
+ Capture the cached length BEFORE clone+mutate; #result
400
+ becomes unreliable once we set entries at appended indices
401
+ past the original boundary.
402
+ ]]
403
+ local cachedLen = #cached
404
+ local result: { any } = table.clone(cached)
405
+ local total = 1
406
+
407
+ local newLen, lLen = varintRead(src, pos + total)
408
+ if lLen == 0 then
409
+ Log.error("truncated deltaArray length")
410
+ end
411
+ enforceMaxCount(newLen, maxCount, "newLen")
412
+ total += lLen
413
+
414
+ local changeCount, cLen = varintRead(src, pos + total)
415
+ if cLen == 0 then
416
+ Log.error("truncated deltaArray change count")
417
+ end
418
+ -- changeCount is also bounded by newLen: every index appears at most once.
419
+ if changeCount > newLen then
420
+ Log.error(`deltaArray changeCount {changeCount} exceeds newLen {newLen}`)
421
+ end
422
+ total += cLen
423
+
424
+ for _ = 1, changeCount do
425
+ local idx, iLen = varintRead(src, pos + total)
426
+ if iLen == 0 then
427
+ Log.error("truncated deltaArray index")
428
+ end
429
+ -- Reject out-of-range idx so the wire can't poison the cache with
430
+ -- arbitrary keys that survive truncation.
431
+ if idx < 1 or idx > newLen then
432
+ Log.error(`deltaArray index {idx} out of range [1, {newLen}]`)
433
+ end
434
+ total += iLen
435
+ local v, vc = element.read(src, pos + total, refs)
436
+ if vc == 0 then
437
+ Log.error("truncated deltaArray element")
438
+ end
439
+ total += vc
440
+ result[idx] = v
441
+ end
442
+
443
+ for i = newLen + 1, cachedLen do
444
+ result[i] = nil
445
+ end
446
+
447
+ Baseline.setCache(deltaId, result)
448
+ return result, total
449
+ end
450
+
451
+ Log.error(`invalid deltaArray flag {flag}`)
452
+ end,
453
+ }) :: Types.InternalCodec<any>
195
454
  end
196
455
 
197
456
  return table.freeze(Array)