Skip to content

The C++ binding

C++ writes against the ABI header directly. There is no SDK layer between you and sdk/abi.h, because on this side there is nothing for one to do: the header is already C, already the contract, and already the thing every other binding mirrors.

That is the trade. Rust gives you Result, RAII and a checked call for every slot. C++ gives you the contract with nothing in the way, and the four things the SDK would have done are yours to do:

Rust C++
Entry point generated by a macro you write pier_main
Capability check inside every wrapper you compare against struct_size at each call site
Strings &str conversions you build PierStr yourself
Errors Result<T> a bool return and an out-parameter

Pick C++ when the mod is mostly engine work and the ABI is a small part of it, or when it has to sit in a codebase that is already C++. Pick Rust when the ABI is most of what the mod does.

What building one takes

One header, no library, no build system:

Text Only
cl /std:c++20 /EHsc /utf-8 /LD /I <pier>/packages/pier-abi/include ^
   src\Main.cpp /Fe:my_mod.dll

examples/hello-pier-cpp in the repository is that mod, with four build files beside it β€” MSVC, clang-cl, CMake and xmake β€” producing the same DLL. Use whichever your project already uses; the source does not change between them.

The compiler has to produce an MSVC-ABI x64 binary. On Windows that means cl or clang-cl. A GNU-driver clang++ or MinGW g++ compiles and links and then cannot be loaded, which is a slow way to find out.

The smallest mod

C++
#include <cstring>
#include "sdk/abi.h"

namespace {
    PierApi const* gApi = nullptr;
    PierModHandle gSelf = nullptr;

    PierStr str(char const* s) { return PierStr{s, std::strlen(s)}; }

    bool onEnable(void*) {
        gApi->log(gSelf, 3, str("enabled"));
        return true;
    }
    bool onDisable(void*) { return true; }
    bool onUnload(void*)  { return true; }
}

PIER_MAIN_EXPORT bool pier_main(PierApi const* api, PierModHandle self,
                                PierModVTable* out) {
    if (api == nullptr || out == nullptr) return false;
    gApi = api;
    gSelf = self;

    out->struct_size = sizeof(PierModVTable);
    out->abi_version = PIER_ABI_VERSION;
    out->mod_flags = 0;
    out->_reserved0 = 0;

    out->instance = nullptr;
    out->on_enable = &onEnable;
    out->on_disable = &onDisable;
    out->on_unload = &onUnload;
    return true;
}

Three things in there are not optional.

PIER_MAIN_EXPORT, not a bare extern "C". Declaring the symbol does not export it. A DLL exports only what is marked, so a plain declaration builds cleanly and is then refused at load with "does not export pier_main".

The four header scalars. The host reads struct_size to know how much of your table it may touch, which is the mirror of what you do to its table. Leaving them zero means the host sees a table of length zero.

mod_flags names the target. 0 for a server mod, PIER_FLAG_CLIENT for one meant for the client host. The host compares bit 0 with its own and refuses a mismatch at load, so a DLL built for one target says so instead of failing slot by slot on the other.

log is called without a capability check, and only log is. It is one of four slots in the fixed header that every host has. Everything past that header needs the check below.

Checking a capability before calling it

PierApi grows by appending. A host older than the header you compiled against has a shorter table that simply ends earlier, so reading a slot past its end reads memory the host never wrote.

C++
#include <cstddef>   // offsetof, std::size_t

bool has(std::size_t end) {
    return gApi != nullptr && gApi->struct_size >= end;
}

#define HAS_SLOT(m) (has(offsetof(PierApi, m) + sizeof(void*)) && gApi->m != nullptr)

if (HAS_SLOT(register_command)) {
    gApi->register_command(gSelf, str("hello"), str("Says hello."), 0, &onHello, nullptr);
}

Two different reasons a slot is unusable, and both are normal:

  • past struct_size β€” the host predates this capability.
  • NULL β€” the host is new enough but was built without the package that fills the slot. pier-dimensions and pier-lane are droppable, and a client build fills a different set. A NULL slot is not a bug to report; it is a capability to do without.

tools/pier-probe is a mod that reports both, per slot, against a running host.

Strings

PierStr is {pointer, length} and is not NUL-terminated. A string handed to your callback belongs to the caller and is valid only for that call: copy it if you want to keep it.

C++
1
2
3
4
std::string_view view(PierStr s) {
    return s.ptr == nullptr ? std::string_view{} : std::string_view{s.ptr, s.len};
}
PierStr wrap(std::string const& s) { return PierStr{s.data(), s.size()}; }

A mod hands strings back through a sink callback within the current call frame. Ownership never crosses the boundary in either direction; there is no call in this ABI that returns a pointer the other side must free.

Errors

A slot that can fail returns bool, and the value goes into an out-parameter. False means the host has no answer, and it does not distinguish "not found" from "this engine version does not expose that property". Both are results, not errors, and neither is logged by the host.

False and a zero result are different answers. Collapsing them is the mistake contract Β§5.2 exists to name.

Exceptions

Every function the host calls is a C function: pier_main, the lifecycle callbacks, and every event, command, task, service and sink callback. An exception must not leave any of them. Unwinding into the host's frames is undefined behavior; with MSVC's /EHsc the host is compiled on the assumption that a C function never throws.

The host catches what it can around pier_main and the lifecycle callbacks and treats it as a refusal, but that is a last fence, not a contract. Anything that allocates, parses or calls into a library that throws needs its own try at the top of the callback:

C++
1
2
3
4
5
6
7
8
9
void onSomething(void* user, PierStr data /* ... */) {
    try {
        // the real work
    } catch (std::exception const& e) {
        log(1, e.what());
    } catch (...) {
        log(1, "onSomething failed with an unknown exception");
    }
}

Marking the callback noexcept instead is not the same thing: an exception reaching it calls std::terminate, which takes the whole server down instead of one request.

Hangs

A callback runs on the host's thread, usually the server thread. If it does not return, the server stops ticking. Pier's watchdog names the mod and the entry it is stuck in after watchdog.warn_ms, and ends the process after watchdog.hang_ms so a supervisor can restart the server; see Configuring. It cannot stop just your mod: there is no safe way to pull a thread out of code that holds locks.

Blocking work β€” network, disk, long computations β€” belongs on a thread of your own, with the result handed back through schedule.

Where to go

  • Your first mod β€” from an empty directory to a DLL the server loads.
  • The ABI β€” the contract itself, which on this side you read directly.
  • The Rust binding β€” the same capabilities with the four columns above filled in for you.