V3Xengine works for Victoria 3

Unofficial · not affiliated with Paradox Interactive

Docs · For modders

Extending game script from a V3X plugin

Ship game script, localization and GUI files from a native plugin.

Copied from docs/scripting.md in the V3X repository, file dated 2026-11-13.

This guide is for modders who want a native V3X plugin to add game script (effects, triggers, script values, events, on_actions), localization or GUI. Engine background: docs/re/script.md.

What works today

Layer Status
Ship script, loc and GUI files from a plugin (script_ext, sdk/v3x_script.h) implemented, tested offline (test/script_ext_test.c), not run in game yet
Run effect or trigger text from C at runtime not available (see the end of this guide)
New script keywords implemented in C (v3x_<name> = ...) not available (see the end of this guide)

Shipping files covers most “add something to script” cases. Anything a mod can define, a plugin can now define too: scripted effects and triggers, script values, events, decisions, journal entries, on_actions, defines, localization and GUI. The plugin can also generate those files at load, for example from its ini, the player’s settings or data computed in C.

How it works

  1. Your plugin’s v3x_plugin_load runs before the game starts. It calls script_ext to write files into binaries/v3x/content_packs/<pack>/.
  2. The content_packs plugin mounts every pack into the game’s virtual file system when the VFS initializes. That happens after all plugins are loaded and before any script is read (docs/re/vfs.md).
  3. The engine reads common/..., events/..., localization/... and gui/... from the pack exactly as it reads a mod’s files.

Priority: packs sit above game/ and below Paradox mods (mods are mounted later and go on top). A new file name (for example common/scripted_effects/my_plugin_effects.txt) adds to the game; it does not replace anything. A file with the same path as a vanilla file replaces the vanilla file.

Both script_ext and content_packs must be installed and enabled.

API (sdk/v3x_script.h)

const V3X_ScriptExt *sx = v3x_script_get(api);   /* NULL = script_ext.dll not installed */
sx->begin_pack("my_pack");                        /* creates the pack, deletes files YOU wrote there last session */
sx->write_file("my_pack", "relative/path.txt", data, size);
sx->end_pack("my_pack");                          /* writes the pack's manifest; returns the file count */
sx->packs_dir();                                  /* ...\binaries\v3x\content_packs\ */

Rules:

  • Call it from v3x_plugin_load. Files written later in the session are not read: the game loads its script databases once, at startup.
  • Pack names may contain letters, digits, _, - and ., up to 63 characters, and must not start with _ or . (content_packs skips such folders). Paths are relative (/ or \) with no .. and no drive letter, up to 200 characters. Anything else returns V3X_SCRIPT_EARG.
  • *.yml files get a UTF-8 BOM if they lack one, because the engine needs it for localization.
  • Files are replaced atomically. A file you stop writing is deleted at the next begin_pack. Files in the pack that were not written by script_ext are never touched.
  • Prefix your names (my_plugin_...). Vanilla uses no v3x_ names (checked by the test against 1.15.1), so v3x_ is free for V3X’s own content.
  • In the V3X console, scriptext lists the packs written this session.

Complete example: v3x_hello logs the country every month

The plugin adds:

  • a scripted effect v3x_hello
  • an on_action that runs it for every country each month
  • one localization key

The effect logs the scopes with the engine’s own debug_log / debug_log_scopes (output goes to Documents/Paradox Interactive/Victoria 3/logs/debug.log).

/* plugins/hello_script/hello_script.c - build: python sdk/build_plugin.py plugins/hello_script */
#include <string.h>
#include "v3x.h"
#include "v3x_script.h"

V3X_EXPORT const char *v3x_plugin_name = "hello_script";

static const char EFFECTS[] =
    "# generated by hello_script\n"
    "v3x_hello = {\n"
    "    debug_log = \"v3x_hello ran\"\n"
    "    debug_log_scopes = yes\n"
    "}\n";

static const char ON_ACTIONS[] =
    "# appends to the vanilla on_action (root = country)\n"
    "on_monthly_pulse_country = {\n"
    "    on_actions = { v3x_hello_monthly }\n"
    "}\n"
    "v3x_hello_monthly = {\n"
    "    effect = { v3x_hello = yes }\n"
    "}\n";

static const char LOC[] =
    "l_english:\n"
    " v3x_hello_title: \"Hello from V3X\"\n";

V3X_EXPORT int v3x_plugin_load(const V3X_API *api)
{
    const V3X_ScriptExt *sx = v3x_script_get(api);
    if (!sx) { api->log("hello_script", "script_ext not installed - nothing written"); return 0; }
    if (sx->begin_pack("hello_script") != V3X_SCRIPT_OK) return 0;
    sx->write_file("hello_script", "common/scripted_effects/v3x_hello_effects.txt", EFFECTS, strlen(EFFECTS));
    sx->write_file("hello_script", "common/on_actions/v3x_hello_on_actions.txt", ON_ACTIONS, strlen(ON_ACTIONS));
    sx->write_file("hello_script", "localization/english/v3x_hello_l_english.yml", LOC, strlen(LOC));
    api->log("hello_script", "wrote %d file(s)", sx->end_pack("hello_script"));
    return 0;
}

Check it in game (not yet done by us):

  1. debug.log has v3x_hello ran once per country per month.
  2. error.log has no script errors for these files.
  3. The console command script_docs lists v3x_hello among the scripted effects.

A scripted effect only runs in the game’s own script context (tick tasks, events, on_actions, decisions). That is the safe place for script (docs/safety.md “Script and RNG scope”). You never run it yourself from a hook or a frame callback.

Open point to check in game: the example assumes that a second on_monthly_pulse_country definition merges its on_actions list into the vanilla one. That is the usual Jomini behaviour, but it is not verified for 1.15.1. If it does not merge, trigger your on_action from an event or a decision instead, or ship a copy of the vanilla on_action file with your line added. A copy of a vanilla file breaks when the game patches that file.

Not available (and why)

  • Running effect/trigger text from C and C-implemented keywords are not implemented. The engine internals they would use are described in docs/re/script.md sections 1-5: the Script Runner parse/execute functions, game-state access, the random-scope wrapper, and the effect/trigger registries filled by static initializers. During implementation of these two layers the worker’s output was stopped by a safety check, and the work was not continued. The CTO decides whether and how these layers are added.
  • Until then, use script itself for logic. Communicate from C to script by generating files at load (values, lists, effects built from your data). Communicate from script to C by reading what script writes to the logs (debug_log), or through game state your plugin already reads.

Files

  • sdk/v3x_script.h: API and the v3x_script_get helper
  • plugins/script_ext/script_ext.c: plugin (exports v3x_script_ext, console scriptext)
  • plugins/script_ext/sx_pack.h: file writer
  • test/script_ext_test.c: offline test. It also checks that the RE anchors in docs/re/script.md still match the exe.
  • tools/re_script_rtti.py: RTTI vtable list used for the RE