Skip to content

Debugging

Lesson 11.2

Every mod has bugs at first. The trick is finding them fast. This lesson shows how to read the game’s messages about WASM mods, what happens when a mod crashes or hangs, and the mistakes almost everybody makes once.

The mod doesn’t load

When the game can’t load your mod, the log says why, with a code:

[clight] mod 'my_mod' not loaded: CLL-3007: clight_mod_main trapped
Message Means Fix
CLL-3001: entry 'bin/my_mod.wasm' not found The .wasm file isn’t where the manifest says. Run clight build, and check runtime.entry (see The manifest).
CLL-3003: missing CLight exports The module has no CLIGHT_MOD, or wasn’t linked with the SDK’s flags. Add CLIGHT_MOD(YourMod) to one file; build with clight build.
CLL-3004: ... The module needs something the sandbox doesn’t have, like an unknown import. Don’t use libraries that need threads, sockets or other system functions.
CLL-2004: built for CLight 2.x The headers are from another major API version. Build with the SDK that came with this game.
CLL-2003: needs CLight 1.3 The headers are newer than the player’s game. The player needs a newer Celestial, or you build with the older SDK.
CLL-3007: _initialize trapped A global object’s constructor crashed, before your mod class existed. Look at your global and static objects (see Memory in the sandbox).
CLL-3007: clight_mod_main trapped Your mod’s constructor or on_construct crashed. See “Traps” below.

Traps: when C++ code crashes

In a normal program, a bad pointer or a division by zero crashes the program. In the sandbox, it traps: the game stops your callback, writes it to the log, and goes on. The game doesn’t crash, and other mods keep running.

[my_mod] callback trapped: wasm trap: out of bounds memory access

The most common causes in C++ mods:

  • A dangling reference: a lambda that captured a local variable by reference ([&]), a std::string_view or cl_str pointing into a std::string that is gone, a pointer to an object that was deleted.
  • Running off the end of an array, a std::vector with [], or the stack (see Memory in the sandbox).
  • Something that would throw: .value() on an Expected with an error, std::stoi on text that isn’t a number, .at() out of range, new without memory. With exceptions off, these write a line from the standard library to the log and then trap (see Errors and Expected).
  • Integer division by zero (x / 0 with whole numbers), and std::abort().
  • A failed assert, which calls std::abort().

In a development build (plain clight build) the module keeps your function names, so when the message comes with a backtrace, it names them. C++ names come out long, with namespaces and template arguments; read it from the top, and the first line that names one of your own functions or lambdas is usually where it went wrong. When there is no backtrace, log lines (below) narrow it down quickly.

The error limit

One trap is forgiven. But if your mod traps 10 times, the game turns it off for the rest of the session, so a broken mod can’t flood the log:

[clight] mod 'my_mod' disabled: CLL-3007: too many traps; last: wasm trap: out of bounds memory access

The Mods screen shows the reason too. Fix the bug, rebuild and restart. A trap in a tick or render callback reaches 10 within a second, because those run so often.

Timeouts: when C++ code hangs

A callback that never returns, like an endless while loop, would freeze the whole game. The sandbox watches the time: loading the mod (with on_construct) may take up to 20 seconds, every other callback about 2 seconds. Then the call is stopped, and because a hanging mod is never safe to call again, it is turned off at once:

[clight] mod 'my_mod' disabled: CLL-3007: a callback ran too long (infinite loop?)

If your mod really has a lot of work, spread it over ticks with a timer (see Timers and tasks). 2 seconds is a hard limit, but players notice lag long before that: aim for well under a millisecond per callback.

API calls that fail

Failing API calls don’t trap; they come back as errors (see Errors and Expected). In development mode, the game also logs most misuses for you:

[clight] [my_mod] cl_level_set_block failed with -10

The wrapper calls the C functions, so the names in these lines are the C names: player.give shows up as cl_player_give. When something “just doesn’t work”, look for these lines first.

Logging to find a bug

The simplest debugger is a log line. Put clight::log::debug at the start of a handler to see whether it runs at all, and log the values you are unsure about:

