Skip to content

C++ SDK (include/scosdk/)

The C++20 layer of the sco plugin SDK: header-only, over sco_api.h, with nothing beyond the C++ standard library. It writes the three exports for you, keeps your handlers’ strings and callables alive, and makes sure no C++ exception crosses the C ABI. A plugin built with it is an ordinary kind = native plugin; the host can’t tell it from a C one.

sco_api.h stays the contract. Everything here is a wrapper, so the API v1 reference rules (threads, the reply buffer, done callbacks, services, raw handlers) still apply; this page covers what the C++ layer adds.

Header What
scosdk/plugin.hpp sco::sdk::Plugin, Subscription, MakeArg, LoadPlugin / UnloadPlugin, SCO_PLUGIN
scosdk/command.hpp CommandBuilder, Args, Reply
scosdk/service.hpp Provide, Release, ServiceRef<T>, HasMember, ServiceVersion
scosdk/raw.hpp RegisterRaw<In, Out>, InvokeRaw, RegisterRawBytes, InvokeRawBytes
scosdk/storage.hpp Storage, StorageCursor, StorageTransaction, SqlInt / SqlFloat / SqlText / SqlBlob / SqlNull (the host service sco.storage, sco_storage.h); not in scosdk.hpp, include it when you use storage
scosdk/datacore.hpp DataCore, DataCorePatch, DataCoreInstance, DataCoreGuid, DataCoreEnum, DataCoreRef (the host service sco.datacore, sco_datacore.h); not in scosdk.hpp
scosdk/net.hpp Net, NetChannel: typed message channels with the other players of a session the product opened, callbacks as lambdas on the game thread (the host service sco.net, sco_net.h; Multiplayer messages)
scosdk/ipc.hpp Ipc, IpcChannel: shared-memory channels to another program on the same PC (the host service sco.ipc, sco_ipc.h; IPC); IpcChannel closes its channel on destruction; not in scosdk.hpp
scosdk/game/vehicles.hpp sco::sdk::game::Vehicles: the ship you’re aboard, its seats, Seat / Eject / PowerOn for your own actors and ships (the game service game.vehicles, sc_vehicles.h; API); not in scosdk.hpp
scosdk/game/creative.hpp sco::sdk::game::Creative: SetGodMode, SetFlyMode, SetFlySpeed, SetInfiniteAmmo, SetInfiniteShipAmmo, State, LastError (the game service game.creative, sc_creative.h; API); toggles you switch on go off when your plugin unloads; not in scosdk.hpp
scosdk/settings.hpp Settings, AsSettingsChanged: read the typed [settings] of your plugin.ini (the host service sco.settings, sco_settings.h); not in scosdk.hpp, include it when you use settings
scosdk/widgets.hpp Widgets: renderer-neutral retained panels and events (sco.widgets 1.0); include explicitly
scosdk/menu_builder.hpp scosdk::PanelBuilder, PanelTree: declarative widgets, 1.0 by default with opt-in 1.1 tooltip, severity and paging; include explicitly
scosdk/ui_surfaces.hpp UiSurfaces: show a sco.widgets panel as a HUD rectangle, billboard or world quad (the host service sco.ui.surfaces, sco_ui_surfaces.h); Hud, Billboard, Quad build poses; include explicitly
scosdk/ui.hpp Ui: tabs, overlays, badges and hotkeys (the host service sco.ui, sco_ui.h); not in scosdk.hpp, include it when you use UI
scosdk/game/actors.hpp sco::sdk::game::Actors: LocalPlayer, SpawnNpc, Despawn, Health, State (game.actors 1.1; Health’s max is 0, State is SCO_UNAVAILABLE until game.actors.state is ready), LastError, LastReadError (the game pack’s service game.actors, sc_actors.h; game services); not in scosdk.hpp
scosdk/game/world.hpp sco::sdk::game::World: Raycast(zone, from, dir, maxDist, hit), Camera(pos, rot, zone) (world frame; no field of view), LastError (the game pack’s service game.world, sc_world.h; API); not in scosdk.hpp
scosdk/game/events.hpp sco::sdk::game::OnPlayerSpawned, OnPlayerDied, OnActorDied, OnZoneChanged: Plugin::Subscribe helpers for the game pack’s bus events (sc_game_events.h; game services) that hand your function a copy of the payload and drop one shorter than the struct; not in scosdk.hpp
scosdk/game/entities.hpp sco::sdk::game::Entities: Alive, ClassOf, GetTransform, SetTransform, Spawn, Despawn, Keep (1.1), Watch, Unwatch, QueryRadius, LastError, LastReadError (the game service game.entities, sc_entities.h; API); not in scosdk.hpp
scosdk/scosdk.hpp Core plugin.hpp, command.hpp, service.hpp, raw.hpp only; include the optional headers above explicitly

