Docs · 09

The Game-State Reader

The reader gives the agent and the harness the numbers a player reads off the game's panels: gold, stored goods, population, popularity, the date, troops, messages. It also tells the harness whether the game is paused, which the run loop depends on.

How It Works

Stronghold Crusader: Definitive Edition is a Windows game running under Proton, built on Unity's Mono runtime. The reader is our own code; no game file is changed, and no third-party mod is used.

  1. tools/ubuntu/run-proton.py finds the running game and its Proton environment, and starts our Windows launcher inside the same Proton prefix.
  2. The launcher (src/windows/probe.cpp) checks SHA-256 fingerprints of four game files against the build the reader was mapped for, and stops if any differ.
  3. It loads our reader DLL (src/windows/adapter.cpp) into the game process and calls it once per sample. The DLL reads published game fields through the Mono runtime, reads everything twice and rejects the sample if the two reads differ.
  4. Each sample is printed as one JSON line, and run-proton.py adds freshness and lifecycle information.

"Read-only" means it never changes game state or calls game functions that do. Loading the DLL does run our code inside the game process, and when the game's garbage collector has stalled, the reader asks the runtime for a collection (see Collector watchdog).

The tile map and whole-map summary use a second, simpler path: the probe reads the engine's tile layers from outside the process (no DLL), in about 2 seconds.

Single-Player Only

A sample is accepted only on an active local single-player map. The reader rejects the main menu, loading screens, multiplayer games, the map editor and spectator mode; those samples are unavailable, never zeros.

Running It by Hand

On the game machine, with the game running:

Shell
python3 tools/ubuntu/run-proton.py watch                     # stream until stopped
python3 tools/ubuntu/run-proton.py watch --samples 20         # 20 samples
python3 tools/ubuntu/run-proton.py watch --interval-ms 100    # sampling interval, 50–5000 (default 500)
python3 tools/ubuntu/run-proton.py probe                     # find the game and check fingerprints only
python3 tools/ubuntu/run-proton.py tiles --region 390 350 60 60  # tile layers for a rectangle
python3 tools/ubuntu/run-proton.py map                       # whole-map tile summary
python3 tools/ubuntu/run-proton.py test                      # run the Windows test programs under Proton

Readings go to standard output as JSON lines; diagnostics go to standard error. The harness samples every 100 ms (GAME_READER_INTERVAL_MS); in a live check that cost no measurable game CPU, memory or speed compared with 500 ms. watch does not start the game or reconnect after the game exits.

The Stream

Each line has a status. Only ok lines carry an observation:

StatusMeaning
okA valid sample
unavailableNot on an active single-player map, or the read failed
staleThe sample was too old or took too long to capture
game_exiting, game_exitedThe game is closing or gone
stream_closedThe reader stopped

Every line also has a session ID, a generation, capture start and end times, the time it was received and valid_until_unix_ms, 1.5 seconds after capture. Treat a sample as gone after that time, even if no newer line arrives, and never keep the last good sample as current.

The generation changes when the map, map name or local player changes, when game time goes backwards (a reload), and after any rejected sample. Polling can miss a very short change. Samples are not atomic snapshots: the double read catches most concurrent changes, not all.

What a Sample Contains

This is the full sample the harness receives. The model gets a compact summary of it with each screenshot (see Context); the full sample is saved in the run's events.jsonl.

