OP2TestHarness turns the original Outpost 2: Divided Destiny into a measuring instrument. A DLL loaded into the real
game (harness.dll, an op2ext module) plays a mission or a scenario unattended: no shell or clicking, the game speed
uncapped, scripted orders at given ticks, a fixed seed. It writes what happened as files (unit state every tick as
JSONL, telemetry, PNG screenshots) and quits by itself. Any mission DLL works without changes. Runs are deterministic:
the same recipe gives byte-identical output, so a recipe (scenario JSON or script) stands for its recording.
It was written for op2-rs, a reimplementation of the game, which plays the same recipes in its own simulation and compares every field, tick by tick ("Comparing with op2-rs"). It works for any reimplementation or tool that can read the outputs. ARCHITECTURE.md has the design, PROVENANCE.md the origin.
Version: 1.0.0 (VERSION). What other projects' recordings rely on (options, script verbs, unit names, output
fields, the scenario format) changes only with a version bump; CHANGELOG.md has the rules and the history.
Releases: https://github.com/leviathan400/OP2TestHarness/releases. Each has two packages,
OP2TestHarness-<VERSION>-mingw.zip (built with make, as op2-rs's recorded runs were) and -msvc.zip (built with
windows\build.bat), which behave identically. Each holds the ten DLLs, the Python driver, the sources and
SHA256SUMS, and comes with a .sha256. CI (GitHub Actions) builds both on every push and checks each is reproducible.
Licence: GPL-3.0 (LICENSE). op2-rs, where the harness was written, is GPL-3.0-or-later.
Windows (native, no Wine; windows/README.md):
windows\build.bat :: build the ten DLLs into build\win (MSVC; or unpack a release)
python -I windows\run_win.py setup :: private copy of the game in game\, harness installed
python -I windows\run_win.py selftest :: make check on Windows
python -I windows\run_win.py run %ORACLE%\scenarios\line_fast.json build\runs\line_fast --shot-every 350
python -I windows\compare.py %ORACLE%\scenarios\cmb_duel_laser.json --tick 200 :: the original vs op2-rsLinux (Wine + Xvfb, the full option set below):
make install # once (and after changing src/): build and install into ../prefix-harness
./run.sh --mission cHello.dll --ticks 6000 --screenshot-every 1500 --telemetry-every 500 --out /tmp/op2run
./run.sh --scenario $ORACLE/scenarios/line_fast.json --out /tmp/op2scn # oracle trace of a scenario
make check # end-to-end self-testRequirements.
- Outpost 2, the GOG release (1.2), with the OPU 1.4.1 update: op2ext (part of OPU) loads
harness.dllas an ini module, and every address insrc/harness.cppis for thatOutpost2.exe. Recordings are of GOG 1.2 + OPU 1.4.1; a different OPU version can change them, so re-record and review the differences before switching. - TethysAPI at commit 141fd47 in
vendor/include/Tethys(git clone https://github.com/OutpostUniverse/TethysAPI.git vendor/include/Tethys, thengit -C vendor/include/Tethys checkout 141fd47). - Windows: Visual Studio 2022 Build Tools (C++), Python 3.11+.
- Linux:
i686-w64-mingw32-g++, Wine (a wow64 prefix), Xvfb, Python 3.11+ (PIL for PNG screenshots).
Where things go. On Linux the Wine prefixes sit beside this folder: ../prefix is the one you play from, and
harness runs use ../prefix-harness, a copy of it (cp -a ../prefix ../prefix-harness), or the absolute path in
OP2_HARNESS_PREFIX. On Windows the harness runs in game\, a copy run_win.py setup makes; the install you play from
is never written to. Run outputs go where --out says; build/, game/ and vendor/ are not committed (they hold
game data or upstream code).
Names in this file. op2-sim, op2-engine, op2-inspect, op2-missionhost and op2-accuracy are op2-rs's tools,
and paths written as op2-rs notes/..., op2-rs docs/..., op2-rs accuracy/... or op2-rs tools/... are in the op2-rs
repository.
Scenarios and recorded runs (ORACLE). The recipes this harness plays (scenario JSON, mission scripts, input and
network scripts) and the findings measured with them stay in op2-rs, in harness/oracle/: they are op2-rs's regression
tests. This file writes that folder as $ORACLE (Linux) or %ORACLE% (Windows), e.g.
ORACLE=~/op2-rs/harness/oracle or set ORACLE=C:\Users\Desktop\GITHUB\op2-rs\harness\oracle. Comments inside
those recipe files write this harness's folder as harness/.
The Linux launcher's options. On Windows, windows/run_win.py run covers scenarios so far (--shot-every,
--no-turbo, --timeout, --bot; the scenario sets ticks, seed, trace and telemetry intervals) and run_win.py selftest is make check; the module itself reads the same OP2H_* settings on both.
| option | default | |
|---|---|---|
--mission NAME |
mission DLL file name, found the same way the game finds it (OPU/maps/*/, game dir) |
|
--scenario FILE |
scenario JSON instead of a mission (see "Scenarios"); exactly one of the two is required | |
--out DIR |
required | output directory; previous outputs in it are replaced |
--ticks N |
3000 | quit after game tick N (100 ticks = 1 mark). 0 = run until the mission ends. Default: the scenario's ticks |
--trace-every N |
0, or 1 with --scenario |
trace.jsonl line every N ticks (sub-pixel unit positions, see below) |
--trace-map |
add map to each trace line: the tiles whose cell type, graphic or wall-or-building bit changed since the previous line, [[x, y, cell, graphic, wall], ...] in file coordinates, row by row (the first line lists none). Default: the scenario's traceMap |
|
--trace-from TICK |
0 | no trace.jsonl lines before TICK, rounded up to a multiple of --trace-every (so the lines kept are those a full trace has), and the last tick as always: a full per-tick --trace-raw trace of a long run reaches 500 MB |
--trace-raw |
add raw MapObject (0x80) and PathContext (0xD4) bytes as hex to each trace unit, and to each ents entry its animation, frame and raw MapObject bytes ([..., px, py, anim, frame, "raw"]; with --draw-every 1 the animation and frame are what was drawn, e.g. a structure a ConVec builds) |
|
--screenshot-every N |
0 (off) | PNG at tick 5, then every multiple of N, plus one at the end |
--telemetry-every N |
100 | JSONL record at tick 1, then every multiple of N, plus one at the end. 0 = off |
--script FILE |
scripted commands, see below | |
--bot DLL |
bot DLL called every tick, see below | |
--seed N |
1 | random seed (default: the scenario's). Identical seed and inputs give byte-identical telemetry |
--difficulty 0/1/2 |
1 | easy / normal / hard |
--players N |
1 | N human players in a multiplayer start (2-6), plus any DescBlockEx AI slots; 1 keeps the single-player setup. Aborts when N exceeds the descriptor's maximum; a DLL without DescBlockEx has no AI slots, as in the game (0x48958F) |
--audio |
audio.jsonl: every sound, status message, Savant line and the music playlist the game asks for (see "Audio log") |
|
--state |
session state: events.jsonl through the run and state.json at the end (see "Session state") |
|
--stop KINDS |
end the run at the first event of these kinds after --stop-from (implies --state) |
|
--stop-from T |
0 | --stop only counts events after tick T |
--checkpoint SAVE |
when the run stops with the game going on (--ticks, --stop), save it to SAVE (OPU/saves/SAVE, or a path) first, with the mission DLL's state beside it (see "Checkpoints") |
|
--resume SAVE |
load that save at the first game cycle and play the script on from its tick; every script line must be at that tick or later (see "Checkpoints") | |
--campaign FILE |
start as a campaign mission with this carry-over (MissionResults): key value lines food, common, rare, workers, scientists, kids, satellites, moraleLevel, morale, event, techLevel, mission (the finished mission's number; default the DLL's minus one, as the game starts the next one), and techs followed by the completed topic ids. Older files without techs still start with no carried research |
|
--no-turbo |
keep the game's frame limiter and render every frame (real time at --speed) |
|
--presents |
present.log: detail/minimap/command pane presents per game tick and per real second |
|
--rng-log FROM-TO |
rng.log (one tick: --rng-log T; a malformed range is reported in harness.log): each gameplay RNG draw (Random::Rand 0x46F000, Rand(n) 0x46F060) in those ticks as tick caller n (- for a plain Rand()), the caller being the return address: which code drew where op2-sim's RNG first differs |
|
--speed 1-10 |
10 | in-game speed setting. Only matters with --no-turbo |
--draw-every N |
0 | in turbo mode, also render every N ticks. Default: render only just before screenshots |
--size WxH |
1024x768 | requested client size. The game rounds it (1024x768 gives 1032x766) |
--timeout S |
600 | wall-clock limit. On timeout the X screen is saved to timeout_screen.png and the game is killed |
--display :N |
private Xvfb | use an existing X display instead, e.g. to watch a run |
--record |
record a person playing (see "Recording your own play"): real time, real mouse and keys, sound on, no tick limit or timeout | |
--desktop WxH |
run the game in a Wine virtual desktop of that size (dialogs stay inside it; for gamescope) | |
--silent |
with --record: sound and music off, no audio device |
|
--fast |
with --record, for tests of recording: turbo on a private Xvfb, nobody playing (savegame/loadgame lines stand in for the dialogs) |
The exit status is 0 when the run reached its tick limit or the mission ended.
summary.json: exit code, whether it timed out, wall time, last tick, how the game ended (Running,MissionAccomplished,MissionFailed, ...), screenshot count, and any game dialogs that were suppressed.telemetry.jsonl: one object per sample:{"tick":3000,"mark":30,"end":0, "players":[{"id":0,"human":1,"eden":1,"food":2528,"commonOre":0,"rareOre":0,"workers":20,"scientists":10, "kids":10,"morale":0,"moraleLevel":4,"powerGen":300,"powerUsed":70,"foodNet":-40,"buildings":4, "idleBuildings":0,"techs":[0,2099,2101,...]}], "units":[{"id":7,"type":"LIGHT_TANK","owner":0,"x":79,"y":61,"hp":300,"maxHp":300,"cmd":"Attack","cargo":"LASER"}]}x/yare internal tile coordinates, the sameLocationvalues mission code uses. On non-wrapping maps the in-game display shows x-31, y+1.moraleLevelruns from 0 (Great) to 4 (Rotten).endis the rawGameTermReasonsvalue.techslists the tech IDs the player has completed.cmdis the unit's currentCommandType.cargois the weapon type, or the kit a ConVec carries.unitsholds every live building and vehicle of each player. Gaia objects such as beacons and wreckage are left out.
trace.jsonl(with--trace-every/--scenario): the op2-rs ENGINE_PLAN.md trace format, one line per sampled tick:{"tick":120,"units":[{"id":1,"spawn":0,"type":"LIGHT_TANK","owner":0,"px":1680,"py":976,"sx":1679.666656, "sy":976.0,"fix":[110078635,63963136],"dir":0,"hp":300,"cmd":"Move","action":"Move","busy":0,"ipx":2704, "ipy":976,"frame":0,"anim":0,"dest":[42,22],"path":{"start":[1648,976],"moved":[10922,32768],"toMove":[1,0], "rotSpeed":0,"wp":0,"numWp":1,"pt":2,"numPts":30,"steps":"0000000000000000000000000000"}}]}- Units spawned by the script come first, in spawn order, with
spawn= spawn index. Without spawns every vehicle is listed. px/py: the game's integerpixelX_/pixelY_in world pixels (file tile * 32 + offset, see "Coordinates").fixis the exact 16.16 fixed-point position andsx/sythe same as floats (see below).ipx/ipyare the raw internal pixels.frame/animare the sprite animation fields. The renderer sets them, so they depend on whether and when frames were drawn. Everything else is unaffected by rendering.dirisrotation_, 0..255, 0 = east, clockwise.cmd/actionare CommandType/ActionType names.destis the current waypoint's tile (file coordinates).pathis the raw PathContext:stepsholds the directions still planned in the current path fragment (one digit per tile, 0 = E, clockwise),pt/numPtsthe position in it,rotSpeedthe per-tick rotation while turning.tiles: the file tiles within 3 of the unit whose TileData::unitIndex is the unit (a building's footprint without its dock, a moving vehicle's next tile).- A spawn is listed until it dies (the dead flag), and never again: the game recycles object indices, so a dead
spawn's ID can later belong to a shot or an explosion. A vehicle in a Garage bay isn't listed while it's in there
(the game marks it dead too, with
pNext_-2) and is listed again once it's out. A spawn that failed (the object table full: 2047 objects) is never listed. - Armed units (vehicles and Guard Posts; other structures use the weapon word for other things, e.g. a lab's research
countdown) add
weapon,tur,reload,tgt(op2-rs ENGINE_PLAN.md). A unit under a weapon status effect addsstatus: its [EMP, stickyfoam, ESG] timers (+0x56, +0x54, +0x58, in 8-tick phases; 0 for an effect that is off). Top level:fx(weapon fire of spawned units, withtimer),ents(every other live object as[index, type, action, timer, px, py]: explosions, unspawned units),alloc(the object index allocator, 0x439A10: highest index so far, the ring of freed objects as[index, tick freed, reference count]from its head to its tail, and the ring's[head, tail]slots; the ring has 2048 slots as OPU patches it in memory, 1024 on disk; a slot holding a live object would be[index, 4294967295, 0]),rng(the gameplay RNG).
- Units spawned by the script come first, in spawn order, with
gamedata.json, written at the first cycle: map geometry (width,height,paddingTiles,clipRect...), the game's cell type table (trackSpeedper track type: Legged, Wheeled, Miner, Tracked, Hover,blocksLineOfSight), every unit type's player-0 stats (moveSpeed,turnRate,track,hp,armor, the rawstatswords, ...; buildings:size,dockper colony,rubble[common, rare],buildingFlags; weapons:pixelsSkipped,radius,indirect= type +0x250), andcells: the cell type of every tile as the running game sees it, in file coordinates, row-major.shot_<tick>.png: the game window's client area.present.log(with--presents): one JSON object per game tick and per real second. Tick records count the detail, minimap and command surfaces' presents; second records add the interval'sms, tick delta and ending tick. The hook is the shared DIB present method 0x4A9410 (BitBlt/StretchDIBits), filtered by the three panes' surface pointers.harness.log: what the in-game module did, including script commands, suppressed dialogs, the quit reason and the tick rate.wine.log, andoutpost2.ini: the private settings copy used for the run (see below).
One command per line: <tick> <verb> <args>. Text after # is ignored. Unit IDs are the ids from the telemetry,
or @N for the unit made by the Nth spawn command, or a name ($NAME, "Unit names" below). Coordinates are internal tile coordinates unless a
coords file line switched to file coordinates. Example: test/hello_script.txt.
100 move 7 90 60 # unit 7 to tile (90,60)
100 dock 7 41 41 # Dock order (ctMoDock) to a tile, e.g. a structure's dock
100 dockeg 7 32 42 # ctMoDockEG, the Dock the UI gives at an Eden Garage (ends facing north)
3200 attack 8 11 # unit 8 attacks unit 11
0 coords file # later x/y are file tiles (internal x = file x + map padding)
0 spawn LIGHT_TANK 0 20 30 LASER 64 # type, owner, x, y, weapon (or -), rotation 0-255
0 setcell 10 20 50 24 7 # x1 y1 x2 y2 cellType: repaint terrain (inclusive)
10 move @0 40 30 # the first spawned unit
0 view 60 64 # centre the view on a tile (for screenshots; not applied with --draw-every 1)
stop <id> | selfdestruct <id> | research <labId> <techId> <scientists> | shot | telemetry | trace | quit
guard <id> <id> | attackground <id> x y | lights <id> 0|1 | scatter <id> <id>... # scatter: one ctMoScatter
movewp <id> x,y[,r] ... | standground <id> x,y[,r] ... | patrol <id> <loop> x,y[,r] ... # waypoints, range r
1236 packet 2 012b0001003007a300 # a recorded UI packet (type, data hex), run inside the cycle starting at 1236
savegame <file> | loadgame <file> # TApp::SaveGame/LoadGame on OPU/saves/<file>, once each (recording tests)
gamespeed <1-10> # what the in-game Game Speed buttons queue: ctGameOpt 0x14 = speed x 4 (0x4231C0), which runs 2 turns later
The last group builds the packets the UI sends (Guard, Attack on a tile, LightToggle, Move/Stand Ground/Patrol with up
to 8 waypoints; r is the waypoint's range in its top 3 bits, loop the waypoint a patrol returns to). See
op2-rs notes/findings-commands-audio.md.
Commands for a tick run after that game cycle. A move at tick T shows cmd: Move in tick T's trace line and the unit
starts turning or moving in cycle T+1.
The order verbs send the packets the UI sends, so the game's own checks apply (a ConVec that is building or an EMPed
vehicle ignores them; chaos_sd_mid_dock), except attack, which goes through TethysAPI's Unit::DoAttack: it sends
nothing when the target isn't live. A command whose first argument is a spawn (@N) the trace has seen die is skipped
(logged): the dead unit's object index may belong to a shot by then, and an order to that crashes the game.
Differential fuzzing. op2-inspect fuzz --template $ORACLE/fuzz/colony.json --seeds 1-300 --out DIR
generates a random but legal script of these verbs per seed for a template ($ORACLE/fuzz/*.json: a scenario plus
what to generate), plays it here and in op2-sim, compares the traces, and shrinks each diverging script (and each
crash of the original, by crash site) to a minimal one with ddmin (DIR/seed-N/minimal.json). It is op2-inspect's
(op2-rs tools/crates/op2-inspect/src/fuzz_run.rs) and runs this harness for the original's side. A template's extras mixes in verb groups
beyond the orders (disaster, earthwork, repair, damage, scatter, eco; extra_percent of the lines) and
wrap (512) lets the area run past a wrapping map's seam. A diverging run whose script names a dead spawn after a
line's first word (an order that reached whatever took the dead unit's index) is played again without those lines
(dropped in summary.jsonl). Templates: colony, skirmish, seam, choke, warfare, colony_stress, edges.
Script verbs that drive the game's UI the way a player does (the orders above bypass the UI):
10 click unit @0 # left click on a unit (select it)
20 click tile 34 28 +shot # left click on a tile; +shot = screenshot one tick later
30 rclick 432 416 # right click at game window client pixels (as in screenshots)
40 drag tile 22 22 tile 28 28 # left-button drag (box select); `right`/`middle` for other buttons
50 click unit @1 shift # modifiers: shift, ctrl, alt (held while the button goes down)
60 click mini 50 50 hold 2 # minimap pixel showing tile (50,50); release the button 2 ticks later
70 click ui tab2 # command pane tab 0-5 (Factories, Labs, Resources, Communications, Space, Game Options)
80 click ui Self_Destruct # a control by tooltip substring ('_' = space); `uilist` logs every control
90 key ctrl+1 alt+c f5 enter # key presses, through the frame's accelerators like real input
100 type hello world # characters (chat: `key enter`, `type ...`, `key enter`)
mouse <pos> | down <pos> [button] | up <pos> [button] | dblclick <pos> | mclick <pos> | keydown <key> | keyup <key>
select <unit> # left click on the unit, centring the view first if it is off screen
hotkeys # the command pane's hotkey table now: a line of hotkeys.jsonl (key, flags, tooltip)
- A
<pos>is<x> <y>(client pixels),tile <x> <y>(centre of that tile in the detail pane's current view;coords fileapplies),unit <id|@N>,mini <x> <y>, orui <name>(tabN,minimapN, or tooltip text). - Keys: letters, digits,
f1-f12,enter,esc,space,tab,backspace,delete,home,end,pageup,pagedown, arrows,pause,plus,minus,numplus,numminus,vk:0xNN, withshift+/ctrl+/alt+. - With
--scenario,--scriptlines are appended to the scenario's own script (so in file coordinates), and a scenario may carry raw script lines as"script": ["10 click unit @0", ...].$ORACLE/input/has the controls experiments (ui_base.jsonplus one script per behaviour, see $ORACLE/FINDINGS.md "Controls"):./run.sh --scenario $ORACLE/input/ui_base.json --script $ORACLE/input/box_rules.txt --telemetry-every 1 --out /tmp/x. - When a script uses input verbs, each telemetry line adds
selected(unit IDs, the game's selection list at 0x547210) andview(the detail pane's top-left corner in file pixels).harness.loglogs every input with the pane and map tile it hit, plus the pane rectangles.
How it works, and what is deterministic:
- Input runs in process right after the game cycle of its tick, like everything else in the script. Mouse messages
go straight to the pane under the cursor (or the one with capture); key and character messages go through
GameFrame::TranslateAcceleratorsandIDlgWnd::PretranslateModeless, then to the frame, asTApp::PumpMessagewould. The game also pollsGetCursorPosandGetAsyncKeyState; their IAT slots point at the virtual cursor and keys, so nothing depends on the X pointer (which is parked off the window). Same script, same output: repeated and parallel runs give byte-identical telemetry and traces. With--no-turbothe same orders land on the same ticks (only the draw-dependentframe/animtrace fields differ, as always). - Orders made through the UI are not immediate: a click at tick T executes in the first cycle 4k+1 after T+8 (9 to
12 ticks later), where the
moveverb executes at once. Selection changes are immediate. - Some UI only advances when the panes are drawn: the view change from
viewor a minimap click, arrow-key and edge scrolling, the bandbox. In turbo mode the harness draws for 4 ticks around every input (and everyviewin an input run), so give aviewa tick before inputs that depend on it, and hold the minimap button over a tick (hold 1+). Scroll distances depend on how many frames are drawn; use--draw-every 1for realistic ones. - Input runs keep the game window active (parallel runs share a wineserver and take the foreground from each other; an inactive game ignores keys and scrolling).
- Don't pause the game (
ctrl+p,pause): no cycles run while paused, so the script can't unpause it and the run ends at--timeout. To measure input while paused use shell mode, whose driver's real input doesn't wait for cycles, and itsstatestep (below; runsshell_pause_input).
Script verbs for colony experiments (oracle scenarios eco_*, spec in $ORACLE/ECONOMY.md):
1 player 0 workers 40 # set a PlayerImpl field: workers scientists kids food common rare morale
# moraleLevel solarsat, or a byte offset (0x94); sets recalculateValues_
0 tube 44 47 69 47 # tubes over an inclusive rectangle (Game::CreateWall); `wall x1 y1 x2 y2 [TYPE]`
0 beacon 40 60 0 1 2 # mining beacon: ore 0 common / 1 rare, bar 0-2 (0 = 3-bar), internal variant 0-2
0 beacon 44 60 vent # magma vent (`fumarole` for a fumarole)
5 route @3 @1 @2 # cargo truck @3 on a mine (@1) <-> smelter (@2) route, as the UI sends it
5 produce @4 CARGO_TRUCK # factory order (TYPE [weapon]); `idle`/`unidle <bldg>`, `train <university> <n>`
5 kit @5 TOKAMAK # put a structure kit in a ConVec; `build <convec> <x> <y>` (the margin's bottom-right tile)
5 bay @4 0 TOKAMAK # fill a factory bay; `transfercargo <factory> <bay> [1]` (1: a Spaceport's bay
# into the rocket on its pad); `launch <spaceport> [x y]` launches that rocket
5 cargo @3 2 1000 # set a truck's cargo (CargoType, amount); `load`/`unload <building>`: the
# building whose dock holds the truck (the load and unload handlers 0x424090/0x424240)
5 poke @2 0x1e 2 1200 # write a MapObject field (offset, size 1/2/4, value), e.g. damage
1 peek 0x439A10 64 # log game memory as hex (up to 256 bytes): code as OPU patches it in memory
10 tech 0 5317 # _Player::MarkResearchComplete(techID) for player 0, as a mission grants a topic
# (a topic not in the loaded tech tree is logged with the valid ids, not granted)
10 doze @0 38 36 41 38 # Robo-Dozer: bulldoze an inclusive rectangle (ctMoDoze, as its click sends it)
10 buildwall @1 38 40 44 40 TUBE # Earthworker: TUBE, WALL, WALL_LAVA or WALL_MICROBE over an inclusive rectangle
# (ctMoBuildWall; the packet's bottom-right corner is exclusive, as the UI's)
10 removewall @2 40 70 # Earthworker: remove the tube or wall on a tile (ctMoRemoveWall, one waypoint)
20 repairobj @4 @1 # repair @1 (ctMoRepairObj, as the Repair command's click sends it; `none` for
# its no-target click; more units after the target join the packet);
# `reprogram` and `dismantle` send ctMoReprogram / ctMoDismantle the same way
--eco (or "eco": true in a scenario) adds to each telemetry player an eco object (storage limits, available and
required workers/scientists, scientists working as workers, food production/consumption, growth and death remainders,
capacities, morale inputs) and pi, every dword of PlayerImpl 0x00..0x1EF; and to each unit flags (MapObject +0x44),
act/timer (action and its timer), busy, w24 (the cargo/researchTimer word) and raw (bytes 0x48..0x7F), plus
truck [cargo type, amount] for Cargo Trucks and, for buildings, hive: the counters of its hive (+0x38) as
[+0x8 owner, Command Centers, one enabled, enabled DIRTs, members, slot] (the slot: its index in HiveManager's hive array, which tells hives apart), which DoDamage reads live (0x430050). Each --eco telemetry line also has a top-level beacons array:
x/y in the same internal tile coordinates as units, plus ore, bar (yield) and variant for every live mining
beacon. gamedata.json gets morale (levels and modifiers as loaded), mines
(the yield table) and typeExtra ([mapId, resource priority, research topic]). Scenarios may also set
telemetryEvery, traceEvery and difficulty. Runs without --eco produce the same output as before.
Unit ids are handed out as units appear, so a fight that goes differently shifts every later id (p12: miner 154 -> 155, rare mine 162 -> 168) and a script naming ids can't be replayed under another history. A script can name units instead; the harness resolves a name when the command runs, in the game, so it follows whatever ids that run has:
5 produce 16 BIG_TRUCK as T1 # `as NAME` ends a produce or build: NAME = the unit that order makes
5 build $MINER2 22 17 MINE as M2 # a build with a TYPE; op2-inspect: `buildat <id> x y as NAME` too
10 name M1 $MINER@21,18 # bind an existing unit (id, @N, $NAME or selector) to a name now
700 move $T1 232 40 # a word `$NAME` anywhere a unit goes
700 route $T1 $MINE1 $SMELTER # (names are case-insensitive: letters, digits, _)
710 attack $TOWER_GUARD@12,34 $TANK_2 # `$TYPE@x,y`: the live unit of TYPE nearest to the tile, within 3 tiles
as NAMEbinds a unit that appears after the order: forproduce, a new vehicle of the produced type within 8 tiles of the factory (if several pending names fit, the one whose factory is nearest takes it, ties to the earlier order); forbuild, a new structure (any type: a Mine on a rare beacon becomes MINE_ADV) inside the build rectangle plus margin. Only that player's units match. Aproduceon a Structure Factory can't bind (it makes kits) and is refused with a warning. A name that finds no unit within 4000 ticks (factory busy, site refused) is dropped with a WARN, so a later unit doesn't take it.harness.loghasscript: name T1 = unit 139 (BIG_TRUCK)when a name binds, andstate.jsonlistsnames(name -> id) when any exist.- A name is letters, digits and
_(not all digits), case-insensitive; anything else is refused with a WARN. It is unbound the first cycle its unit isn't live or isn't the bound type any more, so a new unit that reuses the id doesn't inherit it (a unit that dies and is replaced by one of the same type inside one cycle would).$TYPE@x,ytakes any owner and the nearest unit of exactly that type (so$MINE@x,ydoesn't match a MINE_ADV; tiles in the script's coordinate mode,coords fileincluded); none within 3 tiles is not found. - op2-missionhost's
play(and sogame_verifyand themission_playcheck) and op2-engine's mission--script(netpairand network games too) resolve names the same way (op2_missionhost::unitnames, the rules above, the scan after every cycle), so a named script replays there too. op2-engine's scenario scripts take@Nonly.
An order naming something that can't be resolved is skipped, not fatal. That is a $name or $TYPE@x,y that
doesn't resolve, or a plain id past the object table (before: reading it crashed the game, exit 5). Every verb whose
first argument is a unit is covered (the first two for attack/guard/repairobj/reprogram/dismantle, the first
three for route; scatter leaves such units out). The log gets
script: WARN tick T line L: : <reason> (order skipped), L being the line in the script file, and
op2-inspect's game_step prints it as ! SCRIPT WARNING tick T: .... A plain id in range that names a dead unit
is sent exactly as before (the packet may still change the actor's state, as in the recorded runs) and only noted in
the log. @N spawns keep their own rule (skipped once they have died).
audio.jsonl, one event per line, from hooks on the game's own sound calls (so it works with the run's sound off):
{"tick":0,"ev":"music","when":"start","songs":[12,13],"repeat":0,"pos":0}
{"tick":20,"ev":"msg","id":97,"name":"savant4.wav","x":976,"y":1008,"at":"4a3a7a","pass":1}
{"tick":20,"ev":"savant","id":97,"name":"savant4.wav","x":976,"y":1008,"at":"opu"}
{"tick":86,"ev":"play","id":97,"name":"savant4.wav","x":-1,"y":-1,"at":"47f236"}
{"tick":338,"ev":"sfx","id":64,"name":"doc_grab.wav","x":496,"y":624,"at":"43e55d"}sfx: AddMapSound (0x47EDB0; AddGameSound 0x47EFD0 goes through it with x = y = -1): an effect.msg: a status message (MessageLog::AddMessage 0x439070) with its SoundID (0 = none, which beeps) and, for Savant ids,pass: the throttle's verdict (ShouldAddMessage 0x439590; -1 = not throttled).savant: a line handed to the Savant queue (AddSavantSound 0x47F010).play: the queue starting a line (0x47F200; the harness takes the queue's turn itself on ticks withtick & 3 == 2, as SoundManager's per-frame update would with sound on).music: the MusicManager playlist (song numbers of op2.clm, the repeat index, the position) after PrepareGame and whenever it changes.tables(first line): Savant priorities and every sound's length in ms as the game loaded them.x/yare world pixels in file coordinates (like the trace'spx/py).atis the return address of the call in Outpost2.exe that asked, oropufor OPU's own code.tickis the cycle the request came from.- The throttle and the queue run on timeGetTime; in an audio run those call sites read a virtual clock instead,
tick * 1000 / 36ms (real time at speed 10), so runs are deterministic. Nothing else changes: an audio run's other outputs are the same as without--audio.harness.lognotes the time from MusicManager::Init to the mission's InitProc. - The message log's throttle slots are stamped before the harness loads (0x438FE0), from the real clock; at the mission's InitProc the harness rebases them to what the log's reset (0x439510) writes at time 0, so the verdicts don't depend on the machine's uptime (before that fix, past ~27.7 h of uptime no throttled message passed all run).
- The
tablesline'sprio_ctorisn't all priorities: the game reuses part of those bytes, including a real timeGetTime stamp (0x56E89C), so it changes between recordings; op2-rs accuracy/runs.py leaves it out of the manifest hash.
For tools that play a mission by replaying a growing script (op2-inspect's game_* tools, op2-rs tools/README.md): a
replay costs about 1 s of wall time at any tick (Wine and Xvfb start in ~0.8 s; measured 2026-10-22: e05's whole
23,448-tick win in 1.0-1.3 s, 40,000 ticks of e06 or e12 in 1.1 s, p12 to its end at 37,652 in 1.4 s; with
--telemetry-every 1 e05 takes 3.6 s, writing 125 MB), so a session replays its script at every step instead of
keeping a paused game, and its history is exactly the script that reproduces it. Long games replay from a checkpoint
instead of tick 0 (next section): with --eco a replay runs at 20k-30k ticks/s (c_euf_win to 46,296 in 2.4 s). Reading the state changes nothing:
the telemetry of a run with --state is byte-identical to one without (measured on e05_win; make check compares them).
events.jsonl, one line per event, file tiles, written during the run:{"tick":1783,"ev":"new","id":48,"type":"MINE","owner":0,"x":60,"y":42} {"tick":1784,"ev":"gone","id":39,"type":"MINER","owner":0,"x":61,"y":43} {"tick":3336,"ev":"idle","id":48,"type":"MINE","owner":0,"x":60,"y":42,"was":"Develop","dest":[-1,-1],"emp":0} {"tick":2694,"ev":"tech","id":3405,"name":"Metallogeny"} {"tick":5600,"ev":"hit","id":24,"type":"BIG_TRUCK","owner":0,"x":5,"y":0,"dmg":30,"hp":370} {"tick":8532,"ev":"disaster","type":"EARTHQUAKE","x":10,"y":13,"timer":0} {"tick":9543,"ev":"msg","text":"DANGER: Quake detected at 11,14","x":10,"y":13}new: a structure or vehicle of player 0 appeared,enemy: one of another player's;gone: one of any player disappeared (idis the object index, which the game reuses);hit: lost hit points;idle: a unit of player 0 went from a command to none (was;destwhere a vehicle was going, so a stop short of it shows, and whether it is EMPed);tech: player 0 got a topic;objective: a victory condition's trigger changed (met,text);disaster: a disaster object appeared (the DLL creates it before its warnings);msg: a status message (MessageLog::AddMessage 0x439070, hooked;x/y-1 for none);end.state.jsonat the end of the run (any end: tick limit,--stop, the mission's end):tick,end,stop(the event that stopped it),map(file width, height, padding),players(the telemetry fields plus storage, required and available workers and scientists, food production and use),techs(the tech tree: id, name, lab 1-3, Eden and Plymouth cost, max scientists, required ids,has= bitmask of players),typeTopics(per unit and weapon type, the topic that makes it available),objectives(met,text: VictoryConditionImpl's text, read from the ScStub list),units(every player's: id, type, file tile, hp, cmd, action; vehicles:truck[CargoType, amount], a ConVec'skit,weapon,dest,emp; structures:flags(MoFlagBld*: 0x1 active, 0x2000 power, 0x4000 workers, 0x8000 scientists),dock, a lab'sresearch{tech, team, left, cost} ($ORACLE/ECONOMY.md section 7), a factory'sbaysandproducing),gaia(beacons: ore 0 common / 1 rare, bar 0 = 3-bar, variant, surveyed by player 0; magma vents; fumaroles; wreckage: tech, discovered; disasters with their timer, action and flags (0x2000 started, 0x4000 first warning done, 0x8000 second warning done)) andcells(file coordinates row by row: cell type | lava << 5 | microbe << 6 | wall or building << 7 | lava possible << 8 | lava/Blight expansion marker << 9).--stop KINDSends the run (as a tick limit does, with all outputs) at the first event after--stop-fromof the listed kinds:new(player 0's),enemy(another player's unit appeared),gone,hit,idle,tech,objective,msg,disaster,end,any(all but msg and hit);kind:<unit id>waits for that unit, e.g.idle:48. The stop tick is part of the game, so it is deterministic too.
Learned while building it: wreckage is scavenged by a Cargo Truck driving onto it only once it has been discovered, and a Scout discovers it by coming near (e06: tanks next to it for 3000 ticks didn't).
A replay from tick 0 costs about 1 s plus the game's time, 20k-30k ticks/s with --eco and --state: 10 s for a
250,000-tick colony game, and a session makes hundreds. op2-inspect's sessions therefore resume from a save of an
earlier run where the script up to it is unchanged (op2-rs tools/README.md "Checkpoints"); the harness's part:
-
--checkpoint SAVE: when the run stops with the game going on (its tick limit, a--stopevent), the game is saved to SAVE first, at the end of that tick's cycle (after its script lines, samples and session events), as TApp::SaveGame saves (0x4877E0, what the Save dialog calls).checkpoint: saved SAVE at tick Tin harness.log. Only these saves write what follows and put the hives' counts back; asavegameline saves as the game does, so the original's own behaviour after a save stays measurable. -
Beside the save,
SAVE.dll: the mission DLL's writable sections (from its PE headers in memory) as they are at the save, and each dword as the process's DecodePointer reads it. -
--resume SAVE: at the first game cycle, before any script line runs, TApp::LoadGame (0x4878F0, what the Load dialog calls) loads the save;resume: load SAVE at tick 1: ok, now tick Tin harness.log. The run then goes on as the cycle of tick T: the script's lines (all must be at T or later; earlier ones are skipped with a WARN) at T run right after the load, where they would have run after the saved cycle, then the samples at T (telemetry, trace) and the session's events. The session state starts from the loaded game without events (nonewfor every unit). -
After the load the harness compares the mission DLL's memory outside its save region (GetSaveRegions, as MissionManager keeps it) with
SAVE.dll. A load loads the mission DLL again (its CRT draws a new security cookie; measured on c_euf.dll), so every global the DLL doesn't save starts over from the DLL file's values. Each dword that differs is classed, in this order:- put back as saved: the loaded value is still the DLL file's (nothing set it since the load) and the saved one is a small number (below 64 KB, or a small negative) or an address in a module mapped here (Outpost2.exe's, the DLL's own: the same in every process, e.g. a function pointer TethysAPI looks up on first use): a global the mission set during play, so the DLL goes on as the continued game's would. Only for a DLL at its preferred base (else the file's pointers aren't the loaded ones);
- lost (mission state not put back): other plain numbers below 4 MB (unless the loaded one is an address mapped here: then both are heap addresses, process-only), a value this process set itself (a handle, a TLS index, a CRT counter) or possibly a low heap address (Wine allocates below 4 MB), where the other process's value would be wrong; and whatever is left after the next two;
- process-only: the CRT's cookie and values it encodes (XORed, or a pointer encoded with the cookie or with EncodePointer, decoding to addresses), and heap addresses (both values addresses: a process allocates elsewhere).
checkpoint: dll same | process-only | restored: ... | DIFFERS: ...says which; with any lost dword op2-inspect checks the session against a replay from tick 0 at once and then more often. c_euf.dll keeps two such globals (0x11027780 and 0x11027784, which its unit-creating helpers pass on as arguments: 2,1 saved against the file's 0,6), which a resume puts back. -
The hives' counts. A save doesn't hold the tube networks (HiveManager): saving rebuilds them in the game that goes on (new slots in HiveManager's array) and loading builds them afresh, both with each hive's counts (Command Centers +0xC, an enabled one +0x10, DIRTs +0x14) at zero until its owner's next allocation pass recounts them ($ORACLE/ECONOMY.md "Tube network"; a computer player's pass comes every 256 ticks): p12's Eden read [1,0,0,0] instead of [1,2,1,1] for 140 ticks after a save at 5000, with no load. DoDamage's Damage Prevention reads them in that window (0x430050). So a checkpoint save notes each structure's hive counts first, puts them back after SaveGame, and writes them beside the save (
SAVE.hive); a resume puts them back after the load (checkpoint: hives: the counts of N structures' hives put back). Only the slot numbers stay different (they decide nothing measured). -
What a save doesn't hold of the harness's own state: unit names (
as NAME,name), spawn indices, group names, the UI (selection, view) of input verbs, apacketqueued for the next cycle. op2-inspect doesn't resume past such lines.
Fidelity (2026-11-06, python3 test/checkpoint_check.py --mission M --script WIN --at T1,T2,..: a
reference run of the win script, runs that save at each T, and a run per T resumed in a new process, compared with
the reference from T on: telemetry (--eco every 10 ticks), trace (every 4 ticks; every 20, the
checker's default since a whole-run trace at 4 is gigabytes, in the last pass of c_euf, ml4_01 and the split),
events, the final state.json; the checker streams the files and peaked at 24 MB):
| mission | script | resumed at | result |
|---|---|---|---|
| e01 | e01.txt | 1000, 2000 | exact to the win at 3000 |
| cResEsc | cResEsc.txt | 5000, 15000 | exact to the win at 17792 |
| e10 | e10.txt (e10_campaign.txt) | 12000, 30000, 45000 | exact to the win at 48720 |
| p12 | p12_partial.txt (p12_campaign.txt) | 5000, 15000 | exact to 27400 with the hives' counts put back (before: Eden's counts 0 for 140 ticks after each save) |
| c_euf | c_euf_win.txt | 10000, 30000, 45000 | exact to the win at 46296 (the two DLL globals put back) |
c_euf, --split |
c_euf_win.txt | 40800 (the assist and that tick's orders run after the load) | exact to the win |
ml4_01, --players 2 |
ml4_01.txt | 2000, 6000 | exact to the Victory at 12464 |
The Sierra DLLs (e01, e10, p12, ml4_01) each have one dword the check can't class (e.g. p12.dll 0x11012268, 0xF0F03B01
saved against 0xF0F03801): by the code around it (the list at 0x11012258) the MSVC CRT small-block heap's per-page
free counts, not mission state; op2-inspect then checks the session against a replay from tick 0 sooner. p12_win.txt
binds unit names (as NAME) before 10000, which a save doesn't hold: resumed there, its $NAME orders were skipped
and the game crashed at 0x42324F (an order to a dead unit) at 29240; op2-inspect never resumes past such lines.
"Exact": the trace (positions, actions, every unit field it writes) identical at every sample, the telemetry identical every 10 ticks, the events identical, the final state.json identical, the win at the same tick, apart from:
- the structure hives' slot numbers in HiveManager's array (telemetry
hive's last value): a save itself renumbers them in the game that goes on (c_euf, from the first sample after a save to the end, with nothing else differing), and a load numbers them its own way (their counts are put back, above); and each unit's MapObject +0x7C (a heap address for most units; a Garage's goes from -2 to 0x40 when the game is saved) and other heap addresses in the raw bytes (as record_check.py masks); - the telemetry record at the checkpoint's tick itself, taken right after the load: each player's recalc flag (PlayerImpl dword 40) is set until the game's next cycle (from the next record on it is the continued game's);
- status messages (
msgevents): MessageLog's throttle runs on the real clock, so they differ between any two runs of different speed, resumed or not (two plain runs of c_euf_win, with and without--trace-every 4: 10 messages apart by tick 40000).test/checkpoint_check.py --splitleaves the script's lines at T out of the save and plays them after the load (orders given at a checkpoint's tick).
You play a mission of the original by hand and your game becomes a harness script: a win script, an oracle run and
a comparison with a reimplementation. Linux only for now (it needs an X display: --display, or $DISPLAY, which
gamescope sets).
./run.sh --record --mission p12.dll --campaign $ORACLE/missions/p12_campaign.txt --desktop 1088x816 --out recordings/p12
# -> recordings/p12/recording.txt, plus telemetry.jsonl and harness.log of the session (not committed)op2-rs runs this under gamescope with make -C missions record MISSION=p12.dll.
- How it starts.
run.py --recordloads the harness as for any scripted run (sameStartMissionHook: seed 1, normal difficulty,--campaigncarry-over, player name, start info), in prefix-harness, never prefix/ (the prefix you play from isn't touched; mission DLLs are mirrored from it as for every run). The game runs in real time with the frame limiter, sound and music as prefix-harness's ini has them (--silentturns them off), the real mouse and keys (no virtual cursor, no forced focus), the game's own questions answered by the player (the message box isn't intercepted), and no tick limit or timeout. It refuses to run when the prefix resolves to prefix/ (no prefix-harness, orOP2_HARNESS_PREFIXpointing there). It holds OPU/saves's lock for the session (shell.py --clean-saveswaits for it, saying who holds it; the lock fileOPU/saves.harness-locknames the holder).--desktop 1088x816runs it in a Wine virtual desktop around the 1024x768 game, so its dialogs stay inside (for gamescope). An optional--scriptis a setup script (it runs as in any run and is copied to the top of the recording); its input verbs run through the harness's virtual cursor and keys, as in a replay, and the real mouse and keys take over once the last of them has run (telemetry then has the player's ownviewandselected, which a replay doesn't share). A recording played on as a setup isn't recorded twice: packets thepacketverb injected are left out. - What is captured (harness.cpp "Recording a player's own game"). The UI never runs an order: each command writes its
packet into the local player's pending slot of the command queue (0x490810), and
GameImpl::ProcessGameCyclehands each player's slot toPlayerImpl::ProcessCommandPacket(0x40E300) at 0x489FF5 on every 4th tick, before the units act. The harness hooks that call: every packet of the local player that takes effect is logged with its tick, after any overwrite in the slot. ctNop and ctMachineSettings (machine speed, memory and window size, which the frame sends itself every 32 ticks and only a debug line reads) are left out. Selection, scrolling, the pane's pages and other UI that sends nothing don't change the game and aren't recorded. - The script. One line per packet,
T packet TYPE HEX [+TAIL] # comment: the command type (1-53) and itsdataLengthdata bytes, and TAIL, the slot's bytes after them up to its last nonzero one (what earlier packets left there; handlers read by their own counts, so the slot's whole 99 data bytes are kept), run by the replay in the cycle that starts at tick T (a multiple of 4): thepacketverb puts those bytes into the slot (the rest zero) just asProcessCommandPacketreads it, so the same bytes run at the same point of the same cycle. Apacketwhose cycle doesn't hand the slot over (a cycle that skips packet processing) is dropped with a WARN, and shell mode refusespacketlines (no hook there). The comment says what it does with the units asid(TYPE@x,y)(file tiles, when it ran):= verb ...when a harness verb builds the same bytes (one unit, tile centres),~ verb ...for the nearest verb (the UI's Move carries the clicked pixel, not the tile's centre, and a selection gives one packet for every unit), else a description (GameOpt 20 = 40for a game speed change). Unit ids are the game's own; they replay because the history is the same. Quitting (ctQuit) is kept as a comment only. The header has the replay command line; the last line says how the session ended. - Saves and loads. Save as often as you like: TApp::SaveGame (0x4877E0) and TApp::LoadGame (0x4878F0) are hooked.
A save writes a sidecar folder beside the save,
OPU/saves/SGAMEn.OP2.rec/in prefix-harness (packets.txt, the telemetry so far, meta.txt with the tick, the save file's size and FNV hash, and the session's identity: mission, seed, difficulty, carry-over, setup script without itssavegame/loadgamelines; written last, only when the rest was written). A load puts them back, so recording.txt is always the one linear history that led to the loaded game, and the abandoned branch is gone (# load ...header lines say what each load rolled back; harness.log has every packet that ran, abandoned ones included). Slots save the pending orders too: an order clicked just before a save runs after the load and is recorded then. Loading a save whose sidecar is missing, of another start, of another file (the slot saved over without a recording: the shell, plain play, a run that isn't a recording) or of another tick marks the recording# BROKEN: ...(it no longer replays). A failed save, and asavegamein a run that isn't a recording, delete the slot's sidecar. recording.txt is rewritten whole after every change (a temporary file renamed over it). A new session in the sameREC_OUTkeeps the previous files asrecording-<time>.txt; loading a save from an earlier session resumes that history. - Turning it into a win script and an oracle run. Copy recording.txt to
$ORACLE/missions/<m>.txt, write the header (what the mission asks, the plan as played; keep the packet lines), then replay it:./run.sh --mission <m>.dll --campaign <carry-over> --script $ORACLE/missions/<m>.txt --ticks 0 --out /tmp/<m>must end as the session did (MissionAccomplished at the recorded tick), and its telemetry.jsonl must equal the session's (same--telemetry-every: 100 by default) line for line. Then add[runs.<m>_win]to op2-rs accuracy/recordings.toml, op2-rstools/agent/record <m>_win, andop2-inspect game_carry_overfor the next mission's carry-over. To play on from the recording's end through op2-inspect (a partial game),game_start mission=<m> script=<recording> tick=<end>takes it as the session's base script. - Check:
python3 test/record_check.py --out DIR(about a minute; Xvfb, real X input through shell.py'sX): plays e01 with clicks, SAVE GAME, two more orders, LOAD GAME and a different order, then replays the recording and requires the same executed packets, byte-identical telemetry and an identical trace (frame/anim aside), the abandoned packets gone, and op2-inspect's game_start to start from the recording at its end with the same units.--script WIN --loads S:L,...(with--mission,--campaign,--ticks) instead plays a win script in a--record --fastsession that saves at tick S and loads that save at tick L (script verbssavegame NAME/loadgame NAME, which run once each, after the cycle's samples, and stay comments in the recording), and compares its--ecotelemetry with the plain replay: whether saving and loading are faithful for that mission (MapObject +0x7C masked, the hive slot numbers reported apart).--inputchecks a setup script with input verbs in a recording. - Comparing a recording with op2-rs. op2-missionhost reads
packetlines:op2-missionhost play e01.dll --script recording.txt --compare DIR(DIR: the session's own folder, or a replay of it with--telemetry-every 10 --trace-every 4) decodes each packet's bytes (op2_sim::original_packet: unit ids, internal pixels to file tiles, map ids to unit kinds, the research index to a tech id) into the op2-sim command a UI click makes, queues it for the cycle that starts at the line's tick and plays the mission on, so the compare tools (op2-inspect compare_run,game_verify missionhost=true, op2-rstools/agent/missioncompare) work on a recording as on a win script. A packet op2-sim has no equivalent for (Transfer, Ally, GoAI, Guard on a tile, most GameOpt cheats, a stored-group selection) is listed in the summary asunsupported packet: tick T ...and not run; chat, machine settings and packets for units that no longer exist are left out silently. Packets only reach a cycle that is a multiple of 4 (apacketline at another tick is dropped with a note, as the harness does). Rune01_ui_packetsis such a recording (three UI Moves at click pixels) and replays exactly. A verb script can stand in for a recording:python3 tools/packetize.py SCRIPT [--gamedata RUN/gamedata.json] [--max-delay N] [--keep VERBS]rewrites its orders aspacketlines (each at the first free command cycle after the verb's tick); runse01_packets,e02_packets,e03_packets,e04_packets,e05_packets,p02_lose_packetsandp03_packetsare such scripts played in the original and in op2-missionhost, all exact (Move, Dock, LoadCargo, TransferCargo, Produce, Research, Train, Idle, Unidle, BuildWall, Attack, ...). Packetized e10, e11 and p10 (with their Attack orders) were not exact and aren't kept as runs: leads in op2-rs notes/findings-commands-audio.md. - Limits. Only the local player's packets are recorded (single player). The
= verband~ verbcomments of a recording are for reading; op2-missionhost reads the hex. A load restores the game as the save holds it; across the p08, e05, e10, p12 and e01 scripts a loaded game went on as the saved one would have (the plain telemetry byte for byte, the win at the same tick) apart from internal values in--ecotelemetry: every unit's MapObject +0x7C, a heap address (0x06xxxxxx, 0x78 apart from unit to unit) that a loaded game allocates 2 MB away, and the structure hives' slot numbers in HiveManager's array, which stay different to the end without anything else differing; and in p12, Eden's hive counters read 0 for up to ~140 ticks after a load (after a save too, in the game that goes on: "Checkpoints" puts them back for its own saves; a recording's saves are the player's and stay as the game makes them). The measured scope is in op2-rs notes/uncertain-claims.md ("a load replays like continuing"). Sound on during a recording hasn't been tested for determinism (the checks run silent; replays are always silent): compare the first real recording's replay with its telemetry. Other harness runs share prefix-harness's wineserver and can take the foreground (input runs do): don't run input-verb runs while someone records. Mission dialogs from a DLL are suppressed as in every run.
Script verbs that schedule a disaster the way a mission does (the TethysGame::Set* exports; $ORACLE/FINDINGS.md
"Disasters"). A trailing now skips the two 500-tick warnings, as the API's immediate flag does.
5 meteor 30 30 2 # x y size (0 small .. 2 large, -1 random)
5 quake 20 20 1 # x y magnitude
5 eruption 40 40 5 # x y lava speed
5 lightning 20 20 10 60 30 # x1 y1 duration x2 y2
5 tornado 20 20 16 60 30 now # x1 y1 duration x2 y2
5 blight 40 40 [speed] # GameMap::SetVirusUL; lavaspeed n; lavapossible x1 y1 x2 y2 0|1
5 empmissile 0 0 1 60 50 # TethysGame::SetEMPMissile: from x y, owner, to x y (an EMP missile, e12's FireEMP1)
group <name> <op> [args] calls the mission API's AI group exports on a group the script names (op2-sim's script
runner understands the same verbs, op2_sim::groups):
1 group g new fight 1 # CreateFightGroup(Player[1]) (also mining, building)
1 group g guardrect # DoGuardRect (patrolonly attackenemy guardgroup guardunit attackunit exitmap)
1 group g rect 30 25 36 30 # SetRect; guardedrect x1 y1 x2 y2 = AddGuardedRect; clearguarded
1 group g patrol 0 20,20 34,20 # SetPatrolMode(loop, waypoints); clearpatrol
1 group g take @0 @1 # TakeUnit; remove <unit>; lights 0|1; enable; disable; destroy
1 group g combine 1 # Set/ClearCombineFire; follow <mode>; targetunit <unit>; targetgroup <name>; attacktype TYPE
1 group m mine @3 @4 x1 y1 x2 y2 # MiningGroup::Setup(Unit, Unit, MAP_RECT)
1 group g targcount TYPE WEAPON n # SetTargCount; cleartargcount; reinforce <group> <priority>; unreinforce <group>
1 group b building x y TYPE WEAPON [group] # RecordBuilding; tube x y; wall x y [TYPE]
1 group g dump # harness.log: members in list order (unit:class@issue tick), fight group state
--scenario FILE runs a scenario in the op2-rs ENGINE_PLAN.md format against the real game, so op2-sim can be compared with
it:
{"map": "cm01.map", "seed": 1, "ticks": 1400,
"setcell": [{"rect": [10, 20, 50, 24], "cell": 7}],
"spawn": [{"type": "LIGHT_TANK", "owner": 0, "tile": [12, 22], "weapon": "LASER", "dir": 0}],
"orders": [{"tick": 10, "unit": 0, "cmd": "Move", "tile": [42, 22]}]}- Tiles are file coordinates.
typeandweaponare sheet code names.dir(optional) is the starting rotation.orders[].unitis a spawn index.cmdis Move, Dock (withtile), Attack (withtarget, a spawn index, or atile), Guard (target), Lights (on), StandGround or Patrol (tile, orwaypointsas[x, y]/[x, y, range]; Patrol alsoloop), Stop or SelfDestruct. Move takeswaypointstoo.view(optional tile, default the spawns' centroid) centres the camera for screenshots.techtree(optional, defaultmultitek.txt) is the tech tree the scenario mission loads (run.py patches its TechtreeName; up to 12 characters, e.g.edentek.txt).setcell(optional) repaints cell types before spawning (cellis a CellType id or name).noteis a comment. op2-sim refuses a key that neither it nor the harness reads (op2-simScenario::parse,HARNESS_KEYS), so add a new harness-only key there too. - run.py turns the scenario into a script (
scenario_script.txtin the output) and runs the scenario mission (test/Scenario.cpp): an empty colony onmap, with player 0 a human Eden player, player 1 an idle Plymouth AI, and daylight everywhere. Spawns happen at tick 1, the first harness cycle. The trace starts there and samples every tick. - The game reads a mission's
MapNamefrom the DLL file without loading it (MissionManager::GetModuleDesc), so run.py writes one patched copy ofzScenario.dllper map, asOPU/Harness/zs_<map>.dll. Missions inOPU/Harnessnever appear in the game's mission lists. - A scenario run takes about 1 to 3 s.
$ORACLE/(in op2-rs) holds the reference scenarios (make_scenarios.pywrites them; op2-rs'stools/agent/record NAME...records them into$ORACLE/runs/, which is never committed). Measured results are in$ORACLE/FINDINGS.mdand op2-rsnotes/findings-*.md. On Windows,windows/run_win.py runplays one. Any scenario file works: the recipes don't have to be op2-rs's.
Recording a scenario as video frames: --screenshot-every 1 (or 2, 5...) saves a PNG per sampled tick of the view
centred on the units, about 20 frames/s of wall time. Assemble them with e.g.
ffmpeg -framerate 25 -pattern_type glob -i 'shot_*.png' out.mp4.
shell.sh boots the game normally (intro video, title, OP2Shell menus, briefings, movies) in a Wine virtual desktop
on a private Xvfb, drives it with real X input (XTEST) from a driver script, screenshots the whole screen and logs
what the game does. The startup flow it measured is in op2-rs notes/findings-shell.md.
./shell.sh --drive $ORACLE/shell/eden1.txt --script $ORACLE/missions/e01.txt \
--ini Music=0 --ini GameSpeed=10 --audio --out /tmp/shell # about 3 minutes: the movies play in real time| option | |
|---|---|
--drive FILE |
driver script (below) |
--out DIR |
output directory |
--desktop WxH |
virtual desktop and X screen, default 800x600; the game's 640x480 window is centred in it |
--ini KEY=VALUE |
a [Game] key in the run's private outpost2.ini (repeatable): IntroAVI, ShowMovies, Music, Sound, GameSpeed, Demo ... |
--script FILE |
in-game tick script (the order verbs of "Scripted input", e.g. a mission's winning orders); its ticks count from each game's start. Input verbs don't apply: the driver's real input does |
--audio, --presents, --telemetry-every N |
in-game audio.jsonl, present.log and telemetry.jsonl, as in mission runs |
--timeout S |
wall-clock limit (default 600) |
--clean-saves |
run with an empty OPU/saves (the SAVE and LOAD dialogs list SGAME*.OP2 from there): the old saves are set aside and put back after the run, and the run's own go to <out>/saves (shell_saveload); such runs take turns (a lock file beside the folder) |
--window WxH|max |
the window's size: [GAME_WINDOW] of prefix-harness's OPU/outpost2.ini set for the run (at the desktop's top left, [DESKTOP] the run's desktop; max: MAXIMIZED=1) and put back after; such runs take turns. Make --desktop at least as large. Wine rounds the window up to a multiple of 32 px, so pick multiples of 32 (shell_window_*, op2-rs notes/findings-shell.md "The shell window's size") |
Driver steps, one per line (# comments; times in wall-clock seconds):
wait <ev> [field=value | field=~regex ...] [timeout=S] the next shell.jsonl event of kind <ev> whose fields match
sleep <S> e.g. to let a movie play
shot <name> | shots <name> <count> <every S> screenshots, cropped to the game's window
click <x> <y> [right] | move <x> <y> desktop pixels
unfocus | minimize | restore focus another window, minimize, or restore the game window
ctl <text | id=N> [in=<window regex>] [right] click a control's centre ('_' = space, '&' ignored)
key <keysym> ... X keysyms: Escape Return space n alt+n Down F1 ...
dump [tag] | mark <text> | quit
state [tag] [after=N] the game's tick, pause flag, active/iconic state, cursor handle,
selection with the units' commands, and view: running, read on
the game thread N ticks on; paused, at once (unlike telemetry)
Outputs: shell.jsonl (below), driver.jsonl (every step with its time on shell.jsonl's clock), shot_<name>.png
(640x480, the game's window), flow.json (the deterministic part of shell.jsonl: dialogs and their controls,
layouts from the dumps in window coordinates, state messages, files, movies and how they ended, game start and
end, the state probes without their ticks; byte-identical between runs), summary.json (movies with timings, the window rectangle), the private
outpost2.ini, harness.log, wine.log. Shots of static screens (named ref_* by convention) double as reference
images: they reproduce too, except that dialog text (OPU's fonts) occasionally lands 2 px lower in one run than
another, probably a race between OPU loading its private fonts and the first dialog, so compare with a tolerance.
shell.jsonl, one event per line with ms since the module loaded (harness.cpp "Shell mode"):
mci: everymciSendCommandAof OP2Shell.dll (the movies through theavivideodevice,music1.wavthroughwaveaudio),notify: MM_MCINOTIFY as the shell gets it (1 = played to the end).post/send: the shell's state messages (0x720 main menu, 0x72B play the movie in this+0x53C ...).modal/modal_end/modeless: IDlgWnd::DoModal/DoModeless (0x431A80/0x431BF0) with the dialog resource and its DLL, the caller and the return value (the next state);dialog(after WM_INITDIALOG, with every control),show/hide/destroyfor top-level windows.file: ResManager::OpenStream/GetFilePath asked by the shell;lib: its LoadLibraryA;input: key and button messages as the game receives them;start: TApp::StartSingleGame's GameStartInfo;activate_shell: TApp::ActivateShell (0x486DB0), the game handing back to the shell with its end reason;game: first cycle, every 500 ticks, the end reason changing;script:doneafter every tick-script command has run (the first-cycle event precedes these commands, so wait forscriptwhen the setup matters);dump: windows and controls on request;state: the tick, the pause flag (0x56E9E0) and count, active and iconic flags, the cursor's handle (to compare within a run), the selection (0x547210) with each unit's command, and the detail view's top-left corner in file pixels, on request: running, by the game thread at the end of a cycleafterticks on (one cycle's state, a tick count rather than wall time); paused or outside a game, by the control thread at once. Probe what an order did after enough ticks for it to be under way and not yet over, so flow.json stays the same from run to run.
Notes: the program must be started by its full Windows path (wine explorer /desktop=NAME,WxH Outpost2.exe with a
relative name silently starts nothing). The harness Wine has no audio device, so MCI waveaudio fails at once and the
shell's music loop (close and reopen music1.wav on every notification) floods the message queue, starving the main
menu's timers and the driver's input: use --ini Music=0 (the default ini plays music). Movies play at the right
speed without a device. Each run uses its own desktop name, so parallel runs on other displays don't share it.
Two to six copies of the original play a LAN game over 127.0.0.1, each logged from inside (op2-rs docs/multiplayer.md "Stage 2a"):
cp -a ../prefix-harness ../prefix-net && make install HARNESS_PREFIX=../prefix-net # once
OP2_HARNESS_PREFIX=$(realpath ../prefix-net) ./netgame.py --out /tmp/net --ticks 3000 --turbo \
--script0 $ORACLE/net/ml2_08_p0.txt --script1 $ORACLE/net/ml2_08_p1.txt
OP2_HARNESS_PREFIX=$(realpath ../prefix-net) ./netgame.py --out /tmp/net3 --players 3 --mission 3P --vehicles 4 \
--seed 3003 --eden 1 --ticks 5000 --turbo --script0 ... --script1 ... --script2 ... # recordings.toml net_ml3_08
op2-missionhost netlog /tmp/net --bundle /tmp/net/desync.tar.gz # op2-rs: replay and compare- Shell-mode runs boot the game in their own Xvfb and virtual desktop in one prefix: the host creates a TCP/IP
game, the joiners join 127.0.0.1 one after another and tick Ready, the host starts it; the drivers (written into OUT
as
host_drive.txt,join_drive.txt,join2_drive.txt, ...) wait for each other with shell.py'stouchandwaitfilesteps in--sync DIR. Use a prefix of its own (OP2_HARNESS_PREFIX; netgame.py refuses prefix/), not prefix-harness, whose wineserver other tools share. About a minute per game, most of it the boots and the lobby. - The lobby:
--players N(NEW NETWORK GAME's Max Players),--type(its Scenario Type: Last One Standing, Midas, Resource Race, Space Race, Land Rush),--mission(a regex on GAME SETTINGS' scenario list of that type; default its first, 2P LoS 'Ambush Alley', ml2_08.dll),--vehicles(Initial Vehicles),--eden 0,2(who ticks Eden; the rest play Plymouth),--dll(the mission DLL the scenario must be: netgame.py fails when any copy's start_info.json differs from what was asked: seed, DLL, players, Eden boxes, vehicles). shell.py'spickstep chooses a combo box entry with the keyboard and checks it (the window dumps list each combo box'sitemsandsel). Player k's units are the scenario's slot k; their ids depend on the seed (the bases' layout), so write a script for a fixed--seed(check the ids withop2-missionhost play DLL --start-info). --speed 10,3: each copy's Game Speed preference (its private ini's GameSpeed; one value for every copy). Every shell-mode game writes aspeedevent to shell.jsonl at its first cycle and whenever GameImpl's speed changes, and summary.json'spacinggives each copy's speed spans with the ticks per wall second it ran at (from net.jsonl's datagram times; meaningful without--turbo). Runnet_speed(op2-rs docs/multiplayer.md "Game speed").- Chat and other input verbs (
key enter,type TEXT,key enter) work in the tick scripts as in single player: the chat line's ctChat goes through the turns like an order. Every shell-mode game writes each status message to shell.jsonl (msgevents: tick, SoundID, place, text; a chat line is "NAME: text" with SoundID 93), which op2-missionhost compares with op2-sim's chat lines per machine. A script with input verbs adds each machine's ownviewandselectedto its telemetry; netgame.py's comparison of the copies leaves those out. Runnet_chat(op2-rs docs/multiplayer.md "Chat"). --seed N: every copy starts from seed N instead of the lobby's (harness OP2H_NET_SEED; the PrepareGame hook in harness.cpp "Network games"), so the game can be recorded again from its arguments: in op2-rs, a recordings.tomlnetgamerun whoseargshold--seedis recorded by its runs.py (tools/agent/record --replace NAMEwithOP2_HARNESS_PREFIXset to prefix-net).shell.py --net(harness.cpp "Network games") writesstart_info.json(the lobby's GameStartInfo at the first cycle),packets.jsonl(every player's command slot as it executes, every 4th tick) andnet.jsonl(every UDP datagram, sent and received), and makes a--script's orders the local player's own commands: their packets go into the player's pending slot, as the UI's do, and cross the network. Orders for another player's units are skipped; verbs that change the game directly (spawn, setcell, poke, ...) would make the copies differ. One order per player per 4-tick turn; an order given at a tick that is a multiple of 32 takes the slot of that turn's machine settings (executed at ticks 8 mod 32) and replaces them, harmlessly: harness.log WARNs when an order replaces a pending command or a queued order didn't run.packetlines are refused (they would run on one machine only).--turbolifts the frame limiter (the turn exchange still keeps the copies in lockstep) and draws the panes 4 ticks in 256, and every tick from--draw-from TICKon (netgame.py passes its--ticks, so the closing screenshots are current).- OUT/summary.json says whether every copy executed the same orders as the host and kept the same telemetry on every
tick both have (they quit a few ticks apart in turbo). Recorded games: the
net_*runs in op2-rs accuracy/recordings.toml; net_ml2_08 (the lobby's own seed) isn't re-recordable: runs.py checks its files and refuses to record it. op2-accuracy's missions check replays every one (metricnet.<run>.exact_ticks, scoreboard row "Network games of the original replayed").
- Internal tile x = file tile x + 32 on non-wrapping maps (
GameMap::GetPaddingWidth()), and + 0 on 512-wide world maps, which wrap. Internal y = file y. Pixel = tile * 32 + offset, with unit centres at +16. The status bar shows file + 1 on both axes. This was verified for cm01 and newworld by comparing the game's whole cell grid with the .map file. - Exact position: while a vehicle steps between tiles,
PathContext::numPixelsMoved{X,Y}hold the 16-bit fraction of a 16.16 position, biased by 0x8000, aroundpixelX_/pixelY_. They read 0 when no step has started.
A bot is a DLL that exports extern "C" void __cdecl HarnessBotTick(int tick). The harness calls it after every game
cycle, and it can use all of TethysAPI to read state and give orders. bots/sample_bot.cpp sends player 0's idle
tanks at the nearest enemy. Build it with make, then run with --bot build/sample_bot.dll.
bots/easter_bot.cpp reproduces Dan's Dog (EGG_MODE=dog, with test/egg_dandog.json) and shows the unused Santa
(EGG_MODE=santa, with test/egg_santa.json) in the original; see op2-rs notes/findings-easter-eggs.md.
bots/hitmask_bot.cpp writes hitmask.jsonl: for every pixel round each unit, what a click there selects and what a
command targets, asked of the game's own point query at the scenario's hitTicks ($ORACLE/input/make_hit.py,
op2-rs notes/findings-input.md "What a click hits").
All orders, from scripts or bots, should use command packets: Unit::Do* helpers that build a CommandPacket, or
PlayerImpl::ProcessCommandPacket. Avoid TethysAPI's Unit::DoMove(). It calls MapObject::CmdMove(), the AI/ScGroup
path, and on a human player's unit it crashed the game as soon as a frame was rendered. The harness move verb builds
the Move packet itself (see IssueMove).
Commands like these run immediately on the local player, which is fine in single player. In multiplayer they would
desync: real player input is queued through the network layer (GameNetLayer) and runs on every machine at the same
command-processing tick. A multiplayer-safe bot would have to queue packets there.
op2-rs plays the same recipes in op2-sim and compares them with a run of the original, field by field and tick by tick.
Its tools come from a built op2-rs checkout (cargo build -p op2-inspect -p op2-engine -p op2-missionhost in its
tools/), with OP2_DIR set to the game.
- A scenario:
op2-inspect compare --scenario $ORACLE/scenarios/S.json --oracle RUN --out DIR, where RUN is this harness's output for that scenario. op2-sim loads RUN'sgamedata.jsonas its rules (unit stats, terrain speeds), replays the scenario, and printsMATCHor each trace field's first divergence, with a trajectory plot (DIR/plot.png). Without--oracle, op2-inspect runs the harness itself (Linux, inside an op2-rs checkout). - On Windows, in one command:
python -I windows/compare.py $ORACLE/scenarios/S.json --tick Truns the original, the compare above, and op2-engine's picture of tick T, and writesside_by_side.png(windows/README.md). - A mission (a win script or a recording):
op2-missionhost play M.dll --script S --compare RUN, RUN being this harness's run of the same mission and script (--telemetry-every 10 --trace-every 4for a fine comparison). - Many random scripts:
op2-inspect fuzz("Differential fuzzing" above). - op2-rs's accuracy suite (
make accuracythere) compares every recorded run of the original; itsaccuracy/recordings.tomllists each run's recipe andaccuracy/manifest.jsonthe hashes anyone with the game can reproduce.
The outputs are plain JSONL and PNG (see "Outputs"), so another reimplementation can be checked the same way: play the
recipe, write the same fields, compare. Marcelo Gonçalves's C++/SDL2 port
(marcelomgoncalves/outpost2, and its fork outpost2-port) does this
through its --harness mode: windows/compare_port.py compares it with the original (and op2-rs) and draws the
three side by side, and windows/trace_diff.py compares any two traces (windows/README.md).
src/harness.cpp builds OPU/Harness/harness.dll, an op2ext ini module listed in OPU/outpost2.ini
([ExternalModules] Harness=yes). op2ext calls InitMod from its TApp::Init hook. If the environment has no
OP2H_MISSION, InitMod returns at once and the game behaves normally. Otherwise it:
- Points the game's settings object (
g_configFile.iniPath_) at a copy ofoutpost2.iniin the output directory, with music and sound off and window geometry set. The game writes its window and desktop size back to the ini while it runs, and this keeps those writes away from the user's settings. - Sets
TApp::bShowShell_ = 0, the same thing the hidden/noshellswitch does.TApp::CreateFrames()then callsGameImpl::LoadDebugMap()instead of opening the shell. The harness redirects that call (0x486606) to a function that fills aGameStartInfo(script name, seed, speed, difficulty) and callsGameImpl::PrepareGame(), which is whatTApp::StartSingleGame()does when the shell starts a game. - Redirects the call to
GameImpl::ProcessGameCycle()inGameFrame::OnIdle()(0x49C4BD). After each cycle it runs due script commands, calls the bot, writes telemetry and screenshots, and quits (TerminateProcess) at the tick limit or whengameTermReasons_leavesRunning. - Turbo: changes the frame limiter's
jaeat 0x49C374 tojmp, so a game cycle runs on every idle loop. It also skips the three paneOnIdle()draw calls (0x49C5FC) except during the first ticks and the 4 ticks before each capture, because rendering costs about 10x more than simulating. Skipping draws does not change the simulation: telemetry with screenshots matches telemetry without them, byte for byte. - Replaces the game's modal message box (0x41E0E0) with a function that logs the message and returns OK. A bad mission name therefore ends the run with exit status 2 instead of hanging.
Startup defaults to single-player missions. Missions whose names start with t get the Tutorial type, all others
Colony. With --campaign the start info is a MissionResults with isCampaign set (op2-rs notes/findings-missions.md).
With --players N (2-6), startup instead reads the mission's real type and maximum from DescBlock, initializes N
human players plus any DescBlockEx AI slots, and uses the original multiplayer defaults: high resources, day/night
on, morale steady on, zero initial vehicles, cheats off, and disasters off for Land Rush / Last One Standing but on
otherwise. The lobby's host is Eden; later slots use the original's alternating colony flags. The no-shell path has no
NetTransportLayer, so PrepareGame uses the original's playback branch while preserving isMultiplayer and the player
setup, then restores playback. This is an offline start, not a network session; harness.log records the whole profile.
Measured speed with turbo depends on machine load and map size. Cases seen: a 16-unit map ran at about 500 to 50,000 ticks/s with occasional screenshots, about 100 ticks/s when rendering every tick, and 36 ticks/s at real-time speed 10. Wine and Xvfb startup add about 3 s per run. Parallel runs work: each gets its own Xvfb and output directory, and they share the harness prefix's wineserver.
Own prefix (Linux). The prefixes sit beside this folder. When ../prefix-harness/ exists (a plain copy of the
prefix you play from: cp -a ../prefix ../prefix-harness), run.py and make install use it (override with
OP2_HARNESS_PREFIX, an absolute path, or make's HARNESS_PREFIX; prefixes.py). make install and uninstall refuse
the play prefix ../prefix, so without ../prefix-harness/ they need one of the overrides. Harness runs then have their
own wineserver, so they can't take focus from, or replace DLLs under, a game being played from ../prefix. Mission
DLLs you install into ../prefix (OPU/maps) are mirrored into the harness prefix before each run.
Own game copy (Windows). windows/run_win.py setup copies the game to game/ and installs the harness there; the
install you play from is never written to (setup refuses to install into it).
One installed harness per prefix. make install replaces OPU/Harness/harness.dll and zScenario.dll for every
run from that prefix, including other checkouts of this folder. The builds are reproducible (no PE timestamps), and
run.py warns when the installed copy differs from this folder's build/ (another version is installed, or yours
isn't). Before installing, check that no other runs depend on the installed version.
- Normal play is unaffected while
OP2H_MISSIONis unset. To unregister the module completely (Linux):make uninstall. This removes the ini lines andOPU/Harness/. On Windows, delete thegame/copy. OPU/outpost2.ini.pre-harnessis a backup of the ini taken before the first install.make checkputs its test mission inOPU/maps/HarnessTest/only while the check runs, so it never appears in the in-game lists.
- The game rounds the window size, so
--sizeis only approximate. - If a mission ends on its own, the final screenshot may show a frame from up to
--screenshot-everyticks earlier, because rendering was skipped. Add--draw-every 50(for example) if the last frame matters. - Only the game's own message box is intercepted. A
MessageBoxAfrom a mission DLL still blocks until--timeout, andtimeout_screen.pngshows it. A crash ends the run with a non-zero exit (winedbg is disabled for harness runs on Linux). On Windows, Windows Error Reporting's crash dialog can hold a crashed run until the timeout. - Scenario runs are single player only. Spawns are vehicles and buildings of players 0 and 1 via
Game::CreateUnit. - Telemetry covers what is cheap to read, and is easy to extend in
WriteTelemetry(). Research progress, factory timers and cargo amounts need--eco(raw object bytes, decoded in$ORACLE/eco_check.py); disasters aren't covered. - TethysAPI's
Unit::DoBuild(itsLocation - Locationis a distance, not a difference) andDoMiningRoutebuild broken packets, andDoTransferCargotakes the AI path, which crashed: thebuild,routeandtransfercargoverbs send their own packets. - The game's code addresses are hard-coded for Outpost2.exe 1.2 as patched by OPU 1.4.x. They are the same addresses TethysAPI and OPUPatch use.
- Coordinates: internal tile x = file x + 32 on non-wrapping maps, no offset on 512-wide maps (Coordinates, above; $ORACLE/FINDINGS.md).
- In OPU 1.4.1, TethysAPI
Unit::DoMoveon human units crashes; send command packets instead.ScGroup::RemoveUniton a non-member crashes, and so doesFindEMPMissileTarget. Triggers naming an AI player fire immediately. Mission callback names areOnLoad/OnUnload/OnEnd, not the TethysAPI docs'OnLoadMission. - A
buildwhose site's 1-tile margin touches a cliff (or anything else the cursor refuses) is never moved: the ConVec drives to the margin's bottom-right tile and the order ends there with "can't build here", kit kept; if that tile is itself a cliff it waits beside it forever (runs eco_build_cliff, input_build_cliff_*). Only structures a mission creates (spawn, CreateUnit) are relocated: their spiral search skips the right margin column ($ORACLE/FINDINGS.md, op2-rs notes/findings-sim.md "Spawn relocation"). - The harness and every oracle run assume GOG 1.2 + OPU 1.4.1 (op2ext loads the harness).
- Linux: the harness uses its own Wine prefix,
../prefix-harness/(a copy of../prefix/), so harness runs don't disturb a game being played from../prefix/. Never run harness installs orwineserver -kagainst../prefix/. - Linux: the Wine prefix is wow64:
WINEARCH=win32isn't supported. Every run from a prefix shares its wineserver: checkpsfor a game being played beforewineserver -k, and coordinatemake install(it replaces the installed harness DLLs for every run from that prefix). - Windows: a run's game window opens on the desktop (1032x766 client area: the ini's frame padding is Wine's) and takes focus, and the harness parks the real mouse pointer in the screen's corner for the run (windows/README.md).