Put the SDK’s include/ folder on the include path; the headers find sco_api.h there.

#include "scosdk/scosdk.hpp"
class Hello : public sco::sdk::Plugin {
public:
sco_result OnLoad() override {
using namespace sco::sdk;
sco_result r = CommandBuilder(*this, "hello.wave")
.Title("Wave")
.Arg<const char*>("name", "Who to wave at")
.Handle([](const Args& args, Reply& reply) {
reply.Printf("Hello, %s", args.String(0));
return SCO_OK;
})
.Register();
if (r != SCO_OK) return r;
tick_ = Subscribe("tick", [this](const void*) { ++ticks_; });
Info("loaded on %s", Api()->host_version());
return tick_ ? SCO_OK : tick_.Result();
}
private:
sco::sdk::Subscription tick_;
unsigned ticks_ = 0;
};
SCO_PLUGIN(Hello, "hello", "1.0.0", "you");

sdk/examples/cpp_hello is the complete example: two commands, a subscription to game.ready and tick, and a service it publishes and looks up.

SCO_PLUGIN(Class, "id", "version", "author") emits sco_plugin_query (with this header’s SCO_API_MAJOR / SCO_API_MINOR), sco_plugin_load (constructs Class, runs OnLoad) and sco_plugin_unload (runs OnUnload, releases the SDK’s state, destroys the object). Use it once per DLL, at namespace scope; id must equal plugin.ini’s id. To run several Plugin objects in one binary (tests do), call sco::sdk::LoadPlugin(plugin, api, self) and sco::sdk::UnloadPlugin(plugin) yourself.

Member Does
virtual sco_result OnLoad() Called from sco_plugin_load, on the game thread. Anything but SCO_OK unloads the plugin; OnUnload is not called, but the SDK still releases what was registered
virtual void OnUnload() Called from sco_plugin_unload, on the game thread, before the SDK releases its state. Stop your own threads here
Api(), Self() The host’s table and this plugin’s handle; valid from OnLoad until OnUnload returns
ApiCovers(offsetof(sco_api, f)) True when the host’s table has function f (the api->size check)
Log(level, fmt, ...), Info, Warn, Error, Status printf formats into a 256-byte buffer (longer is cut). gcc and clang check the format; pass other text as Info("%s", text)
Has(capability) api->has(capability) != 0
RunOnGameThread(std::function<void()>) Queues the task; see Lifetimes
Subscribe(event, std::function<void(const void* data)>) Returns a Subscription; see Lifetimes
Invoke(name, {args...}, &reply) Runs a command. From the game thread it runs now and fills reply; from another thread it is queued, returns SCO_OK, and the reply is lost
InvokeAsync(name, {args...}, done) Runs a command with completion on the game thread; see the completion rules below

MakeArg(3), MakeArg(2.5), MakeArg("Ada") and MakeArg(true) build the sco_arg values for Invoke.

On the game thread, a dispatched InvokeAsync calls done(result, reply) before returning the same command result, including a non-SCO_OK result, unless the calling plugin has been released. Pre-dispatch failures (validation, allocation or runtime admission) do not call done. From another thread, SCO_OK means queued; a non-SCO_OK return means nothing was queued and done will not run. A queued call dropped when the calling plugin unloads has no completion callback.

CommandBuilder(plugin, "id.name") collects the command: .Title, .Help, .Capability, .Arg<T>(name, help) with T one of int64_t, double, const char* or bool, and .Handle(fn). Register() returns the host’s answer. The title defaults to the name.

The handler is sco_result(const Args&, Reply&). Args reads by index: Int(i), Float(i), String(i), Bool(i) (and Count(), Type(i)). The host has already checked the count and types against the Arg<T> list; a wrong index or type returns the fallback (0, 0.0, "", false), never throws. Reply writes the reply: Printf(fmt, ...) or Set(text), cut at the host’s buffer (256 bytes with the NUL).

A service table is a C struct of function pointers that starts with uint32_t size; Provide and ServiceRef refuse to compile for anything else.

