Wrapping with metatables, or "How to alter the functionality of Roblox objects without touching them"


#1

This is a tutoral on using metatables to create wrappers around Roblox’s locked objects, such as Instances and special data types. This gives you the freedom to change or add new functionality to the existing API. The following guide assumes you’re comfortable with Lua, particularly with metatables, and are familiar with the direction Roblox’s object-based scripting tends to go.

I’ll preface this with a disclaimer: This isn’t a very good idea. Using Roblox’s API the way it was intended helps with performance, readability, and just about everything else. It’s possible that you have a very specific use case, but it’s a lot more possible that you don’t.

Don’t let that dissuade you though. This is fun. In addition, it can provide some good mental exercise, and it improves your grasp on what Roblox’s objects are actually doing. The disclaimer is only there because some people get picky about what’s practical and what isn’t. I think that sometimes it’s fine to be impractical.

Let’s get started with a quick overview of why this works and the steps necessary to do it. Or if you’re an expert and you hate reading, just scroll down for code samples.


The first thing to recognize is that what we’re about to do is basically already what Roblox objects are doing on a slightly lower level. Take, for example, a Part instance called Baseplate.

The actual Lua type for this object is a userdata. It’s called this because it can store any arbitrary data inside it defined by the user. An example of that data is the struct that makes up the Part instance, which is used by Roblox’s internal (C++) engine code. The user in userdata, in this case, is Roblox.

The other reason it’s called userdata is because it has built-in support for metatables. That’s where all the functionality comes from. As Roblox’s engine code is in C++, and the engine functions that are making use of the data in the Baseplate instance are also written in C++, there needs to be a way for Lua interactions to trigger those functions. All of that is done through metatables, and through the same metamethods we can assign from our side in normal scripts.

I was going to put an infographic here but I don’t think it’s necessary. If anyone has trouble with this part though please let me know. It’s not too important for the method but I like going over this stuff to make sure we’re always on the same page.

It’s clear now why Roblox metatables are locked. Their metamethods are C functions and we can’t really write our own implementations. What we’re doing in this method is creating a third layer—another metatable object—in which the real userdata making up the Roblox object acts as the core. We have to make it so that everything we do to this proxy object will reflect on the real object, and anything the real object returns will be also returned by the proxy as another proxy object.

We can actually create new userdatas from the Lua side using the newproxy function. Some people know this already, but some people don’t. These proxy userdatas are empty and you don’t get to store arbitrary data in them like you can when creating userdatas from C++, but by passing true you get an unlocked metatable to create whatever functionality you want on the object. And we could use Lua tables and other values with those metatables to create our own pseudo-storage if we wanted to.

I saw another tutorial on here for doing OOP stuff with custom objects and pseudo-classes. I didn’t read through it all the way, but I’m pretty sure anyone who followed that is familiar with this step. Instead of creating our own functionality though, we are going to make it into a mirror for a real object.

Let’s go back to the Baseplate example from before. If you want to follow along, create a Part called Baseplate and put it in Workspace. Let’s do a whole bunch of stuff with it in a script as a goal/benchmark for our fake version.

local Baseplate = game.Workspace.Baseplate

--can we get basic stuff from it?
print(Baseplate, Baseplate.Name, Baseplate.ClassName)

--can we set properties?
Baseplate.Anchored = false

--let's call a custom method that doesn't exist in the real Part class
Baseplate:TellMeAJoke()

--let's try to break the wrapper and get a non-wrapped reference
Baseplate = Baseplate.Parent.Baseplate
Baseplate:TellMeAJoke()

--same thing, but stepping it up with a method call
Baseplate = Baseplate.Parent:FindFirstChild("Baseplate")
Baseplate:TellMeAJoke()

--let's do something funky: cloning the Part by using the unary minus operator (-)
NewBaseplate = -Baseplate

--messing with the cloned Part
NewBaseplate.Parent = game.Workspace
NewBaseplate.Name = "Otherplate"
NewBaseplate.Anchored = true
NewBaseplate.Size = Vector3.new(50,50,50)

--is it wrapped?
NewBaseplate:TellMeAJoke()

--help NewBaseplate reign supreme
Baseplate:Destroy()

If we run this by itself, we’ll get to the third test before an error is thrown. To build our custom Baseplate that’s capable of calling the custom method and more, we need to wrap it. Let’s create the most basic possible wrapping function now:

wrap = function(real) --we pass a real object, and it will spit out a fake copy
	if type(real) == "userdata" then --this is all we should worry about for now
		local fake = newproxy(true)
		local meta = getmetatable(fake)

		meta.__index = function(s,k)
			--when we get things from the fake object, they should also be fake objects
			return wrap(real[k]) --that's why this recursion is essential
		end

		meta.__newindex = function(s,k,v)
			--allowing us to set values
			real[k] = v
		end

		meta.__tostring = function(s)
			--the more it behaves like the real object, the better
			return tostring(real)
		end

		--meta.__etc...

		--you can implement as many of these as necessary
		--when you're done, return the new fake object
		return fake
	--elseif...
	else
		--remember that everything we get from the object will be passed through this function
		--that includes non-userdatas, such as strings, tables, functions and more
		--some of these things we will eventually need handlers for, and some we won't
		--if this else block is ever reached though, we can just return the object without doing anything
		return real
	end
end

Now, if we use this function to make a fake reference to Baseplate, we can get a couple lines into our test before it errors:

local Baseplate = wrap(game.Workspace.Baseplate)

print(Baseplate, Baseplate.Name, Baseplate.ClassName)
Baseplate.Anchored = false

This isn’t anything special, but it’s good still. We can index values and set values. This isn’t a real Part instance, so that’s noteworthy despite being kind of boring compared to the rest of what we’re going to do.

We can also easily implement our custom method by sneaking it into the __index metamethod from our wrap function:

meta.__index = function(s,k)
	if k == "TellMeAJoke" then
		return function(self)
			print(self.Name .. " here, to tell you a joke:")
			print("Q: What do you call a fly with no wings?")
			print("A: A walk.")
		end
	end
	return wrap(real[k])
end

Now if we call it with our fake Baseplate:

Baseplate:TellMeAjoke()
  Baseplate here, to tell you a joke:
  Q: What do you call a fly with no wings?
  A: A walk!

Easy!

Alright, it gets a bit harder when we start really messing with things. But we should still be able to manage. Take these lines for instance:

Baseplate = Baseplate.Parent.Baseplate
Baseplate:TellMeAJoke()

Baseplate = Baseplate.Parent:FindFirstChild("Baseplate")
Baseplate:TellMeAJoke()

The first attempt to break things is a good one, but the recursion in __index takes care of it. Baseplate.Parent returns a wrapped version of Workspace, and indexing that returns another wrapped copy of Baseplate. It passes.

But when we hit Baseplate.Parent:FindFirstChild("Baseplate"), we get a peculiar error:

Expected ':' not '.' calling member function FindFirstChild

This error is misleading, but it makes sense if you think about it. The : operator works by automatically passing the object itself as the first argument, then shoving your custom arguments after it. Object:Method(...) is esentially the same as Object.Method(Object, ...), it’s just syntax sugar. The way Roblox tells when you’ve accidentally used a . where a : should be is by checking whether the first argument works the way it should. If not, it assumes you’ve used the wrong operator and gives you that helpful error.

In our case, we haven’t used the wrong operator, but there’s still a problem. FindFirstChild is a real method that deals with real objects. Methods like this usually run using Roblox’s C++ back-end, and it’s expecting real data inside the object, something we have no power over. It fails at interacting with our fake Baseplate. In order to solve this problem, we have to build a handler inside our wrapper for functions too. Our wrapped functions will be responsible for feeding the real functions their real values, then giving back wrapped objects for whatever is returned.

There’s one problem though. This implementation requires us to be able to unwrap values as well as wrap them. Since we’re passing wrapped objects and we need to transform them into real objects, we need to be able to take any wrapped object and cleanly get the real object out of it. We can do this by implementing a cache for our wrapped objects. Then we can check the cache anytime we need to cross-reference between wrapped and unwrapped objects.

We’ll create the cache as a table. The keys will be wrapped objects, and they will point to their respective real objects. Because we don’t want this table getting in the way of garbage collection, we’ll use the __mode metamethod to enable weak keys. This means when there’s no references to the keys beyond the ones in this table, the garbage collector is allowed to collect them.

local wrappercache = setmetatable({}, {__mode = "k"})

Now let’s alter our wrap function. Before doing anything else, it should check if there’s already a wrapped value in the cache. If so, return that. Otherwise, after it’s finished wrapping the object, it should place it in the cache for later.

