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/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 \n" " portablekit toolchain\n" + " portablekit version also --version\n" " 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 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 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 a5ab095..15206c4 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. @@ -297,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. @@ -324,6 +444,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/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/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/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/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/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<std::uint32_t, 4> &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<std::uint32_t, kGuestCallMaxArguments>; +// 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<std::uint32_t, 4> 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<void(std::uint32_t)> 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(AllegrexContext &, std::uint32_t)>; - void call_guest(AllegrexContext &ctx, std::uint32_t function, const std::array<std::uint32_t, 4> &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_; } 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/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()) 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..4e92b77 --- /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. + { + static 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; +} 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;