CLOSES #779 , CLOSES #743 + new font

This commit is contained in:
bryanthaboi
2026-08-04 06:41:34 -04:00
parent fe139ca7f4
commit 3a6557ffe2
30 changed files with 1214 additions and 79 deletions
+3
View File
@@ -0,0 +1,3 @@
*.gb
*.sym
__pycache__/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 hernan0078
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+97
View File
@@ -0,0 +1,97 @@
# gbromdiff
Compare two Game Boy ROMs bank by bank, and say **where** and **how** they
disagree.
Written for one specific problem: bringing a localised Gen 1 Pokémon release
into a disassembly. It is not specific to Pokémon, or to Gen 1 — any pair of
Game Boy ROMs will do.
## Why
The European Pokémon releases are rebuilds of their US counterparts. Most
banks are byte-identical, the text banks are entirely different, and a handful
of code banks are the US code with pointers shifted. Which bank is which is
the first thing you need to know and the last thing anyone writes down.
That distinction is the whole point of this tool:
```
bank differing bytes shape
0x04 30 ( 0.2%) patched
0x10 16328 ( 99.7%) replaced
```
A bank differing in 99.7% of its bytes is a **different payload** — translated
text — and needs new source. A bank differing in 0.2% is **the same code with
values moved**, and usually needs a pointer table corrected rather than a
rewrite. Those two findings lead to completely different work, and a plain
`cmp` cannot tell them apart.
## The loop it is built for
```
1. build the disassembly
2. diff the build against the retail ROM ← this tool
3. fix the banks that disagree
4. go to 1, until nothing disagrees
```
Exit status is **0 when the ROMs are identical** and **1 when they differ**, so
it drops straight into that loop:
```bash
make && ./gbromdiff.py build.gb retail.gb || echo "not there yet"
```
## Usage
```bash
./gbromdiff.py A.gb B.gb # bank-by-bank summary
./gbromdiff.py A.gb B.gb --regions # contiguous differing runs
./gbromdiff.py A.gb B.gb --sym pokeyellow.sym # name the symbols involved
./gbromdiff.py A.gb B.gb --json # machine-readable
```
With an rgbds `.sym` file it names the symbol each differing region falls
inside, which turns
```
0x0703A1 differs
```
into
```
0x0703A1-0x0703B4 bank 0x1C 20 bytes TextPredef+0x3A1
```
`--gap N` controls how many matching bytes are tolerated inside one region
(default 16). Without it, a shifted pointer table reports as hundreds of
one-byte findings instead of one region worth looking at.
No dependencies beyond Python 3.
## What this does not do
It does not build anything, and it does not write a disassembly. It tells you
where two ROMs differ. Turning that into a source tree that rebuilds a
localised ROM byte-for-byte is the actual project; this is the instrument you
point at it between iterations.
## Context
The Spanish Red and Blue releases are supported in
[gen1recomp](https://github.com/bryanthaboi/gen1recomp) by way of
[einstein95/pokered-es](https://github.com/einstein95/pokered-es), a
shift-matching disassembly whose `.sym` files provide Spanish addresses for
every symbol.
Spanish **Yellow** has no equivalent, so it cannot be supported the same way.
French (`Narishma-gb/pokeyellow-fr`) and German (`Brianum/pokeyellow-de`)
Yellow disassemblies both exist, so adapting pret's Yellow to a European
release is demonstrably possible — it simply has not been done for Spanish.
This tool exists to make that attempt less tedious.
MIT licensed. Contributions welcome, particularly from anyone actually
attempting `pokeyellow-es`.
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Compare two Game Boy ROMs bank by bank, and say where they disagree.
Written for the problem of bringing a localised Gen 1 release into a
disassembly. The European Pokemon releases are rebuilds of their US
counterparts: most banks are byte-identical, the text banks are wholly
different, and a handful of code banks are the US code with shifted
pointers. Knowing WHICH is which is the first thing you need and the
last thing anyone writes down.
The loop this is built for:
1. build the disassembly
2. diff the build against the retail ROM
3. fix the banks that disagree
4. go to 1, until nothing disagrees
Step 2 is this script. With a .sym file it also names the symbols that
live inside each differing region, which turns "bank 0x1C differs at
0x4A31" into "bank 0x1C differs, starting inside TextPredef".
Usage:
gbromdiff.py A.gb B.gb bank-by-bank summary
gbromdiff.py A.gb B.gb --regions contiguous differing runs
gbromdiff.py A.gb B.gb --sym pokeyellow.sym name the symbols involved
gbromdiff.py A.gb B.gb --json machine-readable
Exit status is 0 when the ROMs are identical, 1 when they differ, 2 on a
usage error -- so it can drive a build loop directly.
"""
import argparse
import json
import os
import sys
BANK_SIZE = 0x4000
def load(path):
with open(path, "rb") as fh:
return fh.read()
def header(rom):
"""Title, CGB flag and global checksum, straight out of the cartridge
header. Useful for saying WHICH releases are being compared without
the caller having to know the hashes."""
if len(rom) < 0x150:
return {}
title = rom[0x134:0x143].split(b"\x00")[0]
try:
title = title.decode("ascii", "replace").strip()
except Exception:
title = repr(title)
return {
"title": title,
"cgb": rom[0x143],
"rom_size_code": rom[0x148],
"global_checksum": (rom[0x14E] << 8) | rom[0x14F],
}
def banks(rom):
return (len(rom) + BANK_SIZE - 1) // BANK_SIZE
def bank_report(a, b):
"""Per-bank identical / differs / missing, with a byte count."""
out = []
for i in range(max(banks(a), banks(b))):
lo, hi = i * BANK_SIZE, (i + 1) * BANK_SIZE
ba, bb = a[lo:hi], b[lo:hi]
if not ba or not bb:
out.append({"bank": i, "state": "missing",
"in_a": bool(ba), "in_b": bool(bb)})
continue
if ba == bb:
out.append({"bank": i, "state": "identical", "differing": 0})
continue
n = sum(1 for x, y in zip(ba, bb) if x != y)
# A bank that differs in nearly every byte is a different payload
# (translated text); one that differs in a scatter of bytes is the
# same code with pointers moved. That distinction is the whole
# reason to look at a percentage rather than a boolean.
pct = 100.0 * n / min(len(ba), len(bb))
out.append({"bank": i, "state": "differs", "differing": n,
"percent": round(pct, 2),
"shape": "replaced" if pct > 60 else
("patched" if pct < 5 else "mixed")})
return out
def regions(a, b, gap=16):
"""Contiguous runs of differing bytes, merging runs separated by fewer
than `gap` matching bytes -- otherwise a shifted pointer table reads as
hundreds of one-byte findings instead of one region."""
out = []
n = min(len(a), len(b))
start = None
last = None
for i in range(n):
if a[i] != b[i]:
if start is None:
start = i
elif last is not None and i - last > gap:
out.append((start, last))
start = i
last = i
if start is not None:
out.append((start, last))
if len(a) != len(b):
out.append((n, max(len(a), len(b)) - 1))
return out
def load_symbols(path):
"""An rgbds .sym file: `BB:AAAA Name` per line. Returned as a list of
(absolute_offset, bank, addr, name), sorted, so a region can be mapped
to whatever symbol most recently preceded it."""
syms = []
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.split(";")[0].strip()
if not line or ":" not in line:
continue
try:
where, name = line.split(None, 1)
bank_s, addr_s = where.split(":")
bank, addr = int(bank_s, 16), int(addr_s, 16)
except ValueError:
continue
# bank 0 is 0000-3FFF; every other bank is paged in at 4000
offset = addr if bank == 0 else bank * BANK_SIZE + (addr - 0x4000)
syms.append((offset, bank, addr, name.strip()))
syms.sort()
return syms
def symbol_before(syms, offset):
"""The last symbol at or before `offset` -- i.e. the thing this byte is
most likely part of. Binary search over the sorted table."""
lo, hi = 0, len(syms) - 1
best = None
while lo <= hi:
mid = (lo + hi) // 2
if syms[mid][0] <= offset:
best = syms[mid]
lo = mid + 1
else:
hi = mid - 1
return best
def main():
ap = argparse.ArgumentParser(
description="Compare two Game Boy ROMs bank by bank.")
ap.add_argument("rom_a")
ap.add_argument("rom_b")
ap.add_argument("--regions", action="store_true",
help="list contiguous differing runs, not just banks")
ap.add_argument("--sym", help="rgbds .sym file, to name the symbols "
"each differing region falls inside")
ap.add_argument("--gap", type=int, default=16,
help="matching bytes tolerated inside one region "
"(default 16)")
ap.add_argument("--limit", type=int, default=40,
help="max regions to print (default 40)")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
for p in (args.rom_a, args.rom_b):
if not os.path.isfile(p):
print(f"error: no such file: {p}", file=sys.stderr)
return 2
a, b = load(args.rom_a), load(args.rom_b)
rep = bank_report(a, b)
same = [r for r in rep if r["state"] == "identical"]
diff = [r for r in rep if r["state"] == "differs"]
result = {
"a": {"path": args.rom_a, "bytes": len(a), **header(a)},
"b": {"path": args.rom_b, "bytes": len(b), **header(b)},
"banks_total": len(rep),
"banks_identical": len(same),
"banks_differing": len(diff),
"banks": rep,
}
if args.regions or args.sym:
regs = regions(a, b, args.gap)
syms = load_symbols(args.sym) if args.sym else None
listed = []
for start, end in regs[: args.limit]:
item = {"start": start, "end": end, "length": end - start + 1,
"bank": start // BANK_SIZE}
if syms:
s = symbol_before(syms, start)
if s:
item["symbol"] = s[3]
item["symbol_offset"] = start - s[0]
listed.append(item)
result["regions_total"] = len(regs)
result["regions"] = listed
if args.json:
print(json.dumps(result, indent=2))
return 0 if not diff and len(a) == len(b) else 1
ha, hb = result["a"], result["b"]
print(f"A {ha.get('title','?'):<16} {len(a):>8} bytes {args.rom_a}")
print(f"B {hb.get('title','?'):<16} {len(b):>8} bytes {args.rom_b}")
print()
if not diff and len(a) == len(b):
print(f"IDENTICAL — all {len(rep)} banks match")
return 0
print(f"{len(same)}/{len(rep)} banks identical, {len(diff)} differ")
print()
print(" bank differing bytes shape")
for r in diff:
if r["state"] != "differs":
continue
print(f" 0x{r['bank']:02X} {r['differing']:>6} "
f"({r['percent']:>5.1f}%) {r['shape']}")
print()
print(" replaced = a different payload (translated text)")
print(" patched = the same code with a few values moved")
if "regions" in result:
print()
print(f"{result['regions_total']} differing regions "
f"(showing {len(result['regions'])}):")
for item in result["regions"]:
line = (f" 0x{item['start']:06X}-0x{item['end']:06X} "
f"bank 0x{item['bank']:02X} {item['length']:>6} bytes")
if "symbol" in item:
line += f" {item['symbol']}+0x{item['symbol_offset']:X}"
print(line)
return 1
if __name__ == "__main__":
sys.exit(main())