Efficient SerDes Library for Roblox. Successor to VoidSentry.
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. VoidSentry2 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, plus instance (by-reference) and serInstance (by-snapshot).
Optional Zstd compression through Roblox EncodingService.
Configurable: buffer cap, 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.
Manual cursor API (writeStart / writeFinish, readStart / readFinish) if you want to interleave VoidSentry2 writes with your own bytes.
Quick start
Static
--!strict
local VS = require(path.to.VoidSentry2)
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.VoidSentry2)
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
Clone github.com/Elentium/VoidSentry2 and wire the src tree into your project with Rojo (or copy the source into ReplicatedStorage). Point require at the package entry module.
Model file (.rbxm)
A packaged /roblox/VoidSentry2.rbxm model is provided for drag-and-drop into Studio. Insert it under ReplicatedStorage (or your preferred folder) and require the ModuleScript entry.
Roblox Creator Store
The library is published as a Creator Store asset so you can add it from the toolbox.
All switches live in src/shared/config/constants.luau. Edit a vendored or forked copy so Wally updates do not overwrite your changes.
Options
maxBufferSize
Default:10_000_000 bytes. Effect: hard cap for the internal scratch buffer used during a single serialize call. If a payload would exceed it, serialization fails.
Change when: you know your payloads never approach this size (reduce to catch bugs earlier) or you legitimately serialize larger snapshots (raise it).
strictMode
Default:true. Effect: type mismatches, missing fields, buffer overflows, invalid input, etc. throw an error. With false the same conditions warn and serialize/deserialize return nil.
Change when: shipping to production and you want best-effort failure tolerance (network payloads, corrupted save data). Keep true during development to fail loudly.
dynamicVectorType
Default:"vector". Values:"vector" or "Vector3". Effect: when the dynamic serializer infers a vector value, it chooses the Luau vector node or the Roblox Vector3 node. Both are 12 bytes; the difference is which Luau type is produced on deserialize.
Change when: your codebase prefers one native type over the other. Ignored by the static serializer — static schemas always use whichever node you put in them.
dynamicInstanceType
Default:"instance". Values:"instance" or "serInstance". Effect: when the dynamic serializer encounters an Instance, "instance" writes its numeric id and looks the same object up on the peer; "serInstance" writes a property snapshot using the registered schema for the instance's class.
Change when: dynamic messages should carry property snapshots by default (e.g. template replication) instead of live references. Ignored by the static serializer.
instanceIdAttribute
Default:"_VSID". Effect: attribute name used by types.instance and the server/client instance-map scripts to tag and look up instances. Both sides must agree on this name.
Change when: the default collides with your own attribute names or you want a shorter name. Set once, before any instance is tagged.
unsupportedUnionExTypes
Default:{ union, any, void, optional }. Effect: set of exType strings that cannot appear as a variant of T.union(...); the builder throws if you try.
Change when: almost never. These are blocked because they do not round-trip cleanly through extypeof (unions cannot distinguish them from other variants).
Internal status codes
unknownType, success, fail, eor are the numeric flags returned by reader/writer primitives. Do not change.
Related code
src/shared/config/init.luau re-exports the constants plus typeSigns for the rest of the package. Game code does not require it directly — the module is only touched when you maintain a fork of VoidSentry2.
API reference
Library properties
Top-level members of the module returned by require(VoidSentry2).
.types
Table of built-in type nodes and factories (int32, struct(...), array(T), union(...), etc.). You pass these to the static serializer. See Types for the full list.
.static
Schema-based serializer: { serialize, deserialize }. You supply a root typeNode that describes the whole payload. See Static API.
.dynamic
Schemaless serializer: { serialize, deserialize }. You pass a variable list of values; each is tagged with a 1-byte type id on the wire. See Dynamic API.
Declares which properties of className should be captured by types.serInstance(className). Keys are property names; values are type nodes (e.g. Name = T.string255). Call once at startup per class, before any serialize that references it.
.combine(t1, t2)
combine<T1, T2>(t1: T1, t2: T2) -> T1 & T2
Shallow merge helper. Useful for composing struct field tables at module scope. Not serialization-specific.
Manual cursor API
Low-level hooks for advanced users who want to mix VoidSentry2 writes with their own bytes, or batch several type-node writes under one buffer.
writeStart(offset: number?) -> () — reset the scratch buffer cursor.
writeFinish() -> buffer — copy bytes [0, cursor) into a new exact-sized buffer.
getWriteCursor() -> number — current write position.
readStart(buf: buffer, offset: number?) -> () — bind the reader to buf.
readFinish() -> () — release the current read buffer.
getReadCursor() -> number — current read position.
If you only call static / dynamic you never touch these.
Static API
Each static call has exactly one root type node. To send multiple logical fields, wrap them in a single struct (or array / map).
serdes — root type node describing the payload shape.
input — the Luau value matching serdes.
offset (optional, default 0) — number of bytes to reserve at the start of the returned buffer. These bytes are zero-filled; you can overwrite them with a header, magic value, or version after the call. Deserialize with the same value.
compressionLevel (optional) — Zstd level passed to EncodingService. When set, the full payload is compressed. When nil, no compression.
Returns the packed buffer, or nil in lax mode when the input fails validation.
offset — must match the offset passed to serialize.
wasCompressed — true only when serialize was called with a non-nilcompressionLevel.
Example
local T = VS.types
local static = VS.static
local Player = T.struct({
id = T.uint32,
name = T.string255,
hp = T.float32,
})
local buf = static.serialize(Player, { id = 1, name = "Aria", hp = 100 })
local back = static.deserialize(Player, buf)
Dynamic API
Dynamic packs a variable list of values. Each value is tagged with a 1-byte type id so the receiver can decode without a shared schema. No root node; decode order matches encode order. Leading arguments are options, not values.
wasCompressed — true when the buffer was produced with a compression level; otherwise nil/false.
offset — must match the offset passed to serialize.
buf — the buffer to decode.
Returns the unpacked values in the order they were written.
Inference notes
The dynamic serializer picks a type for each value via src/shared/inferValueType.luau. Integers are chosen by magnitude; numbers with a fractional part become float32; tables with sequential integer keys become array, otherwise map. Empty tables become void. Instance and vector inference follow dynamicInstanceType / dynamicVectorType in the config. Wider integer widths (uint40, uint48) are not inferred — use the static API when you need them.
Example
local dynamic = VS.dynamic
local buf = dynamic.serialize(nil, nil, "Move", 42, Vector3.new(0, 10, 0), true)
local op, id, pos, sprint = dynamic.deserialize(nil, nil, buf)
Types
Every member of VoidSentry2.types. Sizes below are for the payload only; collection types also write a length or count prefix as noted.
Fixed record. Field order on the wire is sorted by key name — no per-field tags, no length prefix.
Roblox-specific
Type
Size
Notes
enum(Enum.X)
2 B
Writes EnumItem.Value as uint16. Bind to a specific Enum at schema-build time.
instance
3 B
Writes a uint24 id from the instanceIdAttribute. Resolves the same object on the peer via the shared instance map. Use for live references.
serInstance(className)
sum of fields
Writes a snapshot of the properties registered via registerInstanceSchema. Deserialize constructs a newInstance.new(className).
Specials
Type
Size
Notes
optional(T)
1 B + maybe T
Presence byte; payload only when the value is present. Absent round-trips as nil.
any
1 B + value
Writes a 1-byte type sign via the dynamic inference path, then the value. Use when a field can hold multiple unrelated shapes.
union(v1, v2, …)
1 B + variant
Writes a 1-byte variant index + the chosen variant's payload. Dispatches on runtime exType. See union details.
union(v1, v2, …) — in depth
T.union(...) builds a typeNode that accepts any one of the provided variant nodes. On the wire it emits a 1-byte variant index followed by the chosen variant's payload, so the total size is 1 byte + variant size and the upper bound is 255 variants.
How dispatch works. At write time, the library calls extypeof(value) and looks the returned string up in the union's internal exType → variantNode table. The chosen variant's write runs; the variant's position in the argument list is stored as the index byte. At read time that byte selects the variant and its read runs.
exType rules (enforced at build time).
Each variant's exType must be unique within the union — the dispatch table rejects duplicates.
Type families that share an exType therefore collapse into one slot: all integer widths share "int"; all floats share "float"; string and string255 share "string"; every CFrame encoding shares "CFrame"; every Vector3 encoding shares "Vector3"; struct and map share "map"; instance and serInstance share "Instance". Pick the width/encoding you want, once per union.
Variants whose exType is listed in config.unsupportedUnionExTypes (union, any, void, optional) are rejected — these cannot participate because their runtime shape does not round-trip through extypeof.
A value whose extypeof result matches no variant is a serialization error in strict mode (or a warn + nil buffer in lax mode).
What extypeof returns. Numbers become "int" or "float" by value (integer-valued floats go to "int"). Tables are "array" if sequential, otherwise "map". EnumItem becomes "Enum." .. EnumType. Everything else is the raw typeof string ("string", "boolean", "Vector3", "CFrame", "Instance", …).
To route a value shape that extypeof does not distinguish (e.g. two different kinds of non-sequential table), extend extypeof to return a new tag and add a variant whose exType equals that tag. See Extending union.
Usage
Choosing between static and dynamic, handling failure, and interleaving your own bytes with VoidSentry2 payloads.
Static vs dynamic — how to choose
Use static when the layout is stable across calls: gameplay state, inventory snapshots, save blobs, RPC payloads with a fixed shape. The wire carries no per-field type metadata, so the result is smaller and faster than dynamic and matches what the code already knows about the data.
Use dynamic when the values or their types change call-to-call: debug tooling, admin commands, "send whatever mix I have right now". You pay one tag byte per value plus any extra metadata (enum id, serInstance class id) but avoid maintaining a schema per shape.
A static struct covers the "many fields, one message" case without reaching for dynamic.
Type nodes and one root value
Entries in VoidSentry2.types are small internal objects. You build them at module scope and pass them to static.serialize / static.deserialize. One static call = one root value — usually a struct wrapping the real fields.
local T = VS.types
local Packet = T.struct({ id = T.uint16, name = T.string255 })
Strict vs lax failure (strictMode)
With strictMode = true (default), invalid input or buffer overflows throw. With false, they warn and serialize/deserialize return nil. In lax mode every call site must check the result.
Important: an absent optional(T) legally decodes to nil on success. In lax mode a nil result from deserialize is ambiguous — use strict mode, an outer length/checksum, or protocol context to distinguish "absent" from "failed".
Headers with offset
Pass a positive offset to reserve that many leading bytes for your own header (magic, protocol version, chunk length). The returned buffer's bytes [0, offset) are zero-filled and yours to overwrite. Deserialize with the same value so reading begins after the header.
If keeping the header in the same buffer is inconvenient, serialize with offset = 0 and frame the payload yourself.
Compression choices
Zstd (via Roblox EncodingService) costs CPU on both ends and rarely shrinks small payloads meaningfully. Measure on real traffic: it helps the most on repetitive or string-heavy blobs, less on mostly-numeric packets that are already compact.
Manual buffer mode
The manual cursor API (writeStart/writeFinish, readStart/readFinish) exposes the same scratch buffer used internally. You can call a type node's write/read directly between writeStart and writeFinish to interleave your own buffer.write* calls with VoidSentry2 nodes. This is an escape hatch — prefer the static API for everything that fits in a single schema.
Examples
Player snapshot (static)
Assume local VS = require(...), local T = VS.types, local static = VS.static for each block below.
local T = VS.types
local static = VS.static
local Player = T.struct({
id = T.uint32,
name = T.string255,
pos = T.vector3,
hp = T.float32,
})
local buf = static.serialize(Player, {
id = 1,
name = "Aria",
pos = Vector3.new(0, 10, 0),
hp = 100,
}, nil, nil)
local round = static.deserialize(Player, buf, nil, nil)
Batched entities + Zstd
Serialize with a numeric level; deserialize with wasCompressed = true.
local EntityList = T.array(T.struct({
id = T.uint16,
x = T.float32,
y = T.float32,
}))
local buf = static.serialize(EntityList, entities, nil, 5)
local out = static.deserialize(EntityList, buf, nil, true)
Offset: reserve bytes, then write a header
First HEADER bytes stay zero until you fill magic/version; payload starts at HEADER. Deserialize with the same offset.
local HEADER = 8
local Payload = T.struct({ score = T.uint16, name = T.string255 })
local buf = static.serialize(Payload, { score = 10, name = "Ann" }, HEADER, nil)
buffer.writeu32(buf, 0, 0x50505332) -- example magic "PPS2"
buffer.writeu32(buf, 4, 1) -- example protocol version
local data = static.deserialize(Payload, buf, HEADER, nil)
Dynamic with Zstd
First argument to serialize is the compression level (or nil for none). First argument to deserialize is true if that buffer was compressed.
local dynamic = VS.dynamic
local bigString = string.rep("x", 4000)
-- First arg = Zstd level 4; then offset nil; then the values to pack.
local buf = dynamic.serialize(4, nil, "ServerLog", bigString)
if not buf then return end
-- Because we used a compression level above, pass true here.
local label, payload = dynamic.deserialize(true, nil, buf)
-- label == "ServerLog", payload == bigString
Static “RPC-shaped” packet (several fields, one struct)
local Rpc = T.struct({
op = T.string255,
playerId = T.uint32,
amount = T.float32,
})
local buf = static.serialize(Rpc, {
op = "Damage",
playerId = 7,
amount = 12.5,
}, nil, nil)
enum (Roblox EnumItem)
local MaterialField = T.enum(Enum.Material)
local Row = T.struct({ mat = MaterialField })
local buf = static.serialize(Row, { mat = Enum.Material.Slate }, nil, nil)
local back = static.deserialize(Row, buf, nil, nil)
Dynamic RPC-style tuple
Uncompressed dynamic buffer: first two arguments to serialize are nil (no Zstd, no write offset). Receiver unpacks in order.
local dynamic = VS.dynamic
local playerId = 42
local targetPos = Vector3.new(1, 0, 0)
local b = dynamic.serialize(nil, nil, "Move", playerId, targetPos, true)
if not b then return end
local opName, id, pos, wantsJump = dynamic.deserialize(nil, nil, b)
-- opName == "Move", id == playerId, pos == targetPos, wantsJump == true
Optional fields + nested struct
Model “maybe present” values with optional; nest struct for grouped fields.
local T = VS.types
local static = VS.static
local Profile = T.struct({
userId = T.uint32,
displayName = T.string255,
bio = T.optional(T.string255),
stats = T.struct({
kills = T.uint16,
deaths = T.uint16,
}),
})
local buf = static.serialize(Profile, {
userId = 9001,
displayName = "Neo",
bio = nil,
stats = { kills = 12, deaths = 3 },
}, nil, nil)
String keys → numbers (map)
local T = VS.types
local static = VS.static
local Scores = T.map(T.string255, T.float32)
local buf = static.serialize(Scores, {
["arena_1"] = 99.5,
["arena_2"] = 12.0,
}, nil, nil)
local out = static.deserialize(Scores, buf, nil, nil)
void, nothing, and any
void is an empty table {} marker (zero payload bytes). nothing is Luau nil with zero bytes. any stores a one-byte type id plus the inner value — useful when one field can hold several shapes.
void and nothing are niche
They exist mainly so the dynamic serializer can represent empty tables and nil without extra tag bytes in those cases — saving bandwidth when you pack values that inference picks as void or nothing. They are not aimed at typical static schemas; prefer optional(T) or a concrete type when you control the layout.
local Ping = T.struct({ kind = T.string255, payload = T.any })
local buf = static.serialize(Ping, { kind = "int", payload = 42 }, nil, nil)
local back = static.deserialize(Ping, buf, nil, nil)
serInstance: register schema, then serialize
Run registerInstanceSchema once per class name (usually at module load). Then use types.serInstance("ClassName") in your static schema. Deserialization creates a new Instance and assigns properties from the buffer.
local VS = require(game.ReplicatedStorage.VoidSentry2)
local T = VS.types
local static = VS.static
VS.registerInstanceSchema("Part", {
Name = T.string255,
Anchored = T.boolean,
})
local PartSnapshot = T.struct({
template = T.serInstance("Part"),
})
local src = Instance.new("Part")
src.Name = "Floor"
src.Anchored = true
local buf = static.serialize(PartSnapshot, { template = src }, nil, nil)
local copy = static.deserialize(PartSnapshot, buf, nil, nil)
-- copy.template is a new Part with Name / Anchored restored
Mixing instance (by reference) and static structs
types.instance writes a numeric id and resolves the same object on the peer via your instanceMap and _VSID — use for live object graphs. serInstance snapshots property bags into new instances — use for templates or reconstructed props.
-- instance: same Part reference round-trips if both sides share instanceMap + _VSID
local RefPayload = T.struct({ target = T.instance })
-- serInstance: wire carries property values; deserialize builds a fresh Instance
local ClonePayload = T.struct({ cloneOfPart = T.serInstance("Part") })
Benchmarks
What this is
A short summary of typical hot-path cost (≈ average µs per serialize→deserialize in the static rows; dynamic rows match the schemaless API). It is not the full voidSentry2_Test matrix.
The results highly depend on the hardware.
Useful tips
Short habits that save time.
Build schemas once
Define local MySchema = T.struct({ ... }) at module scope. Creating new struct tables inside hot loops creates garbage and costs CPU.
Use smaller types when the data allows
If a number never exceeds 255, uint8 beats uint32. If position does not need full float precision, vector3f24 or vector3f16 saves bytes versus vector3.
Measure compression on real traffic
Zstd helps repetitive blobs; tiny messages may not shrink enough to pay for CPU.
Changing your schema breaks old bytes
Caution: adding/removing/reordering struct fields or switching node kinds changes the wire layout. Old clients or old save files will mis-read data if you do not migrate.
Tip: if you care about backwards compatibility, use a non-zero offset on static.serialize so the buffer has room at the front for your own header. Write metadata there (for example a schema version), then parse that header first and call static.deserialize with the matching offset so the typed payload aligns with the version you expect. Branch reads or migrate once based on that version instead of stuffing a version field inside the root struct.
nil from optional vs nil from failure
An absent optional(T) decodes as nil on success. In lax mode, failures can also yield nil. Use strictMode, outer framing (length + checksum), or protocol context to tell the cases apart when it matters.
Pick instance vs serInstance deliberately
Use types.instance when the peer already holds the same live object (replicated parts, player characters). Use types.serInstance when you want a property snapshot that reconstructs a fresh Instance on the other side. Mixing them in the same packet is fine — pick per field.
Prefer struct over multiple dynamic values
When a message's shape is stable, a single struct through the static API is smaller and faster than sending the same fields via dynamic.serialize. Reserve dynamic for genuinely variable argument lists.
Custom types
Most games only need to compose built-in nodes. Forking the library is for brand-new wire encodings.
1. Compose built-in nodes (recommended)
Describe the wire shape by combining existing nodes: struct, array, map, optional, union, numbers, strings, vectors, enum, any. No package changes. Use this for almost every gameplay, RPC, or save-blob shape.
local T = VS.types
local static = VS.static
local InventorySlot = T.struct({
itemId = T.uint16,
count = T.uint8,
})
local PlayerSave = T.struct({
coins = T.uint32,
slots = T.array(InventorySlot),
})
local buf = static.serialize(PlayerSave, saveTable)
2. serInstance: snapshot Roblox instances
types.serInstance(className) serializes a declared set of properties for a given class. Flow:
Register once per class.VoidSentry2.registerInstanceSchema(className, { PropName = typeNode, ... }). Keys are property names as strings; values are type nodes. Call at startup, before any serialize.
Reference in schemas. Use T.serInstance(className) inside a struct or collection.
Serialize. Pass a real Instance of that class; the library reads the registered properties and writes them.
Deserialize. The library constructs a newInstance.new(className) and assigns properties from the buffer. Object identity is not preserved.
Versus types.instance.instance writes a numeric id (from instanceIdAttribute) and resolves the same live object on the peer via the shared instance map. Use instance for live references, serInstance for templates or fresh clones.
3. Extending union with a custom variant
Once you have a union use-case that the built-in variants don't cover, you can add your own. See union details for how dispatch works; this section focuses on the extension steps.
Write the variant node. A frozen table { write, read, exType }. The idiomatic home for a reusable node is a new file under src/types/<name>.luau (matching the convention of every built-in type and allowing T.<name> access) — use any existing file there as a template. Defining the node inline with table.freeze({ ... }) next to the union that uses it is acceptable for a genuine one-off where promoting it to a first-class type would be noise. Either way, exType must be unique within the union and must not appear in config.unsupportedUnionExTypes (union, any, void, optional).
Make extypeof return that string for your values. If your variant's values already fall into an existing category ("int", "float", "array", "map", "Enum.<T>", or a raw typeof string), set exType to that category and remember only one variant per category may appear in a union. If your variant handles a shape the defaults don't distinguish, add a branch at the top of src/shared/extypeof.luau that returns your custom tag.
Use it. Pass the variant to T.union(variantA, variantB, yourVariant). Writes look up your node by exType and emit a 1-byte variant index; reads use the same index to pick the variant.
Example: routing a tagged Luau table to a dedicated variant
Two shapes both arrive as non-sequential tables but must serialize differently. The defaults map both to "map". Extend extypeof to look at a tag field and return a distinct string for one of them.
-- 1. Your variant node. Prefer src/types/myTagged.luau for reuse;
-- the inline form below is fine for a single-call-site one-off.
local MyTaggedNode = table.freeze({
write = function(value) --[[ ... ]] return 0 end,
read = function() --[[ ... ]] return 0, value end,
exType = "myTagged",
})
-- 2. Patch src/shared/extypeof.luau (simplified):
-- if vType == "table" and rawget(value, "_tag") == "MY_TAGGED" then
-- return "myTagged"
-- end
-- 3. Compose a union that can hold the custom variant alongside plain maps:
local Packet = T.union(T.map(T.string255, T.int32), MyTaggedNode)
4. Forking: adding a brand-new wire type
Only needed when the byte layout you want does not exist in any built-in node. Two layers are involved.
4a. Buffer primitives — src/buffer/writer.luau and src/buffer/reader.luau
Low-level helpers that advance the cursor and write/read raw bytes (U8, U16, writef32, NEED, …). Not typed, no dispatch. Add symmetric write* / read* helpers here if you need a new byte pattern.
4b. Type node — src/types/<name>.luau
Exports a frozen { write, read, exType }. Validates input, calls NEED(n), then writes using the buffer primitives. On read, checks the status and returns (flag, value). Use src/types/uint8.luau or src/types/float32.luau as a template.
Add a unique 1-byte id: myformat = 48. The key must match the filename. src/init.luau iterates typeSigns and loads each matching src/types/<name>.luau into signMap and types.
Only needed if the dynamic serializer should automatically pick your type from plain Luau values. Static schemas reference nodes directly and do not need this step.
After adding all four pieces, the new type is available as T.myformat, works in struct/array/map, and (if added to inferValueType) can be chosen by the dynamic path.
Update logs
VoidSentry2 v0.1.1
New types: union (discriminated variants, up to 255 branches) and boolPacked (eight booleans in one byte).
New integer widths: uint40, int40, uint48, int48.
New CFrame layouts: cframef16 and qcframef16.
Color3, CFrame family, Luau vector, and Vector2/Vector3 (including compact float variants) now share fast paths in the buffer reader/writer layer.
Manual serialization: writeStart, writeFinish, readStart, readFinish, plus getWriteCursor and getReadCursor.
Type export fix: getItemType works correctly with the Luau LSP.
Documentation refresh, general code quality, and small performance tweaks.