Skip to content

Commit d2997d4

Browse files
committed
ye
1 parent 662eefb commit d2997d4

28 files changed

Lines changed: 666 additions & 283 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Schemas, packets, queries, groups, validation, rate limiting. Every send batches
1616
Wally — add to your `wally.toml`:
1717

1818
```toml
19-
Lync = "axp3cter/lync@2.3.1"
19+
Lync = "axp3cter/lync@2.3.2"
2020
```
2121

2222
npm (roblox-ts):
@@ -314,6 +314,9 @@ Tracks the previous frame's value and ships only what changed. Rejected on `unre
314314
| `deltaVec3(min, max, precision)` | 3 B | 3–15 B |
315315
| `deltaCFrame(posMin, posMax, precision)` | 1 B | 4–13 B |
316316

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.
319+
317320
### Meta
318321

319322
| Codec | Notes |

bench/Scenarios.luau

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -399,14 +399,14 @@ local blinkCases: { NetCase } = table.freeze({
399399
of POOL_SIZE values forming a realistic mutation stream so the bench
400400
measures steady-state cache behavior, not first-frame FULL cost.
401401
]]
402-
local function poolEntityArrSparseMut(): { { any } }
402+
local function poolEntityArrSparseMut(count: number): { { any } }
403403
-- Stable array shape; 3 random indices flip per frame to exercise PATCH.
404-
local current = entityArr(ENT_COUNT)
404+
local current = entityArr(count)
405405
local p = table.create(POOL_SIZE)
406406
p[1] = table.clone(current)
407407
for frame = 2, POOL_SIZE do
408408
for _ = 1, 3 do
409-
local idx = rng:NextInteger(1, ENT_COUNT)
409+
local idx = rng:NextInteger(1, count)
410410
current[idx] = entity()
411411
end
412412
p[frame] = table.clone(current)
@@ -462,7 +462,25 @@ local function poolCFrameStream(): { CFrame }
462462
end
463463

464464
local positionsCodec = Lync.deltaVec3(-1000, 1000, 0.01)
465+
local positionsBaseline = Lync.vec3(-1000, 1000, 0.01)
465466
local cframeStreamCodec = Lync.deltaCFrame(-1000, 1000, 0.01)
467+
local cframeStreamBaseline = Lync.cframe()
468+
local counterCodec = Lync.deltaInt(0, 1000000)
469+
local counterBaseline = Lync.int(0, 1000000)
470+
471+
local function poolCounterStream(): { number }
472+
local p = table.create(POOL_SIZE)
473+
for i = 1, POOL_SIZE do
474+
p[i] = i
475+
end
476+
return table.freeze(p) :: { number }
477+
end
478+
479+
-- Shared pools so each delta/full pair drives the same input stream.
480+
local sharedPositionMap = poolPositionMap()
481+
local sharedPositionStream = poolPositionStream()
482+
local sharedCFrameStream = poolCFrameStream()
483+
local sharedCounterStream = poolCounterStream()
466484

