Skip to content

Writing Your First Mod

Borys Stelmakh edited this page Sep 16, 2026 · 5 revisions

Writing Your First Mod

A SacredSDK mod is a Lua file that returns a list of FunkCode records. The bake runs it once at game startup and writes the result to a .bin the game reads. This page walks you from the smallest possible mod to modding vanilla, then to running real Lua callbacks at game-time.

Verified on Sacred Gold Steam, build 2.0.2.28 (2006-10-13).

New to the SDK? Quick Start builds three working runtime mods step by step: a message, a hero class with an NPC's body, a companion with an ambush. It is also published as a Steam guide.

Contents

TL;DR

-- save as: custom/lua/bin/TYPE_NPC_GLADIATOR/QuestCode.lua
local q = require "quest"

return q.script {
  q.set_hero_qbit(1101),
  q.set_hero_qbit(31),
  q.var "DaemonTotFranz",
}

Launch Sacred. It loads the mod, generates custom/bin/TYPE_NPC_GLADIATOR/QuestCode.bin from your Lua, and uses it instead of the vanilla file. The vanilla bin/ tree is never modified.

A mod's path mirrors the game's bin/ tree: a file under custom/lua/bin/<rel>/<name>.lua bakes to custom/bin/<rel>/<name>.bin and overrides bin/<rel>/<name>.bin. The per-class folders are TYPE_NPC_* (TYPE_NPC_GLADIATOR, TYPE_NPC_SERAPHIM, ...); inside each, QuestCode, FunkCode, StartCode, and QuestPoolCode are the script files.

What a mod returns replaces the whole file at its path. A runtime mod, one that only registers callbacks and ends in return {}, bakes to an empty file: put it in custom/lua/mods/<name>.lua, a folder that mirrors no game file. Saved as custom/lua/bin/TYPE_NPC_GLADIATOR/FunkCode.lua, it would hand the game an empty FunkCode.bin for that class.

Authoring styles

There are three layers, top to bottom. Use the highest one that covers your case and drop down only when it doesn't.

High level — lib/quest.lua and lib/dialog.lua

One Lua line per quest/dialog primitive.

local q = require "quest"
local d = require "dialog"

return q.script {
  q.var "campaign_progress",
  q.assign("campaign_progress", 1),
  q.set_hero_qbit(1101),

  d.trigger "HQ_3_2_1_DLG_START",
  d.line   ("res:1037", "btn_ok"),
  d.emit   (31, "9511"),

  q.log_entry(9511,
              "HQ_3_2_1_Log_Title",
              "HQ_3_2_1_Log_Header",
              "HQ_3_2_1_Log_Qstart"),
}

The full set of quest/dialog/text/state builders is documented on Quests and Dialog Authoring.

Mid level — lib/funkcode.lua and lib/raw.lua

When no high-level helper exists yet, build records by hand.

local raw = require "raw"
local fc  = require "funkcode"

return {
  -- a ResRef record by hand
  raw.rec(0x3c, 0x00, fc.dialog("res:1042", "trigger9999")),

  -- a "ConditionalEval" record (encoding not fully understood yet)
  raw.rec(0x3a, 0x00, {"STACK_96"}),
}

Low level — lib/unsafe.lua and _HEX

Last resort for opcodes not yet modeled in the helpers — paste raw bytes.

local raw = require "raw"

return {
  -- an opcode not yet named — paste the raw bytes
  raw.rec(0x67, 0x00, raw.hex("0b 27 25 00 00")),
}

raw.hex (and the _HEX pseudo-op underneath) copy bytes verbatim into the payload. The round-trip stays byte-perfect.

Modding vanilla

Instead of authoring from scratch, you can load a vanilla script, mutate it, and return the result. Nothing to prepare: v.load decompiles the game's own .bin on the spot through sacred.disasm, and hands you the same records the baker takes back. Measured on a whole shipped script — 125,236 records out of 3.97 MB in 721 ms, and baking them back unchanged produced a byte-identical file.

  1. Write a transformer mod in custom/lua/bin/<rel>/<name>.lua:

    local v = require "vanilla"
    
    local recs = v.load "bin/TYPE_NPC_SERAPHIM/FunkCode"
    v.gsub_bytes(recs, "HQ_3_2_1_sera_", "HQ_3_1_4_glad_")
    return recs
  2. Launch Sacred. The bake decompiles, transforms and re-bakes in a second or two; restart Sacred to apply edits.

vanilla.* helpers

Function Use
v.load(rel) The records of a shipped script: the game's own .bin, decompiled live, or a snapshot under _vanilla/ if you made one
v.gsub_strings(recs, pat, repl) Lua-gsub over every decoded string arg
v.gsub_bytes(recs, pat, repl) Same, plus inside _HEX fallbacks (full coverage)
v.for_each_op(recs, fn) Visit every op of every record
v.for_each_string(recs, fn) Visit every string arg of every op
v.records_with_tag(recs, tag) Filter records by tag byte
v.label_set(recs) Counter map of opcode-labels in use
v.have(rel) Whether a script is readable at all, so a mod can fall back

