Files
PS5_RetroArch/tests/test_core_loader.py
T
Mihawk 9155dd5c23 Add native Snes9x with verified colours and core-local C++ cleanup
Build pinned libretro Snes9x 1.63 with this project's SDK and official metadata.
Convert final RGB565 frames to independent XRGB8888 storage for the existing
Vulkan upload path. Integrate staging, native imports and loader diagnostics.

Keep C++ destructor registration inside the unloadable core. Validate bounded
fini arrays before constructors and execute cleanup at last-reference close.
Test all 65536 colours through GPU upload, pitches/bounds/cached frames and
C++ destruction/reload/malformed finalizers. Existing core bytes are unchanged.

Verification: bash tools/verify.sh passed format/unit/build/integration/evidence
with 60 tests. bash tools/run-title.sh --no-build --core-test=snes9x --watch 180
passed loader/recovery and remained running until runner close. Owner: Works
flawlessly, answering colours/audio/input and menu/content-transition checks.
Zero Vulkan refusals, GPU API failures or kernel fatal-signal reports.

Evidence: evidence/snes9x-native/{capture,expectation,iterations,abi,build}.json,
loader/recovery reports and exact linked-driver hashes. Replay with
bash tools/verify.sh evidence. Frontend identity:
64bef2e7826e5a63a6644a933e24ef5403d28c754230c0038eb8a76b4baa1c53.
PS5_Vulkan was not modified; special-mode/save-state/performance coverage is
not implied by this ordinary-game acceptance.
2026-09-19 16:19:59 -04:00

256 lines
12 KiB
Python

