Add verified two-bit tagged MIPS relocation reader

This commit is contained in:
Matthew Stanley
2026-09-11 22:56:59 -07:00
parent a1e7db08e7
commit 860bd6a237
2 changed files with 122 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
"""Reconstruct MIPS images with a trailing two-bit tagged relocation stream.
The first little-endian word is the byte offset of the stream and therefore
the retained image size. Stream words encode an aligned image offset plus:
0 = ABS32, 1 = HI16 with a following full addend, 2 = LO16, 3 = J26.
0xffffffff ends the stream. This format carries no load address: callers must
establish each destination independently from the original loader.
"""
from dataclasses import dataclass
import struct
@dataclass(frozen=True)
class Relocation:
offset: int
kind: int
addend: int | None = None
def parse(data: bytes) -> tuple[bytes, tuple[Relocation, ...]]:
"""Validate the entire file and return its retained image and relocations."""
if len(data) < 8 or len(data) % 4:
raise ValueError("Relocation file must contain aligned header and terminator")
end = struct.unpack_from("<I", data)[0]
if end < 8 or end % 4 or end > len(data) - 4:
raise ValueError("Invalid relocation stream offset")
cursor, relocations, written = end, [], set()
while cursor <= len(data) - 4:
tag = struct.unpack_from("<I", data, cursor)[0]
cursor += 4
if tag == 0xFFFFFFFF:
if cursor != len(data):
raise ValueError("Unaccounted bytes after relocation terminator")
return data[:end], tuple(relocations)
offset, kind, addend = tag & ~3, tag & 3, None
if offset < 4 or offset + 4 > end:
raise ValueError("Relocation target outside retained image")
if offset in written:
raise ValueError("Repeated relocation target")
written.add(offset)
if kind == 1:
if cursor > len(data) - 4:
raise ValueError("Truncated HI16 addend")
addend = struct.unpack_from("<I", data, cursor)[0]
cursor += 4
relocations.append(Relocation(offset, kind, addend))
raise ValueError("Missing relocation terminator")
def relocate(data: bytes, load_addr: int) -> tuple[bytes, tuple[Relocation, ...]]:
"""Apply the loader's 32-bit and halfword writes at a verified RAM base."""
source, relocations = parse(data)
if load_addr % 4 or not 0x80000000 <= load_addr < load_addr + len(source) <= 0x80200000:
raise ValueError("Relocated image must fit aligned PlayStation main RAM")
image = bytearray(source)
for item in relocations:
offset = item.offset
if item.kind == 0:
word = struct.unpack_from("<I", image, offset)[0]
struct.pack_into("<I", image, offset, (word + load_addr) & 0xFFFFFFFF)
elif item.kind == 1:
# The original loader adds 0x8000 before extracting the high word
# because the matching low-half instruction sign-extends its imm.
value = ((load_addr + item.addend + 0x8000) & 0xFFFFFFFF) >> 16
struct.pack_into("<H", image, offset, value)
elif item.kind == 2:
value = struct.unpack_from("<H", image, offset)[0]
struct.pack_into("<H", image, offset, (value + load_addr) & 0xFFFF)
else:
word = struct.unpack_from("<I", image, offset)[0]
# This is addition to the complete instruction word, matching the
# loader, rather than masking or replacing the encoded jump field.
value = word + (((load_addr << 4) & 0xFFFFFFFF) >> 6)
struct.pack_into("<I", image, offset, value & 0xFFFFFFFF)
return bytes(image), relocations
@@ -0,0 +1,47 @@
"""Synthetic exact-loader and malformed-input checks; no game bytes."""
from pathlib import Path
import struct
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mips_tagged_relocations import parse, relocate
def packed(words):
return struct.pack("<" + "I" * len(words), *words)
class TaggedRelocationTests(unittest.TestCase):
def test_all_four_writes_and_hi16_carry(self):
data = packed([24, 0x28, 0x3C080000, 0x2508F000, 0x0C00000A,
0xDEADBEEF, 4, 8 | 1, 0xF000, 12 | 2, 16 | 3, 0xFFFFFFFF])
image, records = relocate(data, 0x80108000)
self.assertEqual(struct.unpack("<6I", image),
(24, 0x80108028, 0x3C088011, 0x25087000, 0x0C04200A, 0xDEADBEEF))
self.assertEqual([r.kind for r in records], [0, 1, 2, 3])
self.assertEqual(len(data), 48)
def test_no_relocations(self):
image, records = relocate(packed([8, 0x12345678, 0xFFFFFFFF]), 0x80100000)
self.assertEqual(image, packed([8, 0x12345678]))
self.assertEqual(records, ())
def test_rejects_malformed_streams(self):
invalid = [b"", packed([8, 0]), packed([7, 0, 0xFFFFFFFF]),
packed([8, 0, 0]), packed([8, 0, 8, 0xFFFFFFFF]),
packed([8, 0, 5]), packed([8, 0, 4, 4, 0xFFFFFFFF]),
packed([8, 0, 0xFFFFFFFF, 0])]
for data in invalid:
with self.subTest(data=data), self.assertRaises(ValueError):
parse(data)
def test_rejects_unproven_memory_layout(self):
data = packed([8, 0, 0xFFFFFFFF])
for base in [0, 0x80000002, 0x80200000, 0x801FFFFC]:
with self.subTest(base=base), self.assertRaises(ValueError):
relocate(data, base)
if __name__ == "__main__":
unittest.main()