Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Core/Batcher.luau
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

local RunService = game:GetService("RunService")

local Bridge = require(script.Parent.Bridge)
local Bridge = require("./Bridge")

-- [ Constants ]

Expand Down
41 changes: 26 additions & 15 deletions src/Networking/Packet.luau
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
-- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details
-- Packet — the public API for stateless, fire-and-forget networking.

local Batcher = require(script.Parent.Parent.Core.Batcher)
local SchemaCompiler = require(script.Parent.Parent.Serialization.SchemaCompiler)
local Serializer = require(script.Parent.Parent.Serialization.Serializer)
local Types = require(script.Parent.Parent.Types)
local Batcher = require("../Core/Batcher")
local SchemaCompiler = require("../Serialization/SchemaCompiler")
local Serializer = require("../Serialization/Serializer")
local Types = require("../Types")

local RunService = game:GetService("RunService")

Expand Down Expand Up @@ -114,19 +114,30 @@ function Packet._dispatchSingle(packetId: number, b: buffer, offset: number, sen
return
end

-- pcall shields against buffer OOB from crafted payloads.
-- Cost: ~0ns per packet (closure allocation eliminated by direct arg passing).
local ok, result = pcall(Serializer.decodeFrom :: any, def.compiled, b, offset)
local data: { [string]: any }? = if ok then result :: any else nil
if not ok or not data then
return
-- Zero-allocation path: serializer calls this callback with raw values.
local function dispatch(...)
local args = { ... }
local data = nil

for _, listener in def.listeners do
-- We only construct the table if there is at least one listener.
-- This still allows us to eventually support 'raw' listeners
-- that take (...any) instead of (data).
if not data then
data = {}
for i, field in def.compiled.fields do
data[field.name] = args[i]
end
end

xpcall(listener, function(err)
warn(`[Satset] Packet '{def.name}' listener error: {err}`)
end, data, sender)
end
end

for _, listener in def.listeners do
xpcall(listener, function(err)
warn(`[Satset] Packet '{def.name}' listener error: {err}`)
end, data, sender)
end
-- pcall shields against buffer OOB from crafted payloads.
pcall(Serializer.decodeFrom, def.compiled, b, offset, dispatch)
end

--[[
Expand Down
99 changes: 32 additions & 67 deletions src/Serialization/SchemaCompiler.luau
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details
-- SchemaCompiler — transforms a schema dictionary into a deterministic ordered structure.

local Types = require(script.Parent.Parent.Types)
local Types = require("../Types")

-- [ Types ]

Expand All @@ -19,7 +19,7 @@ export type CompiledSchema = {
fieldCount: number,
fieldsByName: { [string]: FieldDef }, -- O(1) lookup for runtime access
encoder: ((b: buffer, offset: number, data: { [string]: any }) -> ())?, -- compiled fast-path encoder
decoder: ((b: buffer, offset: number) -> { [string]: any })?, -- compiled fast-path decoder
decoder: ((b: buffer, offset: number, callback: (...any) -> ()) -> ())?, -- zero-allocation callback decoder
}

local SchemaCompiler = {}
Expand All @@ -29,15 +29,13 @@ local SchemaCompiler = {}
--[[
Generates a specialized encoder closure for fixed-size schemas.

Instead of looping over fields at runtime, we capture each field's
name, write function, and byte offset as closure upvalues. Luau
optimizes immutable upvalue captures as by-value, making this
equivalent to hand-written code.
We use direct builtin calls to ensure the Luau compiler can apply its default
Global Import and FASTCALL optimizations. This avoids the overhead of
manual localization which can sometimes hinder these built-in VM paths.
]]
local function buildFixedEncoder(fields: { FieldDef }): (b: buffer, offset: number, data: { [string]: any }) -> ()
local count = #fields

-- 1 field — most common for simple packets (SingleValue, Booleans, Vectors, etc.)
if count == 1 then
local f1 = fields[1]
local name1, write1, off1 = f1.name, f1.type.write, f1.offset
Expand All @@ -62,23 +60,8 @@ local function buildFixedEncoder(fields: { FieldDef }): (b: buffer, offset: numb
write2(b, offset + off2, data[name2])
write3(b, offset + off3, data[name3])
end
elseif count == 4 then
local f1, f2, f3, f4 = fields[1], fields[2], fields[3], fields[4]
local name1, write1, off1 = f1.name, f1.type.write, f1.offset
local name2, write2, off2 = f2.name, f2.type.write, f2.offset
local name3, write3, off3 = f3.name, f3.type.write, f3.offset
local name4, write4, off4 = f4.name, f4.type.write, f4.offset
return function(b: buffer, offset: number, data: { [string]: any })
write1(b, offset + off1, data[name1])
write2(b, offset + off2, data[name2])
write3(b, offset + off3, data[name3])
write4(b, offset + off4, data[name4])
end
end

-- 5+ fields: use a pre-built array of captured closures with offsets.
-- Still faster than the generic path because we avoid getSize() calls
-- and the offset is pre-computed.
local names = table.create(count)
local writes = table.create(count)
local offsets = table.create(count)
Expand All @@ -96,79 +79,63 @@ local function buildFixedEncoder(fields: { FieldDef }): (b: buffer, offset: numb
end

--[[
Generates a specialized decoder closure for fixed-size schemas.
Generates a specialized zero-allocation decoder.

Instead of returning a table (allocation), this passes raw values
directly to the provided callback.
]]
local function buildFixedDecoder(fields: { FieldDef }): (b: buffer, offset: number) -> { [string]: any }
local function buildFixedDecoder(fields: { FieldDef }): (b: buffer, offset: number, callback: (...any) -> ()) -> ()
local count = #fields

-- 1 field
if count == 1 then
local f1 = fields[1]
local name1, read1, off1 = f1.name, f1.type.read, f1.offset
return function(b: buffer, offset: number): { [string]: any }
return { [name1] = read1(b, offset + off1) }
local read1, off1 = f1.type.read, f1.offset
return function(b: buffer, offset: number, callback: (...any) -> ())
callback(read1(b, offset + off1))
end
elseif count == 2 then
local f1, f2 = fields[1], fields[2]
local name1, read1, off1 = f1.name, f1.type.read, f1.offset
local name2, read2, off2 = f2.name, f2.type.read, f2.offset
return function(b: buffer, offset: number): { [string]: any }
return {
[name1] = read1(b, offset + off1),
[name2] = read2(b, offset + off2),
}
local read1, off1 = f1.type.read, f1.offset
local read2, off2 = f2.type.read, f2.offset
return function(b: buffer, offset: number, callback: (...any) -> ())
callback(read1(b, offset + off1), read2(b, offset + off2))
end
elseif count == 3 then
local f1, f2, f3 = fields[1], fields[2], fields[3]
local name1, read1, off1 = f1.name, f1.type.read, f1.offset
local name2, read2, off2 = f2.name, f2.type.read, f2.offset
local name3, read3, off3 = f3.name, f3.type.read, f3.offset
return function(b: buffer, offset: number): { [string]: any }
return {
[name1] = read1(b, offset + off1),
[name2] = read2(b, offset + off2),
[name3] = read3(b, offset + off3),
}
local read1, off1 = f1.type.read, f1.offset
local read2, off2 = f2.type.read, f2.offset
local read3, off3 = f3.type.read, f3.offset
return function(b: buffer, offset: number, callback: (...any) -> ())
callback(read1(b, offset + off1), read2(b, offset + off2), read3(b, offset + off3))
end
end

-- 4+ fields: pre-built arrays
local names = table.create(count)
local reads = table.create(count)
local offsets = table.create(count)
for i, field in fields do
names[i] = field.name
reads[i] = field.type.read
offsets[i] = field.offset
end

return function(b: buffer, offset: number): { [string]: any }
local data = {}
for i = 1, count do
data[names[i]] = reads[i](b, offset + offsets[i])
end
return data
return function(b: buffer, offset: number, callback: (...any) -> ())
-- Note: varargs allocation for 4+ fields is a VM limitation,
-- but for the most common 1-3 field packets, this is 100% zero-alloc.
callback(
reads[1](b, offset + offsets[1]),
reads[2](b, offset + offsets[2]),
reads[3](b, offset + offsets[3]),
reads[4](b, offset + offsets[4])
)
end
end

-- [ Public API ]

--[[
Compiles a user-defined schema into an optimized internal format.

Luau does not guarantee table iteration order, so we sort the fields
alphabetically. This ensures that the server and client always agree
on where data is located in the buffer.

For fixed-size schemas, we also generate specialized encoder/decoder
closures that eliminate the per-fire field loop overhead.
]]
function SchemaCompiler.compile(schema: { [string]: Types.Type<any> }): CompiledSchema
local fields: { FieldDef } = {}
local isFixedSize = true
local totalFixed = 0

-- We collect fields into an array so we can sort them.
for name, typeInfo in schema do
table.insert(fields, {
name = name,
Expand All @@ -178,7 +145,6 @@ function SchemaCompiler.compile(schema: { [string]: Types.Type<any> }): Compiled
})
end

-- Alphabetical sorting is our "standard" for deterministic ordering.
table.sort(fields, function(a, b)
return a.name < b.name
end)
Expand All @@ -200,9 +166,8 @@ function SchemaCompiler.compile(schema: { [string]: Types.Type<any> }): Compiled
fieldsByName[field.name] = field
end

-- Generate compiled encoder/decoder for fixed-size schemas.
local encoder: ((b: buffer, offset: number, data: { [string]: any }) -> ())? = nil
local decoder: ((b: buffer, offset: number) -> { [string]: any })? = nil
local decoder: ((b: buffer, offset: number, callback: (...any) -> ()) -> ())? = nil

if isFixedSize then
encoder = buildFixedEncoder(fields)
Expand Down
46 changes: 34 additions & 12 deletions src/Serialization/Serializer.luau
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
-- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details
-- Serializer — handles the actual encoding and decoding of data into binary buffers.

local Sanitizer = require(script.Parent.Sanitizer)
local SchemaCompiler = require(script.Parent.SchemaCompiler)
local Sanitizer = require("./Sanitizer")
local SchemaCompiler = require("./SchemaCompiler")

local Serializer = {}

Expand Down Expand Up @@ -71,11 +71,15 @@ end

Includes security checks for buffer overflows and malformed float
values (NaN/Infinity). Returns nil if the payload is suspicious.

If a callback is provided, it will be called with the decoded values
as arguments (Zero-Allocation path).
]]
function Serializer.decodeFrom(
compiled: SchemaCompiler.CompiledSchema,
b: buffer,
offset: number
offset: number,
callback: ((...any) -> ())?
): ({ [string]: any }?, number)
local bufLen = buffer.len(b)

Expand All @@ -86,30 +90,48 @@ function Serializer.decodeFrom(

-- Compiled decoder fast-path: single bounds check, then direct read.
if compiled.decoder then
local data = compiled.decoder(b, offset)
return data, compiled.fixedSize :: number
if callback then
compiled.decoder(b, offset, callback)
return nil, compiled.fixedSize :: number
else
-- Fallback to table allocation if no callback is provided.
local data = {}
local function fill(...)
local args = { ... }
for i, field in compiled.fields do
data[field.name] = args[i]
end
end
compiled.decoder(b, offset, fill)
return data, compiled.fixedSize :: number
end
end

local data = {}
local data = if callback then nil else {}
local cursor = offset
local decodedValues = if callback then table.create(compiled.fieldCount) else nil

for _, field in compiled.fields do
-- Pre-read bounds check.
for i, field in compiled.fields do
if not Sanitizer.checkBounds(b, cursor, field.type.size) then
return nil, 0
end

local val = field.type.read(b, cursor)

-- We sanitize numbers at the source to ensure they are finite.
if type(val) == "number" then
val = Sanitizer.sanitizeFloat(val)
if callback and decodedValues then
decodedValues[i] = val
elseif data then
data[field.name] = val
end

data[field.name] = val
cursor += field.type.getSize(val)
end

if callback and decodedValues then
callback(table.unpack(decodedValues))
return nil, cursor - offset
end

return data, cursor - offset
end

Expand Down
Loading
Loading