TO:R Data Map

Ghidra and rea: static analysis of the game's executable

Source
docs/executable/ghidra-rea.md
SHA-256
8a029d31…1ea0845f

How we read the game's own code, Tactics Ogre Reborn.exe, to learn what its files and fields mean: the tools, how they're set up on the owner's Mac, how the executable is unpacked, how to run our scripts and indexes, and how findings from them are cited. What the tools found about the executable is in the executable, not here.

Based on Tactics Ogre Reborn.exe in local/game/, SHA-256 da6504e9079ca6901569cb0fa4d0bc6eac72f6769996a8146e8be6581d9650dc, and its unpacked copy local/exe/Tactics Ogre Reborn.exe.unpacked.exe, SHA-256 7b0230a11498f40396c9048345e7cfe0ef442cb7ca8faffc6f197920d2669599.
Checked 2026-10-10
Method rea 6.3.0 (binary_session, open_binary, binary_overview, search_strings, xrefs, procedure_pseudo_code) with its Hopper and Ghidra 12.1.4 providers; Ghidra's own log of a GUI import; grep -a over the executable; the process list for the rea server; Steamless's unpacker output; analyzeHeadless runs and their logs; DuckDB over the Version Tracking matches file; Ghidra 12.1.4's bundled scripts and source (*-src.zip); xxd of the executable's headers. Outside facts cite their source.

Tools

Tool Version Where What it's for
Ghidra 12.1.4 Homebrew, /opt/homebrew/opt/ghidra/libexec Disassembler and decompiler. The owner browses and annotates in its GUI; rea drives it headless.
OpenJDK 21 Homebrew, /opt/homebrew/opt/openjdk@21 The Java runtime Ghidra runs on.
rea (rea-agents) 6.3.0 MCP server started by npx -y rea-agents@6.3.0 mcp, set in ~/.claude.json Lets agents open the executable and search, decompile and cross-reference it as tools, through a provider: Ghidra or Hopper.
Hopper Disassembler — ~/Applications/Hopper Disassembler.app rea's other provider. Its string scan found none of FileTable.EDAT, FileTable.bin or File\FileTable.cpp, which Ghidra's finds (and grep -a confirms), so Ghidra is the one to use.
Steamless source at commit cd770bf Built from source on demand (Unpacking) Removes the SteamStub DRM layer from Steam executables, so the code can be read (Packing).

Setup

  1. Install Ghidra and OpenJDK 21 with Homebrew.

  2. Put Java on the shell's PATH, and the Ghidra and Java locations in its environment, in ~/.zshrc:

    export GHIDRA_INSTALL_DIR=/opt/homebrew/opt/ghidra/libexec
    export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
    export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
    
  3. Give rea the Ghidra location in its MCP entry in ~/.claude.json, since Claude Code passes only HOME, LOGNAME, PATH and SHELL from the shell to an MCP server (likely: those four were the only variables on the running rea process, from pgrep -fl, while GHIDRA_INSTALL_DIR was set in the shell):

    "env": {
      "HOPPER_LAUNCHER_PATH": "/Users/garrett/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
      "GHIDRA_INSTALL_DIR": "/opt/homebrew/opt/ghidra/libexec"
    }
    
  4. Quit and reopen VS Code (or Claude Code) so the server restarts with them.

  5. Check: rea's binary_session lists the ghidra provider as available.

  6. Unpack the executable (Unpacking), and open the unpacked copy, never the original, in Ghidra or rea.

Ghidra projects, rea snapshots and the unpacked executable go in local/exe/, never committed: they hold the game's code. The GUI project made before unpacking, local/exe/ghidra-packed/tactics-ogre-reborn.gpr, is of the packed executable; the one to use is local/exe/ghidra/tactics-ogre-reborn.gpr (The Ghidra project).

analyzeHeadless is on PATH through a wrapper in ~/.local/bin that sets the Java and Ghidra environment, and it is allowed in .claude/settings.local.json, so agents can run it without prompts.

The Ghidra project

local/exe/ghidra/tactics-ogre-reborn.gpr holds the unpacked executable, auto-analyzed, with C++ classes recovered from RTTI and our names applied. It is where findings persist: rea can't open it (rea's limits), so anything learned in a rea session that should last goes into the names list and from there into this project.

