diff --git a/docs/ACTIVE.md b/docs/ACTIVE.md index dc417f3..792ed30 100644 --- a/docs/ACTIVE.md +++ b/docs/ACTIVE.md @@ -4,79 +4,92 @@ _Updated: 2026-09-19_ ## Now -**FCEUmm build and metadata staging pass; native core loading is blocked.** -The user requested a core build script using this project's SDK, with manual -core/game selection for console testing. This follows that request rather than -continuing the earlier input step. No PS5_Vulkan changes were needed. +**Native FCEUmm loading and first gameplay are verified.** The owner answered +“Works flawlessly!” to the manual video/audio/controls test. This implements the +requested native core loading and failed-load recovery step. No PS5_Vulkan +changes were made by this task. The sibling advanced independently during the +work (currently **085c632**); the accepted title identity includes its exact +linked archive bytes and is not attributed to an assumed driver commit. -`make fceumm` fetches SHA-256-verified source and metadata, builds with this -project's cross SDK, checks the shared ELF and stages the `.so` and `.info`. -`tools/build-title.sh` includes them in the title. Metadata is placed in both -`info/` and `cores/`: the owner's saved empty core-info path uses the latter. -Deployment refreshes the metadata cache and strictly checks core SHA-256. +The title loads this pipeline's shared ELF through a bounded in-process loader, +with explicit native runtime imports and separate RX/R/RW segments. Missing or +unsupported cores return an error. Menu selection checks success before tearing +down the frontend; a late initialization failure stops driver polling safely. +FCEUmm's XRGB8888 frames are converted to matching RGBA8 staging/sampled textures +and presented through the existing Vulkan path. ## Verified build and evidence -- Frontend identity: `261d33099adec97c231205beb21d6011e1587436df099d4e6ac6f29949c50c8f`. -- FCEUmm upstream revision: `236ccdfc911e84c60fea6b9d0699c2d440a8de14`. -- Core SHA-256: `d1afcd2ea84de627f4a28d8ec4e81d63d23ca5b531811ba57357d98f8b31759f`. -- Core size 4,836,144 bytes; ELF64 x86-64 ET_DYN FreeBSD ABI, 25 callbacks, - 16 KiB-compatible segments, native libc/Posix/kernel_web imports. -- Repeated fresh builds give the same hash after replacing Clang's random - PS5 build UUID with `--build-id=sha1`. FTP returns exactly those core bytes. -- Console command: `tools/run-title.sh --no-build --watch 120`; owner manually - selected core/content. The decoded console cache reports FCEUmm's full name, - `fds|nes|unif|unf` and `has_info: true`. Metadata discovery is verified. -- All five host gates pass; 45 tests. Evidence: `evidence/fceumm-build/` includes - ABI report and the failed runtime result. This is not playable-core acceptance. -- Raw logs: `klog/fceumm-metadata-run.log`, `klog/retroarch-135742.log`, - `klog/run-PPSA99169-135742.log`. User filenames remain in ignored captures. -- PS5_Vulkan remains unchanged at **6f0ce0d**. +- Frontend identity: `9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5`. +- FCEUmm source revision: `236ccdfc911e84c60fea6b9d0699c2d440a8de14`. +- Core SHA-256: `fec7dc4eb7ec6cae937ae778f0b59365d8bd7570927a73353424345e13589d9c`. +- Core size: 4,836,368 bytes; 25 callbacks; 71 native bindings; 16,001 + relocations; mapped image 9,076,736 bytes. No payload loader hooks/static core. +- All five host gates pass, with 52 tests. `evidence/native-core-loading/` + contains loader/recovery reports, ABI report, failed iterations and acceptance. +- Final command: `tools/run-title.sh --no-build --core-test --watch 180`. + Eight load/export/API/identity/unload cycles passed. A second load with XMB + resident passed. Missing-core selection and content startup were rejected + through actual task functions while preserving the initialized menu context. +- The owner manually loaded an NES archive. The core negotiated XRGB8888, + 256x224, 60.10 Hz and 48 kHz audio. The declared rate is not a speed measurement. +- Current-build trace: three launches, zero Vulkan refusals, zero GPU API failure + records; kernel capture has zero fatal signals. The owner closed/reopened the + title, and the runner closed a later menu instance. Do not describe this as + 180 seconds of uninterrupted gameplay. +- Raw captures: `klog/native-core-game-rgba-run.log`, + `klog/run-PPSA99169-143921.log`, `klog/core-rgba-live-retroarch.log` and + `klog/core-rgba-final-trace.txt`. The live frontend log preserves the gameplay + session; later menu launches replaced `/app0/retroarch.log`. -## Open target blockers +## Limits and remaining acceptance -1. **Core open fails:** both manual tests log `Failed to open libretro core` - for `/app0/cores/fceumm_libretro.so`, followed by `Error(s): (null)`. - Correct metadata and a byte-identical upload do not resolve native loading. -2. **Frontend recovery crashes:** after the owner starts content, initialization - reports that the dynamic core path is unset. The main loop then calls - `vulkan_alive` with null context and faults reading address `0x80`. - RIP `0xab3066`, image base `0x400000`, ELF offset `0x6b3066`; symbolized with - `addr2line -Cfipe build/llvm-pie.elf 0x6b3066 0xae4774 0xad7db3 0x3df4 0x190`. - The owner explicitly confirmed this crash; it was not a manual close. - -These failures are recorded rather than waived as core success. Build/staging is -verified, but M3 core execution remains incomplete. No game frames/audio/saves -were verified. Implementation and acceptance plan: `parked/native-core-loading/`. -Do not substitute a static core, donor SDK or CPU video route to hide the blocker. +- Return from gameplay to XMB and repeated game unload/reload are **not verified**: + the owner had not configured a menu shortcut and manually closed the app. + Repeated ELF load/unload is verified separately by the diagnostic. +- Save RAM, save states, long-run A/V sync and measured core performance remain + separate milestones. RGB565 content, other cores, runahead and multiple + controllers are not covered by this FCEUmm acceptance. +- The loader explicitly rejects TLS, constructors/finalizers, unsupported + relocation types and additional shared dependencies. It is not a general + system dynamic linker; a faulty core can still crash the shared process. +- Failed-core guards cover the tested menu selection/new-content paths. The + late-initialization safeguard exits instead of polling freed drivers; recovery + from every possible content/core callback failure is not claimed. +- Tolerated log: `GET_VARIABLE: fceumm_hdpacks - Not implemented.` The optional + HD-pack query returns false and upstream uses its default before checking for + a pack. No pack is shipped/tested. Core-option/HD-pack support needs separate + validation; this message did not prevent the accepted standard NES game. +- The old stdio/heap file read failed after XMB initialized. Bounded POSIX reads + into mapped memory fixed the observed failure; the precise libc/allocator + cause was not isolated. Earlier failed runs remain in the evidence history. ## Preserved platform functionality -Native joypad/menu analog navigation and binding capture were confirmed by the -owner in `evidence/native-joypad/`. Native audio and buffering are verified in -`evidence/native-audio/`; real-core audio and long-run A/V sync remain unverified. +Native joypad/menu analog navigation and binding capture are verified in +`evidence/native-joypad/`. Native audio/buffering are in `evidence/native-audio/`. XMB stays default with statically linked Vulkan; `video_ps5` and RGUI remain -registered/selectable. Single-level menu textures remain the verified path. -Useful frontend/core/audio/error logs are retained without frame-success chatter. +registered/selectable. Useful frontend/core/audio/error logging is retained. Config saving and directory browsing are verified in `evidence/native-paths/`. -Managed directories are repaired to `0777`; FTP upload/readback/cleanup was -verified in `evidence/ftp-directory-permissions/`. Deployments preserve live config -and user content. Input remains limited to the initial user's single controller; -rumble, multiple controllers and persistent remapping need separate acceptance. +Managed directories are repaired to `0777`; FTP upload/readback/cleanup is in +`evidence/ftp-directory-permissions/`. Deployments preserve live config/content. ## User file locations FTP base: `/data/homebrew/PPSA99169/`; in-title mount: `/app0`. Use `cores/` for compatible cores, `content/` for games, `system/` for BIOS, `config/retroarch.cfg` for settings, and `savefiles/` / `savestates/` for saves. -The build now ships experimental FCEUmm and metadata, but no game or BIOS. -See `docs/DEPLOYMENT.md` for build/deploy procedures and file locations. +The build ships FCEUmm and `.info` metadata in both `info/` and `cores/`, with +cache refresh on deployment. No game or BIOS is shipped. `make fceumm` builds +only the core; see `docs/DEPLOYMENT.md` for procedures. ## Operating notes -The user authorized uploads/runs without further permission and necessary driver -fixes with documentation plus a PS5_Vulkan commit. Never interrupt an existing -title. The runner checks idle before upload and launch. Core/game selection is -manual at the user's request; collect retroarch.log afterward. For appended trace -files, select the last `bss check=` substring and verify the build identity. +Uploads/runs are authorized without further permission. Never interrupt an +existing title; the runner checks idle before upload/launch. Core/game selection +is manual. Keep private filenames and console details in ignored captures. +Trace appends across launches: separate `bss check=` sections and verify identity. +A manual close/relaunch may replace the frontend log during a capture window; +save the live gameplay log before it is lost. Do not infer gameplay duration +from the runner's final process count. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6c3e8b3..c6e1517 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -194,3 +194,11 @@ After uploading each `.info`, deployment writes and reads back RetroArch's `core_info.refresh` marker in the same directory. RetroArch consumes it when rebuilding its metadata cache; it is not a shipped manifest file. This also refreshes entries previously cached as having no metadata. + + +For a no-game native loader/recovery diagnostic, use +`tools/run-title.sh --no-build --core-test --watch 30` after the host gates pass. +For manual core/game testing, launch with `--no-build --watch 180` (also +`--no-deploy` if the same build is already verified on the console). The owner +selects the game; collect `retroarch.log`, `trace.txt` and the kernel capture. +The loader's supported ELF/runtime contract is in `docs/REFERENCE.md`. diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index f3384d9..52c2046 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -2673,3 +2673,38 @@ context at `vulkan_alive+0x26` (ELF 0x6b3066) reading address 0x80. The owner confirmed the crash. This is not evidence that FCEUmm reached emulation or that libps5vk refused GPU work. Keep loader and error-recovery acceptance open. Evidence: `evidence/fceumm-build/`; next work: `parked/native-core-loading/`. + +## 2026-09-19: native ELF core loading and the first software-core frame + +The native title's payload-oriented dlfcn route did not open the staged FCEUmm +ELF. The local native converter also refuses application exports. A no-game +probe using public anonymous mmap, RW-to-RX mprotect and a return-42 function +succeeded (`klog/run-PPSA99169-141420.log`). This supported implementing a bounded +application ELF loader without websrv hooks, a donor SDK or kernel changes. + +The core now uses `tooling/native/ps5-core.ld` to separate 16 KiB RX/R/RW pages; +`-T` is required because the previous shared layout combined code and data. +Runtime imports are explicit title addresses generated from the core's dynamic +symbols, including this port's directory adapters. Bounds, relocations, imports +and final protection are checked before publishing a reference-counted handle. +TLS/constructors/unwind support is deliberately outside this first contract. + +Eight pre-frontend load/API/identity/unload cycles passed, but the first manual +selection failed reading the same file after XMB started. That diagnostic did +not cover the failing lifecycle stage. Replacing the single stdio/heap read with +bounded POSIX reads into mapped memory resolved the observed failure. The exact +libc/allocator cause remains unisolated; the new diagnostic loads with the full +frontend resident and tests rejection through the actual menu task functions. + +The next manual game start reached FCEUmm's video callback and aborted inside +`vulkan_copy_staging_to_dynamic`: a BGRA staging buffer differed from the sampled +RGBA image, selecting the RGB565-only compute branch. The core-frame upload now +requests RGBA8 for both textures and converts XRGB8888 channel order/alpha while +respecting row pitches. This is the existing GPU presentation route, not a switch +to CPU video. Tests exercise colours, padding and in-place writes. + +The accepted build is recorded in `evidence/native-core-loading/`. The owner +confirmed flawless gameplay and reported manual close because no return-to-menu +shortcut was set. The capture contains three current-build launches with zero +Vulkan refusals, GPU failure records or fatal signals; it is not a continuous +180-second gameplay measurement. Repeated game unload and saves remain untested. diff --git a/docs/PHASE_LOG.md b/docs/PHASE_LOG.md index 992e912..29d724b 100644 --- a/docs/PHASE_LOG.md +++ b/docs/PHASE_LOG.md @@ -2017,3 +2017,55 @@ Host verification: `tools/verify.sh` passes all five gates, 45 tests. Artifact: failed target result. `parked/native-core-loading/README.md` records the remaining loader/recovery work and its acceptance. PS5_Vulkan unchanged at 6f0ce0d; no ROM or BIOS is distributed. This commit completes build tooling, not a playable port. + +## 2026-09-19: native FCEUmm loader, failure guards and first gameplay + +Implemented the requested native loading/recovery step. The loader maps checked +ELF64 segments, binds 71 explicit native imports, applies 16,001 relocations and +publishes handles only after protection succeeds. FCEUmm remains a dynamically +loaded core built with this project's SDK; PS5_Vulkan remains unchanged at +6f0ce0d. Core upload uses matching RGBA8 textures with an XRGB8888 conversion. + +Runs and failures: + +- `tools/run-title.sh --no-build --core-test --watch 30`, memory probe, + `klog/run-PPSA99169-141420.log`: anonymous RW -> RX code returned 42; full window. +- Same command, `klog/run-PPSA99169-142423.log`: eight real-core load/export/API/ + identity/unload cycles passed before frontend startup; full window. +- `tools/run-title.sh --no-build --no-deploy --watch 180`, + `klog/run-PPSA99169-142526.log`: menu-time core file read failed; owner saw + No Core/archive failure and confirmed manual quit/close. Kernel exit(0) then + SIGSYS is retained, not classified as a core execution crash. +- Mapped/chunked-read diagnostic, `klog/run-PPSA99169-143443.log`: startup and + live-menu loader/rejection reports passed. Manual game start then aborted in + `vulkan_copy_staging_to_dynamic` (ELF offset 0x6b87ff, ce7176a7 build). + Symbolization: `addr2line -Cfipe klog/native-core-ce7176a7.elf 0x6b87ff + 0x6b3880 0x6b0129 0xae8f1b 0xada6fd 0x620a`. The format mismatch selected the + RGB565-only compute path; matching RGBA core textures resolve it. +- `tools/verify.sh`: PASS, all five gates, 52 tests; log + `/tmp/core-frame-verify.log`. Loader tests execute relocations/imports and + malformed-file rejection; recovery test fails on upstream's unchecked return + and passes with the guard; frame test verifies channels/alpha/pitch/in-place. +- `tools/run-title.sh --no-build --core-test --watch 180`, + `klog/native-core-game-rgba-run.log`, kernel `klog/run-PPSA99169-143921.log`: + identity 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5; + core SHA-256 fec7dc4eb7ec6cae937ae778f0b59365d8bd7570927a73353424345e13589d9c. + Both diagnostic JSON reports pass. Owner manually loaded the archive and + answered “Works flawlessly!” to video/audio/controls, then reported closing + manually because the menu shortcut was unset. Three launches occurred within + the capture; the runner closed a later menu instance. Zero GPU refusals/API + failure records and zero kernel fatal signals. Do not claim continuous runtime. + +Evidence: `evidence/native-core-loading/`, replay with `tools/verify.sh evidence`. +Raw gameplay log was saved before a later menu launch replaced it. No ROM or +private filename is committed. Return-to-menu shortcut, repeated game unload, +saves/states and measured performance are not included in this acceptance. + +Provenance correction for the preceding entry: this task did not edit +PS5_Vulkan, but its checkout advanced independently during the work to 8c2985a +and then 085c632. “Remains unchanged at 6f0ce0d” is therefore not a valid claim +about the sibling checkout or the linked driver revision. The accepted title +identity includes the actual archive bytes read at build time; no driver source +commit is inferred from the later checkout. Its archive changed after the +accepted title linked, so the accepted dist/ artifact has not been rebuilt +against that newer archive. Console acceptance remains tied to identity 9b287ca7. diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index ea47cfa..3019e91 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -374,6 +374,9 @@ host build. The linker is the compiler driver because upstream passes `-Wl` options; `-nostdlib -nodefaultlibs` excludes payload CRT/static libc defaults. The `--build-id=sha1` override replaces Clang's random PS5 UUID with a content-derived ELF ID so repeated builds have stable hashes. +`-T tooling/native/ps5-core.ld` separates executable, read-only and writable +segments on 16 KiB boundaries; the loader never maps writable executable pages. +The core checker rejects a writable executable load segment. Math and libc imports come from the native runtime stubs, with `libkernel_web` rather than the payload `libkernel_sys`. No SDL or decoder is added. @@ -399,3 +402,42 @@ After uploading each `.info`, deployment writes and reads back RetroArch's `core_info.refresh` marker in the same directory. RetroArch consumes it when rebuilding its metadata cache; it is not a shipped manifest file. This also refreshes entries previously cached as having no metadata. + + +## Native in-process core loader + +`src/core_loader_ps5.cpp` supplies the dynamic-library operations used by the +frontend. It loads this pipeline's ELF64 x86-64 FreeBSD-ABI shared cores into +anonymous memory, resolves their imports against explicit title bindings, +applies checked RELA relocations, then protects each segment as RX, R or RW. +File reads use bounded POSIX reads into a separate mapped buffer. Failed opens +release allocations and report the failing operation; successful handles are +reference counted and unmapped on the final close. + +`tools/core-imports.py` derives the required native bindings from the staged +FCEUmm ELF; those bindings and the core bytes participate in the title build +identity. Directory imports use this port's existing directory adapters. The +runtime dependencies are limited to the public kernel_web, libc and Posix stubs. +This route does not depend on websrv's payload loader hooks or publish native +module exports through the title converter. + +The initial supported contract is deliberately narrower than a general dynamic +linker: no TLS, ELF interpreter, constructors/finalizers, C++ unwind registration, +REL/RELR, or additional dependent shared libraries. Unsupported imports and +relocations fail explicitly. Adding another core requires reviewing this +contract and extending both host tests and target diagnostics as needed. + +Core selection checks load success before content startup tears down drivers. +The main loop also refuses to poll drivers after frontend initialization fails. +This protects against load failures; it cannot isolate a fault inside arbitrary +core code running in the same process. + + +Software core frames in libretro XRGB8888 are converted by +`src/core_frame_ps5.cpp` to RGBA8 bytes with opaque alpha, respecting both row +pitches and in-place framebuffers. The Vulkan frontend requests RGBA8 for both +staging and sampled core textures. This extends the menu's matching-format +upload rule to actual content, avoiding the RGB565-only compute branch. Frame +scaling and presentation remain on the Vulkan GPU path; `video_ps5` stays +registered as the selectable fallback. RGB565 core frames retain their existing +path and require separate content acceptance. diff --git a/docs/TESTING.md b/docs/TESTING.md index ed099b1..8f4a4e2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -181,3 +181,34 @@ core load, content initialization and presented frames separately; a visible core entry or `.info` name is not proof of loaded executable code. Owner game paths remain in ignored logs. Keep normal XMB, Vulkan and audio startup working when no core is selected. + + +## Native core loader diagnostics + +`tests/test_core_loader.py` compiles and executes the real loader against a small +host ELF fixture. It tests relocation/import execution, reference counts, +reloads, missing imports, malformed header/table bounds, forbidden segment flags, +TLS, and invalid relocation targets/types. The fixture's OSABI byte is adjusted +only for this host test; shipped cores must pass the actual cross-build checker. +`tests/test_core_recovery.py` runs the upstream core-selection function before +and after the patch and checks rejection without marking the core as selected. + +`tools/run-title.sh --no-build --core-test --watch 30` arms an opt-in no-game +console diagnostic. It executes eight FCEUmm load/symbol/API/identity/unload +cycles, rejects a missing core/export, and loads the core again with the frontend +resident. It then attempts missing-core selection/content startup through the +actual task functions and checks that the initialized menu context survives. +The runner collects `core-loader-test.json` and `core-recovery-test.json`, checks +their build identity and pass status, captures logs, and closes the title. Normal +runs clear the control file; diagnostics never run without the flag. + +These diagnostics do not establish gameplay. The owner selects the core and +content manually in a separate captured run and confirms video, audio, controls +and return to XMB. Keep user content paths in ignored captures, not evidence. + + +`tests/test_core_frame.py` executes the actual XRGB8888 upload conversion with +red/green/blue and mixed colours, checks opaque alpha, distinct input/output row +pitches, untouched padding and in-place conversion. Real-core acceptance also +requires a trace with no Vulkan refusals or failed command buffers, rather than +inferring correctness from the core's successful initialization. diff --git a/evidence/native-core-loading/abi.json b/evidence/native-core-loading/abi.json new file mode 100644 index 0000000..0fc1e42 --- /dev/null +++ b/evidence/native-core-loading/abi.json @@ -0,0 +1,126 @@ +{ + "sha256": "fec7dc4eb7ec6cae937ae778f0b59365d8bd7570927a73353424345e13589d9c", + "bytes": 4836368, + "elf": "ELF64 x86-64 ET_DYN FreeBSD/PS5", + "needed": [ + "libkernel_web.sprx", + "libSceLibcInternal.sprx", + "libScePosixForWebKit.sprx" + ], + "libretro_exports": [ + "retro_api_version", + "retro_cheat_reset", + "retro_cheat_set", + "retro_deinit", + "retro_get_memory_data", + "retro_get_memory_size", + "retro_get_region", + "retro_get_system_av_info", + "retro_get_system_info", + "retro_init", + "retro_load_game", + "retro_load_game_special", + "retro_reset", + "retro_run", + "retro_serialize", + "retro_serialize_size", + "retro_set_audio_sample", + "retro_set_audio_sample_batch", + "retro_set_controller_port_device", + "retro_set_environment", + "retro_set_input_poll", + "retro_set_input_state", + "retro_set_video_refresh", + "retro_unload_game", + "retro_unserialize" + ], + "undefined_symbols": [ + "__error", + "__isthreaded", + "atan", + "atof", + "atoi", + "calloc", + "close", + "closedir", + "cos", + "exp", + "fclose", + "ferror", + "fflush", + "fileno", + "fopen", + "fread", + "free", + "fseeko", + "ftello", + "ftruncate", + "fwrite", + "isspace", + "isxdigit", + "ldexp", + "localtime", + "log", + "lseek", + "malloc", + "mbstowcs", + "memchr", + "memcmp", + "memcpy", + "memmove", + "memset", + "mkdir", + "open", + "opendir", + "pow", + "qsort", + "read", + "readdir", + "realloc", + "remove", + "rename", + "setlocale", + "setvbuf", + "sin", + "sincos", + "snprintf", + "sprintf", + "sscanf", + "stat", + "strchr", + "strcmp", + "strdup", + "strftime", + "strlen", + "strncmp", + "strncpy", + "strrchr", + "strstr", + "strtod", + "strtok", + "strtoul", + "time", + "tolower", + "toupper", + "vsnprintf", + "wcslen", + "wcstombs", + "write" + ], + "load_segments": [ + { + "flags": 5, + "alignment": 16384 + }, + { + "flags": 4, + "alignment": 16384 + }, + { + "flags": 6, + "alignment": 16384 + } + ], + "abi_passed": true, + "console_loading_verified": false +} diff --git a/evidence/native-core-loading/capture.json b/evidence/native-core-loading/capture.json new file mode 100644 index 0000000..c9ed775 --- /dev/null +++ b/evidence/native-core-loading/capture.json @@ -0,0 +1,47 @@ +{ + "step": "native-core-loading", + "recorded": "2026-09-19", + "revision": "7af1081 + native loader/recovery/core upload", + "command": "tools/verify.sh; tools/run-title.sh --no-build --core-test --watch 180 (owner selects FCEUmm and NES archive manually)", + "raw_capture": "klog/native-core-game-rgba-run.log", + "raw_lines": 8363, + "lines": [ + "frontend build identity: 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5", + "core SHA-256: fec7dc4eb7ec6cae937ae778f0b59365d8bd7570927a73353424345e13589d9c", + "ELF64 x86-64 ET_DYN FreeBSD; 25 libretro exports; kernel_web; separate RX/R/RW segments", + "core loader test: passed=1 cycles=8 exports=25 api=1 name=FCEUmm", + "core recovery test: passed=1 selection_rejected=1 content_rejected=1 menu_context_preserved=1 menu_core_load=1", + "[INFO] [Environ] SET_PIXEL_FORMAT: XRGB8888.", + "[INFO] [Core] Geometry: 256x224, Aspect: 1.306, FPS: 60.10, Sample rate: 48000.00 Hz.", + "[libretro INFO] Player 1: Gamepad", + "[libretro INFO] Player 2: Gamepad", + "current-build launches captured: 3; Vulkan refusals: 0; GPU API failure records: 0; kernel fatal signals: 0", + "owner: Works flawlessly! I manually closed as I didnt set the shortcut to close a game and return to menu", + "Runner used a 180-second window; owner closed/reopened the title, and runner closed a later menu instance. This is not 180 seconds of uninterrupted gameplay.", + "Gameplay acceptance: owner-confirmed; return-to-XMB hotkey and repeated game unload/reload: NOT VERIFIED", + "Host verification: PASS format unit build integration evidence; 52 tests", + "PS5_Vulkan not edited by this task; sibling advanced independently; title identity binds the linked archive bytes" + ], + "loader": { + "build_identity": "build identity: 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5", + "passed": true, + "cycles": 8, + "exports": 25, + "api": 1, + "missing_rejected": true, + "unknown_symbol_rejected": true + }, + "recovery": { + "build_identity": "build identity: 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5", + "passed": true, + "selection_rejected": true, + "content_rejected": true, + "menu_context_preserved": true, + "menu_core_load": true + }, + "gpu_refusals": 0, + "gpu_api_failure_records": 0, + "kernel_fatal_signals": 0, + "core_sha256": "fec7dc4eb7ec6cae937ae778f0b59365d8bd7570927a73353424345e13589d9c", + "return_to_menu_verified": false +} diff --git a/evidence/native-core-loading/expectation.json b/evidence/native-core-loading/expectation.json new file mode 100644 index 0000000..3b218c9 --- /dev/null +++ b/evidence/native-core-loading/expectation.json @@ -0,0 +1,15 @@ +{ + "step": "native-core-loading", + "note": "Native FCEUmm loading and first owner-confirmed gameplay; failed selection preserves live frontend. No claim of game hotkey/unload or sustained-rate acceptance.", + "must_contain": [ + "frontend build identity: 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5", + "core SHA-256: fec7dc4eb7ec6cae937ae778f0b59365d8bd7570927a73353424345e13589d9c", + "core loader test: passed=1 cycles=8 exports=25 api=1 name=FCEUmm", + "core recovery test: passed=1 selection_rejected=1 content_rejected=1 menu_context_preserved=1 menu_core_load=1", + "[INFO] [Core] Geometry: 256x224, Aspect: 1.306, FPS: 60.10, Sample rate: 48000.00 Hz.", + "current-build launches captured: 3; Vulkan refusals: 0; GPU API failure records: 0; kernel fatal signals: 0", + "Host verification: PASS format unit build integration evidence; 52 tests" + ], + "must_not_contain": [], + "human_check": "Owner answered the video/audio/controls question with \u201cWorks flawlessly!\u201d and reported manually closing the app because no return-to-menu shortcut was set." +} diff --git a/evidence/native-core-loading/iterations.json b/evidence/native-core-loading/iterations.json new file mode 100644 index 0000000..5b52c80 --- /dev/null +++ b/evidence/native-core-loading/iterations.json @@ -0,0 +1,26 @@ +[ + { + "raw_capture": "klog/run-PPSA99169-141420.log", + "stage": "executable mapping probe", + "build_identity": null, + "outcome": "RW anonymous memory -> RX; executed return value 42; title survived 30s" + }, + { + "raw_capture": "klog/run-PPSA99169-142423.log", + "stage": "pre-frontend loader test", + "build_identity": "4dfefb6b14414a24c164fdbbe10d364897aa8b7e0058745ac53e8e8b2dcca513", + "outcome": "8 load/export/API/identity/unload cycles passed; title survived 30s" + }, + { + "raw_capture": "klog/run-PPSA99169-142526.log", + "stage": "manual selection", + "build_identity": "4dfefb6b14414a24c164fdbbe10d364897aa8b7e0058745ac53e8e8b2dcca513", + "outcome": "core file read failed after frontend initialization; owner confirmed manual quit/close; exit(0) followed by SIGSYS" + }, + { + "raw_capture": "klog/run-PPSA99169-143443.log", + "stage": "mapped file reads and live recovery", + "build_identity": "ce7176a7e6688563b2b59cfa52808f403d5021299308e6038ea4f64d67fd661d", + "outcome": "loader/recovery reports passed; manually loaded content reached video callback; aborted in vulkan_copy_staging_to_dynamic" + } +] diff --git a/evidence/native-core-loading/loader.json b/evidence/native-core-loading/loader.json new file mode 100644 index 0000000..63375f5 --- /dev/null +++ b/evidence/native-core-loading/loader.json @@ -0,0 +1,9 @@ +{ + "build_identity": "build identity: 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5", + "passed": true, + "cycles": 8, + "exports": 25, + "api": 1, + "missing_rejected": true, + "unknown_symbol_rejected": true +} diff --git a/evidence/native-core-loading/recovery.json b/evidence/native-core-loading/recovery.json new file mode 100644 index 0000000..ef188e8 --- /dev/null +++ b/evidence/native-core-loading/recovery.json @@ -0,0 +1,8 @@ +{ + "build_identity": "build identity: 9b287ca7e0d4a04721c68d30fb88b678a62cc568c1f2d44b75716b464c21b6d5", + "passed": true, + "selection_rejected": true, + "content_rejected": true, + "menu_context_preserved": true, + "menu_core_load": true +} diff --git a/parked/native-core-loading/README.md b/parked/native-core-loading/README.md deleted file mode 100644 index d115191..0000000 --- a/parked/native-core-loading/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Native dynamic core loading: unresolved - -FCEUmm is cross-built and its metadata is discovered. It is not a working core -port yet. Evidence: `evidence/fceumm-build/`. No loader implementation or untested -crash fix is included here. The failed target gate remains open. - -The native title cannot currently open the generated shared core. Both manual -runs log `Failed to open libretro core` and `Error(s): (null)`. The final ELF is -byte-identical on FTP readback, so this is not a stale upload or missing `.info`. -The later content attempt has no core path after the failed selection, tears down -the frontend, fails initialization, then reaches `vulkan_alive(NULL)` and faults -on the video-width read at address 0x80. This is a frontend lifecycle crash; -it does not establish a Vulkan driver failure or execution inside FCEUmm. - -## Next implementation and acceptance - -1. Add native dynamic-loader diagnostics around failed open/symbol resolution, - retaining the core path and error status without printing user content paths. - Establish whether a native module/export conversion or an application ELF - loader is the supported route. The current native module converter explicitly - rejects application exports; the websrv SDK environment is not interchangeable. -2. Fix failed-core selection/content startup so it returns to a valid menu or - exits cleanly before polling a destroyed video context. Reproduce the current - failure in a regression test. A null check alone must not conceal a broken - frontend reinitialization or select a fallback video driver. -3. Test load, all required libretro symbols, API version and FCEUmm identity on - console, then the owner's manually selected game. Capture current frontend - identity, exact core SHA-256, retroarch.log, trace and klog. Require no crash. -4. Confirm visible gameplay, audio, controls, repeated load/unload and preserved - menu. Saves/states are separate acceptance. No static-only core substitution - or donor-SDK swap is implied by this build step. diff --git a/patches/series b/patches/series index fb01402..c2827f5 100644 --- a/patches/series +++ b/patches/series @@ -77,3 +77,14 @@ # 0071 expose PS5 pad through the binding/menu joypad interface and auto profile 0071-register-native-ps5-joypad + +# Refuse failed core selection before frontend teardown; stop a failed runloop. +0072-native-core-failure-recovery + +# Route libretro dynamic loading through the native ELF core loader. +0073-native-elf-core-loader + +# Opt-in native loader diagnostic verifies rejected loads preserve the live menu. +0074-native-core-recovery-diagnostic + +0075-native-core-rgba-upload diff --git a/src/core_frame_ps5.cpp b/src/core_frame_ps5.cpp new file mode 100644 index 0000000..cdd041c --- /dev/null +++ b/src/core_frame_ps5.cpp @@ -0,0 +1,19 @@ +/* Convert libretro's little-endian XRGB8888 to the sampled RGBA8 upload format. */ +#include +#include + +extern "C" void ps5_core_frame_rgba(void *output, size_t stride, const void *input, size_t pitch, + unsigned width, unsigned height) +{ + auto *dst = static_cast(output); + auto *src = static_cast(input); + for (unsigned y = 0; y < height; ++y, dst += stride, src += pitch) + for (unsigned x = 0; x < width; ++x) + { + const uint8_t blue = src[4 * x], green = src[4 * x + 1], red = src[4 * x + 2]; + dst[4 * x] = red; + dst[4 * x + 1] = green; + dst[4 * x + 2] = blue; + dst[4 * x + 3] = 255; + } +} diff --git a/src/core_imports_ps5.cpp b/src/core_imports_ps5.cpp new file mode 100644 index 0000000..fcf2d72 --- /dev/null +++ b/src/core_imports_ps5.cpp @@ -0,0 +1,2 @@ +/* Public runtime addresses are relocated by the native title builder. */ +#include "../build/core_imports.inc" diff --git a/src/core_loader_probe.cpp b/src/core_loader_probe.cpp new file mode 100644 index 0000000..41e0a2d --- /dev/null +++ b/src/core_loader_probe.cpp @@ -0,0 +1,106 @@ +/* Opt-in native loader diagnostics: no content is loaded or executed. */ +#include +#include +#include + +extern "C" +{ + const char *ps5_frontend_build_identity(); + void *ps5_core_dlopen(const char *, int); + void *ps5_core_dlsym(void *, const char *); + int ps5_core_dlclose(void *); + char *ps5_core_dlerror(); +} + +namespace +{ +bool recovery_pending = false; +} +extern "C" bool ps5_core_recovery_test_pending() +{ + bool pending = recovery_pending; + recovery_pending = false; + return pending; +} + +extern "C" void ps5_core_loader_test_if_requested() +{ + FILE *control = std::fopen("/app0/core-loader-test.txt", "r"); + if (!control) + return; + std::fclose(control); + std::remove("/app0/core-loader-test.txt"); + recovery_pending = true; + const char *exports[] = {"retro_api_version", + "retro_init", + "retro_deinit", + "retro_run", + "retro_get_system_info", + "retro_get_system_av_info", + "retro_load_game", + "retro_load_game_special", + "retro_unload_game", + "retro_reset", + "retro_set_environment", + "retro_set_video_refresh", + "retro_set_audio_sample", + "retro_set_audio_sample_batch", + "retro_set_input_poll", + "retro_set_input_state", + "retro_set_controller_port_device", + "retro_serialize_size", + "retro_serialize", + "retro_unserialize", + "retro_get_region", + "retro_get_memory_data", + "retro_get_memory_size", + "retro_cheat_reset", + "retro_cheat_set"}; + bool missing = ps5_core_dlopen("/app0/cores/__missing_loader_test__.so", 0) == nullptr && + ps5_core_dlerror() != nullptr; + unsigned cycles = 0, found = 0, api = 0; + char name[64] = {}, error[512] = {}; + bool unknown_symbol = false; + for (unsigned i = 0; i < 8; ++i) + { + void *core = ps5_core_dlopen("/app0/cores/fceumm_libretro.so", 0); + if (!core) + { + std::snprintf(error, sizeof(error), "%s", ps5_core_dlerror()); + break; + } + found = 0; + for (const char *symbol : exports) + found += ps5_core_dlsym(core, symbol) != nullptr; + if (found == 25) + { + auto version = + reinterpret_cast(ps5_core_dlsym(core, "retro_api_version")); + auto info = reinterpret_cast( + ps5_core_dlsym(core, "retro_get_system_info")); + retro_system_info system{}; + api = version(); + info(&system); + std::snprintf(name, sizeof(name), "%s", system.library_name ? system.library_name : ""); + unknown_symbol = ps5_core_dlsym(core, "__missing_export__") == nullptr && + ps5_core_dlerror() != nullptr; + } + if (ps5_core_dlclose(core) || found != 25 || api != RETRO_API_VERSION || + std::strcmp(name, "FCEUmm")) + break; + ++cycles; + } + const bool passed = missing && unknown_symbol && cycles == 8; + std::fprintf(stderr, + "core loader test: passed=%d cycles=%u exports=%u api=%u name=%s error=%s\n", + passed, cycles, found, api, name, error); + if (FILE *out = std::fopen("/app0/core-loader-test.json", "w")) + { + std::fprintf(out, + "{\"build_identity\":\"%s\",\"passed\":%s,\"cycles\":%u,\"exports\":%u," + "\"api\":%u,\"missing_rejected\":%s,\"unknown_symbol_rejected\":%s}\n", + ps5_frontend_build_identity(), passed ? "true" : "false", cycles, found, api, + missing ? "true" : "false", unknown_symbol ? "true" : "false"); + std::fclose(out); + } +} diff --git a/src/core_loader_ps5.cpp b/src/core_loader_ps5.cpp new file mode 100644 index 0000000..04dfdf4 --- /dev/null +++ b/src/core_loader_ps5.cpp @@ -0,0 +1,454 @@ +/* PS5 RetroArch - bounded in-process ELF loader for libretro cores. + * Copyright (C) 2026 Mihawk + * SPDX-License-Identifier: GPL-3.0-or-later + * Runtime imports are bound to the title's public SDK imports, not websrv hooks. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef R_X86_64_JUMP_SLOT +#define R_X86_64_JUMP_SLOT R_X86_64_JMP_SLOT +#endif + +extern "C" void *ps5_core_import(const char *name); + +namespace +{ +constexpr size_t page = 0x4000; +constexpr size_t max_file = 128 * 1024 * 1024; +constexpr size_t max_image = 512 * 1024 * 1024; +struct Module +{ + Module *next; + unsigned references; + char path[1024]; + unsigned char *file; + size_t file_size; + size_t file_span; + unsigned char *base; + size_t span; + const Elf64_Phdr *ph; + size_t ph_count; + const Elf64_Sym *symbols; + size_t symbol_count; + const char *strings; + size_t string_size; +}; +Module *modules = nullptr; +pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; +thread_local char error_text[512]; + +bool fail(const char *format, ...) +{ + va_list args; + va_start(args, format); + std::vsnprintf(error_text, sizeof(error_text), format, args); + va_end(args); + std::fprintf(stderr, "core loader: %s\n", error_text); + return false; +} + +bool range(uint64_t offset, uint64_t size, uint64_t limit) +{ + return offset <= limit && size <= limit - offset; +} + +template const T *records(const Module *m, uint64_t offset, uint64_t count) +{ + if (offset % alignof(T) || count > m->file_size / sizeof(T) || + !range(offset, count * sizeof(T), m->file_size)) + return nullptr; + return reinterpret_cast(m->file + offset); +} + +const char *string_at(const Module *m, uint64_t offset) +{ + if (offset >= m->string_size || !std::memchr(m->strings + offset, 0, m->string_size - offset)) + return nullptr; + return m->strings + offset; +} + +bool mapped(const Module *m, uint64_t address, uint64_t bytes, unsigned flags = 0) +{ + for (size_t i = 0; i < m->ph_count; ++i) + { + const auto &p = m->ph[i]; + if (p.p_type == PT_LOAD && (p.p_flags & flags) == flags && address >= p.p_vaddr && + range(address - p.p_vaddr, bytes, p.p_memsz)) + return true; + } + return false; +} + +void release(Module *m) +{ + if (m->base) + munmap(m->base, m->span); + if (m->file) + munmap(m->file, m->file_span); + std::free(m); +} + +bool symbol_address(Module *m, size_t index, uintptr_t &address) +{ + if (index >= m->symbol_count) + return fail("relocation symbol index out of range"); + const auto &s = m->symbols[index]; + const char *name = string_at(m, s.st_name); + if (!name) + return fail("unterminated symbol name"); + const unsigned type = ELF64_ST_TYPE(s.st_info); + if (type != STT_NOTYPE && type != STT_FUNC && type != STT_OBJECT && type != STT_SECTION) + return fail("unsupported symbol type %u for %s", type, name); + if (s.st_shndx == SHN_UNDEF) + { + address = reinterpret_cast(ps5_core_import(name)); + if (!address && ELF64_ST_BIND(s.st_info) != STB_WEAK) + return fail("unresolved native runtime import: %s", name); + return true; + } + if (s.st_shndx == SHN_ABS) + { + address = s.st_value; + return true; + } + if (s.st_shndx >= SHN_LORESERVE || !mapped(m, s.st_value, s.st_size ? s.st_size : 1)) + return fail("symbol outside loaded segments: %s", name); + address = reinterpret_cast(m->base) + s.st_value; + return true; +} + +bool load(Module *m) +{ + const int fd = open(m->path, O_RDONLY); + if (fd < 0) + return fail("open failed errno=%d", errno); + const off_t size = lseek(fd, 0, SEEK_END); + if (size < off_t(sizeof(Elf64_Ehdr)) || size > off_t(max_file) || lseek(fd, 0, SEEK_SET) < 0) + { + const int error = errno; + close(fd); + return fail("invalid core file size/seek: size=%lld errno=%d", (long long)size, error); + } + m->file_size = size_t(size); + m->file_span = (m->file_size + page - 1) & ~(page - 1); + void *buffer = + mmap(nullptr, m->file_span, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (buffer == MAP_FAILED) + { + const int error = errno; + close(fd); + return fail("core file buffer mapping failed: bytes=%zu errno=%d", m->file_span, error); + } + m->file = static_cast(buffer); + size_t done = 0; + while (done < m->file_size) + { + const size_t remaining = m->file_size - done; + const ssize_t count = read(fd, m->file + done, remaining < 65536 ? remaining : 65536); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + { + const int error = count < 0 ? errno : 0; + close(fd); + return fail("core read failed: offset=%zu size=%zu count=%lld errno=%d", done, + m->file_size, (long long)count, error); + } + done += size_t(count); + } + close(fd); + const auto *eh = records(m, 0, 1); + const unsigned char expected[] = {0x7f, 'E', 'L', 'F', 2, 1, 1, 9}; + if (!eh || std::memcmp(eh->e_ident, expected, sizeof(expected)) || eh->e_type != ET_DYN || + eh->e_machine != EM_X86_64 || eh->e_version != EV_CURRENT || eh->e_ehsize != sizeof(*eh) || + eh->e_phentsize != sizeof(Elf64_Phdr) || eh->e_shentsize != sizeof(Elf64_Shdr) || + !eh->e_phnum || eh->e_phnum > 32 || !eh->e_shnum || eh->e_shnum > 4096) + return fail("expected PS5 ELF64 shared core with program/section headers"); + m->ph = records(m, eh->e_phoff, eh->e_phnum); + m->ph_count = eh->e_phnum; + const auto *sections = records(m, eh->e_shoff, eh->e_shnum); + if (!m->ph || !sections) + return fail("truncated ELF header tables"); + const Elf64_Phdr *dynamic = nullptr; + size_t loads = 0, sym_index = 0; + for (size_t i = 0; i < m->ph_count; ++i) + { + const auto &p = m->ph[i]; + if (p.p_type == PT_TLS || p.p_type == PT_INTERP) + return fail("TLS and ELF interpreters are not supported by this core loader"); + if (p.p_type == PT_DYNAMIC) + { + if (dynamic) + return fail("duplicate dynamic segment"); + dynamic = &p; + } + if (p.p_type != PT_LOAD) + continue; + if (!p.p_memsz) + { + if (p.p_filesz) + return fail("nonempty file segment without memory"); + continue; + } + if (p.p_filesz > p.p_memsz || !range(p.p_offset, p.p_filesz, m->file_size) || + !range(p.p_vaddr, p.p_memsz, max_image - page) || p.p_vaddr % page || + p.p_offset % page || p.p_align < page || (p.p_align & (p.p_align - 1)) || + (p.p_flags & (PF_W | PF_X)) == (PF_W | PF_X)) + return fail("invalid, unaligned or writable-executable load segment"); + const uint64_t end = (p.p_vaddr + p.p_memsz + page - 1) & ~(page - 1); + for (size_t j = 0; j < i; ++j) + { + const auto &q = m->ph[j]; + const uint64_t qend = (q.p_vaddr + q.p_memsz + page - 1) & ~(page - 1); + if (q.p_type == PT_LOAD && q.p_memsz && p.p_vaddr < qend && q.p_vaddr < end) + return fail("overlapping load segment pages"); + } + if (end > m->span) + m->span = end; + ++loads; + } + if (!loads || !dynamic || !m->span) + return fail("missing load/dynamic segments"); + for (size_t i = 0; i < eh->e_shnum; ++i) + { + const auto &s = sections[i]; + if (s.sh_type != SHT_DYNSYM) + continue; + if (m->symbols || s.sh_entsize != sizeof(Elf64_Sym) || s.sh_size % sizeof(Elf64_Sym) || + s.sh_link >= eh->e_shnum || s.sh_size / sizeof(Elf64_Sym) > 100000) + return fail("invalid dynamic symbol table"); + m->symbol_count = s.sh_size / sizeof(Elf64_Sym); + m->symbols = records(m, s.sh_offset, m->symbol_count); + sym_index = i; + const auto &str = sections[s.sh_link]; + if (!m->symbols || !m->symbol_count || str.sh_type != SHT_STRTAB || + !range(str.sh_offset, str.sh_size, m->file_size)) + return fail("invalid symbol strings"); + m->strings = reinterpret_cast(m->file + str.sh_offset); + m->string_size = str.sh_size; + } + if (!m->symbols) + return fail("missing dynamic symbols"); + const auto *dt = + records(m, dynamic->p_offset, dynamic->p_filesz / sizeof(Elf64_Dyn)); + if (!dt || dynamic->p_filesz % sizeof(Elf64_Dyn)) + return fail("invalid dynamic entries"); + bool terminated = false; + for (size_t i = 0; i < dynamic->p_filesz / sizeof(Elf64_Dyn); ++i) + { + auto tag = dt[i].d_tag; + auto value = dt[i].d_un.d_val; + if (tag == DT_NULL) + { + terminated = true; + break; + } + if (tag == DT_NEEDED) + { + const char *name = string_at(m, value); + if (!name || (std::strcmp(name, "libkernel_web.sprx") && + std::strcmp(name, "libSceLibcInternal.sprx") && + std::strcmp(name, "libScePosixForWebKit.sprx"))) + return fail("unsupported core dependency"); + } + // Constructors, TLS and C++ unwind registration need a later loader extension. + if (tag == DT_TEXTREL || ((tag == DT_INIT || tag == DT_FINI || tag == DT_INIT_ARRAYSZ || + tag == DT_FINI_ARRAYSZ || tag == DT_PREINIT_ARRAYSZ || + tag == DT_RELSZ || tag == 35 /* DT_RELRSZ */) && + value)) + return fail("unsupported core initialization/relocation feature"); + } + if (!terminated) + return fail("unterminated dynamic entries"); + // Reserve aligned memory without ever making a page writable and executable. + void *allocation = + mmap(nullptr, m->span + page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (allocation == MAP_FAILED) + return fail("core mmap failed errno=%d", errno); + const uintptr_t address = reinterpret_cast(allocation); + const uintptr_t aligned = (address + page - 1) & ~(page - 1); + const size_t prefix = aligned - address; + const size_t suffix = page - prefix; + if (prefix) + munmap(allocation, prefix); + if (suffix) + munmap(reinterpret_cast(aligned + m->span), suffix); + m->base = reinterpret_cast(aligned); + for (size_t i = 0; i < m->ph_count; ++i) + if (m->ph[i].p_type == PT_LOAD && m->ph[i].p_filesz) + std::memcpy(m->base + m->ph[i].p_vaddr, m->file + m->ph[i].p_offset, m->ph[i].p_filesz); + // Resolve every import before publishing the handle or executing code. + for (size_t i = 1; i < m->symbol_count; ++i) + { + uintptr_t unused; + if (!symbol_address(m, i, unused)) + return false; + } + size_t relocations = 0; + for (size_t i = 0; i < eh->e_shnum; ++i) + { + const auto &s = sections[i]; + if (!(s.sh_flags & SHF_ALLOC)) + continue; + if (s.sh_type == SHT_REL || s.sh_type == 19 /* SHT_RELR */) + return fail("unsupported relocation encoding"); + if (s.sh_type != SHT_RELA) + continue; + if (s.sh_link != sym_index || s.sh_entsize != sizeof(Elf64_Rela) || + s.sh_size % sizeof(Elf64_Rela)) + return fail("invalid relocation section"); + const size_t count = s.sh_size / sizeof(Elf64_Rela); + const auto *rel = records(m, s.sh_offset, count); + if (!rel) + return fail("truncated relocation section"); + for (size_t j = 0; j < count; ++j) + { + const auto &r = rel[j]; + const unsigned type = ELF64_R_TYPE(r.r_info); + if (type == R_X86_64_NONE) + continue; + if (!mapped(m, r.r_offset, sizeof(uint64_t), PF_W)) + return fail("relocation destination outside writable segment"); + uintptr_t value = 0; + if (type == R_X86_64_RELATIVE) + { + if (ELF64_R_SYM(r.r_info) || r.r_addend < 0 || !mapped(m, uint64_t(r.r_addend), 1)) + return fail("invalid relative relocation"); + value = reinterpret_cast(m->base) + r.r_addend; + } + else if (type == R_X86_64_64 || type == R_X86_64_GLOB_DAT || type == R_X86_64_JUMP_SLOT) + { + if (!symbol_address(m, ELF64_R_SYM(r.r_info), value)) + return false; + value += uintptr_t(r.r_addend); + } + else + return fail("unsupported x86-64 relocation %u", type); + std::memcpy(m->base + r.r_offset, &value, sizeof(value)); + ++relocations; + } + } + if (mprotect(m->base, m->span, PROT_NONE)) + return fail("core protect reserve failed errno=%d", errno); + for (size_t i = 0; i < m->ph_count; ++i) + { + const auto &p = m->ph[i]; + if (p.p_type != PT_LOAD || !p.p_memsz) + continue; + int protection = ((p.p_flags & PF_R) ? PROT_READ : 0) | + ((p.p_flags & PF_W) ? PROT_WRITE : 0) | + ((p.p_flags & PF_X) ? PROT_EXEC : 0); + const size_t length = (p.p_memsz + page - 1) & ~(page - 1); + if (mprotect(m->base + p.p_vaddr, length, protection)) + return fail("core segment mprotect flags=%u failed errno=%d", p.p_flags, errno); + } + std::fprintf(stderr, "core loader: ready symbols=%zu relocations=%zu mapped_bytes=%zu\n", + m->symbol_count, relocations, m->span); + return true; +} +} // namespace + +extern "C" void *ps5_core_dlopen(const char *path, int) +{ + pthread_mutex_lock(&mutex); + error_text[0] = 0; + if (!path || !*path || std::strlen(path) >= sizeof(Module::path)) + { + fail("invalid core path (no process-global lookup)"); + pthread_mutex_unlock(&mutex); + return nullptr; + } + unsigned count = 0; + for (auto *m = modules; m; m = m->next) + { + ++count; + if (!std::strcmp(path, m->path)) + { + ++m->references; + pthread_mutex_unlock(&mutex); + return m; + } + } + auto *m = count < 8 ? static_cast(std::calloc(1, sizeof(Module))) : nullptr; + if (!m) + fail("core handle capacity/allocation exhausted"); + if (m) + { + std::strcpy(m->path, path); + if (!load(m)) + { + release(m); + m = nullptr; + } + else + { + m->references = 1; + m->next = modules; + modules = m; + } + } + pthread_mutex_unlock(&mutex); + return m; +} + +extern "C" void *ps5_core_dlsym(void *handle, const char *name) +{ + pthread_mutex_lock(&mutex); + error_text[0] = 0; + void *answer = nullptr; + for (auto *m = modules; m; m = m->next) + if (m == handle && name) + for (size_t i = 1; i < m->symbol_count; ++i) + { + const auto &s = m->symbols[i]; + const unsigned bind = ELF64_ST_BIND(s.st_info); + if (s.st_shndx == SHN_UNDEF || (bind != STB_GLOBAL && bind != STB_WEAK) || + ELF64_ST_VISIBILITY(s.st_other) == STV_HIDDEN || + std::strcmp(string_at(m, s.st_name), name)) + continue; + uintptr_t address; + if (symbol_address(m, i, address)) + answer = reinterpret_cast(address); + break; + } + if (!answer && handle) + fail("core export not found: %s", name ? name : ""); + pthread_mutex_unlock(&mutex); + return answer; +} + +extern "C" int ps5_core_dlclose(void *handle) +{ + pthread_mutex_lock(&mutex); + error_text[0] = 0; + for (Module **at = &modules; *at; at = &(*at)->next) + if (*at == handle) + { + Module *m = *at; + if (--m->references == 0) + { + *at = m->next; + release(m); + } + pthread_mutex_unlock(&mutex); + return 0; + } + fail("invalid core handle on close"); + pthread_mutex_unlock(&mutex); + return -1; +} + +extern "C" char *ps5_core_dlerror() +{ + return error_text[0] ? error_text : nullptr; +} diff --git a/src/core_recovery_test.cpp b/src/core_recovery_test.cpp new file mode 100644 index 0000000..acecfd0 --- /dev/null +++ b/src/core_recovery_test.cpp @@ -0,0 +1,68 @@ +/* Opt-in refused-load test against the actual menu/task pipeline. No ROM runs. */ +extern "C" +{ +#include +#include +#include +#include + bool ps5_core_recovery_test_pending(); + void *ps5_core_dlopen(const char *, int); + void *ps5_core_dlsym(void *, const char *); + int ps5_core_dlclose(void *); + const char *ps5_frontend_build_identity(); +} +#include +#include + +extern "C" void ps5_core_recovery_test_if_requested() +{ + if (!ps5_core_recovery_test_pending()) + return; + auto *runloop = runloop_state_get_ptr(); + auto *video = video_state_get_ptr(); + void *context = video->data; + bool passed = false; + bool selection = true, content = true; + bool menu_core_load = false; + if (context && (runloop->flags & RUNLOOP_FLAG_IS_INITED) && + runloop->current_core_type == CORE_TYPE_DUMMY) + { + char old_paths[3][PATH_MAX_LENGTH]; + const rarch_path_type slots[] = {RARCH_PATH_CORE, RARCH_PATH_CORE_LAST, RARCH_PATH_CONTENT}; + for (unsigned i = 0; i < 3; ++i) + std::snprintf(old_paths[i], sizeof(old_paths[i]), "%s", path_get(slots[i])); + // Exercise allocation, I/O and execution with the full frontend resident. + if (void *core = ps5_core_dlopen("/app0/cores/fceumm_libretro.so", 0)) + { + auto api = reinterpret_cast(ps5_core_dlsym(core, "retro_api_version")); + menu_core_load = api && api() == RETRO_API_VERSION; + menu_core_load = ps5_core_dlclose(core) == 0 && menu_core_load; + } + content_ctx_info_t info{}; + const char *missing = "/app0/cores/__missing_loader_test__.so"; + selection = + task_push_load_new_core(missing, nullptr, &info, CORE_TYPE_PLAIN, nullptr, nullptr); + content = task_push_load_content_with_new_core_from_menu( + missing, "/app0/__not_loaded_test__.nes", &info, CORE_TYPE_PLAIN, nullptr, nullptr); + passed = menu_core_load && !selection && !content && context == video->data && + (runloop->flags & RUNLOOP_FLAG_IS_INITED) && + runloop->current_core_type == CORE_TYPE_DUMMY; + for (unsigned i = 0; i < 3; ++i) + path_set(slots[i], old_paths[i]); + } + std::fprintf(stderr, + "core recovery test: passed=%d selection_accepted=%d content_accepted=%d " + "menu_context_preserved=%d menu_core_load=%d\n", + passed, selection, content, context && context == video->data, menu_core_load); + if (FILE *out = std::fopen("/app0/core-recovery-test.json", "w")) + { + std::fprintf(out, + "{\"build_identity\":\"%s\",\"passed\":%s,\"selection_rejected\":%s,\"content_" + "rejected\":%s,\"menu_context_preserved\":%s,\"menu_core_load\":%s}\n", + ps5_frontend_build_identity(), passed ? "true" : "false", + selection ? "false" : "true", content ? "false" : "true", + context && context == video->data ? "true" : "false", + menu_core_load ? "true" : "false"); + std::fclose(out); + } +} diff --git a/src/main.cpp b/src/main.cpp index d3a1105..c01803c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -47,6 +47,7 @@ extern "C" int sceSystemServiceHideSplashScreen(); extern "C" void ps5_vulkan_profile_init(); extern "C" void ps5_audio_test_if_requested(); +extern "C" void ps5_core_loader_test_if_requested(); extern "C" const char *ps5_frontend_build_identity() { return PS5_RETROARCH_BUILD_ID; @@ -219,6 +220,7 @@ int main() argv_with_extras[base_count + extra_count] = nullptr; ps5_audio_test_if_requested(); + ps5_core_loader_test_if_requested(); const int status = rarch_main(static_cast(base_count + extra_count), argv_with_extras, nullptr); diff --git a/tests/test_core_frame.py b/tests/test_core_frame.py new file mode 100644 index 0000000..8aa895e --- /dev/null +++ b/tests/test_core_frame.py @@ -0,0 +1,31 @@ +"""Verify core upload channel order, alpha, row pitch and in-place writes.""" +import ctypes +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +class CoreFrame(unittest.TestCase): + def test_xrgb_upload_with_padding_and_in_place(self): + with tempfile.TemporaryDirectory() as td: + lib = Path(td) / 'frame.so' + subprocess.run(['c++', '-shared', '-fPIC', '-Wall', '-Wextra', '-Werror', + str(ROOT / 'src/core_frame_ps5.cpp'), '-o', str(lib)], check=True) + convert = ctypes.CDLL(str(lib)).ps5_core_frame_rgba + convert.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, + ctypes.c_size_t, ctypes.c_uint, ctypes.c_uint] + source = bytes([0, 0, 255, 0, 0, 255, 0, 128, 9, 9, 9, 9, + 255, 0, 0, 0, 0x56, 0x34, 0x12, 1, 9, 9, 9, 9]) + expected = bytes([255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 0x12, 0x34, 0x56, 255]) + src = ctypes.create_string_buffer(source) + dst = ctypes.create_string_buffer(bytes([0xAA]) * 32) + convert(dst, 16, src, 12, 2, 2) + self.assertEqual(dst.raw[:8] + dst.raw[16:24], expected) + self.assertEqual(dst.raw[8:16] + dst.raw[24:32], bytes([0xAA]) * 16) + convert(src, 12, src, 12, 2, 2) + self.assertEqual(src.raw[:8] + src.raw[12:20], expected) + self.assertEqual(src.raw[8:12] + src.raw[20:24], bytes([9]) * 8) diff --git a/tests/test_core_loader.py b/tests/test_core_loader.py new file mode 100644 index 0000000..4f1c180 --- /dev/null +++ b/tests/test_core_loader.py @@ -0,0 +1,125 @@ +"""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 +static bool allow = true; +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) { + return allow && !std::strcmp(name, "native_add") ? reinterpret_cast(&native_add) : 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(' +#include +#include +#define HAVE_DYNAMIC 1 +#define RARCH_PATH_CORE 0 +#define RARCH_PATH_CORE_LAST 1 +#define CMD_EVENT_LOAD_CORE 2 +enum rarch_core_type { CORE_TYPE_DUMMY, CORE_TYPE_PLAIN }; +typedef void content_ctx_info_t; +typedef void (*retro_task_callback_t)(void); +static const char *paths[2] = {"", ""}; +static bool accepted; +static int loads, changes; +static void path_set(int key, const char *path) { paths[key] = path; } +static const char *path_get(int key) { return paths[key]; } +static bool string_is_equal(const char *a, const char *b) { return !strcmp(a,b); } +static bool command_event(int command, void *data) { ++loads; return accepted; } +static void runloop_set_current_core_type(enum rarch_core_type type, bool explicit_type) { ++changes; } +''' + main = r''' +int main(void) { + accepted = false; + if (task_push_load_new_core("bad.so", "", NULL, CORE_TYPE_PLAIN, NULL, NULL)) return 1; + if (loads != 1 || changes != 0) return 2; + accepted = true; + if (!task_push_load_new_core("good.so", "", NULL, CORE_TYPE_PLAIN, NULL, NULL)) return 3; + return loads == 2 && changes == 1 ? 0 : 4; +} +''' + with tempfile.TemporaryDirectory() as td: + for name, text, expected in [('before', source, 1), ('after', patched, 0)]: + body = re.search(r'bool task_push_load_new_core\(.*?\n\}', text, re.S)[0] + path = Path(td) / (name + '.c') + path.write_text(fixture + body + main) + binary = Path(td) / name + subprocess.run(['cc', '-std=c99', str(path), '-o', str(binary)], check=True) + result = subprocess.run([str(binary)], check=False) + self.assertEqual(result.returncode, expected) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 928148f..ab43724 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -300,7 +300,7 @@ class PortPatches(unittest.TestCase): change deliberately, in a commit that says why. """ self.assertEqual( - len(self.blocks()), 108, + len(self.blocks()), 116, "the patch count changed: if a block was added or removed on purpose, " "update this number in the same commit and say why in its message") diff --git a/tooling/native/ps5-core.ld b/tooling/native/ps5-core.ld new file mode 100644 index 0000000..8f079c9 --- /dev/null +++ b/tooling/native/ps5-core.ld @@ -0,0 +1,33 @@ +/* PS5 libretro shared cores: distinct 16 KiB code, read-only and data pages. */ +OUTPUT_FORMAT("elf64-x86-64") +OUTPUT_ARCH(i386:x86-64) +PHDRS { + text PT_LOAD FLAGS (5); + ro PT_LOAD FLAGS (4); + data PT_LOAD FLAGS (6); + dynamic PT_DYNAMIC FLAGS (6); +} +SECTIONS { + .text 0 : ALIGN(CONSTANT(MAXPAGESIZE)) { *(.text .text.*) *(.plt .plt.*) } : text + . = ALIGN(CONSTANT(MAXPAGESIZE)); + .rodata : { *(.rodata .rodata.*) } : ro + .eh_frame_hdr : { KEEP(*(.eh_frame_hdr)) } : ro + .eh_frame : { KEEP(*(.eh_frame)) } : ro + .gcc_except_table : { *(.gcc_except_table .gcc_except_table.*) } : ro + .note.gnu.build-id : { *(.note.gnu.build-id) } : ro + .dynsym : { *(.dynsym) } : ro + .gnu.hash : { *(.gnu.hash) } : ro + .hash : { *(.hash) } : ro + .dynstr : { *(.dynstr) } : ro + .rela.dyn : { *(.rela.dyn) } : ro + .rela.plt : { *(.rela.plt) } : ro + . = ALIGN(CONSTANT(MAXPAGESIZE)); + .data : { *(.data .data.*) } : data + .data.rel.ro : { *(.data.rel.ro .data.rel.ro.*) } : data + .got : { *(.got) *(.got.plt) } : data + .preinit_array : { KEEP(*(.preinit_array)) } : data + .init_array : { KEEP(*(.init_array .init_array.* .ctors .ctors.*)) } : data + .fini_array : { KEEP(*(.fini_array .fini_array.* .dtors .dtors.*)) } : data + .dynamic : { *(.dynamic) } : data : dynamic + .bss : { *(.bss .bss.*) *(COMMON) } : data +} diff --git a/tools/apply-port-patches.py b/tools/apply-port-patches.py index 3bf6520..f4e8b6d 100755 --- a/tools/apply-port-patches.py +++ b/tools/apply-port-patches.py @@ -30,6 +30,14 @@ from pathlib import Path # (file, anchor, inserted-before-anchor, already-present-marker) EDITS = [ + ('gfx/drivers/vulkan.c', ' vk->tex_fmt = video->rgb32 ? VK_FORMAT_B8G8R8A8_UNORM : VK_FORMAT_R5G6B5_UNORM_PACK16;', ' /* patches/series, 0075: matching sampled RGBA staging and dynamic core frames. */\n vk->tex_fmt = video->rgb32 ? VK_FORMAT_R8G8B8A8_UNORM : VK_FORMAT_R5G6B5_UNORM_PACK16;', 'patches/series, 0075: matching'), + ('gfx/drivers/vulkan.c', ' if (frame != chain->texture.mapped)\n {', ' /* patches/series, 0075: libretro XRGB pixels become RGBA upload bytes. */\n if (vk->video.rgb32)\n {\n extern void ps5_core_frame_rgba(void *, size_t, const void *, size_t, unsigned, unsigned);\n ps5_core_frame_rgba(chain->texture.mapped, chain->texture.stride,\n frame, pitch, frame_width, frame_height);\n }\n else if (frame != chain->texture.mapped)\n {', 'patches/series, 0075: libretro XRGB'), + ('retroarch.c', ' settings = config_get_ptr();\n\n ui_companion_driver_init_first(', ' /* patches/series, 0074: opt-in refused-core test against the live menu. */\n {\n extern void ps5_core_recovery_test_if_requested(void);\n ps5_core_recovery_test_if_requested();\n }\n settings = config_get_ptr();\n\n ui_companion_driver_init_first(', 'patches/series, 0074: opt-in refused-core test'), + ('libretro-common/dynamic/dylib.c', '#include ', '#include \n/* patches/series, 0073: native title core loading, not payload dlfcn hooks. */\nextern void *ps5_core_dlopen(const char *, int);\nextern void *ps5_core_dlsym(void *, const char *);\nextern int ps5_core_dlclose(void *);\nextern char *ps5_core_dlerror(void);\n#define dlopen ps5_core_dlopen\n#define dlsym ps5_core_dlsym\n#define dlclose ps5_core_dlclose\n#define dlerror ps5_core_dlerror', 'patches/series, 0073: native title core loading'), + ('tasks/task_content.c', ' /* Load core */\n command_event(CMD_EVENT_LOAD_CORE, NULL);\n\n /* Load content */', ' /* patches/series, 0072: keep the live menu when core selection fails. */\n if (!command_event(CMD_EVENT_LOAD_CORE, NULL))\n {\n RARCH_ERR("[PS5] Core selection failed; preserving the running frontend.\\n");\n ret = false;\n goto end;\n }\n\n /* Load content */', 'patches/series, 0072: keep the live menu'), + ('tasks/task_content.c', ' /* Load core */\n command_event(CMD_EVENT_LOAD_CORE, NULL);\n\n#ifndef HAVE_DYNAMIC', ' /* patches/series, 0072: do not mark a refused core as selected. */\n if (!command_event(CMD_EVENT_LOAD_CORE, NULL))\n return false;\n\n#ifndef HAVE_DYNAMIC', 'patches/series, 0072: do not mark'), + ('tasks/task_content.c', ' command_event(CMD_EVENT_LOAD_CORE, NULL);\n\n runloop_set_current_core_type(CORE_TYPE_PLAIN, true);', ' /* patches/series, 0072: reject contentless core failure before teardown. */\n if (!command_event(CMD_EVENT_LOAD_CORE, NULL))\n {\n ret = false;\n goto end;\n }\n\n runloop_set_current_core_type(CORE_TYPE_PLAIN, true);', 'patches/series, 0072: reject contentless'), + ('retroarch.c', ' ret = runloop_iterate();', ' /* patches/series, 0072: failed reinitialization cannot poll freed drivers. */\n if (!(runloop_st->flags & RUNLOOP_FLAG_IS_INITED))\n {\n RARCH_ERR("[PS5] Frontend initialization failed; leaving the main loop safely.\\n");\n break;\n }\n ret = runloop_iterate();', 'patches/series, 0072: failed reinitialization'), # 0071: native joypad discovery, binding capture and built-in defaults. ('input/input_driver.h', 'extern input_device_driver_t ps4_joypad;', 'extern input_device_driver_t ps4_joypad;\nextern input_device_driver_t ps5_joypad;', 'extern input_device_driver_t ps5_joypad;'), ('input/input_driver.c', ' &null_joypad,\n NULL,\n};', ' &ps5_joypad,\n &null_joypad,\n NULL,\n};', ' &ps5_joypad,'), diff --git a/tools/build-fceumm.sh b/tools/build-fceumm.sh index 7f9a2da..f33c763 100755 --- a/tools/build-fceumm.sh +++ b/tools/build-fceumm.sh @@ -50,7 +50,7 @@ make -C "$work" -f Makefile.libretro -j"${JOBS:-8}" platform=unix \ CC="$CC" CXX="$CXX" AR="$AR" LD="$LD" \ GIT_VERSION="\" ${revision:0:7}\"" LIBM= \ LIBS='-lkernel_web -lSceLibcInternal -lScePosixForWebKit' \ - LDFLAGS='-nostdlib -nodefaultlibs -Wl,--build-id=sha1' + LDFLAGS="-nostdlib -nodefaultlibs -Wl,--build-id=sha1 -Wl,-T,$root/tooling/native/ps5-core.ld" python3 tools/check-core.py "$work/fceumm_libretro.so" --report "$work/abi.json" cp -- "$work/fceumm_libretro.so" "$stage/cores/fceumm_libretro.so" cp -- "$info" "$stage/info/fceumm_libretro.info" diff --git a/tools/build-title.sh b/tools/build-title.sh index 627d780..8a9f9f6 100755 --- a/tools/build-title.sh +++ b/tools/build-title.sh @@ -52,6 +52,7 @@ sdk="$root/.deps/native/ps5-payload-sdk" echo "==> [title] step 1/3: the frontend" "$root/tools/build-retroarch.sh" bash "$root/tools/build-fceumm.sh" +python3 "$root/tools/core-imports.py" # The title's own sources are compiled with the same feature defines as the # frontend, because the two share a header full of #ifdefs and a struct whose @@ -153,6 +154,7 @@ root = pathlib.Path(sys.argv[1]) inputs = sorted(p for p in (root / "src").rglob("*") if p.is_file()) inputs += [root / name for name in ( "build/ra/libretroarch.a", "build/ra-conf/config.h", "tools/build-title.sh", + "build/core_imports.inc", "build/cores/stage/cores/fceumm_libretro.so", "tools/build.sh", "tools/retroarch-flags.sh")] inputs += [pathlib.Path(name) for name in sys.argv[2:]] digest = hashlib.sha256() diff --git a/tools/check-core.py b/tools/check-core.py index 6f93726..94debfc 100755 --- a/tools/check-core.py +++ b/tools/check-core.py @@ -56,7 +56,7 @@ def inspect(path): ' memsz or offset + filesz > len(data)): raise ValueError('invalid PS5 16 KiB load segment') loads.append({'flags': flags, 'alignment': align}) diff --git a/tools/core-imports.py b/tools/core-imports.py new file mode 100644 index 0000000..192e600 --- /dev/null +++ b/tools/core-imports.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Generate direct native-runtime bindings for the built core's imports.""" +from pathlib import Path +import re +import subprocess + +ROOT = Path(__file__).resolve().parent.parent +core = ROOT / 'build/cores/stage/cores/fceumm_libretro.so' +symbols = subprocess.check_output(['readelf', '--dyn-syms', '-W', str(core)], text=True) +imports = {} +for line in symbols.splitlines(): + fields = line.split() + if len(fields) < 8 or fields[6] != 'UND': + continue + name, kind = fields[7], fields[3] + if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or kind not in ('FUNC', 'OBJECT'): + raise SystemExit(f'Unsupported native core import: {name} ({kind})') + imports[name] = kind + +lines = ['// Generated by tools/core-imports.py; never edit.', '#include '] +aliases = {'opendir': 'ps5_opendir', 'readdir': 'ps5_readdir', 'closedir': 'ps5_closedir'} +for index, (name, kind) in enumerate(sorted(imports.items())): + target = aliases.get(name, name) + declaration = f'void core_import_{index}()' if kind == 'FUNC' else f'char core_import_{index}[]' + lines.append(f'extern "C" {declaration} asm("{target}");') +lines.extend(['extern "C" void *ps5_core_import(const char *name)', '{']) +for index, name in enumerate(sorted(imports)): + lines.append(f' if (!std::strcmp(name, "{name}")) return reinterpret_cast(&core_import_{index});') +lines.extend([' return nullptr;', '}']) +(ROOT / 'build/core_imports.inc').write_text('\n'.join(lines) + '\n') +print(f'core imports: {len(imports)} explicit native bindings generated') diff --git a/tools/run-title.sh b/tools/run-title.sh index 7133c19..65c81c7 100755 --- a/tools/run-title.sh +++ b/tools/run-title.sh @@ -40,14 +40,16 @@ deploy=1 watch=30 profile=0 audio_test=0 +core_test=0 while (( $# )); do case "$1" in --no-build) build=0 ;; --no-deploy) deploy=0 ;; --watch) shift; watch=${1:?--watch needs seconds} ;; --audio-test) audio_test=1 ;; + --core-test) core_test=1 ;; --gpu-profile) shift; profile=${1:?--gpu-profile needs seconds} ;; - *) echo "usage: ${0##*/} [--no-build] [--no-deploy] [--watch SECONDS] [--gpu-profile 1..60] [--audio-test]" >&2; exit 2 ;; + *) echo "usage: ${0##*/} [--no-build] [--no-deploy] [--watch SECONDS] [--gpu-profile 1..60] [--audio-test] [--core-test]" >&2; exit 2 ;; esac shift done @@ -149,7 +151,7 @@ fi # appeared. A run that does not ask for extras must not inherit them, so the file # is removed on every run - before the launch, because deleting it afterwards # would leave it for the next one if this run dies. -python3 - "$title_id" "$profile" "$audio_test" <<'PY' +python3 - "$title_id" "$profile" "$audio_test" "$core_test" <<'PY' import importlib.util, sys spec = importlib.util.spec_from_file_location("dt", "tools/deploy-title.py") dt = importlib.util.module_from_spec(spec); spec.loader.exec_module(dt) @@ -161,6 +163,13 @@ with connect(**dt.load_settings()) as ftp: print(" cleared a leftover args.txt from the console") except Exception: pass + control = f"/data/homebrew/{sys.argv[1]}/core-loader-test.txt" + remove_if_present(ftp, control) + if int(sys.argv[4]): + remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/core-loader-test.json") + remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/core-recovery-test.json") + ftp.storbinary(f"STOR {control}", io.BytesIO(b"native core loader test\n")) + print(" armed native core loader test (no game)") control = f"/data/homebrew/{sys.argv[1]}/gpu-profile.txt" remove_if_present(ftp, control) if int(sys.argv[2]): @@ -233,7 +242,7 @@ except Exception as error: PY # --- preserve development logs and optional buffered timing ------------------ -python3 - "$title_id" "$profile" "$stamp" "$audio_test" "$expected_identity" <<'PY' +python3 - "$title_id" "$profile" "$stamp" "$audio_test" "$expected_identity" "$core_test" <<'PY' import importlib.util, json, sys from pathlib import Path sys.path.insert(0, "tools") @@ -243,7 +252,10 @@ dt = importlib.util.module_from_spec(spec); spec.loader.exec_module(dt) with connect(**dt.load_settings()) as ftp: remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/gpu-profile.txt") remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/audio-test.txt") + remove_if_present(ftp, f"/data/homebrew/{sys.argv[1]}/core-loader-test.txt") names = ["retroarch.log"] + if int(sys.argv[6]): + names.extend(["core-loader-test.json", "core-recovery-test.json"]) if int(sys.argv[4]): names.append("audio-test.json") if int(sys.argv[2]): @@ -257,6 +269,11 @@ with connect(**dt.load_settings()) as ftp: if name == "retroarch.log": if f"build identity: {expected}" not in target.read_text(errors="replace"): raise SystemExit("RetroArch log is stale or logging failed: current build identity absent") + if name in ("core-loader-test.json", "core-recovery-test.json"): + report = json.loads(target.read_text()) + if report.get("build_identity") != f"build identity: {expected}" or not report.get("passed"): + raise SystemExit("Native core loader test failed or has stale identity") + print(" native core loader test PASS") if name == "audio-test.json": report = json.loads(target.read_text()) checks = (