Total War: Three Kingdoms

TK Script Extender

Extra se.* functions in the campaign Lua VM, for things the stock script interface cannot reach.

What it is

A native DLL that is injected into the running Three_Kingdoms.exe and registers extra functions into the campaign Lua VM. It started from one problem — moving an existing character between factions' recruitment pools without creating a new character, which stock campaign Lua simply cannot do — and has grown from there.

What the se.* API now covers:

  • Characters and faction recruitment pools
  • Retinues and units: recruit, replace, disband, strength, chevrons
  • Character experience and skill points; assignments
  • Faction progression, the Three Kingdoms and emperor seats
  • Faction potential, CAI personality, diplomacy attitude
  • Buildings and region slots; alliances and coalitions
  • Effect bundles, income lines and horde income
  • Auto-resolve outcomes, the main-menu build string
  • Performance diagnostics and an AI recruitment policy

The full signature-level reference is on the se.* API reference page.

Install

Use the TK Mod Manager. That is the supported path: tick Script extender in the launch panel and it downloads script_extender.dll, checks the release's manifest.json fingerprint against your installed executable, and injects the DLL once the game reaches the main menu.

There is a command-line loader in the releases for development use:

injector.exe <dll> [Three_Kingdoms.exe]

Run it once the main menu is up. Building from source:

cargo build --release

which produces target/release/script_extender.dll and injector.exe.

Never inject twice Do not inject into the same process more than once, and never load a second or newer DLL on top of a running one. To update: quit the game, start it again, and inject once after the main menu.

How it avoids breaking your game

The DLL fingerprints the executable by its PE TimeDateStamp and SizeOfImage, and verifies every engine address it intends to use by its first bytes before touching anything. On any mismatch it logs and does nothing. A patched or updated game is therefore safe but inert — never half-working.

  • The on-disk executable is never patched. Everything happens in memory, in that one session.
  • There is deliberately no generic peek / poke / call primitive. Only named, high-level operations are exposed.
  • The internal natives (se_recruit_unit, se_slot_construct, …) are not the public surface. Scripts use the se module only.
  • Saves made after using se.modify.* carry the results — they are ordinary engine state. The DLL is not required to load such a save, only to keep using the API.

A healthy DLL log contains all N addresses and M vtables verified, then lua_gettop hook installed, then one registered se_* functions into lua_State … per Lua state.

Multiplayer

Not yet verified on two machines The rules below are implemented and reasoned through, but the project has not yet confirmed them against a real two-machine session. Treat multiplayer support as provisional.

Campaign multiplayer is lockstep: every machine runs the same scripts and must make the same change at the same model tick. Since DLL 0.30 the API is built for that:

  • se.modify.* runs only inside a model callback in multiplayer. Called from a UI click, a timer or the console it is refused rather than queued — queuing would change only one machine's model and desync the game.
  • Nothing a synced script does may depend on which machine it runs on. Do not branch on cm:get_local_faction() or is_local_player in code that ends in a modify call, and do not use math.random or os.time to decide a change.
  • Both machines must run the same extender with the same simulation settings. The DLL forces its version and a hook fingerprint into the game's build string, which the lobby compares, so a player without the DLL or with a different one cannot join.

Session-only state — auto-resolver variables, redefined bundles, income lines, emperor policy — must be re-applied by the mod after loading, from a callback that runs on every machine.

For mod authors

Feature-gate rather than assuming the DLL is there:

if type(se) ~= "table" then
    ModLog("script extender not present in this Lua state")
    return
end
ModLog("script extender " .. se.version())
if not se.available("se_slot_construct") then
    ModLog("this DLL is too old for buildings")
end

Two objects must be handed over explicitly, because the module chunk runs inside the DLL and does not see the script libraries' globals:

se.logger = ModLog   -- any function(string)
se.core   = core     -- the event manager

Tell the mod manager that your pack needs the extender by shipping SE/script_extender.json — how to declare it. Then read the API reference, which has the conventions, every function signature, and ten worked recipes.

Troubleshooting

The API is not there at all

  • type(se) ~= "table" means the DLL is not injected, or has not yet registered into this Lua state. Registration happens the first time a state ticks Lua: the main-menu state is served first, the campaign state once a campaign has loaded.
  • Check the DLL log for fingerprint mismatch: expected … or N anchor(s) failed; refusing to run. That means the game was updated and the DLL has to be rebuilt against the new build.

Where the logs are

  • DLL log: script_extender.log, next to the injected script_extender.dll. Truncated on every injection and mirrored to OutputDebugString.
  • Lua log: lua_mod_log.txt in the game root, plus the per-session ironic_log_<faction>_<stamp>.txt — newest by modification time is the current one.
  • Crash dumps: %LOCALAPPDATA%\CrashDumps.

The mod manager's Logs tab shows all of these without you having to find them.

Reporting a crash

Send the game build number, the DLL version (se.version()), the DLL log, the minidump from %LOCALAPPDATA%\CrashDumps, the Lua log, and the exact script that was running.

The natives validate vtables, back-pointers and registry round-trips and refuse instead of writing, so a refusal message is the expected outcome of a bad argument. A crash is a bug worth reporting — open an issue or say so in the Discord.

The API reference has the full table of refusal messages and what each one means.

License

MIT, the same as the mod manager. You are free to use, modify and redistribute it, including commercially, as long as the copyright notice and licence text come along.

Further reading

Two companion documents live in the repository rather than on this site, because they are a design writeup and a dated experiment log rather than reference material:

  • AI_RECRUITMENT.md — how the vanilla campaign AI recruits, the measurements showing what it leaves on the table, and the design of the replacement policy.
  • AI_RECRUITMENT_RUN_T1_T50.md — what that policy actually did to AI armies over 50 unattended turns.