Docs · For modders
Safety rules
The rules that keep the game alive. Each one was learned from a crash.
Copied from docs/safety.md in the V3X repository, file dated 2026-11-13.
Every rule here comes from a crash, a hang or a corrupted session while building the plugins in this repo. A native plugin runs inside the game with no sandbox: one wrong pointer kills the process, and a subtle mistake can corrupt a save without any crash. The rules are ordered by how often they bit us.
Argument forwarding
Rule: a pass-through hook must forward every argument the engine function really takes.
Use api->hook_safe; if you write a raw detour, forward at least 8 integer slots (V3X_PASS8).
What happened: sim_opt’s detour on the parallel-for helper 0xe02c90 (1.15.1) was written with
4 arguments. The function takes a 5th one on the stack (a source-location pointer at
[rsp+0x28]). The detour’s own frame put garbage there, the original read it, and the game
crashed later at an unrelated-looking address (a NULL dereference at 0x7f2dbf).
Why it is easy to get wrong: in the Win64 ABI arguments 1-4 travel in registers, so a 4-argument detour on a 6-argument function “works” until the compiler reuses the stack slots. Nothing warns you. There are no headers for the engine; you only know the argument count after reading the callee.
How to avoid it:
api->hook_safe(target, pre, post, user)(API v4) forwards all 16 integer slots and xmm0-3 whatever the prototype is, and keeps the return value. Tested with 0-16 arguments, floats, doubles, mixed, struct returns, recursion and threads (test/hook_safe_test.c).- Register rule for
hook_safe: the caller gets every register (rcx, rdx, r8-r11, xmm0-5, upper ymm0-5) exactly as the original left it, and only rax/xmm0 change when a callback setsret/xmm_ret, because the game is built with whole-program optimisation and its callers may keep values in volatile registers across a call (the original also gets the caller’s registers back afterpre, except r10). - For a raw detour (
api->hook) on a function whose prototype you have not proven: theV3X_PASS8_ARGS/V3X_PASS8macros insdk/v3x_hook.h. They do not preserve float arguments if your detour does floating-point work before calling the original. - Measure:
tools/re_simopt_argcheck.py <exe> <rva>...counts the stack arguments a function reads (callee side) and its callers pass (caller side). - Sharing a function with another plugin: use
hook_safe. Several plugins mayhook_safethe same target; they run nested in load order (pre A, pre B, original, post B, post A), and the firstprethat setsskip_originalstops the chain (details in api-reference.md). Tested intest/hook_chain_test.c(also 8 threads calling while subscribers come and go). - Raw detours (
api->hook) stay exclusive: a raw hook on a target that already has a raw hook orhook_safesubscribers (andhook_safeon a raw-hooked target) fails withV3X_EHOOKBUSY(100) and v3x.log names the plugin that holds it. Check the return value and fail closed (log, do not run the feature); never assume your hook is in. If you need a function another plugin holds with a raw hook, ask its owner to move tohook_safe, subscribe to its event or ask for one. unhookonly removes your own plugin’s hook or subscriptions on that target.
Script and RNG scope
Rule: never run game script (effects, triggers, journal/event pulses, anything that may roll a
random number) outside an engine tick task. Check v3x_script_random_allowed() first.
What happened (two separate crashes):
sim_opt stagger_journalran deferred journal pulses from thesave_beginevent. Saves run as a session command on a job, outside any tick task. The script RNG (0x3787300) requires anALLOW_RANDOM_IN_SCOPEowner; there was none, and it dereferenced NULL at0x3787360, even thoughGameStateAccessread as Logic.enhance fast_speed5posted the next tick from the network/session thread. Ticks then ran back to back inside random scopes taken by other code; a scope exit restored NULL over the console’s scope pointer, and the console crashed at the same address.
What the engine does (docs/re/simulation.md, docs/re/tick_par.md): the script RNG has ONE global
owner pointer (exe+0x540ae80 on 1.15.1), not one per thread. The owner record holds the owning
thread id at +0x10. Tick execution sets it; parallel ranges and 38 other functions swap it with
xchg. So “am I on the right thread” is not enough: the scope must currently be owned by your
thread.
How to check:
#include "v3x_game.h"
static V3xGame g;
/* load time */ v3x_game_resolve(api, &g); /* finds the owner global by anchors, fail-closed */
/* in a hook */ if (v3x_script_random_allowed(&g) == 1) { /* script may roll random here */ }
1 = a scope is active and owned by the calling thread; 0 = no (do not run script); -1 =
not resolved (treat as no). This is necessary, not sufficient: you also need the right
GameStateAccess and no concurrent writers. The safe place to run script is inside a hook on a
tick task the engine itself runs (see “Ranked hook targets” in docs/re/simulation.md).
Related: the main thread (on_frame, run_on_main) renders and runs the UI. It is not a tick
task. Console commands run on the console input thread.
DllMain
Rule: do nothing in DllMain, and never do work in DLL_PROCESS_DETACH.
DLL_PROCESS_ATTACHruns under the loader lock. Creating a thread and waiting for it, loading libraries, or calling into the engine deadlocks or crashes (a Stellaris large-page loader crashed withCreateThreadinside DllMain). Do your setup inv3x_plugin_load.DLL_PROCESS_DETACHat exit runs afterExitProcesskilled every other thread. Any of them may have died holding a lock (the V3X log lock, a CRTFILElock, the heap lock, MinHook’s lock). Taking any of those hangs the process forever. The hang we hunted: the devtools sampler had suspended the main thread right as it enteredNtTerminateProcess; the sampler was killed before resuming it.- Since API v4 the core emits
process_exit(from a hook onRtlExitUserProcess, all threads still alive). Write final reports there or on theload_transitionGame->Empty event, keep it short, and never wait for other threads without a timeout. All V3X API calls refuse to do anything once the process is exiting. - Never
FreeLibraryyour own plugin: a thread may still be inside your hook.
Fail-closed anchors
Rule: never hook or patch an address you have not proven on the running build. If any check fails, install nothing and log why.
-
No hardcoded addresses. Every game patch moves everything. Find code by things that survive: strings the function references, RTTI class names, unique byte shapes.
-
Prove uniqueness.
pattern_scanreturns the first match; a pattern with two matches is a coin flip. A string used by several functions is ambiguous.sdk/v3x_anchor.hrefuses both:static V3xAnchor A[] = { {"game_tick", "Processing Tick: ", "48 89 5C 24 ?? 48 89 74 24", V3X_ANCHOR_ALL_SAME}, }; if (v3x_anchor_resolve_all(api, TAG, A, 1) != 0) return 1; /* logged, nothing installed */ -
Check the shape, not only the location: prologue bytes, the call you expect at an offset, the field offset an instruction uses. Add a
checkcallback for these. -
Per feature: if one anchor of an optional feature fails, disable that feature, not the whole plugin, and say so in the log (
feature X FAILED: <why>; the console shows it red). -
Write an offline check (
sdk/v3x_testkit.h) that runs the plugin’s real anchor code againstvictoria3.exemapped as an image, so a game update is caught before anyone starts the game. -
A manifest’s
game_buildsdocuments what you tested; it does not replace anchors.
Threads and locks in hooks
- A hook runs on whatever thread calls the function. Engine workers run with the scheduler’s state; if you block them, the whole tick waits.
- No unbounded locks inside hooks. Never take a lock in a hook that your other code holds while
calling into the engine (deadlock). Prefer
Interlocked*counters. - Do not
logper call in hot functions (each log takes a global lock and writes the file). Count, and report from a console command or every N seconds. - Do not allocate in code that runs while another thread is suspended (samplers): the suspended thread may hold the heap lock.
- Patch code only while it cannot run: at load time (before the game starts) or between ticks on the main thread, with workers idle.
Reading game memory
- Prefer reading through
sdk/v3x_mem.h(v3x_mem_read_u64,v3x_mem_chain,v3x_mem_read_cstr) in console commands, diagnostics and anything that follows reverse- engineered pointer chains. They never fault: VirtualQuery + ReadProcessMemory. - A fault on the console thread or a worker kills the game. The devconsole plugin reads every byte this way, so a wrong address prints an error.
- Raw dereferences are fine for pointers the engine just handed you as arguments in a hook, with a layout you verified.
Test offline first, then smoke test, with logs backed up
Rule: an offline test must pass before the game starts with your plugin; then a short smoke test; then a long session.
- Offline (
python sdk/build_plugin.py <dir> --test): anchors against the real exe, config parsing, the pure logic of your plugin compiled into a test exe. No game process. - Back up the previous run’s logs before every in-game test:
binaries/v3x/*.log,crash.log,crash.prev.log, and the game’sDocuments/Paradox Interactive/Victoria 3/logs/. The next start overwrites them, and the crash you want to understand is usually in the run you just lost. (crash_loggerkeeps one previouscrash.prev.log; that is not enough when you restart twice.) - Smoke test: start, load a save, play a few in-game days, save, load, quit to the menu,
quit. Read
v3x.logforFAILED/WARNING. A plugin that “loads only” is not a tested plugin; the README tables separate “loads (smoke test)” from “played (4h session)”. - Long session for anything that changes behaviour: saves, autosaves, speed 5, a load of a second save, alt-tab, minimise.
- Never test on your only copy of an ironman save.
Changing simulation behaviour
- Anything that changes what the simulation computes changes saves and makes multiplayer desync. Single player only; make it opt-in (default off) and say so in the ini.
- “Declared independent” tick tasks are not safe to run in parallel: they share unsynchronized globals (profiler scope, render guards, the RNG owner). tick_par’s analysis found no task pair that is provably safe (docs/re/tick_par.md).
- Skipping or deferring engine work is only safe if nothing reads its result before the next run, including a save that may happen at any pause.
Disk and memory
- The game’s commit can exceed 17 GB. Writing big files to a full C: fails saves
(
Failed writing file) and can crash the game. Write plugin outputs toapi->v3x_dir(), rotate or cap them, and do not write per tick. - Test scratch data (
TEMP,ZIG_GLOBAL_CACHE_DIR) can be moved to another drive; see CONTRIBUTING.md.