Skip to content

Quests and Dialog Authoring

Borys Stelmakh edited this page Sep 12, 2026 · 2 revisions

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).

Contents

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.

The quest composer

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.

State: variables and quest-bits

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.

Dialog primitives

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 triple

A minimal scene authored by hand:

return q.script {
  d.trigger "MyNPC_DLG_START",
  d.line   ("res:1037", "btn_ok"),
  d.emit   (31, "9511"),
}

Quest log entries

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).

Inline strings

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 stable res: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 vanilla scripts/us/global.res, appends one new slot per unique string, and writes the result to custom/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_State across every mod, so T() calls from different mods dedupe through one combined global.res.

Use T.named("MY_KEY", "text") for a specific symbolic name — e.g. to override a known vanilla string by hash.

Inventory and reward

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.

Quest state machines

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.

Registering a brand-new quest

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.

Limitations

  • if/else from Lua: only the if-then form is fully helper-wrapped. The else branch via STACK_96/STACK_97 markers needs raw.rec until 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 ctx methods 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 ItemDrop encoding is not fully verified and not yet exposed via a helper.

Clone this wiki locally