Skip to content

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 code handlers. 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.
  • exitcode means "I handled this event, stop here." passcode means "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:

OperatorMeaningNote
+ - * /arithmetic
@string concatenationnot +
& |logical and / orno short-circuit before DreamFactory 5: both sides always run
=equalitycase-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 a switch case) compare the two type tags before the values — DF.EXE 0x40d461/0x40d523, TI.EXE and tl.exe alike — so "5" = 5 is false and "uparrow" = 0 too. An uninitialised variable is the integer 0: equal to 0, not to "0" or "". A boolean is a third type: 1 = true and 0 = false are false, and an unset variable is not false. 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, & | not booleans, @ two strings (and at most 255 characters of result), < > <= >= two integers — anything else is runtime error 0xe, as is an if/while condition that is not a boolean (if 1 is an error, not true), a for bound that is not an integer, and a switch case of another type than its subject. / 0 is 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 like propvisible (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 bare random (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 no postscript (dispatches in 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) and pausewalk (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), if tests it against 0, not is xor 1 (so not of depth 2 is still true) and &/| are bitwise (depth 2 & true is false). TI.EXE and tl.exe answer their pause getters 0 or 1. The port carries such a value as a Logic (interp.ts); no shipped script reads these getters.

  • A for variable is a local. to and step are read once; each pass reads the variable back and adds the step, so the body may move it, and the loop ends one step past to. A global of the same name is shadowed, not counted. dumplocal deletes a local the way dumpglobal deletes a global.

  • dumpglobal looks like a declaration and is a demolition. global x, y brings variables into scope; dumpglobal x, y destroys them — the whole list, as DF.EXE does and the port does. All 64 uses in the corpus are in a teardown — closeset, closestage, closeenigma, endfight, or a dump…globals() helper called from one — and TURBINE.STG's exists for nothing else: dumpturbineglobals is four dumpglobal lines and no other statement, against initvalue's plain global + assignment on the way in. BRIDGE.STG's monkey() works around it from the author's side: arg = drifthappen, then dumpglobal drifthappen, then every test against arg — a copy that is pointless unless the next line destroys the original. The shipped saves agree: coal, valve1..3, pump1, pump2 and savenorth are 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_001 and D4M_MISS hold cutdowns at +3644 and wincount at +3676 with a stale second wincount node at +3708 and seven stale roundnum nodes after it. Nothing distinguishes those from live records, so "the save carries the name" measures how little has been allocated since, not what dumpglobal did.

    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:

    teardowndumpedwhat 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, saveitemsaveitem carries a real string in 27 saves, and in the 3 taken between the teardown and the next global borrowgun, saveitem it stands with borrowgun gone
    FIGHT.FLT/0002dellpower, playerpower, fightoverplayerpower and fightover in D1E_009, dellpower in none
    KID.PUP/0051badcount, wincountrunyoself () assigns both on adjacent unconditional lines and talk5 () dumps them; D4E_001 carries wincount and no badcount
    map.stg (TAOOT)savenorth, saveeast, savewestsaveeast in 78 of 109 and savewest in 85, savenorth in none

    bedsit1.set does not fit that pattern: its fourteen names are all gone from 107 Titanic saves (everything between its closeset and 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:

    nameafter its teardowngoes
    fightover1 in D1E_009, D2M_001, D2M_002D2M_003, which allocates slot2, slot3
    targethitcount, bottlehitcountfrozen at 3 from D3M_003 to D3E_002D3E_003, which allocates 21 poker globals
    cutdowns, wincount5 and 3 in D4E_001, D4M_MISSD4MINES, which allocates six underground globals
    betorder, winner, playercount, playerhandunchanged from D3E_003 to MSKPZLMESAPZL, which allocates five over four of their slots

    saveitem, LEROY.PUP/0088's second name, is "boots", then "sugarcubes", and from D3M_CLAS onwards the number 0, 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 and D4E_001 still reads cutdowns = 5 — a save taken before anything was allocated; and SALGAMES.FLT's poker teardown appears to keep hasnopair, its own list's first name, all the way to ENDING, because hasnopair sits in the deepest region the teardown freed, which the rest of the game never reaches.

    GANG.CST/0984's bouncer, dirgo is excluded from the table: the two names are written by isaoidle () on different schedules — bouncer every pass, dirgo only 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 here
    

    Playthrough 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 first case, 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 the code that starts the next handler, close whatever is still open — because "unterminated blocks" is not only a handler ending in a bare exitcode (TURBINE's boilsound), it is also a switch that never gets its endswitch. SMETH1.PUP's stewardwell nests a second plaque inside case 101 and closes only the inner one, and a parser still hunting for case or endswitch reads clean through endcode and 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 being gang.cst's container 1267, which really does declare endwalk twice.

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:

EventFires when
openset / closeseta set is entered / left
openscene / closesceneyou arrive at / leave a scene
mousedownyou click on a hotspot
setcursorthe mouse hovers a hotspot (the handler picks the cursor)
keydowna key is pressed (arg is a name like "uparrow")
idlethe 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 calls setupshayhack() and setupcsea() — both of which end in exitcode — and then passcodes, 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's openset does sendtoactor("elev", setupactor()) and then passcodes; setupactor exitcodes, so read as consumed, the boot's own openset (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 keydown down 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 own keydown that 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 closescene handler 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 library

So 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:

  1. parser.ts — turns a decoded script's token stream into an abstract syntax tree (AST). It tolerates the compiler quirks listed above.
  2. interp.ts — the interpreter core: variable scopes (global + local), the operators, control flow (if, switch/ case, loops), handler dispatch with me/target context, the exitcode/passcode/return signals, and a builtin registry holding each engine command's recovered behaviour.
  3. 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/phase model 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 is taoot/tools/exetable.ts, and the full opcode table is in engine/src/df/opcodes.ts.

Docs licensed GPL-3.0 (the decoder is ported from DFET). Game data © CyberFlix — not included.