The vanilla -> Lua -> vanilla round-trip is byte-perfect (verified on 132 of 132 vanilla .bin files), so v.load + return with no edits produces an identical file.

Runtime trigger hooks

Bake-time records emit native Sacred bytecode the engine runs on its own. For logic that needs to run your Lua at game-time, attach a callback to a trigger name. It fires when Sacred dispatches a matching trigger from an NPC dialog or quest state change.

-- React to the player meeting Leandra in the Seraphim main quest
sacred.on_trigger("HQ_3_2_1_sera_DLG_OFFEN", function(ctx)
  sacred.log("Leandra greeted the hero!")
  ctx:notify("Quest discovered!")        -- gold banner, top-center
  ctx:give_gold(100)                     -- live hero-struct write
end)

-- Multiple handlers per trigger stack; all run in registration order.
sacred.on_trigger("HQ_3_2_1_sera_DLG_START", function(ctx)
  -- maybe layered logic from a different mod
end)

-- Drop all handlers (for clean re-registration on a re-bake).
sacred.clear_triggers()

sacred.on_trigger registers the handler during bake; the persistent Lua state survives the bake and the handler fires during play. Every handler receives a ctx table — see the ctx reference for its methods (ctx:gold(), ctx:give_gold(N), ctx:has_item(res), ctx:notify(text), ctx:get_var/set_var, and the ctx.trigger_name field).

For multi-step quests with order and guards, prefer the declarative state machine in lib/questfsm.lua (fsm.define{...}) — see Quests and Dialog Authoring.

For spawning living NPCs, companions, and dialog at runtime, see Runtime NPCs and the npcobj wrapper in the Lua API Reference.

Workflow

  1. Edit custom/lua/bin/<rel>/<name>.lua in any editor.
  2. Save.
  3. Relaunch Sacred (or click Rebake all .lua in the SacredSDK overlay).
  4. The new custom/bin/<rel>/<name>.bin is generated and read by Sacred.
  5. Observe in-game.

The bake runs once, on DLL attach. There is no hot reload yet — relaunch Sacred to pick up Lua edits.

Examples

Copy-paste starters live in sdk/custom/lua/examples/ (the framework tree). Files under examples/ are never baked, so copying is the point. The runtime ones that end in return {} (07, 08, 09, 14, 15) go to custom/lua/mods/<name>.lua. The ones that return records replace the whole game script at their path under custom/lua/bin/.

File Purpose
01_hello.lua The smallest meaningful mod — just declares state
02_text_swap.lua Bulk-rewrite vanilla text via gsub
03_dialog_block.lua Author one NPC dialog scene from scratch
04_full_quest.lua A complete (small) quest from scratch
05_conditional_dialog.lua Native Sacred branching skeleton
06_sidequest.lua Full side-quest template, copy-paste ready
07_runtime_triggers.lua sacred.on_trigger patterns
08_questfsm.lua Declarative multi-step quest state machines
09_state_vars.lua Read/write Sacred's named-state store at runtime
10_register_quest.lua Register a brand-new quest_id with the engine
11_classes.lua The 8 playable hero classes as constants
12_npc_classes.lua Every creature/NPC class as constants (474)
13_spawn_npc.lua Spawn NPCs via the engine CreateNPC bake path
14_npc_runtime.lua Runtime NPC spawning & behavior
15_storyline.lua The runtime storyline layer (quest-giver, items, teleport)

Troubleshooting

The mod didn't apply in game.

  1. Check sdk/logs/sdk_loaded.log for [lua_bake] baked '...' -> '...'.
  2. Check that custom/bin/<rel>/<name>.bin exists and has a plausible size. A runtime mod in custom/lua/mods/ bakes to an empty custom/mods/<name>.bin; that is expected.
  3. If your mod transforms vanilla, make sure the pre-decompiled snapshot is at custom/lua/_vanilla/<rel>.lua.
  4. Restart Sacred — the bake runs once, on attach.

The log has no line for my file. Windows hides file extensions by default, so a file saved from Notepad as hello.lua can be hello.lua.txt, which the bake ignores. Turn on View > File name extensions in File Explorer, and save from Notepad with Save as type: All files.

The game ignores the mouse. The SDK overlay has the input; press F11 (the overlay).

Game crashes after I save my mod. You probably emitted invalid bytecode; Sacred's interpreter is not forgiving. Roll back, relaunch, and iterate in smaller steps. If using raw.rec with unknown payloads, make sure the bytes don't reference NPC/item/resource ids that don't exist.

I get a Lua syntax error. sdk/logs/sdk_loaded.log shows [lua_bake] <file>: lua error: <msg> with the file path and line number.

Clone this wiki locally