Reverse Engineering Harvester with Ghidra and Codex - Part 4: Command Opcodes
Series: Reverse Engineering HarvesterThis review is part of the Reverse Engineering Harvester series, where I document my journey of reverse engineering the 1996 DOS game Harvester to re-implement its game engine in ScummVM.
- ← Reverse Engineering Harvester with Ghidra and Codex - Part 3: File Formats
- Reverse Engineering Harvester with Ghidra and Codex - Part 4: Command Opcodes
Article 4 of 4 in this series.
Harvester’s startup / world script is not bytecode. It is XOR-obfuscated text, and opcode dispatch happens through COMMAND records in HARVEST.SCR:
1
COMMAND triggerTag opcodeName arg1 arg2 arg3 [arg4]
In the original game and in ScummVM, these opcode names come from the data pipeline, not from a compiled bytecode table:
- In ScummVM,
Script::load()readsHARVEST.SCR,decode()XOR-deobfuscates it, andparseTownRecords()turns it into typed startup records. - Within
parseTownRecords(),COMMANDlines are parsed intoStartupCommandRecordentries alongsideROOM,OBJECT,REGION,TIMER,USEITEM, and related world records. - Those other records provide the entry labels for command chains through room setup/exit, interactions, and timers.
findCommandRecord()andexecuteCommandChain()resolve the current label to aCOMMANDrecord, decode the opcode name on that line, and dispatch into engine handlers for room flow, media, inventory, actor state, and other subsystems.
Command Labels
triggerTag
triggerTag is the label attached to one COMMAND record. It is the string used to find that record later.
- The parser stores it from the token immediately after
COMMANDinparseTownRecords(). Script::findCommandRecord()resolves a command by comparing the requested tag string againstcommand.triggerTag.
So triggerTag is not a condition and not an opcode argument in the behavioral sense. It is the command node’s name.
currentTag
currentTag is the interpreter’s working variable while it walks a command chain.
executeCommandChain()initializescurrentTagfrom the caller-supplied starting tag.- It then resolves the current command with
findCommandRecord(currentTag). - After each opcode runs,
currentTagis updated to the next label:- branch opcodes like
CHECK_FLAGandCHECK_PERCset it fromarg2orarg3 - most linear opcodes continue to
arg4 - deferred opcodes may stash
arg4as a continuation tag and return to the caller instead of immediately continuing
- branch opcodes like
If you think of the script as a graph, triggerTag is the node name stored in the file, and currentTag is the interpreter’s current node pointer.
Where Starting Tags Come From
The interpreter does not enter command chains automatically just because a COMMAND record exists. Some other game record must point at its label.
Common entry points in the current engine:
- object interaction uses
object.actionTag - region interaction uses
region.actionTag - use-item interaction uses
useItem.actionTag - room enter / exit uses
room->onEnterCommandandroom->onExitCommand - timer execution starts from
timer->arg2
That means a more precise reading of the format is:
1
COMMAND label opcodeName arg1 arg2 arg3 [nextLabel]
with the caveat that arg2, arg3, and arg4 are opcode-specific, so only some of them are actually labels for a given opcode.
Examples
Example 1: straight-line chain
1
2
COMMAND "OPEN_GATE" "SET_FLAG" "GATE_OPEN" "T" "" "OPEN_GATE_TEXT"
COMMAND "OPEN_GATE_TEXT" "SHOW_TEXT" "Gate_Is_Open" "" "" ""
If an object’s actionTag is "OPEN_GATE":
executeCommandChain()starts withcurrentTag = "OPEN_GATE".findCommandRecord("OPEN_GATE")resolves the first line because itstriggerTagis"OPEN_GATE".SET_FLAGruns and then setscurrentTag = arg4, which is"OPEN_GATE_TEXT".findCommandRecord("OPEN_GATE_TEXT")resolves the second line.SHOW_TEXTruns. Because it is deferred, the interpreter returns control to the caller instead of continuing immediately.
Example 2: branch on a flag
1
2
3
COMMAND "TRY_SHED" "CHECK_FLAG" "HAS_SHED_KEY" "SHED_OPEN" "SHED_LOCKED"
COMMAND "SHED_OPEN" "CHANGE_ROOM" "SHED_INT" "" "" ""
COMMAND "SHED_LOCKED" "SHOW_TEXT" "Need_A_Key" "" "" ""
If the chain starts at "TRY_SHED":
currentTagstarts as"TRY_SHED".CHECK_FLAGlooks upHAS_SHED_KEY.- If the flag is true,
currentTagbecomesarg2, so the next lookup is"SHED_OPEN". - If the flag is false,
currentTagbecomesarg3, so the next lookup is"SHED_LOCKED".
So here the first COMMAND line is acting like a named branch node.
Example 3: deferred opcode with continuation
1
2
COMMAND "POTTS_EVENT" "GOFLIC" "GRAPHIC/FST/C001B.FST" "" "" "POTTS_AFTER_MOVIE"
COMMAND "POTTS_AFTER_MOVIE" "SET_FLAG" "STEPH_MIDGAME_PLAYED" "T" "" ""
When currentTag reaches "POTTS_EVENT":
GOFLICdoes not immediately jump to"POTTS_AFTER_MOVIE".- Instead, it stores
arg4as a continuation tag and returns the movie request to the caller. - After the cutscene finishes, room/dialogue code can resume by starting another command-chain execution at
"POTTS_AFTER_MOVIE".
That is why arg4 is often best read as “the next tag after this opcode completes”, not just “the next line”.
Most of the opcode recognition below lives in Script::executeCommandChain(), while deferred outputs such as modal text, dialogue continuations, lighting changes, player moves, and follow-up tags are consumed by the room interaction processor in room.cpp.
Control Flow And Transitions
| Opcode | Args used | Effect | Status / notes |
|---|---|---|---|
CHANGE_CD | arg1=cdNumber | Change CD | Not Implemented |
CHECK_FLAG | arg1=flagName, arg2=trueTag, arg3=falseTag | Branches on the current runtime value of a flag. Missing flags read as false. | Implemented |
CHECK_PERC | arg1=threshold, arg2=trueTag, arg3=falseTag | Rolls 0..99 and branches on roll < threshold. Threshold is clamped to 0..100. | Implemented |
EXEC_LIST | arg1=listName, arg4=nextTag | Runs each entry tag in an EXEC_LIST record until one produces deferred output, then stops. Otherwise continues to arg4. | Implemented |
START_DIALOG | arg1=npcName, arg4=continuationTag | Defers into the room/dialogue system and resumes at arg4 after the dialogue finishes. | Implemented with caveat: if no dialogue context is supplied, the interpreter logs an unsupported-command message and aborts the current chain. |
GOFLIC | arg1=cutscenePath, arg4=continuationTag | Defers a cutscene and stores arg4 as the continuation tag to run after the movie. | Implemented with caveat: if no cutscene output slot is provided, the interpreter logs and continues to arg4 without playing a movie. |
GODEATHFLIC | arg1=cutscenePath | Defers a death movie and requests a return to the main menu. | Implemented with caveat: requires menu-exit context. Without it, the interpreter logs an unsupported-command message and aborts the current chain. If transitions are disabled, it logs a skipped transition and returns. |
CLOSEUP | arg1=targetName | Requests a nested room / closeup transition. | Implemented with caveat: if transitions are disabled, the opcode is skipped and the chain ends immediately. |
CHANGE_ROOM | arg1=targetName | Requests a room handoff. In room gameplay, this queues the next room instead of nesting immediately. | Implemented with caveat: if transitions are disabled, the opcode is skipped and the chain ends immediately. |
World And Runtime State
| Opcode | Args used | Effect | Status / notes |
|---|---|---|---|
SET_FLAG | arg1=flagName, arg2=value, arg4=nextTag | Creates or updates a runtime flag, then continues to arg4. | Implemented |
SPOOL_MUSIC | arg1=musicPath, arg4=nextTag | Defers a startup music change. | Implemented |
ADD | arg1=ownerOrRoom, arg2=objectName, arg4=nextTag | Makes an object visible by setting visible and runtimeVisible true. | Implemented. This is a visibility toggle, not an ownership transfer. |
DELETE | arg1=ownerOrRoom, arg2=objectName, arg4=nextTag | Makes an object invisible by setting visible and runtimeVisible false. | Implemented |
ADD2INV | arg1=objectName, arg4=nextTag | Moves an object into INVENTORY, makes it visible, and marks it identified. | Implemented |
SET_ANIM | arg1=animName, arg2=active, arg3=visible, arg4=nextTag | Updates a runtime animation’s active / visible state. | Implemented |
SET_REGION | arg1=regionName, arg2=enabledFlag, arg4=nextTag | Toggles startEnabled on a region. Any arg2 other than F enables the region. | Implemented with caveat: this does not touch cursorEnabled. |
SET_NPC | arg1=npcName, arg2=active, arg3=visible, arg4=nextTag | Updates a runtime NPC’s active / visible state. | Implemented |
SET_MONSTER | arg1=monsterName, arg2=active, arg3=visible, arg4=nextTag | Updates a runtime monster’s active / visible state. | Implemented with nuance: activating a monster forces visibility on and restores HP if the monster was dead. |
SET_TIMER | arg1=timerName, arg2=ON/OFF, arg4=nextTag | Enables or disables a timer. When enabling, resets currentValue to initialValue. | Implemented |
KILL_TIMER | arg1=timerName, arg4=nextTag | Disables a timer. | Implemented |
KILL_NPC | arg1=npcName, arg2=damageType, arg4=nextTag | Marks an NPC as dead / removed and optionally records damage type BLUDGE, SLASH, or PROJ. | Implemented |
MONSTERFY | arg1=npcName, arg2=damageType, arg4=nextTag | Uses the same death/monsterfy flagging path as KILL_NPC, and also activates the NPC’s linked monster target when one exists. | Implemented |
Player And UI
| Opcode | Args used | Effect | Status / notes |
|---|---|---|---|
SHOW_TEXT | arg1=textKey, arg4=continuationTag | Resolves a TEXT record and defers modal text display. | Implemented with caveat: rendering currently requires BOX1..BOX4. Unknown text boxes log and do not display. |
HEAL_PC | arg1=delta, arg4=nextTag | Adds arg1 to current player HP, clamped to 0..30. | Implemented. Current code treats this as the same operation as ADJ_HP. |
ADJ_HP | arg1=delta, arg4=nextTag | Adds arg1 to current player HP, clamped to 0..30. | Implemented |
KILL_PC | arg4=nextTag | Sets player HP to 0. | Implemented |
PAUSE_PC | arg4=nextTag | Sets the runtime player-control-paused flag. | Implemented |
RESUME_PC | arg4=nextTag | Clears the runtime player-control-paused flag. | Implemented |
PC_GOTO_XZ | arg1=x, arg2=z, arg4=continuationTag | Defers a player reposition request in room space. | Implemented with caveat: if no player-move consumer is present, the interpreter logs and continues to arg4 without moving the player. |
CHANGE_LIGHTING | arg1=mode, arg4=continuationTag | Defers a lighting command. Supported parsed modes are DIM, NORMAL, NONE, and FADE_IN. | Implemented with caveats: NONE maps to a black-screen command, not a no-op. FADE_IN is recognized but has no direct room-side effect yet. If no lighting consumer is present, the interpreter logs and continues. |
Audio
The audio opcodes all share the same queueing path through appendStartupAudioCommand() and are later handed off to Flow::executeStartupAudioCommands().
| Opcode | Args used | Effect | Status / notes |
|---|---|---|---|
START_WAV | arg1=path, arg4=nextTag | Plays a sound effect on one of eight rotating SFX handles. | Implemented |
START_SINGLE_WAV | arg1=path, arg4=nextTag | Plays a sound effect on one dedicated “single” SFX handle, replacing the prior one. | Implemented |
LOAD_WAV | arg1=path, arg2=slot, arg4=nextTag | Loads a sound into a persistent slot for later playback. | Implemented. Valid loaded-sound slots are 0..3. |
PLAY_WAV | arg1=slot, arg4=nextTag | Plays a sound previously loaded by LOAD_WAV. | Implemented |
DELETE_WAV | arg1=slot, arg4=nextTag | Deletes a sound previously loaded by LOAD_WAV. | Implemented |
Observed Engine-Side Aliases And Shared Paths
HEAL_PCandADJ_HPcurrently share the exact same implementation.KILL_NPCandMONSTERFYshare the same base handler;MONSTERFYadditionally activates the linked monster target when present.CLOSEUPandCHANGE_ROOMshare the same transition-output path, differing only in the transition kind reported to room logic.
