Skip to main content

Types

Compared to other existing Ser/Des modules (like Squash), ByteWorks does not use dynamically resized buffers. Each type has a size method that calculates the exact buffer size needed to hold the serialized data, with some types having a static size, while still being a method so everything is consistent.

This changes how you serialize multiple types sequentially slightly, instead of just being able to push them to the dynamic buffer, you have to create a buffer with the right size before performing any serialization.

The huge benefit of this approach is performance. If you use a dynamic approach not only do you need to create a new buffer each time a resize happens, you also have to copy the previous contents to the new buffer. Then, once you're done serializing all your data, you have to create another buffer with the actual needed size for the serialized data and then copy the contents to the new buffer.

Numbers

Numbers in luau are stored as 8 byte double precision floating point numbers. If we want to represent integers, then the representable range is −2⁵³ to 2⁵³.

Unsigned Integers

Unsigned integers refer to whole numbers. The available types with their respective ranges are:

TypeBytesMinMax
u810255
u162065,535
u243016,777,215
u32404,294,967,295
u53709,007,199,254,740,992

Signed Integers

Signed Integers refer to integers as they are commonly known in math. They have a sign bit as their first bit to dictate whether the number is a positive number or a negative number. The available types with their respective ranges are:

TypeBytesMinMax
i81-128127
i162-32,76832,767
i243-8,388,6088,388,607
i324-2,147,483,6482,147,483,647
i537-9,007,199,254,740,9929,007,199,254,740,992

You are able to store up to 56 bit integers theoretically with i53, but past the specified ranges integers are not exactly representable anymore, since they round to a multiple higher than 1.

Floating Point Numbers

Floating point numbers are commonly known as decimal numbers, the available types are:

TypeBytesMinMax
f162-65,50465,504
f324ca. -10^38ca. 10^38
f648ca. -10^308ca 10^308

It is very important to note that floating point numbers behave differently from integers, with floating point numbers, the higher the number is, the lower the precision. Let me demonstrate this with f16:

MinMaxInterval
1282560.125
2565120.25
51210240.5
102420481
204840962
409681924

Naturally, f32 and f64 have way better precision with low numbers like these, but this is something you have to keep in mind when using floating point numbers, especially when you're using f16.

Variable Length Quantity

Variable length quantity numbers are useful when you don't know how many bytes a number needs, or when you encode a diverse set of numbers and you don't want to opt for the largest type needed to accomodate every possible number you will serialize. VLQs store the number in the first 7 bits and the last bit is the "continuation bit", which basically tells the algorithm if there is more to the number and if it should continue reading/writing. This means that VLQs are less efficient if you have numbers in similar ranges, as each block of 8 bits needs a continuation bit.

Here are some example ranges for different byte counts:

BytesMinMax
10127
2016,383
302,097,151

Signed Variable Length Quantity

Here are some example ranges for different byte counts:

BytesMinMax
1-6363
2-8,1918,191
3-1,048,5751,048,575

Booleans

We only need 1 bit to determine if a boolean is true (1) or false (0), however buffers work in bytes, so we can combine 8 booleans into 1 byte to make the most of our space.

local boolean = ByteWorks.boolean
local buff = buffer.create(boolean.size())
boolean.ser(buff, 0, true, true, false, false, true, false, true, false)
--we use select because the first returned value is the offset after the deserialization, and we dont need that
print(select(2, boolean.des(buff, 0))) --> true true false false true false true false

Buffers

Buffers can be written into buffers, which is just a copy operation, this is useful if you only want to serialize data when it changes instead of every time it is replicated for example.

local bufferType = ByteWorks.buffer
local sourceBuff = buffer.create(4)
buffer.writeu32(sourceBuff, 0, 123456) --write some data into the source buffer

local destBuff = buffer.create(bufferType.size(sourceBuff))
bufferType.ser(destBuff, 0, sourceBuff)

local _, deserializedBuff = bufferType.des(destBuff, 0)
print(buffer.readu32(deserializedBuff, 0)) --> 123456

CFrame

Simple implementation of CFrame serialization and deserialization, it is very general purpose and I reccomend making your own serializer for CFrames that is better suited to your needs.

local cframeType = ByteWorks.cframe
local cframe = CFrame.new(10, 20, 30) * CFrame.fromOrientation(math.pi / 4, math.pi / 2, 0)

local buff = buffer.create(cframeType.size(cframe))
cframeType.ser(buff, 0, cframe)

local _, deserializedCFrame = cframeType.des(buff, 0)
print(deserializedCFrame.Position, deserializedCFrame:ToOrientation()) --> 10, 20, 30, pi/4, pi/2, 0

