Tooling for matching decompilations of Touhou games built with MSVC 7 (Visual C++ .NET 2002) under Wine, on Linux. The goal of such a project is C++ source that compiles, with the original toolchain, into code byte-for-byte identical to the original executable.
The tools compile the game's source under Wine, compare every function with the original byte for byte, hook the new code into the running game through a DLL, link a standalone executable and compare it with the original section by section, drive the game headless under Xvfb, and analyze the original with headless Ghidra.
This repository holds tools only. It contains no game code or data, no decompiled source, no symbol tables and nothing from Microsoft's toolchain or SDKs. Each game repository brings its own source and symbols, and each user brings their own copy of the game and the compiler.
A game repository includes this repository as a git submodule at
tools/, describes itself in project.toml at its root, and
imports the shared recipes into its justfile:
import 'tools/common.just'
# the game's own recipes followIt expects this layout around it:
project.toml the game's settings (see below)
justfile imports tools/common.just
config/ symbols.csv: address, size, name and mangled symbol
src/ decompiled source; src/dll/ is the hook DLL's own code,
src/exe/ the data and globals only the standalone exe builds
orig/ the user's game files (not tracked) and their checksums
.wine/ the Wine prefix with the toolchain (not tracked)
build/ outputs
tools/ this repository
examples/ has the configuration of th06, th07 and th08, and th06's
build hook. To add the tools to a game repository:
git submodule add https://github.com/Cirno-Labs/decomp-tools.git tools
cp tools/examples/th07.toml project.toml # then adapt it
echo "import 'tools/common.just'" > justfile
just tools-init hooksjust lists them. common.just has:
| Recipe | Does |
|---|---|
build |
compile src/ and link the hook DLL, build/<name>.dll |
compare [SYMBOL...] |
compare every compiled function with the original; disassembly diffs for SYMBOLs |
game |
set up build/game/: the game's files, an exe patched to load the DLL, the DLL |
run [STEP...] |
run the patched game headless (wait:S key:K hold:K:S shot:NAME) |
exe [STEP...] |
link the standalone build/<name>.exe; FEAT=a,b turns on features |
check-exe |
link the standalone exe and fail unless it is the original byte for byte |
compare-exe [-v] |
compare the standalone exe with the original, section by section |
replay [RPY...] |
play replays to their end headless in the original, twice, and the standalone exe, and compare every calculation (replay/replay.py, with replay.hook) |
psdk-libs ZIP |
extract VS.NET 2002's Platform SDK import libraries from its install media |
analyze |
analyze the original with headless Ghidra, seed config/symbols.csv |
decompile [ADDR...] |
write Ghidra's pseudocode to build/decomp/ |
permute NAME [ARGS] |
search source variants of a function for a byte match (see The permuter) |
farm [ARGS] |
permute every hooks.allow_diff function unattended, several at once |
progress |
build, then render docs/progress.svg |
hooks |
use tools/hooks/: 50/72 commit messages without type prefixes or trailers, with hooks.compare only matching functions besides hooks.allow_diff, with hooks.readme only existing paths in README.md, and with hooks.progress a progress image in every commit |
clean |
remove build outputs (keeps the Ghidra project) |
Compiles run on half the cores; JOBS overrides it. Ghidra is found
through GHIDRA, ghidra.path or the newest ~/opt/ghidra_*.
| Recipe | Runs |
|---|---|
tools-init |
git submodule update --init tools, then puts tools/ on a main branch at the recorded commit |
tools-pull |
git pull --ff-only on tools/' main |
tools-push |
git push of tools/' main to git@github.com:Cirno-Labs/decomp-tools.git |
tools/ is a checkout of this repository, so a change to the tools is
a commit made inside tools/ (git -C tools commit), pushed with
just tools-push. The game repository then records the new commit
with a commit of its own (git add tools). In the other games,
just tools-pull and commit the pointer the same way.
A fresh clone of a game needs git clone --recursive, or
git submodule update --init tools after it: the justfile imports
from tools/, so just can't run before. Either leaves tools/ on a
detached HEAD, where a commit belongs to no branch; just tools-init
puts it on main at the same commit.
| File | Does |
|---|---|
project.py |
reads project.toml; the orig, game, standalone, check-exe and clean steps |
build.py |
compiles src/ (Shift-JIS, per-file flags, parallel), writes the hook table and thunks, links the DLL and the standalone exe |
compare.py |
compares compiled functions with the original, relocations resolved through symbols.csv |
compare_exe.py |
compares the standalone exe with the original: headers, Rich header, sections, imports, code and data by symbol |
order.py |
lists, per object, which of the standalone exe's functions sit at their original address and in the original's order (--summary: one line per object) |
reorder.py |
sorts a .cpp's function definitions into the original's address order |
patch_exe.py |
adds the hook DLL to a copy of the original's import table |
play.py |
runs the game under Xvfb, sends keys, takes screenshots |
seed_symbols.py |
adds Ghidra's functions and the imports to symbols.csv |
name.py |
names a row of symbols.csv |
rename.py |
renames functions in the working tree or throughout history |
render_progress.py |
draws the progress image |
permute.py |
searches rewrites of one function (declaration, operand and statement order, if/else, loop and condition forms) for a byte match, compiling each variant alone |
farm.py |
runs permute.py over every hooks.allow_diff function, several at once, unattended; keeps matching bodies in build/farm/, and --apply writes them into src/ |
gate.py |
the commit rule hooks.compare turns on, for the pre-commit hook |
readme_paths.py |
the commit rule hooks.readme turns on: every repository path README.md names must exist |
locals.py |
predicts and picks local variable names for a wanted stack layout under /Od |
psdk_libs.py, msi_extract.py |
extract import libraries from the VS.NET 2002 media |
coff.py, rsrc.py, peinfo.py |
COFF objects, resources, PE and Rich header |
ghidra/ |
headless Ghidra: analysis, function export, decompilation |
hooks/ |
commit-msg and pre-commit |
Paths are relative to the game repository's root. Hex numbers are
written as such (0x46004B). DECOMP_PROJECT names another file.
| Key | Default | Meaning |
|---|---|---|
name |
required | short name: build/<name>.dll, build/<name>.exe, <name>.exe in build/game/, the hook log's prefix |
dir |
required | the original game's files, such as orig/th07_100b |
exe |
required | the original executable's file name in dir |
original |
required | where the verified executable is copied, an ASCII path such as build/th07_100b.exe |
game_end |
required | the address the game's code ends at; library code follows |
checksums |
none | a sha1sum -c file, run from its own directory |
title |
name |
the progress image's title |
progress_exclude |
[] |
name globs of symbols.csv rows no source makes, such as linker thunks, left out of the progress |
window_title |
title |
the window play.py looks for |
standalone_extras |
[] |
globs copied into build/game/ with the standalone exe |
| Key | Default | Meaning |
|---|---|---|
path |
none | the Ghidra installation; GHIDRA overrides it |
project |
original's name without .exe |
the Ghidra project in build/ghidra/ |
functions |
build/functions_<project>.csv |
the exported function list |
| Key | Default | Meaning |
|---|---|---|
cflags |
required | the compiler flags; a source file's first line // cflags: /Os -/Oi adds and removes some |
bin |
C:\msvc7\bin |
where cl.exe and link.exe are, in the Wine prefix |
include |
C:\dx8\include, C:\msvc7\include, C:\psdk\Include |
INCLUDE |
lib |
C:\dx8\lib, C:\msvc7\lib, C:\msvc7\PlatformSDK\Lib, C:\psdk\Lib |
LIB, in search order |
timeout |
60 |
seconds before a compile counts as hung |
hook |
none | a Python file with the game's own build steps, see below |
| Key | Default | Meaning |
|---|---|---|
ldflags |
/DLL /NODEFAULTLIB /ENTRY:DllMain /BASE:0x30000000 |
the hook DLL's linker flags |
libs |
kernel32.lib user32.lib winmm.lib |
its libraries |
| Key | Default | Meaning |
|---|---|---|
obj_dir |
build/obj_exe |
the standalone exe's objects; build/obj shares the DLL's |
cflags |
[] |
flags added to build.cflags, before a file's own; with /GL the exe is a second, /LTCG build and the plain objects give the link order |
defines |
[] |
macros defined after a file's own flags (TH06_EXE=1) |
link_order |
median |
object order: by the median or first original address of the functions only that object defines, or source order |
pch |
"" |
a header in src/ to precompile (global.h): each object directory gets a /Yc stub, pch.obj, and every file whose // cflags: line changes no /O flag is compiled with /FI and /Yu. The header's inline functions then go at the end of each object, in the header's order, as in an original built that way |
ldflags |
/SUBSYSTEM:WINDOWS /FIXED /NODEFAULTLIB:libc.lib |
linker flags |
libs |
libcmt.lib |
libraries, in the original's order |
features |
{} |
FEAT names and their macros: frame_cap = "FEAT_FRAME_CAP" compiles FEAT=frame_cap into build/obj_frame_cap with /DFEAT_FRAME_CAP=1 |
feature_sources |
{} |
sources only the exe with a feature builds and links: frame_cap = ["src/exe/framecap.cpp"]; without the feature they make no object, which would still count in the Rich header |
The exe's map is build/<name>_exe.map, the DLL's build/<name>.map.
| Key | Default | Meaning |
|---|---|---|
psdk.libs |
[] |
the import libraries psdk-libs extracts, as named on the media (User32.Lib) |
symbols.import_libs |
.wine/drive_c/psdk/Lib, .wine/drive_c/dx8/lib |
where seed_symbols.py looks up decorated import names |
hooks.progress |
false |
the pre-commit hook redraws docs/progress.svg and adds it |
hooks.compare |
false |
the pre-commit hook fails unless every compiled function matches the original, or hooks.allow_diff lists it |
hooks.readme |
false |
the pre-commit hook fails if README.md names a repository path that doesn't exist (gitignored places such as build/ are skipped) |
hooks.allow_diff |
[] |
functions, by their symbols.csv name, that are in src/ though they don't match yet; farm permutes these |
replay.hook |
none | the game's Python file for replay/replay.py: its replay format, what its tracer patches and reads, and the run's setup (replay.py documents it) |
With hooks.compare, the pre-commit hook builds src/ and runs the
comparison (gate.py); src/ and config/ may then have no unstaged
changes, so that what is built is what is committed. It also fails
when a function HEAD's docs/progress.svg shows as matching is no
longer compiled, such as one now inlined everywhere. A commit that
makes a listed function match takes it off hooks.allow_diff. For a
group of functions that only match together, commit them one at a time
with DECOMP_ALLOW_DIFF=NAME[,NAME...] set: the named functions may
differ in that commit. git commit --no-verify skips the hooks.
build.hook is for what the settings don't cover. The file may define:
exe_flags(feats): extra compiler flags for the standalone exe's objects, given the features asked for; it may also generate the files they needheaders(): more headers whose changes rebuild everythingexe_resources(feats): the executable whose resources the standalone exe gets, instead of the original's
examples/th06_build_hook.py is th06's, for its English feature.
just permute NAME takes a function's definition in src/ (NAME as
symbols.csv names it, Stage::DrawLayer), applies random rewrites
and compiles each variant alone with the file's flags, without
linking. A variant scores the instructions that differ from the
original, as compare cuts the function, plus 1000 for each other
function of the file it breaks; the search climbs towards 0. Most
rewrites must touch the lines whose code differs, which a /Zd
compile maps to source lines. src/ is never written: the best
variant goes to build/permute/STEM.c (STEM is NAME with :: as
-) and is printed as a diff. A variant that matches byte for byte is
correct by the match; any other may have changed the meaning.
| Option | Does |
|---|---|
-j N, -t S, -s SEED |
compiles at once, seconds, random seed |
--start FILE |
start from an earlier best variant |
--file |
also move the file's top-level declarations; the variant goes to STEM.file whole |
--sweep |
enumerate instead: declaration orders, operand swaps and single rewrites at the differing lines, beam search until a pass finds nothing (sweep exhausted) |
--decls |
every order of the local declarations, which decides stack slots and can sway registers |
--pad, --plan |
look for a shift of the compiler's symbol IDs (a VC5 effect, not seen with MSVC 7.1 so far) |
just farm runs it over every hooks.allow_diff function (or the
NAMEs given), -p functions at a time with -j compiles each, rounds
of a sweep and a -t second random search from the best variant so
far, -r rounds (0: until stopped). Matches land in build/farm/;
just farm --apply NAME writes one into src/. Under Wine, a
wineserver stays up between compiles, which makes each several times
faster. On Windows, build.py runs the compiler directly, finding
C:\ paths of the build settings under .wine/drive_c first, and
compare.py disassembles with capstone, so a copy of the repository
with its prefix's drive_c and the original exe can farm there; copy
build/farm/ back.
- Python 3.11 or later (for
tomllib), just recent enough for the[default]attribute (tested with 1.58) - Wine (32-bit, with its
msidb), binutils (objdump), 7-Zip - For
run: Xvfb, xdotool, ImageMagick, 32-bit Mesa OpenGL and theja_JP.UTF-8locale - The original toolchain in the Wine prefix (bring your own copies)
- Ghidra, for analysis
- On Windows (
permute,farm,build,compareonly): Python withcapstone, run in UTF-8 mode (PYTHONUTF8=1)
GNU General Public License, version 3 only (GPL-3.0-only), see
LICENSE.