#!/usr/bin/env python3
# checkAbi.py - compare a rebuilt opponent module against the stock one and prove that nothing the
# rest of the game depends on has moved.
#
#   python3 checkAbi.py build/swiftlinkDriverE000.bin ../disassembly/build/game_modemDriverE000.orig.bin
#
# The game reaches into the module at fixed addresses, and the map generator overlay calls a whole
# block of shared utilities in its top quarter.  Those regions must stay byte-identical; the UART
# layer in the middle is what we are allowed to rewrite.
import sys

BASE = 0xE000

# Regions the rest of the game depends on, and why.
FROZEN = [
    (0xE000, 0xE017, "jump table: $E000/$E003/$E006/$E009/$E00C/$E012/$E015"),
    (0xE01D, 0xE047, "shared variables: packet buffers, host counts, build id, NMI chain, link state"),
    (0xEC00, 0xEFFF, "shared utilities the map generator overlay calls ($EC00, $EC0F, $EC1C, $EC2B, "
                     "$EC54, $EC58, $EC91, $ECF3, $ED43, $EE26, $EE4A, $EEB9, $EEDD)"),
]

# Entry points other code calls, which must still hold a JMP or the routine itself.
ENTRY_POINTS = {
    0xE000: "commRequest", 0xE003: "commLinkControl", 0xE006: "getLinkByte", 0xE009: "putLinkByte",
    0xE00C: "pollLinkStatus", 0xE012: "keyboardScanHook", 0xE015: "commKeyEntry",
    0xE079: "runTrainerRoundHook", 0xE0E8: "trainerUnitStepHook",
    0xEC00: "clearBattlefieldMap", 0xEC0F: "initTrainerAi", 0xEC1C: "setViewOriginToHome",
    0xEC2B: "loadMapSeedIntoScenarioRng", 0xEC54: "makeMapPointSymmetric",
    0xEC58: "rotateMapAndMirrorCodes", 0xEC91: "mirrorAllUnitCoordinates",
    0xECF3: "loadUnitStartTemplate", 0xED43: "exchangePlayerNamesAndSettings",
    0xEE26: "measureRangeToTarget", 0xEE4A: "loadFilmStartSnapshot",
    0xEEB9: "handleDisplayToggleKey", 0xEEDD: "toggleBattleDisplayMode",
}


def changedRanges(new, old):
    out = []
    start = None
    for i in range(len(old)):
        if new[i] != old[i]:
            if start is None:
                start = i
        elif start is not None:
            out.append((start, i - 1))
            start = None
    if start is not None:
        out.append((start, len(old) - 1))
    return out


def main():
    new = open(sys.argv[1], "rb").read()
    old = open(sys.argv[2], "rb").read()
    if len(new) != 4096 or len(old) != 4096:
        raise SystemExit("both modules must be 4096 bytes")

    failures = []
    for lo, hi, why in FROZEN:
        a, b = lo - BASE, hi - BASE + 1
        if new[a:b] != old[a:b]:
            diffs = [f"${BASE+a+i:04X}" for i in range(b - a) if new[a+i] != old[a+i]]
            failures.append(f"frozen region ${lo:04X}-${hi:04X} changed ({why}); first: {' '.join(diffs[:8])}")

    for addr, name in sorted(ENTRY_POINTS.items()):
        if new[addr - BASE] == 0x00:
            failures.append(f"entry point ${addr:04X} ({name}) starts with $00 - almost certainly missing")

    changed = changedRanges(new, old)
    total = sum(hi - lo + 1 for lo, hi in changed)
    print(f"bytes changed: {total} of 4096, in {len(changed)} region(s)")
    for lo, hi in changed:
        print(f"  ${BASE+lo:04X}-${BASE+hi:04X}  ({hi-lo+1} bytes)")
    if failures:
        print("\nABI CHECK FAILED:")
        for f in failures:
            print("  -", f)
        return 1
    print("\nABI CHECK PASSED: every address the rest of the game depends on is unchanged")
    return 0


sys.exit(main())
