@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 +29 -19
- package/package.json +1 -1
- package/src/Types.luau +12 -6
- package/src/api/Packet.luau +95 -35
- package/src/codec/Base.luau +7 -0
- package/src/codec/composite/Array.luau +274 -15
- package/src/codec/composite/Map.luau +328 -39
- package/src/codec/composite/Optional.luau +4 -0
- package/src/codec/composite/Shared.luau +60 -81
- package/src/codec/composite/Struct.luau +13 -3
- package/src/codec/composite/Tagged.luau +8 -0
- package/src/codec/composite/Tuple.luau +12 -1
- package/src/codec/datatype/Buffer.luau +1 -3
- package/src/codec/datatype/CFrame.luau +6 -106
- package/src/codec/meta/DeltaScalar.luau +390 -0
- package/src/codec/primitive/Varint.luau +13 -7
- package/src/codec/primitive/Zint.luau +99 -0
- package/src/index.d.ts +46 -0
- package/src/init.luau +7 -0
- package/src/internal/Channel.luau +60 -25
- package/src/transport/Reader.luau +16 -5
- package/src/util/Buffer.luau +2 -4
- package/src/util/Quantize.luau +27 -3
- package/src/util/Quat.luau +124 -0
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.
|
|
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;
|
|
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
|
|
288
|
-
| `vec3(min, max, precision)` | 3 / 6 / 12 | Per-component
|
|
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
|
-
|
|
305
|
+
Tracks the previous frame's value and ships only what changed. Rejected on `unreliable = true`.
|
|
305
306
|
|
|
306
|
-
| Codec |
|
|
307
|
-
|
|
308
|
-
| `deltaStruct(schema)` |
|
|
309
|
-
| `deltaArray(c, max?)` |
|
|
310
|
-
| `deltaMap(k, v, max?)` |
|
|
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** | **
|
|
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]`
|
|
364
|
-
| `array<entity>[100]` reused | **2.4** |
|
|
365
|
-
| `array<bool>[1000]`
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
368
|
-
| `
|
|
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
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
|
-
|
|
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?,
|
package/src/api/Packet.luau
CHANGED
|
@@ -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
|
-
--
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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)}`)
|
package/src/codec/Base.luau
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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)
|