-
-
Notifications
You must be signed in to change notification settings - Fork 3
Quests and Dialog Authoring
The bake-time system for writing quests from scratch: declaring state,
authoring dialog scenes, adding journal entries, and routing inline text
through global.res. Everything here is compiled to native FunkCode records
that Sacred's own interpreter runs — no runtime hook needed.
Verified on Sacred Gold Steam, build 2.0.2.28 (2006-10-13).
- The quest composer
- State: variables and quest-bits
- Dialog primitives
- Quest log entries
- Inline strings
- Inventory and reward
- Quest state machines
- Registering a brand-new quest
- Limitations
Bake-time authoring emits records; runtime callbacks run your Lua at game-time.
This page covers the bake side. For game-time callbacks (sacred.on_trigger,
ctx), see Writing Your First Mod.
For dialog text rendered natively in the talk window, see
Dialog Text. For the dialog node-name catalogue, see
Dialog Nodes.
q.script flattens nested record lists into the format the baker expects.
Return its result from your mod file.
local q = require "quest"
return q.script {
rec1,
rec2,
list_of_recs, -- nested lists are flattened
cond and rec_x, -- nil/false entries are skipped
}require "quest" re-exports the dialog, state, text, reward, and
questfsm helpers, so a single local q = require "quest" is usually enough.
Sacred quest scripts keep per-hero state as named integer "globals"
(dq_belohnung, hq_uw, ...) plus a large array of hero quest-bits. These are
declared as native records at bake time.
q.var(name) -- declare a quest variable (tag 0x43)
q.assign(name, value) -- set a quest variable (tag 0x69)
q.assign_inc(name, value) -- variant of assign with "\x0b\x01" trailer
q.set_hero_qbit(N) -- declare hero quest-bit N (tag 0x44)The variable patterns aren't 100% universal — some vanilla records have extra
ConditionalEval/BlockReader siblings. For those, drop down to
lib/funkcode.lua or lib/unsafe.lua.
Named-state values persist inside the save game, so a quest variable set at
bake time and later overwritten with ctx:set_var / sacred.state_set survives
across sessions. See Lua API Reference for the
runtime read/write side.
From lib/dialog.lua:
local d = require "dialog"
d.trigger(name) -- tag 0x1a QuestTrigger
d.line(res_id, button_action) -- tag 0x3c ResRef (one renderable line)
d.emit(channel, target) -- tag 0x42 BlockReader
d.scene(trigger, res, btn, channel, target)
-- convenience: trigger + line + emit tripleA minimal scene authored by hand:
return q.script {
d.trigger "MyNPC_DLG_START",
d.line ("res:1037", "btn_ok"),
d.emit (31, "9511"),
}q.log_entry(quest_id, title, header, start_text)
-- Emits three tag-0x35 (QuestLogSet) records pointing at
-- res:<title>, res:<header>, res:<start_text> in global.res.The three text arguments are resource references — either existing
res:NNNN ids, vanilla symbolic names, or inline strings (next section).
Write text inline with T"..." and the bake auto-routes it through
custom/scripts/us/global.res — no need to find free res:NNNN slots.
local T = require "text" -- or `q.T` after `local q = require "quest"`
return q.script {
d.trigger "MyQuest_DLG_START",
d.line (T "Hello, hero! I lost my cat — help?", "btn_ok"),
q.log_entry(9512,
T "The Missing Cat",
T "Chapter 1",
T "An old woman beckons you over..."),
}How it works:
- Each
T(s)returns a stableres:SDK_<8 hex>reference derived from the string content (FNV-1a). Two identical strings dedupe to the same slot. - At the end of the bake,
text.flush()reads vanillascripts/us/global.res, appends one new slot per unique string, and writes the result tocustom/scripts/us/global.res. The Patch-1 detour serves the custom file to Sacred on the next launch. - It is idempotent across re-bakes — flushing twice produces the same file.
- The bake shares one
lua_Stateacross every mod, soT()calls from different mods dedupe through one combinedglobal.res.
Use T.named("MY_KEY", "text") for a specific symbolic name — e.g. to override
a known vanilla string by hash.
These are native Sacred bytecode predicates and effects: the engine evaluates them at game-time, no runtime hook needed.
-- Check: does the hero carry item resource 17562?
q.has_item(17562) -- predicate (tag-0x3a ConditionalEval) alone
-- if/then convenience: predicate + body wrapper (tag-0x42 BlockReader)
q.if_has_item(17562, {
d.line(T "You brought it back!", "btn_ok"),
q.give_gold(500),
q.set_hero_qbit(9512),
})
-- Pure gold ops:
q.give_gold(500) -- positive amount -> grant gold
q.charge_gold(200) -- alias for give_gold(-200)
q.give_gold_from_var "RewardX" -- amount stored in a named global, looked up
-- at game-time (mirrors vanilla's
-- Belohnung_Whiskey pattern)The conditional ELSE branch (fired when the predicate is false) is only partly
reverse-engineered — see examples/05_conditional_dialog.lua for the raw
STACK_96/STACK_97 pattern. Until the encoding is modeled cleanly, the
if-then form is the supported one and else needs raw.rec.
For a quest with more than one "this happens next" beat, lib/questfsm.lua
collapses the order-and-guard bookkeeping into one declarative block built on
sacred.on_trigger.
local fsm = require "questfsm"
local lost_tome = fsm.define {
id = "lost_tome",
steps = {
{ name = "started",
trigger = "17095", -- Sacred resource id that fires the step
on_enter = function(self, ctx)
ctx:notify("Quest started: the Lost Tome")
end },
-- ... further steps; each may add an optional `guard`
},
}The FSM enforces a linear chain: step K only enters if step K-1 is current
(skips are rejected and logged), each on_enter fires once per session, and an
optional guard can refuse to advance unless a runtime condition is met
(e.g. the hero carries the item). Persistence is in-memory only and resets each
launch — Sacred's own qbits/journal already persist inside the save game, so the
FSM is a transient overlay that re-derives itself as the engine queries triggers
during play. See examples/08_questfsm.lua.
There are two ways to do this now. The one below writes a journal entry by hand and is still the lightest option when all you want is a line in the journal. The other — Native Quests — puts your quest id in the engine's own quest registry, after which the game writes the entry itself, picks the category, plays the accept and solved fanfares, runs the
QIS_*hooks and keeps the state in the savegame. Prefer it for a real quest.
q.log_entry mutates an existing quest-display entry. For a brand-new
quest_id that vanilla never declared, register it with the engine first, then
populate it. Do the register from sacred.on_world_load so the entry exists
before any tag-0x35 records dispatch.
sacred.on_world_load(function()
sacred.questbook_register(9550) -- append a new display entry
sacred.questbook_set_log(9550, 0, -- page 0, then journal lines
"MyQuest_Title", "MyQuest_Header", "MyQuest_Line1")
sacred.questbook_set_marker(9550, 3200, 2300) -- map/compass marker
end)The display registry is rebuilt from the save game on every load, so a mod must
re-register its quest_ids on each world-load. The full quest-book API
(questbook_register, questbook_set_log, questbook_add_log,
questbook_mark_solved, questbook_complete, questbook_set_marker,
questbook_set_kompass, questbook_set_step_done) is in the
Lua API Reference. See
examples/10_register_quest.lua.
-
if/else from Lua: only the
if-thenform is fully helper-wrapped. Theelsebranch viaSTACK_96/STACK_97markers needsraw.recuntil the encoding is modeled. -
Live game-state from Lua at bake time: Sacred evaluates inventory/gold/
position checks natively; bake-time Lua only emits the bytecode. Use runtime
ctxmethods for live reads. - Custom opcodes: every record must use one of Sacred's native opcodes (the bytecode is run by Sacred's own interpreter). You cannot invent new ones.
-
Drop-item-into-inventory: the suspected
tag-0x37 ItemDropencoding is not fully verified and not yet exposed via a helper.
Getting started
Authoring
- Quests and Dialog Authoring
- Native Quests
- Runtime NPCs
- Hero Classes
- Dialog Text
- Dialog Nodes (catalogue)
The engine's own verbs
Reference