Docs · For modders
API stability contract
What a plugin can rely on when V3X, the game, or both are updated.
Copied from docs/api-stability.md in the V3X repository, file dated 2026-11-13.
What a plugin author can rely on when V3X, the game, or both are updated. The reference for each function is api-reference.md; the rules for touching engine code are in safety.md. This page is the promise; the tests named below enforce it.
What is covered
| part | stability | enforced by |
|---|---|---|
include/v3x.h: V3X_API, V3X_HookCall, V3X_Xmm, V3X_PluginManifest, V3X_LoadTransition, V3X_SaveBegin, every #define constant, callback types |
binary (ABI) frozen, append-only | test/abi_test.c (compile-time layout asserts), test/compat_test.c |
| loader behaviour documented in api-reference.md (return values, thread rules, load order, manifest rules, event names and payloads) | frozen; changes only as fixes that make the documented behaviour true | test/api_v4_test.c, test/api_v5_test.c, test/compat_test.c |
sdk/*.h (header-only helpers) |
source compatible within a V3X_SDK_VERSION; compiled into your DLL, so a new SDK never changes an already built plugin |
test/sdk_test.c, template test |
include/v3x_rtti.h |
source compatible (header-only) | test/rtti_test.c |
sdk/build_plugin.py command line, check exit codes and --json keys |
stable; options are only added | test/compat_check.py |
| the engine (victoria3.exe) | nothing: every game patch may move or change any function | anchors that fail closed (below) |
Not covered: the bundled plugins’ own console commands, ini keys and report formats (each plugin
documents its own), log line wording other than the ERROR: / WARNING: prefixes, and anything
marked “test only” or “not verified in game”.
Three version numbers
| number | where | changes when | a plugin uses it to |
|---|---|---|---|
API version V3X_API_VERSION / api->api_version (5) |
include/v3x.h; loader export v3x_loader_api |
a field or event is appended to the API (never for fixes) | require features: V3X_HAS(api, field) at run time, min_api in the manifest |
loader version (semver) api->loader_version() (“0.5.0”) |
src/core.c V3X_LOADER_SEMVER; loader export v3x_loader_semver |
patch: fixes; minor: new API version, new commands/ini keys, hardening; major: reserved, see Deprecation | report it in bug reports; never gate features on it (use V3X_HAS) |
game build api->game_version() (“release/1.15.1”) and api->game_build_id() (“6af3094f-60fb000”) |
caligula_branch.txt; the exe’s PE header | every game patch (the build id also for hotfixes that keep the branch name) | game_builds in the manifest, api->build_matches(), and anchors |
The API version and the loader version are independent of the game: the same plugin binary keeps loading after a game patch; whether it still works depends on its anchors.
ABI rules (append-only)
V3X_APIis a table of 8-byte function pointers after twouint32_t(api_version,struct_size). Slot N of API vN stays at the same offset with the same signature forever. New fields are added only at the end, in a new API version, and the loader reports that version only when it fills every field of it (test/compat_test.cchecks that no pointer up tostruct_sizeis NULL).- Never reordered, removed, resized or retyped: fields of
V3X_API,V3X_HookCall(0x110 bytes, the thunk writes it at fixed offsets;reservedstays 0 for you),V3X_Xmm(16),V3X_PluginManifest(56; grows only at the end, read through itsstruct_size),V3X_LoadTransition(24),V3X_SaveBegin(8). - Constants keep their values:
V3X_HOOK_MAX_ARGS16,V3X_HOOK_MAX_SUBSCRIBERS8,V3X_EHOOKBUSY100,V3X_MANIFEST_REQUIRE_BUILD1, the return codes and log levels below, the event names. - Calling convention: Win64 C. Strings are UTF-8 / ASCII, NUL-terminated, owned by whoever passes them (the core copies what it keeps).
- A plugin built against any older header keeps working: it sees a prefix of today’s table.
Proven by
test/compat_test.c: plugins compiled against the frozen headerstest/compat_v1.h..test/compat_v4.h(v4 is the 317e574 header verbatim, v1-v3 are that header cut at the documented version boundaries) load into the current loader and every call they make works; and a v5 plugin loads into the real v4 loader built from git 317e574 and degrades (compat_test.exe old). - A plugin built against a newer header runs on an older loader if it checks before using newer
fields:
if (V3X_HAS(api, log_at)) ...(struct_sizecovers the field and the pointer is set), or declaresmin_apiso the older loader refuses it cleanly.
Changing include/v3x.h therefore means: append, bump V3X_API_VERSION, pin the new slot in
test/abi_test.c (it fails to compile until you do), fill it in src/core.c g_api, and add it to
the tables in api-reference.md and the CHANGELOG.
Return codes
The values of v1-v4 are unchanged; API v5 names them. 0 is success everywhere.
| code | value | meaning |
|---|---|---|
V3X_OK |
0 | success |
V3X_EFAIL |
-1 | bad arguments, a table or queue is full, unknown (or disabled) command, the process is exiting. Also MinHook’s MH_UNKNOWN |
V3X_EEXIST |
-2 | the name / subscription already exists (register_command, on_event) |
V3X_EMH_* |
2..10 | MinHook status from hook / unhook / hook_safe (see api-reference.md) |
V3X_EHOOKBUSY |
100 | another plugin holds the hook target; v3x.log names it |
| Win32 error | > 0 | patch only (GetLastError()) |
| counts | >= 0 | print (chars), emit_event (subscribers called), enum_commands (commands) |
Functions without a result (log, log_at) and getters never fail; getters return a valid
string ("unknown" when the value is unknown), never NULL.
Thread rules
| function | callable from | callbacks run on |
|---|---|---|
log, log_at, print |
any thread | - |
log_level, loader_version, build_matches, image_*, game_*, *_dir, main_thread_id |
any thread, lock-free, constant after boot (log_level changes only by loglevel) |
- |
pattern_scan, find_string, find_xref, function_start |
any thread (read-only) | - |
hook, unhook, hook_safe |
any thread, best at load (MinHook briefly suspends other threads) | detour / pre / post: the thread calling the hooked function |
patch |
load time, or while the code cannot run | - |
on_frame |
v3x_plugin_load only |
main thread, every message-pump iteration |
run_on_main |
any thread | main thread, next frame tick |
register_command |
any thread | console input thread, or the caller of exec_command / exec_capture |
exec_command, exec_capture, emit_event, enum_commands |
any thread | synchronously on the caller |
on_event |
any thread | the emitting thread; config_reload and load_transition* on the main thread |
Process exit: once ExitProcess has started killing threads, every function returns at once
without effect; do nothing in DllMain(DLL_PROCESS_DETACH).
Behaviour the loader guarantees (0.5.0)
- Fault isolation: a hardware fault or escaping C++ exception inside
v3x_plugin_load,on_frame,run_on_main, a command or an event subscriber is caught; that plugin’s callbacks are skipped from then on and the game continues (logged asFAULT in plugin ...). Faults in hook detours /hook_safepre/post, in engine code and in your own threads are not caught: they crash the game exactly as without V3X (crash_logger records them). - Rollback:
v3x_plugin_loadreturning non-zero or faulting removes every hook the plugin installed and restores its patches; its callbacks never run. Return non-zero whenever you could not install everything you need: half-installed plugins do not exist. - Crash-loop guard: if the game dies inside a plugin’s load, that plugin is skipped on the next start (once) and the console says so.
- Unknown-build gate: see Game patches.
- The
pluginscommand always shows why a plugin is not running.
Game patches
A game patch can change every engine function. V3X never hardcodes engine addresses; a plugin must not either. What happens on a patch:
- The loader knows the builds it was tested on (
v3x_loader_known_builds, 0.5.0:6af3094f-60fb000= 1.15.1). On any othervictoria3.exeit starts in safe mode (defaultunknown_build = safe): only plugins whose manifest declares the running build are loaded. Declaring means"*"(the plugin does not depend on engine internals), a prefix ("release/1.15*", your claim that it survives patches of that line) or the exact build id. An exactgame_versionstring such as"release/1.15.1"does not count, because a hotfix keeps the branch name. Players can force everything withunknown_build = loadat the top ofv3x/v3x.ini. The console and v3x.log say what happened. - The plugin’s anchors (
sdk/v3x_anchor.h) are the second line: each engine function is found from strings and byte shapes and verified (unique match, prologue, call targets). If any check fails, the plugin installs nothing for that feature, logsanchor X FAILED: ...and returns non-zero fromv3x_plugin_load(which rolls back anything already installed). Never fall back to a guessed address or prototype. - The author re-runs the offline test against the new exe
(
python sdk/build_plugin.py <dir> --test path/to/victoria3.exe), fixes anchors that moved, adds the new build id or version togame_builds, and checks the result withpython sdk/build_plugin.py check <dll>.
Use V3X_MANIFEST_REQUIRE_BUILD for plugins that patch data layouts (structure offsets) rather
than functions: there an anchor cannot prove the layout, so refuse unknown builds outright.
Deprecation
- A field of
V3X_APIis never removed and never changes meaning. A deprecated function stays in the table and keeps working; it is marked/* deprecated: use X */inv3x.h, api-reference.md and the CHANGELOG, at least one minor loader release before any behaviour change. - If keeping a function working would be unsafe (for example it turns out it cannot be made
thread-safe), it may become a stub that returns
V3X_EFAILand logs oneWARNINGnaming the calling plugin. That is the only allowed behaviour change, it is announced in the CHANGELOG first, and it never crashes or blocks. - An incompatible table would be a new generation with a new export name (e.g.
v3x_plugin_load2) loaded side by side with the old one, together with loader major version 1+; there is no plan for one. - SDK headers: a helper’s signature does not change within a
V3X_SDK_VERSION; replaced helpers keep compiling for at least one SDK version with a deprecation comment.
Support matrix
| supported | tested by | |
|---|---|---|
| game | Victoria 3 1.15.1 (release/1.15.1, build 6af3094f-60fb000); other builds in safe mode |
offline anchor tests against the real exe, in-game sessions |
| OS | Windows 10 / 11 x64 (the game’s own requirement) | Windows 10 LTSC 21H2 |
| loader 0.5.0 (API v5) + plugins built for API v1, v2, v3, v4, v5 | yes | test/compat_test.c |
| loader before 0.5.0 (API v4) + plugin built for v5 | yes, if the plugin uses V3X_HAS (or min_api refuses it) |
compat_test.exe old |
| plugin compilers | any C ABI x64 compiler: zig cc (tested), clang, MSVC, mingw gcc. v3x.h and the SDK compile as C and C++ |
test/run_all.sh (zig cc; cxx_headers with zig c++) |
| Proton / Wine | not tested | - |
SDK audit (what plugin authors kept re-implementing)
Grep of the 28 bundled plugins (2026-11-13) and what the platform now offers instead:
| re-implemented in | what | now |
|---|---|---|
| 25 plugins, ~40 sites | snprintf(path, "%s<name>.ini", api->v3x_dir()) and report paths |
v3x_plugin_file, v3x_path (sdk/v3x_util.h) |
| 15 plugins | a private QueryPerformanceCounter clock (now_ms, qpc_freq, …) |
v3x_qpc, v3x_now_ms, v3x_now_s, v3x_qpc_ms |
| 8 plugins | .tmp + MoveFileEx report writes |
v3x_file_write_atomic |
| 0 plugins (none had it) | log levels; every plugin logs everything or has its own verbose key |
log_at / log_level (API v5), V3X_LOG* macros with fallback, [log] in v3x.ini, loglevel command |
| 1 plugin (map_cache) | re-reading its ini at run time | reload [plugin] command + config_reload event (API v5), v3x_reload_is_for |
| 5 plugins | own main-thread id capture | main_thread_id (v4), v3x_on_main_thread (sdk/v3x_game.h) |
14 own ini parsers + 7 GetPrivateProfile* |
config parsing | v3x_ini.h (exists since API v4; migration of the plugins is up to their owners) |
| 13 plugins | VirtualQuery / ReadProcessMemory guarded reads |
v3x_mem.h (exists) |
| 0 | manifest | all bundled plugins now declare one (crash_logger, hello: "*"; the rest release/1.15.1) |
Release checklist for a plugin
V3X_PLUGIN_MANIFESTwith a version and the builds you tested (game_builds).- Every engine address comes from a
V3xAnchor; any failure = install nothing, return non-zero. - Optional fields behind
V3X_HAS; ormin_apiinV3X_PLUGIN_MANIFEST_EX. - Offline test passes against the real exe:
python sdk/build_plugin.py <dir> --test. python sdk/build_plugin.py check build/plugins/<name>.dllsayscompatible.