diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 0da8c6ac..8c868a91 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -31,6 +31,11 @@ jobs: - name: Build CLI package shell: msys2 {0} run: python tools/build_cli.py release + - name: Test boot path handling + shell: msys2 {0} + run: | + cmake --build recompiler/build-cli --target cli_boot_path_test --parallel 2 + ctest --test-dir recompiler/build-cli -R '^cli_boot_path_test$' --output-on-failure - name: Smoke test packaged CLI shell: msys2 {0} run: dist/psxrecomp-cli-windows-x86_64/psxrecomp.exe --help @@ -40,6 +45,21 @@ jobs: path: dist/psxrecomp-cli-*.zip if-no-files-found: error + linux-boot-path-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - name: Configure boot path test + run: cmake -S recompiler -B recompiler/build-boot-path-test -DBUILD_TESTING=ON + - name: Build boot path test + run: cmake --build recompiler/build-boot-path-test --target cli_boot_path_test --parallel 2 + - name: Run boot path test + run: >- + ctest --test-dir recompiler/build-boot-path-test + -R '^cli_boot_path_test$' --output-on-failure + publish-release-assets: if: github.event_name == 'release' needs: build diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index d671e597..ad8c6a6e 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -110,6 +110,7 @@ target_link_libraries(psxrecomp-toml PRIVATE fmt) # psxrecomp — user-facing media-to-source project generator. add_executable(psxrecomp src/main_cli.cpp + src/cli_boot_path.cpp src/ps1_exe_parser.cpp ../runtime/src/iso_reader.cpp ) @@ -244,8 +245,15 @@ target_include_directories(l2_structural_test PRIVATE ) target_link_libraries(l2_structural_test PRIVATE fmt rabbitizer) -if(BUILD_TESTING) - add_executable(recompiler_patch_test +if(BUILD_TESTING) + add_executable(cli_boot_path_test + tests/cli_boot_path_test.cpp + src/cli_boot_path.cpp + ) + target_include_directories(cli_boot_path_test PRIVATE src) + add_test(NAME cli_boot_path_test COMMAND cli_boot_path_test) + + add_executable(recompiler_patch_test tests/recompiler_patch_test.cpp src/ps1_exe_parser.cpp src/mips_decoder.cpp diff --git a/recompiler/src/cli_boot_path.cpp b/recompiler/src/cli_boot_path.cpp new file mode 100644 index 00000000..3dda5843 --- /dev/null +++ b/recompiler/src/cli_boot_path.cpp @@ -0,0 +1,36 @@ +#include "cli_boot_path.h" + +#include +#include + +namespace PSXRecompCLI { + +std::string normalize_boot_path(const std::string& boot) { + std::string normalized; + normalized.reserve(boot.size()); + for (char c : boot) normalized += (c == '\\') ? '/' : c; + return normalized; +} + +std::string boot_filename(const std::string& boot) { + return std::filesystem::path(normalize_boot_path(boot)).filename().string(); +} + +std::string serial_from_boot(const std::string& boot) { + std::string base = boot_filename(boot); + std::string clean; + for (char c : base) { + if (std::isalnum(static_cast(c))) { + clean += static_cast(std::toupper(static_cast(c))); + } + } + size_t split = 0; + while (split < clean.size() && + std::isalpha(static_cast(clean[split]))) { + split++; + } + if (split && split < clean.size()) clean.insert(split, "-"); + return clean.empty() ? "PSX-GAME" : clean; +} + +} // namespace PSXRecompCLI diff --git a/recompiler/src/cli_boot_path.h b/recompiler/src/cli_boot_path.h new file mode 100644 index 00000000..ced50d42 --- /dev/null +++ b/recompiler/src/cli_boot_path.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace PSXRecompCLI { + +// SYSTEM.CNF uses backslashes for disc-internal paths. Canonicalize them +// before passing the path to host filesystem utilities. +std::string normalize_boot_path(const std::string& boot); + +std::string boot_filename(const std::string& boot); +std::string serial_from_boot(const std::string& boot); + +} // namespace PSXRecompCLI diff --git a/recompiler/src/main_cli.cpp b/recompiler/src/main_cli.cpp index 1c438ce8..83478b67 100644 --- a/recompiler/src/main_cli.cpp +++ b/recompiler/src/main_cli.cpp @@ -21,6 +21,7 @@ #endif #include "fmt/format.h" +#include "cli_boot_path.h" #include "iso_reader.h" #include "ps1_exe_parser.h" @@ -82,18 +83,6 @@ std::string parse_boot_path(const std::string& cnf) { return path; } -std::string serial_from_boot(const std::string& boot) { - std::string base = fs::path(boot).filename().string(); - std::string clean; - for (char c : base) { - if (std::isalnum((unsigned char)c)) clean += (char)std::toupper((unsigned char)c); - } - size_t split = 0; - while (split < clean.size() && std::isalpha((unsigned char)clean[split])) split++; - if (split && split < clean.size()) clean.insert(split, "-"); - return clean.empty() ? "PSX-GAME" : clean; -} - std::string safe_stem(std::string value) { std::string out; for (char c : value) { @@ -263,9 +252,7 @@ int build_project(const Options& options, const fs::path& exe_dir) { // Normalize boot path: convert backslashes to forward slashes so that // std::filesystem::path (which treats '\' as a regular character on Linux) // correctly splits the path into directory and filename components. - std::string boot_norm; - boot_norm.reserve(boot.size()); - for (char c : boot) boot_norm += (c == '\\') ? '/' : c; + const std::string boot_norm = PSXRecompCLI::normalize_boot_path(boot); size_t exe_size = disc.GetFileSize(boot_norm); if (!exe_size) throw std::runtime_error("boot executable was not found on disc: " + boot); std::vector exe_bytes(exe_size); @@ -280,7 +267,7 @@ int build_project(const Options& options, const fs::path& exe_dir) { // happens to live inside another checkout. write_file(options.output / ".gitignore", "build/\ninput/\ngenerated/\nbios-generated/\n*.mcr\nsaves/\n"); - const std::string boot_file = fs::path(boot_norm).filename().string(); + const std::string boot_file = PSXRecompCLI::boot_filename(boot_norm); const fs::path local_exe = options.output / "input" / boot_file; write_bytes(local_exe, exe_bytes); @@ -288,7 +275,7 @@ int build_project(const Options& options, const fs::path& exe_dir) { auto parsed = PSXRecomp::PS1ExeParser::parse_file(local_exe, parse_error); if (!parsed) throw std::runtime_error("failed to parse boot executable: " + parse_error); const auto& image = *parsed; - const std::string serial = serial_from_boot(boot); + const std::string serial = PSXRecompCLI::serial_from_boot(boot_norm); const std::string title = options.name.empty() ? trim(disc.GetVolumeID()) : options.name; diff --git a/recompiler/tests/cli_boot_path_test.cpp b/recompiler/tests/cli_boot_path_test.cpp new file mode 100644 index 00000000..8d516f16 --- /dev/null +++ b/recompiler/tests/cli_boot_path_test.cpp @@ -0,0 +1,41 @@ +#include "cli_boot_path.h" + +#include +#include + +namespace { + +int failures = 0; + +void check(bool condition, const char* message) { + if (condition) return; + std::fprintf(stderr, "FAIL: %s\n", message); + failures++; +} + +} // namespace + +int main() { + const std::string windows_style = "EXE\\SLUS_123.45"; + const std::string posix_style = "EXE/SLUS_123.45"; + + check(PSXRecompCLI::normalize_boot_path(windows_style) == posix_style, + "normalizes SYSTEM.CNF backslashes independent of the host OS"); + check(PSXRecompCLI::boot_filename(windows_style) == "SLUS_123.45", + "extracts a subdirectory boot filename from backslashes"); + check(PSXRecompCLI::boot_filename(posix_style) == "SLUS_123.45", + "extracts a subdirectory boot filename from forward slashes"); + check(PSXRecompCLI::serial_from_boot(windows_style) == "SLUS-12345", + "does not include the boot directory in the generated game ID"); + check(PSXRecompCLI::serial_from_boot(windows_style) == + PSXRecompCLI::serial_from_boot(posix_style), + "generates the same game ID for Linux and Windows path forms"); + check(PSXRecompCLI::boot_filename("SCUS_944.23") == "SCUS_944.23", + "preserves root-level boot filenames"); + check(PSXRecompCLI::serial_from_boot("SCUS_944.23") == "SCUS-94423", + "preserves root-level game ID generation"); + check(PSXRecompCLI::serial_from_boot("") == "PSX-GAME", + "preserves the fallback game ID for an empty boot path"); + + return failures == 0 ? 0 : 1; +}