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.
Contents
Section titled “Contents”- Headers
- A plugin
- Plugin
- Commands
- Services
- Raw handlers
- Storage
- DataCore
- UI
- Settings
- Lifetimes
- The exception boundary
- Threads
- Building
Headers
Section titled “Headers”| 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.
A plugin
Section titled “A plugin”#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.
Plugin
Section titled “Plugin”| 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.
Commands
Section titled “Commands”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).
Services
Section titled “Services”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 pluginif (g.Query(*this, "hello.greeter", sco::sdk::ServiceVersion(1, 0)) == SCO_OK) g->greet(...);Queryanswers likequery_service(SCO_OK,SCO_UNAVAILABLEfor another major or an older version,SCO_NOT_FOUND) andSCO_UNAVAILABLEon a 1.0 host. The ref is empty unlessSCO_OK; check it (if (g)) before->.HasMember(ref, &T::field)is true when the provider’ssizecoversfield: 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.ServiceRefis 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_tvalues, as the game’s are), resolves them on every call on the game thread, and answersSCO_NOT_FOUNDorSCO_UNAVAILABLEonce 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 inteleport.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 plusdoublex, y, z in that zone, never the raw id; resolve the name back to an id when you need it.
Raw handlers
Section titled “Raw handlers”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 forvoid); otherwiseSCO_BAD_ARGand the handler isn’t called. - A caller whose buffer is smaller than
sizeof(Out), or who passes none, getsSCO_TOO_MANYand the size needed.InvokeRaw’s optionaluint32_t* sizereports it. InvokeRawwritesoutonly onSCO_OK, and answersSCO_BAD_ARGif the handler wrote a size other thansizeof(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).
Storage
Section titled “Storage”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
noexceptand answerssco_result(out of memory isSCO_TOO_MANY);LastError()has the message of the last failure. StorageCursoris move-only and closes its cursor on destruction;Int,Float,Text,BlobandValueread the current row.Keys(prefix, out)lists keys in byte order.- The transaction is the plugin’s, not the thread’s (Storage § Threads).
DataCore
Section titled “DataCore”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() */ }}DataCorePatchis move-only and discards its patch on destruction unless committed. Keep it to readReports()(onesco_dc_reportper operation, then the patch’s).- Every call is
noexceptand answerssco_result;AddInstancereturns aDataCoreInstancewith its ownResult().AddRecord(type, name, cloneRecord, guid = "", filePath = "")returns aDataCoreInstancetoo, the new record’s root (emptyguid: a stable one derived from the plugin id and name; emptyfilePath:libs/foundry/records/sco/<plugin id>/<name>.xml); itsResult()isSCO_UNAVAILABLEon a sco.datacore 1.0 host, anddc.AddRecordSupported()says beforehand.DataCoreRef{ "Record" }sets a reference field. - Results arrive with the event
datacore.applied(Subscribe; data:sco_dc_applied).
Renderer-neutral widgets
Section titled “Renderer-neutral widgets”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/AddOverlayis borrowed untilRemoveTab/RemoveOverlayor unload; an exception from itsDrawis caught and dropped. - Draws run on the game thread; registration and hotkeys work from any thread (UI § Threads).
Settings
Section titled “Settings”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.
Lifetimes
Section titled “Lifetimes”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
Resetreturns. - 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
Subscriptionmember of your plugin class is the normal way to hold one.
The exception boundary
Section titled “The exception boundary”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.
Threads
Section titled “Threads”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.
Building
Section titled “Building”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.