scripts: add a GameCube disc-image validator for BBA dumps
./scripts/check-gcm.py dvd.iso Checks the header (DVD magic, game/maker code, title, DOL/FST offsets, apploader), parses the FST, then READS EVERY FILE -- because a dump that lost data still has a perfect header, so structure alone proves nothing. Also reports how much of the image the filesystem accounts for, which is where a truncated or zero-filled tail shows up, and prints SHA-1 for comparison against a reference or a second dump. ⚠️ The zero-run heuristic was wrong in its first form and the fix is the interesting part. Flagging any zero run >= 32 KiB as a dropped transfer gave 7 false positives on a known-good Wind Waker dump, all in Audiores/Stream/*.afc: streamed audio legitimately begins with silence right after its 16-byte header. What a dropped transfer actually looks like is a run ALIGNED to the transfer granularity -- whole 2048-byte disc sectors, because nothing reads or writes a fraction of a sector. Structural silence starts and ends wherever the audio does. So alignment is the discriminator, not length. Sector-aligned runs are now a failure; unaligned ones are reported as structural with that reasoning spelled out. .gitignore: *.iso and *.gcm, so a 1.4 GB dump sitting in the workspace cannot be committed by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a GameCube disc image (GCM/ISO) — structure, then contents.
|
||||
|
||||
./scripts/check-gcm.py dvd.iso
|
||||
|
||||
Written for checking dumps made over the BBA: a dump that lost data will
|
||||
usually still have a perfect header, so the header alone proves nothing. This
|
||||
parses the filesystem and then reads every file's bytes, which is what
|
||||
actually catches dropped blocks.
|
||||
|
||||
Exit status is non-zero if anything failed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
GC_MAGIC = 0xC2339F3D
|
||||
GC_DISC_LEN = 1459978240 # a full single-layer GameCube disc
|
||||
APPLOADER = 0x2440
|
||||
|
||||
|
||||
def s(b):
|
||||
return b.split(b"\0")[0].decode("latin-1", "replace")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("image")
|
||||
ap.add_argument("--max-list", type=int, default=12,
|
||||
help="files to list (0 for all)")
|
||||
ap.add_argument("--no-content", action="store_true",
|
||||
help="skip the per-file content read (structure only)")
|
||||
a = ap.parse_args()
|
||||
|
||||
size = os.path.getsize(a.image)
|
||||
bad, warn = [], []
|
||||
f = open(a.image, "rb")
|
||||
|
||||
hdr = f.read(0x2440)
|
||||
magic = struct.unpack(">I", hdr[0x1C:0x20])[0]
|
||||
game, maker = s(hdr[0:6]), s(hdr[4:6])
|
||||
disc_id, version, audio, _ = hdr[6], hdr[7], hdr[8], hdr[9]
|
||||
name = s(hdr[0x20:0x400])
|
||||
dol_off, fst_off, fst_size, fst_max = struct.unpack(">IIII", hdr[0x420:0x430])
|
||||
|
||||
print(f"{a.image}: {size} bytes ({size/2**30:.3f} GiB)")
|
||||
if size == GC_DISC_LEN:
|
||||
print(f" size is EXACTLY a full GameCube disc image")
|
||||
else:
|
||||
warn.append(f"size {size} is not the standard {GC_DISC_LEN} "
|
||||
f"({size - GC_DISC_LEN:+d})")
|
||||
|
||||
print(f"\nheader")
|
||||
print(f" magic {magic:#010x} " +
|
||||
("OK" if magic == GC_MAGIC else f"BAD, expected {GC_MAGIC:#010x}"))
|
||||
if magic != GC_MAGIC:
|
||||
bad.append("DVD magic word wrong -- not a GameCube image, or "
|
||||
"the first sector is corrupt")
|
||||
print(f" game code {s(hdr[0:4])!r} maker {maker!r} "
|
||||
f"disc {disc_id} version {version} audio-stream {audio}")
|
||||
print(f" title {name!r}")
|
||||
if not all(32 <= c < 127 for c in hdr[0:6]):
|
||||
bad.append(f"game/maker code is not printable: {hdr[0:6]!r}")
|
||||
if not name:
|
||||
warn.append("title string is empty")
|
||||
|
||||
print(f" DOL at {dol_off:#x}")
|
||||
print(f" FST at {fst_off:#x} size {fst_size} (max {fst_max})")
|
||||
for label, off in (("DOL", dol_off), ("FST", fst_off)):
|
||||
if not (0 < off < size):
|
||||
bad.append(f"{label} offset {off:#x} is outside the image")
|
||||
if not (0 < fst_size <= fst_max or fst_max == 0):
|
||||
warn.append(f"FST size {fst_size} > max {fst_max}")
|
||||
if fst_off + fst_size > size:
|
||||
bad.append(f"FST ({fst_off:#x}+{fst_size}) runs past the end")
|
||||
|
||||
# Apploader
|
||||
f.seek(APPLOADER)
|
||||
al = f.read(0x20)
|
||||
al_size, al_trailer = struct.unpack(">II", al[0x14:0x1C])
|
||||
print(f" apploader date {s(al[0:10])!r} size {al_size} "
|
||||
f"trailer {al_trailer}")
|
||||
if al_size <= 0 or al_size > 0x100000:
|
||||
warn.append(f"apploader size {al_size} looks implausible")
|
||||
|
||||
# ── Filesystem ───────────────────────────────────────────────────────
|
||||
files = []
|
||||
if magic == GC_MAGIC and 0 < fst_off < size and 0 < fst_size <= 0x400000:
|
||||
f.seek(fst_off)
|
||||
fst = f.read(fst_size)
|
||||
if len(fst) < 12:
|
||||
bad.append("FST truncated")
|
||||
else:
|
||||
n = struct.unpack(">I", fst[8:12])[0]
|
||||
print(f"\nfilesystem: {n} entries")
|
||||
if n * 12 > len(fst):
|
||||
bad.append(f"FST claims {n} entries but is only {len(fst)} bytes")
|
||||
n = len(fst) // 12
|
||||
strtab = n * 12
|
||||
stack = [(0, n, "")]
|
||||
i = 1
|
||||
dirs = 0
|
||||
while i < n:
|
||||
e = fst[i * 12:(i + 1) * 12]
|
||||
is_dir = e[0]
|
||||
name_off = struct.unpack(">I", b"\0" + e[1:4])[0]
|
||||
off, length = struct.unpack(">II", e[4:12])
|
||||
nm_end = fst.find(b"\0", strtab + name_off)
|
||||
nm = fst[strtab + name_off:nm_end].decode("latin-1", "replace")
|
||||
while stack and i >= stack[-1][1]:
|
||||
stack.pop()
|
||||
path = "/".join([p for _, _, p in stack if p] + [nm])
|
||||
if is_dir:
|
||||
dirs += 1
|
||||
stack.append((off, length, nm))
|
||||
else:
|
||||
files.append((path, off, length))
|
||||
if off + length > size:
|
||||
bad.append(f"{path}: {off:#x}+{length} runs past the "
|
||||
f"end of the image")
|
||||
i += 1
|
||||
total = sum(l for _, _, l in files)
|
||||
print(f" {len(files)} files, {dirs} directories, "
|
||||
f"{total} bytes ({total/2**20:.1f} MiB) of file data")
|
||||
shown = files if a.max_list == 0 else files[:a.max_list]
|
||||
for path, off, length in shown:
|
||||
print(f" {off:#011x} {length:>10} {path}")
|
||||
if a.max_list and len(files) > a.max_list:
|
||||
print(f" ... {len(files) - a.max_list} more")
|
||||
|
||||
# ── Contents: this is what catches a lossy dump ──────────────────────
|
||||
if files and not a.no_content:
|
||||
print(f"\ncontent check: reading all {len(files)} files")
|
||||
empty, holed, unaligned, checked = [], [], [], 0
|
||||
for path, off, length in files:
|
||||
if length == 0:
|
||||
continue
|
||||
f.seek(off)
|
||||
data = f.read(length)
|
||||
checked += 1
|
||||
if len(data) < length:
|
||||
bad.append(f"{path}: short read ({len(data)}/{length})")
|
||||
continue
|
||||
if not any(data):
|
||||
empty.append(path)
|
||||
continue
|
||||
# Long zero runs alone do NOT mean a dropped transfer, and
|
||||
# treating them that way gives false positives: streamed audio
|
||||
# (Audiores/Stream/*.afc) legitimately begins with silence right
|
||||
# after its 16-byte header, and Wind Waker has several such files.
|
||||
#
|
||||
# What a DROPPED TRANSFER looks like is a run ALIGNED to the
|
||||
# transfer granularity -- whole 2048-byte disc sectors, since
|
||||
# nothing reads or writes a fraction of a sector. Structural
|
||||
# silence starts and ends wherever the audio does. So the
|
||||
# alignment is the discriminator, not the length.
|
||||
zrun = st = 0
|
||||
runs = []
|
||||
for j, b in enumerate(data):
|
||||
if b:
|
||||
if zrun >= 32768:
|
||||
runs.append((st, zrun))
|
||||
zrun = 0
|
||||
else:
|
||||
if zrun == 0:
|
||||
st = j
|
||||
zrun += 1
|
||||
if zrun >= 32768:
|
||||
runs.append((st, zrun))
|
||||
aligned = [(o, l) for o, l in runs
|
||||
if o % 2048 == 0 and l % 2048 == 0]
|
||||
if aligned:
|
||||
holed.append((path, aligned[0][1], length))
|
||||
elif runs:
|
||||
unaligned.append((path, max(l for _, l in runs), length))
|
||||
print(f" read {checked} non-empty files")
|
||||
if empty:
|
||||
warn.append(f"{len(empty)} file(s) are entirely zero: "
|
||||
+ ", ".join(empty[:5]))
|
||||
if holed:
|
||||
bad.append(f"{len(holed)} file(s) contain SECTOR-ALIGNED zero runs "
|
||||
f">=32 KiB -- the signature of dropped blocks: "
|
||||
+ ", ".join(f"{p} ({m} B of {l})" for p, m, l in holed[:5]))
|
||||
if unaligned:
|
||||
print(f" {len(unaligned)} file(s) have long zero runs that are "
|
||||
f"NOT sector-aligned:")
|
||||
for p, m, l in unaligned[:6]:
|
||||
print(f" {p} longest {m} B of {l}")
|
||||
print(f" -> unaligned means structural, not lost: a dropped "
|
||||
f"transfer moves whole")
|
||||
print(f" 2048-byte sectors. Streamed audio (.afc) starts "
|
||||
f"with silence after")
|
||||
print(f" its 16-byte header, which is exactly this shape.")
|
||||
if not empty and not holed:
|
||||
print(" no all-zero files, no sector-aligned zero runs -- "
|
||||
"contents look intact")
|
||||
|
||||
# Coverage: how much of the image the filesystem actually accounts
|
||||
# for. A truncated or zero-filled tail shows up here.
|
||||
if files:
|
||||
hi = max(o + l for _, o, l in files)
|
||||
print(f" file data reaches {hi:#x}, "
|
||||
f"{size - hi} bytes short of the end of the image")
|
||||
if size - hi > 1 << 20:
|
||||
warn.append(f"{(size-hi)/2**20:.1f} MiB past the last file -- "
|
||||
f"check whether it is pseudo-random padding "
|
||||
f"(retail) or zero-filled (scrubbed dump)")
|
||||
|
||||
# Whole-image hash, for comparing against redump/another dump
|
||||
f.seek(0)
|
||||
h, got = hashlib.sha1(), 0
|
||||
while True:
|
||||
chunk = f.read(1 << 22)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
got += len(chunk)
|
||||
print(f"\nSHA-1 {h.hexdigest()} ({got} bytes read)")
|
||||
f.close()
|
||||
|
||||
print()
|
||||
for w in warn:
|
||||
print(f"⚠️ {w}")
|
||||
for b in bad:
|
||||
print(f"❌ {b}")
|
||||
if not bad:
|
||||
print("✅ valid GameCube image" + (" (with warnings)" if warn else ""))
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user