Arrays

There are both dynamic arrays and fixed size arrays, arrays can only work with one type.

Dynamic Arrays

local arrayType = ByteWorks.array(ByteWorks.u8)
local data = {1, 2, 3, 4, 5}

local buff = buffer.create(arrayType.size(data))
arrayType.ser(buff, 0, data)

local _, deserializedData = arrayType.des(buff, 0)
print(table.concat(deserializedData, ", ")) --> 1, 2, 3, 4, 5

Fixed Size Arrays

local fixedArrayType = ByteWorks.fixedSizeArray(ByteWorks.u8, 3)
local data = {10, 20, 30}

local buff = buffer.create(fixedArrayType.size(data))
fixedArrayType.ser(buff, 0, data)

local _, deserializedData = fixedArrayType.des(buff, 0)
print(table.concat(deserializedData, ", ")) --> 10, 20, 30

Maps

Maps are tables that associate keys with values. While the value type can be anything, the key type must be indexable (e.g., numbers, strings).

local mapType = ByteWorks.map(ByteWorks.string, ByteWorks.u8)
local data = {apple = 1, banana = 2, cherry = 3}

local buff = buffer.create(mapType.size(data))
mapType.ser(buff, 0, data)

local _, deserializedData = mapType.des(buff, 0)
print(deserializedData) --> {apple = 1, banana = 2, cherry = 3}

Struct

Structs map a string identifier to a specific value type, since the identifiers are known ahead of time, we don't have to serialize them.

Regular Structs

local structType = ByteWorks.struct({
name = ByteWorks.string,
age = ByteWorks.u8,
isAdmin = ByteWorks.boolean,
})

local data = {name = "Alice", age = 25, isAdmin = true}

local buff = buffer.create(structType.size(data))
structType.ser(buff, 0, data)

local _, deserializedData = structType.des(buff, 0)
print(deserializedData) --> {name = "Alice", age = 25, isAdmin = true}

Optional Structs

Structs also support optional fields, the cost for an optional field is only 1 byte per 8 optionals. They are a seperate type due to performance implications.

local optStructType = ByteWorks.optStruct({
name = ByteWorks.string,
age = ByteWorks.opt(ByteWorks.u8),
})

local data = {name = "John", age = nil}

local buff = buffer.create(optStructType.size(data))
optStructType.ser(buff, 0, data)

local _, deserializedData = optStructType.des(buff, 0)
print(deserializedData) --> {name = "John"}

Literal

Literals are values that can be stored with just an u8, which is useful for things like unique identifiers.

local literalType = ByteWorks.literal("apple", "banana", "cherry")
local value = "banana"

local buff = buffer.create(literalType.size(value))
literalType.ser(buff, 0, value)

local _, deserializedValue = literalType.des(buff, 0)
print(deserializedValue) --> banana

Enum

Enums store a roblox Enum either as an u8 or an u16, depending on the highest enum value present within that enum.

local enumType = ByteWorks.enum(Enum.Material)
local value = Enum.Material.Concrete

local buff = buffer.create(enumType.size(value))
enumType.ser(buff, 0, value)

local _, deserializedValue = enumType.des(buff, 0)
print(deserializedValue) --> Enum.Material.Concrete

Bitfield

A bitfield allows you to pack multiple numeric values into a single integer by allocating a specific number of bits for each value. This is useful for efficiently storing small integers with bit precision. The provided type has to be either an unsigned or signed integer, other types might work if they are constant sized but this is the intended use case.

--allocated 2, 2, and 4 bits respectively, must add up to the type's size
local bitfieldType = ByteWorks.bitfield(ByteWorks.u8, {2, 2, 4})

local values = {2, 1, 8}

local buff = buffer.create(bitfieldType.size(values))
bitfieldType.ser(buff, 0, values)

local _, deserializedValues = bitfieldType.des(buff, 0)
print(table.concat(deserializedValues, ", ")) --> 2, 1, 8

Bitmask

A bitmask allows you to store multiple boolean flags in a single byte (or larger integer, if enough flags are present). Each bit in the byte represents a single flag, making it highly space-efficient.

local bitmaskType = ByteWorks.bitmask(8)

--create a table of boolean flags to serialize
local flags = {true, false, true, false, true, false, true, false}

local buff = buffer.create(bitmaskType.size(flags))
bitmaskType.ser(buff, 0, flags)

local _, deserializedFlags = bitmaskType.des(buff, 0)
print(deserializedFlags) --> {true, false, true, false, true, false, true, false}