OmniSentry
Multi-Cursor Oriented fork of VoidSentry2.
What it is
Sending Luau tables or JSON over remotes is convenient but costly: payloads grow quickly and every value carries type metadata on the wire. OmniSentry converts the same data into a compact, typed byte layout using the Luau buffer type, so the bytes you ship match the shape of the data you actually store.
Two usage modes cover most cases. Static: you declare the exact shape once as a type node and reuse it to serialize and deserialize — smallest size, lowest CPU, predictable layout. Dynamic: you pass a variable list of values and the library tags each with a 1-byte type id so the receiver can decode them without a shared schema.
Highlights
- Static (schema) and dynamic (tagged) serialization in one module.
- 45+ built-in types: fixed-width integers and floats (including 16/24-bit variants), strings, vectors,
CFrame(matrix and quaternion, full and compressed),Color3,EnumItem, arrays, maps, structs,optional,union,any,boolPacked, plusinstance(by-reference) andserInstance(by-snapshot). - Optional Zstd compression through Roblox
EncodingService. - Configurable: per-cursor buffer growth, strict vs lax failure mode, dynamic inference for instances and vectors, instance-id attribute name.
- Fully typed Luau API so generics, autocomplete, and refactors work through the call sites.
- Explicit multi-cursor API (
writeStart/writeFinish,readStart,alloc) if you want several independent serialize sessions or to interleave OmniSentry writes with your own bytes.
Differences from VoidSentry2
OmniSentry is a fork oriented around multi-cursor serialization. The wire format and type nodes are largely the same; the buffer layer and manual API are not.
Single global cursor vs explicit cursors
VoidSentry2 keeps one shared write cursor and one shared read cursor inside the library. Every primitive, type node, and serializer advances that global position. That design is lean — no cursor object is threaded through calls — but only one serialize or deserialize operation can safely run at a time. Nested or concurrent writes/reads would stomp each other's offset.
OmniSentry passes a cursor table through the whole stack:
writeStart(offset?) -> cursor— creates a private growable buffer for this session.readStart(buf, offset?) -> cursor— binds a read cursor to an existing buffer.- Type nodes use
write(cursor, value)andread(cursor, lastFlag?). alloc(cursor, bytes)— reserves space before writing (replaces VoidSentry2'sNEED).writeFinish(cursor) -> buffer— trims and returns the written bytes.
You can hold multiple write cursors at once (e.g. build two payloads in parallel) or mix OmniSentry type-node writes with your own buffer.write* calls on the same cursor. The trade-off is a small amount of extra per-call overhead (passing the cursor, per-session buffers that grow on demand) compared to VoidSentry2's single reused scratch buffer.
NEED → alloc
VoidSentry2's NEED(n) checked whether n bytes fit in a fixed global scratch buffer capped by maxBufferSize; it returned false on overflow. OmniSentry's alloc(cursor, n) grows the cursor's private buffer (starting at startingBufferSize, multiplied by bufferExpandMultiplier when full). There is no global byte cap — growth stops only when memory runs out.
Removed / changed surface API
- Gone:
readFinish,getWriteCursor,getReadCursor,updateBigBuffer,maxBufferSize. - Read position: use
cursor.offseton the object returned byreadStart. - Write position: use
cursor.offseton the object returned bywriteStart. - New:
alloc(cursor, bytes)on the library root (same aswriter.ALLOC).
static.serialize / dynamic.serialize still manage cursors internally — you only touch the cursor API for custom/manual serialization.
Quick start
Static
--!strict
local VS = require(path.to.OmniSentry)
local T = VS.types
local static = VS.static
local row = T.struct({
n = T.int32,
s = T.string,
})
local buf = static.serialize(row, { n = 42, s = "hi" }, nil, nil)
local out = static.deserialize(row, buf, nil, nil)
Dynamic
Each value you pass becomes one tagged chunk in the buffer. The receiver reads the same number of values in the same order.
local VS = require(path.to.OmniSentry)
local dynamic = VS.dynamic
-- Example: send a name, a user id, a position, and whether sprint is on — no struct definition.
local playerId = 9001
local targetPos = Vector3.new(10, 0, -3)
local buf = dynamic.serialize(nil, nil, "MovePlayer", playerId, targetPos, true)
if not buf then return end
local name, id, pos, sprinting = dynamic.deserialize(nil, nil, buf)
-- name == "MovePlayer", id == 9001, pos == targetPos, sprinting == true