-
-
Notifications
You must be signed in to change notification settings - Fork 3
Vanilla Verbs
One Lua builder per FunkCode record, copied from records the shipped quests actually use, and a way to run them now. This is the layer everything else in the SDK is built on: when your mod plays a sound, pays gold, moves an NPC or writes a journal line, the engine does it with its own handler — your Lua only decides when.
Verified on Sacred Gold Steam, build 2.0.2.28 (2006-10-13). Every builder below was byte-compared against a shipped record, and the ones marked live were watched working in game.
- How it works
- Running records
- Rewards and the hero
- Sound, music, effects
- Timers, triggers, calls
- NPCs
- Quest journal and compass
- The quest lifecycle
- Conditions and variables
- World objects
- Zones, barriers and section flow
- What does not work from an SDK section
A FunkCode record is a tag, a size and a stream of operands. A section is a
run of records the engine executes in order; the SDK can define its own sections
at run time (sections.lua) and ask the engine to run one (actions.lua).
local Vb = require "verbs"
local A = require "actions"
A.run(Vb.add_gold(100), Vb.info("MY_BANNER_KEY"))That builds two records, puts them in an SDK section and runs it on the next heartbeat. The engine pays the gold with its own "+100" popup and shows its own banner — there is no Lua in between and nothing to keep in sync.
Two rules worth knowing:
-
A single record must stay ≤ 255 bytes. The engine reads the tag and the
size as two little-endian 16-bit words, which only matches the big-endian size
the tools write while records are short.
sections.luaasserts this for you. -
A section runs top to bottom, once. Anything that must happen later needs a
timer, a trigger, or your own
sacred.on_tick.
| Call | What it does |
|---|---|
A.run(rec, ...) |
Run these records on the next heartbeat (an SDK section under the hood). |
A.run_as(handle, rec, ...) |
The same, but the object behind handle is the section's context — what the engine treats as "this object". Needed by records that act on themselves, e.g. fill_chest. |
S.define(name, rec, ...) |
Name a section so a timer, a death hook, a trigger or a button can run it later. |
S.define_owned(name, qid, rec, ...) |
The same, owned by a quest: the engine refuses to run it until that quest is set up (its own gate, used on 6,196 shipped sections). |
sacred.section_run(name [, handle]) |
Run a section by name — yours or a shipped one. |
Running an SDK section also fires the Lua trigger SECTION:<name>, which is how
a chest's "opened" callback or a button's handler reaches your code.
| Builder | Record | Notes |
|---|---|---|
Vb.add_gold(n [, who]) |
AddGold 0x12
|
live — the engine's own "+N" popup. |
Vb.add_exp(n [, who]) |
AddExp 0x11
|
live — real experience, level-ups included. |
Vb.gewinn(mask, level, tier) |
Gewinn 0x7d
|
live — vanilla's reward roll: bit 0 gold, bit 1 experience. |
Vb.set_hp(percent [, who]) |
SetHP 0x34
|
Sets HP to N % of maximum. |
Vb.give_stat(n) / Vb.give_skill(n)
|
0x8a / 0x8b
|
Keywords only; the effect is not decoded. |
Vb.equip_npc(who, types) |
EquipNPC 0x07
|
Put items on an NPC. |
Gold and experience are gated by the running section's owner quest: a quest-owned section pays only while that quest is set up and not yet done. SDK sections are global by default, so they always pay.
| Builder | Record | Notes |
|---|---|---|
Vb.play_sound(key) |
PlaySound 0x68
|
live — key is a SOUND_FX_* sample name. |
Vb.play_jingle(key) |
PlayJingle 0x80
|
live — the short musical stings. |
Vb.play_music(key) |
PlayMusic 0x7f
|
Switch the track. |
Vb.music_control(on) |
0x81 |
Hand music back to the engine, or take it. |
Vb.popup(node) |
Popup 0x5f
|
live — opens a dialog node's window with no NPC involved, the way signposts do. |
Vb.blessing(effect, seconds) |
Partikel 0x63
|
live — the blessing glow on the hero. |
Vb.fx_on(who, argb, fx) |
Partikel 0x63
|
A particle effect on a creature. |
Vb.info(key [, selector]) |
InfoPlayer 0x84
|
live — the game's own on-screen message; selector 0 and 1 pick the chime. |
| Builder | Record | Notes |
|---|---|---|
Vb.set_timer(id, minutes, section) |
SetTimer 0x38
|
live — runs section later; a game minute is about 2.5 s. The timer is in the savegame, so define the target section at load. |
Vb.del_timer(id) |
DelTimer 0x39
|
Cancel it. |
Vb.area_trigger(name, ll, ur) |
SetBaseTrigger 0x04
|
live — a rectangle; while somebody stands in it the engine runs the section named OMO-1<name> every heartbeat, once per creature. Use zones.lua, which turns that into enter/leave. |
Vb.del_trigger(name) |
DelBaseTrigger 0x05
|
live — remove it; from inside the handler section this is vanilla's one-shot. |
Vb.call(section) |
CallFunktion 0x16
|
live — run another section right here. |
| Builder | Record | Notes |
|---|---|---|
Vb.teleport(who, pos [, facing]) |
Teleport 0x2e
|
Jump. For the hero the party comes along. A name the engine cannot resolve teleports the hero instead — always pass a name you spawned. |
Vb.npc_goto(who, pos) |
NPC_Goto 0x48
|
live — walk, with path-finding. Pass coordinates; see the caveat at the bottom. The NPC's home pulls it back after a few tiles unless you move the home too — Npc:go does both (Runtime NPCs). The alt flag (op 0x66): walk versus run not confirmed. |
Vb.group_goto(g, pos [, teleport]) |
GroupGoto 0x4a
|
The same for a whole group. |
Vb.npc_state(who, ...) |
SetNPCState 0x03
|
live — one or more sub-ops from Vb.ST. |
Vb.group_state(g, ...) |
SetGroupState 0x49
|
The same for a group (an ambush waking up, a group vanishing). |
Vb.group_is_dead(g) |
GroupIsDead 0x18
|
live — a guard: the rest of the section is skipped while any member lives. |
Vb.del_npc(who [, kill]) |
DelNPC 0x37
|
Remove, or kill. |
Vb.attack(a, t) |
Attack 0x5c
|
a attacks t; the attacker must be awake and hostile first. |
Vb.play_anim(who, anim) |
PlayAnim 0x5b
|
live — 53, 54, 55, 57, 89, 100 and 154 are ids the shipped scripts use. |
Vb.set_icon(node, kind) |
SetIcon 0x56
|
live — the glyph over a head: "offer" (!), "handin" (?), "none". It writes the dialog node's marker, nothing else. |
Vb.declare_node(node [, marker]) |
DlgNPC 0x28
|
live — declare a dialog node at run time, StartCode's own record (the walker appends its 80 bytes to the node table). With Popup it opens a window nobody speaks: Sc.note (Cut Scenes). Each run appends another node, so declare one once. |
Vb.ST sub-ops: hostile, peaceful, wake, invulnerable, follow,
unfollow, no_dialog, stay, vanish, and the parameterised
ST.hook(section) (live — on-death, run with the corpse as the context),
ST.group(id), ST.level(n),
ST.node(name) (live — move the NPC to another dialog node, window and
glyph together), ST.no_node (unbind), ST.anchor(x, y) (live — the NPC's
home, in tiles; vanilla writes it next to ST.node for a talkable NPC, see
Runtime NPCs).
| Builder | Record | Notes |
|---|---|---|
Vb.quest_log(qid, sub, key) |
QuestBook 0x35
|
live — sub 0 is the title, anything else appends a body line (10 at most). |
Vb.compass_pos(x, y, qid) |
QuestKompassPos 0x3f
|
live — point that quest's arrow at a spot. |
Vb.compass_at(pos_name, qid) |
0x3f |
The same by named position. |
Vb.compass_obj(obj, qid) |
QuestKompassObj 0x40
|
Follow an object. |
Vb.compass_off(qid) |
0x40 |
Arrow off. |
Vb.map_icon(x, y, icon) |
SetMapIcon 0x61
|
live — an icon on the world map: 1 is a portal glyph, 2 a cave mouth (what the shipped scripts mostly use). |
These need a quest id the engine knows. Shipped ids qualify; for your own, see Native Quests.
| Builder | Record | Effect |
|---|---|---|
Vb.setup_quest(qid) |
SetUpQuest 0x15
|
live — arms the quest and runs its QIS_OnSetUp once, then its QIS_Trigger. |
Vb.trigger_quest(qid) |
TriggerQuest 0x14
|
live — enters it: the engine writes the journal entry, picks the category, registers the compass column, plays the accept fanfare, then runs QIS_OnEnter. |
Vb.exit_quest(qid) |
ExitQuest 0x0f
|
live — solved, with the vanilla sound and journal look. |
Vb.lose_quest(qid) |
LoseQuest 0x36
|
Failed. |
Vb.if_(preds, thens [, else_rec]) builds the engine's own IF / ELSE block —
live, and the way to make something happen once without Lua:
A.run(Vb.if_({ Vb.P.var_eq("my_chest_paid", 0) },
{ Vb.add_gold(100), Vb.set_var("my_chest_paid", 1) }))Predicates: P.var(name), P.var_eq(name, v), P.var_bit(name, b),
P.var_no_bit(name, b), P.has_gold(n), P.hero_bit(n), P.no_hero_bit(n),
P.has_item(owner, item), P.difficulty(d). All of them must hold.
Variables live in the engine's own table and travel with the savegame:
Vb.set_var, Vb.inc_var, Vb.dec_var, Vb.rnd_var(name, lo, hi), and
Vb.set_var_bit(name, bit) (with the name "HeroQBit" it sets the hero's own
quest bit, which follows the hero rather than the world).
Vb.create_obj, Vb.obj_state, Vb.fill_chest, Vb.mouse_event and
Vb.set_on_kill / set_on_collect / set_drop are covered on their own pages:
World Objects and Native Quests.
Vb.create_obj(type, pos, { place = true, give = true, take = ... }) is
vanilla's quest item lying on the ground
(World Objects).
Added 2026-09-12, all live. The first three have a page of their own — Zones and Barriers — because the raw records need a wrapper to be usable.
| Builder | Record | Notes |
|---|---|---|
Vb.create_trigger(name [, kind]) |
CreateTrigger 0x30
|
a named trigger object, the thing vanilla lays over map cells |
Vb.trigger_patch(name, cells) |
TriggerPatch 0x32
|
bind it onto {x, y} cells — 16 to a record |
Vb.trigger_state(name, ...) |
SetTriggerState 0x31
|
"open", "close", "lock", "unlock"; closed + locked blocks the way
|
Vb.section_restart() |
SectionEnd 0x6f
|
run this section again from its first record. Guard it with an if_ over a variable the section itself changes, or the engine loops forever. Measured: two passes, exactly as intended |
Vb.section_latch([section]) |
DeleteFunktionBlock 0x0e
|
never run this section (or the named one) again. Measured: three runs, one read. The latch is cleared when the SDK re-injects its section table, so it is per world, not forever |
Vb.activate_quest(qid) |
ActivateQuest 0x75
|
make a registry quest the tracked one |
Vb.quest_page(1..4) |
SetQuestInfo 0x57
|
the journal tab. Carries no quest id — it writes the entry of the section's owner quest, so it only works from S.define_owned
|
Vb.unset_var_bit(name, bit) |
UnsetVarBit 0x45
|
the complement of S.set_var_bit; measured 8 → 0 |
Vb.morph(who, type_id) |
Morph 0x4f
|
re-type a live creature. Measured: a sheep (544) became a Grey Wolf (569), model and all |
Vb.autosave() |
AutoSave 0x79
|
the game's own autosave; the engine saved a second later |
Vb.anim_mode() |
SetAnimMode 0x58
|
the queueing half of cinema mode — see Cut Scenes |
Vb.fx_at(x, y [, colour, fx]) |
Partikel 0x63
|
a particle effect at a world position, how vanilla marks a trigger area on the ground. Careful: one is fine; a run that placed ten at once ended in an engine crash inside cGranny::bindTextures, not reproduced since |
Measured, not guessed — each of these was watched against the engine's own state:
-
Vb.npc_gototo a creature ("HERO","res:X") instead of coordinates. Both shipped cut scenes use that form; from an SDK section it moves nobody. Pass coordinates:Vb.npc_goto(who, { sacred.hero_pos() }). -
Wait (
0x5a) and NPC_TalkTo (0x47) — see Cut Scenes. -
FillChest (
0x4e) on a chest the SDK created — the engine refuses it even when the chest is the section's context. Drop loot on the floor instead, which is what a vanilla chest does anyway (World Objects). -
A record over 255 bytes, which
sections.luarefuses to build.
Getting started
Authoring
- Quests and Dialog Authoring
- Native Quests
- Runtime NPCs
- Hero Classes
- Dialog Text
- Dialog Nodes (catalogue)
The engine's own verbs
Reference