Skip to content

Instantly share code, notes, and snippets.

@RetroGameDeveloper
Last active April 19, 2026 13:40
Show Gist options
  • Select an option

  • Save RetroGameDeveloper/bfecf7c09eea56398f4bc66d25d4d5ee to your computer and use it in GitHub Desktop.

Select an option

Save RetroGameDeveloper/bfecf7c09eea56398f4bc66d25d4d5ee to your computer and use it in GitHub Desktop.
Best-effort converter for Paul Hughes' `mrdo.asm` (Ocean/Special FX assembler dialect) into RGBDS-compatible assembly.
#!/usr/bin/env python3
"""
Audit that the direct-conversion Mr Do! build contains the ROM-side routines
defined in the released source snapshot.
This checks two things:
- Every "procedure-style" label detected in `build/mrdo.asm` under ROM `ORG` regions
appears in the RGBDS link map.
- Those labels land in ROM space (ROM0/ROMX), not accidentally in WRAM/HRAM/VRAM.
It does *not* prove behavioural correctness or byte-identity vs retail; it is a
completeness sanity check for the conversion/link step.
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, Tuple
_ORG_RE = re.compile(r"^\s*ORG\s+(.+)$", re.IGNORECASE)
_LABEL_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\b(.*)$")
_DIRECTIVES = {"EQU", "ORG", "DEFB", "DEFW", "DEFS", "HEX", "ENT", "END"}
@dataclass(frozen=True)
class MapSymbol:
region: str
bank: int
addr: int
def _parse_map(map_text: str) -> Dict[str, MapSymbol]:
symbols: Dict[str, MapSymbol] = {}
current_region: Optional[str] = None
current_bank: int = 0
bank_header = re.compile(
r"^(ROM0|ROMX|VRAM|SRAM|WRAM0|WRAMX|OAM|HRAM)\s+bank\s+#?(\d+)?",
re.IGNORECASE,
)
sym_re = re.compile(r"^\s*\$([0-9A-Fa-f]{4})\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\s*$")
for raw in map_text.splitlines():
line = raw.strip()
m = bank_header.match(line)
if m:
current_region = m.group(1).upper()
current_bank = int(m.group(2) or "0")
continue
m = sym_re.match(raw)
if m and current_region is not None:
addr = int(m.group(1), 16)
name = m.group(2)
symbols[name] = MapSymbol(region=current_region, bank=current_bank, addr=addr)
return symbols
def _next_meaningful_line(lines: List[str], start: int) -> Optional[str]:
for i in range(start, len(lines)):
s = lines[i].strip()
if not s or s.startswith(";") or s.startswith("*"):
continue
return s
return None
def _detect_rom_code_labels(source_lines: List[str]) -> Set[str]:
"""
Best-effort find "procedure-style" labels:
- label at column 0
- not followed by a data directive (DEFB/DEFW/DEFS/HEX/EQU/ORG)
- under a ROM ORG region (ORG $.... < $8000)
"""
segment = "unknown"
out: Set[str] = set()
for i, raw in enumerate(source_lines):
line = raw.rstrip("\n")
if not line or line.lstrip().startswith(";") or line.startswith("*"):
continue
m = _ORG_RE.match(line)
if m:
expr = m.group(1).strip().upper()
if "WORKRAM" in expr:
segment = "wram"
continue
if expr.startswith("$"):
try:
v = int(expr[1:], 16)
except ValueError:
segment = "other"
else:
segment = "rom" if v < 0x8000 else "other"
else:
segment = "other"
continue
if segment != "rom":
continue
if line[0].isspace():
continue
m = _LABEL_RE.match(line)
if not m:
continue
label = m.group(1)
rest = m.group(2).strip()
if not rest:
nxt = _next_meaningful_line(source_lines, i + 1)
if nxt and nxt.split()[0].upper() in _DIRECTIVES:
continue
continue
tok = rest.split()[0].upper()
if tok in _DIRECTIVES:
continue
out.add(label)
return out
def main(argv: Optional[Iterable[str]] = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--source", default="build/mrdo.asm")
ap.add_argument("--map", dest="map_path", default="build/mrdo.map")
args = ap.parse_args(list(argv) if argv is not None else None)
source_lines = Path(args.source).read_text(encoding="utf-8", errors="replace").splitlines()
map_text = Path(args.map_path).read_text(encoding="utf-8", errors="replace")
map_syms = _parse_map(map_text)
labels = sorted(_detect_rom_code_labels(source_lines))
missing = [l for l in labels if l not in map_syms]
wrong_region: List[Tuple[str, str, int, int]] = []
for l in labels:
ms = map_syms.get(l)
if ms is None:
continue
if ms.region not in ("ROM0", "ROMX"):
wrong_region.append((l, ms.region, ms.bank, ms.addr))
print(f"Source ROM-side procedure labels: {len(labels)}")
print(f"Missing from link map: {len(missing)}")
print(f"Mapped outside ROM0/ROMX: {len(wrong_region)}")
if missing:
print("\nMissing labels:")
for l in missing:
print(f"- {l}")
if wrong_region:
print("\nNon-ROM mappings:")
for (l, region, bank, addr) in wrong_region:
print(f"- {l}: {region} bank {bank} @ ${addr:04X}")
return 0 if (not missing and not wrong_region) else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Compare two Game Boy ROM images and (optionally) try to locate symbols from an
RGBDS link map within the other ROM via byte-signature search.
This is intentionally heuristic: it helps answer questions like:
- Do any 16 KiB banks match exactly?
- Does the original contain any long byte sequences from the rebuilt ROM?
- If not, are we likely looking at a different codebase/build/layout?
Example:
python3 scripts/compare-gb-roms.py \\
--rebuilt build/mrdo.gb \\
--original build/mrdo_original.gb \\
--map build/mrdo.map \\
--symbols START,SYSETUP,WAITBLANK,DMATRANS,MENUTEXT
"""
from __future__ import annotations
import argparse
import hashlib
import re
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
BANK_SIZE = 0x4000
@dataclass(frozen=True)
class Header:
title: str
cartridge_type: int
rom_size_code: int
ram_size_code: int
destination_code: int
version: int
header_checksum: int
global_checksum: int
def parse_header(data: bytes) -> Header:
title_bytes = data[0x134:0x144]
title = title_bytes.split(b"\0", 1)[0].decode("latin1", errors="replace").rstrip()
return Header(
title=title,
cartridge_type=data[0x147],
rom_size_code=data[0x148],
ram_size_code=data[0x149],
destination_code=data[0x14A],
version=data[0x14C],
header_checksum=data[0x14D],
global_checksum=int.from_bytes(data[0x14E:0x150], "big"),
)
def gb_header_checksum(data: bytes) -> int:
"""
Game Boy header checksum algorithm: bytes $0134-$014C inclusive.
(The stored checksum at $014D should satisfy this.)
"""
x = 0
for b in data[0x134:0x14D]:
x = (x - b - 1) & 0xFF
return x
def gb_global_checksum(data: bytes) -> int:
"""
Global checksum is the 16-bit sum of all bytes except the checksum field itself ($014E-$014F).
"""
total = sum(data) - data[0x14E] - data[0x14F]
return total & 0xFFFF
def cart_type_name(t: int) -> str:
return {
0x00: "ROM ONLY",
0x01: "MBC1",
0x02: "MBC1+RAM",
0x03: "MBC1+RAM+BATTERY",
0x19: "MBC5",
0x1A: "MBC5+RAM",
0x1B: "MBC5+RAM+BATTERY",
}.get(t, "unknown")
def rom_size_from_code(code: int) -> Optional[int]:
# Standard mapping; returns bytes.
mapping = {
0x00: 32 * 1024,
0x01: 64 * 1024,
0x02: 128 * 1024,
0x03: 256 * 1024,
0x04: 512 * 1024,
0x05: 1024 * 1024,
0x06: 2 * 1024 * 1024,
0x07: 4 * 1024 * 1024,
0x08: 8 * 1024 * 1024,
}
return mapping.get(code)
def extract_ascii_strings(data: bytes, min_len: int) -> List[bytes]:
out: List[bytes] = []
start = None
for i, b in enumerate(data):
if 0x20 <= b <= 0x7E:
if start is None:
start = i
continue
if start is not None:
if i - start >= min_len:
out.append(data[start:i])
start = None
if start is not None and len(data) - start >= min_len:
out.append(data[start:])
return out
def window_hashes(data: bytes, window: int, step: int) -> Dict[int, List[int]]:
"""
Map crc32(window_bytes) -> list[offsets] for windows in `data`.
"""
out: Dict[int, List[int]] = {}
if window <= 0 or window > len(data):
return out
for off in range(0, len(data) - window + 1, step):
h = zlib.crc32(data[off : off + window]) & 0xFFFFFFFF
out.setdefault(h, []).append(off)
return out
def coverage_by_windows(
rebuilt: bytes, original: bytes, window: int, rebuilt_step: int, original_step: int
) -> Tuple[int, int, List[Tuple[int, int]]]:
"""
Returns (matched_bytes, total_bytes, regions) where regions are (start,end_exclusive)
in rebuilt file offsets covered by at least one window match (rebuilt scanned with step).
"""
if window <= 0 or window > len(rebuilt) or window > len(original):
return (0, len(rebuilt), [])
orig = window_hashes(original, window, original_step)
covered = bytearray(len(rebuilt))
for off in range(0, len(rebuilt) - window + 1, rebuilt_step):
h = zlib.crc32(rebuilt[off : off + window]) & 0xFFFFFFFF
if h not in orig:
continue
# Verify to avoid crc collision; accept any hit.
chunk = rebuilt[off : off + window]
if any(original[o : o + window] == chunk for o in orig[h]):
for i in range(off, off + window):
covered[i] = 1
regions: List[Tuple[int, int]] = []
i = 0
while i < len(covered):
if not covered[i]:
i += 1
continue
j = i
while j < len(covered) and covered[j]:
j += 1
regions.append((i, j))
i = j
matched = int(sum(covered))
return (matched, len(rebuilt), regions)
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha1_hex(data: bytes) -> str:
return hashlib.sha1(data).hexdigest()
def split_banks(data: bytes) -> List[bytes]:
return [data[i : i + BANK_SIZE] for i in range(0, len(data), BANK_SIZE)]
def fmt_bank_addr(offset: int) -> str:
bank = offset // BANK_SIZE
within = offset % BANK_SIZE
if bank == 0:
addr = within
else:
addr = 0x4000 + within
return f"bank{bank}:{addr:04X}"
def parse_rgbds_map(map_text: str) -> Dict[str, Tuple[int, int]]:
"""
Parse `rgblink -m` output enough to get symbol -> (bank, address).
Returns addresses in CPU space: $0000-$3FFF for ROM0 bank0, $4000-$7FFF for ROMX banks.
"""
out: Dict[str, Tuple[int, int]] = {}
bank = None
bank_header_re = re.compile(r"^(ROM0|ROMX) bank #(\d+):")
sym_re = re.compile(r"^\s*\$([0-9a-fA-F]{4})\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\s*$")
for line in map_text.splitlines():
m = bank_header_re.match(line.strip())
if m:
kind, b = m.group(1), int(m.group(2))
bank = b # physical bank number in file
continue
m = sym_re.match(line)
if m and bank is not None:
addr = int(m.group(1), 16)
name = m.group(2)
out[name] = (bank, addr)
return out
def file_offset_from_bank_addr(bank: int, addr: int) -> int:
if bank == 0:
return addr
# ROMX bank N appears at CPU address $4000..$7FFF for that bank
return bank * BANK_SIZE + (addr - 0x4000)
def find_all(haystack: bytes, needle: bytes) -> List[int]:
if not needle:
return []
out: List[int] = []
start = 0
while True:
i = haystack.find(needle, start)
if i == -1:
return out
out.append(i)
start = i + 1
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--rebuilt", type=Path, required=True)
ap.add_argument("--original", type=Path, required=True)
ap.add_argument("--map", type=Path)
ap.add_argument("--symbols", type=str, default="")
ap.add_argument("--sig-len", type=int, default=48)
ap.add_argument("--scan-map", action="store_true", help="Scan all map symbols for signature hits")
ap.add_argument("--max-report", type=int, default=50, help="Max scan results to print")
ap.add_argument("--window", type=int, default=256, help="Window size for coverage scan (bytes)")
ap.add_argument("--rebuilt-step", type=int, default=16, help="Step for rebuilt coverage scan")
ap.add_argument("--original-step", type=int, default=1, help="Step for original coverage scan")
ap.add_argument("--min-string", type=int, default=10, help="Min ASCII length for string diff")
ap.add_argument("--strings", action="store_true", help="Report ASCII strings present in rebuilt but not original")
args = ap.parse_args()
rebuilt = args.rebuilt.read_bytes()
original = args.original.read_bytes()
print("**Header**")
rh = parse_header(rebuilt)
oh = parse_header(original)
rrom = rom_size_from_code(rh.rom_size_code)
orom = rom_size_from_code(oh.rom_size_code)
print(
f"- rebuilt: {args.rebuilt} ({len(rebuilt)} bytes) title={rh.title!r} cart=0x{rh.cartridge_type:02X} ({cart_type_name(rh.cartridge_type)}) rom=0x{rh.rom_size_code:02X}"
+ (f" ({rrom} bytes)" if rrom else "")
)
print(
f"- original: {args.original} ({len(original)} bytes) title={oh.title!r} cart=0x{oh.cartridge_type:02X} ({cart_type_name(oh.cartridge_type)}) rom=0x{oh.rom_size_code:02X}"
+ (f" ({orom} bytes)" if orom else "")
)
print(f"- rebuilt: header_ck=0x{rh.header_checksum:02X} (calc 0x{gb_header_checksum(rebuilt):02X}) global_ck=0x{rh.global_checksum:04X} (calc 0x{gb_global_checksum(rebuilt):04X})")
print(f"- original: header_ck=0x{oh.header_checksum:02X} (calc 0x{gb_header_checksum(original):02X}) global_ck=0x{oh.global_checksum:04X} (calc 0x{gb_global_checksum(original):04X})")
print("")
print("**Hashes**")
print(f"- rebuilt sha256: {sha256_hex(rebuilt)}")
print(f"- original sha256: {sha256_hex(original)}")
print("")
print("**Bank hashes (SHA1)**")
for label, data in [("rebuilt", rebuilt), ("original", original)]:
banks = split_banks(data)
print(f"- {label}: {len(banks)} banks")
for i, b in enumerate(banks):
print(f" - bank{i}: {sha1_hex(b)}")
print("")
# Quick per-bank equality matrix (same-size prefix).
rbanks = split_banks(rebuilt)
obanks = split_banks(original)
print("**Exact bank matches**")
any_match = False
for i, rb in enumerate(rbanks):
for j, ob in enumerate(obanks):
if rb == ob:
print(f"- rebuilt bank{i} == original bank{j}")
any_match = True
if not any_match:
print("- none")
print("")
print("**Coverage scan**")
matched, total, regions = coverage_by_windows(
rebuilt, original, window=args.window, rebuilt_step=args.rebuilt_step, original_step=args.original_step
)
pct = (100.0 * matched / total) if total else 0.0
print(f"- window: {args.window} bytes (rebuilt step {args.rebuilt_step}, original step {args.original_step})")
print(f"- matched bytes in rebuilt: {matched}/{total} ({pct:.1f}%)")
if regions:
# Report up to 10 largest regions.
regions.sort(key=lambda r: (r[1] - r[0]), reverse=True)
for start, end in regions[:10]:
print(f" - rebuilt {fmt_bank_addr(start)}..{fmt_bank_addr(end - 1)} ({end - start} bytes)")
else:
print("- no matching regions found at this window size/step")
print("")
if args.strings:
print("**Strings present in rebuilt but not original**")
rebuilt_strings = extract_ascii_strings(rebuilt, args.min_string)
original_blob = original
missing: List[bytes] = []
for s in rebuilt_strings:
if s not in original_blob:
missing.append(s)
missing.sort(key=len, reverse=True)
if not missing:
print("- none")
else:
for s in missing[:25]:
txt = s.decode("latin1", errors="replace")
print(f"- {txt!r} (len {len(s)})")
print("")
def report_symbol_hits(names: Iterable[str], syms: Dict[str, Tuple[int, int]]) -> List[Tuple[str, int, List[int]]]:
results: List[Tuple[str, int, List[int]]] = []
for name in names:
if name not in syms:
continue
bank, addr = syms[name]
off = file_offset_from_bank_addr(bank, addr)
sig = rebuilt[off : off + args.sig_len]
if len(sig) < args.sig_len:
continue
hits = find_all(original, sig)
if hits:
results.append((name, len(hits), hits))
return results
# Optional signature search from map symbols.
if args.map and args.map.exists() and (args.symbols.strip() or args.scan_map):
syms = parse_rgbds_map(args.map.read_text(encoding="utf-8", errors="replace"))
print("**Signature search**")
print(f"- map: {args.map}")
print(f"- signature length: {args.sig_len} bytes")
if args.symbols.strip():
sym_wanted = [s.strip() for s in args.symbols.split(",") if s.strip()]
for name in sym_wanted:
if name not in syms:
print(f"- {name}: not found in map")
continue
bank, addr = syms[name]
off = file_offset_from_bank_addr(bank, addr)
sig = rebuilt[off : off + args.sig_len]
if len(sig) < args.sig_len:
print(f"- {name}: signature truncated (rebuilt too small at {fmt_bank_addr(off)})")
continue
hits = find_all(original, sig)
if not hits:
print(f"- {name}: no hits")
continue
locs = ", ".join(fmt_bank_addr(h) for h in hits[:5])
more = "" if len(hits) <= 5 else f" (+{len(hits)-5} more)"
print(f"- {name}: {len(hits)} hit(s): {locs}{more}")
if args.scan_map:
# Scan all symbols and report a short summary of anything that hits.
all_names = sorted(syms.keys())
results = report_symbol_hits(all_names, syms)
results.sort(key=lambda x: (-x[1], x[0]))
print(f"- scan results: {len(results)} symbol(s) had at least one hit")
for name, count, hits in results[: args.max_report]:
locs = ", ".join(fmt_bank_addr(h) for h in hits[:3])
more = "" if len(hits) <= 3 else f" (+{len(hits)-3} more)"
print(f" - {name}: {count} hit(s): {locs}{more}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Best-effort converter for Paul Hughes' `mrdo.asm` (Ocean/Special FX assembler dialect)
into RGBDS-compatible assembly.
Goals:
- Keep the original source file unchanged.
- Produce an output `.asm` that `rgbasm` can parse (sections + directive conversions).
Non-goals:
- Guarantee a working, byte-identical ROM output.
The original devkit toolchain could pre-initialize RAM; RGBDS cannot, so WRAM/HRAM
sections here are primarily for statically allocated labels.
Mr Do! Source Code analysis - https://www.retroreversing.com/mrdo
"""
from __future__ import annotations
import argparse
import ast
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional
_EQU_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s+EQU\s+(.+?)(\s*;.*)?$")
_ORG_RE = re.compile(r"^\s*ORG\s+(.+?)(\s*;.*)?$")
_ENT_RE = re.compile(r"^\s*ENT(\s*;.*)?$")
_HEX_RE = re.compile(r"^(?P<prefix>\s*)(?P<label>[A-Za-z_][A-Za-z0-9_]*\s+)?HEX\s+(?P<hex>[0-9A-Fa-f]+)(?P<rest>\s*;.*)?$")
_END_RE = re.compile(r"^\s*END(\s*;.*)?$")
# Standard Nintendo logo bytes used in the cartridge header ($0104-$0133).
# These bytes are also present in the released `mrdo.asm` header block.
_NINTENDO_LOGO = bytes.fromhex(
"CEED6666CC0D000B03730083000C000D0008111F8889000E"
"DCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E"
)
# Symbols that RGBASM treats specially (registers/directives/operators) and cannot be defined as-is.
_RENAME_EQU: dict[str, str] = {
"DIV": "rDIV",
"IF": "rIF",
}
# Single-letter flag symbols from the original source; rename only in data/directive contexts.
_SINGLE_LETTER_FLAGS: dict[str, str] = {
"U": "FLG_U",
"D": "FLG_D",
"L": "FLG_L",
"R": "FLG_R",
}
@dataclass(frozen=True)
class Section:
name: str
type: str
addr_expr: Optional[str]
bank_expr: Optional[str] = None
def render(self) -> str:
if self.addr_expr is None:
bits = [f'SECTION "{self.name}", {self.type}']
else:
bits = [f'SECTION "{self.name}", {self.type}[{self.addr_expr}]']
if self.bank_expr is not None:
bits.append(f"BANK[{self.bank_expr}]")
return ", ".join(bits)
def _strip_comment(line: str) -> tuple[str, str]:
"""
Split `line` into (code, comment), where `;` starts a comment only when it appears
outside of quoted strings / character literals.
"""
if ";" not in line:
return line, ""
in_double = False
in_single = False
for i, ch in enumerate(line):
if ch == '"' and not in_single:
in_double = not in_double
continue
if ch == "'" and not in_double:
in_single = not in_single
continue
if ch == ";" and not in_double and not in_single:
return line[:i], line[i:]
return line, ""
def _safe_int_eval(expr: str, symbols: Dict[str, int]) -> Optional[int]:
"""
Evaluate a restricted integer expression:
- literals: decimal, $hex, %binary
- identifiers from `symbols`
- ops: + - * / // << >> & | ^ ~ (unary)
"""
expr = expr.strip()
if not expr:
return None
# Convert "$C000" -> "0xC000" and "%1010" -> "0b1010"
expr = re.sub(r"\$([0-9A-Fa-f]+)", r"0x\1", expr)
expr = re.sub(r"%([01]+)", r"0b\1", expr)
try:
node = ast.parse(expr, mode="eval")
except SyntaxError:
return None
def eval_node(n: ast.AST) -> int:
if isinstance(n, ast.Expression):
return eval_node(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, int):
return int(n.value)
if isinstance(n, ast.Name):
if n.id not in symbols:
raise KeyError(n.id)
return int(symbols[n.id])
if isinstance(n, ast.UnaryOp) and isinstance(n.op, (ast.UAdd, ast.USub, ast.Invert)):
v = eval_node(n.operand)
if isinstance(n.op, ast.UAdd):
return +v
if isinstance(n.op, ast.USub):
return -v
return ~v
if isinstance(n, ast.BinOp) and isinstance(
n.op,
(
ast.Add,
ast.Sub,
ast.Mult,
ast.FloorDiv,
ast.LShift,
ast.RShift,
ast.BitAnd,
ast.BitOr,
ast.BitXor,
),
):
a = eval_node(n.left)
b = eval_node(n.right)
if isinstance(n.op, ast.Add):
return a + b
if isinstance(n.op, ast.Sub):
return a - b
if isinstance(n.op, ast.Mult):
return a * b
if isinstance(n.op, ast.FloorDiv):
return a // b
if isinstance(n.op, ast.LShift):
return a << b
if isinstance(n.op, ast.RShift):
return a >> b
if isinstance(n.op, ast.BitAnd):
return a & b
if isinstance(n.op, ast.BitOr):
return a | b
return a ^ b
raise ValueError(f"Unsupported expression: {expr!r}")
try:
return int(eval_node(node))
except Exception:
return None
def _infer_section(addr: Optional[int], addr_expr: str, seq: int, *, map_org_0800_to_romx: bool) -> Section:
# If we can't evaluate, default to ROM0; RGBDS will still accept it.
a = addr if addr is not None else -1
if 0xC000 <= a <= 0xCFFF:
return Section(name=f"MrDo_WRAM0_{seq:02d}", type="WRAM0", addr_expr=addr_expr)
if 0xD000 <= a <= 0xDFFF:
return Section(name=f"MrDo_WRAMX_{seq:02d}", type="WRAMX", addr_expr=addr_expr, bank_expr="1")
if 0xFF80 <= a <= 0xFFFE:
return Section(name=f"MrDo_HRAM_{seq:02d}", type="HRAM", addr_expr=addr_expr)
if 0xFE00 <= a <= 0xFE9F:
return Section(name=f"MrDo_OAM_{seq:02d}", type="OAM", addr_expr=addr_expr)
if 0x8000 <= a <= 0x9FFF:
return Section(name=f"MrDo_VRAM_{seq:02d}", type="VRAM", addr_expr=addr_expr, bank_expr="0")
# Heuristic: `mrdo.asm` uses `ORG $800` as a ROM anchor but then continues far beyond ROM0's $3FFF.
# To keep the conversion buildable without byte-accurate bank splitting, we treat ORG $800 as the
# start of fixed bank 1 code at $4000 (ROM-only bank 1).
if map_org_0800_to_romx and a == 0x0800:
return Section(name=f"MrDo_ROMX_{seq:02d}", type="ROMX", addr_expr="$4000", bank_expr="1")
# ROM / fallback
return Section(name=f"MrDo_ROM_{seq:02d}", type="ROM0", addr_expr=addr_expr)
def _hex_to_db(hex_str: str) -> str:
hs = re.sub(r"\s+", "", hex_str)
if len(hs) % 2 != 0:
# Keep as-is but still emit something deterministic.
hs = "0" + hs
bytes_ = [f"${hs[i:i+2].upper()}" for i in range(0, len(hs), 2)]
return "db " + ",".join(bytes_)
def _convert_line(line: str) -> str:
code, comment = _strip_comment(line.rstrip("\n"))
code = re.sub(r"\bDEFB\b", "db", code)
code = re.sub(r"\bDEFW\b", "dw", code)
code = re.sub(r"\bDEFS\b", "ds", code)
# The original source uses 1-character double-quoted tokens (for example `"0"`, `"A"`).
# RGBASM still accepts them but warns about treating strings as numbers; convert to character literals.
code = re.sub(r'"(.)"', r"'\1'", code)
# RGBDS uses [] for memory indirection (the source uses () for that purpose).
code = re.sub(r"\(([-+A-Za-z0-9_$%<>]+)\)", r"[\1]", code)
# `JP (HL)` is an indirect jump, not a memory access; RGBDS spells it `jp hl`.
code = re.sub(r"\bJP\s+\[HL\]\b", "JP HL", code)
code = re.sub(r"\bJP\s+\[HL\]", "JP HL", code)
# Convert "<LABEL" / ">LABEL" low/high-byte operators into RGBDS functions.
code = re.sub(r"(?<![A-Za-z0-9_])<([A-Za-z_][A-Za-z0-9_]*)", r"LOW(\1)", code)
code = re.sub(r"(?<![A-Za-z0-9_])>([A-Za-z_][A-Za-z0-9_]*)", r"HIGH(\1)", code)
return (code + comment).rstrip()
def _ram_db_size(args: str) -> Optional[int]:
"""
Best-effort size calculation for `db` initializers in RAM sections.
Supports numeric items separated by commas, and double-quoted strings (bytes = length).
"""
s = args.strip()
if not s:
return None
parts: list[str] = []
buf = ""
in_str = False
for ch in s:
if ch == '"':
in_str = not in_str
buf += ch
continue
if ch == "," and not in_str:
parts.append(buf.strip())
buf = ""
continue
buf += ch
if buf.strip():
parts.append(buf.strip())
total = 0
for p in parts:
if not p:
continue
if p.startswith('"') and p.endswith('"') and len(p) >= 2:
total += len(p) - 2
else:
total += 1
return total if total > 0 else None
def _ram_dw_size(args: str) -> Optional[int]:
s = args.strip()
if not s:
return None
count = 1 + s.count(",")
return 2 * count
def _emit_retail_like_header(out: list[str]) -> None:
"""
Emit a "retail-like" cartridge header block, without touching the original source file.
Notes:
- Retail images typically jump to $0150 from the entrypoint at $0100. We mirror that layout.
- A small ROM0 stub at $0150 switches to ROM bank 1 and jumps to `START`.
- Title padding uses NUL bytes so the CGB flag byte at $0143 stays 0x00 (retail-like).
- Checksums are left as zeroes; if you use `rgbfix`, it will populate them.
"""
out.append('SECTION "MrDo_CartridgeHeader", ROM0[$0100]')
out.append("\tNOP")
out.append("\tJP $0150")
out.append("\t; Nintendo logo ($0104-$0133)")
logo = _NINTENDO_LOGO
for i in range(0, len(logo), 16):
chunk = ",".join(f"${b:02X}" for b in logo[i : i + 16])
out.append(f"\tdb {chunk}")
out.append("\t; Title/CGB field ($0134-$0143), NUL-padded (CGB flag ends up as $00)")
out.append('\tdb "MR.DO!",0,0,0,0,0,0,0,0,0,0')
out.append("\t; New licensee ($0144-$0145)")
out.append("\tdb $00,$00")
out.append("\t; SGB flag ($0146)")
out.append("\tdb $00")
out.append("\t; Cartridge type ($0147): MBC1")
out.append("\tdb $01")
out.append("\t; ROM size ($0148): 64 KiB")
out.append("\tdb $01")
out.append("\t; RAM size ($0149): none")
out.append("\tdb $00")
out.append("\t; Destination code ($014A): non-Japanese (retail-like)")
out.append("\tdb $01")
out.append("\t; Old licensee code ($014B): 0x67 (retail-like)")
out.append("\tdb $67")
out.append("\t; Version ($014C)")
out.append("\tdb $00")
out.append("\t; Header checksum ($014D) + global checksum ($014E-$014F) placeholders")
out.append("\tdb $00")
out.append("\tdw $0000")
out.append("")
out.append('SECTION "MrDo_EntryStub", ROM0[$0150]')
out.append("\t; Switch to ROM bank 1, then jump into the main code.")
out.append("\tld a,$01")
out.append("\tld [$2000],a")
out.append("\tjp START")
out.append("")
def _find_retail_rom_bytes() -> Optional[bytes]:
"""
Best-effort locate the known-good retail ROM already checked into this repo.
"""
candidates = [
Path("build/mrdo_original.gb"),
Path("mrdo_original.gb"),
]
for p in candidates:
try:
if p.is_file():
return p.read_bytes()
except OSError:
continue
return None
def _emit_header_from_retail_rom(out: list[str], retail_rom: bytes) -> None:
"""
Emit header bytes ($0100-$014F) exactly as found in the retail ROM, and keep a small
$0150 stub that bank-switches to 1 then jumps to `START`.
"""
header = retail_rom[0x0100:0x0150]
if len(header) != 0x50:
raise ValueError("Retail ROM too small to contain a full header block")
out.append('SECTION "MrDo_CartridgeHeader", ROM0[$0100]')
out.append("\t; Header bytes copied from build/mrdo_original.gb ($0100-$014F).")
for i in range(0, len(header), 16):
chunk = ",".join(f"${b:02X}" for b in header[i : i + 16])
out.append(f"\tdb {chunk}")
out.append("")
out.append('SECTION "MrDo_EntryStub", ROM0[$0150]')
out.append("\t; Retail ROM jumps here; we bank-switch to 1 and then jump into the converted code.")
out.append("\tld a,$01")
out.append("\tld [$2000],a")
out.append("\tjp START")
out.append("")
def _extract_source_block(lines: list[str], *, start_label: str, end_label: str) -> list[str]:
"""
Extract a source block starting at a label line and ending immediately before `end_label`.
Returns raw source lines (without trailing newlines).
"""
start_re = re.compile(rf"^\s*{re.escape(start_label)}\b")
end_re = re.compile(rf"^\s*{re.escape(end_label)}\b")
start_idx: Optional[int] = None
end_idx: Optional[int] = None
for i, raw in enumerate(lines):
if start_idx is None and start_re.match(raw):
start_idx = i
continue
if start_idx is not None and end_re.match(raw):
end_idx = i
break
if start_idx is None or end_idx is None or end_idx <= start_idx:
return []
return [l.rstrip("\n") for l in lines[start_idx:end_idx]]
def _emit_relocated_logo_block(out: list[str], src_lines: list[str]) -> bool:
"""
Emit the `LOGO` data block at the retail-like ROM0 location ($0E3B).
This keeps the original source unchanged by synthesizing a relocated copy.
"""
block = _extract_source_block(src_lines, start_label="LOGO", end_label="CLOGO")
if not block:
return False
out.append('SECTION "Retail_LOGO", ROM0[$0E3B]')
out.append("; Relocated from the source LOGO block (between LOGO and CLOGO).")
for raw in block:
m = _HEX_RE.match(raw)
if m:
prefix = m.group("prefix") or ""
label = (m.group("label") or "").strip()
hs = m.group("hex")
rest = m.group("rest") or ""
if label:
# Ensure RGBDS label syntax.
if not label.endswith(":"):
label = label + ":"
out.append(f"{prefix}{label}\t{_hex_to_db(hs)}{rest}".rstrip())
else:
out.append(f"{prefix}{_hex_to_db(hs)}{rest}".rstrip())
continue
converted = _convert_line(raw)
# If this is the label line (LOGO DEFB ...), ensure it gets a colon.
if converted and not converted[0].isspace():
ccode, ccomment = _strip_comment(converted)
ccode_stripped = ccode.rstrip()
first = ccode_stripped.split()[0] if ccode_stripped.split() else ""
if first == "LOGO" and not first.endswith(":"):
rest = ccode_stripped[len(first) :]
converted = f"LOGO:{rest}{ccomment}".rstrip()
out.append(converted.rstrip())
out.append("")
return True
def convert(lines: Iterable[str], profile: str) -> list[str]:
out: list[str] = []
symbols: Dict[str, int] = {}
section_seq = 0
in_section = False
current_section_type: Optional[str] = None
at_section_start = True
equ_rename: dict[str, str] = dict(_RENAME_EQU)
inserted_rom0_data_section = False
placed_retail_labels: set[str] = set()
retail_skipped: list[str] = []
skipping_source_header = False
skipping_source_logo = False
map_org_0800_to_romx = profile in ("explore", "retail-mbc1")
# Retail-oriented placement map (derived from signature hits vs retail ROM).
# Keys are label names in the source; values are (bank, cpu_addr).
# Bank 0 uses ROM0 ($0000-$3FFF); banks 1+ use ROMX ($4000-$7FFF).
retail_place: Dict[str, tuple[int, int]] = {
# ROM0 placements (standalone blocks)
"LOGO": (0, 0x0E3B),
"CHRTABLE": (0, 0x0400),
"CTAB": (0, 0x09A8),
"CLOGO": (2, 0x4080),
"CSTAR": (2, 0x47F0),
"CICONS": (2, 0x4B20),
"CHEADS": (2, 0x4D60),
"CFINI": (2, 0x5840),
# Prefer bank3 placements to avoid colliding with bank1 code in the explore build.
"CMRDO": (3, 0x4000),
"CMRSDO": (3, 0x4300),
"CBADS": (3, 0x4C00),
}
# Some ROM0 labels occur mid-block under a large ORG region. Allow injecting a new ROM0
# placement section for these labels even when we're not at the start of an ORG section.
retail_place_allow_midblock_rom0 = {"CTAB"}
out.append("; Auto-generated by scripts/convert-mrdo-to-rgbds.py")
out.append("; Source: mrdo.asm as released by Paul Hughes")
out.append("; NOTE: This is a syntax/section conversion for RGBDS, not a guaranteed working build.")
out.append("")
out.append("; Minimal interrupt vector stubs.")
out.append("; The original source enables interrupts, but the release does not define handlers at $0040+.")
out.append('SECTION "MrDo_VBlank_Vector", ROM0[$0040]')
out.append("\treti")
out.append('SECTION "MrDo_STAT_Vector", ROM0[$0048]')
out.append("\treti")
out.append('SECTION "MrDo_Timer_Vector", ROM0[$0050]')
out.append("\treti")
out.append('SECTION "MrDo_Serial_Vector", ROM0[$0058]')
out.append("\treti")
out.append('SECTION "MrDo_Joypad_Vector", ROM0[$0060]')
out.append("\treti")
out.append("")
src_list = list(lines)
if profile == "retail-mbc1":
_emit_relocated_logo_block(out, src_list)
for raw in src_list:
line = raw.rstrip("\n")
# Strip stray control characters that occasionally leak into old source archives.
line = re.sub(r"[\x00-\x08\x0B-\x1F\x7F]", "", line)
# Many releases use asterisks as a visual divider; RGBASM treats '*' as an operator.
if line.startswith("*") or line.startswith("****************************************************************************"):
out.append(";" + line)
continue
m = _EQU_RE.match(line)
if m:
name, expr, comment = m.group(1), m.group(2), m.group(3) or ""
rgb_name = _SINGLE_LETTER_FLAGS.get(name, equ_rename.get(name, name))
# Emit modern RGBDS constant form; also try to track numeric values for ORG inference.
out.append(f"DEF {rgb_name} EQU {expr}{comment}".rstrip())
v = _safe_int_eval(expr, symbols)
if v is not None:
symbols[rgb_name] = v
continue
m = _ORG_RE.match(line)
if m:
addr_expr, comment = m.group(1).strip(), m.group(2) or ""
addr_val = _safe_int_eval(addr_expr, symbols)
if skipping_source_header:
skipping_source_header = False
# In retail mode, replace the source's ORG $100 header block with a retail-derived header.
if profile == "retail-mbc1" and addr_val == 0x0100:
out.append("")
out.append(f"; ORG {addr_expr}{comment}".rstrip())
retail_rom = _find_retail_rom_bytes()
if retail_rom is not None:
_emit_header_from_retail_rom(out, retail_rom)
else:
_emit_retail_like_header(out)
in_section = True
current_section_type = "ROM0"
at_section_start = False
skipping_source_header = True
continue
section_seq += 1
# Retail mode: allow selected ORG blocks to float to avoid hard overlaps when we
# intentionally relocate some ROM0 tables to match retail layout.
if profile == "retail-mbc1" and addr_val == 0x0600:
section = Section(name=f"MrDo_ROM_{section_seq:02d}", type="ROM0", addr_expr=None)
else:
section = _infer_section(addr_val, addr_expr, section_seq, map_org_0800_to_romx=map_org_0800_to_romx)
out.append("")
out.append(f"; ORG {addr_expr}{comment}".rstrip())
out.append(section.render())
in_section = True
current_section_type = section.type
at_section_start = True
continue
m = _ENT_RE.match(line)
if m:
comment = m.group(1) or ""
out.append(f"; ENT{comment}".rstrip())
continue
m = _END_RE.match(line)
if m:
comment = m.group(1) or ""
out.append(f"; END{comment}".rstrip())
continue
# If we replaced the cartridge header, skip everything until the next ORG.
if skipping_source_header:
# Preserve blank/comment lines (helps keep the output readable).
if line.strip() == "" or line.lstrip().startswith(";"):
out.append(line.rstrip())
continue
# If we relocated LOGO, skip the original LOGO block until CLOGO.
if skipping_source_logo:
if re.match(r"^\s*CLOGO\b", line):
skipping_source_logo = False
else:
continue
if profile == "retail-mbc1" and re.match(r"^\s*LOGO\b", line):
skipping_source_logo = True
continue
m = _HEX_RE.match(line)
if m:
prefix = m.group("prefix") or ""
label = m.group("label") or ""
hs = m.group("hex")
rest = m.group("rest") or ""
if not in_section:
# RGBDS requires a section before data; treat as ROM0 floating if no ORG was seen yet.
section_seq += 1
out.append("")
out.append('; (converter inserted section because data appeared before first ORG)')
out.append(Section(name=f"MrDo_ROM_{section_seq:02d}", type="ROM0", addr_expr="$0000").render())
in_section = True
current_section_type = "ROM0"
if label:
label_name = label.strip()
# Retail profile: force certain known data blobs to retail-like bank+address locations.
# These large blobs are usually expressed as `LABEL HEX ...`, so handle them here too.
if profile == "retail-mbc1" and label_name in retail_place and label_name not in placed_retail_labels:
bank, addr = retail_place[label_name]
can_place = False
if bank == 0:
can_place = at_section_start or (
label_name in retail_place_allow_midblock_rom0 and current_section_type == "ROM0"
)
else:
can_place = current_section_type == "ROMX"
if can_place:
out.append("")
if bank == 0:
out.append(f'SECTION "Retail_{label_name}", ROM0[${addr:04X}]')
current_section_type = "ROM0"
else:
out.append(f'SECTION "Retail_{label_name}", ROMX[${addr:04X}], BANK[{bank}]')
current_section_type = "ROMX"
placed_retail_labels.add(label_name)
else:
retail_skipped.append(label_name)
# Heuristic bank split: treat scene + large content blocks as ROM0 data if we previously
# moved the main code to bank 1 (via ORG $800 -> ROMX[$4000]).
if profile == "explore" and (not inserted_rom0_data_section) and label_name == "SCENE1":
out.append("")
out.append('; (converter inserted ROM0 data section for SCENE/gfx blocks)')
out.append('SECTION "MrDo_ROM0_Data", ROM0[$0800]')
current_section_type = "ROM0"
inserted_rom0_data_section = True
out.append(f"{prefix}{label_name}:\t{_hex_to_db(hs)}{rest}".rstrip())
else:
out.append(f"{prefix}{_hex_to_db(hs)}{rest}".rstrip())
at_section_start = False
continue
if not in_section:
# Pass through comments/whitespace before the first ORG/SECTION.
if line.strip() == "" or line.lstrip().startswith(";") or line.startswith("*") or line.startswith("****************************************************************************"):
out.append(line.rstrip())
continue
# If we got here, we found code/data before any ORG. Insert a default ROM0 section.
section_seq += 1
out.append("")
out.append('; (converter inserted section because code appeared before first ORG)')
out.append(Section(name=f"MrDo_ROM_{section_seq:02d}", type="ROM0", addr_expr="$0000").render())
in_section = True
current_section_type = "ROM0"
# Split code/comment so we can do safe token rewrites on the code portion only.
code, comment = _strip_comment(line)
# Rewrite renamed EQU symbols (e.g. IF -> rIF) in expressions and memory operands.
for old, new in equ_rename.items():
if old == new:
continue
code = re.sub(rf"\b{re.escape(old)}\b", new, code)
converted = _convert_line(code + comment)
# Convert label syntax:
# - In the original assembler, labels do not require a trailing ':'.
# - In RGBASM, a bare identifier at column 0 is parsed as a macro/opcode unless it ends with ':'.
if converted and not converted[0].isspace():
ccode, ccomment = _strip_comment(converted)
ccode_stripped = ccode.rstrip()
if ccode_stripped and ":" not in ccode_stripped.split()[0]:
first = ccode_stripped.split()[0]
# If line is just a label, or label + directive/opcode, add ':' after the first token.
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", first):
rest = ccode_stripped[len(first) :]
converted = f"{first}:{rest}{ccomment}".rstrip()
# Retail profile: force certain known data blobs to retail-like bank+address locations.
# We only do this once per label to avoid spamming SECTION directives.
if profile == "retail-mbc1":
mlabel = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):", converted)
if mlabel:
label_name = mlabel.group(1)
if label_name in retail_place and label_name not in placed_retail_labels:
# Only place labels that appear at the start of an ORG/SECTION block.
# If we inject a SECTION mid-block, RGBLINK may fail or we may silently
# shift later ORG-anchored code/data into overlaps.
bank, addr = retail_place[label_name]
can_place = False
if bank == 0:
can_place = at_section_start or (
label_name in retail_place_allow_midblock_rom0 and current_section_type == "ROM0"
)
else:
# For ROMX, we allow mid-block placement so we can split oversized banked
# sections into multiple 16 KiB banks. This is only enabled while we're
# already assembling into ROMX; injecting ROMX mid-ROM0 would be a footgun.
can_place = current_section_type == "ROMX"
if can_place:
out.append("")
if bank == 0:
out.append(f'SECTION "Retail_{label_name}", ROM0[${addr:04X}]')
current_section_type = "ROM0"
else:
out.append(f'SECTION "Retail_{label_name}", ROMX[${addr:04X}], BANK[{bank}]')
current_section_type = "ROMX"
placed_retail_labels.add(label_name)
else:
retail_skipped.append(label_name)
# RGBDS RAM sections cannot contain initialized data.
# Convert `db`/`dw` in WRAM/HRAM/OAM/VRAM sections into `ds` reservations, preserving labels.
if current_section_type is not None and current_section_type not in ("ROM0", "ROMX"):
ccode, ccomment = _strip_comment(converted)
stripped = ccode.strip()
# Strip DS fill arguments in RAM sections: `ds N,0` -> `ds N`
stripped = re.sub(r"\bds\s+([^,;]+)\s*,\s*[^;]+", r"ds \1", stripped)
mdir = re.match(r"^(?P<label>[A-Za-z_][A-Za-z0-9_]*:)?\s*(?P<dir>db|dw|ds)\b(?P<args>.*)$", stripped)
if mdir:
label = (mdir.group("label") + " ") if mdir.group("label") else ""
d = mdir.group("dir")
args = mdir.group("args").strip()
if d == "db":
n = _ram_db_size(args)
size = n if n is not None else 1
converted = f"{label}ds {size}{(' ' + ccomment.strip()) if ccomment else ''}".rstrip()
elif d == "dw":
n = _ram_dw_size(args)
size = n if n is not None else 2
converted = f"{label}ds {size}{(' ' + ccomment.strip()) if ccomment else ''}".rstrip()
else:
# ds in RAM is allowed; keep (but ensure fill args are stripped).
converted = f"{stripped}{ccomment}".rstrip()
# Single-letter flags: only rewrite in data contexts (`db`/`dw`) so we don't break register operands.
ccode, ccomment = _strip_comment(converted)
if re.search(r"\b(db|dw)\b", ccode):
for old, new in _SINGLE_LETTER_FLAGS.items():
# Do not rewrite inside quoted strings or character literals (e.g. "D" or 'D').
ccode = re.sub(
rf"(?<![A-Za-z0-9_\"']){re.escape(old)}(?![A-Za-z0-9_\"'])",
new,
ccode,
)
converted = (ccode + ccomment).rstrip()
out.append(converted.rstrip())
if at_section_start:
ccode, _ = _strip_comment(converted)
stripped = ccode.strip()
if stripped and not re.match(r"^[A-Za-z_][A-Za-z0-9_]*:$", stripped):
at_section_start = False
if retail_skipped:
out.append("")
for name in sorted(set(retail_skipped)):
out.append(f"; retail-mbc1 note: skipped placement for {name} (not at section start)")
out.append("")
return [l + "\n" for l in out]
def main() -> int:
ap = argparse.ArgumentParser(description="Convert mrdo.asm to RGBDS-compatible assembly.")
ap.add_argument("input", type=Path, help="Path to original mrdo.asm")
ap.add_argument("output", type=Path, help="Path to write converted .asm")
ap.add_argument(
"--profile",
choices=["explore", "retail-mbc1"],
default="explore",
help="Conversion profile: 'explore' (default) links a minimal ROM for debugging; 'retail-mbc1' forces select asset blocks into retail-like banks/addresses.",
)
args = ap.parse_args()
src = args.input.read_text(encoding="utf-8", errors="replace").splitlines(True)
converted = convert(src, args.profile)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text("".join(converted), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment