feat(phase-1): add disc extraction and EXE validation

This commit is contained in:
Christopher Williams
2026-09-23 14:30:45 -04:00
parent 58346a4d99
commit 04331bd5b4
6 changed files with 1864 additions and 25 deletions
+9 -24
View File
@@ -2,25 +2,18 @@
Matching decompilation of **Syphon Filter 3 (USA)** for PlayStation. The goal is to rebuild original game binaries from C source with byte-for-byte identical output. A native PC port is out of scope until the matching decompilation is established.
## Current bootstrap state
## Session-start load order
- The legally dumped disc image is local-only: `Syphon Filter 3 (USA).bin`.
- Ghidra 12.1.2 is installed at `/opt/ghidra`.
- The locally built `ghidra_psx_ldr` extension is installed for Ghidra 12.1.2.
- PCSX-Redux is installed and launches normally.
- Do not assume the game serial, executable name, compiler, SDK version, archive format, compression, overlay layout, or RAM load addresses. Establish each from the bytes or runtime observation.
At the start of every session, read these files in order:
## Phase 0 — establish governance before reverse engineering
1. `AGENTS.md` — standing agent rules.
2. `PROJECT_CONTEXT.md` — permanent project constitution.
3. `phase-ends/DIGEST.md` — completed-phase findings.
4. `phase-ends/CURRENT_PHASE.md` — active in-phase state, when present.
5. The active phase plan in `phase-ends/`.
6. Task-relevant records under `docs/`.
The first task is to create the repository governance and state files:
- `.gitignore` and `.gitattributes`
- `PROJECT_CONTEXT.md` (permanent constitution)
- `phase-ends/DIGEST.md` and `phase-ends/CURRENT_PHASE.md`
- `docs/SETUP.md`, `docs/formats.md`, and `docs/memory-map.md`
- a task-level Phase 1 plan
Until those files exist, use this document as the bootstrap authority. Once they exist, update this file only to point to their session-start load order; do not duplicate their evolving state here.
The active plan and current-phase record govern day-to-day execution; do not duplicate their evolving state in this file.
## Mandatory behavior
@@ -65,11 +58,3 @@ Until those files exist, use this document as the bootstrap authority. Once they
- Keep current in-phase state in `phase-ends/CURRENT_PHASE.md`; write a checkpoint before ending a session.
- After every four completed tasks, re-read these mandatory rules and state: `Rules check — re-read complete. Continuing with [next task].`
- A phase closes only after every checklist item is explicitly verified and the developer confirms the milestone. Then write a PhaseEnd file, update the digest, and stop; do not begin the next phase in the same session.
## Initial roadmap
1. Phase 0: repository firewall, documentation/state structure, and disc-image characterization.
2. Phase 1: deterministic extraction, PS-X EXE discovery, and first Ghidra import.
3. Phase 2: runtime loader/overlay and archive-format investigation with PCSX-Redux.
4. Phase 3: matching toolchain, all-assembly rebuild, and compiler fingerprint.
5. Phase 4+: function matching, shared-body infrastructure, and scalable verification.
+19 -1
View File
@@ -13,7 +13,25 @@ The current USA disc image is exactly 294,018 sectors of 2352 bytes. Phase 0 T1
Filesystem/executable extraction may therefore read the BIN with a 2352-byte stride and offset 24 without a CUE. The following remain unverified:
- track count and track boundaries beyond the data filesystem;
- executable filename and format;
- archive, compression, audio, video, and overlay formats.
Do not infer later format details from other Syphon Filter games or other regions.
## PS-X EXE candidate (Phase 1 T3)
The exact ISO9660 identifier `SCUS_946.40;1` from the local USA image was extracted without renaming. `tools/sf3_extract psx-exe-info` validated its `PS-X EXE` magic, 0x800-byte header, declared text bounds, and streaming SHA-1 against ignored `extracted/MANIFEST.tsv`. The header-field offsets were cross-checked against the locally installed loader source, `ghidra_psx_ldr/src/main/java/psx/PsxExe.java`.
| Property | Header field / evidence | Verified value |
|---|---|---|
| File SHA-1 | Full extracted file; matches manifest | `e173426c157384ebf1b6caf8c6fea18a85a14af9` |
| File size | Full extracted file | 1,886,208 bytes |
| Header size | PS-X EXE header | 0x800 (2,048 bytes) |
| Text load address | 0x18 | `0x80010000` |
| Text size | 0x1C | 1,884,160 bytes (`0x001CC000`) |
| Declared text range | load address + text size | `[0x80010000, 0x801DC000)` |
| Entry PC | 0x10 | `0x800FB368` (within the declared text range) |
| Initial GP header field | 0x14 | `0x00000000` |
| SP base header field | 0x30 | `0x801FFFF0` |
| SP offset header field | 0x34 | `0x00000000` |
These are static header declarations from the USA file, not PCSX-Redux observations. In particular, the zero GP and SP-offset values do not establish post-loader register behavior, and the declared text range is not yet a runtime memory-map claim.
+11
View File
@@ -2,4 +2,15 @@
No USA runtime addresses are verified yet.
## Static PS-X EXE header declarations (Phase 1 T3)
These entries are header-declared values from the local USA image's exact ISO9660 file `SCUS_946.40;1`. They are **not** Ghidra-import findings or PCSX-Redux runtime observations.
| Address / field | Purpose | Source build / region | Evidence | Verification status |
|---|---|---|---|---|
| `[0x80010000, 0x801DC000)` | Declared text range | Local USA image | PS-X EXE header offsets 0x18/0x1C, parsed by `tools/sf3_extract`; field layout cross-checked with local `ghidra_psx_ldr` source | Header-valid; runtime load status unverified |
| `0x800FB368` | Declared entry PC | Local USA image | PS-X EXE header offset 0x10 | Header-valid and within declared text range; runtime execution unverified |
| `0x00000000` | Initial GP header field | Local USA image | PS-X EXE header offset 0x14 | Header-valid; not a runtime GP observation |
| `0x801FFFF0` + `0x00000000` | SP base and SP offset header fields | Local USA image | PS-X EXE header offsets 0x30/0x34 | Header-valid; no effective/runtime SP inference made |
Record each future entry with: address range, purpose, source build/region, evidence source (Ghidra or runtime observation), and verification status. Do not transfer addresses from other Syphon Filter releases without explicit USA validation.
+23
View File
@@ -0,0 +1,23 @@
# CURRENT_PHASE — Phase 1: Deterministic Extraction, Executable Discovery, and First Ghidra Import
**Status:** in progress
**Plan:** `phase-ends/Phase1_PLAN.md` (approved by developer)
## Tasks
- [x] **P1-T1 — ISO walker:** added executable, dependency-free `tools/sf3_extract` and synthetic-only `unittest` coverage. The read-only `list` command validates MODE2/2352 sector geometry, ISO9660 descriptors and directory records, extents/bounds, output-path components, and recursive directory extents before returning deterministic paths.
- [x] **P1-T2 — Deterministic extraction:** extended `tools/sf3_extract` with transactional `extract`; it preserves exact ISO identifiers, copies every validated extent from the same open image handle, and writes ignored `extracted/MANIFEST.tsv` with path, first LBA, complete extents, size, and SHA-1. Two fresh direct runs produced identical manifests and file counts.
- [x] **P1-T3 — Executable characterization:** added synthetic-tested `psx-exe-info` validation to `tools/sf3_extract`. Exact ISO file `SCUS_946.40;1` passed PS-X EXE magic/header/text-bound checks and a streaming SHA-1 match against `extracted/MANIFEST.tsv`; permitted metadata is recorded in `docs/formats.md` and static-only header declarations in `docs/memory-map.md`.
- [ ] **P1-T4 — Ghidra import:** import the extracted executable with `ghidra_psx_ldr` into ignored `ghidra/` and record loader findings.
- [ ] **Rules check:** re-read `AGENTS.md` mandatory behavior after P1-T4.
- [ ] **P1-T5 — Reproducibility record:** document commands, results, and remaining unknowns; confirm no ROM-derived material is staged.
## Session checkpoint
Phase 1 opened in a fresh session after the completed Phase 0 close. The approved plan has been loaded, and the session-start load order and active checkpoint have been restored.
P1-T1 is complete. `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tools/tests -v` initially passed 8 synthetic-only walker tests, including malformed record, non-MODE2 sector, out-of-bounds extent, unsafe path, recursive extent, and CLI refusal cases. `./tools/sf3_extract --help` passed as a direct-command smoke check.
P1-T2 is complete. The host path is on native writable btrfs. The expanded synthetic-only suite passed 14 tests, including transactional extraction, multi-extent copying, refusal of a corrupt data sector and existing output, CLI extraction, and two fresh synthetic runs. Two serial fresh runs of `./tools/sf3_extract extract 'disks/Syphon Filter 3 (USA).bin' extracted` produced 96 files and 601,671,468 bytes each; their ignored manifests were byte-identical. All 96 final extracted files were re-hashed against the final manifest successfully. The first-run manifest is retained only in ignored `.run/p1-t2-manifest-run1.tsv`; the second direct run remains in ignored `extracted/`. No generated material is staged.
P1-T3 is complete. The 19-test synthetic-only suite covers PS-X EXE parsing plus bad magic, declared-text-bound, and manifest-hash rejection. `SCUS_946.40;1` passed the header and manifest checks with file SHA-1 `e173426c157384ebf1b6caf8c6fea18a85a14af9`, declared text range `[0x80010000, 0x801DC000)`, and entry PC `0x800FB368`. The GP/SP values in `docs/formats.md` are raw header fields only; no Ghidra import or runtime observation has occurred. The ignored metadata report is `.run/p1-t3-psx-exe-info.tsv`. P1-T4 has not started.
+1146
View File
File diff suppressed because it is too large Load Diff
+656
View File
@@ -0,0 +1,656 @@
"""Synthetic-only tests for ISO9660 extraction and PS-X EXE validation.
These fixtures are assembled in temporary directories. They never inspect the
local disc image or any extracted game material.
"""
from __future__ import annotations
import hashlib
import importlib.machinery
import importlib.util
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
TOOL_PATH = Path(__file__).resolve().parents[1] / "sf3_extract"
def _load_tool() -> object:
loader = importlib.machinery.SourceFileLoader("sf3_extract_under_test", str(TOOL_PATH))
spec = importlib.util.spec_from_loader(loader.name, loader)
if spec is None:
raise RuntimeError("could not create an import specification for sf3_extract")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
return module
sf3_extract = _load_tool()
def _set_both_endian_u16(data: bytearray, offset: int, value: int) -> None:
data[offset : offset + 2] = value.to_bytes(2, "little")
data[offset + 2 : offset + 4] = value.to_bytes(2, "big")
def _set_both_endian_u32(data: bytearray, offset: int, value: int) -> None:
data[offset : offset + 4] = value.to_bytes(4, "little")
data[offset + 4 : offset + 8] = value.to_bytes(4, "big")
def _directory_record(
lba: int,
size: int,
identifier: bytes,
*,
flags: int = 0,
extended_attribute_blocks: int = 0,
) -> bytes:
if not identifier:
raise ValueError("synthetic records require a non-empty identifier")
padding = 1 if len(identifier) % 2 == 0 else 0
record = bytearray(33 + len(identifier) + padding)
record[0] = len(record)
record[1] = extended_attribute_blocks
_set_both_endian_u32(record, 2, lba)
_set_both_endian_u32(record, 10, size)
record[25] = flags
_set_both_endian_u16(record, 28, 1)
record[32] = len(identifier)
record[33 : 33 + len(identifier)] = identifier
return bytes(record)
def _directory_block(records: list[bytes]) -> bytes:
block = bytearray(sf3_extract.LOGICAL_BLOCK_SIZE)
cursor = 0
for record in records:
if cursor + len(record) > len(block):
raise ValueError("synthetic directory records exceed one logical block")
block[cursor : cursor + len(record)] = record
cursor += len(record)
return bytes(block)
def _mode2_sector(user_data: bytes) -> bytes:
if len(user_data) != sf3_extract.LOGICAL_BLOCK_SIZE:
raise ValueError("synthetic user data must occupy one logical block")
sector = bytearray(sf3_extract.RAW_SECTOR_SIZE)
sector[: len(sf3_extract.MODE2_SYNC)] = sf3_extract.MODE2_SYNC
sector[15] = 2
sector[
sf3_extract.USER_DATA_OFFSET : sf3_extract.USER_DATA_OFFSET
+ sf3_extract.LOGICAL_BLOCK_SIZE
] = user_data
return bytes(sector)
def _user_block(prefix: bytes) -> bytes:
if len(prefix) > sf3_extract.LOGICAL_BLOCK_SIZE:
raise ValueError("synthetic prefix exceeds one logical block")
return prefix.ljust(sf3_extract.LOGICAL_BLOCK_SIZE, b"\x00")
def _primary_volume_descriptor(volume_size: int, root_lba: int, root_size: int) -> bytes:
pvd = bytearray(sf3_extract.LOGICAL_BLOCK_SIZE)
pvd[0] = 1
pvd[1:6] = b"CD001"
pvd[6] = 1
_set_both_endian_u32(pvd, 80, volume_size)
_set_both_endian_u16(pvd, 120, 1)
_set_both_endian_u16(pvd, 124, 1)
_set_both_endian_u16(pvd, 128, sf3_extract.LOGICAL_BLOCK_SIZE)
root = _directory_record(
root_lba,
root_size,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
)
pvd[156 : 156 + len(root)] = root
return bytes(pvd)
def _volume_descriptor_terminator() -> bytes:
descriptor = bytearray(sf3_extract.LOGICAL_BLOCK_SIZE)
descriptor[0] = sf3_extract.VOLUME_DESCRIPTOR_TERMINATOR
descriptor[1:6] = b"CD001"
descriptor[6] = 1
return bytes(descriptor)
def _write_image(
path: Path,
*,
root_block: bytes,
directory_blocks: dict[int, bytes] | None = None,
user_blocks: dict[int, bytes] | None = None,
volume_size: int = 32,
root_lba: int = 20,
) -> None:
if len(root_block) != sf3_extract.LOGICAL_BLOCK_SIZE:
raise ValueError("synthetic root block must be one logical block")
blocks = {root_lba: root_block, **(directory_blocks or {})}
for lba, data in (user_blocks or {}).items():
if lba in blocks:
raise ValueError("synthetic user data collides with a directory block")
blocks[lba] = data
sectors = [_mode2_sector(bytes(sf3_extract.LOGICAL_BLOCK_SIZE)) for _ in range(volume_size)]
sectors[sf3_extract.PVD_LBA] = _mode2_sector(
_primary_volume_descriptor(volume_size, root_lba, sf3_extract.LOGICAL_BLOCK_SIZE)
)
sectors[sf3_extract.PVD_LBA + 1] = _mode2_sector(_volume_descriptor_terminator())
for lba, data in blocks.items():
if not 0 <= lba < volume_size:
raise ValueError("synthetic directory LBA is outside the image")
if len(data) != sf3_extract.LOGICAL_BLOCK_SIZE:
raise ValueError("synthetic directory block must be one logical block")
sectors[lba] = _mode2_sector(data)
path.write_bytes(b"".join(sectors))
def _valid_image(path: Path, *, user_blocks: dict[int, bytes] | None = None) -> None:
root_lba = 20
child_lba = 21
root_block = _directory_block(
[
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(22, 5, b"README.TXT;1"),
_directory_record(
child_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"DATA",
flags=sf3_extract.DIRECTORY_FLAG,
),
]
)
child_block = _directory_block(
[
_directory_record(
child_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(23, 7, b"INNER.BIN;1"),
]
)
_write_image(
path,
root_block=root_block,
directory_blocks={child_lba: child_block},
user_blocks=user_blocks,
)
def _set_u32_le(data: bytearray, offset: int, value: int) -> None:
data[offset : offset + 4] = value.to_bytes(4, "little")
def _write_psx_exe_fixture(
root: Path,
*,
relative_path: str = "MAIN.EXE;1",
payload: bytes = b"\x00" * 16,
declared_text_size: int | None = None,
text_load_address: int = 0x80010000,
entry_pc: int | None = None,
initial_gp: int = 0x80020000,
sp_base: int = 0x801FFF00,
sp_offset: int = 0,
magic: bytes = sf3_extract.PSX_EXE_MAGIC,
manifest_sha1: str | None = None,
) -> tuple[Path, Path]:
root.mkdir(parents=True, exist_ok=True)
header = bytearray(sf3_extract.PSX_EXE_HEADER_SIZE)
header[: len(magic)] = magic
_set_u32_le(
header,
sf3_extract.PSX_EXE_INIT_PC_OFFSET,
text_load_address if entry_pc is None else entry_pc,
)
_set_u32_le(header, sf3_extract.PSX_EXE_INIT_GP_OFFSET, initial_gp)
_set_u32_le(header, sf3_extract.PSX_EXE_TEXT_ADDRESS_OFFSET, text_load_address)
_set_u32_le(
header,
sf3_extract.PSX_EXE_TEXT_SIZE_OFFSET,
len(payload) if declared_text_size is None else declared_text_size,
)
_set_u32_le(header, sf3_extract.PSX_EXE_SP_BASE_OFFSET, sp_base)
_set_u32_le(header, sf3_extract.PSX_EXE_SP_OFFSET_OFFSET, sp_offset)
executable = root / relative_path
executable.parent.mkdir(parents=True, exist_ok=True)
contents = bytes(header) + payload
executable.write_bytes(contents)
digest = manifest_sha1 if manifest_sha1 is not None else hashlib.sha1(contents).hexdigest()
manifest = root / sf3_extract.MANIFEST_FILENAME
manifest.write_text(
sf3_extract.MANIFEST_HEADER
+ f"{relative_path}\t24\t24:{len(contents)}\t{len(contents)}\t{digest}\n",
encoding="ascii",
)
return executable, manifest
class Sf3ExtractTests(unittest.TestCase):
def synthetic_path(self) -> Path:
temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(temporary_directory.cleanup)
return Path(temporary_directory.name) / "synthetic-mode2.bin"
def test_walks_nested_tree_in_deterministic_path_order(self) -> None:
image = self.synthetic_path()
_valid_image(image)
entries = sf3_extract.walk_image(image)
self.assertEqual(
[
(
entry.path,
entry.is_directory,
entry.size,
[(extent.lba, extent.size) for extent in entry.extents],
)
for entry in entries
],
[
("DATA", True, 2048, [(21, 2048)]),
("DATA/INNER.BIN;1", False, 7, [(23, 7)]),
("README.TXT;1", False, 5, [(22, 5)]),
],
)
def test_list_command_uses_validated_walker(self) -> None:
image = self.synthetic_path()
_valid_image(image)
completed = subprocess.run(
[sys.executable, str(TOOL_PATH), "list", str(image)],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(
completed.stdout.splitlines(),
[
"D\tDATA\t2048\t21:2048",
"F\tDATA/INNER.BIN;1\t7\t23:7",
"F\tREADME.TXT;1\t5\t22:5",
],
)
self.assertEqual(completed.stderr, "")
def test_rejects_non_mode2_primary_volume_sector(self) -> None:
image = self.synthetic_path()
_valid_image(image)
with image.open("r+b") as handle:
handle.seek((sf3_extract.PVD_LBA * sf3_extract.RAW_SECTOR_SIZE) + 15)
handle.write(b"\x01")
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "not MODE2"):
sf3_extract.walk_image(image)
def test_rejects_malformed_directory_record(self) -> None:
root_lba = 20
root_block = bytearray(
_directory_block(
[
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
]
)
)
root_block[68] = 20
image = self.synthetic_path()
_write_image(image, root_block=bytes(root_block))
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "shorter than 34"):
sf3_extract.walk_image(image)
def test_rejects_out_of_bounds_file_extent(self) -> None:
root_lba = 20
root_block = _directory_block(
[
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(32, 1, b"OUT.BIN;1"),
]
)
image = self.synthetic_path()
_write_image(image, root_block=root_block)
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "outside volume size"):
sf3_extract.walk_image(image)
def test_rejects_unsafe_path_component(self) -> None:
root_lba = 20
root_block = _directory_block(
[
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(22, 1, b"../ESCAPE;1"),
]
)
image = self.synthetic_path()
_write_image(image, root_block=root_block)
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "unsafe path component"):
sf3_extract.walk_image(image)
def test_rejects_recursive_directory_extent(self) -> None:
root_lba = 20
child_lba = 21
root_block = _directory_block(
[
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
child_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"DATA",
flags=sf3_extract.DIRECTORY_FLAG,
),
]
)
child_block = _directory_block(
[
_directory_record(
child_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"LOOP",
flags=sf3_extract.DIRECTORY_FLAG,
),
]
)
image = self.synthetic_path()
_write_image(image, root_block=root_block, directory_blocks={child_lba: child_block})
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "cycle or alias refused"):
sf3_extract.walk_image(image)
def test_cli_refuses_corrupt_input_loudly(self) -> None:
image = self.synthetic_path()
_valid_image(image)
with image.open("r+b") as handle:
handle.seek((sf3_extract.PVD_LBA * sf3_extract.RAW_SECTOR_SIZE) + 15)
handle.write(b"\x01")
completed = subprocess.run(
[sys.executable, str(TOOL_PATH), "list", str(image)],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(completed.returncode, 2)
self.assertIn("sf3_extract: error:", completed.stderr)
self.assertIn("not MODE2", completed.stderr)
self.assertEqual(completed.stdout, "")
def test_extracts_files_and_writes_deterministic_manifest(self) -> None:
image = self.synthetic_path()
readme = b"hello"
inner = b"content"
_valid_image(image, user_blocks={22: _user_block(readme), 23: _user_block(inner)})
output = image.parent / "extracted"
result = sf3_extract.extract_image(image, output)
self.assertEqual(result.output_root, output.resolve())
self.assertEqual(result.manifest_path, output.resolve() / sf3_extract.MANIFEST_FILENAME)
self.assertEqual((result.file_count, result.byte_count), (2, len(readme) + len(inner)))
self.assertEqual((output / "README.TXT;1").read_bytes(), readme)
self.assertEqual((output / "DATA" / "INNER.BIN;1").read_bytes(), inner)
self.assertEqual(
result.manifest_path.read_text(encoding="ascii"),
"".join(
[
sf3_extract.MANIFEST_HEADER,
"DATA/INNER.BIN;1\t23\t23:7\t7\t"
f"{hashlib.sha1(inner).hexdigest()}\n",
"README.TXT;1\t22\t22:5\t5\t"
f"{hashlib.sha1(readme).hexdigest()}\n",
]
),
)
def test_two_fresh_extractions_have_identical_manifests(self) -> None:
image = self.synthetic_path()
_valid_image(
image,
user_blocks={22: _user_block(b"hello"), 23: _user_block(b"content")},
)
first = image.parent / "first"
second = image.parent / "second"
first_result = sf3_extract.extract_image(image, first)
second_result = sf3_extract.extract_image(image, second)
self.assertEqual(first_result.file_count, second_result.file_count)
self.assertEqual(
first_result.manifest_path.read_bytes(), second_result.manifest_path.read_bytes()
)
self.assertEqual(
(first / "README.TXT;1").read_bytes(), (second / "README.TXT;1").read_bytes()
)
self.assertEqual(
(first / "DATA" / "INNER.BIN;1").read_bytes(),
(second / "DATA" / "INNER.BIN;1").read_bytes(),
)
def test_extract_copies_all_multi_extent_data(self) -> None:
root_lba = 20
root_block = _directory_block(
[
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x00",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(
root_lba,
sf3_extract.LOGICAL_BLOCK_SIZE,
b"\x01",
flags=sf3_extract.DIRECTORY_FLAG,
),
_directory_record(22, 3, b"SPLIT.BIN;1", flags=sf3_extract.MULTI_EXTENT_FLAG),
_directory_record(23, 2, b"SPLIT.BIN;1"),
]
)
image = self.synthetic_path()
_write_image(
image,
root_block=root_block,
user_blocks={22: _user_block(b"abc"), 23: _user_block(b"de")},
)
output = image.parent / "multi"
result = sf3_extract.extract_image(image, output)
self.assertEqual((result.file_count, result.byte_count), (1, 5))
self.assertEqual((output / "SPLIT.BIN;1").read_bytes(), b"abcde")
self.assertIn("SPLIT.BIN;1\t22\t22:3,23:2\t5\t", result.manifest_path.read_text())
def test_extract_refuses_bad_file_sector_without_installing_output(self) -> None:
image = self.synthetic_path()
_valid_image(image)
with image.open("r+b") as handle:
handle.seek((22 * sf3_extract.RAW_SECTOR_SIZE) + 15)
handle.write(b"\x01")
output = image.parent / "failed"
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "not MODE2"):
sf3_extract.extract_image(image, output)
self.assertFalse(output.exists())
self.assertEqual(list(image.parent.glob(".failed.tmp-*")), [])
def test_extract_refuses_existing_destination(self) -> None:
image = self.synthetic_path()
_valid_image(image)
output = image.parent / "existing"
output.mkdir()
marker = output / "keep"
marker.write_text("do not overwrite", encoding="ascii")
with self.assertRaisesRegex(sf3_extract.IsoValidationError, "refusing to overwrite"):
sf3_extract.extract_image(image, output)
self.assertEqual(marker.read_text(encoding="ascii"), "do not overwrite")
def test_extract_command_uses_transactional_extractor(self) -> None:
image = self.synthetic_path()
_valid_image(
image,
user_blocks={22: _user_block(b"hello"), 23: _user_block(b"content")},
)
output = image.parent / "cli-extracted"
completed = subprocess.run(
[sys.executable, str(TOOL_PATH), "extract", str(image), str(output)],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn("extracted 2 files (12 bytes)", completed.stdout)
self.assertTrue((output / sf3_extract.MANIFEST_FILENAME).is_file())
self.assertEqual(completed.stderr, "")
def test_characterizes_manifest_tracked_psx_exe(self) -> None:
root = self.synthetic_path().parent / "psx-exe"
executable, manifest = _write_psx_exe_fixture(root)
info = sf3_extract.characterize_psx_exe(executable, manifest)
self.assertEqual(info.path, "MAIN.EXE;1")
self.assertEqual(info.file_size, sf3_extract.PSX_EXE_HEADER_SIZE + 16)
self.assertEqual(info.text_load_address, 0x80010000)
self.assertEqual(info.text_size, 16)
self.assertEqual(info.entry_pc, 0x80010000)
self.assertEqual(info.initial_gp, 0x80020000)
self.assertEqual(info.sp_base, 0x801FFF00)
self.assertEqual(info.sp_offset, 0)
self.assertEqual(info.sha1, hashlib.sha1(executable.read_bytes()).hexdigest())
def test_rejects_psx_exe_with_bad_magic(self) -> None:
root = self.synthetic_path().parent / "bad-magic"
executable, manifest = _write_psx_exe_fixture(root, magic=b"NOT EXE!")
with self.assertRaisesRegex(sf3_extract.PsxExeValidationError, "magic is missing"):
sf3_extract.characterize_psx_exe(executable, manifest)
def test_rejects_psx_exe_text_beyond_file_payload(self) -> None:
root = self.synthetic_path().parent / "bad-bounds"
executable, manifest = _write_psx_exe_fixture(root, declared_text_size=32)
with self.assertRaisesRegex(sf3_extract.PsxExeValidationError, "exceeds payload size"):
sf3_extract.characterize_psx_exe(executable, manifest)
def test_rejects_psx_exe_when_manifest_hash_differs(self) -> None:
root = self.synthetic_path().parent / "bad-hash"
executable, manifest = _write_psx_exe_fixture(root, manifest_sha1="0" * 40)
with self.assertRaisesRegex(sf3_extract.PsxExeValidationError, "SHA-1 does not match"):
sf3_extract.characterize_psx_exe(executable, manifest)
def test_psx_exe_info_command_uses_header_and_manifest_validation(self) -> None:
root = self.synthetic_path().parent / "psx-cli"
executable, manifest = _write_psx_exe_fixture(root)
completed = subprocess.run(
[sys.executable, str(TOOL_PATH), "psx-exe-info", str(executable), str(manifest)],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn("text_load_address\t0x80010000", completed.stdout)
self.assertIn("entry_pc\t0x80010000", completed.stdout)
self.assertIn("initial_gp\t0x80020000", completed.stdout)
self.assertIn("sp_base\t0x801FFF00", completed.stdout)
self.assertEqual(completed.stderr, "")
if __name__ == "__main__":
unittest.main()