struct greeter_v1 { uint32_t size; int (*greet)(const char* who, char* out, uint32_t n); };
static const greeter_v1 kGreeter = { sizeof(greeter_v1), Greet };
sco::sdk::Provide(*this, "hello.greeter", sco::sdk::ServiceVersion(1, 0), &kGreeter);
sco::sdk::ServiceRef<greeter_v1> g; // in another plugin
if (g.Query(*this, "hello.greeter", sco::sdk::ServiceVersion(1, 0)) == SCO_OK) g->greet(...);
  • Query answers like query_service (SCO_OK, SCO_UNAVAILABLE for another major or an older version, SCO_NOT_FOUND) and SCO_UNAVAILABLE on a 1.0 host. The ref is empty unless SCO_OK; check it (if (g)) before ->.
  • HasMember(ref, &T::field) is true when the provider’s size covers field: the check before calling a function a later minor added.
  • Release(plugin, name) stops new lookup of that service; the host withdraws the rest at unload. It does not revoke cached pointers or wait for calls through them.
  • ServiceRef is a borrowed pointer holder, not a lease. Keep a provided table and all backing state valid until provider-coordinated quiescence: stop new lookup, prevent consumers from starting more calls, and wait for all in-flight calls and custom workers to finish before destruction. Without that coordination, retain them for the admitted host session. Once a native table has been handed out, administrative unload/reload is refused for that session; retained code/state after a fault is not permission to call it. Do not use peer tables during shutdown or across sessions; stop using cached tables and restart after a native fault. See native trust and service lifetime rules.
  • Entity ids, never pointers: a service that deals with game objects takes and returns entity and zone ids (opaque uint64_t values, as the game’s are), resolves them on every call on the game thread, and answers SCO_NOT_FOUND or SCO_UNAVAILABLE once the entity has streamed out. Never return a pointer into the game: objects stream out and a stored pointer dangles. See the API rule.
  • Zone ids (zone_id, as in teleport.spatial) are volatile streaming handles: the same place gets another id after it streams out and back in. Anything you persist or send keeps the zone name string plus double x, y, z in that zone, never the raw id; resolve the name back to an id when you need it.

RegisterRaw<In, Out>(plugin, name, capability, fn) registers a handler sco_result(const In&, Out&) (sco_result(Out&) for In = void); InvokeRaw(plugin, name, in, out) calls one. In and Out must be trivially copyable. The SDK does the size handshake from Raw handlers:

  • Input must be exactly sizeof(In) bytes (none for void); otherwise SCO_BAD_ARG and the handler isn’t called.
  • A caller whose buffer is smaller than sizeof(Out), or who passes none, gets SCO_TOO_MANY and the size needed. InvokeRaw’s optional uint32_t* size reports it.
  • InvokeRaw writes out only on SCO_OK, and answers SCO_BAD_ARG if the handler wrote a size other than sizeof(Out) (the two sides disagree about the struct).

For variable-size data use RegisterRawBytes and InvokeRawBytes with std::span<const std::byte> in and std::span<std::byte> out; the handler sets written to the bytes written, or to the bytes needed with SCO_TOO_MANY. Raw calls run on the game thread only (SCO_WRONG_THREAD elsewhere).

sco::sdk::Storage wraps the host service sco.storage: per-plugin key-value and SQL. Open(*this) queries it once; a host service outlives every plugin, so keep the Storage for the plugin’s life.

#include "scosdk/storage.hpp"
sco::sdk::Storage store;
if (store.Open(*this) == SCO_OK) {
store.Put("spots.home", spot); // trivially copyable: stored as its bytes
Spot back{};
store.Get("spots.home", back); // SCO_BAD_ARG if the stored size differs
std::string name;
store.Get("player.name", name); // any size; the handshake is done here
sco::sdk::StorageTransaction tx(store); // rolled back unless committed
store.Exec("INSERT INTO visits VALUES (?, ?)", { sco::sdk::SqlText("Lorville"), sco::sdk::SqlInt(1) });
tx.Commit();
for (auto c = store.Query("SELECT place FROM visits"); c.Next() == SCO_OK;) { std::string p; c.Text(0, p); }
}
  • Every call is noexcept and answers sco_result (out of memory is SCO_TOO_MANY); LastError() has the message of the last failure.
  • StorageCursor is move-only and closes its cursor on destruction; Int, Float, Text, Blob and Value read the current row. Keys(prefix, out) lists keys in byte order.
  • The transaction is the plugin’s, not the thread’s (Storage § Threads).

sco::sdk::DataCore wraps the host service sco.datacore: DataCore overrides from code. The product must publish it (Open answers SCO_NOT_FOUND otherwise). After the game’s DataCore load (the usual case: plugins load after it), a committed patch is saved and applies from the next launch.

