A few days ago I posted an English patch for Blue Wing Blitz, Square's 2001 air-combat strategy game for the WonderSwan Color. Several people asked how it was made, so this is the long version. I've tried to write it so that a programmer who has never touched a ROM hack can follow the whole process and reuse the ideas on another game.
Please read first: the translation and all the hacking were done entirely with AI (Claude), as a personal project. I directed the work, played the builds and reported what was wrong; Claude did the reverse engineering, the code and the English. I share the patches for personal use, but they must never be uploaded to romhacking sites: they aren't up to the standard of a real fan translation. This post is about the method, not a claim that AI replaces human translators and hackers.
Contents
- The machine in five minutes
- The workbench
- Finding the font, then the text
- How the game prints a line
- Translating with context
- A new font
- Making longer English fit: jump stubs
- The page engine: wrapping, new windows and the tile ring
- Tables the game copies around
- Compressed graphics
- Testing
- The double AI review
- Lessons for programmers
1. The machine in five minutes
- CPU: NEC V30MZ, a fast 80186-compatible 16-bit x86 at about 3 MHz. The usual x86 segment:offset addressing applies: physical address = segment × 16 + offset, 20 bits, so 1 MB of address space.
- Screen: 224 × 144. The video hardware works with 8×8 tiles, 2 bits per pixel (4 colours per palette) in the mode this game uses.
- Memory map (simplified):
- segment 0000: internal RAM (work RAM, tile data, tile maps, palettes);
- 1000: cartridge SRAM (saves);
- 2000 and 3000: two 64 KB windows onto any ROM bank, chosen with I/O ports 0xC2 and 0xC3;
- 4000–FFFF: "linear" ROM, chosen in 1 MB blocks with port 0xC0.
- This game's layout: the ROM is 2 MB, i.e. 32 banks of 64 KB. With the setting the game uses, segments 4000–FFFF read the upper half of the file directly. So segment 7000 = file offset 0x170000, D000 = 0x1D0000, and F000 = 0x1F0000, the last bank, which holds the main code.
- Everything below is written as
segment:offset (what the CPU sees) or as a 6-digit file offset (what you patch).
- Header: the last 16 bytes of the ROM hold the header. The last two bytes are a 16-bit sum of every other byte. Recompute it after patching, or emulators will complain.
2. The workbench
Everything ran in a Linux sandbox that Claude drives by itself:
- Python for every tool: extractors, encoders, compressors, the build script.
- MAME, run headless and scripted in Lua: press buttons on given frames, take a screenshot after every step, read or watch memory.
- A reproducible build.
python3 src/build.py starts from the untouched Japanese ROM and applies everything: font, text, code, tables and graphics.
- Nobody ever edits the ROM by hand.
- Every patch first asserts the original bytes it expects to find (
assert rom[a:b] == ...). If a guess about the code is wrong, the build stops instead of silently corrupting the game.
- BPS output, made by a small Python BPS encoder.
My part was to play, send screenshots of anything wrong ("this window overflows", "this name is cut", "Japanese left in this menu"), and decide on names and taste.
3. Finding the font, then the text
There was no existing table for this game, so the first job is to learn how the game encodes characters.
The font. A tile viewer over the ROM (2bpp, various widths) shows the font sheet at file offset 0x120000:
- 16×16 glyphs, each made of four 8×8 2bpp tiles;
- 8 glyphs per 0x200-byte row, laid out like this:
Kana and kanji.
- The first glyphs are a space, digits, punctuation, then hiragana and katakana in the standard order, so the kana part of the table can be read off directly.
- After them come about 380 kanji in no particular order, plus icons. Those were identified with a small template-matching OCR:
- Render every kanji of JIS X 0208 (about 6,300) with Noto Sans CJK.
- Crop each rendering and each game glyph to its ink, centre it in a square, scale it to 24×24 and blur it slightly.
- Turn each into a zero-mean, unit-length vector and take the best cosine match.
- Fix the misreads by context, once the text around each kanji is readable. Small kanji at 12 px look a lot alike: 未 vs 末, 土 vs 士.
The encoding.
0x30-0xFF one byte = glyph (code - 0x30)
0x21 xx two bytes = glyph 0xD0 + xx
0x22 xx two bytes = glyph 0x1D0 + xx
0x00-0x1F control codes
0x01 end of line (return to the caller)
Finding every line.
- A scanner walks the script banks looking for byte runs that decode to plausible Japanese: valid glyph codes, lots of hiragana, ending in 0x01. It scores each candidate.
- Candidates alone are not proof, so the scanner also cross-references them. The event script calls each line with a 3-byte instruction
04 lo hi (call hi:lo in the same segment), so a real line is one that some 04 points at.
- That gave a dump of about 2,000 lines with their exact address, length and call sites (
refs.json). The call sites matter later.
4. How the game prints a line
This is a real window from the story, as bytes in the event script (bank 0x1B):
04 13 E2 call 1BE213 "われわれは まずロギーナ軍を" (We must first...)
0F 00 new row
04 23 E2 call 1BE223 "おいださなくてはならない" (...drive out the Roggina army)
04 2C 6E call 1B6E2C wait for a key (a small routine)
0F 01 clear the window
And the first line itself:
6B 69 6B 69 59 30 5E 7F BB C9 77 A5 21 4E 6C 01
わ れ わ れ は ␣ ま ず ロ ギ ー ナ 軍(2 bytes) を end
Other control codes worth knowing:
0F 06 nn moves to column nn.
0F 04 waits for a key inline.
07 49 lo hi 04 00 03 prints a name stored in RAM at hi:lo: the eight pilots live at 0B00, 0B10 ... 0B70 and the airship at 0BF0, because the player can rename them.
- A "speaker header" is a small subroutine that prints the name and a full-width colon.
From this the extractor groups lines into pages. A page is what one window shows between two waits: the lines, how many rows they use, the speaker, the column, the kind of window and what ends it. Blue Wing Blitz has 1,431 pages. Translating page by page, not line by line, is what makes the English read as speech instead of as fragments.
5. Translating with context
The English script is a plain text file keyed by ROM address. One entry can cover a whole page (+1 = the page spans one more line):
1BE213+1 First we must drive out the Roggina army.
Names are tags, {Keid}, {Payer} ... {Ship}, because the player can rename the heroes and the airship.
Rules Claude followed:
- Source text. Translate from the Japanese in the ROM; there was no earlier English translation of this game to lean on.
- Register. The Japanese is mostly hiragana with few kanji, written for a young audience. The English should be simple and lively but not childish. Menus, briefings and aircraft profiles use real aviation terms.
- Names. Use official romanizations where they exist: Square's 2001 announcements as reported by RPGamer, the English Wikipedia article, and the official PlayOnline site through the Wayback Machine. The aircraft keep the Norse, Slavic and Celtic myths they are named after: Hraesvelg, Gullinbursti, Svantovit, Triglav...
- Leftovers. Translate them too, even if almost nobody will see them: the debug sound-test strings, and 70 developer placeholder profiles ("プロフィールC / ここにプロフィールが / いろいろでます" → "Profile C / Profile text / goes here.").
6. A new font
The game was already proportional. The text engine reads a one-byte advance width per glyph from a table at 0x1DEB74. This saved a lot of work: no new renderer was needed, only new glyphs and new widths.
- Base font: Shinonome 6x12, a public-domain bitmap font loaded from its BDF file.
- Redrawn in the game's style into the 16×16 cells:
- ink in colour 3;
- a drop shadow in colour 2 one pixel right and down;
- background in colour 1;
- baseline aligned with the Japanese digits;
- advance = ink width + 2 (shadow column + 1 px gap).
- Glyph slots:
- The Japanese no longer needs its hiragana slots, so lowercase letters and punctuation went there. The one-byte codes 0x40–0x59 are now
a–z.
- The capitals use the game's existing Latin slots.
- Digits and the two-byte icons are kept as they are: HP, fuel, missile counts and stat bars, written in the script as
{HP}, {GAS}...
- Encoder: a small
charmap.py turns English plus tags into bytes. | = new row, ^ = wait and clear, {XX} = raw control byte.
7. Making longer English fit: jump stubs
This is the central trick, and it works for many games with a bytecode script.
The problem.
- English is longer than Japanese, usually 1.5–2× in bytes here.
- Each line is called by a 16-bit pointer from the script, inside its own 64 KB segment.
- The script banks are full. Moving every line would mean finding and rewriting every pointer, and some of them hide inside code.
The solution.
- Leave every pointer where it is. If the English fits in the Japanese line's bytes, write it in place (332 lines).
- Otherwise, overwrite the first three bytes of the Japanese line with a jump to the English, stored in free ROM somewhere else.
- The text interpreter has no such jump, so new control codes were added. The interpreter dispatches control codes 0x00–0x1F through a table of near pointers at F000:D2EB, and several entries are unused.
- To find that table: Lua read taps in MAME show which code reads the text bytes, and from there the interpreter loop and its jump table are a short disassembly away.
- Claude put small handlers in free space at F000:E600 and pointed the unused entries at them:
The whole 0x13 handler is five instructions. si points at the control byte and es:di is the interpreter's context:
mov si,[si+1] ; new text pointer = the 2 bytes after the code
mov ax,0x7000
mov ds,ax ; text now comes from segment 7000
mov [es:di+0x83],ds ; store it in the context, so it survives
ret
Why this works.
- The English ends with the same
0x01 as the Japanese. The interpreter keeps its own call stack in that context: depth at +3, 4-byte entries (offset, segment) from +0x85. So 0x01 simply returns to the script, exactly as the original line would have.
- The script never knows anything changed. The window from section 4 now starts with
13 E7 6B, a jump to 7000:6BE7, where "First we must drive out the" is stored.
Two corner cases.
- Lines shorter than three bytes can't hold a jump. For those, the build switches each caller from
04 lo hi to 15 lo hi, a far call into a copy in bank 0x1D. This is why the call sites were collected in section 3.
- Wait routines. Relocated text sometimes needs to call the wait routine of its original bank.
0x02 calls a routine in the segment saved on top of the call stack, so moved text can still use the right one.
Space allocation. A simple first-fit allocator manages:
- the free areas of each bank;
- the "gaps": the tails of Japanese lines that became 3-byte jumps, plus space freed by moved blocks.
Pointer tables that must stay in their own segment (section 9) are fed from those gaps.
Final count: 332 lines in place, 1,586 relocated, 65 moved out through far calls, and about 69 KB of ROM still free.
8. The page engine: wrapping, new windows and the tile ring
Writing the bytes is the easy half. Making every page look right is where most of the time went.
Wrapping.
- Text windows are 13 cells (208 px) wide and show two rows. Each page knows its kind of window, so the usable width is computed per page (for example, minus the column offset).
- Greedy word wrap by pixel width, using the exact advance table of the new font.
- Player names are counted at their maximum width (7 letters), because the player may rename Keid "WWWWWWW".
More rows than the Japanese.
- The extra rows are distributed over the page's lines, with
0F 00 new rows between them.
- When the window is full, the engine inserts a page break: the same wait routine the script uses on that page, then
0F 01 clear. Long speeches therefore continue in a new window, just as the game does by itself.
Names. The script's own name insertions inside a translated page are switched off by changing their last byte 03 into 05, a 1-byte no-op. The English carries its own insertions wherever the name falls in the English sentence.
The tile ring. This was the nastiest bug.
- A text window doesn't draw into a fixed area. It renders glyphs into a ring of 80–96 tiles, which is about 320–384 px of text per page.
- If a page needs more, the ring wraps and the last glyphs overwrite the first ones.
- Japanese never came close to the limit; English did.
- The build now simulates the renderer's tile counter for every page, including the speaker header, and adds a page break wherever the ring would overflow.
The column-jump patch. The original engine charged a column jump at the start of a row as if the skipped pixels had been drawn, wasting part of the ring on every indented row. A 4-byte patch at F000:C6DC calls a 17-byte routine that charges only x & 15.
Checks.
check.py reports any row wider than its window.
flowstat.py counts pages that don't fit; it must report 0 before a release.
- The speaker headers' full-width colon was replaced by a plain
:.
9. Tables the game copies around
Some strings aren't printed by the script. They are data:
- default pilot names;
- aircraft nicknames;
- model numbers;
- airship upgrade messages.
These are read through 16-bit pointer tables in their own segment, and some are copied one after the other into the save data by a block-copy instruction of the script. The game assumes the strings sit back to back.
- Rebuilding them. The build writes a new block and a new pointer table in free space of the same bank, then patches the script operands that point to them.
- The 7-glyph limit. The name-entry screen keeps at most 7 glyphs, the same field the player types into.
- Long default names like "Discovery" use ligatures: two narrow letters drawn in one 16×16 glyph, stored in unused kanji slots (21 per table).
- Model numbers are shortened:
アリウス → "Ar.", ラムヤ → "La.", 改 → "-K".
10. Compressed graphics
Two graphics needed work:
- the place caption of the first mission, "Tadaga, Esk Republic";
- the katakana reading under the title logo.
Finding the caption.
- Graphics are listed in two tables at D000:D484 and D000:E06C. Each starts with a word count, followed by (offset, segment) pairs. The segment's high byte is written to port 0xC2, so the data is read through the 2000 window.
- The decompressor is at F000:0956. Reading it gave the format, here called "lane RLE":
- Interleaving by lanes puts the same bitplane bytes next to each other, so runs compress better.
Redrawing it.
- Claude wrote a decompressor and a compressor in Python and checked that decompress(compress(x)) gives x back.
- The caption was decompressed, redrawn with the new font and recompressed: 571 bytes against the original 627, so it fits in place. Otherwise the table entry would simply be pointed at free space.
The title subtitle is just 9 sprite tiles, cleared. The last one also holds the edge of the "g" tail in the logo, so that tile is masked rather than wiped.
11. Testing
- Scripted runs. A Lua script inside headless MAME feeds input sequences like
-:300,Start:200,A:60,Right:16,... (button:frames), with one screenshot per step. The steps are then assembled into a contact sheet. A full run from boot to a story scene takes seconds.
- Memory taps. Lua write and read taps answer "who writes this byte?", which is how the interpreter, the tile ring and the name buffers were found.
- RAM pokes set up test situations without playing hours: an inventory, a pilot, a stage.
- The developer menu. The ROM still contains the developers' own menu: stage select and an event checker. Test builds reach it by changing one byte in the title screen's dispatcher, so any mission or cutscene can be checked in seconds. It is never enabled in releases.
- Savestates taken in the middle of a text box break after a rebuild, because the text moved. Replay from boot or from SRAM saves instead.
12. The double AI review
After v0.9 the whole script went through a second, independent AI. The result was v0.9.1.
The package for ChatGPT:
- 1,500 rows (1,496 text and 4 graphics) in six CSV batches with the Japanese, the English, the speaker and the row width limits;
- a 216-entry glossary of names and terms;
- a long prompt. The prompt asked ChatGPT to research official sources first and write a style guide and a name sheet, then give every row a verdict: CERTAIN, UNCERTAIN, DOUBTFUL or DECIDE (a human must choose).
Claude rechecked every proposal against the Japanese and the window sizes, and I decided the open choices. Some of the fixes:
最適速度 is "optimal speed", not "Cruise" (now "Opt Spd").
- A dive maneuver boosts every ground attack, not only bombing.
- A "might be the heroes" line had become a certainty.
- The maneuver from the proverb 二階から目薬 ("eye drops from the second floor") became "Long Shot".
- Two names were corrected: Rofe and Gulltoppr.
The reviewer's job is to find suspects; the translator decides with the ROM in hand. Several ChatGPT proposals were right in meaning but didn't fit 18 characters, or relied on names it couldn't verify. Its own report says so.
13. Summary
- Font first, table second, text third. Once the glyph sheet is decoded, the encoding usually falls out, and kanji can be OCR'd against a system font.
- Find the script interpreter's control-code table. Unused codes are free hooks: one jump code saves you from repointing thousands of lines.
- Collect call sites, not just strings. They let you relocate short lines, group lines into pages and translate whole speeches.
- Model the renderer's limits in the build: widths, rows, tile pools. Don't discover them by playing. If a check can say "this page overflows", playing becomes confirmation instead of search.
- Keep the build reproducible and defensive. Start from the clean ROM every time, assert original bytes before every patch, recompute the checksum and emit a BPS.
- Automate the emulator. Scripted input plus screenshots turns "play for an hour to see one window" into a loop of seconds.
- Where the AI needed a human:
- names and jokes (taste, not grammar);
- spotting what looks wrong on a real screen;
- deciding when "faithful" and "fits in 7 glyphs" conflict.
- Where it was strong: patient, exhaustive work, such as 2,000 lines, 1,431 pages, every pointer, every width, and reading x86 without getting bored.
The tools: every script described above (extractor, OCR, page engine, font, table and graphics code, BPS encoder, emulator harness) is in BlueWingBlitz_tools.zip: [link]. It contains no ROM and no Japanese script. You run the extractors on your own copy of the game, and the README lists the commands from the clean ROM to an English build. It's research code written for one game, but the ideas carry over.
Sources and tools
- Japanese text: the WonderSwan Color ROM itself.
- Names: Square's 2001 announcements as reported by RPGamer (archive.rpgamer.com), the English Wikipedia article "Blue Wing Blitz", the official PlayOnline site (via the Wayback Machine), Weblio and Kotobank for terms and idioms.
- Aviation terms: the FAA Airplane Flying Handbook and Wikipedia's articles on basic fighter maneuvers.
- Fonts: Shinonome 6x12 (public domain) for the game; Noto Sans CJK only as the OCR reference.
- Tools: MAME (headless, Lua scripting), Python, a home-made BPS encoder.
Translation and hacking: made entirely with AI (Claude) for personal use. Please don't upload it to romhacking sites. Questions about the method are very welcome in the comments!
I can post other HIMs for different games upon request.