mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-10 03:21:27 -10:00
192 lines
6.7 KiB
Python
Executable File
192 lines
6.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Classify the A/B difference between two fb_row_digest dumps.
|
|
|
|
Written for the AvP "brown bar" sweep (#178). The fix under test must only
|
|
ever change rows the core previously left unwritten, and must only ever
|
|
change them to opaque black. This checks that mechanically so a 46-title
|
|
sweep does not depend on reading 46 screenshots.
|
|
|
|
VJFBDIG3 adds a per-frame overscan column-band digest (columns x >= 320).
|
|
Both inputs must be VJFBDIG3; VJFBDIG2 dumps exit 2 with a clear error.
|
|
|
|
Checks, in order of severity:
|
|
1. Geometry must match frame for frame. Any difference is a hard failure.
|
|
2. Per-frame audio digests must match. A video-window change cannot alter
|
|
audio; any difference is a hard failure.
|
|
3. Every row whose hash differs must be >= the patched build's written-row
|
|
extent for that frame, and its patched hash must equal the all-opaque-
|
|
black hash for that width. Anything else is a hard failure.
|
|
4. Overscan band_hash must match frame for frame (unless --ignore-band).
|
|
|
|
Usage: fb_row_diff.py <stock.bin> <patched.bin> [--label NAME] [--ignore-band]
|
|
Exit: 0 = identical or only expected differences, 1 = unexpected difference,
|
|
2 = usage/format error (missing, unreadable, truncated or wrong-magic
|
|
input included).
|
|
"""
|
|
|
|
import struct
|
|
import sys
|
|
|
|
MAGIC = b"VJFBDIG3"
|
|
HDR = struct.Struct("<4I") # width, rows, frame_hash, written_extent
|
|
BAND = struct.Struct("<6I") # x0, width, hash, nonblack, first_x, first_y
|
|
NO_EXTENT = 0xFFFFFFFF
|
|
|
|
|
|
def fnv1a(data, h=2166136261):
|
|
for b in data:
|
|
h ^= b
|
|
h = (h * 16777619) & 0xFFFFFFFF
|
|
return h
|
|
|
|
|
|
_black_cache = {}
|
|
|
|
|
|
def black_row_hash(width):
|
|
"""Hash of a row of `width` opaque-black XRGB8888 pixels, in the same
|
|
little-endian byte order fb_row_digest hashes the framebuffer in."""
|
|
if width not in _black_cache:
|
|
_black_cache[width] = fnv1a(b"\x00\x00\x00\xff" * width)
|
|
return _black_cache[width]
|
|
|
|
|
|
def load(path):
|
|
with open(path, "rb") as f:
|
|
blob = f.read()
|
|
if blob[:8] != MAGIC:
|
|
raise ValueError(f"{path}: bad magic {blob[:8]!r} (need VJFBDIG3)")
|
|
nvideo, naudio = struct.unpack_from("<2I", blob, 8)
|
|
frames = []
|
|
off = 16
|
|
for _ in range(nvideo):
|
|
w, h, fh, extent = HDR.unpack_from(blob, off)
|
|
off += HDR.size
|
|
rows = struct.unpack_from(f"<{h}I", blob, off)
|
|
off += 4 * h
|
|
band = BAND.unpack_from(blob, off)
|
|
off += BAND.size
|
|
frames.append((w, h, fh, extent, rows, band))
|
|
audio = struct.unpack_from(f"<{naudio}I", blob, off) if naudio else ()
|
|
return frames, list(audio)
|
|
|
|
|
|
def main():
|
|
argv = sys.argv[1:]
|
|
label = "run"
|
|
ignore_band = False
|
|
args = []
|
|
i = 0
|
|
while i < len(argv):
|
|
if argv[i] == "--label" and i + 1 < len(argv):
|
|
label = argv[i + 1]
|
|
i += 2
|
|
elif argv[i] == "--ignore-band":
|
|
ignore_band = True
|
|
i += 1
|
|
elif argv[i].startswith("--"):
|
|
i += 1
|
|
else:
|
|
args.append(argv[i])
|
|
i += 1
|
|
if len(args) != 2:
|
|
print(__doc__)
|
|
return 2
|
|
|
|
try:
|
|
frames_a, audio_a = load(args[0])
|
|
frames_b, audio_b = load(args[1])
|
|
except OSError as e:
|
|
print(f"FAIL {label}: cannot read {e.filename or '?'}: "
|
|
f"{e.strerror or e}", file=sys.stderr)
|
|
return 2
|
|
except struct.error as e:
|
|
print(f"FAIL {label}: truncated or malformed digest: {e}",
|
|
file=sys.stderr)
|
|
return 2
|
|
except ValueError as e:
|
|
print(f"FAIL {label}: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
unexpected = []
|
|
changed_frames = 0
|
|
changed_rows = set()
|
|
first_changed_frame = None
|
|
band_changed_frames = 0
|
|
max_band_nonblack = 0
|
|
any_band_nonblack = False
|
|
|
|
gap_frames = sum(1 for (_w, h, _fh, ext, _r, _b) in frames_b
|
|
if ext != NO_EXTENT and ext < h)
|
|
extents = [ext for (_w, _h, _fh, ext, _r, _b) in frames_b if ext != NO_EXTENT]
|
|
extent_changes = sum(1 for a_, b_ in zip(extents, extents[1:]) if a_ != b_)
|
|
|
|
if len(frames_a) != len(frames_b):
|
|
unexpected.append((-1, -1,
|
|
f"frame count {len(frames_a)} != {len(frames_b)}"))
|
|
if audio_a != audio_b:
|
|
n = sum(1 for x, y in zip(audio_a, audio_b) if x != y)
|
|
first = next((k for k, (x, y) in enumerate(zip(audio_a, audio_b))
|
|
if x != y), None)
|
|
unexpected.append((-1, -1, f"audio differs in {n} frames, "
|
|
f"first at audio frame {first}"))
|
|
|
|
for n, (fa, fb_) in enumerate(zip(frames_a, frames_b)):
|
|
wa, ha, fha, _exta, rowsa, banda = fa
|
|
wb, hb, fhb, extb, rowsb, bandb = fb_
|
|
|
|
if bandb[3] > max_band_nonblack:
|
|
max_band_nonblack = bandb[3]
|
|
if bandb[3] > 0:
|
|
any_band_nonblack = True
|
|
|
|
if (wa, ha) != (wb, hb):
|
|
# "rows" is the stored row count (clamped), not presented height.
|
|
unexpected.append((n, -1, f"geometry w={wa} rows={ha} -> "
|
|
f"w={wb} rows={hb}"))
|
|
continue
|
|
|
|
if banda[2] != bandb[2]:
|
|
band_changed_frames += 1
|
|
if not ignore_band:
|
|
unexpected.append((n, -1, f"overscan band_hash differs "
|
|
f"({banda[2]:08X} -> {bandb[2]:08X})"))
|
|
|
|
if fha == fhb:
|
|
continue
|
|
|
|
changed_frames += 1
|
|
if first_changed_frame is None:
|
|
first_changed_frame = n
|
|
|
|
blk = black_row_hash(wb)
|
|
for r in range(ha):
|
|
if rowsa[r] == rowsb[r]:
|
|
continue
|
|
changed_rows.add(r)
|
|
if extb != NO_EXTENT and r < extb:
|
|
unexpected.append((n, r, f"row < written extent {extb}"))
|
|
elif rowsb[r] != blk:
|
|
unexpected.append((n, r, "patched row is not opaque black"))
|
|
|
|
ok = not unexpected
|
|
rows_desc = (f"{min(changed_rows)}-{max(changed_rows)}"
|
|
if changed_rows else "none")
|
|
print(f"{'OK ' if ok else 'FAIL'} {label}: frames={len(frames_a)} "
|
|
f"gap_frames={gap_frames} extent_changes={extent_changes} "
|
|
f"changed_frames={changed_frames} changed_rows={rows_desc} "
|
|
f"first_changed_frame={first_changed_frame} "
|
|
f"band_changed_frames={band_changed_frames} "
|
|
f"max_band_nonblack={max_band_nonblack} "
|
|
f"band_nonblack_any={int(any_band_nonblack)} "
|
|
f"unexpected={len(unexpected)}")
|
|
for frame, row, why in unexpected[:10]:
|
|
print(f" frame {frame} row {row}: {why}")
|
|
if len(unexpected) > 10:
|
|
print(f" ... {len(unexpected) - 10} more")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|