Memory in the sandbox
Lesson 11.1
A C++ mod lives in a WebAssembly sandbox. It is still C++: new, delete, std::string, std::vector, std::map, classes and most of the standard library all work. But the sandbox has its own rules about memory and files, and knowing them saves you from strange bugs.
Your own memory
Your mod gets its own block of memory, separate from the game and from every other mod. Pointers in your mod point into that block and nowhere else. That is why the API uses handles: your code can’t hold a pointer to the game’s objects, and the game never trusts a pointer from your code without checking it.
If your code passes the game a pointer that points outside your memory (a broken pointer, or a length that is too big), the game stops the call with a trap instead of reading somewhere it shouldn’t. The log says:
[my_mod] callback trapped: clight: invalid guest pointer passed to the API
Inside your own memory, the usual C++ rules apply: writing past the end of an array, or using an object after it was destroyed, doesn’t crash the game, but it can quietly destroy your mod’s other data, exactly as in a normal program. Prefer std::string and std::vector to raw arrays, use .size() in loops, and keep -Wall -Wextra on (see The manifest).
The memory limit
Each mod may use up to 256 MB by default. That includes everything: your globals, the heap (new, std::string, std::vector), and the stack.
Because exceptions are off, running out of memory is not something you can catch: a plain new or a std::vector that can’t grow ends your callback with a trap. Most mods need a few MB and never get close. If you really allocate something big, ask for it without the trap and check:
#include <new>
std::unique_ptr<int[]> make_scores(size_t n) {
std::unique_ptr<int[]> scores(new (std::nothrow) int[n]());
if (!scores) clight::log::error("out of memory");
return scores; // empty when there was no memory
}
new (std::nothrow) returns nullptr instead of failing, and std::unique_ptr deletes the array when it goes away. W3Schools covers new and delete in new and delete.
If your mod really needs more than 256 MB, raise the limit in the manifest, up to 4096:
"limits": { "memory_mb": 512 }
The stack is small
Local variables live on the stack, and a WebAssembly mod’s stack is small: 64 KiB. A few small locals are fine, and so are std::string and std::vector locals, because their contents live on the heap. A std::array<char, 100000> or char big[100000] inside a function is not: it is bigger than the whole stack, and it destroys data or traps.
Put big things in members, static storage or the heap:
class ReportMod final : public clight::Mod {
public:
void build_report() {
std::vector<char> buf(100000); // fine: the contents are on the heap
// ... write the report into buf, then keep it:
std::copy(buf.begin(), buf.end(), page_.begin());
}
private:
std::array<char, 100000> page_{}; // fine: part of the mod object, which lives on the heap
};
Deep recursion uses the stack too, so prefer loops for things like flood fills.
Who owns what
You already know the rules from Handles and strings. Here they are in one place:
- Your memory (
new, containers, members, globals) is yours: RAII classes likestd::vectorandstd::unique_ptrfree it for you; a plainnewneeds itsdelete. - Owned handles (
clight::Text,clight::ItemStack,clight::Buf) release themselves. Aclight::Bufcan only be moved, not copied. - Lambdas you give the wrapper (events, timers, commands, screens, …) are freed when they are done: when the subscription is dropped, a timer ran or was cancelled, a screen or menu closed, or the mod unloads. Their captures are freed with them.
- Borrowed things (players, levels, event payloads, the
databuffer in a payload handler,ctx->stackin an item behaviour) belong to the game: use them during the call, and never put them into an owning class. - Strings from the game (
cl_strfields in payloads) are valid during the callback only. Copy them into astd::stringif you need them later. - Strings you give the game (a
cl_strin a descriptor, a message) are copied by the game during the call, so a localstd::stringis fine, except for changed event fields like a chat message, which must point at memory that is still there after your handler returns: use a member. - Pointers you register (
userpointers, lambdas’ captures) must stay valid as long as the registration exists.thisof your mod is always safe; a reference to a local variable never is.
The wrapper keeps each lambda you give to clight::on, clight::cmd::add or clight::hud::add_layer in a small box on the heap until the mod unloads. That is a few dozen bytes each, so it only matters if you subscribe thousands of times.
The SDK’s scratch space for API calls, about 256 KB of your memory, is set aside when the mod loads. You don’t need to do anything about it; it is why a tiny mod already uses a little memory.
Global objects
Global and static objects with constructors (a global std::map, a std::string constant) are built once, when the module is loaded, before your mod class is created. That works, but the game hasn’t set your mod up at that moment, so don’t call the game API from a global constructor. Do that work in on_construct instead.
Files
A C++ mod can use std::ifstream and std::ofstream, but it only sees two folders:
| Path in your code | Is | Access |
|---|---|---|
/ |
Your mod’s own folder (assets/, datapack/, the manifest, …) |
read only |
/data |
Your mod’s data folder | read and write |
#include <fstream>
// Save a value.
void save_visits(int visits) {
std::ofstream out("/data/visits.txt");
if (out) out << visits << "\n";
}
// Load it again.
int load_visits() {
int visits = 0;
std::ifstream in("/data/visits.txt");
if (!(in >> visits)) visits = 0; // first start: no file yet
return visits;
}
/data is mods/<id>/data/ in the game folder, or build/run/<id>/data/ while you test with clight run. It isn’t part of your published package, and it survives updates of your mod. Everything else (C:\, other mods, saves) doesn’t exist for your mod: opening it simply fails. W3Schools covers file streams in Files.
Writing a file takes time. Save in ev::LifecycleServerStopping or now and then from a timer, not every tick.
Size
The standard library is compiled into your mod. A release build of the template is a module of about 540 KB (about 150 KB once packed into the .clmod), and most of that is the standard library the wrapper uses, like std::format and std::function. <iostream> pulls in more of it, so prefer clight::log to std::cout. Your players download the mod once, so this is rarely a problem; just don’t be surprised when the first .clmod is bigger than its source.
No threads, no network, no programs
The sandbox has no threads (std::thread compiles, but starting one ends your callback), no sockets, and no way to start programs or load native libraries. std::getenv finds nothing, and there are no command-line arguments. This is what makes C++ mods safe to install, and the API gives you game-friendly ways to do the things mods need: timers instead of threads, payloads instead of sockets.
Try it
This mod should count joins, but the count is wrong and sometimes other data in the mod gets damaged. Find the problem and fix it.
class JoinCountMod final : public clight::Mod {
public:
void on_construct() override {
int joins = 0;
clight::on<clight::ev::PlayerJoin>([&joins](cl_ev_player& e) {
joins = joins + 1;
clight::Entity(e.player).send_message("Join number " + std::to_string(joins));
}).detach();
}
};
Show a solution
The lambda captures joins by reference, but joins is a local variable of on_construct. When on_construct returns, joins is gone, and the lambda keeps writing to the stack memory where it used to be, which by then belongs to something else. The compiler can’t warn about it, because the lambda is stored and called later.
class JoinCountMod final : public clight::Mod {
public:
void on_construct() override {
clight::on<clight::ev::PlayerJoin>([this](cl_ev_player& e) {
joins_ = joins_ + 1;
clight::Entity(e.player).send_message("Join number " + std::to_string(joins_));
}).detach();
}
private:
int joins_ = 0;
};
The count is a member now, and the lambda captures this. The mod object lives as long as the mod is loaded, so the lambda always finds it.
Recap: your mod has its own memory (256 MB by default, a 64 KiB stack), the game traps bad pointers, running out of memory traps too (use new (std::nothrow) for big blocks), borrowed things stay the game’s, captured references must outlive the callback, and files are limited to your own folder (read only) and /data (read and write).