clight::on<clight::ev::BlockBreak>([](cl_ev_block_break& e) {
    clight::log::debug("break at {} {} {}, state {}, player {}", e.pos.x, e.pos.y, e.pos.z, e.state,
                       clight::Entity(e.player) ? "yes" : "none");
}).detach();

A small helper switches debug logging off for release builds. clight build --release defines NDEBUG, like most compilers:

template <class... A>
void dbg([[maybe_unused]] std::format_string<A...> f, [[maybe_unused]] A&&... a) {
#ifndef NDEBUG
    clight::log::debug(f, std::forward<A>(a)...);
#endif
}

dbg("slot {}", 4) logs in development builds and does nothing in release builds. assert from <cassert> works the same way: it checks in development builds and disappears in release builds. A failed assert traps, with the condition in the log.

Common mistakes

Nothing happens at all. Is the mod loaded? Look for its loaded line or a not loaded line in the log, or open the Mods screen. Did you rebuild and restart? C++ mods don’t reload while the game runs. And did you .detach() the subscription? Without it, the handler is removed at the end of the line, and the build shows a warning about it (see Listening to events).

A handler works once, then the game logs traps. It probably captured a local variable by reference. Capture this or values.

A name or message has strange characters, or is cut off. A cl_str from the game has no \0, and a std::string_view or cl_str you made points at text that is gone. Read game strings with clight::sv, and copy them into a std::string to keep them.

CL_E_INVALID_ARGUMENT from a register function. The descriptor didn’t start from its _INIT macro, so its size field is 0. Or a required field (an id, a callback) is missing.

give returns false, or a lookup fails with CL_E_NOT_FOUND. A typo in an id. Ids are lowercase, with the namespace in front: minecraft:diamond, not Diamond.

CL_E_WRONG_PHASE when registering an item. Items and blocks must be registered in on_register, not in on_construct or later.

CL_E_WRONG_SIDE. A client function on the server or the other way round. Client code belongs in on_client_setup and client events.

A crash when a buffer goes away. A clight::Buf was copied, or a borrowed cl_buf was put into a clight::Buf. Pass buffers by reference, and read borrowed ones with clight::net::buf_read_*.

Memory that only grows. Subscribing, adding commands or HUD layers again and again (each keeps a small box), new without delete, or a map that never forgets players who left.

Keeping mods fast

Players notice lag before they notice bugs. A few habits keep a mod fast:

  • Do heavy work in ticks, not in render code, and not every tick if every second is enough.
  • Look up ids once (clight::registry::block_lookup, item_lookup) and keep the result in a member.
  • Build strings when they change, not every frame.
  • Use a filter instead of checking the id at the top of a handler.
  • Spread big jobs over several ticks.
  • Publish release builds: they are smaller and faster.

clight::core::time_now_ns() measures how long something takes:

uint64_t t0 = clight::core::time_now_ns();
glass_floor(player);
clight::log::debug("glass_floor took {:.3f} ms", static_cast<double>(clight::core::time_now_ns() - t0) / 1e6);

Try it

This handler should give every new player a map, but the map never arrives, and there is no error in the log. The build printed one warning. Find the bug.

class MapMod final : public clight::Mod {
public:
    void on_construct() override {
        clight::on<clight::ev::PlayerJoin>([](cl_ev_player& e) {
            clight::Entity(e.player).give("minecraft:map");
        });
    }
};
Show a solution

The subscription is never detached or kept. clight::on returns a clight::Subscription, which is a temporary here, so it is destroyed at the semicolon, and its destructor unsubscribes the handler again. The handler never runs, so nothing fails and nothing is logged. The only hint is the compiler’s warning: “a dropped clight::Subscription unsubscribes at once”.

class MapMod final : public clight::Mod {
public:
    void on_construct() override {
        clight::on<clight::ev::PlayerJoin>([](cl_ev_player& e) {
            clight::Entity(e.player).give("minecraft:map");
        }).detach();
    }
};

Ending every clight::on with .detach(), or storing it in a member, makes this mistake hard to make. Reading the build’s warnings catches the rest.

Recap: load failures come with a CLL- code, crashes (also from things that would throw) trap and are logged, 10 traps turn the mod off, a callback that runs longer than about 2 seconds turns it off at once, and log lines plus development builds find most bugs; a missing .detach() shows up only as a compiler warning.