To make it again (after a game patch, or if it's damaged), import the unpacked executable fresh, with our compiler fix before auto-analysis, then recover classes and apply names. One command, from the repo root (-overwrite replaces the program in the project):

analyzeHeadless <repo>/local/exe/ghidra tactics-ogre-reborn \
  -import "<repo>/local/exe/Tactics Ogre Reborn.exe.unpacked.exe" -overwrite \
  -scriptPath <repo>/toolchain/ghidra \
  -preScript SetCompilerScript.java \
  -postScript RecoverClassesFromRTTIScript.java \
  -postScript ApplyNamesScript.java <repo>/data/exe/names.jsonl \
  -postScript SourceFilesScript.java <repo>/data/exe/source_files.jsonl \
  -postScript ExportIndexesScript.java <repo>/data/exe \
  -postScript CallArgsScript.java <repo>/data/exe/file_open_calls.jsonl 0x1402af860 0x1402af940 0x140295760 \
  -postScript RegionsScript.java <repo>/data/exe/regions.jsonl \
  -postScript ScriptTablesScript.java <repo>/data/exe 0x140c9e300 100 0x140c9ff70 0x140c9ff80 0x140694bd0 114 0x14034e220 0x14034e1c0 \
  -postScript DecompileScript.java <repo>/data/exe/regions.jsonl <repo>/local/exe/decomp

To regenerate only the indexes, run the last six -postScripts with -process -noanalysis -readOnly instead of -import … -preScript …, then tor views to pick up any new file.

To apply new names to the existing project without re-importing: analyzeHeadless <repo>/local/exe/ghidra tactics-ogre-reborn -process -noanalysis -scriptPath <repo>/toolchain/ghidra -postScript ApplyNamesScript.java <repo>/data/exe/names.jsonl. Close the project in the Ghidra GUI first: headless runs can't open a project the GUI has locked.

Why the compiler fix: Ghidra guesses the compiler from the DOS stub and the Rich header, which SteamStub overwrote (Packing), so the executable imports with compiler unknown. Ghidra's RTTI analyzer then stays off, and RecoverClassesFromRTTIScript stops with "This script will not work on this program type" (confirmed: our first run did exactly that; the check is AbstractCreateDataTypeModel.isWindows() in Ghidra 12.1.4's MicrosoftCodeAnalyzer source, which requires compiler visualstudio or clang, and PeLoader.CompilerOpinion.getOpinion() returns unknown when e_lfanew is above 0x80 with no Rich header). Setting the compiler on an already-analyzed program isn't enough: the failed run had recorded the RTTI analyzer as run, so the script skipped it and reported "No new classes recovered". Hence a fresh import with SetCompilerScript as a pre-script. Check the script log for its summary ("Identified N classes to process"); "No new classes recovered" means the fix didn't take.

Our Ghidra scripts

Our own Ghidra scripts are Java in toolchain/ghidra/ (../../toolchain/ghidra/), the one exception to the C#-only toolchain: Ghidra 12.1.4 runs scripts only in Java or PyGhidra (it has no Jython module), and ships no script that imports a names list (ImportSymbolsScript.py was Jython). analyzeHeadless -scriptPath loads them from there. Each takes its paths as arguments.

Script What it does
SetCompilerScript.java Pre-script for a fresh import: sets the compiler to visualstudio:unknown and turns on the RTTI analyzer (why).
ApplyNamesScript.java Applies names.jsonl (../../data/exe/names.jsonl): each row's name to the function at its address (or a label), its comment, confidence and doc to the function's plate comment (or a pre comment). Safe to re-run.
SourceFilesScript.java Writes the source files (.jsonl): one row per function that references a source-file string (The source files).
ExportIndexesScript.java Writes functions, calls, strings, fourcc and classes .jsonl into a folder (The indexes).
CallArgsScript.java Writes the decompiler's arguments at every call to the given functions (.jsonl); used for which game files the code opens (The indexes).
RegionsScript.java Writes the region map (.jsonl): every function assigned to a system by address (The region map).
ScriptTablesScript.java Writes the event script VM's opcode records and native table, and the story task opcode table (script_opcodes.jsonl, script_natives.jsonl, task_opcodes.jsonl) into a folder, from the table addresses and the stack pop functions given; makes a function at any handler that has none (Event scripts).
DecompileScript.java Decompiles every function into local/exe/decomp/, one .c file per region of the region map, named by its start address. For reading or grepping many functions at once; it's the game's code, so never committed.
VtOptionsScript.java Sets the options of Ghidra's AutoVersionTrackingScript.java for a headless run: Ghidra's defaults, except a minimum function length of 1 byte instead of 10 for the exact and duplicate function correlators (A new build).
ExportMatchesScript.java Writes a Version Tracking session's accepted matches, and the old functions with none, as the matches file (.jsonl) that tor migrate reads (A new build).
TableReadsScript.java Writes which code reads each game table (.jsonl): every file opened through FileTable_OpenById, where its pack is kept, the entries and rows taken out of it (entries found by their pakd ID too), and every load from a row with how the value is used, by the decompiler's data flow (Table reads).

Ghidra's own scripts we use: RecoverClassesFromRTTIScript.java (Decompiler module), which names classes, vftables, constructors and destructors from the executable's RTTI; results show in the Symbol Tree under Classes, and class layouts under ClassDataTypes in the Data Type Manager. AutoVersionTrackingScript.java (VersionTracking module), which matches a new build's functions and data to an old one's and carries the old one's markup over (A new build).