467485
--[[
468486
Extended workloads at 100 fires/frame. Pairs vary/stable cases on the
@@ -484,7 +502,29 @@ local extendedCases: { NetCase } = table.freeze({
484502
return entityArr(ENT_COUNT)
485503
end)
486504
),
487-
defCase("entity_deltaArr_100__3mut", entityDeltaArray, poolEntityArrSparseMut()),
505+
defCase(
506+
"entity_arr_400__vary",
507+
entityArray,
508+
poolVarying(function()
509+
return entityArr(400)
510+
end)
511+
),
512+
defCase("entity_deltaArr_400__3mut", entityDeltaArray, poolEntityArrSparseMut(400)),
513+
defCase(
514+
"entity_deltaArr_400__stable",
515+
entityDeltaArray,
516+
poolShared(function()
517+
return entityArr(400)
518+
end)
519+
),
520+
defCase("entity_deltaArr_100__3mut", entityDeltaArray, poolEntityArrSparseMut(ENT_COUNT)),
521+
defCase(
522+
"entity_deltaArr_100__stable",
523+
entityDeltaArray,
524+
poolShared(function()
525+
return entityArr(ENT_COUNT)
526+
end)
527+
),
488528
defCase(
489529
"bool_arr_1000__vary",
490530
boolArray,
@@ -502,10 +542,25 @@ local extendedCases: { NetCase } = table.freeze({
502542
),
503543
defCase("state_full__vary", stateCodec, poolVarying(stateOne)),
504544
defCase("state_delta__1mut", stateDelta, poolStateOneMut()),
505-
defCase("position_map_200__5mut", positionMap, poolPositionMap()),
506-
defCase("position_deltaMap_200__5mut", positionDeltaMap, poolPositionMap()),
507-
defCase("vec3_walking_motion", positionsCodec, poolPositionStream()),
508-
defCase("cframe_walking_pose", cframeStreamCodec, poolCFrameStream()),
545+
defCase("position_map_200__5mut", positionMap, sharedPositionMap),
546+
defCase("position_deltaMap_200__5mut", positionDeltaMap, sharedPositionMap),
547+
defCase(
548+
"position_deltaMap_200__stable",
549+
positionDeltaMap,
550+
poolShared(function(): { [number]: Vector3 }
551+
local m: { [number]: Vector3 } = {}
552+
for i = 1, 200 do
553+
m[i] = Vector3.new(rng:NextNumber(-100, 100), 0, rng:NextNumber(-100, 100))
554+
end
555+
return m
556+
end)
557+
),
558+
defCase("vec3_walking__full", positionsBaseline, sharedPositionStream),
559+
defCase("vec3_walking__delta", positionsCodec, sharedPositionStream),
560+
defCase("cframe_walking__full", cframeStreamBaseline, sharedCFrameStream),
561+
defCase("cframe_walking__delta", cframeStreamCodec, sharedCFrameStream),
562+
defCase("counter_int__full", counterBaseline, sharedCounterStream),
563+
defCase("counter_int__delta", counterCodec, sharedCounterStream),
509564
})
510565

511566
-- Handshake --------------------------------------------------------------

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@axpecter/lync",
3-
"version": "2.3.1",
3+
"version": "2.3.2",
44
"description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security",
55
"main": "src/init.luau",
66
"types": "src/index.d.ts",

src/api/Packet.luau

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,10 @@ function Packet.define<T>(
130130
return false, sendData
131131
end
132132

133-
--[[
134-
Encode-once fanout. For non-delta codecs the wire bytes are stateless
135-
(same input -> same bytes), so we encode into a scratch channel once
136-
and bufCopy the payload into each player's channel. Falls through to
137-
the per-player path when the encode emitted Instance refs — renumbering
138-
refs across channels needs per-ref offset tracking that no codec
139-
exposes today.
140-
141-
Channel.writeBatchEncoded is hot-swapped at startup by enableStats,
142-
so it MUST be re-read per call — capturing at packet-define time
143-
would leave broadcast on the plain variant after a stats flip.
144-
]]
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).
145137
local canEncodeOnce = not codec._isDelta and not codec._hasDelta
146138

147139
local function broadcast(players: { Player }, data: any): ()
@@ -153,15 +145,22 @@ function Packet.define<T>(
153145

154146
if canEncodeOnce then
155147
local scratch = Shared.acquireScratch()
148+
scratch.currentPacket = name
156149
codec.write(scratch, data)
150+
scratch.currentPacket = nil
157151
if scratch.refCount == 0 then
158152
local payload = scratch.buff
159153
local payloadLen = scratch.cursor
160154
local server = Transport.server()
155+
-- Channel.writeBatchEncoded is hot-swapped by enableStats; re-read per call.
161156
local writeBatchEncoded = Channel.writeBatchEncoded
162157
for i = 1, count do
163-
local ch = server.getChannel(players[i], isUnreliable)
164-
writeBatchEncoded(ch, reg, payload, payloadLen)
158+
writeBatchEncoded(
159+
server.getChannel(players[i], isUnreliable),
160+
reg,
161+
payload,
162+
payloadLen
163+
)
165164
end
166165
Shared.releaseScratch()
167166
bumpFires()

src/codec/Base.luau

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ local Base = {}
5656
Base.INITIAL_BUF = INITIAL_BUF
5757
Base.alloc = alloc
5858

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+
5966
-- Floor is INITIAL_BUF: anything smaller errors on the very first write.
6067
function Base.setMaxSize(bytes: number): ()
6168
if bytes < INITIAL_BUF then

src/codec/composite/Array.luau

Lines changed: 49 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,27 @@ local DA_PATCH = 2
1919
-- Private ----------------------------------------------------------------
2020

2121
local alloc = Base.alloc
22+
local ensure = Base.ensure
2223
local allocDeltaId = Shared.allocDeltaId
2324
local acquireScratch = Shared.acquireScratch
2425
local releaseScratch = Shared.releaseScratch
2526
local rangeEqual = Shared.rangeEqual
2627
local copyToBuf = Shared.copyToBuf
28+
local containsDelta = Shared.containsDelta
29+
local enforceMaxCount = Shared.enforceMaxCount
2730
local boolBytes = Shared.boolBytes
2831
local packBoolArray = Shared.packBoolArray
2932
local unpackBoolArray = Shared.unpackBoolArray
3033
local varintRead = Varint.read
3134
local varintWrite = Varint.write
35+
local varintLength = Varint.length
3236
local writeu8 = buffer.writeu8
3337
local readu8 = buffer.readu8
3438
local bufCopy = buffer.copy
3539
local bufLen = buffer.len
3640

3741
type DeltaArrayEntry = { eBuf: buffer, eLen: number }
3842

39-
local function ensure(ch: Types.ChannelState, n: number): ()
40-
if ch.cursor + n > ch.size then
41-
alloc(ch, n)
42-
end
43-
end
44-
4543
-- Bit-packed bool element fast path: 1 bit per entry instead of 1 byte.
4644
local function makeBoolArray(
4745
element: Types.InternalCodec<any>,
@@ -60,9 +58,7 @@ local function makeBoolArray(
6058

6159
read = function(src: buffer, pos: number, _refs: { Instance }?): ({ boolean }, number)
6260
local len, lenBytes = varintRead(src, pos)
63-
if maxCount and len > maxCount then
64-
Log.error(`count {len} exceeds max {maxCount}`)
65-
end
61+
enforceMaxCount(len, maxCount)
6662
if len == 0 then
6763
return {}, lenBytes
6864
end
@@ -115,9 +111,7 @@ local function makeDirectArray(
115111

116112
read = function(src: buffer, pos: number, _refs: { Instance }?): ({ any }, number)
117113
local len, lenBytes = varintRead(src, pos)
118-
if maxCount and len > maxCount then
119-
Log.error(`count {len} exceeds max {maxCount}`)
120-
end
114+
enforceMaxCount(len, maxCount)
121115
if len == 0 then
122116
return {}, lenBytes
123117
end
@@ -159,9 +153,7 @@ local function makeGenericArray(
159153

160154
read = function(src: buffer, pos: number, refs: { Instance }?): ({ any }, number)
161155
local len, lenBytes = varintRead(src, pos)
162-
if maxCount and len > maxCount then
163-
Log.error(`count {len} exceeds max {maxCount}`)
164-
end
156+
enforceMaxCount(len, maxCount)
165157
if len == 0 then
166158
return {}, lenBytes
167159
end
@@ -202,7 +194,7 @@ function Array.array(element: Types.InternalCodec<any>, maxCount: number?): Type
202194
if element._hasUnknown then
203195
codec._hasUnknown = true
204196
end
205-
if element._isDelta or element._hasDelta then
197+
if containsDelta(element) then
206198
codec._hasDelta = true
207199
end
208200
return table.freeze(codec)
@@ -225,6 +217,11 @@ function Array.deltaArray(
225217
element: Types.InternalCodec<any>,
226218
maxCount: number?
227219
): Types.InternalCodec<any>
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+
228225
local deltaId = allocDeltaId()
229226
local inner = Array.array(element, maxCount)
230227

@@ -238,9 +235,7 @@ function Array.deltaArray(
238235
value: { any }
239236
): (number, { number }, { number })
240237
local len = #value
241-
if maxCount and len > maxCount then
242-
Log.error(`count {len} exceeds max {maxCount}`)
243-
end
238+
enforceMaxCount(len, maxCount)
244239
local off = table.create(len)
245240
local elen = table.create(len)
246241
for i = 1, len do
@@ -275,11 +270,9 @@ function Array.deltaArray(
275270
ch.cursor += 1
276271
varintWrite(ch, len)
277272
local payloadLen = scratch.cursor
278-
if payloadLen > 0 then
279-
ensure(ch, payloadLen)
280-
bufCopy(ch.buff, ch.cursor, scratchBuf, 0, payloadLen)
281-
ch.cursor += payloadLen
282-
end
273+
ensure(ch, payloadLen)
274+
bufCopy(ch.buff, ch.cursor, scratchBuf, 0, payloadLen)
275+
ch.cursor += payloadLen
283276

284277
local perIdx: { [number]: DeltaArrayEntry } = {}
285278
for i = 1, len do
@@ -323,20 +316,17 @@ function Array.deltaArray(
323316
return
324317
end
325318

326-
-- PATCH cost vs FULL: every changed index pays an index varint.
327-
-- When index headers approach the count cost, FULL wins outright.
328-
if changedCount * 2 >= len then
329-
ensure(ch, 1)
330-
writeu8(ch.buff, ch.cursor, DA_FULL)
331-
ch.cursor += 1
332-
varintWrite(ch, len)
333-
local payloadLen = scratch.cursor
334-
if payloadLen > 0 then
335-
ensure(ch, payloadLen)
336-
bufCopy(ch.buff, ch.cursor, scratchBuf, 0, payloadLen)
337-
ch.cursor += payloadLen
338-
end
339-
else
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
340330
ensure(ch, 1)
341331
writeu8(ch.buff, ch.cursor, DA_PATCH)
342332
ch.cursor += 1
@@ -346,12 +336,18 @@ function Array.deltaArray(
346336
local idx = changed[i]
347337
varintWrite(ch, idx)
348338
local n = elen[idx]
349-
if n > 0 then
350-
ensure(ch, n)
351-
bufCopy(ch.buff, ch.cursor, scratchBuf, off[idx], n)
352-
ch.cursor += n
353-
end
339+
ensure(ch, n)
340+
bufCopy(ch.buff, ch.cursor, scratchBuf, off[idx], n)
341+
ch.cursor += n
354342
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
355351
end
356352

357353
-- Update cache: refresh changed (and appended), drop truncated.
@@ -412,19 +408,29 @@ function Array.deltaArray(
412408
if lLen == 0 then
413409
Log.error("truncated deltaArray length")
414410
end
411+
enforceMaxCount(newLen, maxCount, "newLen")
415412
total += lLen
416413

417414
local changeCount, cLen = varintRead(src, pos + total)
418415
if cLen == 0 then
419416
Log.error("truncated deltaArray change count")
420417
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
421422
total += cLen
422423

423424
for _ = 1, changeCount do
424425
local idx, iLen = varintRead(src, pos + total)
425426
if iLen == 0 then
426427
Log.error("truncated deltaArray index")
427428
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
428434
total += iLen
429435
local v, vc = element.read(src, pos + total, refs)
430436
if vc == 0 then

0 commit comments

Comments
 (0)