diff --git a/tools/colour_inputs.py b/tools/colour_inputs.py index d3003cbb..315ae57d 100644 --- a/tools/colour_inputs.py +++ b/tools/colour_inputs.py @@ -58,6 +58,60 @@ SRC_OFFSET = -12 # lwr $t6,-12($s4): the source word's base DEFAULT_SPAN = 48 # enough to see the table around it +def diff_clusters(a, b, base, gap=8, limit=12): + """Where two buffers differ, coalesced into runs. + + A count is not a finding. "2747 of 23088 bytes differ" says nothing about + whether the two hold the same data shifted, one holds zeros where the other + holds values, or a handful of entries changed -- and those point at + completely different causes. Runs separated by fewer than `gap` matching + bytes are merged, because a differing 4-byte entry usually differs in only + some of its bytes and reporting it as three findings obscures it. + """ + runs = [] + i = 0 + n = min(len(a), len(b)) + while i < n: + if a[i] == b[i]: + i += 1 + continue + start = i + last = i + while i < n: + if a[i] != b[i]: + last = i + elif i - last >= gap: + break + i += 1 + runs.append((start, last)) + if len(runs) >= limit * 4: + break + return [{"addr": f"0x{base + s:08X}", "length": e - s + 1, + "native": a[s:min(e + 1, s + 16)].hex(" "), + "oracle": b[s:min(e + 1, s + 16)].hex(" ")} + for s, e in runs[:limit]] + + +def describe_difference(clusters): + """What KIND of difference is this? The shape narrows the cause.""" + if not clusters: + return "" + nat_zero = sum(1 for c in clusters + if set(c["native"].split()) <= {"00"}) + orc_zero = sum(1 for c in clusters + if set(c["oracle"].split()) <= {"00"}) + if nat_zero == len(clusters): + return ("psx-runtime holds ZEROS everywhere the oracle holds data — it " + "has not written this, rather than written it differently.") + if orc_zero == len(clusters): + return ("the oracle holds zeros where psx-runtime holds data — " + "psx-runtime is writing somewhere the oracle does not.") + aligned = all(int(c["addr"], 16) % 4 == 0 for c in clusters) + return ("both sides hold data and it differs" + + (", on 4-byte boundaries — whole entries, not stray bytes" + if aligned else ", not aligned to entry boundaries")) + + def region_bounds(a, b, pad=0x2000, limit=0x200000): """A span covering both pointers, so the comparison is phase-independent. @@ -351,6 +405,10 @@ def main(argv=None): nd = sum(1 for x, y in zip(ra_, rb_) if x != y) doc["region_differing_bytes"] = nd doc["region_bytes"] = len(ra_) + if nd: + cl = diff_clusters(ra_, rb_, lo) + doc["region_clusters"] = cl + doc["region_shape"] = describe_difference(cl) if nd == 0: doc["verdict"] = "region-matches" print(f"\nVERDICT: the whole {len(ra_)}-byte region is " @@ -381,6 +439,12 @@ def main(argv=None): f"region is not changing — so this is a real data " f"divergence, independent of where either pointer " f"happened to be.") + if doc.get("region_shape"): + print(f"\n {doc['region_shape']}") + for c in doc.get("region_clusters", [])[:6]: + print(f" {c['addr']} ({c['length']} bytes)") + print(f" psx-runtime {c['native']}") + print(f" oracle {c['oracle']}") return _finish(doc, args, 0) same = a == b diff --git a/tools/tests/test_colour_inputs.py b/tools/tests/test_colour_inputs.py index b3e5eb4a..81e3e2b4 100644 --- a/tools/tests/test_colour_inputs.py +++ b/tools/tests/test_colour_inputs.py @@ -284,3 +284,57 @@ class TestPhaseIndependentRegion(unittest.TestCase): self.assertGreater(hi - lo, 0x1000, "a zero-width region would compare nothing and " "report a match") + + +class TestDifferenceShape(unittest.TestCase): + """A count is not a finding. + + "2747 of 23088 bytes differ" is compatible with several completely + different causes: one side has not written the data at all, one is writing + where the other does not, or both hold values that disagree. Each points + somewhere else, so the shape has to be reported alongside the number. + """ + + def test_zeros_on_one_side_are_called_out_as_not_written(self): + a = bytes(8) + b = bytes.fromhex("f8505000f8f8b000") + cl = CI.diff_clusters(a, b, 0xE0000) + self.assertIn("has not written this", CI.describe_difference(cl)) + + def test_zeros_on_the_other_side_are_distinguished(self): + a = bytes.fromhex("f8505000f8f8b000") + b = bytes(8) + self.assertIn("oracle holds zeros", + CI.describe_difference(CI.diff_clusters(a, b, 0))) + + def test_both_holding_data_is_reported_as_a_real_disagreement(self): + a = bytes.fromhex("f8505000") + b = bytes.fromhex("08080800") + self.assertIn("both sides hold data", + CI.describe_difference(CI.diff_clusters(a, b, 0))) + + def test_entry_alignment_is_noted(self): + # Differences on 4-byte boundaries mean whole colour entries changed, + # not stray bytes — which separates a data difference from corruption. + a = bytes.fromhex("00000000" + "f8505000") + b = bytes.fromhex("00000000" + "08080800") + self.assertIn("4-byte boundaries", + CI.describe_difference(CI.diff_clusters(a, b, 0))) + + def test_nearby_differing_bytes_become_one_cluster(self): + # A 4-byte entry usually differs in only some of its bytes; reporting + # that as three findings buries it. + a = bytes.fromhex("f8005000") + b = bytes.fromhex("08ff0800") + cl = CI.diff_clusters(a, b, 0) + self.assertEqual(len(cl), 1) + + def test_distant_differences_stay_separate(self): + a = bytes.fromhex("ff" + "00" * 20 + "ff") + b = bytes.fromhex("00" + "00" * 20 + "00") + self.assertEqual(len(CI.diff_clusters(a, b, 0)), 2) + + def test_identical_buffers_have_no_clusters(self): + a = bytes.fromhex("f8505000") + self.assertEqual(CI.diff_clusters(a, a, 0), []) + self.assertEqual(CI.describe_difference([]), "")