Skip to content
Draft
Changes from 3 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
134 changes: 134 additions & 0 deletions docs/buffer-metatable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Extend buffer with a metatable

## Summary

Extend `buffer.create` with a second argument which provides a metatable for that buffer.
The metatable and some of its keys will be frozen for potential improvements in access.

## Motivation

Providing a buffer object with a metatable will allow buffer objects can have properties (direct or computed), methods and other kinds of operations like operators.

This will allow the behavior to be defined and extended on the Luau side, compared to userdata that is strictly defined by the host.

This extension preserves the simple small core of the Luau language while providing a flexible extension point for developers.

Such buffers can also be made to match the structure of the host data types and to handle FFI cases.

In a way, this will provide an alternative to luajit 'cdata' in Luau.

```luau
-- A float4 in a buffer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a float4.

Suggested change
-- A float4 in a buffer
-- A vector4 in a buffer

local mt = {
__index = function(b, field)
if field == "x" then return buffer.readf32(b, 0)
elseif field == "y" then return buffer.readf32(b, 4)
elseif field == "z" then return buffer.readf32(b, 8)
elseif field == "w" then return buffer.readf32(b, 12)
else error("unknown field") end
end,
__newindex = function(b, field, value)
if field == "x" then buffer.writef32(b, 0, value)
elseif field == "y" then buffer.writef32(b, 4, value)
elseif field == "z" then buffer.writef32(b, 8, value)
elseif field == "w" then buffer.writef32(b, 12, value)
else error("unknown field") end
end
}

local buf = buffer.create(16, mt)

buf.x = 2
buf.y = 4

assert(buf.x + buf.y + buf.z == 6)
```

Or alternatively:
```luau
local mt = {
__index = {
x = function(b) return buffer.readf32(b, 0) end,
y = function(b) return buffer.readf32(b, 4) end,
z = function(b) return buffer.readf32(b, 8) end,
w = function(b) return buffer.readf32(b, 12) end,
setx = function(b, value) buffer.writef32(b, 0, value) end,
sety = function(b, value) buffer.writef32(b, 4, value) end,
setz = function(b, value) buffer.writef32(b, 8, value) end,
setw = function(b, value) buffer.writef32(b, 12, value) end,
magnitude = function(b) return math.sqrt(b.x * b.x + b.y * b.y + b.z * b.z + b.w * b.w) end,
normalize = function(b) return ... end,
},
}

local buf = buffer.create(16, mt)

buf:setx(2)
buf:sety(4)
buf:normalize()

local xn = buf:x()
```

Or any other custom way the developer wants property accesses to be performed.

## Design

`buffer.create(size: number, metatable: table): buffer`
Comment thread
vegorov-rbx marked this conversation as resolved.
Outdated

Buffer construction will accept a second argument which will be a metatable for the buffer object.

This metatable will be frozen.
In addition to that, `__index` and `__newindex` fields will also be frozen if they are a table.
Comment thread
vegorov-rbx marked this conversation as resolved.
Table freezing will have the same limitation as `table.freeze`, throwing an error if table metatable is locked.
Any of these tables can be frozen before the call.

When `__index` or `__newindex` is a function, VM will be allowed to ignore changes to the environment of those function.
This is similar to function inlining functionality we have where an inlined function will no longer respect its environment.

The freezing is performed to provide additional guarantees for field access and other metamethod evaluation to unlock potential optimization opportunities.
This is similar to limitations placed on 'cdata' by luajit.
Having said that, this RFC doesn't make a promise of a particular implementation making those optimizations, so should be viewed as a buffer object usability improvement first.

Any chained metatables on table fields are not modified.
This provides an option to have a more dynamic behavior, but giving up on potential performance improvements of a chained indexing access.
This matches behavior of 'cdata' in luajit and will provide a familiarity for developers switching over.

VM metatable lookups and `getmetatable` will look for the buffer object metatable first and then fall back to the global buffer metatable.
This preserves the existing buffer extensibility point for hosts.

`setmetatable` will still not be supported on buffer objects, the metatable reference is immutable.

Equality checks in the VM will call `__eq` for buffers similar to tables and userdata.
Comment thread
vegorov-rbx marked this conversation as resolved.

`__type` metatable key is ignored by `typeof`, just like it does for tables.
As before, only host is allowed to define type names.

In order for the typechecker to understand buffers with attached metatables, we propose extending the intersections to be allowed on buffers, similar to `extern` types:

`type Obj = buffer & { x: number, y: number, z: number }`
Comment thread
vegorov-rbx marked this conversation as resolved.
Outdated

## Forwards compatibility

A potential evolution of the buffer type might move the data from the buffer memory block to a separate location with a redirection.

Doing that will enable extending the buffer size without a major impact on performance (shrinking is a problem for range check eliminations).
It might also allow buffer views and slices.

In both cases, this change is compatible. The slice will reuse a section of the data but being a buffer it will have its own metatable.

This makes it possible to have a large memory buffer and sub-allocate buffer objects with attached custom behaviors.

## Drawbacks
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to mention anything about the performance characteristics of buffer vs table (even if deserialized from buffers) wrt algorithms that does heavy reads/writes? If I have an algorithm that is very heavy on reads/writes, it has to undergo some overhead to turn a few bytes of a buffer into a TValue (or vice versa) whereas tables skips this ser/de.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to also add another drawback not mentioned here: buffers doesn't let you store any pointer indirections for any other Luau objects, so you're unable to store nested buffers, strings, instances, tables, etc, limiting their usefulness as data structures.

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And on the subject of the lack of pointer indirections: if you have a buffer of length 5, and you have a __newindex that accepts a string, you have to throw an error if the length of the string (minus the offset in the buffer) exceeds the buffer's capacity to store it. This makes it not ideal to allow the outside world to try and store strings in buffers without encapsulation.


This increases buffer size by 8 bytes, with the bigger impact on 0-8 byte buffers going from 16 to 24 bytes and allocated from 32 byte page.

This RFC also introduces a special evaluation rule for metamethod functions.
It is introduced for potential improvement in caching of operations, but might come at a surprise to users of deprecated environment modification functions.
While similar to the effects of function inlining Luau performs, it is an extra item to keep in mind.

## Alternatives

Instead of extending the buffer object with a limited set of functionality, we might pursue a new kind of object like Records were which can build internal field mappings for an easier optimization path.

Another possibility is some alternative way of specifying the fields that would support building both the right `__index` function and internal acceleration structures.