#include "scosdk/datacore.hpp"
sco::sdk::DataCore dc;
if (dc.Open(*this) == SCO_OK) {
sco::sdk::DataCorePatch p = dc.Begin(); // dc.Begin(SCO_DC_NON_ATOMIC): one by one
p.Set("ShipA", "speed", 2.5); // double, integers (signed: INT, unsigned: UINT), bool,
p.Set("ShipA", "kind", sco::sdk::DataCoreEnum{ "Small" }); // string, DataCoreGuid, DataCoreEnum, nullptr, an instance
sco::sdk::DataCoreInstance part = p.AddInstance("Part", "PartX");
p.Set(part.Ref(), "weight", 3.25); // Ref(): "@<id>", the instance's own fields
p.Append("ShipA", "parts", part);
sco::sdk::DataCoreInstance c = p.AddRecord("Ship", "ShipC", "ShipA"); // a new record cloned from ShipA (sco.datacore 1.1)
p.Set("ShipB", "maker", sco::sdk::DataCoreRef{ "ShipC" }); // later operations name it; c.Ref() is its root
if (p.Commit() == SCO_OK) { /* after datacore.applied: p.Reports() */ }
}
  • DataCorePatch is move-only and discards its patch on destruction unless committed. Keep it to read Reports() (one sco_dc_report per operation, then the patch’s).
  • Every call is noexcept and answers sco_result; AddInstance returns a DataCoreInstance with its own Result(). AddRecord(type, name, cloneRecord, guid = "", filePath = "") returns a DataCoreInstance too, the new record’s root (empty guid: a stable one derived from the plugin id and name; empty filePath: libs/foundry/records/sco/<plugin id>/<name>.xml); its Result() is SCO_UNAVAILABLE on a sco.datacore 1.0 host, and dc.AddRecordSupported() says beforehand. DataCoreRef{ "Record" } sets a reference field.
  • Results arrive with the event datacore.applied (Subscribe; data: sco_dc_applied).

scosdk/widgets.hpp (include explicitly, not through the umbrella) provides sco::sdk::Widgets over sco.widgets 1.0. Open(api, self) or Open(plugin) discovers it; SetPanel(id, title, nodes, count, fn, ctx) atomically replaces a complete copied tree and RemovePanel(id) ends callbacks. Each method checks the full member extent and NULL function pointer. The object overload borrows an OnEvent(const char*, const sco_widget_event&) receiver and catches exceptions before they cross the C ABI. All methods/events are game-thread only. See widget contract and the renderer-free example. This route has no ImGui types or link requirement.

scosdk/menu_builder.hpp adds scosdk::PanelBuilder: nest Row / Column bodies, then Build() and PanelTree::Submit(widgets, id, title). A returned WidgetRef supports .Disabled(), .Password(), .Tooltip(const char*), .Severity(uint32_t) (SCO_WIDGET_SEVERITY_*) and .Paging(first_row, visible_rows) (LIST/TABLE only). No extended attribute means unchanged 80-byte 1.0 nodes; any extended attribute opts into 104-byte 1.1 nodes. Submit requires the full capabilities member extent and a non-NULL pointer to send 1.1; otherwise it falls back to 1.0 nodes, dropping only the extended attributes. Nodes() must be paired with Count() and Stride() for direct submission; use Find(id) rather than indexing the base pointer when nodes are extended. Keep the tree and bound variables alive until replacement/removal. See the builder cookbook.

The following Ui draw-context route remains supported and host-renderer-specific; it is not the retained Widgets API.

sco::sdk::Ui wraps the host service sco.ui: tabs and overlays the product draws through your draw function, badges, and hotkeys bound to commands. Keep the Ui for the plugin’s life.

#include "scosdk/ui.hpp"
struct Panel { void Draw(void* frame) { /* the product's frame context (sc-offline: ImGui) */ } };
static Panel g_panel;
sco::sdk::Ui ui;
if (ui.Open(*this) == SCO_OK) {
ui.AddTab("hello.main", "Hello", 100, g_panel); // or (id, title, order, draw_fn, ctx)
ui.SetBadge("hello.main", "3");
if (ui.BindHotkey("ctrl+alt+h", "hello.wave", { sco::sdk::MakeArg("Pilot") }) != SCO_OK)
Warn("hotkey: %s", ui.LastError().c_str()); // "ctrl+alt+h is bound by 'other' to other.cmd"
}
  • The object passed to AddTab / AddOverlay is borrowed until RemoveTab / RemoveOverlay or unload; an exception from its Draw is caught and dropped.
  • Draws run on the game thread; registration and hotkeys work from any thread (UI § Threads).

