Skip to content

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 like std::vector and std::unique_ptr free it for you; a plain new needs its delete.
  • Owned handles (clight::Text, clight::ItemStack, clight::Buf) release themselves. A clight::Buf can 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 data buffer in a payload handler, ctx->stack in 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_str fields in payloads) are valid during the callback only. Copy them into a std::string if you need them later.
  • Strings you give the game (a cl_str in a descriptor, a message) are copied by the game during the call, so a local std::string is 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 (user pointers, lambdas’ captures) must stay valid as long as the registration exists. this of 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).