The scripting language
Prerequisite: How the game works and Engine architecture.
DreamFactory games are mostly scripted. The engine (TI.EXE) is a fixed program that knows how to draw views, play sounds, and move props — but what happens, when, is decided by little scripts stored inside the game files. Getting the scripts to run is what turns a decoded pile of images into a game.
This doc explains the language, the event model, and the interpreter. The binary layout of a compiled script on disk is a separate topic — see the script container doc.
What the language looks like
The scripts read like a small C-ish language. Here's the shape (illustrative):
main
{
code openset
{
if propvisible ("door") = "no"
{
voicesound ("welcome")
}
passcode
}
code mousedown
{
sendtoprop ("door", setupprop ("b59-hallb"))
exitcode
}
}Things to notice:
- An object's script is a bag of named
codehandlers. Each handler is fired by a specific engine event —openset,mousedown,setcursor,idle, and so on. You don't call handlers by looping; the engine calls them when something happens. exitcodemeans "I handled this event, stop here."passcodemeans "not mine, pass it along." (More on that below — it's the heart of the event model.)- Function-like things —
propvisible,voicesound,sendtoprop— are commands (the language calls them all the same way whether they're built into the engine or defined by another script).
Values, operators and quirks
There are integers, strings and booleans — three types, and before DreamFactory 5 the engines keep them strictly apart. Every value carries a type tag (DF.EXE: 2 boolean, 3 string, 4 integer); true and false are booleans, and so is every answer of a comparison, &, | and not. The operators have a couple of surprises:
| Operator | Meaning | Note |
|---|---|---|
+ - * / | arithmetic | |
@ | string concatenation | not + |
& | | logical and / or | no short-circuit before DreamFactory 5: both sides always run |
= | equality | case-insensitive for strings |
Tightest first: * /, + -, @, > < >= <=, = !=, &, |, each level left to right; not and unary - bind tighter than all of them. So a = b > c is a = (b > c). The engines read the whole expression onto a stack before applying any operator, which is why & and | cannot skip their right side: x & random(3) draws a random number even when x is false. DF.EXE's evaluator is 0x40d040 and its priority table 0x40d750; TI.EXE and Timelapse's tl.exe agree on both points. RedJack.exe (DreamFactory 5) keeps the priorities but does skip the right side of &/| once a boolean left side has decided (0x410788), so a v5 game still short-circuits.
Rules the interpreter has to honour exactly, or real scripts misbehave:
A string never equals a number.
=and!=(and aswitchcase) compare the two type tags before the values — DF.EXE 0x40d461/0x40d523, TI.EXE and tl.exe alike — so"5" = 5is false and"uparrow" = 0too. An uninitialised variable is the integer0: equal to0, not to"0"or"". A boolean is a third type:1 = trueand0 = falseare false, and an unset variable is notfalse. RedJack (v5) keeps true/false as 1/0 and still compares a mixed pair as text.Every other operator refuses the wrong type.
+ - * /want two integers,&|notbooleans,@two strings (and at most 255 characters of result),< > <= >=two integers — anything else is runtime error 0xe, as is anif/whilecondition that is not a boolean (if 1is an error, not true), aforbound that is not an integer, and aswitchcase of another type than its subject./ 0is error 0x37. An error unwinds the whole chain of handlers to the event that started it (DF.EXE 0x40d250, 0x40353d, 0x404070; TI.EXE and tl.exe alike). The engine's commands answer typed values too —sounddone (),iswalk (),actorexists (), a property getter likepropvisible (p)are booleans — and a boolean setter (propvisible (p, true),pausewalk,puppetgrab…) refuses anything but a boolean (engine/src/runtime/value-types.ts). A save stores the tag, and a load gives a boolean back as one.A command is a statement, a value, or both, and asking the other way is error 1. The engine runs a statement and a value through two dispatchers, each with its own id bands: 12xxx commands are statements only, 20xxx values only, and the 16xxx properties both. So
x = puppetclear ()and a barerandom (5)are both error 1, as is an id past the engine's bands — DF.EXE's are shorter than TI.EXE's, which is why Dust has nopostscript(dispatchesin engine/src/runtime/value-types.ts). No shipped script in the three trees does either.A boolean is a tag and a 32-bit payload, and the payload need not be 0 or 1. DF.EXE's
pauseloop (kind, name),pauseball (name)andpausewalk (name)answer the pause depth under the boolean tag. The operators then take the payload as it is:=compares payloads (paused twice is not= true),iftests it against 0,notisxor 1(sonotof depth 2 is still true) and&/|are bitwise (depth 2& trueis false). TI.EXE and tl.exe answer their pause getters 0 or 1. The port carries such a value as aLogic(interp.ts); no shipped script reads these getters.A
forvariable is a local.toandstepare read once; each pass reads the variable back and adds the step, so the body may move it, and the loop ends one step pastto. A global of the same name is shadowed, not counted.dumplocaldeletes a local the waydumpglobaldeletes a global.dumpgloballooks like a declaration and is a demolition.global x, ybrings variables into scope;dumpglobal x, ydestroys them — the whole list, asDF.EXEdoes and the port does. All 64 uses in the corpus are in a teardown —closeset,closestage,closeenigma,endfight, or adump…globals()helper called from one — andTURBINE.STG's exists for nothing else:dumpturbineglobalsis fourdumpgloballines and no other statement, againstinitvalue's plainglobal+ assignment on the way in.BRIDGE.STG'smonkey()works around it from the author's side:arg = drifthappen, thendumpglobal drifthappen, then every test againstarg— a copy that is pointless unless the next line destroys the original. The shipped saves agree:coal,valve1..3,pump1,pump2andsavenorthare dumped on a stage close and have a record in none of the 109 (#85).A global's presence in a save is not proof it is live. The globals container is a 32-byte node array and the reader walks it by physical slot, so a destroyed name keeps its node — name, value and all — until something is allocated over it (found at byte level by
rungs/d4mines.ts).D4E_001andD4M_MISSholdcutdownsat +3644 andwincountat +3676 with a stale secondwincountnode at +3708 and seven staleroundnumnodes after it. Nothing distinguishes those from live records, so "the save carries the name" measures how little has been allocated since, not whatdumpglobaldid.This makes five teardowns look as if only the first name is destroyed. In each, one function creates the whole list unconditionally, one destroys it, and nothing else in the corpus touches the names:
teardown dumped what the saves carry TARGET.FLT/0005targetshotcount, targethitcount, bottlehitcount, canhitcount, dummyhitcountthe last four with real values in 12 Dust saves, the first in none of the twelve LEROY.PUP/0088borrowgun, saveitemsaveitemcarries a real string in 27 saves, and in the 3 taken between the teardown and the nextglobal borrowgun, saveitemit stands withborrowgungoneFIGHT.FLT/0002dellpower, playerpower, fightoverplayerpowerandfightoverinD1E_009,dellpowerin noneKID.PUP/0051badcount, wincountrunyoself ()assigns both on adjacent unconditional lines andtalk5 ()dumps them;D4E_001carrieswincountand nobadcountmap.stg(TAOOT)savenorth, saveeast, savewestsaveeastin 78 of 109 andsavewestin 85,savenorthin nonebedsit1.setdoes not fit that pattern: its fourteen names are all gone from 107 Titanic saves (everything between itsclosesetand those saves happens across the start of the game proper, which the BOOTFILE rebuilds).Across all 55 saves of the golden thread, every row above behaves the same way — a dumped name never changes value again, and it disappears at the first save after new globals are allocated:
name after its teardown goes fightover1inD1E_009,D2M_001,D2M_002D2M_003, which allocatesslot2, slot3targethitcount,bottlehitcountfrozen at 3fromD3M_003toD3E_002D3E_003, which allocates 21 poker globalscutdowns,wincount5and3inD4E_001,D4M_MISSD4MINES, which allocates six underground globalsbetorder,winner,playercount,playerhandunchanged from D3E_003toMSKPZLMESAPZL, which allocates five over four of their slotssaveitem,LEROY.PUP/0088's second name, is"boots", then"sugarcubes", and fromD3M_CLASonwards the number0, which it stays for the remaining 83,602 frames of the game: a released payload in a record not yet overwritten, not a variable in use.The first-name-gone, tail-surviving pattern is therefore slot-reclaim order, not semantics. One function creates the list, so the names are adjacent nodes; the dump frees them together; the next allocation takes the lowest freed node, which is the first name. Two further cases fit the same reading:
TOWN.SET/0128 openkid ()dumps ONE name andD4E_001still readscutdowns = 5— a save taken before anything was allocated; andSALGAMES.FLT's poker teardown appears to keephasnopair, its own list's first name, all the way toENDING, becausehasnopairsits in the deepest region the teardown freed, which the rest of the game never reaches.GANG.CST/0984'sbouncer, dirgois excluded from the table: the two names are written byisaoidle ()on different schedules —bouncerevery pass,dirgoonly at the ends of Isao's 40° sweep — so they appear and vanish repeatedly across the thread, as live names do. The dead records above never move.if bouncer = 1 … bouncer = 0 else … bouncer = 1 endif if dirgo = 0 actordeg (me, actordeg (me) + 2) if actordeg (me) > 64 + 20 dirgo = 1 ← assigned here only else actordeg (me, actordeg (me) - 2) if actordeg (me) < 64 - 20 dirgo = 0 ← and herePlaythrough rungs must not rely on a dumped name in an
.rtd; three that did (d2e002,d3a001,d3e003) were reading dead records.The original compiler emitted some oddities the parser must tolerate:
//comment lines that tokenize as two division operators, unterminated blocks, dead statements before the firstcase, and the occasional misspelled bare identifier (reutrn). The parser handles 100% of the shipped corpus — about 2,050 script containers across every file type (SET, STG, SHP, MOV, PUP, CST and the BOOTFILE).A handler's boundary outranks every block inside it.
endcode, and thecodethat starts the next handler, close whatever is still open — because "unterminated blocks" is not only a handler ending in a bareexitcode(TURBINE'sboilsound), it is also aswitchthat never gets itsendswitch. SMETH1.PUP'sstewardwellnests a second plaque insidecase 101and closes only the inner one, and a parser still hunting forcaseorendswitchreads clean throughendcodeand eats the handlers that follow — four of them there, one of which is what makes Smethells turn you away from the first class lounge (#177). Measured across all six editions: every handler declared in every script container is parsed, the only exception beinggang.cst's container 1267, which really does declareendwalktwice.
The event model: objects, handlers, and the chain
Every object in the game owns a script: the set, each scene, each prop, each puppet, the stage, the boot. The engine turns things that happen into events, and delivers each event to the relevant object's matching code handler.
Common events:
| Event | Fires when |
|---|---|
openset / closeset | a set is entered / left |
openscene / closescene | you arrive at / leave a scene |
mousedown | you click on a hotspot |
setcursor | the mouse hovers a hotspot (the handler picks the cursor) |
keydown | a key is pressed (arg is a name like "uparrow") |
idle | the per-frame heartbeat |
The chain, and how an event is "consumed"
An event is not delivered to just one handler. It flows down a chain of objects, and each link either consumes it or forwards it:
The rule: run the current link's handler. If it ends with exitcode, the event is consumed and the chain stops. If it ends normally or with passcode — or the object has no handler for that event at all — the event forwards to the next link.
"Consumed" means a handler of this event exitcoded. A handler routinely calls routines and fires other events, and those end in exitcode for their own reasons, so a flag set from any depth answers a question nobody asked — "did anything, anywhere under here, stop?" rather than "was the player's event consumed?". The interpreter therefore tests the exitcode's own frame by handler name against the event under dispatch. Two shipped scripts depend on this:
STAIR2C.SET's deck rung callssetupshayhack()andsetupcsea()— both of which end inexitcode— and thenpasscodes, precisely so the engine's default move can walk you up out of the standpoint it just placed you on. Read as consumed, the walk never runs, and the 2nd-class staircase cannot be climbed past C deck.recept1c'sopensetdoessendtoactor("elev", setupactor())and thenpasscodes;setupactorexitcodes, so read as consumed, the boot's ownopenset(setupsound) is skipped and the room comes up silent on the wrong theme.
The one case that must still consume looks the same from a distance: boot1's keydown routes the event on with sendtoscene(currentscene(), keydown(arg)), and a set keydown that exitcodes there is overriding the default move on purpose. Same name, same event, consumed — while a helper routine and a foreign event are neither.
Which event a frame belongs to is carried by the frame, not by the interpreter, because this port runs more than one chain at a time and the original never does. TI.EXE's main loop pops one event and runs it to completion; here the heartbeat overlaps a player's press deliberately — serviceGameClock dispatches calctime so that it does not count as a busy script, or a press posted while it settled would sit in the queue — so a press drained on the same tick begins while calctime is still suspended at an await. A single interpreter-wide "current event" field would give the press calctime's name, every exitcode in its chain would compare against the wrong event and decline to consume, and the chain would run on into the boot library's default move — a held key walks through the smokestack crates (#232), whose SMSTACK2 set main is nothing but if blocked & arg = "uparrow" exitcode. Each frame therefore carries the name its own chain was dispatched under, inherited across routine calls and sendto* re-routes alike, so two chains in flight cannot answer for one another.
A passcode off the end of a chain keeps climbing. passcode means "not mine, ask whoever holds me", and that is as true of the last link as of any other: when a chain runs out on one, the event carries on up the containment chain — the file that holds the thing — exactly as an event nobody had a handler for already did. For a prop the chain is a single link, so without this the shop main behind it would be unreachable. inven.shp's notebook (container 0088) is the measured case: its own setcursor claims the one place the notebook is scenery rather than luggage and passcodes everywhere else, onto the shop main's distance-gated answer —
if propview (target) = "small"
if realdist (target) < hotdist () cursor ("touch") exitcode
endif
passcode
endif
cursor ("touch")— which is where the hand cursor over a takeable thing comes from in the whole game. Two rules come with it: a link the chain already ran must not run twice (a scene's chain is scene → set main → stage, and the stage is also the last thing containment tries, so a passcoding stage handler would otherwise run again), and a passcode inside the containment walk keeps climbing too, for the same reason it does along the chain.
This one mechanism explains a lot of the game's behaviour:
- Default movement lives at the bottom. Pressing ↑ fires
keydowndown the whole chain. Normally nothing consumes it and it reaches the boot library's default handler, which walks you forward. But a scene script can add its ownkeydownthat consumes the event (exitcode) and instead send you through a door to another set. Same key, different outcome, decided by a script — no engine change needed. - Doors closing on exit. The boot's default
closescenehandler sends the door prop back to its closed state, so props can't leak into the next scene.
Talking to other objects: sendto*
A handler often needs to make another object do something. That's the sendto* family: sendtoprop, sendtoscene, sendtoset, sendtostage, sendtoboot. They look like function calls but they are special forms:
sendtoprop ("door", setupprop ("b59-hallb"))means "run setupprop("b59-hallb") inside the door prop's script." The second argument is a deferred call — it is not evaluated where it's written; it's shipped to the target and run there, with the target as me. The arguments inside it, though, are evaluated in the caller's frame first.
If the target has no such handler, the call falls back to the boot library with me set to the target's name — that's how generic routines like initprop() work for any prop.
target is the addressee, not the caller — for the four that address a thing: sendtoprop, sendtoactor, sendtoscene, sendtoflat and their fx twins. me says who is running, target says who was addressed, and a great many shipped handlers are written as switch target or read it directly: propview(target), realdist(target), and BOOTFILE's own trackbut, whose body is pointinbutton(currentflat(), target, mouse()) and which every OK button in the game borrows. It is not "always the addressee": sendtoshopfx("house.shp", watchidle()) addresses a file, where there is no thing being addressed and the caller's context is what the handler reads. So the addressee for a thing, the caller for a shop, a cast, a stage, a puppet, the boot.
And the command says what KIND of thing — which matters because a name can be claimed twice. Two shops in the corpus name a prop after a character, and both are a mini-game's opponent drawn as a screen-space sprite over the room he is standing in: fight.shp's vlad and fence.shp's willie. sendtoactor("vlad", …) has to reach the cast member even while the fistfight overlay is up, which is exactly where the fistfight ends — endfight puts Vlad down on the catwalk with sendtoactor("vlad", setupactor("lostfight")) and the prop has no such handler, so resolving the prop first would drop the line and leave him standing (#84).
The boot library = the standard library
The BOOTFILE's scripts are special: they define ~76 handlers that any script can call by name — changeset, spotmovie, progress, setupactor, and so on — plus gotospecial (defined in MAIN.STG). These are the game's standard library.
That's why unqualified calls resolve in this order:
local script → engine builtins → stage script → boot librarySo when a set script says changeset(...), and no builtin or local handler matches, it lands in the boot library, which ultimately calls the engine primitives opensetfile / closesetfile. See BOOTFILE.
The interpreter
The runtime is three files:
parser.ts— turns a decoded script's token stream into an abstract syntax tree (AST). It tolerates the compiler quirks listed above.interp.ts— the interpreter core: variable scopes (global + local), the operators, control flow (if,switch/case, loops), handler dispatch withme/targetcontext, theexitcode/passcode/returnsignals, and a builtin registry holding each engine command's recovered behaviour.setscripts.ts— binds a specific SET's scripts (main, per-scene, per-view-object) to interpreter instances and implements the event chain described above.
Builtins are the bridge from the language to the engine. voicesound, propxy, opensetfile, makeloop — each is a TypeScript function registered in the interpreter that does the real work (plays audio, moves a prop, swaps a set). Reverse engineering the game is, in large part, discovering what each builtin is supposed to do — the script encoding and the full command-name table were already known from DFET and TI.EXE, but the per-command semantics are documented nowhere and are recovered one command at a time from the disassembly.
One more detail worth knowing: a builtin can be a getter or a setter depending on how many arguments it gets. propview(me) reads a prop's current view; propview(me, "x") sets it. Same name, arity decides.
Where to look next
- How these scripts add up to the whole plot — the
mission/phasemodel and the reconstructed story spine: the mission flow. - The on-disk format of a compiled script (the command table, the string pool at the end of the container): script container.
- How the language actually starts the game: BOOTFILE.
- Every builtin the engine registers, family by family — including the deliberate no-ops: the builtin reference.
- The command-name → ID table lives inside
TI.EXE; the tool that extracts it istaoot/tools/exetable.ts, and the full opcode table is inengine/src/df/opcodes.ts.