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

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.

109 Likes

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.

5 Likes

That’s… disappointing. Any idea why?

1 Like

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

2 Likes

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.

1 Like

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?

2 Likes

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.

4 Likes

Sorry to revive topic, but how would u add custom properties to the object ? When I try to add custom properties, I get some errors because it checks if it exists on the real object at this point :

		meta.__newindex = function(s,k,v)
			real[k] = v
		end
1 Like

First, create a modulescript:

--Normal modulescript
local module = {}

return module

Then, add a propertie:

local module = {}
module.Teststring:string = "Teststring"
return module

Then, create a variable to your modulescript and to the index metamethod, add this if-statement:

meta.__index = function(s,k)
    if ModuleScript[k] then --NEW: If the script find something on the ModuleScript, then...
        return ModuleScript[k] --...return the target propertie
    elseif k == "TellMeAJoke" then --Else, do what you know
		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

You also can use this for functions! This is how i am using it lol:


meta.__index = function(s,k)
    if ModuleScript[k] then --If the script find something on the ModuleScript, then, function or not...
        return ModuleScript[k] --...then return the target propertie (function or not)
	end
	return wrap(real[k])
end

Really important, dont do this:

return ModuleScript[k]()

If you are doing this, then you will get a error because of 2 things:

  1. If the target propertie is not a function, then this will error because you are trying to call a function to a not function variable. It is like this:
local String:string = "String"
String() --Error, this isnt a function lol
  1. If the propertie is a function, however, then you are first calling the function, then return the returned value of the function. If you are saying something like: „WTH, wdym?!“, here is a sample:
function module.TestFunction():string
    return "ReturnedString"
end

if you are targeting this function and make the error, then you are calling the function that return this string. In other words: You are getting the result of your function, not your function itself:

local Wrapper:ModuleScript = require(path.to.WrapperModule)
local Wrapped = Wrapper.wrap(Something)
print(Wrapped:TestFunction) --Output: "ReturnedString", not "function:XXXXXXXXX"

Hope this helps, but a really good trick is to use modulescripts for properties/functions like this, not do how the tutorial is doing (@Emilarity is doing nothing wrong, but if you have many functions then this will be really messy to debug something and etc., ModuleScripts will really help, this is why @Emilarity said:

)
I really hope this helps, i tried it myself and this works at 100%.

2 Likes

This is great! But i have an issue, you can’t change other instances’ properties that expect instances such as .Parent to the “wrapped” instances as they are tables.

you can change them. just customize it a bit.

meta.__index = function(s,k)
    if ModuleScript[k] then --If the script find something on the ModuleScript, then, function or not...
        return ModuleScript[k] --...then return the target propertie (function or not)
    elseif s[k] then --else if this is inside the Instance…
        return s[k] --just return this property
    end
	return wrap(real[k])
end

This is how you should make it (I am on mobile, so idk if this works. Try it out)

When I created a custom function, the “self” parameter wasn’t being filled in when I called the function. Does anybody know how I can fix this?

if k == "hello" then
	return function(self)
		print(string.format("Hello, %s", self.Name))
	end
	return wrap(real[k])
end

I would try to just use the normal function that is in the tutorial:

		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

Then just change some parts of it, the result would be:

meta.__index = function(s,k)
	if k == "hello" then
		return function(self)
			print(string.format("Hello, %s", self.Name))
		end
   	end
	return wrap(real[k])
end

Still, it‘s not a recommanded and you should read this:

1 Like