Post

Reverse Engineering Harvester with Ghidra and Codex - Part 4: Command Opcodes

Reverse Engineering Harvester with Ghidra and Codex - Part 4: Command Opcodes
Series: Reverse Engineering Harvester

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

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:

Command Labels

triggerTag

triggerTag is the label attached to one COMMAND record. It is the string used to find that record later.

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() initializes currentTag from the caller-supplied starting tag.
  • It then resolves the current command with findCommandRecord(currentTag).
  • After each opcode runs, currentTag is updated to the next label:
    • branch opcodes like CHECK_FLAG and CHECK_PERC set it from arg2 or arg3
    • most linear opcodes continue to arg4
    • deferred opcodes may stash arg4 as a continuation tag and return to the caller instead of immediately continuing

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:

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

  1. executeCommandChain() starts with currentTag = "OPEN_GATE".
  2. findCommandRecord("OPEN_GATE") resolves the first line because its triggerTag is "OPEN_GATE".
  3. SET_FLAG runs and then sets currentTag = arg4, which is "OPEN_GATE_TEXT".
  4. findCommandRecord("OPEN_GATE_TEXT") resolves the second line.
  5. SHOW_TEXT runs. 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":

  1. currentTag starts as "TRY_SHED".
  2. CHECK_FLAG looks up HAS_SHED_KEY.
  3. If the flag is true, currentTag becomes arg2, so the next lookup is "SHED_OPEN".
  4. If the flag is false, currentTag becomes arg3, 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":

  1. GOFLIC does not immediately jump to "POTTS_AFTER_MOVIE".
  2. Instead, it stores arg4 as a continuation tag and returns the movie request to the caller.
  3. 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

OpcodeArgs usedEffectStatus / notes
CHANGE_CDarg1=cdNumberChange CDNot Implemented
CHECK_FLAGarg1=flagName, arg2=trueTag, arg3=falseTagBranches on the current runtime value of a flag. Missing flags read as false.Implemented
CHECK_PERCarg1=threshold, arg2=trueTag, arg3=falseTagRolls 0..99 and branches on roll < threshold. Threshold is clamped to 0..100.Implemented
EXEC_LISTarg1=listName, arg4=nextTagRuns each entry tag in an EXEC_LIST record until one produces deferred output, then stops. Otherwise continues to arg4.Implemented
START_DIALOGarg1=npcName, arg4=continuationTagDefers 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.
GOFLICarg1=cutscenePath, arg4=continuationTagDefers 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.
GODEATHFLICarg1=cutscenePathDefers 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.
CLOSEUParg1=targetNameRequests a nested room / closeup transition.Implemented with caveat: if transitions are disabled, the opcode is skipped and the chain ends immediately.
CHANGE_ROOMarg1=targetNameRequests 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

OpcodeArgs usedEffectStatus / notes
SET_FLAGarg1=flagName, arg2=value, arg4=nextTagCreates or updates a runtime flag, then continues to arg4.Implemented
SPOOL_MUSICarg1=musicPath, arg4=nextTagDefers a startup music change.Implemented
ADDarg1=ownerOrRoom, arg2=objectName, arg4=nextTagMakes an object visible by setting visible and runtimeVisible true.Implemented. This is a visibility toggle, not an ownership transfer.
DELETEarg1=ownerOrRoom, arg2=objectName, arg4=nextTagMakes an object invisible by setting visible and runtimeVisible false.Implemented
ADD2INVarg1=objectName, arg4=nextTagMoves an object into INVENTORY, makes it visible, and marks it identified.Implemented
SET_ANIMarg1=animName, arg2=active, arg3=visible, arg4=nextTagUpdates a runtime animation’s active / visible state.Implemented
SET_REGIONarg1=regionName, arg2=enabledFlag, arg4=nextTagToggles startEnabled on a region. Any arg2 other than F enables the region.Implemented with caveat: this does not touch cursorEnabled.
SET_NPCarg1=npcName, arg2=active, arg3=visible, arg4=nextTagUpdates a runtime NPC’s active / visible state.Implemented
SET_MONSTERarg1=monsterName, arg2=active, arg3=visible, arg4=nextTagUpdates 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_TIMERarg1=timerName, arg2=ON/OFF, arg4=nextTagEnables or disables a timer. When enabling, resets currentValue to initialValue.Implemented
KILL_TIMERarg1=timerName, arg4=nextTagDisables a timer.Implemented
KILL_NPCarg1=npcName, arg2=damageType, arg4=nextTagMarks an NPC as dead / removed and optionally records damage type BLUDGE, SLASH, or PROJ.Implemented
MONSTERFYarg1=npcName, arg2=damageType, arg4=nextTagUses 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

OpcodeArgs usedEffectStatus / notes
SHOW_TEXTarg1=textKey, arg4=continuationTagResolves 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_PCarg1=delta, arg4=nextTagAdds arg1 to current player HP, clamped to 0..30.Implemented. Current code treats this as the same operation as ADJ_HP.
ADJ_HParg1=delta, arg4=nextTagAdds arg1 to current player HP, clamped to 0..30.Implemented
KILL_PCarg4=nextTagSets player HP to 0.Implemented
PAUSE_PCarg4=nextTagSets the runtime player-control-paused flag.Implemented
RESUME_PCarg4=nextTagClears the runtime player-control-paused flag.Implemented
PC_GOTO_XZarg1=x, arg2=z, arg4=continuationTagDefers 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_LIGHTINGarg1=mode, arg4=continuationTagDefers 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().

OpcodeArgs usedEffectStatus / notes
START_WAVarg1=path, arg4=nextTagPlays a sound effect on one of eight rotating SFX handles.Implemented
START_SINGLE_WAVarg1=path, arg4=nextTagPlays a sound effect on one dedicated “single” SFX handle, replacing the prior one.Implemented
LOAD_WAVarg1=path, arg2=slot, arg4=nextTagLoads a sound into a persistent slot for later playback.Implemented. Valid loaded-sound slots are 0..3.
PLAY_WAVarg1=slot, arg4=nextTagPlays a sound previously loaded by LOAD_WAV.Implemented
DELETE_WAVarg1=slot, arg4=nextTagDeletes a sound previously loaded by LOAD_WAV.Implemented

Observed Engine-Side Aliases And Shared Paths

  • HEAL_PC and ADJ_HP currently share the exact same implementation.
  • KILL_NPC and MONSTERFY share the same base handler; MONSTERFY additionally activates the linked monster target when present.
  • CLOSEUP and CHANGE_ROOM share the same transition-output path, differing only in the transition kind reported to room logic.
This post is licensed under CC BY 4.0 by the author.