From 7f938413dc9f9b2f57a8798b83bc6a2e54950a91 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:37:31 +0300 Subject: [PATCH 1/5] Let calls into the game take up to eight arguments A handler the game registers can take more than four arguments, such as one given an old state, a new state, an event, an error and its own argument. Kernel::call_guest and InterruptCall passed a0-a3 only; they now take up to eight, a0-a3 then t0-t3, and a count. The count defaults to four, which sets a0-a3 and nothing else, so every existing call loads the same registers as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- host/kernel/kernel.cpp | 8 ++++---- host/kernel/kernel.hpp | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/host/kernel/kernel.cpp b/host/kernel/kernel.cpp index 4a90756..3823e75 100644 --- a/host/kernel/kernel.cpp +++ b/host/kernel/kernel.cpp @@ -913,7 +913,7 @@ bool Kernel::begin_pending_interrupt(AllegrexContext &ctx) { interrupt_active_ = true; interrupt_on_return_ = std::move(call.on_return); ctx = pristine_context_; - for (std::uint32_t i = 0; i < 4u; ++i) ctx.set_gpr(4u + i, call.arguments[i]); + load_guest_call_arguments(ctx, call.arguments, call.argument_count); ctx.set_gpr(29, kInterruptStackTop - kThreadArgumentHome); ctx.set_gpr(31, kInterruptReturnStub); ctx.pc = call.function; @@ -955,10 +955,10 @@ void Kernel::thread_exit_stub(AllegrexContext &ctx) { current_thread()->name == "module_start"); } -void Kernel::call_guest(AllegrexContext &ctx, std::uint32_t function, const std::array &arguments, - GuestCallReturn on_return) { +void Kernel::call_guest(AllegrexContext &ctx, std::uint32_t function, const GuestCallArguments &arguments, + GuestCallReturn on_return, std::uint32_t argument_count) { guest_calls_[current_uid_].push_back(GuestCall{ctx.gpr[31], std::move(on_return)}); - for (std::uint32_t i = 0; i < 4u; ++i) ctx.set_gpr(4u + i, arguments[i]); + load_guest_call_arguments(ctx, arguments, argument_count); ctx.set_gpr(31, kGuestCallReturnStub); // A pc other than the import's own tells the import wrapper not to return. ctx.pc = function; diff --git a/host/kernel/kernel.hpp b/host/kernel/kernel.hpp index 019a08b..b1d4c7f 100644 --- a/host/kernel/kernel.hpp +++ b/host/kernel/kernel.hpp @@ -209,10 +209,24 @@ struct SubInterruptHandler { bool enabled{}; }; +// The argument registers of a call into the game: a0-a3, then t0-t3 for a +// function that takes more than four, such as a handler given an old state, +// a new state, an event, an error and its own argument. +inline constexpr std::uint32_t kGuestCallMaxArguments = 8u; +using GuestCallArguments = std::array; +// Loads a0-a3 always, as every call into the game did before calls took more +// than four, and t0 onwards only as far as `count` asks. +inline void load_guest_call_arguments(AllegrexContext &ctx, const GuestCallArguments &arguments, + std::uint32_t count) noexcept { + for (std::uint32_t i = 0; i < 4u; ++i) ctx.set_gpr(4u + i, arguments[i]); + for (std::uint32_t i = 4u; i < count && i < kGuestCallMaxArguments; ++i) ctx.set_gpr(8u + (i - 4u), arguments[i]); +} + // A guest function queued for execution in interrupt context. struct InterruptCall { std::uint32_t function{}; - std::array arguments{}; + GuestCallArguments arguments{}; + std::uint32_t argument_count = 4u; // how many of `arguments` it takes // Invoked on the host after the guest handler returns, with its v0. std::function on_return; }; @@ -290,9 +304,10 @@ class Kernel { // its v0 with `ctx` back at the import's return address. `on_return` then // either finishes the import or makes another call. Use it instead of // finish(), as the last thing the import does. + // `argument_count` above four also passes t0 onwards (GuestCallArguments). using GuestCallReturn = std::function; - void call_guest(AllegrexContext &ctx, std::uint32_t function, const std::array &arguments, - GuestCallReturn on_return); + void call_guest(AllegrexContext &ctx, std::uint32_t function, const GuestCallArguments &arguments, + GuestCallReturn on_return, std::uint32_t argument_count = 4u); void set_dispatch_enabled(bool enabled) noexcept { dispatch_enabled_ = enabled; } [[nodiscard]] bool dispatch_enabled() const noexcept { return dispatch_enabled_; } From 5d70a005a4a69eb05ccced3ae9d13b62c029af21 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:37:31 +0300 Subject: [PATCH 2/5] Add HLE extension modules: system functions from a separate library A library built apart from the framework can now implement PSP system functions, by library and NID, and be linked into every program the build makes, the desktop app with its automatic profile included. The framework knows nothing about any module. - include/portablekit/hle_extension.hpp is the stable interface: a Registry to add functions to, the built-in handler signature, and what a handler needs (arguments, finish, blocking on the host, delays, calls into the game with up to eight arguments, the thread id, the clock, the log and the environment). - A module is a directory whose CMakeLists.txt calls portablekit_add_hle_extension( LICENSE ... SOURCES ...) and whose sources define PORTABLEKIT_HLE_EXTENSION(). The build is given such directories in PORTABLEKIT_HLE_EXTENSION_DIRS (empty by default, which builds exactly what it did before). - Precedence: the framework's HLE and the profile's hook first; then each module in order. A fill-in is used only where nothing implements the function; an override replaces the implementation and is logged once; the first module to add a function keeps it. - The startup log names each module with its version and licence and counts its functions and overrides; LIST_STUBS names the imports each module serves. _NO_HLE_EXTENSIONS ignores every module. - examples/hle_extension is a module of one fill-in and one override that behave as the framework already does; portablekit_hle_extension_tests checks fill-in, override, precedence, the log lines and eight-argument calls with it. - docs/HLE_EXTENSIONS.md describes the interface, the build and licensing. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 34 +++ README.md | 1 + cmake/PortableKit.cmake | 98 ++++++++ docs/BRINGING_UP_A_GAME.md | 1 + docs/HLE_EXTENSIONS.md | 120 ++++++++++ examples/hle_extension/CMakeLists.txt | 8 + examples/hle_extension/example_extension.cpp | 28 +++ host/hle/hle_common.cpp | 8 +- host/hle/hle_common.hpp | 2 + host/hle/hle_extension_host.cpp | 66 ++++++ host/hle/hle_extensions.cpp | 122 ++++++++++ host/hle/hle_extensions.hpp | 113 +++++++++ host/system.cpp | 15 +- include/portablekit/hle_extension.hpp | 191 +++++++++++++++ tests/hle_extension_test_host.cpp | 55 +++++ tests/hle_extension_tests.cpp | 233 +++++++++++++++++++ 16 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 docs/HLE_EXTENSIONS.md create mode 100644 examples/hle_extension/CMakeLists.txt create mode 100644 examples/hle_extension/example_extension.cpp create mode 100644 host/hle/hle_extension_host.cpp create mode 100644 host/hle/hle_extensions.cpp create mode 100644 host/hle/hle_extensions.hpp create mode 100644 include/portablekit/hle_extension.hpp create mode 100644 tests/hle_extension_test_host.cpp create mode 100644 tests/hle_extension_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1cfa418..e0e07c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,25 @@ if(PSPRECOMP_LTO AND PSPRECOMP_LTO_SUPPORTED) set_property(GLOBAL PROPERTY PORTABLEKIT_IPO ON) endif() +# HLE extension modules (docs/HLE_EXTENSIONS.md): directories built apart from +# the framework, each with a CMakeLists.txt that calls +# portablekit_add_hle_extension(). Every program built here links them all. +# Empty, the default, builds exactly what the framework is on its own. +set(PORTABLEKIT_HLE_EXTENSION_DIRS "" CACHE STRING + "Directories of HLE extension modules to build into every program (a list)") +set(_portablekit_extension_index 0) +foreach(_extension_dir IN LISTS PORTABLEKIT_HLE_EXTENSION_DIRS) + get_filename_component(_extension_dir "${_extension_dir}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}") + if(NOT EXISTS "${_extension_dir}/CMakeLists.txt") + message(FATAL_ERROR "PORTABLEKIT_HLE_EXTENSION_DIRS: ${_extension_dir} has no CMakeLists.txt") + endif() + get_filename_component(_extension_leaf "${_extension_dir}" NAME) + string(MAKE_C_IDENTIFIER "${_extension_leaf}" _extension_leaf) + add_subdirectory("${_extension_dir}" + "${CMAKE_CURRENT_BINARY_DIR}/hle_extensions/${_portablekit_extension_index}_${_extension_leaf}") + math(EXPR _portablekit_extension_index "${_portablekit_extension_index} + 1") +endforeach() + # Keep the runtime as an object library so profile executables own its objects. # On Windows this also lets WINDOWS_EXPORT_ALL_SYMBOLS include runtime symbols # in the executable's import library for separately linked overlay modules. @@ -333,6 +352,20 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME portablekit_fast_loading_tests COMMAND portablekit_fast_loading_tests) endif() +# HLE extension modules: filling in and overriding against a runtime with no +# kernel, with the example module in examples/hle_extension/. +if(PSPRECOMP_BUILD_TESTS) + add_executable(portablekit_hle_extension_tests + "${PORTABLEKIT_ROOT}/tests/hle_extension_tests.cpp" + "${PORTABLEKIT_ROOT}/tests/hle_extension_test_host.cpp" + "${PORTABLEKIT_ROOT}/host/hle/hle_extensions.cpp" + "${PORTABLEKIT_ROOT}/examples/hle_extension/example_extension.cpp") + target_include_directories(portablekit_hle_extension_tests PRIVATE "${PORTABLEKIT_ROOT}/host") + target_link_libraries(portablekit_hle_extension_tests PRIVATE psprecomp_core) + set_target_properties(portablekit_hle_extension_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + add_test(NAME portablekit_hle_extension_tests COMMAND portablekit_hle_extension_tests) +endif() + # The system layer belongs to a game's target, so nothing above compiles # host/main.cpp or most of host/. That let two compile errors through until a # second game was built. This target builds the whole backend-independent @@ -366,6 +399,7 @@ if(PSPRECOMP_BUILD_TESTS) "${PORTABLEKIT_ROOT}/host" "${PORTABLEKIT_ROOT}/third_party" "${CMAKE_CURRENT_BINARY_DIR}/host_check_nids") target_link_libraries(portablekit_host_check PRIVATE psprecomp_core ${CMAKE_DL_LIBS}) + _portablekit_link_hle_extensions(portablekit_host_check) find_package(Threads REQUIRED) target_link_libraries(portablekit_host_check PRIVATE Threads::Threads) if(WIN32) diff --git a/README.md b/README.md index 7a81a59..55cfe29 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ A port is its own repository: a file of constants, a short `CMakeLists.txt` and - [Writing a profile](docs/PROFILE_GUIDE.md) - [Building](docs/BUILDING.md), and [why the build keeps its incremental state the way it does](docs/BUILD_SYSTEM.md) - [Architecture](docs/ARCHITECTURE.md) +- [HLE extension modules](docs/HLE_EXTENSIONS.md): system functions from a separately built library, filling in or overriding the framework's - [Instruction coverage](docs/INSTRUCTION_COVERAGE.md): which instructions a game has run, and which are implemented but unverified - [Where the code comes from, and third-party licences](docs/SOURCE_PROVENANCE.md) - [Contributing](CONTRIBUTING.md), and the [working notes](AGENTS.md) every change follows diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index a5ab095..48883bd 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -63,6 +63,8 @@ set(PORTABLEKIT_HOST_SOURCES host/hle/hle_utility.cpp host/hle/hle_savedata.cpp host/hle/hle_adhoc.cpp + host/hle/hle_extensions.cpp + host/hle/hle_extension_host.cpp host/adhoc/client.cpp host/adhoc/discovery.cpp host/adhoc/host.cpp @@ -142,6 +144,101 @@ function(_portablekit_inherit_settings target) endif() endfunction() +# portablekit_add_hle_extension( LICENSE SOURCES ... +# [TITLE ] [VERSION <version>] +# [INCLUDE_DIRECTORIES ...] [LINK_LIBRARIES ...] +# [COMPILE_DEFINITIONS ...]) +# declares an HLE extension module (docs/HLE_EXTENSIONS.md): a static library, +# built from SOURCES against include/portablekit/hle_extension.hpp, that +# defines PORTABLEKIT_HLE_EXTENSION(<name>). LICENSE is the licence the module +# states (an SPDX identifier such as "MIT" where there is one), and it, TITLE +# and VERSION are printed at startup and by --version. Every program portablekit_add_game() +# makes afterwards links it and calls that entry at startup. <name> is a C +# identifier and unique in the build. A module's directory is usually handed +# to the build in PORTABLEKIT_HLE_EXTENSION_DIRS, and its CMakeLists.txt is +# this one call; a port may also call it itself before portablekit_add_game(). +function(portablekit_add_hle_extension name) + cmake_parse_arguments(EXT "" "LICENSE;TITLE;VERSION" + "SOURCES;INCLUDE_DIRECTORIES;LINK_LIBRARIES;COMPILE_DEFINITIONS" ${ARGN}) + if(NOT name MATCHES "^[A-Za-z_][A-Za-z0-9_]*$") + message(FATAL_ERROR "portablekit_add_hle_extension: '${name}' is not a C identifier") + endif() + if(NOT EXT_SOURCES) + message(FATAL_ERROR "portablekit_add_hle_extension(${name}): no SOURCES") + endif() + if(NOT EXT_LICENSE) + message(FATAL_ERROR "portablekit_add_hle_extension(${name}): no LICENSE") + endif() + if(NOT EXT_TITLE) + set(EXT_TITLE "${name}") + endif() + foreach(field IN ITEMS LICENSE TITLE VERSION) + string(FIND "${EXT_${field}}" "\"" quote) + string(FIND "${EXT_${field}}" "\\" backslash) + string(FIND "${EXT_${field}}" "\n" newline) + if(NOT quote EQUAL -1 OR NOT backslash EQUAL -1 OR NOT newline EQUAL -1) + message(FATAL_ERROR "portablekit_add_hle_extension(${name}): ${field} may not contain quotes, backslashes or new lines") + endif() + endforeach() + get_property(known GLOBAL PROPERTY PORTABLEKIT_HLE_EXTENSIONS) + if(name IN_LIST known) + message(FATAL_ERROR "portablekit_add_hle_extension: a module named '${name}' was already added") + endif() + set(target "portablekit_hle_extension_${name}") + add_library(${target} STATIC ${EXT_SOURCES}) + target_compile_features(${target} PRIVATE cxx_std_20) + _portablekit_inherit_settings(${target}) + # Headers only, as for an overlay module: the runtime's objects belong to + # the program the module is linked into. host/ is there for a module that + # reaches past the stable interface, at its own risk. + target_include_directories(${target} PRIVATE + "${PORTABLEKIT_ROOT}/include" "${PORTABLEKIT_ROOT}/host" ${EXT_INCLUDE_DIRECTORIES}) + if(EXT_COMPILE_DEFINITIONS) + target_compile_definitions(${target} PRIVATE ${EXT_COMPILE_DEFINITIONS}) + endif() + if(EXT_LINK_LIBRARIES) + target_link_libraries(${target} PRIVATE ${EXT_LINK_LIBRARIES}) + endif() + set_property(GLOBAL APPEND PROPERTY PORTABLEKIT_HLE_EXTENSIONS "${name}") + set_property(GLOBAL PROPERTY PORTABLEKIT_HLE_EXTENSION_${name}_INFO + "\"${name}\", \"${EXT_TITLE}\", \"${EXT_VERSION}\", \"${EXT_LICENSE}\"") + message(STATUS "HLE extension module: ${EXT_TITLE} ${EXT_VERSION} (${EXT_LICENSE})") +endfunction() + +# Links every HLE extension module added so far into <target> and generates +# the list host/hle/hle_extensions.hpp's linked_hle_extensions() returns. +function(_portablekit_link_hle_extensions target) + get_property(modules GLOBAL PROPERTY PORTABLEKIT_HLE_EXTENSIONS) + set(declarations "") + set(entries "") + foreach(module IN LISTS modules) + string(APPEND declarations "void portablekit_hle_extension_${module}(::portablekit::hle_extension::Registry &);\n") + get_property(info GLOBAL PROPERTY PORTABLEKIT_HLE_EXTENSION_${module}_INFO) + string(APPEND entries " {${info}, &portablekit_hle_extension_${module}},\n") + target_link_libraries(${target} PRIVATE "portablekit_hle_extension_${module}") + endforeach() + if(modules) + set(body " static const HleExtensionModule modules[] = {\n${entries} };\n return modules;") + else() + set(body " return {};") + endif() + set(list_source "${CMAKE_CURRENT_BINARY_DIR}/generated_hle_extensions/${target}_hle_extensions.cpp") + file(CONFIGURE OUTPUT "${list_source}" CONTENT +"// Generated by cmake/PortableKit.cmake: the HLE extension modules linked into ${target}. +#include \"hle/hle_extensions.hpp\" + +${declarations} +namespace portablekit { + +std::span<const HleExtensionModule> linked_hle_extensions() { +${body} +} + +} // namespace portablekit +") + target_sources(${target} PRIVATE "${list_source}") +endfunction() + function(portablekit_add_game target) # Options a program that wraps the port uses (the desktop app, # apps/portablekit); a port passes none of them. @@ -324,6 +421,7 @@ function(portablekit_add_game target) "${GAME_PROFILE_DIR}/host" "${GAME_PROFILE_DIR}/generated") target_link_libraries(${target} PRIVATE psprecomp_core ${CMAKE_DL_LIBS}) + _portablekit_link_hle_extensions(${target}) if(WIN32) # The ad hoc client's and server's sockets, and the list of interfaces. target_link_libraries(${target} PRIVATE ws2_32 iphlpapi) diff --git a/docs/BRINGING_UP_A_GAME.md b/docs/BRINGING_UP_A_GAME.md index aa7dfa2..8ffd641 100644 --- a/docs/BRINGING_UP_A_GAME.md +++ b/docs/BRINGING_UP_A_GAME.md @@ -76,6 +76,7 @@ Every one of these takes the profile's own prefix, so `TENKAWA_TRACE_KERNEL` for | --- | --- | | `<PREFIX>_NO_RENDER`, `<PREFIX>_NO_AUDIO` | No window, no sound: a boot check that runs anywhere | | `<PREFIX>_LIST_STUBS` | Name every import nothing implements, at start-up | +| `<PREFIX>_NO_HLE_EXTENSIONS` | Ignore the [HLE extension modules](HLE_EXTENSIONS.md) the build links, for comparing with the framework's own HLE | | `<PREFIX>_STRICT_HLE` | Do not bind logging stubs for unimplemented imports, so the game stops at the first one instead of carrying on with a wrong answer | | `<PREFIX>_TRACE_KERNEL`, `_TRACE_IO`, `_TRACE_GE`, `_TRACE_SAVEDATA`, … | One subsystem each | | `<PREFIX>_TRACE_SYNC` | Every kernel object a thread waits on, which is how a deadlock is read | diff --git a/docs/HLE_EXTENSIONS.md b/docs/HLE_EXTENSIONS.md new file mode 100644 index 0000000..d8b6b84 --- /dev/null +++ b/docs/HLE_EXTENSIONS.md @@ -0,0 +1,120 @@ +# HLE extension modules + +An HLE extension module implements PSP system functions -- the HLE a game's imports reach, named by library and NID -- in a library built separately from PortableKit's own sources. It is linked into a program at build time. It can: + +- **fill in** functions PortableKit leaves as logging stubs, and +- **override** PortableKit's own implementation of a function, when it says so for that function. + +PortableKit knows nothing about any particular module. A build without modules is exactly the build it was before; a build with them says at startup which modules it carries and which of their functions are active. + +## Writing a module + +A module is a directory with a `CMakeLists.txt` and its sources: + +```text +my_module/ + CMakeLists.txt + my_module.cpp + LICENSE the module's own licence +``` + +```cmake +portablekit_add_hle_extension(my_module + TITLE "My module" # what the logs call it; the name when omitted + VERSION "0.3" # optional + LICENSE "MIT" # required: the licence the module states + SOURCES my_module.cpp) +``` + +`portablekit_add_hle_extension()` (in [`cmake/PortableKit.cmake`](../cmake/PortableKit.cmake)) makes a static library `portablekit_hle_extension_<name>` from `SOURCES`, with PortableKit's `include/` and `host/` on its include path and the framework's compiler settings. It also takes `INCLUDE_DIRECTORIES`, `LINK_LIBRARIES` and `COMPILE_DEFINITIONS` for the module's own needs. `<name>` is a C identifier, unique in the build. + +The sources include [`include/portablekit/hle_extension.hpp`](../include/portablekit/hle_extension.hpp) and define one entry function with the same name: + +```cpp +#include "portablekit/hle_extension.hpp" + +namespace ext = portablekit::hle_extension; + +PORTABLEKIT_HLE_EXTENSION(my_module) { + // Fills in: used only where PortableKit has no implementation. + registry.add("sceHttp", 0xAB1ABE07u, "sceHttpInit", [](ext::Runtime &rt, ext::AllegrexContext &ctx) { + const std::uint32_t pool_size = ext::arg(ctx, 0); + // ... read and write guest memory through rt.memory() ... + ext::finish(ctx, 0u); + }); + // Overrides: replaces PortableKit's implementation. + registry.override_builtin("UtilsForUser", 0x79D1C3FAu, "sceKernelDcacheWritebackAll", handler); +} +``` + +[`examples/hle_extension/`](../examples/hle_extension/) is a complete module of one fill-in and one override, both behaving as PortableKit already does. + +### The interface + +Everything a module may rely on is in `include/portablekit/hle_extension.hpp`. `PORTABLEKIT_HLE_EXTENSION_INTERFACE` is raised when it changes incompatibly. + +| | | +| --- | --- | +| `Registry::add(library, nid, name, handler, mode)` | Adds a function. `name` is for logs and may be empty where [`configs/nids.csv`](../configs/nids.csv) names the NID; a name the table lacks is added to it. `mode` is `Mode::FillIn` (the default) or `Mode::Override`. | +| `Registry::override_builtin(library, nid, name, handler)` | `add` with `Mode::Override`. | +| `Handler` | `void(Runtime &, AllegrexContext &)`, the signature of PortableKit's own handlers. | +| `arg(ctx, i)`, `arg64(ctx, i)` | Arguments in a0-a3, t0-t3; a 64-bit one in an even-aligned pair. | +| `Runtime::memory()`, `read_cstring()` | Guest memory by guest address. | +| `finish(ctx, v)`, `finish64(ctx, v)` | Return a result. The last thing a handler does, unless it blocks. | +| `wait_host(ctx, timeout, poll)` | Block the calling thread until `poll` returns a value, which becomes the result. For work the host does meanwhile (a socket, a worker thread). | +| `delay(ctx, us, result)` | Block the calling thread for a time. | +| `call_guest(ctx, function, {args...}, on_return)` | Call back into the game in the calling thread, as a library does; `on_return` then finishes the import or calls again. | +| `queue_guest_call(function, {args...}, on_return)` | Queue a call in interrupt context, as the system delivers a handler the game registered (an event, a state change). | +| Arguments of either | Up to eight (`kMaxGuestArguments`): a0-a3, then t0-t3. | +| `current_thread_id()` | The calling guest thread's id. | +| `now_us()` | Emulated time since boot. | +| `log_once(key, message)` | Log a line the first time `key` is seen. | +| `env(name)` | The port's variable `<PREFIX>_<name>`. | + +Handlers run on the emulation thread, one at a time. A module may include PortableKit's `host/` headers to reach further than this, but those change without notice; a need that comes up twice belongs in the header instead. + +## Building modules into a program + +Give the build the modules' directories, as a CMake list: + +```sh +cmake -S . -B out -G Ninja -DPORTABLEKIT_HLE_EXTENSION_DIRS="/path/to/my_module;/path/to/other" +cmake -S apps/portablekit -B out-app -G Ninja -DPORTABLEKIT_HLE_EXTENSION_DIRS=/path/to/my_module +``` + +The framework's `CMakeLists.txt` adds each directory, in order, before any program is made, and every program `portablekit_add_game()` makes afterwards -- a port, or the desktop app with its automatic profile -- links all of them. A port may also call `portablekit_add_hle_extension()` itself, between adding PortableKit and `portablekit_add_game()`. A relative directory is taken relative to the top-level source directory. Empty, the default, adds nothing. + +## Precedence + +At startup, `install_system()` registers PortableKit's HLE, then the profile's `register_extra_hle` hook, and then applies the modules in the order the build was given them: + +1. A **fill-in** is bound only where neither PortableKit nor the profile implements the function. Where one does, theirs stays and the startup log says the module's function is not used. +2. An **override** is bound whether or not the function is implemented. Where it replaces an implementation it is logged, once per function, at startup. +3. Between modules, and within one, **the first to add a function keeps it**; a later addition is logged as not used. + +Only after that are the remaining imports bound to logging stubs, so a module's function is never replaced by a stub. + +## What the log says + +```text +HLE extension: Example HLE extension 1.0 (MIT) +HLE extensions: 2 functions (1 overriding built-ins) from Example HLE extension +[hle-extension] UtilsForUser::sceKernelDcacheWritebackAll is Example HLE extension's, overriding the built-in +HLE imports: 412 total, 301 implemented, 111 logging stubs; 1 imports served by HLE extensions +``` + +With `<PREFIX>_LIST_STUBS`, the list of unimplemented imports is followed by the imports extension modules serve, each with its module and whether it fills in or overrides. + +`<PREFIX>_NO_HLE_EXTENSIONS=1` ignores every module for that run, leaving exactly PortableKit's own HLE, so the two can be compared on the same build: + +```text +HLE extensions: 1 module linked, ignored (PORTABLEKIT_NO_HLE_EXTENSIONS) +``` + +## Tests + +`portablekit_hle_extension_tests` builds the example module into a runtime with no kernel and checks a fill-in, an override, a fill-in left to the built-in, two modules adding one function, and the log lines. + +## Licensing + +An extension module may carry its own licence, which it states in `LICENSE` of its `portablekit_add_hle_extension()` call and which the program prints at startup. PortableKit's own code stays under its MIT licence. A build that includes a module is distributed under the terms that combination requires; whoever distributes such a build is responsible for meeting them, including shipping the module's licence text and, where its licence asks for it, its source. diff --git a/examples/hle_extension/CMakeLists.txt b/examples/hle_extension/CMakeLists.txt new file mode 100644 index 0000000..cde6d18 --- /dev/null +++ b/examples/hle_extension/CMakeLists.txt @@ -0,0 +1,8 @@ +# An HLE extension module, as small as one can be (docs/HLE_EXTENSIONS.md). +# Build it into a program with +# -DPORTABLEKIT_HLE_EXTENSION_DIRS=<PortableKit>/examples/hle_extension +portablekit_add_hle_extension(example + TITLE "Example HLE extension" + VERSION "1.0" + LICENSE "MIT" + SOURCES example_extension.cpp) diff --git a/examples/hle_extension/example_extension.cpp b/examples/hle_extension/example_extension.cpp new file mode 100644 index 0000000..692258b --- /dev/null +++ b/examples/hle_extension/example_extension.cpp @@ -0,0 +1,28 @@ +// An example HLE extension module: one function filled in, one overridden. +// Both behave as PortableKit already does, so building it into a program +// changes nothing a game can see; it shows the mechanism and is what +// tests/hle_extension_tests.cpp checks it with. + +#include "portablekit/hle_extension.hpp" + +namespace { + +namespace ext = portablekit::hle_extension; + +// sceHttpsLoadDefaultCert, which PortableKit leaves to a logging stub: the +// stub already returns 0, and this returns 0 without the log line. +void load_default_cert(ext::Runtime &, ext::AllegrexContext &ctx) { ext::finish(ctx, 0u); } + +// sceKernelDcacheWritebackAll: PortableKit's own returns 0 too, since the host +// has no data cache to write back. The override says once that it ran. +void writeback_data_cache(ext::Runtime &, ext::AllegrexContext &ctx) { + ext::log_once("example-dcache", "[example] sceKernelDcacheWritebackAll handled by the example extension"); + ext::finish(ctx, 0u); +} + +} // namespace + +PORTABLEKIT_HLE_EXTENSION(example) { + registry.add("sceHttp", 0x87797BDDu, "sceHttpsLoadDefaultCert", load_default_cert); + registry.override_builtin("UtilsForUser", 0x79D1C3FAu, "sceKernelDcacheWritebackAll", writeback_data_cache); +} diff --git a/host/hle/hle_common.cpp b/host/hle/hle_common.cpp index 6fb018e..723d326 100644 --- a/host/hle/hle_common.cpp +++ b/host/hle/hle_common.cpp @@ -41,11 +41,15 @@ HleRegistrar::HleRegistrar(Runtime &runtime) : runtime_(runtime) { bool HleRegistrar::try_add(std::string_view library, std::string_view name, HleFunction function) { const auto found = nids_by_name_.find({std::string(library), std::string(name)}); if (found == nids_by_name_.end()) return false; - runtime_.register_hle(std::string(library), found->second, std::move(function)); - bound_.insert({std::string(library), found->second}); + bind(std::string(library), found->second, std::move(function)); return true; } +void HleRegistrar::bind(const std::string &library, std::uint32_t nid, HleFunction function) { + runtime_.register_hle(library, nid, std::move(function)); + bound_.insert({library, nid}); +} + void HleRegistrar::add(std::string_view library, std::string_view name, HleFunction function) { if (!try_add(library, name, std::move(function))) throw psprecomp::Error("Unknown HLE function " + std::string(library) + "::" + std::string(name) + diff --git a/host/hle/hle_common.hpp b/host/hle/hle_common.hpp index 1495529..41a817f 100644 --- a/host/hle/hle_common.hpp +++ b/host/hle/hle_common.hpp @@ -42,6 +42,8 @@ class HleRegistrar { void add(std::string_view library, std::string_view name, HleFunction function); // Binds `function` if the name is known; returns false otherwise. bool try_add(std::string_view library, std::string_view name, HleFunction function); + // Binds `function` by NID, replacing whatever was bound to it before. + void bind(const std::string &library, std::uint32_t nid, HleFunction function); [[nodiscard]] bool bound(const std::string &library, std::uint32_t nid) const; [[nodiscard]] std::size_t count() const noexcept { return bound_.size(); } diff --git a/host/hle/hle_extension_host.cpp b/host/hle/hle_extension_host.cpp new file mode 100644 index 0000000..13ea17f --- /dev/null +++ b/host/hle/hle_extension_host.cpp @@ -0,0 +1,66 @@ +// The host's side of the HLE extension interface: what a module's handlers +// call (include/portablekit/hle_extension.hpp) and installing the modules the +// program was built with. + +#include "../profile.hpp" +#include "hle_common.hpp" +#include "hle_extensions.hpp" + +#include <iostream> + +namespace portablekit { + +namespace hle_extension { + +void finish(AllegrexContext &ctx, std::uint32_t result) { kernel().finish(ctx, result); } +void finish64(AllegrexContext &ctx, std::uint64_t result) { kernel().finish64(ctx, result); } +std::uint64_t now_us() { return kernel().now_us(); } +void wait_host(AllegrexContext &ctx, std::optional<std::uint64_t> timeout_us, HostPoll poll) { + kernel().wait_host(ctx, timeout_us, std::move(poll)); +} +void delay(AllegrexContext &ctx, std::uint64_t microseconds, std::uint32_t result) { + kernel().delay_current(ctx, microseconds, result); +} +void call_guest(AllegrexContext &ctx, std::uint32_t function, std::span<const std::uint32_t> arguments, + GuestReturn on_return) { + const auto [packed, count] = guest_call_arguments(arguments); + kernel().call_guest(ctx, function, packed, std::move(on_return), count); +} +void queue_guest_call(std::uint32_t function, std::span<const std::uint32_t> arguments, QueuedReturn on_return) { + const auto [packed, count] = guest_call_arguments(arguments); + InterruptCall call{}; + call.function = function; + call.arguments = packed; + call.argument_count = count; + if (on_return) call.on_return = std::move(on_return); + kernel().queue_interrupt(std::move(call)); +} +std::int32_t current_thread_id() { return kernel().in_interrupt() ? 0 : kernel().current_uid(); } +void log_once(std::string_view key, std::string_view message) { + portablekit::log_once("hle-extension:" + std::string(key), std::string(message)); +} +const char *env(const char *name) { return portablekit::env(name); } + +} // namespace hle_extension + +HleExtensionReport install_linked_hle_extensions(Runtime &runtime, HleRegistrar &hle) { + const std::span<const HleExtensionModule> modules = linked_hle_extensions(); + if (modules.empty()) return {}; + print_hle_extension_modules(std::cout, modules); + if (env_set("NO_HLE_EXTENSIONS")) { + std::cout << "HLE extensions: " << modules.size() << (modules.size() == 1u ? " module" : " modules") + << " linked, ignored (" << env_name("NO_HLE_EXTENSIONS") << ")\n"; + return {.disabled = true, .linked = modules.size()}; + } + const HleExtensionTarget target{ + .implemented = [&hle](const std::string &library, std::uint32_t nid) { return hle.bound(library, nid); }, + .bind = [&hle](const std::string &library, std::uint32_t nid, hle_extension::Handler handler) { + hle.bind(library, nid, std::move(handler)); + }, + }; + HleExtensionReport report = apply_hle_extensions(runtime, modules, target); + print_hle_extension_summary(std::cout, report); + return report; +} + +} // namespace portablekit diff --git a/host/hle/hle_extensions.cpp b/host/hle/hle_extensions.cpp new file mode 100644 index 0000000..33ce0e2 --- /dev/null +++ b/host/hle/hle_extensions.cpp @@ -0,0 +1,122 @@ +#include "hle_extensions.hpp" + +#include "psprecomp/common.hpp" + +#include <algorithm> +#include <map> +#include <ostream> +#include <set> +#include <utility> + +namespace portablekit { +namespace { + +std::string display_name(psprecomp::Runtime &runtime, const hle_extension::Function &function) { + if (!function.name.empty()) return function.name; + return runtime.nids().resolve(function.library, function.nid).value_or(psprecomp::hex32(function.nid)); +} + +} // namespace + +const HleExtensionBinding *HleExtensionReport::find(const std::string &library, std::uint32_t nid) const { + for (const HleExtensionBinding &binding : active) + if (binding.nid == nid && binding.library == library) return &binding; + return nullptr; +} + +HleExtensionReport apply_hle_extensions(psprecomp::Runtime &runtime, std::span<const HleExtensionModule> modules, + const HleExtensionTarget &target) { + HleExtensionReport report; + report.linked = modules.size(); + // Which module bound each function, so a later one does not replace it. + std::map<std::pair<std::string, std::uint32_t>, std::string> taken; + + for (const HleExtensionModule &module : modules) { + hle_extension::Registry registry; + module.entry(registry); + HleExtensionModuleSummary summary{.name = module.title}; + + for (const hle_extension::Function &function : registry.functions()) { + if (function.library.empty() || !function.handler) + throw psprecomp::Error(std::string("HLE extension ") + module.title + " added " + + (function.library.empty() ? "a function without a library" + : function.library + " " + + psprecomp::hex32(function.nid) + + " without a handler")); + const std::string name = display_name(runtime, function); + if (!function.name.empty() && !runtime.nids().resolve(function.library, function.nid)) + runtime.nids().add(function.library, function.nid, function.name); + + const auto key = std::make_pair(function.library, function.nid); + const auto skip = [&](std::string reason) { + report.skipped.push_back({function.library, function.nid, name, module.title, std::move(reason)}); + ++summary.skipped; + }; + if (const auto found = taken.find(key); found != taken.end()) { + skip("already provided by " + found->second); + continue; + } + const bool implemented = target.implemented(function.library, function.nid); + if (implemented && function.mode == hle_extension::Mode::FillIn) { + skip("implemented by the program; not marked as an override"); + continue; + } + const bool overrides = implemented; + target.bind(function.library, function.nid, function.handler); + taken.emplace(key, module.title); + report.active.push_back({function.library, function.nid, name, module.title, overrides}); + ++summary.functions; + if (overrides) ++summary.overrides; + } + report.modules.push_back(std::move(summary)); + } + return report; +} + +std::pair<GuestCallArguments, std::uint32_t> guest_call_arguments(std::span<const std::uint32_t> arguments) { + static_assert(hle_extension::kMaxGuestArguments == kGuestCallMaxArguments); + if (arguments.size() > kGuestCallMaxArguments) + throw psprecomp::Error("A call into the game takes at most " + std::to_string(kGuestCallMaxArguments) + + " arguments, not " + std::to_string(arguments.size())); + GuestCallArguments packed{}; + std::copy(arguments.begin(), arguments.end(), packed.begin()); + return {packed, static_cast<std::uint32_t>(arguments.size())}; +} + +void print_hle_extension_modules(std::ostream &out, std::span<const HleExtensionModule> modules) { + for (const HleExtensionModule &module : modules) { + out << "HLE extension: " << module.title; + if (module.version != nullptr && *module.version != '\0') out << " " << module.version; + out << " (" << module.license << ")\n"; + } +} + +void print_hle_extension_summary(std::ostream &out, const HleExtensionReport &report) { + for (const HleExtensionModuleSummary &module : report.modules) { + out << "HLE extensions: " << module.functions << (module.functions == 1u ? " function" : " functions") + << " (" << module.overrides << " overriding built-ins"; + if (module.skipped != 0u) out << ", " << module.skipped << " not used"; + out << ") from " << module.name << "\n"; + } + for (const HleExtensionBinding &binding : report.active) + if (binding.overrides) + out << "[hle-extension] " << binding.library << "::" << binding.name << " is " << binding.module + << "'s, overriding the built-in\n"; + for (const HleExtensionSkip &skip : report.skipped) + out << "[hle-extension] " << skip.library << "::" << skip.name << " from " << skip.module + << " not used: " << skip.reason << "\n"; +} + +void print_hle_extension_imports(std::ostream &out, const HleExtensionReport &report, + std::span<const psprecomp::PspImport> imports) { + if (report.active.empty()) return; + std::set<std::string> lines; + for (const psprecomp::PspImport &import : imports) + if (const HleExtensionBinding *binding = report.find(import.library, import.nid)) + lines.insert(binding->library + "::" + binding->name + " " + binding->module + + (binding->overrides ? " (overrides the built-in)" : " (fills in)")); + out << "Imports served by HLE extensions: " << lines.size() << "\n"; + for (const std::string &line : lines) out << " " << line << "\n"; +} + +} // namespace portablekit diff --git a/host/hle/hle_extensions.hpp b/host/hle/hle_extensions.hpp new file mode 100644 index 0000000..9739f73 --- /dev/null +++ b/host/hle/hle_extensions.hpp @@ -0,0 +1,113 @@ +#pragma once + +// Applying HLE extension modules (include/portablekit/hle_extension.hpp) on +// top of the HLE a program registers itself. See docs/HLE_EXTENSIONS.md. + +#include "kernel/kernel.hpp" +#include "portablekit/hle_extension.hpp" +#include "psprecomp/elf32.hpp" + +#include <cstddef> +#include <cstdint> +#include <functional> +#include <iosfwd> +#include <span> +#include <string> +#include <vector> + +namespace portablekit { + +class HleRegistrar; + +// One module linked into the program, as its portablekit_add_hle_extension() +// call declares it. +struct HleExtensionModule { + const char *name; // the C identifier its entry is named after + const char *title; // what the logs call it; the name when none was given + const char *version; // may be empty + const char *license; // the licence it states, e.g. "MIT" + void (*entry)(hle_extension::Registry &); +}; + +// The modules this program was built with, in the order the build was given +// them. Defined by a source the build generates for each program +// (cmake/PortableKit.cmake); empty when it was given none. +[[nodiscard]] std::span<const HleExtensionModule> linked_hle_extensions(); + +// A function an extension module provides and the program now runs. +struct HleExtensionBinding { + std::string library; + std::uint32_t nid{}; + std::string name; // as the module or the NID table names it; hex NID otherwise + std::string module; + bool overrides{}; // replaced the program's own implementation +}; + +// A function an extension module added that the program does not run, and why. +struct HleExtensionSkip { + std::string library; + std::uint32_t nid{}; + std::string name; + std::string module; + std::string reason; +}; + +struct HleExtensionModuleSummary { + std::string name; + std::size_t functions{}; // active, overrides included + std::size_t overrides{}; + std::size_t skipped{}; +}; + +struct HleExtensionReport { + bool disabled{}; // modules were linked and the off switch ignored them + std::size_t linked{}; // modules linked into the program + std::vector<HleExtensionModuleSummary> modules; + std::vector<HleExtensionBinding> active; + std::vector<HleExtensionSkip> skipped; + + [[nodiscard]] const HleExtensionBinding *find(const std::string &library, std::uint32_t nid) const; +}; + +// How the program's own HLE is asked and changed: whether it implements a +// function, and binding one (replacing whatever was bound before). +struct HleExtensionTarget { + std::function<bool(const std::string &library, std::uint32_t nid)> implemented; + std::function<void(const std::string &library, std::uint32_t nid, hle_extension::Handler handler)> bind; +}; + +// Calls each module's entry and binds what it added, in this order of +// precedence: +// - Mode::FillIn binds only a function the program does not implement; +// otherwise the program's own stays and the function is reported skipped. +// - Mode::Override binds whether or not the program implements it, and +// counts as an override only where it does. +// - Between modules, and within one, the first to add a function keeps it; +// a later addition is reported skipped. +// Names the NID table does not know are added to it, so logs show them. +// Throws psprecomp::Error for a function without a handler or library. +[[nodiscard]] HleExtensionReport apply_hle_extensions(psprecomp::Runtime &runtime, + std::span<const HleExtensionModule> modules, + const HleExtensionTarget &target); + +// One line per module: "HLE extension: <title> <version> (<licence>)", for +// the startup log and for a program's --version. +void print_hle_extension_modules(std::ostream &out, std::span<const HleExtensionModule> modules); +// The startup lines: one per module ("HLE extensions: 2 functions (1 overrides +// a built-in) from example"), then one per override and per skipped function. +void print_hle_extension_summary(std::ostream &out, const HleExtensionReport &report); +// For <prefix>_LIST_STUBS: the game's imports that extension modules serve, +// and which module serves each. +void print_hle_extension_imports(std::ostream &out, const HleExtensionReport &report, + std::span<const psprecomp::PspImport> imports); + +// The arguments of a call into the game as the kernel takes them, and how +// many there are. Throws psprecomp::Error past kGuestCallMaxArguments. +[[nodiscard]] std::pair<GuestCallArguments, std::uint32_t> guest_call_arguments( + std::span<const std::uint32_t> arguments); + +// The host's side (hle_extension_host.cpp): applies linked_hle_extensions() to +// `hle`, unless <prefix>_NO_HLE_EXTENSIONS is set, and prints the summary. +[[nodiscard]] HleExtensionReport install_linked_hle_extensions(psprecomp::Runtime &runtime, HleRegistrar &hle); + +} // namespace portablekit diff --git a/host/system.cpp b/host/system.cpp index 03fd09c..ddb5068 100644 --- a/host/system.cpp +++ b/host/system.cpp @@ -4,6 +4,7 @@ #include "profile.hpp" #include "hle/hle_common.hpp" +#include "hle/hle_extensions.hpp" #include "kernel/kernel.hpp" #include "overlays.hpp" @@ -93,6 +94,10 @@ void install_system(Runtime &runtime, const psprecomp::Elf32Image &elf, const Pr register_utility(hle, paths.memory_stick); register_adhoc(hle); if (const ExtraHleHook hook = active_register_extra_hle(); hook != nullptr) hook(hle); + // Extension modules the build linked in (docs/HLE_EXTENSIONS.md): after + // everything above, so they fill in what it leaves and override only what + // they say they override. + const HleExtensionReport extensions = install_linked_hle_extensions(runtime, hle); const bool strict = env_set("STRICT_HLE"); std::size_t stubbed = 0u; @@ -119,6 +124,7 @@ void install_system(Runtime &runtime, const psprecomp::Elf32Image &elf, const Pr std::cout << " " << library << " (" << names.size() << ")\n"; for (const std::string &name : names) std::cout << " " << name << "\n"; } + print_hle_extension_imports(std::cout, extensions, imports); } // Without a corpus there is nothing at the import stubs, so the // interpreter would run their own two instructions and carry on with @@ -128,7 +134,14 @@ void install_system(Runtime &runtime, const psprecomp::Elf32Image &elf, const Pr if (runtime.register_import_stub(import.stub_address, import.library, import.nid)) ++bound_stubs; if (bound_stubs != 0u) std::cout << "Import stubs: " << bound_stubs << " bound for interpretation\n"; std::cout << "HLE imports: " << imports.size() << " total, " << hle.count() << " implemented, " << stubbed - << " logging stubs" << (strict ? " (strict mode)" : "") << "\n"; + << " logging stubs" << (strict ? " (strict mode)" : ""); + if (!extensions.active.empty()) { + std::size_t served = 0u; + for (const auto &import : imports) + if (extensions.find(import.library, import.nid) != nullptr) ++served; + std::cout << "; " << served << " imports served by HLE extensions"; + } + std::cout << "\n"; auto &ctx = runtime.cpu(); auto &memory = runtime.memory(); diff --git a/include/portablekit/hle_extension.hpp b/include/portablekit/hle_extension.hpp new file mode 100644 index 0000000..aad7ce0 --- /dev/null +++ b/include/portablekit/hle_extension.hpp @@ -0,0 +1,191 @@ +#pragma once + +// The interface an HLE extension module is written against. +// +// An extension module is a library built separately from PortableKit's own +// sources and linked into a program at build time (PORTABLEKIT_HLE_EXTENSION_DIRS, +// see docs/HLE_EXTENSIONS.md). It implements PSP system functions -- the HLE +// the game's imports reach, named by library and NID -- that PortableKit +// leaves as logging stubs, and, where it asks to, replaces PortableKit's own +// implementation of a function. +// +// PortableKit knows nothing about any module. A module defines one entry +// function with PORTABLEKIT_HLE_EXTENSION(name); the build lists the modules +// it was given and the host calls each entry once at startup, after its own +// HLE is registered, with a Registry to add functions to. +// +// This header is the stable part of the interface. A handler may also include +// headers from PortableKit's host/ directory, which the build puts on the +// module's include path, but those change without notice. + +#include "psprecomp/allegrex_context.hpp" +#include "psprecomp/guest_memory.hpp" +#include "psprecomp/runtime.hpp" + +#include <cstddef> +#include <cstdint> +#include <functional> +#include <initializer_list> +#include <optional> +#include <span> +#include <string> +#include <string_view> +#include <utility> +#include <vector> + +// Raised when this header changes incompatibly, so a module can check what it +// is being built against. +#define PORTABLEKIT_HLE_EXTENSION_INTERFACE 1 + +namespace portablekit::hle_extension { + +using Runtime = psprecomp::Runtime; +using AllegrexContext = psprecomp::AllegrexContext; +using GuestMemory = psprecomp::GuestMemory; + +// The signature of every HLE handler, PortableKit's own included. The handler +// runs when the game calls the import, with `ctx` holding the caller's +// registers; it reads its arguments from them and ends with finish(). +using Handler = std::function<void(Runtime &, AllegrexContext &)>; + +// How a function relates to one PortableKit already implements. +enum class Mode { + // Only where PortableKit has no implementation of its own: the function + // replaces the logging stub. A function PortableKit implements keeps + // PortableKit's implementation, and the startup report says so. + FillIn, + // Replaces PortableKit's implementation. The host logs each override once + // at startup, so it is always visible which implementation is active. + Override, +}; + +struct Function { + std::string library; // the PSP library the game imports it from, e.g. "sceHttp" + std::uint32_t nid{}; + std::string name; // for logs; may be empty when PortableKit's NID table names it + Handler handler; + Mode mode = Mode::FillIn; +}; + +// What a module's entry function fills in. Adding a function does nothing yet; +// the host applies what was added after the entry returns. +class Registry { +public: + void add(std::string_view library, std::uint32_t nid, std::string_view name, Handler handler, + Mode mode = Mode::FillIn) { + functions_.push_back(Function{std::string(library), nid, std::string(name), std::move(handler), mode}); + } + void override_builtin(std::string_view library, std::uint32_t nid, std::string_view name, Handler handler) { + add(library, nid, name, std::move(handler), Mode::Override); + } + + [[nodiscard]] const std::vector<Function> &functions() const noexcept { return functions_; } + +private: + std::vector<Function> functions_; +}; + +// --- What a handler needs -------------------------------------------------- + +// PSP EABI argument registers: a0-a3, then t0-t3. +[[nodiscard]] inline std::uint32_t arg(const AllegrexContext &ctx, unsigned index) noexcept { + return index < 4u ? ctx.gpr[4u + index] : ctx.gpr[8u + (index - 4u)]; +} + +// A 64-bit argument occupies an even-aligned register pair, low word first. +[[nodiscard]] inline std::uint64_t arg64(const AllegrexContext &ctx, unsigned low_index) noexcept { + return static_cast<std::uint64_t>(arg(ctx, low_index)) | + (static_cast<std::uint64_t>(arg(ctx, low_index + 1u)) << 32u); +} + +// Guest memory is Runtime::memory(): load8/16/32 and store8/16/32 by guest +// address. A NUL-terminated string at `address`, at most `max_length` bytes. +[[nodiscard]] inline std::string read_cstring(const GuestMemory &memory, std::uint32_t address, + std::size_t max_length = 512u) { + std::string text; + if (address == 0u) return text; + for (std::size_t i = 0; i < max_length; ++i) { + const auto c = static_cast<char>(memory.load8(address + static_cast<std::uint32_t>(i))); + if (c == '\0') break; + text.push_back(c); + } + return text; +} + +// Implemented by the host. + +// Returns `result` to the game in v0 and lets the kernel switch threads or +// deliver an interrupt that became due, exactly as PortableKit's own handlers +// return. It is the last thing a handler does. +void finish(AllegrexContext &ctx, std::uint32_t result); +// The same for a 64-bit result, in v0 (low word) and v1. +void finish64(AllegrexContext &ctx, std::uint64_t result); +// Emulated time since boot, in microseconds. +[[nodiscard]] std::uint64_t now_us(); + +// Blocking. Each of these ends the handler in place of finish(): the calling +// guest thread waits, other threads run, and the game sees the import return +// when the wait is over. + +// Waits until `poll` returns a value, which becomes the import's result. +// `poll` runs on the emulation thread whenever the scheduler looks for work, +// and at least every millisecond of emulated time; `timed_out` is true on the +// last call, once `timeout_us` of emulated time has passed (none: no limit). +// What a handler waits for on the host (a socket, a worker thread) it checks +// here; the poll must not block. +using HostPoll = std::function<std::optional<std::uint32_t>(bool timed_out)>; +void wait_host(AllegrexContext &ctx, std::optional<std::uint64_t> timeout_us, HostPoll poll); +// Puts the calling thread to sleep for `microseconds` of emulated time, then +// returns `result`. +void delay(AllegrexContext &ctx, std::uint64_t microseconds, std::uint32_t result = 0u); + +// Calls into the game. `arguments` go in a0-a3 and then t0-t3, so a call takes +// up to kMaxGuestArguments; a0-a3 are always set (to 0 past the last given). +inline constexpr std::size_t kMaxGuestArguments = 8u; + +// Calls the guest function at `function` in the calling thread, the way a +// library calls back into the game; it ends the handler in place of finish(). +// When the function returns, `on_return` receives its v0 with `ctx` back at +// the import's return address, and either ends the import (finish()) or calls +// again. More than kMaxGuestArguments arguments throw. +using GuestReturn = std::function<void(AllegrexContext &ctx, std::uint32_t result)>; +void call_guest(AllegrexContext &ctx, std::uint32_t function, std::span<const std::uint32_t> arguments, + GuestReturn on_return); +inline void call_guest(AllegrexContext &ctx, std::uint32_t function, std::initializer_list<std::uint32_t> arguments, + GuestReturn on_return) { + call_guest(ctx, function, std::span<const std::uint32_t>(arguments.begin(), arguments.size()), + std::move(on_return)); +} +// Queues a call of `function` in interrupt context, as the system delivers a +// handler the game registered (an event, a state change): it runs when the +// game next allows interrupts, not inside this handler, which still ends +// with finish(). `on_return`, which may be null, receives its v0. +using QueuedReturn = std::function<void(std::uint32_t result)>; +void queue_guest_call(std::uint32_t function, std::span<const std::uint32_t> arguments, + QueuedReturn on_return = nullptr); +inline void queue_guest_call(std::uint32_t function, std::initializer_list<std::uint32_t> arguments, + QueuedReturn on_return = nullptr) { + queue_guest_call(function, std::span<const std::uint32_t>(arguments.begin(), arguments.size()), + std::move(on_return)); +} +// The kernel's id (SceUID) of the guest thread that made the call; 0 in +// interrupt context. +[[nodiscard]] std::int32_t current_thread_id(); +// Writes `message` to the log the first time `key` is seen. +void log_once(std::string_view key, std::string_view message); +// The port's environment variable <prefix>_<name> (for example +// PORTABLEKIT_<name> in the desktop app), or null when it is not set. +[[nodiscard]] const char *env(const char *name); + +} // namespace portablekit::hle_extension + +// Defines a module's entry function. `name` is the module's name as its +// CMakeLists.txt gives it to portablekit_add_hle_extension(), a C identifier: +// +// PORTABLEKIT_HLE_EXTENSION(my_module) { +// registry.add("sceHttp", 0xAB1ABE07u, "sceHttpInit", [](auto &, auto &ctx) { +// portablekit::hle_extension::finish(ctx, 0u); +// }); +// } +#define PORTABLEKIT_HLE_EXTENSION(name) \ + void portablekit_hle_extension_##name(::portablekit::hle_extension::Registry ®istry) diff --git a/tests/hle_extension_test_host.cpp b/tests/hle_extension_test_host.cpp new file mode 100644 index 0000000..ed1b1bd --- /dev/null +++ b/tests/hle_extension_test_host.cpp @@ -0,0 +1,55 @@ +// The host side of include/portablekit/hle_extension.hpp for +// tests/hle_extension_tests.cpp, which runs handlers with no kernel: a result +// goes to v0 and the rest does as little as it can while staying observable. + +#include "hle/hle_extensions.hpp" +#include "portablekit/hle_extension.hpp" + +#include <cstdio> +#include <set> +#include <string> + +namespace portablekit { +InterruptCall &test_last_queued_call() { + static InterruptCall call; + return call; +} +} // namespace portablekit + +namespace portablekit::hle_extension { + +void finish(AllegrexContext &ctx, std::uint32_t result) { ctx.set_gpr(2, result); } +void finish64(AllegrexContext &ctx, std::uint64_t result) { + ctx.set_gpr(3, static_cast<std::uint32_t>(result >> 32u)); + ctx.set_gpr(2, static_cast<std::uint32_t>(result)); +} +std::uint64_t now_us() { return 0u; } +void log_once(std::string_view key, std::string_view message) { + static std::set<std::string> seen; + if (seen.insert(std::string(key)).second) std::printf("%.*s\n", static_cast<int>(message.size()), message.data()); +} +const char *env(const char *) { return nullptr; } +void wait_host(AllegrexContext &ctx, std::optional<std::uint64_t>, HostPoll poll) { + if (const auto result = poll(true)) ctx.set_gpr(2, *result); +} +void delay(AllegrexContext &ctx, std::uint64_t, std::uint32_t result) { ctx.set_gpr(2, result); } +// A call into the game loads the registers the kernel would and returns at +// once; the test reads them from ctx. +void call_guest(AllegrexContext &ctx, std::uint32_t function, std::span<const std::uint32_t> arguments, + GuestReturn on_return) { + const auto [packed, count] = portablekit::guest_call_arguments(arguments); + portablekit::load_guest_call_arguments(ctx, packed, count); + ctx.pc = function; + on_return(ctx, 0u); +} +// A queued call is kept for the test to look at. +void queue_guest_call(std::uint32_t function, std::span<const std::uint32_t> arguments, QueuedReturn) { + const auto [packed, count] = portablekit::guest_call_arguments(arguments); + portablekit::InterruptCall &call = portablekit::test_last_queued_call(); + call.function = function; + call.arguments = packed; + call.argument_count = count; +} +std::int32_t current_thread_id() { return 0; } + +} // namespace portablekit::hle_extension diff --git a/tests/hle_extension_tests.cpp b/tests/hle_extension_tests.cpp new file mode 100644 index 0000000..03634de --- /dev/null +++ b/tests/hle_extension_tests.cpp @@ -0,0 +1,233 @@ +// HLE extension modules: a function filled in where the program has none, one +// overridden where the module asks, one left to the program where it does +// not, and the first module to add a function keeping it. Uses the example +// module in examples/hle_extension/ and a runtime with no kernel or game. + +#include "hle/hle_extensions.hpp" + +#include "psprecomp/runtime.hpp" + +#include <cstdio> +#include <functional> +#include <set> +#include <sstream> +#include <string> +#include <utility> + +void portablekit_hle_extension_example(portablekit::hle_extension::Registry ®istry); + +namespace portablekit { +InterruptCall &test_last_queued_call(); // hle_extension_test_host.cpp +} + +namespace { + +namespace ext = portablekit::hle_extension; +using portablekit::HleExtensionModule; + +int g_failures = 0; + +void check(bool condition, const char *what) { + std::printf("%s %s\n", condition ? "ok " : "FAIL", what); + if (!condition) ++g_failures; +} + +constexpr std::uint32_t kLoadDefaultCert = 0x87797BDDu; // sceHttp, filled in by the example +constexpr std::uint32_t kDcacheWritebackAll = 0x79D1C3FAu; // UtilsForUser, overridden by the example +constexpr std::uint32_t kBuiltinResult = 0xB0117u; +constexpr std::uint32_t kUntouched = 0xDEADu; + +// A program's own HLE, as far as the extension layer can see it. +struct Program { + psprecomp::Runtime runtime; + std::set<std::pair<std::string, std::uint32_t>> bound; + + void builtin(const std::string &library, std::uint32_t nid, std::uint32_t result) { + runtime.register_hle(library, nid, [result](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { + ctx.set_gpr(2, result); + }); + bound.insert({library, nid}); + } + portablekit::HleExtensionTarget target() { + return { + .implemented = [this](const std::string &library, std::uint32_t nid) { + return bound.contains({library, nid}); + }, + .bind = [this](const std::string &library, std::uint32_t nid, ext::Handler handler) { + runtime.register_hle(library, nid, std::move(handler)); + bound.insert({library, nid}); + }, + }; + } + // v0 after the game calls library::nid; kUntouched when nothing set it. + std::uint32_t call(const std::string &library, std::uint32_t nid) { + psprecomp::AllegrexContext ctx{}; + ctx.set_gpr(2, kUntouched); + runtime.invoke_import(library, nid, ctx); + return ctx.gpr[2]; + } +}; + +constexpr HleExtensionModule kExample{"example", "Example HLE extension", "1.0", "MIT", + &portablekit_hle_extension_example}; + +// A second module that adds the example's fill-in again, and a fill-in of a +// function the program implements. +void second_module(ext::Registry ®istry) { + registry.add("sceHttp", kLoadDefaultCert, "sceHttpsLoadDefaultCert", + [](ext::Runtime &, ext::AllegrexContext &ctx) { ext::finish(ctx, 2u); }); + registry.add("UtilsForUser", 0x3EE30821u, "sceKernelDcacheWritebackRange", + [](ext::Runtime &, ext::AllegrexContext &ctx) { ext::finish(ctx, 2u); }); +} +constexpr HleExtensionModule kSecond{"second", "second", "", "MIT", &second_module}; + +void broken_module(ext::Registry ®istry) { registry.add("sceHttp", 1u, "nothing", nullptr); } +constexpr HleExtensionModule kBroken{"broken", "broken", "", "MIT", &broken_module}; + +} // namespace + +int main() { + // The example alone, over a program that implements the override's target. + { + Program program; + program.builtin("UtilsForUser", kDcacheWritebackAll, kBuiltinResult); + check(program.call("UtilsForUser", kDcacheWritebackAll) == kBuiltinResult, "the built-in answers first"); + const HleExtensionModule modules[] = {kExample}; + const auto report = portablekit::apply_hle_extensions(program.runtime, modules, program.target()); + check(program.call("sceHttp", kLoadDefaultCert) == 0u, "a function the program lacks is filled in"); + check(program.call("UtilsForUser", kDcacheWritebackAll) == 0u, + "a function the module marks as an override replaces the built-in"); + check(report.active.size() == 2u && report.skipped.empty(), "both of the example's functions are active"); + const auto *fill = report.find("sceHttp", kLoadDefaultCert); + const auto *over = report.find("UtilsForUser", kDcacheWritebackAll); + check(fill != nullptr && !fill->overrides && fill->module == "Example HLE extension", + "the fill-in is reported as the example's and not as an override"); + check(over != nullptr && over->overrides, "the override is reported as one"); + check(report.modules.size() == 1u && report.modules[0].functions == 2u && report.modules[0].overrides == 1u, + "the module's summary counts two functions, one override"); + check(program.runtime.nids().resolve("sceHttp", kLoadDefaultCert) == "sceHttpsLoadDefaultCert", + "the module's names reach the NID table"); + + std::ostringstream summary; + portablekit::print_hle_extension_summary(summary, report); + check(summary.str().find("HLE extensions: 2 functions (1 overriding built-ins) from Example HLE extension") != + std::string::npos, + "the startup line names the module and counts its overrides"); + check(summary.str().find("UtilsForUser::sceKernelDcacheWritebackAll is Example HLE extension's, " + "overriding the built-in") != std::string::npos, + "each override is logged"); + + const psprecomp::PspImport imports[] = {{"sceHttp", kLoadDefaultCert, 0u}, {"IoFileMgrForUser", 1u, 0u}}; + std::ostringstream listing; + portablekit::print_hle_extension_imports(listing, report, imports); + check(listing.str().find("Imports served by HLE extensions: 1") != std::string::npos && + listing.str().find("sceHttp::sceHttpsLoadDefaultCert Example HLE extension (fills in)") != + std::string::npos, + "the stub listing says which imports come from which module"); + + std::ostringstream version; + portablekit::print_hle_extension_modules(version, modules); + check(version.str() == "HLE extension: Example HLE extension 1.0 (MIT)\n", + "the module line gives its title, version and licence"); + } + + // A fill-in never replaces the program's own, and the first module to add + // a function keeps it. + { + Program program; + program.builtin("UtilsForUser", 0x3EE30821u, kBuiltinResult); + const HleExtensionModule modules[] = {kExample, kSecond}; + const auto report = portablekit::apply_hle_extensions(program.runtime, modules, program.target()); + check(program.call("UtilsForUser", 0x3EE30821u) == kBuiltinResult, + "a fill-in of a function the program implements leaves the built-in"); + check(program.call("sceHttp", kLoadDefaultCert) == 0u, "the first module to add a function keeps it"); + check(program.call("UtilsForUser", kDcacheWritebackAll) == 0u, + "an override of a function the program lacks is bound"); + const auto *over = report.find("UtilsForUser", kDcacheWritebackAll); + check(over != nullptr && !over->overrides, "and is not counted as an override"); + check(report.skipped.size() == 2u && report.modules.size() == 2u && report.modules[1].functions == 0u && + report.modules[1].skipped == 2u, + "both of the second module's functions are reported unused"); + } + + // Nothing linked: nothing changes. + { + Program program; + program.builtin("UtilsForUser", kDcacheWritebackAll, kBuiltinResult); + const auto report = portablekit::apply_hle_extensions(program.runtime, {}, program.target()); + check(report.active.empty() && report.modules.empty() && + program.call("UtilsForUser", kDcacheWritebackAll) == kBuiltinResult, + "with no modules the program's HLE is untouched"); + } + + // A function without a handler is refused at startup. + { + Program program; + const HleExtensionModule modules[] = {kBroken}; + bool refused = false; + try { + (void)portablekit::apply_hle_extensions(program.runtime, modules, program.target()); + } catch (const std::exception &) { + refused = true; + } + check(refused, "a function without a handler is refused"); + } + + // Calls into the game with more than four arguments: t0-t3 after a0-a3. + { + constexpr std::uint32_t kCallback = 0x08900040u; + Program program; + std::uint32_t seen[8]{}; + const auto calling = [&](ext::Registry ®istry) { + registry.add("TestLibrary", 1u, "callsBack", [&](ext::Runtime &, ext::AllegrexContext &ctx) { + ext::call_guest(ctx, kCallback, {11u, 12u, 13u, 14u, 15u, 16u, 17u, 18u}, + [&](ext::AllegrexContext &back, std::uint32_t) { + for (unsigned i = 0; i < 8u; ++i) seen[i] = ext::arg(back, i); + ext::finish(back, 0u); + }); + }); + registry.add("TestLibrary", 2u, "queuesFive", [](ext::Runtime &, ext::AllegrexContext &ctx) { + ext::queue_guest_call(kCallback, {1u, 2u, 3u, 4u, 5u}); + ext::finish(ctx, 0u); + }); + }; + static std::function<void(ext::Registry &)> entry; + entry = calling; + const HleExtensionModule modules[] = { + {"calls", "calls", "", "MIT", [](ext::Registry ®istry) { entry(registry); }}}; + (void)portablekit::apply_hle_extensions(program.runtime, modules, program.target()); + (void)program.call("TestLibrary", 1u); + bool all = true; + for (unsigned i = 0; i < 8u; ++i) all = all && seen[i] == 11u + i; + check(all, "a call into the game receives all eight argument registers, a0-a3 and t0-t3"); + + (void)program.call("TestLibrary", 2u); + const portablekit::InterruptCall &queued = portablekit::test_last_queued_call(); + check(queued.function == kCallback && queued.argument_count == 5u && queued.arguments[4] == 5u && + queued.arguments[5] == 0u, + "a queued call keeps its five arguments and says how many"); + psprecomp::AllegrexContext ctx{}; + for (unsigned r = 8u; r < 12u; ++r) ctx.set_gpr(r, 0xAAu); + portablekit::load_guest_call_arguments(ctx, queued.arguments, queued.argument_count); + check(ext::arg(ctx, 4) == 5u && ctx.gpr[9] == 0xAAu && ctx.gpr[10] == 0xAAu && ctx.gpr[11] == 0xAAu, + "five arguments set t0 and leave t1-t3 as they were"); + + psprecomp::AllegrexContext four{}; + for (unsigned r = 8u; r < 12u; ++r) four.set_gpr(r, 0xAAu); + portablekit::load_guest_call_arguments(four, {1u, 2u, 3u, 4u}, 4u); + check(four.gpr[4] == 1u && four.gpr[7] == 4u && four.gpr[8] == 0xAAu && four.gpr[11] == 0xAAu, + "a call of four arguments sets a0-a3 only, as calls always did"); + + bool refused = false; + const std::uint32_t nine[9]{}; + try { + (void)portablekit::guest_call_arguments(nine); + } catch (const std::exception &) { + refused = true; + } + check(refused, "a ninth argument is refused"); + } + + if (g_failures != 0) std::printf("%d failed\n", g_failures); + return g_failures == 0 ? 0 : 1; +} From 6680d5919eeeafc24e7ec7bd08e963568b831a4c Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:43:56 +0300 Subject: [PATCH 3/5] Add PORTABLEKIT_BUILD_LABEL and portablekit --version A build made with other components than the plain one, such as HLE extension modules, needs to be recognisable without patching sources. PORTABLEKIT_BUILD_LABEL (empty by default) names it: the window title reads "<program> (<label>)", the menu's About section and the desktop app's library header show it, and the new `portablekit version` (also --version, with --json) prints the version, the label, the corpus ABI and each linked HLE extension module with its version and licence. The label is written at configure time to a header of its own, so only a change of the label rebuilds what shows it. Empty, nothing a player sees changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- apps/portablekit/src/app_main.cpp | 25 +++++++++++++++++++++++++ apps/portablekit/src/launcher_ui.cpp | 4 +++- cmake/PortableKit.cmake | 23 +++++++++++++++++++++++ docs/BUILDING.md | 2 ++ docs/DESKTOP_APP.md | 1 + host/gpu/vulkan_renderer.cpp | 8 ++++++++ host/gpu/vulkan_renderer.hpp | 6 +++++- host/ui/menu.cpp | 2 ++ 8 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/portablekit/src/app_main.cpp b/apps/portablekit/src/app_main.cpp index 9dc5e1a..8ee451b 100644 --- a/apps/portablekit/src/app_main.cpp +++ b/apps/portablekit/src/app_main.cpp @@ -32,7 +32,10 @@ #include "app_paths.hpp" #include "corpus_abi.hpp" +#include "hle/hle_extensions.hpp" #include "install/user_data.hpp" +#include "portablekit_build_label.hpp" +#include "portablekit_version.hpp" #include "psprecomp/interpreter.hpp" @@ -72,6 +75,7 @@ const char *const kUsageText = " portablekit keys status\n" " portablekit cache clear <game>\n" " portablekit toolchain\n" + " portablekit version also --version\n" " <game> is a game's id, its disc id (UCES01059 or UCES-01059), or a unique prefix.\n" " Every command takes --json and then prints one JSON object on stdout, and\n" " --data-dir <folder> to use another data folder than the one next to the program.\n"; @@ -466,6 +470,25 @@ int command_toolchain() { return toolchain.found ? 0 : 20; } +// The build: its version, its label and the HLE extension modules linked in. +int command_version() { + const auto modules = linked_hle_extensions(); + if (g_json) { + std::vector<Json> items; + for (const HleExtensionModule &module : modules) + items.push_back(Json().field("name", module.name).field("title", module.title) + .field("version", module.version).field("license", module.license)); + std::cout << Json().field("ok", true).field("version", kBuildVersion).field("label", kBuildLabel) + .field("abi", kCorpusAbi).array("hle_extensions", items).str() << "\n"; + return 0; + } + std::cout << "PortableKit " << kBuildVersion; + if (*kBuildLabel != '\0') std::cout << " (" << kBuildLabel << ")"; + std::cout << "\n corpus ABI: " << kCorpusAbi << "\n"; + print_hle_extension_modules(std::cout, modules); + return 0; +} + int dispatch(int argc, char **argv) { const Arguments args = parse(argc, argv, 1); g_json = args.has("json"); @@ -474,6 +497,8 @@ int dispatch(int argc, char **argv) { std::cout << kUsageText; return 0; } + if (args.has("version") || (!args.positional.empty() && args.positional[0] == "version")) + return command_version(); if (args.positional.empty()) { activate_launcher_profile(); set_environment("PORTABLEKIT_DATA_DIR", path_text(home_directory() / "launcher")); diff --git a/apps/portablekit/src/launcher_ui.cpp b/apps/portablekit/src/launcher_ui.cpp index c93ce77..f77415f 100644 --- a/apps/portablekit/src/launcher_ui.cpp +++ b/apps/portablekit/src/launcher_ui.cpp @@ -10,6 +10,7 @@ #include "text_format.hpp" #include "hle/hle_common.hpp" +#include "portablekit_build_label.hpp" #include "ui/file_browser.hpp" #include "ui/layer.hpp" #include "ui/widgets.hpp" @@ -166,7 +167,8 @@ bool Launcher::frame() { void Launcher::library() { Layer &layer = Layer::get(); if (layer.take_back()) quit_ = true; - begin_panel("##library", "PortableKit", "Your games", false); + begin_panel("##library", *kBuildLabel != '\0' ? std::string("PortableKit (") + kBuildLabel + ")" : "PortableKit", + "Your games", false); begin_content(); if (first_) focus_next_row(); first_ = false; diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 48883bd..15206c4 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -394,6 +394,29 @@ function(portablekit_add_game target) target_compile_features(${target} PRIVATE cxx_std_20) _portablekit_inherit_settings(${target}) add_dependencies(${target} ${target}_version) + # What a build is called besides its version (PORTABLEKIT_BUILD_LABEL), in + # a header of its own, so that only a change of the label rebuilds what + # shows it. + set(PORTABLEKIT_BUILD_LABEL "" CACHE STRING + "A name for this build, shown beside the version in window titles, menus and --version") + string(FIND "${PORTABLEKIT_BUILD_LABEL}" "\"" label_quote) + string(FIND "${PORTABLEKIT_BUILD_LABEL}" "\\" label_backslash) + string(FIND "${PORTABLEKIT_BUILD_LABEL}" "\n" label_newline) + if(NOT label_quote EQUAL -1 OR NOT label_backslash EQUAL -1 OR NOT label_newline EQUAL -1) + message(FATAL_ERROR "PORTABLEKIT_BUILD_LABEL may not contain quotes, backslashes or new lines") + endif() + file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/generated_version/portablekit_build_label.hpp" CONTENT +"#pragma once + +// Generated by cmake/PortableKit.cmake from PORTABLEKIT_BUILD_LABEL. +namespace portablekit { +// Empty when the build was given no label. +inline constexpr const char *kBuildLabel = \"${PORTABLEKIT_BUILD_LABEL}\"; +} +") + if(PORTABLEKIT_BUILD_LABEL) + message(STATUS "${target}: build label \"${PORTABLEKIT_BUILD_LABEL}\"") + endif() if(PORTABLEKIT_RENDERER) add_dependencies(${target} ${target}_shaders) # The sink opens its own SDL audio subsystem, so it only needs SDL to exist. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 7c0ee46..7874d4a 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -69,6 +69,8 @@ Measured on an Apple M1 with 8 GB, from a fresh clone with an empty compiler cac | `PORTABLEKIT_FFMPEG` | `bundled` | `bundled` builds FFmpeg with the project, `system` uses one found through pkg-config, `OFF` drops music and movies | | `PORTABLEKIT_RELEASE` | `OFF` | Build for distribution: no paths into the checkout, libraries from `lib/` next to the executable | | `PSPRECOMP_LTO` | `OFF` | Whole-program optimisation | +| `PORTABLEKIT_BUILD_LABEL` | empty | A name for the build, shown beside the version: in the window title, the menu's About section, the desktop app's library header and `portablekit --version`. For telling a build made with other components (see below) from the plain one without patching sources | +| `PORTABLEKIT_HLE_EXTENSION_DIRS` | empty | Directories of [HLE extension modules](HLE_EXTENSIONS.md) to link into every program | ## Dependencies diff --git a/docs/DESKTOP_APP.md b/docs/DESKTOP_APP.md index 500e5ac..e08ddb7 100644 --- a/docs/DESKTOP_APP.md +++ b/docs/DESKTOP_APP.md @@ -355,6 +355,7 @@ portablekit keys import <file> portablekit keys status portablekit cache clear <game> portablekit toolchain +portablekit version also --version: the build, its label, the corpus ABI, HLE extension modules ``` `<game>` is the id (`UCES01059-e1075b96`), the disc id with or without its dash, or a unique prefix. `run` starts a background compile when -O2 is not ready yet, `tiered` by default: -O0 first, then -O2 from the same C++ (the recompiler runs once). `--seconds` bounds a run for tests; `PORTABLEKIT_MAX_OPT` caps the level loaded, for comparing levels. `--data-dir <folder>` works with every command. diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index bad21f9..43aa31e 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -1,4 +1,5 @@ #include "../profile.hpp" +#include "portablekit_build_label.hpp" #include "ui/input_script.hpp" #include "vulkan_renderer.hpp" @@ -53,6 +54,13 @@ #include <vector> namespace portablekit::gpu { + +std::string default_window_title() { + std::string title = portablekit::game().app_name; + if (*kBuildLabel != '\0') title += std::string(" (") + kBuildLabel + ")"; + return title; +} + namespace { constexpr std::uint32_t kPspWidth = 480u; diff --git a/host/gpu/vulkan_renderer.hpp b/host/gpu/vulkan_renderer.hpp index 63bc5ad..d9e1803 100644 --- a/host/gpu/vulkan_renderer.hpp +++ b/host/gpu/vulkan_renderer.hpp @@ -64,8 +64,12 @@ struct CameraReading { std::array<float, 16> view{}; }; +// The program's name, followed by the build label (PORTABLEKIT_BUILD_LABEL) +// when there is one. +[[nodiscard]] std::string default_window_title(); + struct RendererConfig { - std::string title{portablekit::game().app_name}; + std::string title{default_window_title()}; }; // Vulkan backend for the GE. Draw calls are rendered into an offscreen target diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index 18eb900..b71f547 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -27,6 +27,7 @@ #include "perf/frame_stats.hpp" #include "save_data/save_transfer.hpp" #include "settings/settings.hpp" +#include "portablekit_build_label.hpp" #include "portablekit_version.hpp" #if defined(PORTABLEKIT_ANDROID_APP) #include "platform/android_documents.hpp" @@ -1252,6 +1253,7 @@ void Menu::system() { section("About"); info_row(portablekit::game().project_name, std::string(kBuildVersion)); + if (*kBuildLabel != '\0') info_row("Build", kBuildLabel); info_row("Game", std::string(portablekit::game().game_title) + " (" + portablekit::game().disc_id_display + ")"); info_row("Data folder", data_dir); if (!savedata::memory_stick().empty()) From 6f7fbc4b3688687a08206694fac367739772dc82 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:28:20 +0300 Subject: [PATCH 4/5] Make the test's callback address static, so every compiler sees it without a capture GCC rejects using a local constexpr in a lambda that captures nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- tests/hle_extension_tests.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hle_extension_tests.cpp b/tests/hle_extension_tests.cpp index 03634de..4e92b77 100644 --- a/tests/hle_extension_tests.cpp +++ b/tests/hle_extension_tests.cpp @@ -175,7 +175,7 @@ int main() { // Calls into the game with more than four arguments: t0-t3 after a0-a3. { - constexpr std::uint32_t kCallback = 0x08900040u; + static constexpr std::uint32_t kCallback = 0x08900040u; Program program; std::uint32_t seen[8]{}; const auto calling = [&](ext::Registry ®istry) { From 0b3bea950db323de019f549237f5dc2ebf582da0 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:33:51 +0300 Subject: [PATCH 5/5] Flip a byte to modify the save in the savedata test, instead of writing a fixed one The encrypted data differs from run to run; one run in 256 already held the fixed value there, so nothing changed and the test failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- tests/savedata_tests.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/savedata_tests.cpp b/tests/savedata_tests.cpp index 82cd5e1..509d4ae 100644 --- a/tests/savedata_tests.cpp +++ b/tests/savedata_tests.cpp @@ -148,11 +148,15 @@ void test_store() { check(loaded.status == LoadStatus::Ok && loaded.contents.data == contents.data, "the save loads back"); check(loaded.contents.title == "Title" && loaded.contents.detail == "Detail", "the titles load back"); - // A changed data file must be reported as broken. + // A changed data file must be reported as broken. Flip the byte: the + // encrypted data is different every run, so writing a fixed value can + // leave it unchanged. { std::fstream data(folder / "DATA.BIN", std::ios::binary | std::ios::in | std::ios::out); + data.seekg(100); + const int original = data.get(); data.seekp(100); - data.put('\x7f'); + data.put(static_cast<char>(original ^ 0xFF)); } check(load_save(root, files).status == LoadStatus::Broken, "a modified data file is rejected"); SaveFiles missing = files;