#!/usr/bin/env python3 # findStrings.py - locate the game's bit-7-terminated text strings inside regions that the # disassembly still renders as raw bytes, and emit annotations/06_strings.json (labels + text # data types + a comment holding the text). Run after tools/build.py. import os, re, json, glob ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OUT = os.path.join(ROOT, "disassembly") ANN = os.path.join(ROOT, "annotations") MIN_LEN = 4 # shortest run of characters worth calling a string def dataAddresses(unit, dataMap): """addresses the disassembler classified as data (from build/dataMap.json)""" addrs = set() for lo, hi in dataMap.get(unit, []): addrs.update(range(int(lo, 16), int(hi, 16) + 1)) return addrs # The game's text is uppercase PETSCII/ASCII; restricting the alphabet keeps graphics data # (which is full of bytes that happen to fall in the printable range) from matching. ALLOWED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,!?'\"-:;()/%$*+=&#") SKIP_UNITS = ("boot/title_bitmap_A000", "boot/title_screen_9C00", "boot/title_colorram_9800") def isText(b): return chr(b) in ALLOWED or b == 0x0D # $0D separates the lines of a multi-line message def looksLikeMessage(text): text = text.replace("\r", " ") letters = sum(1 for c in text if c.isalpha()) if len(text) < 5 or letters < 3: return False if (letters + text.count(" ")) / len(text) < 0.7: return False for i in range(len(text) - 3): # runs like "OOOO" are graphics, not text if text[i].isalpha() and text[i] * 4 == text[i:i+4]: return False if len(text) < 8 and not all(c.isalpha() or c == " " for c in text): return False return True def nameFor(text, used): words = re.findall(r"[A-Za-z0-9]+", text.title()) base = "msg" + "".join(words)[:28] if len(base) < 6: base = "msgText" name = base n = 2 while name in used: name = f"{base}{n}" n += 1 used.add(name) return name def main(): index = {} for line in open(os.path.join(OUT, "INDEX.txt")): m = re.match(r"^(\S+)\s+\$([0-9A-F]{4})-\$([0-9A-F]{4})", line) if m: index[m.group(1)] = (int(m.group(2), 16), int(m.group(3), 16)) dataMap = json.load(open(os.path.join(OUT, "build", "dataMap.json"))) unitLabels, dataTypes, notes = {}, {}, {} used = set() total = 0 for unit, (start, end) in sorted(index.items()): srcPath = os.path.join(OUT, unit + ".s") binPath = os.path.join(OUT, "build", unit.replace("/", "_") + ".orig.bin") if not (os.path.exists(srcPath) and os.path.exists(binPath)) or unit in SKIP_UNITS: continue data = open(binPath, "rb").read() dataSet = dataAddresses(unit, dataMap) a = start while a <= end: if a not in dataSet or not isText(data[a - start]): a += 1 continue b = a text = "" terminated = False while b <= end and b in dataSet: v = data[b - start] if isText(v): text += chr(v) b += 1 elif isText(v & 0x7F) and v & 0x80: text += chr(v & 0x7F) b += 1 terminated = True break else: break if terminated and looksLikeMessage(text): lines = [l for l in text.split("\r") if l != ""] or [text] name = nameFor(lines[0], used) unitLabels.setdefault(unit, {})[f"{a:04X}"] = name dataTypes.setdefault(unit, {})[f"{a:04X}"] = ["text", b - a] if len(lines) == 1: block = [f'{name} - message text "{text}" (the last character carries bit 7 as the terminator).'] else: block = [f"{name} - message text, {len(lines)} lines separated by $0D; the last character carries bit 7:"] block += [f' "{l}"' for l in lines] notes[f"{a:04X}"] = {"unit": unit, "block": block} total += 1 a = b else: a = max(b, a + 1) json.dump({"_comment": "generated by tools/findStrings.py - bit-7-terminated strings found in data areas", "unitLabels": unitLabels, "unitDataTypes": dataTypes, "notes": notes}, open(os.path.join(ANN, "06_strings.json"), "w"), indent=1) print(f"{total} strings in {len(unitLabels)} units") for unit, m in sorted(unitLabels.items()): print(f" {unit}: {len(m)}") main()