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:
2026-10-10 15:49:05 +02:00
parent 4e7a2ef429
commit 2da721d7ed
2 changed files with 240 additions and 0 deletions
+4
View File
@@ -39,3 +39,7 @@ gc_bba_test/build/
gc_bba_test/*.elf gc_bba_test/*.elf
gc_bba_test/*.dol gc_bba_test/*.dol
gc_bba_test/*.map gc_bba_test/*.map
# Disc images (dumped over the BBA) - never commit, they are ~1.4 GB
*.iso
*.gcm
+236
View File
@@ -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())