Discover counted relocatable modules and build declared native placements

This commit is contained in:
Matthew Stanley
2026-10-03 16:53:41 -07:00
parent 9f7031f878
commit d221cc221e
6 changed files with 193 additions and 2 deletions
+2
View File
@@ -62,6 +62,8 @@ Profiles declare these reusable methods:
| `mod_packages[]` / `mod_package` | A named, verified selection of a trusted mod package. Images and checks carrying `mod_package` read the original disc with that selection applied; see below. |
| `images[].transfer_entries` | `{from: "mod_package_writes", count}`: J/JAL targets inside the image, taken only from words the selected package wrote. The count pins the inventory. Optional `exclude_writes: [{start, end, reason}]` names intervals of package-written DATA (for example a pack linked into the EXE that the package replaced) whose words are not transfers. |
| `images[].method = tagged_relocated_files` | Apply a trailing two-bit ABS32/HI16/LO16/J26 relocation stream to a verified `load_addr`, retaining only the original loader's image interval. Destinations are explicit evidence, never inferred from a historical RAM capture. |
| `images[].method = counted_relocated_members` | Verify each original container's SHA-256, classify every counted member, and relocate its image at each separately established heap placement. Pin `image_size` and `relocation_count`; each member needs nonempty unique `placements` or an explained exclusion. New placements remain interpreted. See [counted containers](COUNTED_MIPS_RELOCATIONS.md). |
| `images[].entry_pointer_offsets` | Aligned image-relative offsets of loader-established callable exports. Read the pointer after relocation, ignore null callbacks, and require every nonnull callback to target aligned code inside the image. Pointer-shaped script/data fields are not callable exports. |
| `images[].method = psx_exe` | Read the load address and image from a PS-X EXE header; the generic extractor may split resident and overlay floors. |
| `images[].method = packed_sector_members` | Decode u32 count/offset sector descriptors across ordered payload files. Verify every member classification and requested full-payload coverage; use loader-established addresses for executable members. |
| `images[].method = sector_extent_members` | Decode `{sector offset, byte size}` pairs in an archive. Use archive-relative offsets, verify the exact count and terminator, reject gaps/overlaps, and account for every member after sector rounding. Each member needs a verified load address or an explicit exclusion. |
+24 -1
View File
@@ -39,7 +39,28 @@ The container has **no destination addresses**. `relocate` takes an aligned
KSEG0 main-RAM image base; this is not the allocation base of a container whose
headers remain in memory. Callers must establish placement and executable/data
boundaries from the original loader before constructing native AOT producers.
The decoder alone does not provide dynamically placed native module dispatch.
The original-disc AOT pipeline accepts `counted_relocated_members` with
`containers: [{file, sha256, members: [...]}]`. Each member pins its `index`,
`image_size`, `relocation_count` and nonempty `placements: [{load_addr, ...}]`.
Every member must be classified, including an explicit reason for exclusions.
The placement is the image address, which can differ from the container's
allocation address by the header size. Native emission uses relocated original
bytes and the normal guarded dispatcher. A newly encountered heap placement
continues through the interpreter until its own native variant is established.
`entry_pointer_offsets` declares image-relative callback fields proven by the
loader's consumers. Reading those fields after relocation lets the same export
declaration apply at multiple bases. Null callbacks are ignored; unaligned,
out-of-image and excluded required entries fail extraction. Script pointers and
other data fields must not be nominated as MIPS entry points merely because they
look like addresses.
The generic disc discoverer now recognizes complete counted containers and
writes `<output>.relocatable.json` alongside its positioned recipes. The sidecar
records original hashes, member extents, sizes and relocation counts with
`placement_required: true`. It does not infer a heap address or claim native
coverage. This exposes unresolved loader work during initial discovery instead
of waiting for gameplay to reveal interpreter callbacks.
The implementation was compared byte for byte with the owned USA MediEvil II
relocation routine (`0x80078328`) for 24 modules at two distinct RAM bases:
@@ -49,4 +70,6 @@ all four relocation kinds, opcode preservation and malformed inputs:
```sh
python -m unittest discover -s tools/tests -p test_counted_mips_relocations.py
python -m unittest discover -s tools/tests -p test_counted_relocation_discovery.py
python -m unittest discover -s tools/tests -p test_aot_overlay_pipeline.py
```
+55 -1
View File
@@ -25,6 +25,7 @@ from sector_extent_archive import extract_members as extract_extent_members
from aligned_lzss_banks import banks as extract_lzss_banks
from indexed_lzss_pack import members as extract_pack_members
from mips_tagged_relocations import parse as parse_tagged_relocations, relocate as relocate_tagged_image
from counted_mips_relocations import parse as parse_counted_relocations, relocate as relocate_counted_image
from mod_package_images import ModPackageView
FRAMEWORK = Path(__file__).resolve().parents[1]
@@ -369,6 +370,8 @@ def positioned_sources(disc, specifications, views=None, ram_bytes=RETAIL_RAM_BY
def spec_sources(disc, spec):
sources = []
method = spec['method']
if method == 'counted_relocated_members':
return counted_relocated_sources(disc, spec)
if method == 'fixed_address_extents':
return extent_sources(disc, spec)
if method == 'aligned_lzss_banks':
@@ -469,6 +472,48 @@ def spec_sources(disc, spec):
return sources
def counted_relocated_sources(disc, spec):
"""Original members at declared heap placements, never captured RAM bytes.
Counted containers describe image boundaries and relocation writes, but no
destination. Each placement must be established separately from the loader.
A new heap placement remains interpreted until a guarded variant is added.
"""
sources = []
for container in spec['containers']:
file = container['file'].upper()
data = disc.read(file)
require(hashlib.sha256(data).hexdigest() == container['sha256'],
f'Counted relocation container changed: {file}')
members = parse_counted_relocations(data)
configured = {number(item['index']): item for item in container['members']}
excluded = {number(item['index']): item for item in container.get('excluded_members', [])}
require(len(configured) == len(container['members']) and
len(excluded) == len(container.get('excluded_members', [])),
'Duplicate counted member inventory')
require(not configured.keys() & excluded.keys() and
configured.keys() | excluded.keys() == set(range(len(members))),
'Counted member inventory has missing or conflicting classifications')
require(all(item.get('reason', '').strip() for item in excluded.values()),
'Excluded counted member needs a reason')
for index, member in enumerate(members):
item = configured.get(index)
if item is None:
continue
require(len(member.image) == number(item['image_size']) and
len(member.relocations) == number(item['relocation_count']),
f'Counted member inventory changed: {file} {index}')
require(item.get('placements'), 'Counted member needs an established placement')
bases = [number(p['load_addr']) for p in item['placements']]
require(len(bases) == len(set(bases)), 'Duplicate counted member placement')
for placement, base in zip(item['placements'], bases):
body = relocate_counted_image(member, base)
sources.append(dict(name=f'{file}:IMAGE_{index:04X}@{base:08X}',
base=base, body=body, spec={**spec, **item, **placement, 'allow_missing': True},
source_file=file, source_offset=member.image_offset, aliases=[]))
return sources
def match_sources(record, sources):
require(not record.get('executed_pcs'), 'Runtime observations cannot be AOT inputs')
data = base64.b64decode(record['bytes_b64'], validate=True)
@@ -495,6 +540,15 @@ def declared_entries(source, disc):
offset = number(item.get('file_offset', 0)) + number(item['address']) - number(item.get('base', 0))
entries.add(struct.unpack_from('<I', view.read(item['file']), offset)[0])
entries.update(number(x) for x in spec.get('entries', []))
# Offsets designate loader-established callback fields in this image.
# Read after relocation so one declaration works at several heap bases.
for value in spec.get('entry_pointer_offsets', []):
offset = number(value)
require(offset % 4 == 0 and 0 <= offset <= len(body) - 4,
'Callback pointer offset outside image')
entry, = struct.unpack_from('<I', body, offset)
if entry:
entries.add(entry)
if 'transfer_entries' in spec:
entries |= transfer_entries(source, view)
require(all(base <= entry < base + len(body) and entry % 4 == 0 for entry in entries),
@@ -587,7 +641,7 @@ def prepare(profile, disc, records, output, views=None):
if (lo, hi) == (source['base'], source['base'] + len(source['body'])):
singles[source['name']] = record
for source in sources:
if source['spec']['method'] in ('fixed_address_files', 'fixed_address_extents', 'packed_sector_members', 'sector_extent_members', 'aligned_lzss_banks', 'indexed_lzss_members', 'tagged_relocated_files') and source['name'] not in singles:
if source['spec']['method'] in ('fixed_address_files', 'fixed_address_extents', 'packed_sector_members', 'sector_extent_members', 'aligned_lzss_banks', 'indexed_lzss_members', 'tagged_relocated_files', 'counted_relocated_members') and source['name'] not in singles:
record = make_fixed_record(source, disc)
singles[source['name']] = record
records.append(record)
@@ -32,6 +32,7 @@ import extract_overlays as eo
import compile_overlays as co
from packed_sector_table import extract_members as extract_sector_members
from sector_extent_archive import extract_members as extract_extent_members
from counted_mips_relocations import parse as parse_counted_relocations
try:
import tomllib
except ImportError:
@@ -52,6 +53,24 @@ def parse_cue_datatrack(cue_path):
return cur_bin, (int(t.group(2))==2352)
return eo.parse_cue(cue_path) # fallback
def counted_relocation_inventory(data):
"""Identify a complete container without inventing its destination or code.
The format carries image-relative pointers. Sweeping fixed link bases for
these bytes cannot establish their heap placement. Report the gap early so
a loader profile can provide placements and callback fields before play.
"""
try:
members = parse_counted_relocations(data)
except ValueError:
return None
return dict(method='counted_relocated_members', placement_required=True,
members=[dict(index=index, image_offset=member.image_offset,
image_size=len(member.image), relocation_count=len(member.relocations),
image_sha256=hashlib.sha256(member.image).hexdigest())
for index, member in enumerate(members)])
def prologues(data, base):
"""Return framed function entries, including a proven pre-frame prelude.
@@ -1015,6 +1034,7 @@ def main():
# The boot EXE (game.toml `exe`) is the STATIC-recompiled base, NOT an overlay.
exe_base=os.path.basename(str(game.get('exe',''))).upper()
records=[]; np=nh=nr=na=ne=nc=0; seen_crc=set(); header_files=[]; raw_files=[]
relocatable=[]
positioned=[]
for p,l,s in sorted(files):
if p.upper() in hed_consumed:
@@ -1023,6 +1043,13 @@ def main():
if exe_base and os.path.basename(p).upper()==exe_base:
continue # skip the statically-compiled base executable
data=dr.read_file_bytes(l,s)
relocation = counted_relocation_inventory(data)
if relocation is not None:
relocatable.append(dict(source_name=p, source_sha256=hashlib.sha256(data).hexdigest(),
**relocation))
print(f" [counted relocations] {p}: {len(relocation['members'])} images; "
'loader placement required (no fixed base inferred)')
continue
# Position-fixed overlays are reused verbatim across AREA folders; dedup
# by content so identical bytes -> one shard set, not N (same as hand tool).
dcrc=binascii.crc32(data)&0xFFFFFFFF
@@ -1271,6 +1298,9 @@ def main():
f"sha256={record['bios_sha256'][:12]}...")
with open(a.out, 'w') as f:
json.dump(records, f)
with open(a.out + '.relocatable.json', 'w') as f:
json.dump(dict(schema='psxrecomp unresolved original-disc relocations v1',
containers=relocatable), f, indent=2)
print(f"producers: {np} PS-X EXE (full-discovery), {nh} header-table, "
f"{nr} raw-code, {na} indexed-archive members, "
f"{ne} HED-companion members, "
+51
View File
@@ -348,6 +348,57 @@ class ModPackageImageTest(unittest.TestCase):
class AotMethodsTest(unittest.TestCase):
def test_counted_original_members_at_multiple_heap_placements(self):
# A relocated callback pointer enters a frameless return stub.
image = struct.pack('<4I', 8, 0x12345678, 0x03e00008, 0)
original = struct.pack('<4I', 1, 0, len(image), 0) + image + struct.pack('<2I', 0, 0xffffffff)
disc = FakeDisc({'RELOCS/MODULE.LVB': original})
member = dict(index=0, image_size=16, relocation_count=1,
entry_pointer_offsets=[0], placements=[dict(load_addr='0x80101010'),
dict(load_addr='0x80102020')])
container = dict(file='RELOCS/MODULE.LVB', sha256=sha(original), members=[member])
spec = dict(method='counted_relocated_members', containers=[container])
sources = pipeline.positioned_sources(disc, [spec])
self.assertEqual([s['base'] for s in sources], [0x80101010, 0x80102020])
self.assertEqual([pipeline.declared_entries(s, disc) for s in sources],
[{0x80101018}, {0x80102028}])
self.assertEqual(disc.read('RELOCS/MODULE.LVB'), original)
with tempfile.TemporaryDirectory() as directory:
disc.binary = Path(directory) / 'source.bin'; disc.binary.write_bytes(original)
inventory = pipeline.prepare(dict(game_id='TEST', images=[spec], strict_bounds=True,
expected_records=2), disc, [], Path(directory))
self.assertEqual(inventory['recipe_count'], 2)
self.assertEqual([j['required_entries'] for j in inventory['jobs']],
[[0x80101018], [0x80102028]])
self.assertEqual(inventory['jobs'][0]['known_ranges'], [(0x80101010, 0x80101020)])
def test_counted_sources_reject_inventory_and_callback_drift(self):
image = struct.pack('<4I', 8, 0, 0x03e00008, 0)
original = struct.pack('<4I', 1, 0, 16, 0) + image + struct.pack('<2I', 0, 0xffffffff)
disc = FakeDisc({'MODULE.LVB': original})
item = dict(index=0, image_size=16, relocation_count=1,
placements=[dict(load_addr='0x80100000')])
container = dict(file='MODULE.LVB', sha256=sha(original), members=[item])
def sources(c):
return pipeline.positioned_sources(disc, [dict(method='counted_relocated_members',
containers=[c])])
for change, error in ((dict(sha256='0'*64), 'container changed'),
(dict(members=[]), 'missing'),
(dict(members=[{**item, 'relocation_count': 2}]), 'inventory changed'),
(dict(members=[{**item, 'placements': []}]), 'placement'),
(dict(members=[{**item, 'placements': item['placements']*2}]), 'Duplicate')):
with self.subTest(change=change), self.assertRaisesRegex(ValueError, error):
sources({**container, **change})
source, = sources(container)
for offset in (-4, 1, 16):
with self.subTest(offset=offset), self.assertRaisesRegex(ValueError, 'outside image'):
pipeline.declared_entries({**source, 'spec': {**source['spec'],
'entry_pointer_offsets': [offset]}}, disc)
source['body'] = struct.pack('<4I', 0x80020000, 0, 0x03e00008, 0)
with self.assertRaisesRegex(ValueError, 'Entry outside image'):
pipeline.declared_entries({**source, 'spec': {**source['spec'],
'entry_pointer_offsets': [0]}}, disc)
def test_tagged_relocated_original_file_and_inventory_check(self):
original = struct.pack('<8I', 16, 8, 0x03e00008, 0, 4, 0xFFFFFFFF, 0, 0)[:24]
disc = FakeDisc({'MODULE.DLL': original})
@@ -0,0 +1,31 @@
"""Unpositioned module discovery must report facts without inventing a base."""
from pathlib import Path
import struct
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from aot_overlay_spike.extract_generic import counted_relocation_inventory
class CountedDiscoveryTest(unittest.TestCase):
def test_complete_container_reports_members_and_requires_placement(self):
image = struct.pack('<4I', 8, 0x0c000002, 0x03e00008, 0)
original = struct.pack('<4I', 1, 0, 16, 0) + image + struct.pack('<3I', 0, 7, 0xffffffff)
inventory = counted_relocation_inventory(original)
self.assertTrue(inventory['placement_required'])
self.assertNotIn('load_addr', inventory)
self.assertEqual(inventory['members'][0]['image_offset'], 16)
self.assertEqual(inventory['members'][0]['relocation_count'], 2)
self.assertEqual(inventory['members'][0]['image_size'], 16)
def test_partial_stream_and_pointer_header_are_not_relocation_containers(self):
for data in (struct.pack('<4I', 1, 0x80100000, 0x03e00008, 0),
struct.pack('<4I', 1, 0, 4, 0) + struct.pack('<2I', 0, 0),
b'PS-X EXE' + bytes(2040)):
with self.subTest(data=data[:16]):
self.assertIsNone(counted_relocation_inventory(data))
if __name__ == '__main__':
unittest.main()