FieldContentsNotes
local_player_id, map_name, app_modeWho and whereOnly modes 14 and 16 are accepted
game_time, pausedGame clock in ticks (30 per game second) and pause state
gold, population, popularityAs shown in the gameChecked against the game's panels
tax_indexTax level, 0 (largest bribe) to 11Not a rate or a gold amount
resources_by_name20 stored goods: wood, hops, stone, iron, pitch, wheat, bread, cheese, meat, apples, ale, flour, bows, crossbows, spears, pikes, maces, swords, leather and metal armourEach checked against nonzero amounts in the game
own_troopsTotal and 34 named types from the Army ReportNames follow the game's own spellings
settlementMonth and year, housing capacity, idle peasants, total food, rations, food types eaten and available, worker efficiency, popularity factors and upcoming change, the church and cathedral bonus (church_bonus), and the keep panel's tax figure (tax_amount)Popularity factors are 25 per point shown in the game; food is rationing and food_variety added up, and the game's religion line is religion plus church_bonus, in the same units (75 for a church and a cathedral, shown as +3). efficiency is 0 until the game first reckons it. months_of_food does not match the game's panel; do not use it
selected_buildingType, workers, vacancies, working, keep access, missing inputs, health; null without an open panel
selected_workshopFor an open fletcher's, blacksmith's or poleturner's panel: its instance id, type, and the goods numbers (as in resources) it is making and will make next; null otherwiseFrom the building record at +0x1bc and +0x1be, set by the panel's two buttons (live 2026-10-12). inspect_building and set_workshop read it
storageThe local player's stockpile piles (tile, good, amount, capacity), granaries and armories (tile, amount, capacity); null when unavailableRead from the engine's building records (below). An armory holds 50 weapons and armour of any mix and an empty one reads 0 and 0, as a granary does (live 2026-10-12)
buildingsThe local player's building records counted by engine type number; null when unavailableFrom the same records. Parts with records of their own count too: a stockpile's four piles, a quarry's pile, the keep's doors. harness/server/building-types.ts names the types. Not shown to the model; the results list them
structuresCount and limitMap-wide, including other players; use it only as a change signal
placementWhat is being placed, and in which mode
cameraCentre tile, visible tiles across and down, zoom
visible_messagesOn-screen message text, by channelSee below
managed_heapThe game's managed heap: size, used bytes, collection count, whether collection is disabled, and the watchdog's forced collectionsMemory diagnostics; not shown to the model. See Collector watchdog

Storage

A stockpile is four buildings: 2×2 piles at the corners of a 5×5 square, each holding one good. A pile's capacity depends on its good (48 for wood, stone and iron, the only ones checked); an empty pile reads amount 0 and capacity 0 and takes whichever good arrives next. When no pile is empty, a good without room in its own piles is not delivered: the market refuses to buy it and producers stop, and the game shows no text message. Each granary holds 250 food of all kinds together; an empty granary also reads capacity 0. All of this was seen live on Oasis by the Sea on 2026-10-09.

The reader takes these from the engine's records of building instances (one per id, read in place, with no game function called), filtered by an owner field seen only with player 1. buildings counts the same records by type: run on both reference games' end saves on 2026-10-10, it found the buildings the players had built. python3 tools/ubuntu/run-proton.py structures --ids FIRST COUNT dumps raw records for mapping more fields; its output is game memory, so keep it out of the repository.

Messages

The reader reports message text visible in the game's message areas, and placement feedback such as "Needs to be placed adjacent to the stockpile". The stream turns these into events: an event means "first seen on screen", not a guaranteed new game event, and it repeats only after the text disappears.

Polling can miss brief or repeated messages, and spoken lines without text are never captured. Placement and resource warnings were checked live. Low-popularity warnings and enemy messages have not been, and the skirmish chat area is not read.

Collector Watchdog

Under Proton, the game's garbage collector (Unity's Boehm collector, inside Mono) can stop starting collections for good. The heap then grows by what the game allocates, about 7.5 MiB a second whether paused or running, until the memory guard closes the game (see Status). The cause is Wine bug 59333: Wine can report a bogus stack pointer for a thread the collector has suspended, the collector's total of thread stack sizes wraps around, and the allocation threshold it keeps until the next collection becomes too large ever to reach. Any collection recomputes that threshold.

So on every call the reader also checks the collection count. When the count has not moved for 20 seconds while the heap's used bytes grew by 64 MiB, it asks Mono for one full collection (mono_gc_collect, what System.GC.Collect does; the game's finalizers stay on the game's own finalizer thread). Just before, it copies four of the collector's variables, which tell the causes apart:

FieldMeaning
total_stack_bytesThe thread-stack total from the last collection; a huge value is the Wine bug
threshold_bytesThe allocation that starts the next collection (about 23 MiB when healthy)
dont_gcNon-zero when collection is disabled
automatic_disabledNon-zero when automatic collection is off (Unity's manual mode)

Their addresses were found by disassembly and hold only for the Mono runtime whose fingerprint the launcher checks. managed_heap.watchdog gives the number of forced collections in this game process and the latest one, with these fields, how long the count stood still, how much the heap grew, the counts before and after, and how long the collection took. The harness logs each one as a gc_watchdog event (see The harness).

About 150 MiB piles up during a stall before the collection frees it; the heap grows by at most that much, and the game reuses it. The watchdog runs only while the reader is sampling, as it is whenever the harness is connected.

Limits

  • One game build (see Setup).
  • Not atomic, and expires after 1.5 seconds.
  • Single-player only.
  • If a reader call is interrupted, restart the game before reading again.

This page is docs/reader.md in the benchmark’s repository, at commit dd89b61 (13 October 2026). View it on GitHub