The names list

data/exe/names.jsonl (../../data/exe/names.jsonl) is our one list of names for the executable: one JSON object per line, with address, name, comment, confidence, how_found and doc, one row per function or address. It's committed: it holds our names and the addresses of one build, not the game's code. It's view exe.names in DuckDB. Every name added in Ghidra or a rea session that should last goes here, with how it was found and its confidence; then ApplyNamesScript puts it in the Ghidra project. Names are System_Action (FileTable_Load), the system being the game's class or source-file name where one is known (SoundManager_, FileDesc_).

At the start of a rea session, apply the function rows with annotate_native_function (name, and the comment at the entry), since rea starts from a fresh import every time.

The source files

The executable's assert and log strings name its source files (File\FileTable.cpp, BattleData.cpp, …). SourceFilesScript finds every defined string that is a source file's path and writes data/exe/source_files.jsonl (committed, regenerated by the command above): fields source_file, source_string, string_address, function_address, function_name, reference_address, one row per function that references the string. Browse it in DataGrip as view exe.module_map in local/views.duckdb, or query it with DuckDB, to find where a game system's code lives. Paths are normalized (lower case, /, the build root h:\tacos\mainline\programs\ dropped), since the executable spells some files two ways (h:\tacos\...\menu\MenuWindow.h and H:\Tacos\...Menu/MenuWindow.h). A function can show up under a module it doesn't belong to, when a header or helper from another file is inlined into it, so treat a row as a lead until the function is read. What it found is in Source modules.

The region map

