Everything the README used to carry inline. Nothing here has been reworded; the figures and their sources are exactly as they were.
Download the build for your platform from Releases. No Python, and nothing else to install.
| Windows | gk2port-windows-x64.exe |
| Linux (glibc 2.35+) | gk2port-linux-x64 |
macOS: build from source. A macOS binary compiled and self-tested fine in CI, but
nobody ever ran one on an actual Mac, and shipping a binary no one has executed is not
much of a favour, so it is not built. pip install UnityPy Pillow numpy and use
tools/build.py instead, which works the same way.
Put it in a folder of its own next to your fan ROM and run it with no arguments. On Windows that means double-clicking it. It finds the ROM, searches your Steam libraries for the Collection, shows you what it found, and asks before doing anything:
fan ROM Gyakuten Kenji 2 (AAI2 Final v2).nds [verified AAI2 Final v2]
Collection D:\Steam Games\steamapps\common\Ace Attorney Investigations Collection
output C:\gk2\out\GK2 (Official English, DS port).nds
Build? [Y/n]
It extracts what it needs into dump/ beside itself and writes the ROM to out/.
Takes a couple of minutes.
You can also drag the .nds onto the program, or pass paths yourself:
gk2port-windows-x64.exe --fan-rom "GK2 (AAI2 Final v2).nds" --collection "C:\Program Files (x86)\Steam\steamapps\common\Ace Attorney Investigations Collection"
--collection accepts the game folder, the StreamingAssets/aa folder, or the platform
folder inside it, and is only needed when auto-detection fails: a non-Steam copy, or a
console dump you extracted yourself.
It's a terminal program. Mark it executable and run it:
chmod +x gk2port-linux-x64
./gk2port-linux-x64Steam auto-detection knows the usual locations, including Flatpak's. The Collection is a Windows game, so it will be a Proton install, which lives in an ordinary Steam library and is found the same way.
The binary is built on Ubuntu 22.04, so it needs glibc 2.35 or newer. On anything older, build from source.
Console builds are untested. The bundle lookup matches by name prefix and does not care which platform folder it finds, so a Switch or PS4 dump you have already extracted yourself may well work. Point
--collectionat the extracted folder. But nothing here has been run against one, so treat it as unknown rather than supported.
The binaries are unsigned, so Windows SmartScreen will complain. Certificates cost
money. Every release ships a SHA256SUMS file, the binaries are built in public by
GitHub Actions rather than on someone's desktop, and
gk2port --selftest checks that the one you downloaded is complete.
- The fan ROM is verified by hash. Point it at a raw Japanese cart or a different
patch and it stops and says so, instead of producing a ROM that boots to a black
screen with no explanation.
--any-romoverrides it, but expect the black screen. - It refuses to overwrite its own input, which is easy to do by accident once a previous output is sitting next to the fan ROM.
gk2port --verify hashes a built ROM and checks it against the version's published
reference. A MATCH means it is the genuine, unmodified output of the tool, so you can
trust a ROM without trusting whoever built it. Pass a path to check a specific file:
gk2port --verify "path\to\rom.nds". Something going wrong? See
TROUBLESHOOTING.md.
pip install UnityPy Pillow numpy
python tools/build.py --fan-rom "...nds" --collection "..."See BUILDING.md to rebuild gk2port.exe, to regenerate the metadata
files that ship in dump/, or for the one known gap in the control-code table.
The Collection is only read during extraction. Once dump/ exists it holds everything
the injection needs, so you can free the 7 GB install and still rebuild forever:
gk2port-windows-x64.exe --fan-rom "GK2 (AAI2 Final v2).nds" --skip-extract
The wizard notices this by itself: if dump/ is already there and the Collection isn't
installed any more, it says so and carries on.
That needs the fan ROM and dump/ (about 187 MB, 186,956,489 bytes by du -sb: the script
dumps plus the title assets and voice clips pulled from the Collection) and nothing else. Verified byte-identical to
a full run.
Or run the injection alone:
python tools/inject.py "Gyakuten Kenji 2 (AAI2 Final v2).nds" -o out/GK2-official.ndsThe build prints a full audit of what it did and, crucially, what it refused to do:
strings patched from localization tables: 343
episode titles switched to official names: 27
kept-fan strings renamed to official names: 64
entries replaced with official English: 423
entries where a joined string was split back: 11
relaid strings rebuilt in the fan layout: 74
hollow official strings kept as fan: 36
rows kept as fan to keep their message box: 4
rows kept as fan to keep a DS-only command: 105 (in 67 script banks)
sparse official banks swapped row-by-row: 2 (853 rows kept fan)
records kept as fan - still Japanese: 347
records kept as fan - fan relaid it vs JP: 38
records kept as fan - would lose a message box: 3
records kept as fan - official-only control code: 21
kept fan text - string count mismatch: 3
kept fan text - control-code shape off: 8
nameplates redrawn with official names: 148
title screen: official logo 248x116 at (4,30), 439/768 tiles, 222 colours
logic keyword cards: 103 of 133 slots named officially, 206 card images rewritten
choice strips redrawn with official text: 297 (48 condensed, 17 at a smaller size, 0 without English)
voices: 20 slots in Capcom's audio (18 English shouts, 2 sound effects), sound archive 11066644 -> 11283308 bytes
Every one of those counters is a guard that fired. They exist because each one, once, broke the game. The three at the bottom of the guard list were each added after a specific hang, and the last two lines of the injection report exist because a player hit the thing they now prevent.
Measured on the 1.11.0 release ROM (sha256 fccfb88e9943c11dc7f9a00ab7c61d471c050495bcf9899aa99b021afada34f4) with python tools/coverage.py:
| Episode | Official | character units |
|---|---|---|
| 1. Turnabout Trigger | 97.7% | 175,424 / 179,474 |
| 2. The Captive Turnabout | 100.0% | 368,696 / 368,696 |
| 3. Turnabout Legacy | 100.0% | 377,751 / 377,751 |
| 4. A Turnabout Forsaken | 100.0% | 297,663 / 297,663 |
| 5. Turnabout for the Ages | 100.0% | 497,811 / 497,811 |
| Menus & UI | 94.5% | 105,423 / 111,588 |
| Total | 99.4% | 1,822,768 / 1,832,983 |
The text the game can actually show (1.11.0, static analysis). The 99.4% above counts every string stored in the ROM. Most of what is left over is never displayed: Japanese placeholder strings in the Mind Chess banks and lines that nothing in the game's code points to. Counting only the rows that a chain of script and code references can reach from a place the game starts, Capcom's share is 99.813% (conservative, rows that might be reachable count as shown) and 99.857% (rows proven reachable only). One code path could not be fully traced, so only "about 99.8%" is quoted publicly. Distrusting even the arguments that are displayed only gives 99.709%. This comes from reading the code, not from a playthrough.
The counting changed in 1.11.0, slightly. A fan row that already reads exactly as Capcom's wording now counts as Capcom's (10 rows, 195 units; the total is 99.4% with or without it), and 29 rows I wrote in Capcom's style, where Capcom had nothing that fit, are reported on their own line (1,783 fan units replaced) and counted as neither. Rows the build splits to fit the engine's load buffer are folded back into their original rows before comparing, so the split does not affect the count. 1.10.0 measured 98.8%.
The counting changed in 1.5.0. Every release from v1.4.0 to v1.4.4 quoted 96.5%, and the
ROM did not get worse when that figure dropped; the counting got stricter. tools/coverage.py used to call a string official
whenever its bytes differed from the fan ROM's, on the premise that the injector only
replaces whole strings. That stopped being true in v1.4.0, when the rename pass started
swapping Capcom's character names into fan-written rows: 84 long conversations
were counted as official because a name in them had changed. As of 1.5.0 a row counts as
official only if it differs from the fan row after that row has been given the official
names and episode titles, so a fan-written line with "Bronco Knight" in it is fan text,
which is what it is. Measured this way v1.4.4 was 93.8%, not 96.5%.
Two figures in this file have been wrong before, both remembered rather than measured;
this third one was measured, by a rule that had quietly stopped meaning what it said.
Every number in this table is what tools/coverage.py printed for the ROM whose hash
--verify checks. If you build it yourself and get different numbers, the README is the
thing that is wrong.
The counting is tools/coverage.py: character units (everything that is not a control
code or one of its arguments) in strings whose bytes differ from the fan ROM after names
and titles, over all character units. Earlier releases quoted "85.7%" from a methodology
that did not survive; the two numbers are not directly comparable.
Also ported: evidence and profile descriptions, Logic card text, and the Organizer messages, none of which live in the script files at all. They come from the Collection's Unity Localization string tables, matched through the Japanese text.
Capcom renamed most of the cast. With the dialogue in their words, the fan's names had become the inconsistency rather than the tradition: the text said Fender while the nameplate above it said Ray and the Organizer said Raymond Shields.
All three now agree. The text is the easy part; the other two are graphics:
- 28 dialogue nameplates:
jpn/idlocal.binentries 27-86, one LZ11'd 144×8 tile strip each; the text sits on a 42-pixel bar and is redrawn in the fan's own letters (three faces, see BUILD_NOTES), centred like the fan's plates. - 119 evidence and profile title cards: 128×16 strips inside that same file's sprite bundles, drawn as four 32×16 OAM objects.
- 297 choice and topic buttons: the option plates of every choice menu and talk-topic list, entries 364-670 of the same file (224×32 and 144×32 sprite bundles). These are where a fan name could still reach the screen after 1.5.1: one Episode 1 choice offered Nicole Swift as an option while the script around it said Tabby Lloyd.
Neither can be swapped as text, so they are redrawn at build time in the fan patch's own pixel font, which the tool recovers from your own ROM, by cutting the letters out of the cards whose wording it already knows. Nothing but Capcom's names and a handful of hand-drawn glyphs ships in this repository: a few letters the fan set never used and, on the nameplates, the thin face's M, O, S, a and c, which are drawn versions of letters the fan uses in its wider faces.
The fan→official mapping was derived from the two scripts rather than from memory: for every line present in both translations, fan names were paired with the official names in the same line. That caught what recall would have missed: Nicole Swift is officially Tabby Lloyd, the monster Moozilla is Taurusaurus, and the chairman's nickname Blaisie is Celsius.
The choice buttons are different again: their wording is Capcom's option text, not a
name, so each plate had to be paired with its Collection string first. That pairing was
made from the retail Japanese ROM, whose plates sit at the same entries: the Japanese
lettering on each was matched glyph by glyph to the Collection's own Japanese select
tables, and the English follows from the id, never from guessing which English sentence
"means the same" as the fan's. tools/select_strips.json ships that pairing as numbers
only; the text is read from your Collection at build time and set in its UD Kakugo M
face on the fan's plates (tools/choice_strips.py). Long options condense by up to 12%
and then step down a size, as the episode titles do.
tools/names.py holds the map and rewrites only strings that are byte-identical to the
fan ROM's, so official text is never touched: 64 whole rows in the current build, more
rows renamed line by line where a whole-row rename would not fit, plus 148 nameplates
and title strips redrawn. Since 1.5.1 no line in the game keeps a fan character name: the five that the
official name pushed past their box carry hand-shortened lines (ROWFIX in names.py),
and a scan of the built ROM finds zero fan names in kept-fan text. See the width guard
under Guards. tools/plates.py does the graphics.
Other pictures the fan lettered with a name (since 1.11.1). Comparing the fan ROM with
the retail Japanese ROM entry by entry gives exactly the pictures the fan edited, and each
one was checked for a fan name. These turned up and are fixed at build time from your own
files: the cake-show logo on the Episode 3 opening card, which is Capcom's own "Samson &
Judy's / Bake 'n' Bop!" logo (tools/opening_card.py); the room names on the cake-contest
map pieces (tools/room_names.py); the business card and victim's letter evidence pictures
(tools/idcom_icons.py); six Logic keyword cards (tools/logic_names.py); and one Court
Record title that was still Japanese, now "Promise Notebook" (tools/plates.py).
BUILD_NOTES.md has the method and the buffer checks for each. The office label and the corridor label of one later map
piece (jpn/idlocal.bin 349; entry 348, which the 1.11.1 notes name beside it, is a cell bank with no lettering) stayed Japanese in 1.11.1, because Capcom's Collection has no English
version of that map; since 1.11.2 tools/plan_labels.py letters them in the fan's own face from
Capcom's captions for the room and its word for a corridor.
Both the DS ROM and the Collection store dialogue in a TPS (SPT) container, the
Collection inherited the DS format wholesale. The DS uses 16-bit fields, the Collection
32-bit. Text is UTF-16LE XOR 0x55AA; code points 0xE000-0xF8FF decode as control
codes rather than characters.
Two traps cost real debugging time:
A file holds n+1 strings, not n. String 0 is described by the header (offset 0x0C,
length 0x0E); the record table covers strings 1..n. Miss it and every script file
silently truncates: the game boots, reaches the first scene, and goes black.
The fan patch changed what offsets mean. Retail ROMs store byte offsets. The fan
patch reads them as 16-bit unit offsets, doubling the addressable range from 64 KB to
128 KB. Write byte offsets into a fan ROM and it black-screens. spt.offset_scale()
detects which convention a file uses by checking whether the data start matches the
computed header size, or half of it.
The DS message box is three lines of a variable-width font. Capcom's is much wider, and their script is full of line breaks that belong to their box, not to the sentence.
Measured across the whole English corpus: 20,516 of 26,172 newlines end a line of 40-59 visible characters, and none ever exceeds 59: the signature of a fixed-width wrapper, not an author's choice. Honouring them produced ragged messages: a line of two words, then a nearly full line, then a single word left on its own.
Only the 79 newlines immediately followed by {E20D} are structural, because that code
opens a new laid-out row on a date/location card. Everything else is folded to a space
and re-wrapped by pixel width. dstext.convert(hard_nl=...) selects the policy.
This is the subtle one, and getting it wrong hung the game.
The obvious move is to check your output against the fan ROM you're injecting into. That's the wrong ruler: the fan ROM is the thing that was rearranged.
Capcom's script matches the retail Japanese DS script box-for-box. The fan patch
redistributed message boxes between strings in 54 of 429 entries, always as a matched
pair: DS[4] moves +8 boxes into str3 and −8 out of str4; DS[27] is −38/+38; DS[60]
is +23/−23.
Swap such a string per-index and the fan shows the moved boxes, then the official text
replays them, and everything after shifts by 8, landing on top of whatever the DS
keeps at those indices. For DS[4] that's the Logic tutorial and the Gourd Lake Park:
Stage location card. The engine waits on them. They never arrive.
Box-count-based guards can't see this: the official string has more boxes (63 vs 55), not fewer. The fix is to compare each string's box count against the Japanese DS ROM and revert only the strings whose count moved. Doing this per-string rather than per-entry was worth about ten points of coverage on its own. v1.2.0 goes further: where a whole RUN of neighbouring strings was relaid with its box total conserved, the official strings are joined and re-cut at the fan's own boundaries, the layout the engine was built against, with strict per-string box-code equality as the gate. The same trick inverts the fan's habit of splitting one long retail string into two (the retail JP and the Collection both hold the joined form; the split point is recovered from three independent structural fingerprints). What used to be reverted wholesale is now mostly rebuilt.
You don't need that ROM, though. The guards want counts, never text: message boxes per
string, and a control-code histogram per entry. Those are integers, facts about the file's
structure rather than its content, so they ship as dump/jp_structure.json (214 KB) and
the retail Japanese cart drops off the requirements list entirely.
tools/jp_profile.py regenerates it if it ever needs updating.
Structural failures don't show up in a format check: the file parses perfectly and the game hangs anyway. Each guard below exists because it happened:
- Scene redistributed across strings: a string with >200 printable DS characters whose Collection counterpart has under 20% of that. Rejects the entry.
- Message-box loss: losing ≥2 boxes reverts that string. Losing exactly one is benign and very common (868 of 913 cases, measured before the v1.2.0 rebuilds): the localization merged a pair.
- A row that loses its box entirely (v1.4.2): if the Collection has nothing for a row, the replacement has no message box at all, and a box that opens with nothing in it never closes. Such a row keeps the fan's, in any language: untranslated text is a blemish, a lost box is a lock.
- Official-only control codes (v1.5.2): the Collection's script carries two codes the
DS engine has never seen,
E2A0(its form of the DSE10D, the opener of theE10D/E10Escripted-wait pair) andE2B0(an inline button icon). The engine skips a code it does not know and then reads the code's arguments as text; a0argument ends the string,E10Eis left orphaned, and the scene stops with the music playing and no input accepted. Every release through 1.5.1 hung there, 15 sites in 12 strings, the first one in Episode 1 right after the bodyguard's introduction.dstext.OFFICIAL_TO_DStranslatesE2A0; the injector keeps the fan's string whenever a converted string still carries any code absent from the fan script (records kept as fan - official-only control code), which is what handles the fiveE2B0tutorial lines. - DS-only engine commands (v1.4.3): the guard that matters most, and the one that took three tries to get right. See below.
- Option-widget width: the confrontation and Logic Chess widgets have no measured width, so the budget is the widest line the fan ever displayed in that widget. Anything wider keeps the fan's line rather than risk a clipped word.
- A renamed line that no longer fits (v1.4.4): Capcom's names are often longer than the fan's, so substituting one into a kept-fan row can push a line past the edge of its box. The build re-breaks the row to fit. When it can't, usually because the over-wide line ends a box, leaving nowhere to push the word, that row keeps the fan's name. Ten rows do. A single line still reading the fan's name costs less than a clipped one. This guard is why the previous release shipped ten clipped lines and this one doesn't: v1.4.3's DS-only revert handed the rename pass many more rows to work on (84, up from 45), and some of them didn't fit.
- Per-record defects: Capcom's English bundle contains
DEMO TEXTplaceholders and untranslated Japanese, but per record, not per file. Guarding at file level threw away 16,780 letters of perfectly good English. - Control-code shape: a wrong file match shows as near-zero overlap in the control-code profile against the Japanese original.
This section used to end here, and the game hung anyway.
Capcom's script has no touch-screen, A-Button or Logic tutorials, because those are DS-only. Converting it can therefore drop the engine commands that drive them. The guard above tried to catch that by counting message boxes, on the reasonable-sounding logic that missing content means missing boxes.
It does not. Capcom's prose is longer, so the converted string ends up with more
boxes than the fan's while the command inside it is gone. DS[4] str3 came out with 18
boxes against the fan's 16 (a healthy-looking number) and Episode 1 never handed the
player control at the Gourd Lake stage. Nameplate over an empty box, music playing,
nothing to press.
So the guard now compares the commands, not the boxes: a string that would drop an
E041/E042 pair keeps the fan's line. That is 105 rows across 67 script banks,
living in the partner-conversation, examine-check and NPC entries where DS-only
interaction sits throughout the game. Worth stating plainly, because the first
announcement of this fix got it wrong: these are not concentrated in Episode 1's
tutorial opening. Episode 1 is where one of them happened to lock the game.
rig-side, audit_cmdloss.py answers the obvious follow-up (is another command being
dropped the same way?) by computing, for every control code, how often the fan string
carries it against how often mine drops it. A ratio near 1.0 means the official script
essentially never has that code, which is the signature of DS-only content. The highest
ratio in the current build is 0.28, on a formatting code the official script uses 62,000
times. Nothing is hiding.
The floor isn't laziness. It's structural, and it breaks down cleanly:
What remains fan (~5.7% of the script):
-
DS-only tutorial and interaction strings: 105 rows whose official replacement drops an engine command the DS build needs. The largest single category, and the newest: see Counting boxes is not enough.
-
Relaid strings with no rebuildable partner: 38 strings whose box counts moved against the retail layout in ways the run-rebuild cannot verify (lone strings, or runs where the official uses a different box-end variant, e.g.
DS[236]). -
Evidence descriptions and Logic cards that overflow their box: official English exists for 108 of them but does not fit the DS's 4-line description box (or the 3-line Logic card). Until 1.5.0 those rows kept the fan's text. As of 1.5.0 they carry Capcom's wording, condensed to fit: the Japanese source says what must survive, Capcom's English supplies the words, and the fan line proves what fits. Nothing is paraphrased where a deletion will do, names and facts are kept, and hedges are kept ("appears to", "I have my doubts"). The edits ship as word-index operations over the official text (
tools/condense_generated.py, produced bytools/desc_fit.pyfrom a reviewed list), so no Capcom text sits in the repo, and each entry carries a result hash: a Collection whose wording differs falls back to the fan line instead of applying a stale edit. To check them,python tools/desc_overflow.py OUT.txtlists every affected row four ways (Japanese, Capcom, condensed, fan) from your own extracted data. Together with the earlier exceptions that makes 135 rows where Capcom's wording is edited: the 108 intools/condense.py(105 generated, plus 3 hand-written entries that take precedence over their generated versions), 20 over-wide card titles intools/plates.py(TRIMS, 8 of them until 1.6.3 widened the set), and 7 renamed rows intools/names.py(ROWFIX). -
Hollow official strings: a few strings are empty or
DEMO TEXTin the Collection's own files. -
Menus & UI residue: DS-only interface text with no official counterpart.
DS-only content is the recurring theme. The touch-screen tutorials, the button prompts, the Logic walkthrough, because Capcom's version targets hardware without a second screen or an L button, so no official wording has ever existed for a lot of it.
Capcom renamed all five episodes (Turnabout Target → Turnabout Trigger, The Imprisoned Turnabout → The Captive Turnabout, The Inherited Turnabout → Turnabout Legacy, The Forgotten Turnabout → A Turnabout Forsaken, The Grand Turnabout → Turnabout for the Ages). The names live in four places, and since 1.5.0 all four say the same thing:
-
Save screen text: 27 strings in
DS[460], switched bytools/episode_titles.py. -
Episode-select buttons and episode splash cards: sprite graphics in
jpn/save_local.binandjpn/opening_local.bin.tools/title_text.pyrenders the official names into the existing sprites using two fonts pulled from your Collection at build time: Modé Mina B for the splash cards, which turns out to be the very face the fan team was imitating by hand, and UD Kakugo M for the buttons. Nothing is drawn outside the sprites the game already draws. -
Title screen: the official English logo sprite (
GK2_Logo_R_eng) is read from the Collection, laid out over the fan's copyright line, and written intojpn/title_local.binbytools/extract_logo.pyandtools/title_logo.py. The build's version number is painted into the empty top-right corner of that picture (tools/title_version.py), so any screenshot says which build it came from. -
Logic keyword cards: the 133 keyword cards on the Logic board (and the banner each one shows above its description) are images in
jpn/logic_keyword_local.bin, lettered by the fan team in their own words ("Ruptured balloon" where Capcom wrote "Popped balloon"). The DS never stores those names as text, only each keyword's Japanese description, sotools/logic_names.pyjoins the DS description to the Collection's description table and from there to the official name table, andtools/logic_cards.pyrepaints the card interior and renders the official name in the fan team's own pixel lettering, cut from the same file at build time bytools/logic_font.py, on the fan's own text-free card: up to three lines on the card and one on the banner. Two names too wide for any banner carry a shorter form there. 103 of the 133 slots have an official name (97 until 1.11.1, when matching with punctuation ignored found six more); the rest, the 30 unused dummy slots, keep the fan lettering. Redrawing the fan's own card text with the same code returns 75 of its 101 cards and 83 of its 93 banners pixel for pixel, background and outline included, and the 17 cards and 18 banners whose official name is the fan's own wording come out byte-identical to the fan's. -
Voices: all twenty sound slots the fan patch re-recorded (the "Objection!" shouts and friends) are Capcom's 2024 audio, pulled from the Collection's sound bundle by
tools/voices.py, resampled and rebuilt intocom/kenji2_sound.sdat. Eighteen are Capcom's English lines; the other two are plain sound effects with no dialogue in the Collection at all, so Capcom's base clip goes in instead of an English take. None of the fan team's shout recordings remain in the build.
No font, audio or title artwork ships with the tool; like the script, all of it comes out
of the player's own install. The one set of pictures that does ship is the six close-up
artworks described under Legal. tools/ncer.py is the cell-bank reader that made the sprite work
possible, and tools/title_art.py exports the pieces for inspection.
The game's title screen therefore reads Prosecutor's Gambit, Capcom's title, while this project keeps the name it started with.
Everything under tools/ builds the ROM. Everything under audits/ checks one after
the fact. Each takes an optional ROM path, so you can point one at any build:
python audits/audit_boxes.py # did any string lose a message box?
python audits/audit_fixtures.py # prove the audits can actually failaudits/audit_boxes.py |
Any string with fewer message boxes than the fan's (the v1.4.2 hang) |
audits/audit_empty.py |
Strings emptied of text that the fan ROM had |
audits/audit_arity.py |
Control-code arguments corrupted into text (the v1.3.3 hang) |
audits/audit_cmdloss.py |
Is another DS-only engine command being dropped? (the v1.4.3 hang) |
audits/audit_widgets.py |
Option lines wider than the fan ever proved that widget can draw |
audits/audit_titles.py |
Every redrawn card keyed off the right source title |
audits/audit_hint.py |
Any SPT buffer hint too small for its own data |
audits/audit_tails.py |
A command argument the fan ROM stores past a string's declared end, zeroed by the rebuild (the 1.8.4 Episode 3 talk fix) |
audits/audit_choicearg.py |
A choice-menu {E187} argument that cannot resolve on the DS: a strip id past the DS's own block, or a target string index skewed against the fan (the Ep2 DS[58] frozen-prompt defect) |
audits/audit_indexargs.py |
Any string-index argument, other than {E187}/{E081}, that no longer points at the fan's string (the Case 2 and Case 4 rebuttal freezes) |
audits/audit_staging.py |
A string's camera, character-position or pose command sequence that no longer matches the fan's, in the same message box (the Case 2 camera fault) |
audits/audit_dangling_wait.py |
A wait on a character ({E14B}) that the string never places and the fan's own string does not wait on either (the Case 3 freeze) |
audits/audit_centring.py |
A centred row ({E20D}) whose engine offset, (240 minus its width) halved, is 0 or 10, a row wider than the line, or a static {E050 0} or {E050 10}: 0x0A is a newline and draws the row flush left, 0x0000 ends the text (the 1.11.3 title and cooking-show defects) |
audits/audit_hotspots.py |
Whether the tap rectangle for Case 4's "point at the burn mark" prompt still covers those words and nothing else; for the nine picture prompts with a local image, whether image and rectangle still equal the fan's, except that an image the port re-letters on purpose (the fixed RELETTERED table of boxes in the audit) may differ only inside its boxes, and its palette entry and rectangle not at all |
audits/audit_zeros.py |
A 0x0000 unit in text position that the fan ROM didn't already ship there, which ends the DS engine's string read early |
audits/audit_plates.py |
Reads the 59 dialogue nameplates back out of a ROM: ink on the bar's border, a margin below the fan's smallest or lopsided beyond the fan's, letters that touch or split, a word gap below 3 without a listed exception (plates.THIN_EXCEPTIONS), and any pixel a redraw may not change; --selftest feeds it broken plates and requires each to be caught |
audits/audit_item_pics.py |
For each picture item_pics.py re-letters (43 on the 1.11.2 branch): a changed pixel outside the box it declares, the fan's old lettering still present (normalised correlation of the box), or a palette that is not the fan's; the same check on the fan ROM must flag every picture, so a detector that stops detecting is noticed |
audits/audit_fixtures.py |
Breaks a copy of your build twenty-three ways across sixteen scripts (the fifteen audits that have a fixture, and the line-width measurement; audit_centring gets three; audit_choicearg two, one per fault class; audit_indexargs two; audit_staging three; audit_widgets two), and checks each one notices |
audits/audit_typography.py |
Not a width check, a SHAPE check: a spurious space, a line opening with a comma, empty brackets, a stray blank line. None of those make a line too wide, which is how the v1.9.0 development build put a space after 144 re-opened brackets and survived a full review. Comparative against an older ROM, since several of these quirks predate this port; --selftest proves it can fail against eight broken fixtures |
audits/measure_linewidth.py |
Not an audit, a measurement: how many dialogue lines are wider than the 240px box, using the font's real advances. Its scope is pinned in its docstring because four differently scoped walkers gave four different line totals while agreeing exactly on the over-budget count, so any figure taken from it is quoted with its command |
spt.py |
SPT container parser, both variants, with offset-scale detection |
build_spt.py |
SPT writer |
dstext.py |
Text conversion: fullwidth mapping, pixel wrapping, page breaks, control-code arity; since 1.11.1 it also drops Capcom's line breaks inside long screams and lays those boxes out as even rows |
build.py |
One-shot entry point: extract everything, then inject. This is what the released binaries run |
locate.py |
Finds the fan ROM and the Collection, and verifies the ROM by hash |
jp_profile.py |
Regenerates dump/jp_structure.json, the shipped structural counts from the JP script |
ctrl_args.py |
Regenerates dump/ctrl_args.json; --check diffs a fresh derivation against it |
inject.py |
Mapping, structural guards, ROM rebuild |
loc_patch.py |
Evidence, profiles and Logic cards from the Unity Localization tables |
logic_names.py |
Maps each DS Logic keyword slot to Capcom's official short name; since 1.11.1 the description match ignores punctuation as well as spaces, which adds six names |
logic_cards.py |
Renders those names into the Logic card and banner images; since 1.11.0 it also rewrites a banner's whole stored extent, so leftover fan letters past the declared data cannot show |
voices.py |
Capcom's English shouts from the Collection into the DS sound archive |
names.py |
The fan→official character-name map, applied only to strings that kept fan text |
plates.py |
Redraws the nameplate and title-card graphics in the fan's own pixel font; since 1.11.1 it also draws the "Promise Notebook" strip, whose fan original is still Japanese, guarded by the strip's own pixels (RETAIL_JAPANESE) |
choice_strips.py |
Sets Capcom's option text on the 297 choice/topic button plates; select_strips.json is the plate→string pairing |
txtcut.py |
Renders Capcom's close-up text screens (reports, letters, notes) into the 39 full-screen images the fan drew, in the fan's own pixel face harvested into txtcut_font.json; txtcut_condensed.json holds the six reviewed edits |
cg_names.py |
Re-letters the fan character names drawn into the room map and the two log tables with the official ones; map_font.json is the fan's small map face |
cg_art.py |
Writes the six shipped close-up pictures in cg_art_final/ (briefing diagrams, cake placards, TV logo, movie poster, magazine) into the ROM and derives the TV logo's 60 zoom frames from the TV picture; cg_art_reg.json holds the measured framing of each zoom frame inside Capcom's picture |
bag_tex.py |
Puts Capcom's Rook and Knight into the security plan paper of the 3D purse model (texture bag_01 in jpn/modelitemlocal.bin, 1.11.1): redraws only the changed letters inside a mask, keeps the texture's palette, tone and grain, and repacks the entry smaller than the fan's; bag_tex_reg.json is the measured fit between the close-up picture and the texture, and bag_tex_register.py (development only, needs scipy) is what measured it |
room_names.py |
Re-letters the room names on the cake-contest map pieces (jpn/cutobj_local.bin entry 10, jpn/idlocal.bin 321, 324 and 327) with Capcom's Gusto's, Scone's, Tangaroa's and Frost's, in faces cut from the fan's own labels at build time; TANGAROA_OPTION picks the layout of the one name that does not fit its wedge (1.11.1) |
opening_card.py |
Puts Capcom's English cake-show logo on the Episode 3 opening card (jpn/opening_local.bin 13 and 14), keeps the fan's art outside the lettering, matches the overlay frames, and extracts three Collection files into dump/title/opening (1.11.1) |
idcom_icons.py |
Capcom's business card and victim's letter pictures for com/idcom.bin 281, 573 and 575, registered onto the fan's icons and written in place; since 1.11.2 also the label words on other icons, and relayout() lays the whole file out again when the law-office card, the licence or the notebook (483, 519, 563) is stored larger than the fan's slot; idcom_icons_reg.json is the measured fit and idcom_icons_register.py (development only, needs scipy) is what measured it (1.11.1) |
item_pics.py / icon_labels.py / plaque_face.py |
In-place re-letterings of item and evidence pictures where the fan's wording differs from Capcom's: the weight block's "30 lb", the keycard icon, the notebook, TV sign, cookbook (painting title, three brass plaques, the frame plaque in two lines), circus bubble, news banner and two chess pictures, in jpn/upcut_local.bin, com/idcom.bin and com/logic_keyword.bin. Reads Collection close-ups saved under dump/title/items/, each guarded by the sha256 of its decoded pixels, and the fan's entries guarded by sha256 (--any-rom skips the guards and leaves the pictures alone). Step 7i of title_assets, after the icon swaps and the map and plan steps. icon_labels.py sets small label words in the fan's bottle-label face and plaque_face.py is the one-pixel face for plaques, bubbles and banners; item_pics_reg.json is the measured fit of each texture and item_pics_register.py (development only, needs scipy) is what measured it. Buffer check m; audit audit_item_pics.py |
menu_button.py |
The partner card's "Change Re-creation" button (jpn/idlocal.bin 357) becomes Capcom's "Switch re-creations", in letters cut from the button itself; reads only dump/loc_en.json. Step 7j, after every other idlocal writer. Buffer check n |
bigg_building.py |
Capcom's "Bigg Building" for the fan's "Grand Tower" on the city map, the cross-section titles and the place cards (16 pictures in idlocal, upcut_local and cutobj_local), in letters cut from the fan's own labels. Step 7d: after the room names (4d) and the last upcut_local writer; needs the fan's idlocal 293, 294 and 326 unchanged in the ROM it builds, so the map pieces (7g) run after it. Buffer check o |
bigg_ground.py |
The ground under a city-map label, derived at build time from Capcom's own map in seven languages (dump/title/bigg_map/, 14 PNG, sha256-guarded); refuses a picture that does not register |
marker_letters.py |
Capcom's letters in the round character markers of the floor plans and maps (28 markers in 10 cutobj_local entries), matched by geometry to Capcom's sprite atlases (dump/title/bigg_markers/, 13 atlases and cell lists, sha256-guarded). Step 7e, after 7d. Buffer check l |
city_labels.py / city_patches.py |
Capcom's wording on the other city-map labels, the cross-section room label, the 50th-floor strip and the highlight patches in cutobj_local 18. Step 7f, after 7d and 7e. Buffer check o |
plan_labels.py / gallery_art.py / board_temps.py |
Capcom's wording on the map pieces (idlocal 282 to 349, cutobj_local 12; step 7g, after the room names and Bigg Building) and the plans of the prison, the detention center and Zodiac Hall (upcut_local 36 to 116; step 7h). gallery_art.py swaps the Zodiac Hall leaflet whole from Capcom's picture (dump/title/gallery/); board_temps.py corrects the temperatures on the information board. Buffer check p |
props.py / props_core.py |
Capcom's wording on signs painted into backgrounds (the prison motto, the Maximum Security plate, the circus banner, the cotton candy stand, the fence permit, the ship plaque): 19 pictures in mapbg, logicbg, upcut and upcut_local, from windows saved under dump/title/props/. Step 7k. Buffer check s |
props_bust.py |
The nurse's cap (SAFETY [pill] FIRST) in com/bustup.bin 252 to 255 and 264 and her portrait icons com/idcom.bin 951 and 953. Step 7l, after every idcom.bin writer (7c and 7i, which may lay the file out again). Buffer check q |
input_hashes.py / gfx_views.py |
The sha256 guards of the Bigg Building, marker and city label steps, and the raw-palette-index views of every graphic entry that the detector and the writers share |
bigg_detect.py / plan_detect.py / props_detect.py |
Detectors, not on the build path: slide the fan's old lettering over a built ROM's pictures and report any that survive (the tower name; the old map-piece and plan wording; the old signs and cap). Each reads the fan's files and the retail Japanese ones (dump/ds_fan and dump/ds_jp, or --fan-rom and --jp-dir for bigg_detect.py). bigg_render.py writes before and after renders of the Bigg Building pictures |
test_bufcheck.py |
Negative tests for every buffer check a to s: each feeds a mutated copy of the built files and requires the check to raise, naming the entry or reason the test expects. python tools/test_bufcheck.py out/x.nds dump |
test_bigg_ground.py / test_marker_letters.py / test_city_labels.py |
Planted-fault tests for those steps (a texture that does not register or is for another entry, a missing cell, a base ROM that is not the fan's, a face without a letter); each takes the fan ROM and the dump folder and builds its own input |
test_plan_art.py / test_props.py |
The same for the map pieces, plans, leaflet and board, and for the signs and the cap (including the detector run on the fan's own containers, which it must flag everywhere); each takes a built ROM and the dump folder |
build_map.py / map_ids.py |
Fuzzy n-gram matching of DS entries to Collection files |
lz11.py / nitro.py |
Nintendo LZ11 (lz11.compress is a real optimal-parse encoder since 1.11.0, used for the Mind Chess banner) and NCGR/NCLR/NSCR/NCER/NANR |
episode_titles.py |
The official episode names in the save-screen strings (on since 1.5.0) |
title_assets.py |
Title logo, episode-title sprites and Logic cards: pulls the assets from the Collection and applies extract_logo / title_logo / title_text / logic_cards |
title_version.py |
Paints the build's version into the title screen's empty corner |
condense.py / condense_generated.py |
The word-index edits that fit 111 official descriptions into the DS box (result-hashed, no Capcom text) |
coverage.py |
The coverage figures above, recomputed from a built ROM; runs in a private temporary folder and warns when it had to fall back to the width estimate |
rowsplit.py |
Splits any script row whose entry would overrun the engine's fixed 0x2000 load buffer into two chained rows, cutting only at a box end, with every word kept (the 1.11.0 Case 4 crash fix) |
rowfold.py |
Folds those split rows back through the build's split manifest so the audits and coverage.py see the fan's row layout |
e11c.py |
Puts back the fan's {E11C} touch-tap on/off commands the port had dropped, and removes three it had added (hash-guarded rows) |
bufcheck.py |
Build-time checks that no script entry, text box, examine row, graphics entry, cutdata slot or Logic banner exceeds what its loader can hold; run by every build. Since 1.11.1 it also checks the room labels, the opening card, the evidence icons and the Promise Notebook strip, and since 1.11.2 the nameplates (k), marker letters (l), item pictures (m), menu button (n), Bigg Building (o), map pieces and plans (p), the nurse's cap (q) and the background signs (s); there is no check r |
desc_overflow.py |
Lists every condensed description four ways from your own extracted data |
build.py --selftest imports every module the build needs and checks that the data files beside them are
present. Its import list deliberately leaves out props_detect, bigg_detect and plan_detect (and bigg_render,
the two *_register tools and the test scripts): none is on the build path, so PyInstaller does not bundle them,
and the frozen exe's selftest would fail on the import. From source it also fails on any tools/*.json that is not
in TOOLS_DATA or that the CI workflow does not bundle. The workflow pins the decoders (UnityPy, texture2ddecoder,
Pillow, numpy) because the build checks the sha256 of every Collection texture it decodes.
Nothing here asks to be taken on trust. Every figure in this README is reproducible
by running the tools on your own files: tools/coverage.py computes the coverage table,
and the format work behind it (20,516 of 26,172 newlines, 54 of 429 relaid entries) is
grounded in measurements over the real files rather than in anyone's recollection.
--verify hashes a finished build against the release's published reference, so a ROM can
be trusted without trusting whoever built it, and the published binary has been confirmed
to reproduce that hash byte-for-byte.
The 18 audits in audits/ guard the structural failure classes, and each one
but audit_typography.py, which checks a shape rather than a fault, is tested against a deliberately corrupted input. audits/audit_fixtures.py
breaks a copy of your build (or, for the title audit, of the fan artwork it reads) in exactly
the way each one claims to detect and checks it notices, reporting the line that changed.
Run it yourself; it never touches out/ or dump/. One of those fixtures immediately
exposed a defect class that had no audit at all, which is why audit_boxes.py exists.
A fixture can be checked against the wrong value and PASS anyway for the wrong reason: an
early audit_choicearg fixture poked a strip id (163) that is a real sprite bundle on the
DS, so it only proved the fan-diff check, never the idlocal sprite-vs-palette check it was
supposed to exercise, and on a build made before its own fix, poking that same already-wrong
value changed nothing at all - a false MISSED that would have looked like a fixtures-suite
bug rather than a bad test value. It now uses an id chosen to land on a real palette entry
(170, not 163), so the fixture proves the sprite-vs-palette check itself and no longer
depends on whether the out/ build already carries the argument fix. An audit that has
never failed has not been tested, it has only been run - and that applies to picking the
fixture's own bytes, not only to the audit it exercises.
Each audit also carries a # SCOPE block stating what it does not look at, because a
check with an unstated scope is how something goes unexamined without anyone being wrong
about anything.
The tooling was written with LLM assistance: Claude, driven through Claude Code, over a series of sessions. That is stated plainly rather than buried, and so is the rest of the record, including the parts that do not flatter it.
Every hang but one was found by playing the game, not by an offline check (the Case 4 freeze fixed in 1.10.0 was found by searching for the cause of the Case 2 one). Three of them:
- a mode launcher whose argument had been converted into a letter, because the
argument's constant value was the letter
D; - seven examine rows emptied so completely they lost their message box;
- Episode 1 refusing to hand over control, while the guard meant to prevent exactly that counted boxes and saw a healthy number.
Each was invisible to a format check: the file parses, the structure verifies, the game stops anyway. Two of the three were found by a person who simply started playing and left the emulator running when it froze, which turned out to be worth more than any audit run that day.
The build's own output did catch one thing, though not a hang: v1.4.4's ten clipped lines were sitting in a warning that v1.4.3 printed and nobody read, because the release was already tagged by the time it scrolled past. A guard that reports into a wall of counters only works if someone reads the wall.
So treat the guards as a record of what has actually gone wrong rather than proof that nothing else will, and treat the code as reviewable rather than authoritative. It is about 34,000 lines across 94 modules, plus 19 audit scripts (the 18 audits and the harness that tests them), MIT licensed, and it ships as source precisely so you do not have to take any of the above on faith. Read it before you trust it with a ROM you care about, and recompute anything here that matters to you.
That is also why issue #1 asks for players rather than for approval. Episodes 1, 3, 4 and 5 and the second half of Episode 2 have been finished by a tester, across several builds. The first half of Episode 2 hasn't been reported finished, and the automated rig has not finished an episode.
Releases carry Prosecutors-Path-X.Y.Z-fan-base.xdelta, a delta from the AAI2 Final v2
fan ROM to the built one. It deserves a plain description rather than a quiet link: that
delta is Capcom's script. The localization is its payload, which is what makes it nearly 4 MB
and what lets it produce the ported ROM without the Collection ever being installed.
Earlier versions of this page argued against shipping one for exactly that reason. It
ships anyway, so the trade is written down here instead of left implied.
Three things that section got right and that still hold:
- The repository distributes nothing beyond the six pictures above. No ROM, no
script, no extracted text. The three
.jsonfiles underdump/are integers and filenames;dump/engis Capcom's text, anddump/titleanddump/voiceare their logo, fonts and audio. None of those are in this tree, patch or no patch. - The patch is inert without your own DS copy. It has to be applied to the AAI2 Final v2 ROM, which comes from applying the fan patch to a Gyakuten Kenji 2 cart you own, and the output is the same non-redistributable ROM a local build produces.
- Buy the Collection. It is very good, and it is the reason there is anything to port at all. Building from your own copy remains the route this project is built around, and the only one where nothing has to be taken on trust from whoever uploaded a file.