wrap = function(real)

	--first check if the object is already wrapped and available in the cache
	for w,r in next,wrappercache do
		if r == real then
			return w
		end
	end

	--otherwise, handle it ourselves
	if type(real) == "userdata" then
		local fake = newproxy(true)
		local meta = getmetatable(fake)
		
		meta.__index = function(s,k)
			if k == "TellMeAJoke" then
				return function(self)
					print(self.Name .. " here, to tell you a joke:")
					print("Q: What do you call a fly with no wings?")
					print("A: A walk.")
				end
			end
			return wrap(real[k])
		end
		
		meta.__newindex = function(s,k,v)
			real[k] = v
		end
		
		meta.__tostring = function(s)
			return tostring(real)
		end

		--store the object in the cache, then return
		wrappercache[fake] = real
		return fake
	else
		return real
	end
end

Now our unwrap function is as simple as checking the cache to see if the real object is there:

unwrap = function(wrapped)
	local real = wrappercache[wrapped]
	if real == nil then
		--theoretically, if it's not in the cache, it's not wrapped, so we'll just return the object without doing anything
		return wrapped
	end
	return real
end

This also inadvertently solves the problem of comparing wrapped objects. Before, if you indexed a wrapped object for the same member twice, you’d get two different fake objects pointing to the same real object. Now it will always return the cached object.

Before implementing the function wrapper, there’s one more thing we should do. We’re dealing with function arguments. We don’t know how many arguments there will be. It would be convenient to handle tables in our wrap and unwrap functions so we don’t have to pass each argument individually. We can do this quickly with some more recursion.

In the wrap function, let’s add a case for tables above the final else block:

elseif type(real) == "table" then
	local fake = {}
	for k,v in next,real do
		fake[k] = wrap(v)
	end
	return fake

And the same thing in reverse fits snugly into our unwrap function:

unwrap = function(wrapped)
	if type(wrapped) == "table" then
		local real = {}
		for k,v in next,wrapped do
			real[k] = unwrap(v)
		end
		return real
	else
		local real = wrappercache[wrapped]
		if real == nil then
			return wrapped
		end
		return real
	end
end

Now we can finally build the function handler into our wrapper. I put it as another elseif block right under the one that handles userdata. It works like this:

elseif type(real) == "function" then
	--the goal is to return a fake function that behaves like the real one, then returns wrapped results
	local fake = function(...)
		--unwrap the arguments so the real method can process the real versions of the objects
		local args = unwrap{...}
		
		--call the method with the real arguments, then catch the return values in a table and wrap them
		local results = wrap{real(unpack(args))}
		
		--return the wrapped results
		return unpack(results)
	end

	-- let's put fake functions into the cache as well for consistency
	wrappercache[fake] = real
	return fake

The process of unwrapping the arguments, calling the method, wrapping the return values then returning them can actually all be done on one line, but it looks pretty ugly and hard to read that way. If you’re into that though, I completely understand, and I’d probably do it too if this wasn’t a tutorial.

Moving on, here is the entire script so far:

local wrappercache = setmetatable({}, {__mode = "k"})

wrap = function(real)

	for w,r in next,wrappercache do
		if r == real then
			return w
		end
	end

	if type(real) == "userdata" then
		local fake = newproxy(true)
		local meta = getmetatable(fake)
		
		meta.__index = function(s,k)
			if k == "TellMeAJoke" then
				return function(self)
					print(self.Name .. " here, to tell you a joke:")
					print("Q: What do you call a fly with no wings?")
					print("A: A walk.")
				end
			end
			return wrap(real[k])
		end
		
		meta.__newindex = function(s,k,v)
			real[k] = v
		end
		
		meta.__tostring = function(s)
			return tostring(real)
		end
		
		wrappercache[fake] = real
		return fake
		
	elseif type(real) == "function" then
		local fake = function(...)
			local args = unwrap{...}
			local results = wrap{real(unpack(args))}
			return unpack(results)
		end
		wrappercache[fake] = real
		return fake
		
	elseif type(real) == "table" then
		local fake = {}
		for k,v in next,real do
			fake[k] = wrap(v)
		end
		return fake
		
	else
		return real
	end
end
	
unwrap = function(wrapped)
	if type(wrapped) == "table" then
		local real = {}
		for k,v in next,wrapped do
			real[k] = unwrap(v)
		end
		return real
	else
		local real = wrappercache[wrapped]
		if real == nil then
			return wrapped
		end
		return real
	end
end


local Baseplate = wrap(game.Workspace.Baseplate)

print(Baseplate, Baseplate.Name, Baseplate.ClassName)
Baseplate.Anchored = false

Baseplate:TellMeAJoke()