RegionsScript splits the code into regions and labels each with the system it belongs to, so every function has a place even when it has no name. It relies on the Microsoft linker keeping each object file's functions together: the 13 .cpp files with 4 or more functions in the source files index each sit in their own address range, with no overlap (likely, from exe.source_files). How a function gets its label, in order:

  1. Anchors. A function that references a .cpp source file takes that file (anchor kind source file); one that references a library's telltale string (FreeType's property names, libjpeg's errors, [sead] logs, …) or that Function ID named as zlib's takes that library; one named in a class or namespace by RTTI takes it (Taco::Application::X, bl::gfx, Mx::Graphics, Bullet's bt*, SQEX::Sd as sead). Class names on functions under 32 bytes aren't anchors: the linker merges identical small functions, so their names can sit far from their class.
  2. Between. An unknown function between two anchors with the same label, at most 64 functions apart, takes it. Import thunks are skipped when looking for the anchors on either side.
  3. Calls. An unknown function between two different labels takes the one more of its direct callers and callees have.
  4. Steps 2 and 3 repeat until nothing changes. What's still unknown keeps only a kind (game, engine, library, runtime), when both sides agree on it.

regions.jsonl has one row per run of functions with the same label: start_address, end_address (last byte), system (null when unknown), kind, functions, bytes, how (functions per step: anchor, between, calls, unknown), anchors (anchor functions per kind of evidence), first_function. A function's system is the region whose range holds its address. On the run of 2026-10-10, 75.9% of code bytes have a system. Every label is a lead (at most likely): a region can run past its file's real end, and a .cpp anchor can come from inlined code. Reading the code found where the map goes wrong: compiler-generated code whose position says nothing about its owner (static initializers at the start of the code, exception-unwind code after 0x140646540, atexit destructors at the end) takes the label of whatever anchor is nearby, and libraries with no telltale strings (libvorbis, most of libjpeg) stay unknown. What the map shows is in What's in the executable.

The indexes

Indexes exported from the Ghidra project, all .jsonl in data/exe/, committed, regenerated by the command in The Ghidra project. Each is a view in schema exe of local/views.duckdb (built by tor views), so it can be joined with the decoded data. Row counts are from the run on 2026-10-10. Every row is static analysis, so a lead (at most likely) until the code is read.

View File Rows One row per
exe.functions functions.jsonl 21,778 function: address, name (with namespace), namespace, size, name_source, thunk_target, calling_convention, signature
exe.calls calls.jsonl 88,387 direct call: caller_address, caller_name, callee_address, callee_name, call_address. Calls through pointers and vftables aren't in it.
exe.strings strings.jsonl 25,763 defined string and function referencing it: string_address, value, type, reference_address, function_address, function_name (nulls when unreferenced)
exe.fourcc fourcc.jsonl 356 32-bit constant operand whose bytes are text: value, text (memory order), text_reversed, instruction_address, instruction, function_address, function_name
exe.classes classes.jsonl 2,452 class namespace: class, bases (from its RTTI base class array, nearest first), vftables, functions (member count)
exe.source_files source_files.jsonl 482 The source files
exe.file_open_calls file_open_calls.jsonl 72 call to FileTable_OpenById, FileTable_OpenByName or FileDesc_OpenPath: callee_address, callee_name, call_address, caller_address, caller_name, args (constant arguments as hex, else null)
exe.names names.jsonl 636 The names list
exe.regions regions.jsonl 1,473 The region map
exe.script_opcodes script_opcodes.jsonl 100 event script opcode record (Opcodes)
exe.script_natives script_natives.jsonl 328 event script native and its handlers (Natives)
exe.task_opcodes task_opcodes.jsonl 114 story task opcode: operand size and handler (task.md)

Using them:

Table reads

TableReadsScript is the code stage of batch mapping (../plan/R34-pipelines.md#batch-mapping): from each call that opens a game file to every load from a row of its tables, followed through the decompiler's data flow (high P-code), not by matching the decompiled text. It writes local/exe/table_reads.jsonl, never committed (it holds one build's addresses and is rebuilt in under a minute). Close the project in the Ghidra GUI first, then from the repo root:

analyzeHeadless <repo>/local/exe/ghidra tactics-ogre-reborn -process -noanalysis -readOnly \
  -scriptPath <repo>/toolchain/ghidra \
  -postScript TableReadsScript.java <repo>/local/exe/table_reads.jsonl \
    <repo>/data/game/FileTable.bin/payload.filetable/files.jsonl \
    <repo>/data/shape/tables.jsonl <repo>/data/exe/names.jsonl <repo>/data/exe/regions.jsonl \
    <repo>/data/game

It reads the decoded file table (tor export) for group and file IDs to paths, data/shape/tables.jsonl (tor validate) for which entries are tables and their row sizes, the names list, the region map, and the decoded data folder for each pakd's entries.jsonl (each entry's id, containers.md), to turn an ID the code searches for into an entry. A seventh argument gives the opener's address (default 0x1402af860, FileTable_OpenById); an eighth, a comma-separated list of function addresses whose every store and load the script logs with the values it gave them (and its ID searches and switch cases), for working out why a table has no reader.

How it works

  1. Summaries. Every function is decompiled (in parallel) and its high P-code turned into expressions: constants, parameters, loads, call results, sums and scaled terms, loop indexes (a MULTIEQUAL that steps by a constant counts its loop's iterations; one that steps by a value, such as an entry's row size *(int *)(E + 0xc), is the iteration count times that value), and choices (other MULTIEQUALs). A loop entered through a copy of its counter ((int)uVar7 of an 8-byte counter) is still seen as that loop. Kept per function: its calls with their arguments, stores, loads (each with how its value is used, from the varnode's descendants, and what it's compared equal or not equal with: the other side, any constant added on the way, and the switch case block the addition is in, with the parameter the switch is on), returns and stack writes.
  2. Openers. Every call to the opener, and to a function that returns its handle (followed up to 3 levels), with the group and file ID expressions resolved where the data flow allows: constants; the callers' arguments (up to 3 callers up; one context per caller, so a wrapper called for several files is followed once per file); a global field whose every store is a constant or such an argument (SoundManager+0x20, 400); constants packed into a stack array (SoundManager_OpenVoiceDamage). The IDs give the game path through the file table. Then where the read buffer goes: the std::function lambdas handed to FileDesc_ReadUnwrap and its kin (the vftable in the stack slot, _Do_call its third function; the stores it makes, with its captures replaced by what the opener wrote after the vftable), a plain callback registered with a context (0x140295e80(handle, callback, this): the callback's parameters from the second on are the context), or a buffer the opener allocated and passed in. Where the buffer is stored is a holder of the pack. A place each caller context fills with its own file, in a wrapper that doesn't return it (the event chapter's pack at 0x140d68120, opened as one file or as any of its group), holds every context's file, heuristic. A buffer stored into an object the opener made by a call (FUN_14042b620 makes the credits object and its lambda keeps the pack at +0x7d0) is matched as a this field of the opener's source folder, heuristic. A global a read callback sets to a global's address, and that is otherwise only ever set to 0, is read as that address (the scene object keeps itself at 0x140d68248).
  3. Fixpoint. Over every function, until nothing new is found (21 passes): a load from a holder gives what it holds; P + *(u32 *)(P + 8 + 4k) is entry k of pack P (Reading a pakd), ? when k is a loop index; when the same loop loads P's ID table (*(u32 *)(P + 0xc + 4 × count + 4k), the ids after pack_size), k is the entry found by its ID: compared with a constant (bg_common.dat's 0xce), the entry with that ID in each candidate file's entries.jsonl; with a constant plus an unknown (EventScene_FindEntry's n + 0xd000 for kind 7, the scene's 0xa000 + u16), any entry whose ID is in that range (the unknown's width, from its type before any extension); with no comparison but the ID used to index a store (slot[ids[k]] = P + offsets[k], the chapter callback 0x140350880), each slot the entry its ID picks. A switch case's constant counts only when the switch's parameter matches the case, so a function given the kind and the ID (EventScene_FindEntry) is followed per call, with its callers' arguments, not as a getter of any entry. An entry that is itself a pack (#75) is read the same way; E + *(int *)(E + 8) is the rows of entry E, plus i × *(int *)(E + 0xC) or i × size (How the game reads it). A pack, entry or rows stored somewhere makes a holder; stored in a loop beside the loop's entry (slot[i] = pack + offsets[i]), each slot gets its own entry. Passed to a function, it seeds the callee's parameter (not for helpers called from more than 40 places); an object passed by pointer seeds the callee with the holders inside it. A function returning one is a getter, or its return is worked out from each caller's arguments. A parameter every caller passes the same global's address in (a singleton's this) is that global.
  4. Reads. A last pass writes every load from a row, or from an entry past its 16-byte header, with the files' row sizes deciding which tables a candidate is: the entry must exist, a constant stride must equal the row size, the offset must fall in the row. For an unknown entry (?) of a pack: the tables at that level of the pack whose row size the constant stride equals, or, when the code steps by the entry's own row size or the entry is one found by its ID, every table the offset fits; an ID range keeps only the entries in it.

What it writes

One JSON object per line, each with kind, and function (address) and name for the code it's about. confidence is data_flow when every step was data flow and one file and table are left, heuristic when any step guessed: IDs resolved only to a group (then every file of it), a this field matched by offset within its scope, an array slot matched by index, several candidate files or tables (also when the row size and stride leave one table of several candidate files), an entry found by a loop and matched by row size, an entry found by an ID in a range (not a constant), a place filled by several caller contexts, an object the opener made matched as this, a lambda with no function in the project. An entry found by a constant ID stays data_flow: the ID comes by data flow, and the file's own ID table gives the entry. Either way it's static analysis: a lead, at most likely.

kind One per Keys
opener call that opens a file, per caller context call_address, via (the opener or the wrapper called), context (the caller's constant arguments), group_expr, file_expr (the ID expressions), group, file (resolved values, hex), group_base (a constant plus an unknown), games (first 40), game_count, games_with_tables (all), how, confidence
holder place a pack, entry or rows is kept key: 0x… a global, [0x…]+0x30 a field of the object a global points to, this@<scope>+0x… a field of this within a source folder (from the region map) or class, call@0x… a call's result; value (pack, entry, rows), game (when one), games, game_count, entry, ids (when found by its pakd ID), delta, how, confidence
entry place code takes an entry out of a pack value, game, games, entry (? for a loop over entries, or an entry found by an ID in a range), ids (the ID, or the range 0x…-0x…, it was found by), delta, how, confidence
pack_getter, entry_getter, row_getter function returning one as entry; how says whether it does whatever its arguments are, or given its callers'
read load from a row, per instruction and table instruction, game and entry (when one table), tables (all), table_count, candidates (the files the pack could be, before the row size picked the tables), base (row, or entry for a load past the header), offset (in the row), row (a constant row's number), width, ctype (Ghidra's type of the value), signed, float (from the type and the ops on the value), indexed, stride (bytes per row the code steps by, or row_size when it steps by the table's own), uses, ids (as entry's), how, confidence
table row of tables.jsonl game, entry, readers (by data flow), heuristic_readers, confidence (null: no reader found)

uses holds what the code does with the value read: compared (constants it's compared with), bits (masks it's tested with), cases (switch cases), passed_to (calls it's an argument of: function, arg, name, ours when in the names list), text_lookup (passed to a Text_ function), index_of (tables whose rows it indexes, or the address it scales into), stored_to (where it's copied).

Limits

The run of 2026-10-13

On the unpacked executable SHA-256 7b0230a1…: 46 s in all (41 s decompiling 21,600 functions on 16 cores, 5 s for 21 passes); 80 openers, 431 holders, 398 entries, 96 getters, 14,442 reads. Tables of tables.jsonl with a reader found, against the text-matching code stage of the batch-mapping trials (session scratchpad bm/code.cs, its code.jsonl per folder; counted from both outputs with DuckDB):

Folder Tables Text matching By data flow Any
WarrenReport/ 23 10 0 16
battle/ 1,230 2 58 59
bg/ 5 1 0 0
effect/ 2 0 0 0
event/ 460 0 10 10
menu/ 90 20 27 57
sound/ 26 0 1 26
system/ 11 10 11 11
textseq/ 152 0 0 0
title/ 4 2 2 2
worldmap/ 101 1 1 26
All 2,104 46 110 207

Five tables the text matching found aren't found here: battle_data_release.dat#82, bg_common.dat#23 (text matching found it by its row size alone), and three of menu_data.dat. The cases checked by hand, all found: SoundManager_OpenSoundTable opens sound.dat, sound_ja.dat and sound_en.dat (group 400, files 500–502) and its lambda keeps them at SoundManager+0x30, +0x38, +0x40; SoundManager_MountSoundTable reads the name offset of every row of all 11 tables of each language; SoundManager_PlaySe reads all four u32s of SECommon.dat#0's 16-byte rows (offset 0 compared with 1 and 2); Sound_PickTitleCallGroup reads sound.dat#1 at 1, 2, 3 and 6 with a stride of 8; title.dat#0's reader 0x1404afcc0 through the row getter 0x1404abc60, and #5's 0x1404abf60 (offset 2 compared with 1).

The run of 2026-10-15

R2's code stage for what the first run didn't reach: entries found by pakd ID, and the readers of textseq/ and bg/. Why those had none: bg_common.dat and the event scenes and chapters take their entries by searching the ids table, which the first run left as an unknown entry ? that no table matched; the scene's pack is kept through the scene object's pointer to itself (0x140d68248), and the chapter's in a place two caller contexts fill; textseq_credit.dat's buffer goes into an object the opener makes (FUN_14042a800's); and the caption's (TextSeqCaption_Load, this+0x318) is read in textseq/textseqcaption.cpp while kept from region Taco::Application::TextSeq, two scopes that didn't match. How it works and Limits say what changed.

On the same executable: 47 s in all (41 s decompiling, 6 s for 21 passes); 80 openers, 2,083 holders, 449 entries, 327 getters, 17,243 reads, written as local/exe/table_reads.r2-10.jsonl and then moved to local/exe/table_reads.jsonl (the first run's kept as table_reads.2026-10-13.jsonl). data/shape/tables.jsonl had grown since the first run (WarrenReport/ 23 → 64 tables, system/ 11 → 12). Tables with a reader found, against the first run (counted from both outputs with DuckDB):

Folder Tables By data flow, 10-13 → 10-15 Any, 10-13 → 10-15
WarrenReport/ 64 0 → 0 16 → 27
battle/ 1,230 58 → 58 59 → 59
bg/ 5 0 → 4 0 → 5
effect/ 2 0 → 0 0 → 0
event/ 460 10 → 10 10 → 451
menu/ 90 27 → 27 57 → 90
sound/ 26 1 → 1 26 → 26
system/ 12 11 → 12 11 → 12
textseq/ 152 0 → 0 0 → 152
title/ 4 2 → 2 2 → 2
worldmap/ 101 1 → 1 26 → 51
All 2,146 110 → 115 207 → 875

event/'s nine left are the chapters' entry 0 (PGRS, ID 1), which the slot its ID picks (0x140d681d8) holds but no reader reads as rows. The scenes' readers are all heuristic: the scene file is opened as group 900 + chapter and found by an ID range. The cases checked by hand in the decompiled code, all found: FUN_1402df220 copies the 56 bytes of row param_1 of the entry with ID 0xce (bg_common.dat#28, offsets 0–48, data_flow); EventScene_FindEntry with kind 7 (n + 0xd000) gives the scenes' 70 tables of 196-byte rows, and FUN_140377440 compares their u16 at +2 with its second parameter, as the code does; FUN_1403235e0 reads the kind 6 entry kept at 0x140d68210 (u16s at 0, 2, 4, 6 and a u32 at 8); FUN_14042f950 takes textseq_credit.dat's entry 6 (*(this + 2000) + 0x20); TextSeqCaption_Update compares each row's u16 at 0 with 3 and 1. tor map with these reads, against the first run's (same inputs otherwise): bg/ 5 layouts, tables with reads 0 → 5, agent's list 101 → 82 items; textseq/ 25 layouts, 0 → 152, 157 → 130; event/ 30 layouts over its 449 tables without a schema, 8 → 449, 786 → 943 items (the 731 "no reader found" items become 483 "read by the code", 179 "not read by the table's readers found" and 281 conflicts between the code's reads and the data, many from the kind 6 range above and from helpers such as FUN_140377550 given every kind 7 entry).

rea's limits

What rea 6.3.0's Ghidra provider can and can't do, from its own binary_session report (operations and their stated limitations), checked 2026-10-10:

So use rea for quick searching and decompiling, and the Ghidra project (GUI, or headless scripts) for anything that should persist.

Unpacking

The executable is wrapped in SteamStub 3.1 (x64) (Packing); Steamless's 3.1 x64 unpacker removes it, recognizing it by its .bind section (f.HasSection(".bind") in Steamless.Unpacker.Variant31.x64/Main.cs, checked 2026-10-10).

Steamless's release build (v3.1.0.5) is a .NET Framework program built for 32-bit x86 only, so it can't run on an Apple Silicon Mac: dotnet refuses its Steamless.CLI.exe and its plugin DLLs with "The assembly architecture is not compatible with the current process architecture". Instead, its source for the API and the 64-bit SteamStub 3.1 unpacker is compiled for .NET 10, unchanged, with a small host program that calls the unpacker the way Steamless.CLI does. Steamless is licensed CC BY-NC-ND 4.0, so this build is for private use and is never shared.

  1. Clone https://github.com/atom0s/Steamless into a scratch folder of its own.

  2. Beside it, make a project folder with this SteamlessHost.csproj, which compiles the Steamless source in place (../steamless_src/ is the clone):

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
        <Nullable>disable</Nullable>
        <ImplicitUsings>disable</ImplicitUsings>
      </PropertyGroup>
      <PropertyGroup>
        <Src>../steamless_src/</Src>
      </PropertyGroup>
      <ItemGroup>
        <!-- ViewModelBase and NavigatedEventArgs are WPF, used only by the GUI. -->
        <Compile Include="$(Src)Steamless.API/**/*.cs" Exclude="$(Src)Steamless.API/Properties/**;$(Src)Steamless.API/Model/ViewModelBase.cs;$(Src)Steamless.API/Model/NavigatedEventArgs.cs" />
        <Compile Include="$(Src)Steamless.Unpacker.Variant31.x64/**/*.cs" Exclude="$(Src)Steamless.Unpacker.Variant31.x64/Properties/**" />
        <Compile Include="Host.cs" />
      </ItemGroup>
    </Project>
    

    and this Host.cs:

    using System;
    using Steamless.API.Model;
    using Steamless.API.Services;
    
    static class Host
    {
        static int Main(string[] args)
        {
            var log = new LoggingService();
            log.AddLogMessage += (s, e) => Console.WriteLine($"[{e.MessageType}] {e.Message}");
    
            var opts = new SteamlessOptions { VerboseOutput = true };
            var plugin = new Steamless.Unpacker.Variant31.x64.Main();
            if (!plugin.Initialize(log)) { Console.WriteLine("Initialize failed."); return 1; }
            if (!plugin.CanProcessFile(args[0])) { Console.WriteLine("Not a SteamStub 3.1 x64 file."); return 2; }
            var ok = plugin.ProcessFile(args[0], opts);
            Console.WriteLine(ok ? "Unpacked." : "Failed.");
            return ok ? 0 : 3;
        }
    }
    
  3. Build it with dotnet build.

  4. Copy local/game/Tactics Ogre Reborn.exe into local/exe/ (the game files are never modified), and run the host on the copy: dotnet <project>/bin/Debug/net10.0/SteamlessHost.dll "<repo>/local/exe/Tactics Ogre Reborn.exe". It writes Tactics Ogre Reborn.exe.unpacked.exe beside it; the copy can then be deleted.

A game patch changes the executable, so it is unpacked again, and findings record which unpacked executable they came from (Citing a finding).

A new build: Version Tracking

A game patch moves the code, so every address in our names, schemas and docs points at the old build. Ghidra's Version Tracking matches the new build's functions and data to the old one's and carries our names and comments over; ExportMatchesScript then writes the matches as the file tor migrate reads to rewrite every address. The old build is the program at the project's root (/Tactics Ogre Reborn.exe.unpacked.exe); the new one is imported into a folder of the same project named for its game version (/<new> below), since a session needs both programs in one project. Close the project in the Ghidra GUI first.

  1. Unpack the new executable (Unpacking) into local/exe/<new>/.

  2. Import and analyze it as the old one was (The Ghidra project), with our compiler fix and class recovery, but not our names:

    analyzeHeadless <repo>/local/exe/ghidra tactics-ogre-reborn/<new> \
      -import "<repo>/local/exe/<new>/Tactics Ogre Reborn.exe.unpacked.exe" \
      -scriptPath <repo>/toolchain/ghidra \
      -preScript SetCompilerScript.java \
      -postScript RecoverClassesFromRTTIScript.java
    
  3. Match, carry the markup over, and write the matches file:

    analyzeHeadless <repo>/local/exe/ghidra tactics-ogre-reborn/<new> \
      -process "Tactics Ogre Reborn.exe.unpacked.exe" -noanalysis \
      -scriptPath <repo>/toolchain/ghidra \
      -postScript VtOptionsScript.java \
      -postScript AutoVersionTrackingScript.java /<new> vt "/Tactics Ogre Reborn.exe.unpacked.exe" \
      -postScript ExportMatchesScript.java /<new>/vt <repo>/local/exe/vt/<old>-to-<new>.jsonl
    

    AutoVersionTrackingScript takes the session's folder, its name and the old (source) program; the program being processed is the new (destination) one. It runs Ghidra's correlators in order (exact symbol names, exact data, exact function bytes, instructions and mnemonics, duplicate functions, then references), accepts each unique match, and applies the old program's markup with Version Tracking's default apply options: function names added as primary (without their namespace), labels added, plate, pre, end-of-line and other comments appended. It saves the new program and the session (/<new>/vt), and fails if a session of that name already exists.

Steps 2 and 3 can't be one run: during -import the program isn't saved in the project yet, so AutoVersionTrackingScript stops with "VT Session destination program … is read-only which prevents its use" (confirmed: our first one-command try did that). ExportMatchesScript can be re-run alone on an existing session (step 3 with only its -postScript).

What to check in the log (pass -log <file> to keep it apart): step 2 ends with "Identified N classes to process" and "REPORT: Import succeeded"; step 3 with "Auto Version Tracking Command completed successfully with no apply markup errors", then ExportMatchesScript's counts (old functions, accepted matches, old functions with no match, user-named functions whose name reached the new build), and no "REPORT SCRIPT ERROR". SetCompilerScript logs "ERROR … Windows x86 PE RTTI Analyzer could not be found for this program" on every fresh import, and the classes are recovered all the same (2,069, as below), so that line is harmless.

The matches file

local/exe/vt/<old>-to-<new>.jsonl, never committed (it holds the game's addresses and names of two builds). One JSON object per line, sorted by old, then new:

Key What it holds
kind function or data.
old The address in the old build, lower-case hex as the schemas write it (0x1404266d0).
new The matched address in the new build, or null for an old function with no accepted match.
old_name The name in the old build, with namespace (FileTable_Load, FUN_1404266d0, DAT_140fd0b68), or null for data with no symbol.
new_name The name in the new build after the markup was applied, with namespace; null when new is.
correlator The first correlator, in the session's order, that matched the pair (Exact Function Bytes Match, Exact Symbol Name Match, Implied Match, …); null when new is.
score Its similarity score (1.0 for an exact match); null when new is.
confidence Its confidence score, not log10 (10.0 for an exact match); null when new is.

Every accepted match is a row; an old function with none is a row with new null, so tor migrate can list what it couldn't move. Matches outside loaded memory (imported functions, which Ghidra keeps in its EXTERNAL space at small offsets like 0x1) are left out, since no address of ours points there. An Implied Match comes from references alone and can be wrong (in the test below, the one moved row is one), so treat those as leads.

The self-match test

There's no older build to test with, so the test matched the build against itself (2026-10-13; unpacked executable SHA-256 7b0230a1…): a copy of the project in local/exe/vt-test/ghidra/, and a copy of the unpacked executable imported as the "new" build. A self-match should pair every function with itself. Counts are from ExportMatchesScript's log and DuckDB over the matches file.

Run Step 2 Step 3 Accepted (in loaded memory) Functions matched Old functions unmatched Not at the same address
Ghidra's defaults (10-byte minimum) 3 m 30 s 2 m 33 s 86,691 (21,337 functions, 65,354 data) 21,337 of 21,835 498 1
VtOptionsScript (1-byte minimum) 3 m 27 s 2 m 50 s 86,852 (21,487 functions, 65,365 data) 21,487 of 21,835 348 1

In step 2, auto-analysis takes about 1 m 40 s and class recovery 1 m 45 s; writing the matches file takes 2 s. With the 1-byte minimum, the 150 functions under 10 bytes that the defaults missed are matched, with no wrong function match, hence VtOptionsScript. On that run (confirmed for this build):

So on a real patch, what tor migrate lists as unmatched will include those natives and labels, until ScriptTablesScript has made the natives' functions in the new program (with the new build's table addresses) and Version Tracking is run again (guess: not yet tried).

Working a question

  1. Open the Ghidra project in local/exe/ghidra/, or the unpacked executable in rea for quick searching (rea's limits).
  2. Find the entry point into the code with the indexes in DataGrip (The indexes): the file's opener in exe.file_open_calls, a string in exe.strings, a source file in exe.source_files, a class in exe.classes, or a caller of a function already mapped (exe.calls).
  3. Decompile it and its callees, and work out what each reads and checks.
  4. Check the result against the data: DuckDB over data/game/, or a quick C# exploration program over the original files.
  5. Write it down, citing each function's address (Citing a finding):
    • how the game reads a format → that format's doc, like filetable.md;
    • what a game system does beyond reading a format → a doc in docs/executable/;
    • what bytes mean → the format doc or table schema, at most likely until the toolchain's decode or round trip confirms it;
    • names for functions and addresses → names.jsonl (../../data/exe/names.jsonl), then ApplyNamesScript;
    • open threads → that doc's Leads.

Citing a finding

A fact found by static analysis names the function's address, the tool and the executable it was found in, for example: "0x1402b1490, Ghidra 12.1.4 via rea, unpacked executable SHA-256 7b0230a1…". Addresses are only meaningful for one build, so the SHA-256 of the unpacked executable goes in the doc's evidence block. Such a fact is at most likely until a decode or an in-game test confirms it.