Decompilation — Wario Land 4, level 1
Matching decompilation as an OpenReward environment.
Level 1 is 60 functions of at most 2,048 bytes each. The level is the difficulty, and it is the only thing that distinguishes this environment from its siblings: same machinery, same game, a harder draw. Raising the size cap is what makes a higher level, since the cap is what decides whether a task's score sits in work a model can reach.
A task hands the agent a real, complete decompilation project with some function bodies removed. The agent writes C; the environment compiles it with the game's original toolchain and diffs the objects against the ones the untouched project produces. The task is solved when every object matches byte for byte — not equivalent code, identical code.
The verifier is a compiler and a differ, so there is no judge and no rubric.
Games
| game | platform | compiler | upstream | functions |
|---|---|---|---|---|
| Wario Land 4 | GBA | agbcc (gcc 2.95 + Nintendo's patches) | TsilaAllaoui/warioland4 (MIT) | 3,361 |
Three of its 186 translation units are left out: hud.c and sprite_ai/vortex.c
include graphics extracted from the ROM, which this environment never has, and
objdiff cannot parse the object minigames/homerun_derby.c produces.
More projects are added as sibling entries in decompilation/games.py and
games/<slug>/; they share this repo and this environment.
The task
Decompile 60 functions of the game — that is what level 1 means. The other ~400 stay in place as gold: they are the only worked examples of the codebase's idioms, and reading the neighbours of what you are writing is the first thing a real contributor does.
There are 100 tasks, differing in which functions they remove. Each is a seeded draw, stratified by compiled size and held to a byte budget, so tasks are comparable as work while overlapping in only ~14% of their functions:
| functions per task | 60 |
| function size cap | 2,048 bytes |
| task size | 24,301 ± 536 bytes (2.2% spread) |
| overlap between two tasks | 8.5 functions (14.2%) |
The size distribution of a typical task, and where its score lives:
size band fns/task share of task bytes
0-32 7.38 0.6%
32-64 8.68 1.6%
64-128 9.42 3.5%
128-256 10.96 8.7%
256-512 7.33 10.7%
512-1024 7.53 23.1%
1024-2048 8.70 51.8%
Two things follow from that shape. Reward is byte-weighted, so the 36 functions under 512 bytes are 61% of a task by count and 14% of its score, while the 8.7 functions of 1-2 KB are 52% of the score. And the cap matters: without it two or three functions of 4-28 KB carry half a task, the score sits entirely in work far beyond what a model completes today, and the reward goes flat long before the time does. 64% of an uncapped task's score is below 2 KB, so the cap costs little difficulty and keeps the frontier inside the task rather than behind a wall.
Every knob is an environment variable — DECOMP_FUNCTIONS_PER_TASK,
DECOMP_FUNCTION_SIZE_CAP, DECOMP_TASK_COUNT, DECOMP_REMOVED_FRACTION (used
only when DECOMP_FUNCTIONS_PER_TASK is 0) — so difficulty is a deployment
setting rather than a rebuild.
Measured
glm-5.3-flash, four agents under AgentOrg, against an earlier 46-function draw: about 30 minutes of orientation, then roughly 16 functions and 1,250 bytes of byte-exact code per hour. Completions average 78 bytes, so the model is closing the small end and has not yet broken into the 512-1024 band. On the task above that projects to a reward around 0.15-0.25 over four hours, still climbing at the end rather than saturating.
Reward
The fraction of the remaining gap closed:
(matched_code_percent - baseline) / (100 - baseline)
with 0.15 weight on objdiff's fuzzy_match_percent for shaping, so a function
taken from 0% to 85% is not invisible.
The best is tracked per function, and the score is the byte-weighted sum of
those. A project-wide reading is a sample of whatever state the tree happened to
be in when make ran, and with several agents editing several files some file
is almost always half-written — the project figure swings by thousands of bytes
independently of real progress, so a project-wide running maximum ends up
rewarding the luckiest sample. Per function, a match that was once achieved
stays credited, and a half-written file cannot erase a finished function
elsewhere. solved still reflects the real simultaneous state of the tree, not
the per-function bests.
It is emitted as increments over a running best, not as one terminal number. A
run almost always ends on the harness's wall clock rather than by calling
finish, and a terminal-only reward scores such a run at nothing no matter what
it recovered. Emitting the increment instead means the stream sums to the best
ever reached. A step that graded nothing emits None and adds no row; a step
that graded without improving emits 0.0, so a run that earns nothing still
scores zero rather than coming back unscored. Every tool result carries the
run's numbers in its metadata, terminal or not, for the same reason.
solved additionally requires every object to compare equal after
--strip-debug, and the data sections to match too. objdiff's percentages are
the shaped signal; the byte compare is the verifier - it also covers the
inter-function alignment padding objdiff attributes to no symbol.
No ROM
baserom.gba is referenced only from the projects' data assembly. The C
translation units build without it, so tasks, targets and grading never touch
the copyrighted image. The cost is that the final ROM link is out of scope: the
win condition is "every object matches" rather than the ROM SHA-1. Those are
equivalent as long as a solution only edits C, which the guards below enforce.
Guards
- Edit surface. Every file is hashed when the task is built. Anything modified, added or deleted outside the project's C sources scores 0 — the linker script, the hand-written assembly and the data objects feed the link too.
- Inline assembly. Scanned on the lines a solution adds, diffed against
the task's starting state: upstream's own
asm("swi 0x2A")is legitimate, an added one is not.register x asm("rN")stays allowed — it constrains register allocation without emitting instructions. A macro or token pasting hides an asm statement from a line scan, so every build is also checked after preprocessing: a code-emitting asm statement or anakedattribute that upstream's tree does not have (the same template in the same function) rejects the build. agbcc marks no inline asm in the assembly it writes, which is why the check reads the preprocessed source (the Makefile tees it tobuild/us/asm/*.i) rather than the compiler's output. An asm statement with an empty template, such as upstream'sCOMPILER_BARRIERor a literalasm("" : "+r"(x)), emits no instruction and is allowed by both checks. - Executable sections. A data object placed in
.textpairs its bytes with a target function of the same name, so machine code can be smuggled in as a C array. The preprocessed scan also rejects asection(".text…")attribute the upstream tree does not have; only.textis executable, so data in upstream's own sections (.bss.code,common_data,iwram_data, …) stays allowed. - Build limits. Every build runs in its own process group under a wall-clock timeout and memory, output-size and CPU limits, so an infinite preprocessor include or a runaway allocation cannot hang the server or fill its disk. A build that exceeds them reads as a failed build the agent can retry.
- Reference solutions. The pristine trees live at
/opt/pristine, outside the work area. No tool can read them, and neither can the build: the references are root-only, and each session's tree is built by a uid of its own, so an#includeof/opt/pristine,/opt/targetor another session's tree fails.
Tools
list_targets, read_file, edit_file, build, diff, progress, finish.
diff renders the target and the current assembly side by side, which is the
environment's real feedback channel.
Resources
CPU only. agbcc, GNU as and objdiff are all single-threaded C; builds run at
make -j4. The image carries the pristine objects, so a session starts from a
built tree and make only recompiles the translation units a task's stubs
touch. Measured at 4 CPUs / 16 GB: session setup 2.9s, the build tool 1.5s,
the diff tool 0.1s. A full 22-object rebuild from cold is about 7 seconds.
Rollouts are token-bound, not build-bound.
Set the deployment's concurrency low — around 4. Unlike most environments every session runs real compiles, so the default of 500 concurrent requests against 4 CPUs would thrash; scale with instances instead.
Running locally
docker build -t decompilation .
docker run -p 8080:8080 decompilation
Point a harness at it with OPENREWARD_SESSION_URL=http://localhost:8080.
Licence
Upstream kleod is MIT (testyourmine) — see games/klonoa/kleod/LICENSE and
games/klonoa/UPSTREAM_COMMIT.