Skip to content

Vanilla Verbs

Borys Stelmakh edited this page Sep 14, 2026 · 3 revisions

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.

Contents

How it works

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.lua asserts 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.

Running records

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.

Rewards and the hero

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.

Sound, music, effects

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.

Timers, triggers, calls

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.

NPCs

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

Quest journal and compass

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

The quest lifecycle

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.

Conditions and variables

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

World objects

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

Zones, barriers and section flow

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

What does not work from an SDK section

Measured, not guessed — each of these was watched against the engine's own state:

  • Vb.npc_goto to 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.lua refuses to build.

Clone this wiki locally