"""Execute the real loader on small relocated ELFs; reject malformed input."""
import ctypes
from pathlib import Path
import struct
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
class CoreLoader(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.tmp.cleanup)
cls.folder = Path(cls.tmp.name)
c = cls.folder / 'core.c'
c.write_text('''extern int native_add(int);
int counter;
int *pointer = &counter;
int (*call_import)(int) = native_add;
int run(void) { return call_import(++*pointer); }
''')
cls.core = cls.folder / 'fixture.so'
subprocess.run(['cc', '-shared', '-nostdlib', '-fPIC', str(c),
'-Wl,-z,max-page-size=16384', '-Wl,-T,' + str(ROOT / 'tooling/native/ps5-core.ld'),
'-o', str(cls.core)], check=True, capture_output=True)
data = bytearray(cls.core.read_bytes())
data[7] = 9
cls.core.write_bytes(data)
cls.original = bytes(data)
wrapper = cls.folder / 'runtime.cpp'
wrapper.write_text('''#include <cstring>
#include <cstdlib>
#include <pthread.h>
static bool allow = true;
static int events = 0;
static void native_record(int value) { events = events * 10 + value; }
extern "C" void clear_events() { events = 0; }
extern "C" int get_events() { return events; }
extern "C" void allow_import(bool value) { allow = value; }
static int native_add(int value) { return value + 40; }
extern "C" void *ps5_core_import(const char *name) {
if (allow && !std::strcmp(name, "native_add")) return reinterpret_cast<void *>(&native_add);
#define BIND(symbol) if (!std::strcmp(name, #symbol)) return reinterpret_cast<void *>(&symbol)
BIND(native_record); BIND(malloc); BIND(free);
BIND(pthread_mutex_lock); BIND(pthread_mutex_unlock); BIND(pthread_mutex_destroy);
#undef BIND
return nullptr;
}
''')
harness = cls.folder / 'loader.so'
subprocess.run(['c++', '-std=c++20', '-shared', '-fPIC', '-pthread', '-O1', '-g',
'-Wall', '-Wextra', '-Werror', str(ROOT / 'src/core_loader_ps5.cpp'),
str(wrapper), '-o', str(harness)], check=True, capture_output=True)
cls.lib = ctypes.CDLL(str(harness))
cls.lib.ps5_core_dlopen.argtypes = [ctypes.c_char_p, ctypes.c_int]
cls.lib.ps5_core_dlopen.restype = ctypes.c_void_p
cls.lib.ps5_core_dlsym.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
cls.lib.ps5_core_dlsym.restype = ctypes.c_void_p
cls.lib.ps5_core_dlclose.argtypes = [ctypes.c_void_p]
cls.lib.ps5_core_dlerror.restype = ctypes.c_char_p
cls.lib.allow_import.argtypes = [ctypes.c_bool]
def setUp(self):
self.lib.allow_import(True)
def open(self, path):
return self.lib.ps5_core_dlopen(str(path).encode(), 0)
def reject(self, data, reason):
path = self.folder / 'invalid.so'
path.write_bytes(data)
handle = self.open(path)
if handle:
self.lib.ps5_core_dlclose(handle)
self.assertFalse(handle)
self.assertIn(reason, self.lib.ps5_core_dlerror().decode())
def test_relocations_imports_reference_count_and_reload(self):
for _ in range(8):
a = self.open(self.core)
self.assertTrue(a, self.lib.ps5_core_dlerror())
b = self.open(self.core)
self.assertEqual(a, b)
run = ctypes.CFUNCTYPE(ctypes.c_int)(self.lib.ps5_core_dlsym(a, b'run'))
self.assertEqual(run(), 41)
self.assertEqual(self.lib.ps5_core_dlclose(a), 0)
self.assertEqual(run(), 42)
self.assertFalse(self.lib.ps5_core_dlsym(b, b'absent'))
self.assertEqual(self.lib.ps5_core_dlclose(b), 0)
self.assertEqual(self.lib.ps5_core_dlclose(1), -1)
def test_missing_import_is_rejected_then_valid_load_recovers(self):
self.lib.allow_import(False)
self.assertFalse(self.open(self.core))
self.assertIn(b'unresolved native runtime import: native_add', self.lib.ps5_core_dlerror())
self.lib.allow_import(True)
handle = self.open(self.core)
self.assertTrue(handle)
self.lib.ps5_core_dlclose(handle)
def test_bad_header_and_header_table_bounds(self):
self.reject(b'not an ELF', 'file size')
data = bytearray(self.original)
data[7] = 0
self.reject(data, 'expected PS5 ELF64')
data = bytearray(self.original)
struct.pack_into('<Q', data, 32, 2**64 - 8)
self.reject(data, 'header tables')
def test_bad_segments_and_unsupported_tls(self):
phoff = struct.unpack_from('<Q', self.original, 32)[0]
data = bytearray(self.original)
struct.pack_into('<I', data, phoff + 4, 7)
self.reject(data, 'writable-executable')
data = bytearray(self.original)
struct.pack_into('<Q', data, phoff + 8, len(data) + 0x4000)
self.reject(data, 'load segment')
data = bytearray(self.original)
struct.pack_into('<I', data, phoff, 7) # PT_TLS
self.reject(data, 'TLS')
def test_bad_relocation_target_and_encoding(self):
shoff = struct.unpack_from('<Q', self.original, 40)[0]
count = struct.unpack_from('<H', self.original, 60)[0]
offset = next(struct.unpack_from('<Q', self.original, shoff + i * 64 + 24)[0]
for i in range(count)
if struct.unpack_from('<I', self.original, shoff + i * 64 + 4)[0] == 4)
data = bytearray(self.original)
struct.pack_into('<Q', data, offset, 2**64 - 8)
self.reject(data, 'relocation destination')
data = bytearray(self.original)
struct.pack_into('<I', data, offset + 8, 5) # R_X86_64_COPY
self.reject(data, 'unsupported x86-64 relocation')
def test_constructor_runs_once_per_mapping_and_rejects_bad_array(self):
source = self.folder / 'ctor.c'
source.write_text('''int ready;
__attribute__((constructor)) static void init(void) { ++ready; }
int state(void) { return ready; }
''')
path = self.folder / 'ctor.so'
subprocess.run(['cc', '-shared', '-nostdlib', '-fPIC', str(source),
'-Wl,-z,max-page-size=16384', '-Wl,-T,' + str(ROOT / 'tooling/native/ps5-core.ld'),
'-o', str(path)], check=True, capture_output=True)
data = bytearray(path.read_bytes())
data[7] = 9
path.write_bytes(data)
for _ in range(3):
handle = self.open(path)
self.assertTrue(handle, self.lib.ps5_core_dlerror())
other = self.open(path)
self.assertEqual(handle, other)
state = ctypes.CFUNCTYPE(ctypes.c_int)(self.lib.ps5_core_dlsym(handle, b'state'))
self.assertEqual(state(), 1)
self.lib.ps5_core_dlclose(other)
self.lib.ps5_core_dlclose(handle)
phoff = struct.unpack_from('<Q', data, 32)[0]
phcount = struct.unpack_from('<H', data, 56)[0]
dynamic = next(struct.unpack_from('<Q', data, phoff + i * 56 + 8)[0]
for i in range(phcount)
if struct.unpack_from('<I', data, phoff + i * 56)[0] == 2)
at = dynamic
while struct.unpack_from('<q', data, at)[0] != 25: # DT_INIT_ARRAY
self.assertNotEqual(struct.unpack_from('<q', data, at)[0], 0)
at += 16
original = bytearray(data)
init_address = struct.unpack_from('<Q', data, at + 8)[0]
struct.pack_into('<Q', data, at + 8, 2**64 - 8)
self.reject(data, 'initializer array')
data = original
writable = next(struct.unpack_from('<Q', data, phoff + i * 56 + 16)[0]
for i in range(phcount)
if struct.unpack_from('<II', data, phoff + i * 56) == (1, 6))
shoff = struct.unpack_from('<Q', data, 40)[0]
shcount = struct.unpack_from('<H', data, 60)[0]
changed = False
for i in range(shcount):
if struct.unpack_from('<I', data, shoff + i * 64 + 4)[0] != 4:
continue
offset, size = struct.unpack_from('<QQ', data, shoff + i * 64 + 24)
for reloc in range(offset, offset + size, 24):
if struct.unpack_from('<Q', data, reloc)[0] == init_address:
struct.pack_into('<q', data, reloc + 16, writable)
changed = True
self.assertTrue(changed)
self.reject(data, 'initializer callback outside executable segment')
def test_cpp_destructors_run_on_last_close_and_bad_finalizer_rejected(self):
source = self.folder / 'dtor.cpp'
source.write_text('''extern "C" void native_record(int);
struct Object {
int marker;
Object(int value) : marker(value) { native_record(value); }
~Object() { native_record(marker + 1); }
};
static Object a(1), b(3);
''')
path = self.folder / 'dtor.so'
subprocess.run(['c++', '-shared', '-nostdlib', '-fPIC', '-fno-exceptions', '-fno-rtti',
str(source), str(ROOT / 'tooling/native/core_cxx_runtime.cpp'),
'-Wl,--no-eh-frame-hdr',
'-Wl,-z,max-page-size=16384', '-Wl,-T,' + str(ROOT / 'tooling/native/ps5-core.ld'),
'-o', str(path)], check=True, capture_output=True)
data = bytearray(path.read_bytes())
data[7] = 9
path.write_bytes(data)
for _ in range(4):
self.lib.clear_events()
handle = self.open(path)
self.assertTrue(handle, self.lib.ps5_core_dlerror())
other = self.open(path)
self.assertEqual(handle, other)
self.assertEqual(self.lib.get_events(), 13)
self.lib.ps5_core_dlclose(other)
self.assertEqual(self.lib.get_events(), 13)
self.lib.ps5_core_dlclose(handle)
self.assertEqual(self.lib.get_events(), 1342)
# Invalid finalizer is rejected BEFORE any constructor can run.
self.lib.clear_events()
phoff = struct.unpack_from('<Q', data, 32)[0]
phcount = struct.unpack_from('<H', data, 56)[0]
dynamic = next(struct.unpack_from('<Q', data, phoff + i * 56 + 8)[0]
for i in range(phcount)
if struct.unpack_from('<I', data, phoff + i * 56)[0] == 2)
at = dynamic
while struct.unpack_from('<q', data, at)[0] != 26: # DT_FINI_ARRAY
self.assertNotEqual(struct.unpack_from('<q', data, at)[0], 0)
at += 16
fini_address = struct.unpack_from('<Q', data, at + 8)[0]
original = bytes(data)
struct.pack_into('<Q', data, at + 8, 2**64 - 8)
self.reject(data, 'finalizer array')
self.assertEqual(self.lib.get_events(), 0)
data = bytearray(original)
writable = next(struct.unpack_from('<Q', data, phoff + i * 56 + 16)[0]
for i in range(phcount)
if struct.unpack_from('<II', data, phoff + i * 56) == (1, 6))
shoff = struct.unpack_from('<Q', data, 40)[0]
shcount = struct.unpack_from('<H', data, 60)[0]
changed = False
for i in range(shcount):
if struct.unpack_from('<I', data, shoff + i * 64 + 4)[0] != 4:
continue
offset, size = struct.unpack_from('<QQ', data, shoff + i * 64 + 24)
for reloc in range(offset, offset + size, 24):
if struct.unpack_from('<Q', data, reloc)[0] == fini_address:
struct.pack_into('<q', data, reloc + 16, writable)
changed = True
self.assertTrue(changed)
self.reject(data, 'finalizer callback outside executable segment')
self.assertEqual(self.lib.get_events(), 0)