Baseplate = Baseplate.Parent.Baseplate
Baseplate:TellMeAJoke()

Baseplate = Baseplate.Parent:FindFirstChild("Baseplate")
Baseplate:TellMeAJoke()

--current milestone 

NewBaseplate = -Baseplate
NewBaseplate.Parent = game.Workspace
NewBaseplate.Name = "Otherplate"
NewBaseplate:TellMeAJoke()

NewBaseplate.Anchored = true
NewBaseplate.Size = Vector3.new(50,50,50)

Baseplate:Destroy()

Now if we run it, we actually make it past the test…! Baseplate.Parent:FindFirstChild("Baseplate") returns correctly, and we can even call our custom method on the result still. Try experimenting with more methods if you’d like. It should be compatible with most if not all of them.

We’ve basically reached the point where you can see the idea of how this works and start implementing more handlers and custom features by yourself. But there’s one last thing in our test, and it’s cloning stuff using the unary negative operator. This is an impractical but point-proving example of the kind of ridiculous stuff I’d like to add to the API for myself if I could. I’m sure you can think of better examples.

We can do this by just adding the __unm operator in the userdata handling block:

meta.__unm = function(s)
	return wrap(real:Clone())
end

As you can see, when we have the groundwork laid down already, adding new stuff becomes a cakewalk.

If you run the entire script now, it should run to the end without erroring. Every test should pass. And now you have a lot of freedom to make whatever lazy or useful shortcuts you desire.

(Yes!)


Before I finish this thread, I should point out some more stuff you can work on. Like setting up your whole global environment to only have wrapped variables. Take the following example for a reason why that would be necessary:

local Part = wrap(Instance.new("Part"))
print(typeof(Part))

Roblox’s typeof function doesn’t support our custom userdatas. This will simply print userdata even though the expected behavior is that it will print Instance. But look, here’s a magic trick:

typeof = wrap(typeof)

Now if we try the same thing:

local Part = wrap(Instance.new("Part"))
print(typeof(Part))

It will actually work correctly.

So remember, if you want to make sure everything works in harmony, it all has to be wrappped.

There’s actually a lot more cases than what we covered to handle. Checking class types, wrapping events, making sure errors look pretty, making sure stuff doesn’t get wrapped twice… More work than is necessary for this tutorial. If the above made sense, you should be able to start expanding on this code to write your own wrapper with as much compatibility as you need.

As a side note, this is also how sandboxes in script builder games work. That almost deserves its own thread.

As a side-side note, lots of nasty Roblox cheats use a method similar to this to avoid having to compile code on Roblox’s VM. That almost deserves its own thread too.

If you have any questions, I’m more than happy to answer.


#2

At least a while ago, newproxy objects were prevented from getting removed by the GC even in weak tables. I don’t know if this changed, but it probably hasn’t.
Edit: please disregard this post and read the ones below.


#3

That’s… disappointing. Any idea why?


#4

newproxy objects do appear to be GC’d:

local t = setmetatable({}, { __mode = "v" })
t[1] = newproxy(true)

repeat
	print(#t)
	wait(1)
until false

However, the __gc metamethod is not called: https://devforum.roblox.com/t/in-rbx-lua-why-does-the---gc-metamethod-not-fire-for-userdata/4022/6?u=bunny_hop


#5

Great! Thanks for the correction.

I remember somebody posting a screenshot of the 2016 source saying something along the lines of may_gc = false; and that the GC has high permissions/high context level or something, so it would be best to “not let users mess with the GC”.

It seems that the actual behaviour is like @bunny_hop states. The name of the may_gc flag is misleading and only affects the __gc metamethod being called, while the object can be collected normally.


#6

Thanks for this tutorial! This article taught me more than what the wiki has offered in terms of using metatables for wrapping/sandboxing things.

Is it possible you do a tutorial on thoroughly sanboxing code?


#7

You’re welcome! And a tutorial on thorough sandboxing is something I’ve wanted to do before. It doesn’t have much application lately though because script builders are kind of a niche. I know there’s other uses for sandboxing too, like maybe running modules or obfuscated scripts that you don’t trust, but I can’t imagine many people going through the trouble of writing a sandbox for that.

Most sandboxes are going to look the same as the code shown in this thread. The difference is that the sandboxed script is forced to run in an environment where every global is wrapped already. Compatibility with the entire API in that case is what makes sure there are no breakouts, which is a very long process and it requires a lot of code. I don’t know if I’m the right person to demonstrate what a thoroughly secure sandbox looks like.