From bddc915c7254f89644c2f8a1e05126a7a2edc825 Mon Sep 17 00:00:00 2001 From: Mark Suckserberg Date: Tue, 5 May 2026 05:23:11 +0700 Subject: [PATCH] feat: add batching config and f16/bit-packed types --- src/Core/Batcher.luau | 93 ++++++++++++++++++++++++++++-------- src/Types/init.luau | 108 ++++++++++++++++++++++++++++++++++++++---- src/init.luau | 10 +++- 3 files changed, 181 insertions(+), 30 deletions(-) diff --git a/src/Core/Batcher.luau b/src/Core/Batcher.luau index 9f6f424..6a4c645 100644 --- a/src/Core/Batcher.luau +++ b/src/Core/Batcher.luau @@ -1,7 +1,7 @@ --!native --!optimize 2 -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details --- Batcher — handles per-frame packet batching and unreliable transport hardening. +-- Batcher — handles frame-level batching, dynamic segmentation, and rate-limited flushing. local RunService = game:GetService("RunService") @@ -13,8 +13,9 @@ local IS_SERVER = RunService:IsServer() -- UnreliableRemoteEvent has a limit around 1000 bytes. -- We cap at 900 to stay safe. -local RELIABLE_SPLIT_THRESHOLD = 45000 +local RELIABLE_SPLIT_THRESHOLD = 60000 local UNRELIABLE_SPLIT_THRESHOLD = 900 +local MAX_PACKETS_PER_FRAME = 0 -- 0 means no limit (Latency Mode) local INITIAL_WORKING_SIZE = 65536 type Stream = { @@ -50,6 +51,24 @@ function Batcher.registerFixedSize(id: number, size: number) packetFixedSizes[id] = size end +export type BatchingConfig = { + reliableThreshold: number?, + unreliableThreshold: number?, + maxPacketsPerFrame: number?, +} + +function Batcher.configure(config: BatchingConfig) + if config.reliableThreshold then + RELIABLE_SPLIT_THRESHOLD = config.reliableThreshold + end + if config.unreliableThreshold then + UNRELIABLE_SPLIT_THRESHOLD = config.unreliableThreshold + end + if config.maxPacketsPerFrame ~= nil then + MAX_PACKETS_PER_FRAME = config.maxPacketsPerFrame + end +end + local function commitStream(stream: Stream, reliable: boolean, seqCounter: number?): (buffer?, number?) if stream.count == 0 then return nil, seqCounter @@ -323,10 +342,18 @@ local function flush() if exact then table.insert(batches, exact) end - for _, batch in batches do - reliableRemote:FireClient(player, batch) + + local sentThisFrame = 0 + local i = 1 + while i <= #batches do + reliableRemote:FireClient(player, batches[i]) + sentThisFrame += 1 + table.remove(batches, i) + + if MAX_PACKETS_PER_FRAME > 0 and sentThisFrame >= MAX_PACKETS_PER_FRAME then + break + end end - table.clear(batches) elseif exact then reliableRemote:FireClient(player, exact) end @@ -342,10 +369,18 @@ local function flush() if exact then table.insert(batches, exact) end - for _, batch in batches do - unreliableRemote:FireClient(player, batch) + + local sentThisFrame = 0 + local i = 1 + while i <= #batches do + unreliableRemote:FireClient(player, batches[i]) + sentThisFrame += 1 + table.remove(batches, i) + + if MAX_PACKETS_PER_FRAME > 0 and sentThisFrame >= MAX_PACKETS_PER_FRAME then + break + end end - table.clear(batches) elseif exact then unreliableRemote:FireClient(player, exact) end @@ -355,20 +390,34 @@ local function flush() if exact then table.insert(broadcastReliableBatches, exact) end - for _, batch in broadcastReliableBatches do - reliableRemote:FireAllClients(batch) + + local sentThisFrame = 0 + local i = 1 + while i <= #broadcastReliableBatches do + reliableRemote:FireAllClients(broadcastReliableBatches[i]) + sentThisFrame += 1 + table.remove(broadcastReliableBatches, i) + + if MAX_PACKETS_PER_FRAME > 0 and sentThisFrame >= MAX_PACKETS_PER_FRAME then + break + end end - table.clear(broadcastReliableBatches) else local exactR, _ = commitStream(serverReliableStream, true) if exactR then table.insert(serverReliableBatches, exactR) end - if #serverReliableBatches > 0 then - for _, batch in serverReliableBatches do - Bridge.getReliable():FireServer(batch) + + local sentThisFrame = 0 + local i = 1 + while i <= #serverReliableBatches do + Bridge.getReliable():FireServer(serverReliableBatches[i]) + sentThisFrame += 1 + table.remove(serverReliableBatches, i) + + if MAX_PACKETS_PER_FRAME > 0 and sentThisFrame >= MAX_PACKETS_PER_FRAME then + break end - table.clear(serverReliableBatches) end local exactU, newSeq = commitStream(serverUnreliableStream, false, clientSeqCounter) @@ -376,11 +425,17 @@ local function flush() if exactU then table.insert(serverUnreliableBatches, exactU) end - if #serverUnreliableBatches > 0 then - for _, batch in serverUnreliableBatches do - Bridge.getUnreliable():FireServer(batch) + + i = 1 + local sentThisFrameU = 0 + while i <= #serverUnreliableBatches do + Bridge.getUnreliable():FireServer(serverUnreliableBatches[i]) + sentThisFrameU += 1 + table.remove(serverUnreliableBatches, i) + + if MAX_PACKETS_PER_FRAME > 0 and sentThisFrameU >= MAX_PACKETS_PER_FRAME then + break end - table.clear(serverUnreliableBatches) end end end diff --git a/src/Types/init.luau b/src/Types/init.luau index c463248..5be37cd 100644 --- a/src/Types/init.luau +++ b/src/Types/init.luau @@ -1,7 +1,7 @@ --!native --!optimize 2 -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details --- Types — defines all primitive and composite networking types. +-- Types — provides optimized serialization primitives and composite types. -- [ Builtin Localization ] -- We localize these for GETIMPORT and FASTCALL optimization in the Luau VM. @@ -283,6 +283,79 @@ function Types.Vector3Quantized(range: number?): Type } end +--[[ + Float16: 2-byte floating point. + Provides lower precision than f32 but significantly saves bandwidth. + Based on Half-Precision (IEEE 754-2008). +]] +Types.f16 = { + read = function(b: buffer, cursor: number) + local raw = readu16(b, cursor) + local sign = (bit32.band(raw, 0x8000) ~= 0) and -1 or 1 + local exp = bit32.rshift(bit32.band(raw, 0x7C00), 10) + local frac = bit32.band(raw, 0x03FF) + if exp == 0x1F then + return (frac == 0) and (sign * huge) or 0 -- NaN as 0 for safety + elseif exp == 0 then + return sign * (2 ^ -14) * (frac / 1024) + else + return sign * (2 ^ (exp - 15)) * (1 + frac / 1024) + end + end, + write = function(b: buffer, cursor: number, value: number) + if value ~= value then + writeu16(b, cursor, 0) + return + end + local sign = 0 + if value < 0 then + sign = 0x8000 + value = -value + end + local exp, frac + if value > 65504 then + exp, frac = 0x1F, 0 + elseif value <= 2 ^ -24 then + exp, frac = 0, 0 + elseif value < 2 ^ -14 then + exp, frac = 0, round(value / (2 ^ -24)) + else + local log2 = math.log(value, 2) + exp = floor(log2) + frac = round((value / (2 ^ exp) - 1) * 1024) + if frac == 1024 then + exp += 1 + frac = 0 + end + exp += 15 + end + writeu16(b, cursor, bit32.bor(sign, bit32.lshift(exp, 10), frac)) + end, + size = 2, + getSize = fixedSize(2), + isFixed = true, +} + +--[[ + Vector3F16: 6-byte Vector3 using Float16 for each component. +]] +Types.Vector3F16 = { + read = function(b: buffer, cursor: number) + local x = Types.f16.read(b, cursor) + local y = Types.f16.read(b, cursor + 2) + local z = Types.f16.read(b, cursor + 4) + return Vector3.new(x, y, z) + end, + write = function(b: buffer, cursor: number, value: Vector3) + Types.f16.write(b, cursor, value.X) + Types.f16.write(b, cursor + 2, value.Y) + Types.f16.write(b, cursor + 4, value.Z) + end, + size = 6, + getSize = fixedSize(6), + isFixed = true, +} + --[[ Vector2Quantized: compressed 4-byte Vector2. @@ -515,12 +588,19 @@ function Types.array(elementType: Type): Type<{ any }> read = function(b: buffer, cursor: number) local bufLen = buffer.len(b) local len = readu16(b, cursor) - local maxPossible = bufLen - cursor - 2 + local maxPossible = (bufLen - cursor - 2) * 8 len = min(len, max(maxPossible, 0)) local arr, pos = table.create(len), cursor + 2 - for i = 1, len do - arr[i] = readu8(b, pos) ~= 0 - pos += 1 + + local byteLen = floor((len + 7) / 8) + for i = 0, byteLen - 1 do + local byte = readu8(b, pos + i) + local startIdx = i * 8 + 1 + local endIdx = min(startIdx + 7, len) + + for j = startIdx, endIdx do + arr[j] = bit32.band(bit32.rshift(byte, (j - 1) % 8), 1) ~= 0 + end end return arr end, @@ -528,14 +608,24 @@ function Types.array(elementType: Type): Type<{ any }> local len = #value writeu16(b, cursor, len) local pos = cursor + 2 - for i = 1, len do - writeu8(b, pos, value[i] and 1 or 0) - pos += 1 + local byteLen = floor((len + 7) / 8) + + for i = 0, byteLen - 1 do + local byte = 0 + local startIdx = i * 8 + 1 + local endIdx = min(startIdx + 7, len) + + for j = startIdx, endIdx do + if value[j] then + byte = bit32.bor(byte, bit32.lshift(1, (j - 1) % 8)) + end + end + writeu8(b, pos + i, byte) end end, size = 2, getSize = function(value: { any }) - return 2 + #value + return 2 + floor((#value + 7) / 8) end, isFixed = false, } diff --git a/src/init.luau b/src/init.luau index 1a04acf..80ed33c 100644 --- a/src/init.luau +++ b/src/init.luau @@ -39,6 +39,7 @@ Satset.defineChannel = Channel.defineChannel export type SatsetConfig = { guard: Guard.GuardConfig?, + batching: Batcher.BatchingConfig?, } --[[ @@ -58,14 +59,19 @@ function Satset.start(config: SatsetConfig?) local context = if IS_SERVER then "Server" else "Client" print(`[Satset v{VERSION}] Initializing on {context}`) + local cfg: SatsetConfig = config or {} :: SatsetConfig + + -- Apply custom batching configuration if provided. + if cfg.batching then + Batcher.configure(cfg.batching) + end + -- We initialize the bridge first to ensure remotes are ready. Bridge.init() -- The batcher hooks into PostSimulation to flush outgoing data. Batcher.init() - local cfg: SatsetConfig = config or {} :: SatsetConfig - -- Dedicated channel folder for organization. local bridgeFolder = game:GetService("ReplicatedStorage"):FindFirstChild("__SatsetBridge")