sco::sdk::Settings reads the [settings] your plugin.ini declares (format), through the host service sco.settings. It is read-only; the player changes values in the product’s menu.

#include "scosdk/settings.hpp"
sco::sdk::Settings settings;
if (settings.Open(*this) == SCO_OK) {
int64_t speed = settings.Int("speed", 5); // the value, or the fallback if the call fails
std::string mode = settings.String("mode", "normal"); // an enum's choice
double fov;
if (settings.GetFloat("fov", fov) != SCO_OK) Warn("fov: %s", settings.LastError().c_str());
}
// React to a change: subscribe to the event "settings.changed" (SCO_SETTINGS_CHANGED_EVENT)
// if (const sco_settings_changed* c = sco::sdk::AsSettingsChanged(data)) { /* c->plugin, c->name */ }

GetBool / GetInt / GetFloat / GetString answer SCO_NOT_FOUND (not declared) or SCO_BAD_ARG (another type) and leave the out value alone; Bool / Int / Float / String return a fallback instead. Every call is noexcept.

The SDK owns every callable you hand it (command and raw handlers, event handlers, tasks, done callbacks) and the strings a command keeps. The ctx it gives the host for each is an id, not a pointer: the callable sits in a registry, and the trampoline looks the id up on every call. When the plugin unloads, UnloadPlugin removes every entry, so a call the host makes later (or never makes) finds nothing instead of freed memory, and nothing leaks.

What Lives until
A command or raw handler The plugin unloads
A task (RunOnGameThread) It runs, then it is freed. A task still queued at unload is dropped and never runs: don’t rely on one to save state
A done callback (InvokeAsync) It runs. A call still queued at unload is dropped and done is not called
An event handler Its Subscription is destroyed or Reset, or the plugin unloads
A service table Yours: keep the table and backing state valid until provider-coordinated quiescence; absent that, retain them for the admitted host session. Release only stops new lookup (see Services)

Subscription is a move-only handle. The SDK keeps one host subscription per event and multiplexes your handlers over it (the host keys a subscription by plugin, event and function, so it can’t hold two for one event). Destroying the handle or calling Reset() removes the handler; when the last handler for an event goes, the host subscription goes too.

  • From the game thread (a tick, a command, a task, or the handler itself), the handler never runs again once Reset returns.
  • From another thread a call already running on the game thread may still finish. It keeps its own reference to the handler, so the SDK frees nothing under it, but anything your handler captured by reference must outlive that call: this is the Freeing ctx rule.
  • A handle still alive when the plugin unloads is detached by the unload and does nothing afterwards, from any thread. A Subscription member of your plugin class is the normal way to hold one.

Every function in scosdk/ is noexcept. An exception that escapes your code is caught where the host called in and logged as [<id>] error: exception in <where>: <what> (unknown exception for a non-std::exception):

Escapes from Becomes
The plugin’s constructor or OnLoad sco_plugin_load returns SCO_FAILED; the SDK releases what OnLoad registered and the host unloads the plugin
A command handler SCO_FAILED, with <name> failed: <what> as the reply
A raw handler SCO_FAILED, no output
An event handler, a task, a done callback, OnUnload Logged; the dispatch goes on with the next handler

An allocation failure inside the SDK answers SCO_TOO_MANY. A crash (an access violation) is not an exception: the host’s crash guard handles it as for any native plugin.

The threading rules are the host’s. RunOnGameThread, Subscribe, Subscription::Reset, Invoke, InvokeAsync, CommandBuilder::Register, Provide, Release, ServiceRef::Query and RegisterRaw work from any thread; handlers, tasks and done callbacks run on the game thread; InvokeRaw is game thread only. OnLoad and OnUnload run on the game thread, and once OnUnload has returned no thread may call into the plugin object.

sco_add_plugin in cmake/sco-plugin.cmake builds .cpp sources as C++20 with hidden visibility (only the three exports are visible), the static C runtime, and /W4 /WX on MSVC or -Wall -Wextra -Wpedantic -Werror elsewhere. Enable C++ in your project:

project(my_plugin CXX)
include("${SCO_SDK}/cmake/sco-plugin.cmake")
sco_add_plugin(my_plugin SOURCES plugin.cpp INI plugin.ini)

Visual Studio 2019 16.11 or newer (/std:c++20), clang 14 or gcc 11 or newer. sco-plugin-check checks a C++ plugin like a C one. sco-core’s own tests: tests/test_sdk.cpp loads two SDK plugins through the real host table under ASan+UBSan and ThreadSanitizer, and the SDK zip’s CI builds and checks cpp_hello with MSVC on Windows and with gcc on Linux.