Last active
April 19, 2026 13:40
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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