Ghidra and rea: static analysis of the game's executable
- Source
docs/executable/ghidra-rea.md- SHA-256
8a029d31…1ea0845f
Tools · Setup · The Ghidra project · Our Ghidra scripts · The names list · The source files · The region map · The indexes · Table reads · rea's limits · Unpacking · A new build: Version Tracking · Working a question · Citing a finding
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
-
Install Ghidra and OpenJDK 21 with Homebrew.
-
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" -
Give rea the Ghidra location in its MCP entry in
~/.claude.json, since Claude Code passes onlyHOME,LOGNAME,PATHandSHELLfrom the shell to an MCP server (likely: those four were the only variables on the running rea process, frompgrep -fl, whileGHIDRA_INSTALL_DIRwas 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" } -
Quit and reopen VS Code (or Claude Code) so the server restarts with them.
-
Check: rea's
binary_sessionlists theghidraprovider asavailable. -
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:
- Anchors. A function that references a
.cppsource file takes that file (anchor kindsource 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'sbt*,SQEX::Sdas 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. - 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.
- Calls. An unknown function between two different labels takes the one more of its direct callers and callees have.
- 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:
- To find a file's loader, query
exe.file_opens:tor viewsbuilds it whendata/game/FileTable.bin/is there, joiningexe.file_open_callsto the decodedFileTable.bin'sfilesby group (args[2]) and, when it's constant, file ID (args[3]). One row per call toFileTable_OpenByIdand file it can open: with a constant file ID, that file; with only a constant group, every file of the group; other calls once, with no file. Columns:caller_address,caller_name,call_address,callee_name,args, then the file'sgroup_id,file_id,path,size. What it found so far is in Which code opens which file. exe.fourccis mostly noise: division constants (gfff,VUUU) and TrueType tags compared big-endian (readtext_reversed). Filter by the tag you want.
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
- 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
MULTIEQUALthat 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 (otherMULTIEQUALs). A loop entered through a copy of its counter ((int)uVar7of 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. - 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: thestd::functionlambdas handed toFileDesc_ReadUnwrapand its kin (the vftable in the stack slot,_Do_callits 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 at0x140d68120, 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_14042b620makes the credits object and its lambda keeps the pack at+0x7d0) is matched as athisfield 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 at0x140d68248). - 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 entrykof packP(Reading apakd),?whenkis a loop index; when the same loop loadsP's ID table (*(u32 *)(P + 0xc + 4 × count + 4k), theidsafterpack_size),kis the entry found by its ID: compared with a constant (bg_common.dat's0xce), the entry with that ID in each candidate file'sentries.jsonl; with a constant plus an unknown (EventScene_FindEntry'sn + 0xd000for kind 7, the scene's0xa000 + 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 callback0x140350880), 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 entryE, plusi × *(int *)(E + 0xC)ori × 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'sthis) is that global. - 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
- Not path-sensitive. Which branch a function takes isn't followed, so every branch's values reach every use:
SoundManager_MountSoundTablekeeps slot 0's rows (sound.dat) and slots 1–2's (sound_ja,sound_en) in different branches, and its readers see all three. The files' tables filter what's left (the entry must exist, a stride must equal the row size), andheuristicmarks it. - Entries found by
pakdID: an ID that isn't a constant gives a range, not an entry. The event scene's kind 6 entry (0xa000plus a u16, found by0x140350b60and kept at0x140d68210) takes the range0xa000–0x19fff, which holds the scenes' 196-byte kind 7 tables (0xd000+ n) as well as its 12-byte ones, so its readers' loads (0x1403235e0) land on both. Only the search idiomids[k]in the loop that takesoffsets[k]is recognised. - Files opened by name aren't followed (
FileTable_OpenByName,0x1402af940: its group and file come fromFileTable_FindNameHash's record, which the script doesn't resolve). Calls through function pointers and vftables aren't followed either, but for the read callbacks above (bg_common.dat's entries passed toDAT_140f6c780's vftable aren't). thisfields are matched by offset within a source folder or class, so two objects with a field at one offset can be joined; and a field passed on by object pointer is followed only from a global. A region or class in a namespace named as a source folder counts as in that folder (Taco::Application::TextSeqandtextseq/textseqcaption.cppare bothtextseq), which joins more fields:menu/andworldmap/tables gainedheuristicreaders this way on 2026-10-15.- Generic walks read every table: a loop over all of a pack's entries that steps through each one's rows by its own row size (
MenuData_Load's string fix-ups at+0and+8) is aheuristicreader of every table it can reach. - IDs: resolved up to 3 callers up; a group that is a constant plus an unknown (
900 + chapter) takes every group within0x40above the constant. - Types are Ghidra's: a wrong guess (
intfor auint) carries through toctypeandsigned.
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:
- It can't open a saved Ghidra project. Each session imports the executable into a temporary project, runs Ghidra's default auto-analysis, and deletes it on close: "The imported Program and temporary project are ephemeral and deleted on close … this is not a saved Ghidra project." So it never sees our recovered classes or names, and a session's first query waits for a full analysis.
- For Ghidra it has no
set_address_name,set_commentor bookmarks; the only way to annotate isannotate_native_function(a function's name, a comment at its entry and an end-of-line comment), lost on close. - Its Ghidra operations:
address_name,address_to_file_offset,analyze_function,annotate_native_function,inspect_native_data_type,inspect_native_instruction,inspect_native_load_image,list_documents,list_names,list_procedures,list_segments,list_strings,procedure_address,procedure_assembly,procedure_callees,procedure_callers,procedure_info,procedure_pseudo_code,procedure_references,read_bytes,read_function_instructions,resolve_containing_procedure,resolve_native_call_targets,search_procedures,search_strings,xrefs.
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.
-
Clone https://github.com/atom0s/Steamless into a scratch folder of its own.
-
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; } } -
Build it with
dotnet build. -
Copy
local/game/Tactics Ogre Reborn.exeintolocal/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 writesTactics Ogre Reborn.exe.unpacked.exebeside 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.
-
Unpack the new executable (Unpacking) into
local/exe/<new>/. -
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 -
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>.jsonlAutoVersionTrackingScripttakes 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):
- Every function match is at the same address; the one row that isn't is a data
Implied Match,0x140fd0b68to0x140fd0b64. - All 576 matched functions we named carry the same name in the new build (26 without the class namespace class recovery gave them in the old project); 16,472 of the old program's 16,523 plate comments and 1,903 of its 1,908 pre comments are in the new one, unchanged or appended.
- The 348 unmatched old functions: 223 thunks (Version Tracking's function correlators don't match thunks), 68 whose bytes are identical to another function's (an exact match is accepted only when it's unique), and 57 with no function at that address in the new program at all, since our scripts made them in the old project after analysis (mostly the event script natives
ScriptTablesScriptmakes at handlers that had none, Event scripts). - Of the 636 rows of names.jsonl (
../../data/exe/names.jsonl), 580 are matched, 51 are unmatched event script natives (Native001_event_end, …), and 5 aren't in the file at all: data labels and code labels with no function that no data match covered (Btx_CanLoad,FileTable_CompareSlots,TextureDb_BtxLoader,Sead_CodecNames,Sab_ChunkTags).
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
- Open the Ghidra project in
local/exe/ghidra/, or the unpacked executable in rea for quick searching (rea's limits). - 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 inexe.strings, a source file inexe.source_files, a class inexe.classes, or a caller of a function already mapped (exe.calls). - Decompile it and its callees, and work out what each reads and checks.
- Check the result against the data: DuckDB over
data/game/, or a quick C# exploration program over the original files. - 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
likelyuntil the toolchain's decode or round trip confirms it; - names for functions and addresses → names.jsonl (
../../data/exe/names.jsonl), thenApplyNamesScript; - 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.