@axpecter/lync 2.3.1 → 2.3.3

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.
@@ -18,7 +18,6 @@ local TS_FULL = 3
18
18
 
19
19
  local INITIAL_BUF = 1024
20
20
  local HEADER_BYTE = 1
21
- local FRAME_MSB = Constants.FRAME_MSB
22
21
  local U8_MAX = Constants.U8_MAX
23
22
  local U16_MAX = Constants.U16_MAX
24
23
  local MAX_BATCH_ITEMS = Constants.MAX_BATCH_ITEMS
@@ -48,8 +47,8 @@ local EMPTY_REFS = table.freeze({}) :: { Instance }
48
47
  -- Private ----------------------------------------------------------------
49
48
 
50
49
  local alloc = Base.alloc
50
+ local ensure = Base.ensure
51
51
  local band = bit32.band
52
- local bor = bit32.bor
53
52
  local bufCopy = buffer.copy
54
53
  local writeu8 = buffer.writeu8
55
54
  local writeu16 = buffer.writeu16
@@ -57,21 +56,7 @@ local writef64 = buffer.writef64
57
56
  local round = math.round
58
57
  local osClock = os.clock
59
58
 
60
- local function ensure(ch: Types.ChannelState, n: number): ()
61
- if ch.cursor + n > ch.size then
62
- alloc(ch, n)
63
- end
64
- end
65
-
66
- local function writeTimestamp(b: buffer, off: number, mode: number): ()
67
- if mode == TS_FRAME then
68
- writeu8(b, off, _frameCounter)
69
- elseif mode == TS_OFFSET then
70
- writeu16(b, off, _offsetMs)
71
- elseif mode == TS_FULL then
72
- writef64(b, off, _clock)
73
- end
74
- end
59
+ -- id is in [1, 127] (Registry.MAX_ID), so id + 128 == bor(FRAME_MSB, id).
75
60
 
