#!/bin/sh # Grant 64 patcher for macOS and Linux. Run it in the folder with your ROM: # curl -fsSL https://grant64.borthwick.dev/apply.sh | sh # curl -fsSL https://grant64.borthwick.dev/apply.sh | sh -s -- --rom ~/roms/sm64.z64 --out ~/roms/grant64.z64 # It finds your own Super Mario 64 (USA) ROM (any byte order: here, then in # Downloads, Desktop and Documents, else it asks), downloads the # Grant 64 patch from this site, checks every hash and writes grant64.0.75.z64. # It never uploads anything and never changes your ROM. Needs sh, perl, and curl or wget. set -eu VERSION='0.75' PATCH_URL='https://grant64.borthwick.dev/grant64.0.75.bps' PATCH_SHA1='4e11a8cc468b7e8e968df48dd6c215f1b94d0431' BASE_SHA1='9bef1128717f958171a4afac3ed78ee2bb4e86ce' OUT_SHA1='22ed670d5bf49572e74180cb192cc61ba9741578' log() { echo "grant64: $*"; } die() { echo "grant64: ERROR: $*" >&2; exit 1; } rom='' out='' while [ $# -gt 0 ]; do case $1 in --rom) [ $# -ge 2 ] || die "--rom needs a file"; rom=$2; shift 2 ;; --out) [ $# -ge 2 ] || die "--out needs a file"; out=$2; shift 2 ;; -h|--help) sed -n '2,7p' "$0" 2>/dev/null || true; exit 0 ;; *) die "unknown option $1 (use --rom FILE and --out FILE)" ;; esac done command -v perl >/dev/null 2>&1 || die "perl is missing: install it (it ships with macOS and almost every Linux)" sha1() { if command -v sha1sum >/dev/null 2>&1; then sha1sum "$1" | cut -c1-40 elif command -v shasum >/dev/null 2>&1; then shasum -a 1 "$1" | cut -c1-40 else perl -MDigest::SHA -e 'print Digest::SHA->new(1)->addfile($ARGV[0])->hexdigest' "$1" fi } fetch() { if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1" elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1" else die "curl or wget is needed to download the patch" fi } tmp=$(mktemp -d 2>/dev/null || mktemp -d -t grant64) trap 'rm -rf "$tmp"' EXIT INT TERM log "Grant 64 $VERSION patcher (macOS/Linux). It reads your ROM, downloads the patch from" log "grant64.borthwick.dev and writes one new file. Your ROM is never changed or uploaded." # The first 64 bytes, in any byte order: prints "z64", "v64" or "n64" when the # header says Super Mario 64 (USA) (title SUPER MARIO 64, game code NSME), # else "no: ". Reads only the header, so other files stay unread. header() { perl -e ' open(my $f, "<:raw", $ARGV[0]) or do { print "no: cannot read it\n"; exit }; read($f, my $h, 64) == 64 or do { print "no: too small\n"; exit }; my $m = unpack("H8", substr($h, 0, 4)); my $order = "z64"; if ($m eq "37804012") { $h = pack("v*", unpack("n*", $h)); $order = "v64"; } elsif ($m eq "40123780") { $h = pack("V*", unpack("N*", $h)); $order = "n64"; } elsif ($m ne "80371240") { print "no: not an N64 ROM\n"; exit } my ($title, $code) = (substr($h, 0x20, 20), substr($h, 0x3B, 4)); $title =~ s/[^\x20-\x7e]//g; $title =~ s/\s+$//; $code =~ s/[^\x20-\x7e]/?/g; if ($title eq "SUPER MARIO 64" && $code eq "NSME") { print "$order\n" } else { print "no: header says \"$title\" ($code)\n" } ' "$1" } # Copy a ROM to .z64 (big-endian) byte order: .v64 swaps byte pairs, .n64 reverses each word. to_z64() { perl -e ' local $/; open(my $f, "<:raw", $ARGV[0]) or die "cannot read $ARGV[0]\n"; my $d = <$f>; my $m = unpack("H8", substr($d, 0, 4)); if ($m eq "37804012") { $d = pack("v*", unpack("n*", $d)); } elsif ($m eq "40123780") { $d = pack("V*", unpack("N*", $d)); } open(my $o, ">:raw", $ARGV[1]) or die "cannot write $ARGV[1]\n"; print $o $d; ' "$1" "$2" } # Check one file, logging why it is or isn't the base ROM; leaves it in $tmp/base.z64. try_rom() { case $(basename "$1") in grant64*) log " skip $1 (a Grant 64 output)"; return 1 ;; esac h=$(header "$1") case $h in no:*) log " skip $1 (${h#no: })"; return 1 ;; esac log " check $1: header says Super Mario 64 (USA), $h byte order; hashing all of it" to_z64 "$1" "$tmp/base.z64" || { log " skip $1 (cannot read it)"; return 1; } got=$(sha1 "$tmp/base.z64") if [ "$got" = "$BASE_SHA1" ]; then log " match: SHA-1 $got is the stock US ROM" return 0 fi log " skip $1 (SHA-1 $got is not the stock US ROM: a different revision, a hack or a bad dump)" return 1 } if [ -n "$rom" ]; then log "checking the ROM you gave" [ -f "$rom" ] || die "no such file: $rom" try_rom "$rom" || die "$rom is not the stock Super Mario 64 (USA) ROM (expected SHA-1 $BASE_SHA1 after byte-order conversion). Use a dump of the US cartridge." else # Not given: look here, then in the usual places, then ask (only with a terminal). found='' for d in "$(pwd)" "$HOME/Downloads" "$HOME/Desktop" "$HOME/Documents" "$HOME"; do [ -d "$d" ] || { log "no folder $d, skipping"; continue; } if [ "$d" = "$(pwd)" ]; then depth=1; else depth=3; fi log "looking for your ROM in $d (8 MB .z64/.v64/.n64 files, $depth folder level(s))" find "$d" -maxdepth $depth -type f -size 8388608c \( -iname '*.z64' -o -iname '*.n64' -o -iname '*.v64' \) 2>/dev/null > "$tmp/list" n=$(wc -l < "$tmp/list" | tr -d ' ') [ "$n" -gt 0 ] || { log " nothing there"; continue; } while IFS= read -r f; do grep -qxF "$f" "$tmp/seen" 2>/dev/null && continue # home overlaps Downloads etc. echo "$f" >> "$tmp/seen" if try_rom "$f"; then found=$f; break; fi done < "$tmp/list" [ -z "$found" ] || break done if [ -n "$found" ]; then rom=$found elif (: /dev/null && [ -t 1 ]; then # curl | sh: stdin is the script itself, so ask on the terminal. log "couldn't find it, so please pick it" if [ "$(uname)" = Darwin ] && command -v osascript >/dev/null 2>&1; then log "opening a Choose File window" rom=$(osascript -e 'POSIX path of (choose file with prompt "Pick your Super Mario 64 (USA) ROM (.z64, .v64 or .n64)")' 2>/dev/null) || rom='' elif [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v zenity >/dev/null 2>&1; then log "opening a file picker" rom=$(zenity --file-selection --title="Pick your Super Mario 64 (USA) ROM" 2>/dev/null) || rom='' fi if [ -z "$rom" ]; then printf 'grant64: drag the ROM file here (or type its path) and press Enter: ' >/dev/tty IFS= read -r rom ; $d } my ($src, $p) = (slurp($ARGV[0]), slurp($ARGV[1])); die "not a BPS patch\n" unless substr($p, 0, 4) eq "BPS1"; my $pos = 4; sub num { my ($d, $s) = (0, 1); while (1) { my $x = ord(substr($p, $pos++, 1)); $d += ($x & 0x7f) * $s; return $d if $x & 0x80; $s <<= 7; $d += $s; } } my ($ssize, $tsize) = (num(), num()); $pos += num(); die "wrong base ROM size\n" unless $ssize == length($src); my $t = "\0" x $tsize; my ($o, $sr, $tr, $end) = (0, 0, 0, length($p) - 12); while ($pos < $end) { my $a = num(); my ($c, $n) = ($a & 3, ($a >> 2) + 1); if ($c == 0) { substr($t, $o, $n) = substr($src, $o, $n); } elsif ($c == 1) { substr($t, $o, $n) = substr($p, $pos, $n); $pos += $n; } else { my $d = num(); $d = ($d & 1) ? -($d >> 1) : ($d >> 1); if ($c == 2) { $sr += $d; substr($t, $o, $n) = substr($src, $sr, $n); $sr += $n; } else { $tr += $d; substr($t, $o + $_, 1) = substr($t, $tr + $_, 1) for 0 .. $n - 1; $tr += $n; } } $o += $n; } die "patch ended early\n" unless $o == $tsize; open(my $f, ">:raw", $ARGV[2]) or die "cannot write $ARGV[2]\n"; print $f $t; ' "$tmp/base.z64" "$tmp/patch.bps" "$tmp/out.z64" || die "patching failed" got=$(sha1 "$tmp/out.z64") [ "$got" = "$OUT_SHA1" ] || die "the patched ROM came out wrong (SHA-1 $got, expected $OUT_SHA1). Nothing was written." log " result SHA-1 $got matches Grant 64 $VERSION" cp "$tmp/out.z64" "$out" || die "cannot write $out" log "OK wrote $out" log "SHA-1 $OUT_SHA1 (Grant 64 $VERSION). Play it in an N64 emulator or on a flash cart."