diff --git a/docs/AOT_SHARDING.md b/docs/AOT_SHARDING.md index 5d2cfad8..5cc05958 100644 --- a/docs/AOT_SHARDING.md +++ b/docs/AOT_SHARDING.md @@ -259,6 +259,34 @@ with table-address setup around the bounds branch. Register dependencies, producer ownership and table-target checks remain required. That improvement contains no Tomba-specific addresses and can benefit other matching binaries. +## When discovery finds no game overlay + +An archive extension or a historical dirty-RAM capture does not establish an +executable overlay. A resident-code game is a valid outcome of bounded disc +inspection. Do not count an independently identified BIOS RAM helper as a game +overlay, or manufacture a release inventory from runtime observations. + +`tools/inspect_disc_inventory.py` verifies a declarative `psxrecomp disc inventory +v1` profile against the original disc. It reuses the AOT evidence-word checks, +matches fixed-stride `{LBA, byte-size}` tables to ISO files, and exposes +`typed_members(data, alignment)` for archives with a `{count, total-size}` header +and `{type, byte-size}` descriptors followed by aligned members. Repeated types +are allowed; declared size, count, bounds and full payload coverage are required. +Header bytes beyond the counted descriptors are opaque. + +The metadata-only report accounts for every ISO file and reports member hashes, +types and aligned MIPS instruction shapes. These shapes are observations, not +proof of code presence or absence; loader destinations, callbacks and any +decompression path still need bounded inspection of the original executable. +The game profile supplies all addresses, filenames, counts and rationales. The +shared tool contains no game-specific branches or bundled game bytes. + +```sh +python3 psxrecomp-v4/tools/inspect_disc_inventory.py \ + --profile aot/disc_inventory.json --cue "path/to/original-disc.cue" \ + --output build/disc-inventory.json +``` + ## Related material - [Compiling and packaging overlays](COMPILING_OVERLAYS.md): cache and static-link workflows. diff --git a/tests/test_disc_inventory.py b/tests/test_disc_inventory.py new file mode 100644 index 00000000..d98c0e78 --- /dev/null +++ b/tests/test_disc_inventory.py @@ -0,0 +1,92 @@ +"""Synthetic structural fixtures; no retail game bytes.""" +from pathlib import Path +import struct +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +from inspect_disc_inventory import inspect, typed_members, mips_shapes + + +class TypedArchiveTest(unittest.TestCase): + def fixture(self): + data = bytearray(3 * 32) + struct.pack_into('<6I', data, 0, 2, len(data), 7, 5, 7, 31) + data[32:37] = b'first' + data[64:95] = b'z' * 31 + return data + + def test_duplicate_types_and_partial_sectors(self): + members = typed_members(self.fixture(), 32) + self.assertEqual([(m['kind'], m['offset'], len(m['body'])) for m in members], + [(7, 32, 5), (7, 64, 31)]) + + def test_reject_size_count_and_extent_errors(self): + for offset, value in [(0, 4), (4, 95), (12, 90), (20, 33)]: + with self.subTest(offset=offset): + data = self.fixture() + struct.pack_into('= 8 and alignment & (alignment - 1) == 0, + 'Alignment must be a power of two') + require(len(data) >= alignment and len(data) % alignment == 0, + 'Archive is not sector aligned') + count, total = struct.unpack_from(' 0 and cursor + size <= len(data), 'Member outside archive') + members.append(dict(index=index, kind=kind, offset=cursor, + body=data[cursor:cursor + size])) + cursor = (cursor + size + alignment - 1) & ~(alignment - 1) + require(cursor == len(data), 'Archive payload coverage mismatch') + return members + + +def mips_shapes(data): + words = (struct.unpack_from('= 4, 'File table stride must be at least four bytes') + fields = ['lba_offset', 'size_offset'] + if 'first_word_offset' in table: + fields.append('first_word_offset') + for field in fields: + field_offset = number(table[field]) + require(0 <= field_offset <= stride - 4 and field_offset % 4 == 0, + f'Invalid file table field offset: {field}') + for index, name in enumerate(table['files']): + at = offset + index * stride + require(0 <= at <= len(data) - stride, 'File table outside source') + lba = struct.unpack_from('> number(profile.get('group_shift', 16))] += 1 + info['members'].append(dict(**member, size=len(body), + sha256=hashlib.sha256(body).hexdigest(), mips_shapes=mips_shapes(body))) + elif spec['method'] == 'raw_file': + if spec.get('inspect_mips_shapes'): + info['mips_shapes'] = mips_shapes(data) + else: + raise ValueError(f"Unknown inventory method: {spec['method']}") + files.append(info) + require(seen == set(disc.files), 'Inventory does not classify the complete ISO file list') + require(dict(groups) == {number(k): v for k, v in profile['expected_groups'].items()}, + 'Member group count drift') + return dict(schema='psxrecomp disc inspection v1', game_id=profile['game_id'], + disc_hashes=profile['disc_hashes'], files=files, groups=dict(groups), + limitation='Structural verification and instruction shapes do not prove code absence.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--profile', required=True, type=Path) + parser.add_argument('--cue', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + profile = json.loads(args.profile.read_text(encoding='utf-8')) + report = inspect(profile, Disc(args.cue)) + write_json(args.output, report) + print(f"Verified {len(report['files'])} files, {sum(report['groups'].values())} typed members") + + +if __name__ == '__main__': + main()