76
61
  --[[
77
62
  Single -> multi upgrade. Patches the previously-emitted single-item
@@ -89,10 +74,7 @@ local function upgradeSingleToMulti(ch: Types.ChannelState, regId: number, heade
89
74
  ensure(ch, 2)
90
75
  -- Re-bind: ensure() may have realloced the buffer.
91
76
  local b = ch.buff
92
-
93
- if payloadLen > 0 then
94
- bufCopy(b, afterTs + 2, b, afterTs, payloadLen)
95
- end
77
+ bufCopy(b, afterTs + 2, b, afterTs, payloadLen)
96
78
 
97
79
  ch.countPos = afterTs
98
80
  writeu16(b, afterTs, 0)
@@ -100,34 +82,106 @@ local function upgradeSingleToMulti(ch: Types.ChannelState, regId: number, heade
100
82
  ch.singleMode = false
101
83
  end
102
84
 
85
+ -- itemCount bump is left to the caller, AFTER the body write succeeds.
86
+ local function openOrUpgrade(ch: Types.ChannelState, reg: Types.Registration): ()
87
+ if reg.id ~= ch.lastId or ch.itemCount >= MAX_BATCH_ITEMS then
88
+ reg._openFn(ch, reg.id)
89
+ elseif ch.singleMode then
90
+ upgradeSingleToMulti(ch, reg.id, reg.headerSize)
91
+ end
92
+ end
93
+
94
+ local function spliceEncoded(ch: Types.ChannelState, payload: buffer, payloadLen: number): ()
95
+ local cursor = ch.cursor
96
+ if cursor + payloadLen > ch.size then
97
+ alloc(ch, payloadLen)
98
+ end
99
+ bufCopy(ch.buff, cursor, payload, 0, payloadLen)
100
+ ch.cursor = cursor + payloadLen
101
+ end
102
+
103
103
  --[[
104
- Two writeBatch implementations, swapped at startup by enableStats(). For
105
- high-rate channels this saves ~1 register read + 1 compare + 1 jump per
106
- item versus branching on `_statsEnabled` inside the loop.
104
+ Hot-swapped by enableStats(). A throwing codec must rewind cursor + refs:
105
+ the next flush sends `[0, ch.cursor)`, so a partial write past `cursor`
106
+ desyncs the receiver from the truncation point onward.
107
107
  ]]
108
108
  local writeBatchPlain: (Types.ChannelState, Types.Registration, any) -> ()
109
109
  local writeBatchStats: (Types.ChannelState, Types.Registration, any) -> ()
110
110
 
111
+ local function rewindOnThrow(ch: Types.ChannelState, cursor: number, refCount: number): ()
112
+ ch.cursor = cursor
113
+ if ch.refCount > refCount then
114
+ local refs = ch.refs
115
+ for i = refCount + 1, ch.refCount do
116
+ refs[i] = nil
117
+ end
118
+ ch.refCount = refCount
119
+ end
120
+ end
121
+
111
122
  writeBatchPlain = function(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
112
- if reg.id ~= ch.lastId or ch.itemCount >= MAX_BATCH_ITEMS then
113
- reg._openFn(ch, reg.id)
114
- elseif ch.singleMode then
115
- upgradeSingleToMulti(ch, reg.id, reg.headerSize)
123
+ openOrUpgrade(ch, reg)
124
+ local rewindCursor = ch.cursor
125
+ local rewindRefCount = ch.refCount
126
+ local ok, err = pcall(reg.codec.write, ch, data)
127
+ if not ok then
128
+ rewindOnThrow(ch, rewindCursor, rewindRefCount)
129
+ error(err, 0)
116
130
  end
117
131
  ch.itemCount += 1
118
- reg.codec.write(ch, data)
119
132
  end
120
133
 
121
134
  writeBatchStats = function(ch: Types.ChannelState, reg: Types.Registration, data: any): ()
122
- if reg.id ~= ch.lastId or ch.itemCount >= MAX_BATCH_ITEMS then
123
- reg._openFn(ch, reg.id)
124
- elseif ch.singleMode then
125
- upgradeSingleToMulti(ch, reg.id, reg.headerSize)
135
+ openOrUpgrade(ch, reg)
136
+ local rewindCursor = ch.cursor
137
+ local rewindRefCount = ch.refCount
138
+ local ok, err = pcall(reg.codec.write, ch, data)
139
+ if not ok then
140
+ rewindOnThrow(ch, rewindCursor, rewindRefCount)
141
+ error(err, 0)
142
+ end
143
+ ch.itemCount += 1
144
+ reg.bytesSent += ch.cursor - rewindCursor
145
+ end
146
+
147
+ --[[
148
+ Splice a pre-encoded payload into the channel. Used by Packet.broadcast
149
+ to encode the codec output once per fanout instead of N times.
150
+ ]]
151
+ local writeBatchEncodedPlain: (Types.ChannelState, Types.Registration, buffer, number) -> ()
152
+ local writeBatchEncodedStats: (Types.ChannelState, Types.Registration, buffer, number) -> ()
153
+
154
+ writeBatchEncodedPlain = function(
155
+ ch: Types.ChannelState,
156
+ reg: Types.Registration,
157
+ payload: buffer,
158
+ payloadLen: number
159
+ ): ()
160
+ openOrUpgrade(ch, reg)
161
+ local rewindCursor = ch.cursor
162
+ local ok, err = pcall(spliceEncoded, ch, payload, payloadLen)
163
+ if not ok then
164
+ ch.cursor = rewindCursor
165
+ error(err, 0)
126
166
  end
127
167
  ch.itemCount += 1
128
- local before = ch.cursor
129
- reg.codec.write(ch, data)
130
- reg.bytesSent += ch.cursor - before
168
+ end
169
+
170
+ writeBatchEncodedStats = function(
171
+ ch: Types.ChannelState,
172
+ reg: Types.Registration,
173
+ payload: buffer,
174
+ payloadLen: number
175
+ ): ()
176
+ openOrUpgrade(ch, reg)
177
+ local rewindCursor = ch.cursor
178
+ local ok, err = pcall(spliceEncoded, ch, payload, payloadLen)
179
+ if not ok then
180
+ ch.cursor = rewindCursor
181
+ error(err, 0)
182
+ end
183
+ ch.itemCount += 1
184
+ reg.bytesSent += payloadLen
131
185
  end
132
186
 
133
187
  -- Public -----------------------------------------------------------------
@@ -209,49 +263,116 @@ function Channel.fullReset(ch: Types.ChannelState): ()
209
263
  Channel.reset(ch)
210
264
  ch.prevDump = nil
211
265
  ch.prevDumpLen = 0
212
- table.clear(ch.deltas)
266
+ if next(ch.deltas) ~= nil then
267
+ table.clear(ch.deltas)
268
+ end
213
269
  end
214
270
 
215
271
  --[[
216
- Resolve the batch-open closure for a given timestamp mode and packet
217
- name. Returned function seals the prior batch, writes the single-mode
218
- header [0x80 | id] + optional timestamp, and records the cursor so
219
- writeBatch can upgrade single -> multi later.
272
+ Per-mode openFn variants. Each seals the prior batch, writes the
273
+ single-mode header [0x80 | id] + optional timestamp, and records the
274
+ cursor so writeBatch can upgrade single -> multi later.
220
275
  ]]
221
- function Channel.resolveOpenFn(
222
- timestampMode: number,
223
- name: string
224
- ): (ch: Types.ChannelState, id: number) -> ()
225
- local headerLen = HEADER_BYTE + TS_BYTES[timestampMode]
226
-
276
+ local function makeOpenNoTs(name: string): (Types.ChannelState, number) -> ()
227
277
  return function(ch: Types.ChannelState, id: number): ()
228
- --[[
229
- Inline sealCount: this is the only call site that can take the
230
- multi-mode branch on a non-fresh channel.
231
- ]]
232
278
  if ch.lastId >= 0 and not ch.singleMode then
233
279
  writeu16(ch.buff, ch.countPos, ch.itemCount)
234
280
  end
281
+ ch.currentPacket = name
282
+ ch.lastId = id
283
+ ch.itemCount = 0
284
+ local cursor = ch.cursor
285
+ if cursor + 1 > ch.size then
286
+ alloc(ch, 1)
287
+ end
288
+ writeu8(ch.buff, cursor, id + 128)
289
+ ch.singleMode = true
290
+ ch.singlePos = cursor
291
+ ch.cursor = cursor + 1
292
+ end
293
+ end
235
294
 
295
+ local function makeOpenFrameTs(name: string): (Types.ChannelState, number) -> ()
296
+ return function(ch: Types.ChannelState, id: number): ()
297
+ if ch.lastId >= 0 and not ch.singleMode then
298
+ writeu16(ch.buff, ch.countPos, ch.itemCount)
299
+ end
236
300
  ch.currentPacket = name
237
301
  ch.lastId = id
238
302
  ch.itemCount = 0
303
+ local cursor = ch.cursor
304
+ if cursor + 2 > ch.size then
305
+ alloc(ch, 2)
306
+ end
307
+ local b = ch.buff
308
+ writeu8(b, cursor, id + 128)
309
+ writeu8(b, cursor + 1, _frameCounter)
310
+ ch.singleMode = true
311
+ ch.singlePos = cursor
312
+ ch.cursor = cursor + 2
313
+ end
314
+ end
239
315
 
316
+ local function makeOpenOffsetTs(name: string): (Types.ChannelState, number) -> ()
317
+ return function(ch: Types.ChannelState, id: number): ()
318
+ if ch.lastId >= 0 and not ch.singleMode then
319
+ writeu16(ch.buff, ch.countPos, ch.itemCount)
320
+ end
321
+ ch.currentPacket = name
322
+ ch.lastId = id
323
+ ch.itemCount = 0
240
324
  local cursor = ch.cursor
241
- if cursor + headerLen > ch.size then
242
- alloc(ch, headerLen)
325
+ if cursor + 3 > ch.size then
326
+ alloc(ch, 3)
243
327
  end
244
328
  local b = ch.buff
245
- writeu8(b, cursor, bor(FRAME_MSB, id))
246
- writeTimestamp(b, cursor + 1, timestampMode)
329
+ writeu8(b, cursor, id + 128)
330
+ writeu16(b, cursor + 1, _offsetMs)
331
+ ch.singleMode = true
332
+ ch.singlePos = cursor
333
+ ch.cursor = cursor + 3
334
+ end
335
+ end
247
336
 
337
+ local function makeOpenFullTs(name: string): (Types.ChannelState, number) -> ()
338
+ return function(ch: Types.ChannelState, id: number): ()
339
+ if ch.lastId >= 0 and not ch.singleMode then
340
+ writeu16(ch.buff, ch.countPos, ch.itemCount)
341
+ end
342
+ ch.currentPacket = name
343
+ ch.lastId = id
344
+ ch.itemCount = 0
345
+ local cursor = ch.cursor
346
+ if cursor + 9 > ch.size then
347
+ alloc(ch, 9)
348
+ end
349
+ local b = ch.buff
350
+ writeu8(b, cursor, id + 128)
351
+ writef64(b, cursor + 1, _clock)
248
352
  ch.singleMode = true
249
353
  ch.singlePos = cursor
250
- ch.cursor = cursor + headerLen
354
+ ch.cursor = cursor + 9
355
+ end
356
+ end
357
+
358
+ function Channel.resolveOpenFn(
359
+ timestampMode: number,
360
+ name: string
361
+ ): (ch: Types.ChannelState, id: number) -> ()
362
+ if timestampMode == TS_NONE then
363
+ return makeOpenNoTs(name)
364
+ end
365
+ if timestampMode == TS_FRAME then
366
+ return makeOpenFrameTs(name)
251
367
  end
368
+ if timestampMode == TS_OFFSET then
369
+ return makeOpenOffsetTs(name)
370
+ end
371
+ return makeOpenFullTs(name)
252
372
  end
253
373
 
254
374
  Channel.writeBatch = writeBatchPlain
375
+ Channel.writeBatchEncoded = writeBatchEncodedPlain
255
376
 
256
377
  --[[
257
378
  Every caller (Server.flushReliable / flushUnreliable, Client.flush)
@@ -282,7 +403,8 @@ function Channel.writeQuery(
282
403
  id: number,
283
404
  correlationId: number,
284
405
  codec: Types.InternalCodec<any>?,
285
- data: any
406
+ data: any,
407
+ name: string?
286
408
  ): ()
287
409
  if ch.lastId >= 0 and not ch.singleMode then
288
410
  writeu16(ch.buff, ch.countPos, ch.itemCount)
@@ -294,9 +416,14 @@ function Channel.writeQuery(
294
416
  here would never be observed before the next reset overwrites it.
295
417
  ]]
296
418
  ch.lastId = -1
419
+ -- Without this, an alloc overflow inside the payload codec would log
420
+ -- the previous packet's name (whichever _openFn last touched currentPacket).
421
+ if name then
422
+ ch.currentPacket = name
423
+ end
297
424
 
298
425
  local hasPayload = codec ~= nil and data ~= nil
299
- local headerByte = if hasPayload then id else bor(FRAME_MSB, id)
426
+ local headerByte = if hasPayload then id else id + 128
300
427
 
301
428
  local cursor = ch.cursor
302
429
  if cursor + 1 > ch.size then
@@ -335,6 +462,7 @@ end
335
462
  function Channel.enableStats(): ()
336
463
  _statsEnabled = true
337
464
  Channel.writeBatch = writeBatchStats
465
+ Channel.writeBatchEncoded = writeBatchEncodedStats
338
466
  end
339
467
 
340
468
  function Channel.statsEnabled(): boolean
@@ -350,6 +478,7 @@ function Channel.resetGlobals(): ()
350
478
  _offsetMs = 0
351
479
  _clock = 0.0
352
480
  Channel.writeBatch = writeBatchPlain
481
+ Channel.writeBatchEncoded = writeBatchEncodedPlain
353
482
  end
354
483
 
355
484
  return Channel
@@ -20,23 +20,24 @@ local _maxSize = DEFAULT_SIZE
20
20
 
21
21
  local Pool = {}
22
22
 
23
- -- fullReset wipes the prior owner's XOR baseline and delta caches.
24
23
  function Pool.acquire(): Types.ChannelState
25
24
  if _depth > 0 then
26
25
  local ch = _stack[_depth]
27
26
  _stack[_depth] = nil
28
27
  _depth -= 1
29
- Channel.fullReset(ch)
30
28
  return ch
31
29
  end
32
30
  return Channel.create()
33
31
  end
34
32
 
35
- -- Above the cap, drop on the floor; the GC reclaims the buffer.
33
+ -- Scrub on release so the pool never holds the prior owner's Instance refs
34
+ -- or XOR baseline buffer; otherwise a player who disconnects mid-frame leaves
35
+ -- those rooted in the pool until the slot is re-acquired (potentially never).
36
36
  function Pool.release(ch: Types.ChannelState): ()
37
37
  if _depth >= _maxSize then
38
38
  return
39
39
  end
40
+ Channel.fullReset(ch)
40
41
  _depth += 1
41
42
  _stack[_depth] = ch
42
43
  end
@@ -207,17 +207,27 @@ function Reader.process(
207
207
  local doGate = isServer and reg.needsGate and player ~= nil
208
208
  local regSignal = reg.signal
209
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
212
+ local accepted = 0
210
213
 
211
214
  for _ = 1, count do
212
- if pos >= incomingLen then
215
+ if not isZeroSize and pos >= incomingLen then
213
216
  Log.warn(`codec for "{regName}" ran past frame end; aborting`)
214
217
  return false
215
218
  end
216
219
  local value, consumed = regCodec.read(incoming, pos, refs)
217
- if consumed == 0 then
220
+ if not isZeroSize and consumed == 0 then
218
221
  Log.warn(`codec for "{regName}" reported 0 bytes; aborting`)
219
222
  return false
220
223
  end
224
+ -- Fixed-size codecs MUST report consumed == _size or `pos` desyncs silently.
225
+ if sizeMeta and consumed ~= sizeMeta then
226
+ Log.warn(
227
+ `codec for "{regName}" consumed {consumed} bytes, expected {sizeMeta}; aborting`
228
+ )
229
+ return false
230
+ end
221
231
  pos += consumed
222
232
  if pos > incomingLen then
223
233
  Log.warn(`codec for "{regName}" overran frame end; aborting`)
@@ -240,15 +250,14 @@ function Reader.process(
240
250
  value = runReceive(value, regName, player)
241
251
  end
242
252
 
243
- if statsEnabled then
244
- reg.recvFires += 1
245
- end
253
+ accepted += 1
246
254
 
247
255
  -- tsValue is nil when mode == TS_NONE; pass through to skip a per-item branch.
248
256
  regSignal:fire(value, player, tsValue)
249
257
  end
250
258
 
251
259
  if statsEnabled then
260
+ reg.recvFires += accepted
252
261
  reg.bytesReceived += (pos - frameStart)
253
262
  end
254
263
  elseif kind == KIND_REQUEST or kind == KIND_RESPONSE then
@@ -266,13 +275,15 @@ function Reader.process(
266
275
  end
267
276
  reg.signal:fire(nil, player, corrId)
268
277
  else
269
- if pos >= incomingLen then
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
270
282
  Log.warn(`query "{reg.name}" body missing; aborting`)
271
283
  return false
272
284
  end
273
- local regCodec = reg.codec
274
285
  local value, consumed = regCodec.read(incoming, pos, refs)
275
- if consumed == 0 then
286
+ if not qIsZeroSize and consumed == 0 then
276
287
  Log.warn(`query codec for "{reg.name}" reported 0 bytes; aborting`)
277
288
  return false
278
289
  end
@@ -32,8 +32,6 @@ local xorApply = Channel.xorApply
32
32
  local sealAndDump = Channel.sealAndDump
33
33
  local resetCh = Channel.reset
34
34
 
35
- type StatsField = "bytesSent" | "bytesReceived"
36
-
37
35
  local function getOrCreateChannel(
38
36
  map: { [Player]: Types.ChannelState },
39
37
  player: Player
@@ -46,16 +44,26 @@ local function getOrCreateChannel(
46
44
  return ch
47
45
  end
48
46
 
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
47
+ -- Hot-swapped at Server.start; stats-off path is a tail-callable noop.
48
+ local function noopStat(_player: Player, _byteLen: number): () end
49
+
50
+ local function realBytesSent(player: Player, byteLen: number): ()
51
+ local ps = _playerStats[player]
52
+ if ps then
53
+ ps.bytesSent += byteLen
56
54
  end
57
55
  end
58
56
 
57
+ local function realBytesReceived(player: Player, byteLen: number): ()
58
+ local ps = _playerStats[player]
59
+ if ps then
60
+ ps.bytesReceived += byteLen
61
+ end
62
+ end
63
+
64
+ local addBytesSent: (Player, number) -> () = noopStat
65
+ local addBytesReceived: (Player, number) -> () = noopStat
66
+
59
67
  local function flushReliable(player: Player): ()
60
68
  local rCh = _reliableChannels[player]
61
69
  if not rCh or rCh.cursor == 0 then
@@ -69,7 +77,7 @@ local function flushReliable(player: Player): ()
69
77
 
70
78
  Bridge.fireClient(player, xored, refs)
71
79
  resetCh(rCh)
72
- addStat(player, "bytesSent", byteLen)
80
+ addBytesSent(player, byteLen)
73
81
  end
74
82
 
75
83
  local function flushUnreliable(player: Player): ()
@@ -81,7 +89,7 @@ local function flushUnreliable(player: Player): ()
81
89
  local snapshot, refs, byteLen = sealAndDump(uCh)
82
90
  Bridge.fireClientUnreliable(player, snapshot, refs)
83
91
  resetCh(uCh)
84
- addStat(player, "bytesSent", byteLen)
92
+ addBytesSent(player, byteLen)
85
93
  end
86
94
 
87
95
  local function flushPlayer(player: Player): ()
@@ -116,7 +124,7 @@ local function handleIncomingReliable(player: Player, data: any, refs: any?): ()
116
124
  _prevIncomingLen[player] = nil
117
125
  end
118
126
 
119
- addStat(player, "bytesReceived", incomingLen)
127
+ addBytesReceived(player, incomingLen)
120
128
  end
121
129
 
122
130
  local function handleIncomingUnreliable(player: Player, data: any, refs: any?): ()
@@ -127,7 +135,7 @@ local function handleIncomingUnreliable(player: Player, data: any, refs: any?):
127
135
 
128
136
  local incomingLen = buffer.len(incoming)
129
137
  Reader.process(incoming, incomingLen, refs, player, true)
130
- addStat(player, "bytesReceived", incomingLen)
138
+ addBytesReceived(player, incomingLen)
131
139
  end
132
140
 
133
141
  local function clearPlayerState(player: Player): ()
@@ -161,6 +169,15 @@ function Server.getChannel(player: Player, isUnreliable: boolean): Types.Channel
161
169
  )
162
170
  end
163
171
 
172
+ -- Pre-resolved variants for broadcast: skip the per-audience-member isUnreliable branch.
173
+ function Server.getReliableChannel(player: Player): Types.ChannelState
174
+ return getOrCreateChannel(_reliableChannels, player)
175
+ end
176
+
177
+ function Server.getUnreliableChannel(player: Player): Types.ChannelState
178
+ return getOrCreateChannel(_unreliableChannels, player)
179
+ end
180
+
164
181
  function Server.flush(): ()
165
182
  if Channel.hasTimestamps() then
166
183
  Channel.updateTimestamp()
@@ -182,6 +199,11 @@ function Server.start(): ()
182
199
  Bridge.reliable().OnServerEvent:Connect(handleIncomingReliable)
183
200
  Bridge.unreliable().OnServerEvent:Connect(handleIncomingUnreliable)
184
201
 
202
+ if statsEnabled() then
203
+ addBytesSent = realBytesSent
204
+ addBytesReceived = realBytesReceived
205
+ end
206
+
185
207
  Players.PlayerAdded:Connect(function(player: Player)
186
208
  if statsEnabled() then
187
209
  _playerStats[player] = { bytesSent = 0, bytesReceived = 0 }
@@ -46,11 +46,9 @@ function Buf.rangeEqual(a: buffer, offA: number, b: buffer, offB: number, len: n
46
46
  return true
47
47
  end
48
48
 
49
- function Buf.snapshot(src: buffer, len: number): buffer
49
+ function Buf.snapshot(src: buffer, len: number, srcOff: number?): buffer
50
50
  local out = bufCreate(len)
51
- if len > 0 then
52
- bufCopy(out, 0, src, 0, len)
53
- end
51
+ bufCopy(out, 0, src, srcOff or 0, len)
54
52
  return out
55
53
  end
56
54
 
@@ -10,12 +10,34 @@ local ceil = math.ceil
10
10
  local maxN = math.max
11
11
  local minN = math.min
12
12
 
13
+ local writeu8 = buffer.writeu8
14
+ local writeu16 = buffer.writeu16
15
+ local readu8 = buffer.readu8
16
+ local readu16 = buffer.readu16
17
+ local band = bit32.band
18
+ local bor = bit32.bor
19
+ local rshift = bit32.rshift
20
+ local lshift = bit32.lshift
21
+
22
+ --[[
23
+ 24-bit wire form. Splits into u16 + u8 since the buffer API has no native
24
+ u24. Saves 1 byte over u32 for ranges needing 17–24 bits of precision.
25
+ ]]
26
+ local function writeu24(b: buffer, off: number, value: number): ()
27
+ writeu16(b, off, band(value, 0xFFFF))
28
+ writeu8(b, off + 2, band(rshift(value, 16), 0xFF))
29
+ end
30
+
31
+ local function readu24(b: buffer, off: number): number
32
+ return bor(readu16(b, off), lshift(readu8(b, off + 2), 16))
33
+ end
34
+
13
35
  -- Public -----------------------------------------------------------------
14
36
 
15
37
  local Quantize = {}
16
38
 
17
39
  --[[
18
- Pick the narrowest u8/u16/u32 wire form for [rangeMin, rangeMax] at
40
+ Pick the narrowest u8/u16/u24/u32 wire form for [rangeMin, rangeMax] at
19
41
  `precision`. Returns (compBytes, wfn, rfn, scale, invScale, delta).
20
42
  `apiName` shows in error messages so callers see their public surface.
21
43
  ]]
@@ -32,8 +54,8 @@ function Quantize.setup(
32
54
  number,
33
55
  number
34
56
  )
35
- if rangeMin > rangeMax then
36
- Log.error(`{apiName}: min ({rangeMin}) must be <= max ({rangeMax})`)
57
+ if rangeMin >= rangeMax then
58
+ Log.error(`{apiName}: min ({rangeMin}) must be < max ({rangeMax})`)
37
59
  end
38
60
  if precision <= 0 then
39
61
  Log.error(`{apiName}: precision must be positive, got {precision}`)
@@ -50,6 +72,8 @@ function Quantize.setup(
50
72
  compBytes, wfn, rfn = 1, buffer.writeu8, buffer.readu8
51
73
  elseif maxInt <= 0xFFFF then
52
74
  compBytes, wfn, rfn = 2, buffer.writeu16, buffer.readu16
75
+ elseif maxInt <= 0xFFFFFF then
76
+ compBytes, wfn, rfn = 3, writeu24, readu24
53
77
  else
54
78
  compBytes, wfn, rfn = 4, buffer.writeu32, buffer.readu32
55
79
  end