|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "Reverse Engineering Harvester with Ghidra and Codex - Part 6: Timers" |
| 4 | +date: 2026-04-14 06:24:07 -0400 |
| 5 | +comments: true |
| 6 | +categories: Programming |
| 7 | +tags: [programming, reverse-engineering, scummvm, ghidra] |
| 8 | +image: /images/ghidra1/harvester_reverse_engineering_banner_1200x600.png |
| 9 | +series: reverse_engineering_harvester |
| 10 | +--- |
| 11 | +{% series_nav %} |
| 12 | + |
| 13 | +<style> |
| 14 | +.content pre, .content pre code { |
| 15 | +white-space: pre-wrap !important; |
| 16 | +word-break: break-word !important; |
| 17 | +overflow-wrap: break-word !important; |
| 18 | +overflow-x: hidden !important; |
| 19 | +} |
| 20 | + |
| 21 | +.highlight { |
| 22 | +overflow-x: visible !important; |
| 23 | +} |
| 24 | +</style> |
| 25 | + |
| 26 | +The Harvester game engine supports time-based functionality through the same scripting system that drives room transitions, object interactions, NPC state changes, and cutscenes. |
| 27 | + |
| 28 | +While re-implementing the engine, one of the main challenges has been figuring out how to debug those systems in a way that gives future Codex prompts useful context. Logs are great after something fires, but timers have a different failure mode: you often need to know whether a timer exists, whether it is currently enabled, how much time is left, and what command chain it will run before it expires. |
| 29 | + |
| 30 | +That is especially important in Harvester because timers are not just visual delays. They can damage the player, monsterfy NPCs, advance dialogue staging, unlock doors, and trigger authored room events. |
| 31 | + |
| 32 | +## Timer records in `HARVEST.SCR` |
| 33 | + |
| 34 | +In [part 4]({% post_url 2026-03-23-reverse-engineering-harvester-with-ghidra-and-codex-part-4-command-opcodes %}) I covered the command-opcode side of the script. Timers sit beside those `COMMAND` records as their own world records. Once `HARVEST.SCR` is XOR-decoded, a timer looks like this: |
| 35 | + |
| 36 | +```text |
| 37 | +initialSeconds TIMER timerName roomName actionTag enabled looping global |
| 38 | +``` |
| 39 | + |
| 40 | +For example: |
| 41 | + |
| 42 | +```text |
| 43 | +133 TIMER "ACID_TIMER3" "EYEHALL" "HURT_PC_ACIDA" "F" "F" "T" |
| 44 | +266 TIMER "ACID_TIMER2" "EYEHALL" "HURT_PC_ACIDB" "F" "F" "T" |
| 45 | +400 TIMER "ACID_TIMER" "EYEHALL" "KILL_PC_ACID" "F" "F" "T" |
| 46 | +``` |
| 47 | + |
| 48 | +The fields map cleanly to the runtime data structure: |
| 49 | + |
| 50 | +| Field | Meaning | |
| 51 | +| --- | --- | |
| 52 | +| `initialSeconds` | Countdown length in seconds. The runtime schedules it against a centisecond clock by multiplying this value by `100`. | |
| 53 | +| `timerName` | Stable lookup key. `SET_TIMER` and `KILL_TIMER` refer to this name. | |
| 54 | +| `roomName` | Room/scope key. Room setup materializes timer entities whose room matches the current room. | |
| 55 | +| `actionTag` | Command-chain entry point to execute when the timer expires. | |
| 56 | +| `enabled` | `T` if the timer starts enabled when the room is built. | |
| 57 | +| `looping` | `T` if the timer should restart after firing. The decoded script I checked currently uses one-shot timers. | |
| 58 | +| `global` | `T` if the live timer entity should be preserved across room transitions. | |
| 59 | + |
| 60 | +The decoded script contains 76 real `TIMER` records. Most are initially disabled and are started by a nearby `COMMAND`, but a few start enabled as part of room setup. |
| 61 | + |
| 62 | +At runtime, these timers are materialized as invisible runtime entities. That turned out to be an important detail: timer state is not only a field in the parsed script record. A live countdown exists in the room entity list, and global timers can survive room changes by preserving that live entity instead of destroying it with the rest of the room. |
| 63 | + |
| 64 | +When a timer entity expires, the room loop records the expired timer name, resolves the backing `TimerRecord`, and dispatches the record's `actionTag`. In other words, the timer name is just the lookup key. The action is whatever command chain is stored in the timer record. |
| 65 | + |
| 66 | +## Starting and stopping timers |
| 67 | + |
| 68 | +Script command chains control timers with `SET_TIMER` and `KILL_TIMER`: |
| 69 | + |
| 70 | +```text |
| 71 | +COMMAND "SET_HALL_TIMER" "SET_TIMER" "ACID_TIMER" "ON" "" "KILL_TRIG_TIMER" |
| 72 | +COMMAND "SET_HALL_2TIMER" "SET_TIMER" "ACID_TIMER2" "ON" "" "SET_HALL_2TIMER2" |
| 73 | +COMMAND "SET_HALL_2TIMER2" "SET_TIMER" "ACID_TIMER3" "ON" "" "" |
| 74 | +``` |
| 75 | + |
| 76 | +`SET_TIMER ... ON` enables a timer. If the timer was previously disabled, the current value is reset back to the initial value. `SET_TIMER ... OFF` and `KILL_TIMER` disable it. |
| 77 | + |
| 78 | +The subtle part is that timer commands often live in longer command chains. They are not isolated API calls. Starting a timer might be one step in a room-entry sequence, and the timer's expiry might run another command chain that branches on flags, shows text, adjusts HP, or changes rooms. |
| 79 | + |
| 80 | + |
| 81 | +_In the `MAINHALL` with `DEBUG_TIMERS` and `DEBUG_ROOM` toggled_ |
| 82 | + |
| 83 | +A good example is when you enter the `MAINHALL` on Disc 3, because it uses multiple global timers with staggered deadlines. |
| 84 | + |
| 85 | +```text |
| 86 | +133 TIMER "ACID_TIMER3" "EYEHALL" "HURT_PC_ACIDA" "F" "F" "T" |
| 87 | +266 TIMER "ACID_TIMER2" "EYEHALL" "HURT_PC_ACIDB" "F" "F" "T" |
| 88 | +400 TIMER "ACID_TIMER" "EYEHALL" "KILL_PC_ACID" "F" "F" "T" |
| 89 | +
|
| 90 | +COMMAND "HURT_PC_ACIDA" "KILL_TIMER" "ACID_TIMER3" "" "" "HURT_PC_ACID" |
| 91 | +COMMAND "HURT_PC_ACIDB" "KILL_TIMER" "ACID_TIMER3" "" "" "HURT_PC_ACID" |
| 92 | +COMMAND "HURT_PC_ACID" "CHECK_FLAG" "CLEANED_CLOTHES" "" "HURT_PC_ACID_1" "" |
| 93 | +COMMAND "HURT_PC_ACID_1" "SHOW_TEXT" "ACID_TEXT2" "" "" "HURT_PC_ACID_2" |
| 94 | +COMMAND "HURT_PC_ACID_2" "ADJ_HP" "-7" "" "" "" |
| 95 | +``` |
| 96 | + |
| 97 | +The first two timers are warning/damage stages. When they expire, they enter `HURT_PC_ACID`, which checks the `CLEANED_CLOTHES` flag. If the clothes have not been cleaned, the game shows acid text and subtracts 7 HP. If the flag has been set, the branch target is empty and the command chain stops. |
| 98 | + |
| 99 | +The last timer, `ACID_TIMER`, is the hard fail path: |
| 100 | + |
| 101 | +```text |
| 102 | +400 TIMER "ACID_TIMER" "EYEHALL" "KILL_PC_ACID" "F" "F" "T" |
| 103 | +``` |
| 104 | + |
| 105 | +Cleaning the clothes is itself just another scripted interaction. Using the money on the cloakroom attendant starts `CLEAN_CLOTHES`, which shows text, sets the flag, removes the money, and disables the lethal acid timer: |
| 106 | + |
| 107 | +```text |
| 108 | +USEITEM "BARCASHFIVE" "CLOAKROOM" "CLOAK_ATND" "CLEAN_CLOTHES" |
| 109 | +
|
| 110 | +COMMAND "CLEAN_CLOTHES" "SHOW_TEXT" "CLEANED_CLOTHS" "" "" "CLEAN_CLOTHES_1" |
| 111 | +COMMAND "CLEAN_CLOTHES_1" "SET_FLAG" "CLEANED_CLOTHES" "T" "" "CLEAN_CLOTHES_2" |
| 112 | +COMMAND "CLEAN_CLOTHES_2" "DELETE" "CLOAKROOM" "BARCASHFIVE" "" "CLEAN_CLOTHES_3" |
| 113 | +COMMAND "CLEAN_CLOTHES_3" "DELETE" "INVENTORY" "BARCASHFIVE" "" "CLEAN_CLOTHES_4" |
| 114 | +COMMAND "CLEAN_CLOTHES_4" "SET_TIMER" "ACID_TIMER" "OFF" "" "CLEAN_CLOTHES_5" |
| 115 | +COMMAND "CLEAN_CLOTHES_5" "KILL_TIMER" "ACID_TIMER" "" "" "" |
| 116 | +``` |
| 117 | + |
| 118 | +This is the kind of script graph that is hard to reason about from static records alone. Some of the authored data is also a little odd: both intermediate acid chains kill `ACID_TIMER3`, even though the second one is entered by `ACID_TIMER2`. Watching the live timers makes it much easier to tell whether that is harmless authored data, a reimplementation bug, or a broken state sync. |
| 119 | + |
| 120 | +## Adding a timer overlay |
| 121 | + |
| 122 | +To make this easier to debug, I asked Codex to add a console command that would render active timers directly over the room: |
| 123 | + |
| 124 | +> *the game manages timers periodically. I want to introduce a DEBUG_TIMERS command that when enabled it will overlay text on the screen that will be the timer name, starting value, current value, action to take when timer expires.* |
| 125 | +> |
| 126 | +> *If multiple timers are active, they should appear one after the other. Draw this near the middle-left of the screen. It should be white text on a black background* |
| 127 | +{: .prompt-tip } |
| 128 | + |
| 129 | +Instead of wiring this up myself, Codex was able to do it in about 5 minutes. |
| 130 | + |
| 131 | + |
| 132 | + |
| 133 | +The implementation is intentionally small: |
| 134 | + |
| 135 | +- `DEBUG_TIMERS` toggles a boolean on the Harvester engine. |
| 136 | +- The room renderer checks that boolean after drawing the room and other debug overlays. |
| 137 | +- The overlay walks the known timer records, finds matching live timer entities, filters out disabled timers, and formats each row as `name start=initial current=current action=tag`. |
| 138 | +- Text is drawn near the middle-left of the screen using white text over a black rectangle. |
| 139 | + |
| 140 | +This means the overlay is showing live runtime state, not just parsed script data. If a timer is missing from the live entity list, disabled, or no longer counting down, it disappears. |
| 141 | + |
| 142 | +The label format is deliberately boring: |
| 143 | + |
| 144 | +```text |
| 145 | +ACID_TIMER3 start=133 current=128 action=HURT_PC_ACIDA |
| 146 | +ACID_TIMER2 start=266 current=261 action=HURT_PC_ACIDB |
| 147 | +ACID_TIMER start=400 current=395 action=KILL_PC_ACID |
| 148 | +``` |
| 149 | + |
| 150 | +That is exactly the information I need while debugging: what is active, how long it has left, and what will happen when it fires. |
| 151 | + |
| 152 | +<video width="640" height="480" controls> |
| 153 | + <source src="/images/ghidra6/harvester-timers.mp4" type="video/mp4"> |
| 154 | + Your browser does not support the video tag |
| 155 | +</video> |
| 156 | + |
| 157 | +Having a visual indicator for timer progress, and whether those timers were triggering the correct actions, made it much easier to discover some more obscure bugs. |
| 158 | + |
| 159 | +These log lines are all coming from the game's scripts. In this run, the clothes have been cleaned, so the acid timers still reach their action chains, but the `CLEANED_CLOTHES` flag prevents the HP penalty path from continuing. |
| 160 | + |
| 161 | +```text |
| 162 | +Harvester: action tag 'CLEAN_CLOTHES' step=0 tag='CLEAN_CLOTHES' opcode='SHOW_TEXT' args=['CLEANED_CLOTHS','','','CLEAN_CLOTHES_1'] |
| 163 | +Harvester: action tag 'CLEAN_CLOTHES_1' step=0 tag='CLEAN_CLOTHES_1' opcode='SET_FLAG' args=['CLEANED_CLOTHES','T','','CLEAN_CLOTHES_2'] |
| 164 | +Harvester: action tag 'CLEAN_CLOTHES_1' SET_FLAG 'CLEANED_CLOTHES' 0 -> 1 existed=1 changed=1 |
| 165 | +Harvester: action tag 'CLEAN_CLOTHES_1' step=1 tag='CLEAN_CLOTHES_2' opcode='DELETE' args=['CLOAKROOM','BARCASHFIVE','','CLEAN_CLOTHES_3'] |
| 166 | +Harvester: action tag 'CLEAN_CLOTHES_1' step=2 tag='CLEAN_CLOTHES_3' opcode='DELETE' args=['INVENTORY','BARCASHFIVE','','CLEAN_CLOTHES_4'] |
| 167 | +Harvester: action tag 'CLEAN_CLOTHES_1' step=3 tag='CLEAN_CLOTHES_4' opcode='SET_TIMER' args=['ACID_TIMER','OFF','','CLEAN_CLOTHES_5'] |
| 168 | +Harvester: action tag 'CLEAN_CLOTHES_1' step=4 tag='CLEAN_CLOTHES_5' opcode='KILL_TIMER' args=['ACID_TIMER','','',''] |
| 169 | +
|
| 170 | +Harvester: timer command 'ACID_TIMER3' step=0 tag='HURT_PC_ACIDA' opcode='KILL_TIMER' args=['ACID_TIMER3','','','HURT_PC_ACID'] |
| 171 | +Harvester: timer command 'ACID_TIMER3' step=1 tag='HURT_PC_ACID' opcode='CHECK_FLAG' args=['CLEANED_CLOTHES','','HURT_PC_ACID_1',''] |
| 172 | +Harvester: timer command 'ACID_TIMER3' flag 'CLEANED_CLOTHES' -> 1 |
| 173 | +
|
| 174 | +Harvester: timer command 'ACID_TIMER2' step=0 tag='HURT_PC_ACIDB' opcode='KILL_TIMER' args=['ACID_TIMER3','','','HURT_PC_ACID'] |
| 175 | +Harvester: timer command 'ACID_TIMER2' step=1 tag='HURT_PC_ACID' opcode='CHECK_FLAG' args=['CLEANED_CLOTHES','','HURT_PC_ACID_1',''] |
| 176 | +Harvester: timer command 'ACID_TIMER2' flag 'CLEANED_CLOTHES' -> 1 |
| 177 | +``` |
| 178 | + |
| 179 | +The overlay was also useful outside this one hallway. Some NPC state changes are timer-driven too: |
| 180 | + |
| 181 | +```text |
| 182 | +COMMAND "START_INQ_TIM" "SET_TIMER" "INQUIST_ATTACK_TIMER" "ON" "" "" |
| 183 | +120 TIMER "INQUIST_ATTACK_TIMER" "PAIN" "MNSTFY_INQUIST" "F" "F" "F" |
| 184 | +
|
| 185 | +COMMAND "START_MERCY_TIMR" "SET_TIMER" "GLADIATOR_TIMER" "ON" "" "" |
| 186 | +30 TIMER "GLADIATOR_TIMER" "MERCY" "MNST_GLAD" "F" "F" "F" |
| 187 | +``` |
| 188 | + |
| 189 | +Those are awkward to verify with logs alone because nothing visible happens until the timer expires. With `DEBUG_TIMERS` enabled, I can start the dialogue branch, see the countdown appear, wait for it to hit zero, and then check whether the expected monsterfy command chain ran. |
| 190 | + |
| 191 | +That has become the general debugging pattern for this engine work: use Ghidra and the decoded script to understand the original data model, add small runtime instrumentation when the model is too indirect to observe comfortably, then feed the resulting logs, screenshots, and videos back into the next prompt. |
0 commit comments