Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SOUP Scripting

Bytecode scripting engine used by the Redguard runtime for actor behavior, dialogue, puzzle logic, and scene control.

Script Data Sources

SourceContainerNotes
Map script bytecodemaps/*.RGM (RASC section)Main per-actor compiled script payload. Offsets and lengths are stored per actor in RAHD (script_data_offset, script_length, script_pc).
Standalone AI script filessoup386/*.AI (for example CAMERA.AI, sword.ai, projtle.ai)Loaded separately from map RGM files. Uses the same SOUP bytecode model as map scripts. No .AI files are included in the shipped game — the soup386/ directory contains only SOUP386.DEF in both the GOG and original CD releases. The engine’s .AI loading path was most likely used during development.
VM definition tableSOUP386/SOUP386.DEFText definition file loaded at runtime. Defines function/task names, flags, equates, and attributes used by script execution. See SOUP386.DEF.
EXE-embedded interpreterRuntime binaryThe executable contains the SOUP VM interpreter and native function handlers; script payload bytes are file-sourced (RGM sections and .AI files).

What Is In the Runtime vs Data Files

  • Runtime contains the SOUP VM/interpreter and native handlers for script-callable operations.
  • Script payload data is loaded from map/script assets (RGM and .AI) and executed by the VM.
  • Function names and metadata come from SOUP386.DEF, while actor-local script bytes come from RASC/.AI content.

VM Architecture

The SOUP386 VM is a register-free, program-counter-driven bytecode interpreter. There is no operand stack — values flow through function return values and are consumed directly by the calling instruction. Local variables (RAVA section, per-actor int[]) and global flags (369 entries shared across all scripts) provide persistent state.

Threading Model

Each script supports up to two concurrent threads sharing the same bytecode buffer:

ThreadStart AddressPurpose
Thread 0RAHD.script_pcMain execution (dialogue, activation, behavior)
Thread 1Offset 0x00Interrupt handler — only created when script_pc != 0

Thread 1 enables actors to remain activatable while performing another action (for example, an NPC walking a patrol route can still respond to player interaction). The two threads share the bytecode array but maintain independent program counters and independent call stacks.

Execution uses cooperative multitasking: tickScript advances each thread by one instruction per tick. runScript drives thread 0 to completion (with an infinite-loop guard at 1024 iterations).

Endint (opcode 0x13) terminates thread 1 by resetting its PC to 0x00. End (opcode 0x05) terminates thread 0.

Value Modes

The same opcode byte is interpreted differently depending on the calling context. There is no separate addressing-mode byte — the calling instruction determines how trailing bytes are consumed.

ModeContextEffect
MAINTop-level statementPerforms assignment (writes to flag/variable/property)
LHSLeft side of if comparisonReads value; consumes 1 extra operator byte
RHSRight side of if comparisonReads value; consumes 1 extra operator byte
PARAMETERArgument to a task/function callReads value; consumes mode-specific padding bytes
FORMULARight side of an assignment expressionReads value only, no extra bytes

The number of trailing bytes consumed after an opcode depends on both the opcode and the current value mode. See Operand Encoding for per-opcode details.

Bytecode Encoding

Opcode Table

All integers are little-endian. The VM reads a leading opcode byte and dispatches. Unrecognized bytes are fatal.

OpcodeNameEncoding (after opcode byte)Description
0x00Tasku16 func_id, u8 param_count, params…Call task on self
0x01Multitasksame as 0x00Async task on self (non-blocking)
0x02Functionsame as 0x00Call function on self (returns value)
0x03Ifcondition chain, u32 end_offset, blockConditional branch
0x04Gotou32 targetUnconditional jump
0x05Endu32 targetHalt — terminates execution
0x06Flagu16 flag_id, mode-dependentGlobal flag read/write
0x07Numerici32 valueImmediate 32-bit integer
0x0ALocalVaru8 var_index, mode-dependentLocal variable read/write
0x0FObjDot++object encoding, u16 ref_idIncrement object property
0x10ObjDot–object encoding, u16 ref_idDecrement object property
0x11Gosubu32 targetSubroutine call (pushes return address)
0x12Return(none)Return from subroutine
0x13Endint(none)End secondary thread
0x14ObjectDotobject encoding, u16 ref_id, mode-dependentObject property read/write
0x15Stringu32 string_indexString literal from RASB/RAST table
0x16NumericAlti32 valueSame as 0x07; used for global flag function arguments
0x17Anchoru8 anchor_valueAnchor assignment
0x19ObjDotTaskobject encoding, task encodingTask call on named object
0x1AObjDotFuncobject encoding, task encodingFunction call on named object
0x1BTaskPauseu32 labelPause until task completes
0x1EScriptRVu8 expected, blockBranch on script return value

Bytes with no known opcode: 0x08, 0x09, 0x0B0x0E, 0x18, 0x1C0x1D, 0x1F+. The RGUnity interpreter throws a fatal error for any unrecognized byte, confirming these are not valid opcodes in shipped scripts.

Operand Encoding

Flag (0x06) and LocalVar (0x0A) consume different trailing bytes depending on value mode:

Flag (0x06)

ModeBytes after u16 flag_id
MAINFormula (assignment to flag)
LHS, RHSu8 operator byte
PARAMETERu16 padding (always 0x0000)
FORMULA(none)

LocalVar (0x0A)

ModeBytes after u8 var_index
MAINFormula (assignment to variable)
LHS, RHSu8 operator byte
PARAMETER3 padding bytes (0x000000)
FORMULA(none)

ObjectDot (0x14)

ModeBytes after object encoding + u16 ref_id
MAINFormula (assignment to property)
LHSu8 operator byte
Other(none)

Object Name Encoding

Used by opcodes 0x0F, 0x10, 0x14, 0x19, 0x1A. A leading byte selects the object target:

ByteAdditionalObject
0x00u8 paddingMe — the script’s own actor
0x01u8 paddingPlayer — Cyrus
0x02u8 paddingCamera
0x04u8 string indexNamed object from per-script string table (RASB/RAST)
0x0Au8 var indexObject name from local variable

Reference Name Encoding

After object name in property/method opcodes: u16 reference ID. Only the low byte is used as an index into the global references table (loaded from [refs] section of SOUP386.DEF). The high byte is discarded.

Operators

Arithmetic Operators

Assignments use a terminated list of (value, operator) pairs. The formula loop reads a value (any value-producing opcode in FORMULA mode), then an operator byte, repeating until the terminator.

ByteOperatorArityEngine instruction
0; (end)(return result)
1+binaryadd eax, ebx
2-binarysub eax, ebx
3*binaryimul ebx (signed)
4/binarydiv ebx (unsigned, zero-check via CPU trap)
5<<binaryshl eax, cl
6>>binarysar eax, cl (arithmetic/signed)
7&binaryand eax, ebx
8|binaryor eax, ebx
9^binaryxor eax, ebx
10++unary(increment; terminates formula)
11--unary(decrement; terminates formula)

Operator bytes outside 1–9 terminate the formula loop. Bytes 10–11 (unary increment/decrement) are consumed as the final operator and also terminate. Bytes 12+ are fatal. Division by zero is not checked in software — it raises a CPU divide-by-zero exception caught by the C runtime signal handler.

Note: The Dillonn241 disassembler/assembler tools swap operators 3 and 4 (/ and *). This swap is internally consistent within those tools (scripts round-trip correctly) but does not match the engine binary, where byte 3 maps to imul (multiply) and byte 4 maps to div (divide). The RGUnity runtime implementation also confirms byte 3 = multiply, byte 4 = divide.

Comparison Operators

Used in if conditions. Each comparison pairs a LHS value, comparison byte, and RHS value.

ByteOperator
0= (equal)
1!=
2<
3>
4<=
5>=

Conjunctions

Multiple comparisons in a single if are chained with conjunction bytes:

ByteMeaning
0End of condition list
1and
2or

Conditions are evaluated left-to-right with no operator precedence — each conjunction folds the running boolean with the next comparison result.

Control Flow

Conditional Branch (If — 0x03)

Evaluates one or more comparisons chained with and/or. If the condition is false, the PC jumps to end_offset (absolute). If true, execution falls through into the inline block.

[0x03]
  repeat:
    [value: LHS mode]
    [u8 comparison]
    [value: RHS mode]
    [u8 conjunction]       // 0 = end, 1 = and, 2 = or
  until conjunction == 0
[u32 end_offset LE]        // false-branch target (absolute)
[block of instructions]    // executed when true; ends at end_offset

Goto (0x04)

Unconditional jump. Sets PC to the u32 target address (absolute).

End (0x05)

Reads a u32 target offset, sets PC to that address, and signals script termination (returns 0xDEAD sentinel to the run loop).

Gosub / Return (0x11, 0x12)

Gosub pushes the current PC onto the per-thread call stack, then jumps to the target address. Return pops the return address and resumes. Subroutines share the same local variable scope — no parameters are passed through the call stack.

Endint (0x13)

Resets the secondary thread’s PC to 0x00 and signals termination (0xDEAD). Used to end thread 1’s current activation while leaving thread 0 running.

Function Dispatch

Call Types

SOUP386 distinguishes three call types, encoded in the opcode byte:

TypeBehaviorScript syntax
TaskBlocking — script waits for completionFunctionName(...)
MultitaskAsynchronous — script continues immediately@FunctionName(...)
FunctionImmediate — returns a valueFunctionName(...) (context determines)

Self-calls use opcodes 0x00/0x01/0x02. Object-targeted calls use 0x19/0x1A with an additional dispatch-type byte (0x00 = task, 0x01 = multitask, 0x02 = function) when used as a top-level statement. In non-MAIN modes, the dispatch-type byte is absent and the call is always treated as a function.

Call Encoding

[u16 func_id LE]     // index into SOUP386.DEF function table
[u8 param_count]     // number of parameters (0 if func_id == 0)
[param_count × value in PARAMETER mode]

Function index 0 is always NullFunction (synthetic; prepended by the parser, not present in SOUP386.DEF). The func_id is multiplied by the function-table entry stride (49 bytes) to index the runtime table.

Parameter Type Overrides

When a Numeric (0x07) or NumericAlt (0x16) opcode appears in PARAMETER mode, the 4-byte value may be reinterpreted based on the calling function:

FunctionsTypeEncoding
ACTIVATE, AddLog, AmbientRtx, menuAddItem, RTX, rtxAnim, RTXp, RTXpAnim, TorchActivateDialogue key4-byte ASCII string (RTX lookup key)
LoadWorldMap IDi32 map identifier
ActiveItem, AddItem, DropItem, HandItem, HaveItem, SelectItem, ShowItem, ShowItemNoRtxItem IDi32 item identifier
All othersIntegeri32 signed integer

Function Table

SOUP386.DEF declares 367 callable functions (indices 1–367; index 0 is the synthetic NullFunction). Functions span the following categories:

CategoryExamplesCount
MovementMove, WalkForward, MoveToLocation, WanderToLocation~16
Rotation / FacingRotate, RotateByAxis, FacePlayer, FaceAngle, FaceObject~10
CamerashowObj, showPlayer, lookCyrus, showCyrusPan~13
Dialogue / RTXRTX, rtxAnim, menuNew, menuProc, menuAddItem, menuSelection~8
AnimationPlayAnimation, PushAnimation, WaitAnimFrame, SetAction~10
CombatbeginCombat, endCombat, isDead, adjustHealth, shoot, shootPlayer~16
Lighting / FXLight, LightRadius, LightFlicker, FxPhase, FxFlickerOnOff~14
Object controlEnableObject, DisableObject, HideMe, ShowMe, KillMe~12
InventoryAddItem, DropItem, HaveItem, HandItem, ShowItem, SelectItem~9
SoundSound, AmbientSound, EndSound, StopAllSounds~5
Weapon / Attachmenthanditem, displayhandmodel, drawsword, sheathsword~11
Spatial queriesInRectangle, InCircle, DistanceFromStart, AtPos~5
Flat (billboards)Flat, FlatSetTexture, FlatAnimate, FlatOff~7
AISetAiType, SetAiMode, Guard, Animal~5
Static objectsLoadStatic, UnLoadStatic, PointAt~3
Global flagsSetGlobalFlag, TestGlobalFlag, ResetGlobalFlag3
AttributesSetAttribute, GetAttribute, SetMyAttr, GetMyAttr4
DebugPrintParms, LogParms, PrintStringParm~3

For the weapon/attachment function interface, see Item Attachment — SOUP Script Interface. For the definition-file format and section layout, see SOUP386.DEF.

Global Flags

369 global flags (indices 0–368), shared across all scripts. Each flag has a declared type:

TypeSemantics
BOOLBinary state (0 or 1)
NUMBERInteger counter or timer
FLIPFLOPToggled state (doors, switches, puzzle elements)

Flags are declared in the [flags] section of SOUP386.DEF and accessed by bytecode via SetGlobalFlag, TestGlobalFlag, and ResetGlobalFlag (which use the NumericAlt opcode 0x16 for their flag-ID parameter).

Six flags have non-zero defaults: TimeOfDay (1), OB_TelV (1), OB_TelH (12), At_Shoals (1), Rock_1_Down (1), Rock_2_Down (1).

Flag categories span narrative progression (acts 1–8, for example After_Catacombs, After_League, Won_Game), inventory state (HaveAmulet, HaveGem, Equipped_Torch), NPC dialogue tracking (DreekiusTalk, TobiasTalk, SionaFriend), puzzle mechanics for catacombs (CTDoor*, CTWeight), caverns (CV_Lock*, CV_Pillar*), observatory (OB_TelV, OB_Platform), palace (PI_Door*, PI_Throne*), dwarven ruins (DR_Steam, DR_Boiler, DR_Pipe*), and the scarab (SCB_Position, SCB_ArmL, SCB_ArmR), as well as runtime control (Talking, MenuRet, StrengthTimer, MapTimer).

Relationship to RGM Sections

  • RAHD stores per-actor script pointers, variable counts, and function-table indices.
  • RASC stores compiled script bytecode as a contiguous blob.
  • RAST/RASB store script string literal data and offset tables.
  • RAVA stores initial local variable values (i32 array).
  • RAHK stores hook entry offsets rebased against script base addresses.
  • RAAT stores per-actor attribute tables (256 bytes each), addressed by names from SOUP386.DEF.

See RGM.md for record-level layouts and offsets.

Open Questions

  • TaskPause (0x1B) semantics are not fully understood — the RGUnity implementation is a stub (“TODO: do the pause somehow; also whats the taskval?”).
  • ScriptRV (0x1E) branches on a script return value whose source is unknown — RGUnity comment: “TODO: where does the return val come from?”
  • The Anchor opcode (0x17) has an encoding discrepancy between the disassembler (reads 0x17 then a byte) and assembler (writes the anchor value directly as the opcode byte).
  • The operator byte consumed after flag/variable reads in LHS/RHS mode has unclear effect — RGUnity marks it “TODO: does this operator do anything?”
  • Some SOUP API surface does not appear in shipped RGM scripts; usage may be limited to .AI flows that were available during development but not included in the final release.

External References