diff --git a/README.md b/README.md index 6ea297c1..9a63dddf 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ Builds support two recompiled BIOS backends: **OpenBIOS** and a compatible **retail BIOS**. OpenBIOS is a free, open-source PlayStation BIOS from the [PCSX-Redux](https://github.com/grumpycoders/pcsx-redux) project that we're allowed to distribute. It is bundled and runs by default, so you usually do not -need to provide a BIOS dump. Bring a game disc image and play. +need to provide a BIOS dump. Bring a game disc image (`.cue`/`.bin`, `.iso`, or +MAME-compatible `.chd`) and play. **If you'd rather use a retail BIOS**, pick your dumped `SCPH1001.BIN` in settings and it will be used instead. Clear that choice to return to OpenBIOS. diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 44a88d5d..b040f978 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -294,6 +294,20 @@ frames. Trusted callbacks receive only the narrow C services exposed by `runtime/include/mod_plugins.h`. Games should continue to use declarative patches and overlays when those operations are sufficient. +`psx_mod_set_load_acceleration(multiplier, release_frames)` is the narrow +pre-boot service for a game-owned fast-loading feature. It changes host +wall-clock pacing only: guest VBlanks, CD deadlines, interrupts, callbacks, and +game logic still execute. Multipliers 2 through 16 are bounded choices; zero +selects uncapped host speed. A zero-frame release stops acceleration as soon as +the sustained-load predicate clears, which is appropriate for timing-sensitive +or speedrun-oriented packages. + +`psx_mod_set_disc_speed(divisor, instant_max_per_frame)` is the guest-visible +alternative. Divisors 2 and 4 shorten emulated CD deadlines; zero selects the +bounded instant scheduler. Because this changes interrupt timing, packages +should label it experimental and make it mutually exclusive with host-only +load acceleration (normally as choices in one default-off feature). + ## Native operations `main_exe` writes use PSX guest virtual addresses. Expected bytes are checked diff --git a/docs/config_schema.md b/docs/config_schema.md index 0d41756e..5fedd322 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -305,10 +305,15 @@ The other load-time accelerators are likewise opt-in: ```toml [runtime] turbo_loads = true +offer_turbo_loads = true idle_skip = true turbo_audio_sink = true ``` +`offer_turbo_loads` defaults to true. A game that moves load acceleration into +its mod catalog sets it false; the generic Settings switch is hidden and stale +persisted values are ignored before the selected mod activation callback runs. + `turbo_audio_sink` is meaningful only while `turbo_loads` is active. It keeps the guest SPU timeline advancing but discards accelerated samples before host playback, then fades normal output back in. diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 52202b0f..c4bc903e 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1,9 +1,11 @@ cmake_minimum_required(VERSION 3.20) -project(PSXRecomp C CXX) -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -include(CTest) - +project(PSXRecomp C CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(CTest) + +include("${CMAKE_CURRENT_SOURCE_DIR}/../runtime/chd_dependency.cmake") + add_subdirectory(lib/fmt) # Vendored fmt 9.1.0 enables its `consteval` format-string check on Apple @@ -121,7 +123,7 @@ target_include_directories(psxrecomp PRIVATE ../runtime/include lib/fmt/include ) -target_link_libraries(psxrecomp PRIVATE fmt) +target_link_libraries(psxrecomp PRIVATE fmt chdr-static) if(MINGW) target_link_options(psxrecomp PRIVATE -Wl,--stack,16777216) elseif(MSVC) @@ -407,8 +409,10 @@ if(BUILD_TESTING) hybrid_dev_any_input launcher_vulkan_option mod_controller_override - overlay_candidate_capacity - overlay_decodable_fallback + mod_load_acceleration + overlay_candidate_capacity + release_zip + overlay_decodable_fallback overlay_init_guard sdl3_main_single_include vk_build_default diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 922ca959..246483fe 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -439,6 +439,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("turbo_loads")) { rt.turbo_loads = toml::find(runtime, "turbo_loads"); } + if (runtime.contains("offer_turbo_loads")) { + rt.offer_turbo_loads = + toml::find(runtime, "offer_turbo_loads"); + } if (runtime.contains("turbo_audio_sink")) { rt.turbo_audio_sink = toml::find(runtime, "turbo_audio_sink"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index de53336b..4761b920 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -248,6 +248,13 @@ struct RuntimeConfig { // load wall-time. Streaming titles (e.g. Crash) must leave this off. bool turbo_loads = false; + // offer_turbo_loads: expose the generic Turbo loads switch through + // recomp-ui Settings. Defaults true for compatibility. A game migrating + // load acceleration into its mod catalog sets this false; stale persisted + // Settings values are then ignored and a trusted activation plugin owns + // the launch policy. + bool offer_turbo_loads = true; + // turbo_audio_sink: while turbo_loads is actively running unpaced, keep // rendering the exact guest-time SPU sample budget (so voice/CD state // advances) but discard those samples before the host playback queue. diff --git a/recompiler/src/main_cli.cpp b/recompiler/src/main_cli.cpp index c1574937..3acb7f54 100644 --- a/recompiler/src/main_cli.cpp +++ b/recompiler/src/main_cli.cpp @@ -45,7 +45,7 @@ struct BiosProfile { void usage(const char* program) { fmt::print( "Usage:\n" - " {} build --disc --bios " + " {} build --disc --bios " "--output [--name ]\n\n" "The output contains generated game/BIOS C, game.toml, CMakeLists.txt,\n" "and build scripts. No compiler toolchain is bundled.\n" diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c0a00278..6e6df765 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -116,6 +116,7 @@ if(BUILD_TESTING) src/cue_sheet.cpp src/iso_reader.cpp) target_include_directories(disc_path_resolve_test PRIVATE include) + target_link_libraries(disc_path_resolve_test PRIVATE chdr-static) add_test(NAME disc_path_resolve_test COMMAND disc_path_resolve_test) # Both BIOS filename conventions (bare vs region-qualified) resolve, in @@ -131,6 +132,7 @@ if(BUILD_TESTING) src/iso_reader.cpp src/cue_sheet.cpp) target_include_directories(iso_reader_cdda_test PRIVATE include) + target_link_libraries(iso_reader_cdda_test PRIVATE chdr-static) add_test(NAME iso_reader_cdda_test COMMAND iso_reader_cdda_test) add_executable(mod_packages_test @@ -149,11 +151,13 @@ if(BUILD_TESTING) src/mod_packages.cpp src/disc_path.cpp src/cue_sheet.cpp + src/iso_reader.cpp src/crc32.c src/psx_sha256.c) target_include_directories(mod_runtime_test PRIVATE include ../recompiler/lib/toml11) + target_link_libraries(mod_runtime_test PRIVATE chdr-static) add_test(NAME mod_runtime_test COMMAND mod_runtime_test) if(WIN32) diff --git a/runtime/chd_dependency.cmake b/runtime/chd_dependency.cmake new file mode 100644 index 00000000..da2fb683 --- /dev/null +++ b/runtime/chd_dependency.cmake @@ -0,0 +1,30 @@ +include_guard(GLOBAL) + +include(FetchContent) + +# libchdr is the small, BSD-licensed decompressor used by MAME-compatible CHD +# images. Pin the exact source archive (and its digest) so generated games do +# not silently change their disc decoder between builds. +set(INSTALL_STATIC_LIBS OFF CACHE BOOL "Do not install libchdr dependencies" FORCE) +set(WITH_SYSTEM_ZLIB OFF CACHE BOOL "Use libchdr's pinned miniz" FORCE) +set(WITH_SYSTEM_ZSTD OFF CACHE BOOL "Use libchdr's pinned zstd" FORCE) +set(CHDR_WANT_RAW_DATA_SECTOR ON CACHE BOOL + "Reconstruct complete 2352-byte CD sectors" FORCE) +set(CHDR_WANT_SUBCODE ON CACHE BOOL + "Decode CHD CD subchannel payloads" FORCE) +set(CHDR_VERIFY_BLOCK_CRC ON CACHE BOOL + "Verify decoded CHD blocks" FORCE) + +set(_psx_libchdr_timestamp_args "") +if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.24) + list(APPEND _psx_libchdr_timestamp_args + DOWNLOAD_EXTRACT_TIMESTAMP TRUE) +endif() + +FetchContent_Declare(psx_libchdr + URL + "https://github.com/rtissera/libchdr/archive/6cde5348eb118da3baf94f75a69577a005a484fd.tar.gz" + URL_HASH + "SHA256=a222b1f1c842f988c2aad51e5f56ba94a423ed87ccab71f1abdb757caa7444f5" + ${_psx_libchdr_timestamp_args}) +FetchContent_MakeAvailable(psx_libchdr) diff --git a/runtime/include/disc_identity.h b/runtime/include/disc_identity.h index 6fa92740..864e0f9b 100644 --- a/runtime/include/disc_identity.h +++ b/runtime/include/disc_identity.h @@ -1,7 +1,7 @@ // disc_identity.h — disc-image identification + verification. // -// Single source of truth for "is this the right disc?". Reads a .cue (or raw -// .bin/.iso), checks for the ISO9660 PVD, extracts the volume id and the +// Single source of truth for "is this the right disc?". Reads a .cue, raw +// .bin/.iso, or CHD, checks for the ISO9660 PVD, extracts the volume id and the // PlayStation boot serial (from SYSTEM.CNF), derives the region from the // serial prefix, and optionally compares against an expected serial / CRC32. // @@ -18,7 +18,7 @@ namespace PSXRecompV4 { struct DiscIdentity { - bool opened = false; // data file (cue-referenced bin or raw image) opened + bool opened = false; // data image opened (including decoded CHD) bool has_header = false; // ISO9660 "CD001" PVD found at a PS1 disc offset std::string volume_id; // PVD volume identifier (trimmed), "" if none std::string detected_serial; // serial parsed from SYSTEM.CNF BOOT line, normalized @@ -40,7 +40,7 @@ struct DiscIdentity { }; // Identify and (optionally) verify a disc image. -// path : a .cue file or a raw .bin/.iso image +// path : a .cue, raw .bin/.iso, or .chd image // expected_serial : the game id, e.g. "SCUS-94236" ("" skips the serial check) // expected_crc : full-file CRC32 to match against // has_expected_crc : whether expected_crc is meaningful diff --git a/runtime/include/disc_path.h b/runtime/include/disc_path.h index 63bd1828..bd3b513b 100644 --- a/runtime/include/disc_path.h +++ b/runtime/include/disc_path.h @@ -13,6 +13,7 @@ // picked a .cue with a missing bin -> mount a sibling image, if one exists // picked a .bin/.iso/.img owned by a cue -> mount that cue (keeps the TOC) // picked a .bin/.iso/.img with no cue -> mount it directly +// picked a .chd -> mount it directly (TOC is embedded) // // Preferring the cue matters beyond tidiness: the cue is the only place the // CD-DA track layout lives. Silently swapping a cue for its same-named .bin diff --git a/runtime/include/iso_reader.h b/runtime/include/iso_reader.h index 5acb98f3..500a5433 100644 --- a/runtime/include/iso_reader.h +++ b/runtime/include/iso_reader.h @@ -2,8 +2,9 @@ #include <cstdint> #include <string> -#include <fstream> -#include <vector> +#include <fstream> +#include <memory> +#include <vector> /** * ISO/BIN/CUE Reader for PS1 CD-ROM images @@ -17,8 +18,10 @@ * - Read file contents by path */ -namespace PS1 { - +namespace PS1 { + +struct CHDState; + /** * Information about a file entry in the ISO filesystem */ @@ -81,7 +84,7 @@ public: /** * Open an ISO/BIN file for reading - * @param filename Path to .iso, .bin, or .cue file + * @param filename Path to .iso, .bin, .cue, or .chd file * @return true if opened successfully, false otherwise */ bool Open(const std::string& filename); @@ -216,8 +219,9 @@ private: std::string volume_id_; std::string bin_path_; // first (data) segment; kept for callers RootDirectoryInfo root_dir_; - std::vector<CDTrack> tracks_; // from the .cue TOC; >=1 entry after Open() - std::vector<BinSegment> segments_; // cue FILE entries in disc order; >=1 after Open() -}; + std::vector<CDTrack> tracks_; // from the .cue TOC; >=1 entry after Open() + std::vector<BinSegment> segments_; // cue FILE entries in disc order; >=1 after Open() + std::unique_ptr<CHDState> chd_; // present only for a directly mounted CHD +}; } // namespace PS1 diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 54f38bc1..390891a9 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -117,6 +117,25 @@ enum { int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); int psx_mod_set_auto_skip_fmv(int enabled); +/* + * Accelerate only the wall-clock pacing of sustained non-XA data loads while + * preserving every guest VBlank, CD deadline, interrupt, and callback. + * wall_clock_multiplier accepts 2..16, or zero for uncapped host speed. + * release_frames controls how many guest frames acceleration may remain active + * after the load predicate clears; zero is the precise/speedrun-safe policy. + */ +int psx_mod_set_load_acceleration(uint32_t wall_clock_multiplier, + uint32_t release_frames); + +/* + * Select guest-visible CD timing for a game-owned loading feature. divisor is + * 2 or 4, or zero for the bounded "instant" scheduler; instant_max_per_frame + * is used only with divisor zero. Unlike host load acceleration, this changes + * when the emulated game receives CD interrupts and can expose timing bugs. + */ +int psx_mod_set_disc_speed(uint32_t divisor, + uint32_t instant_max_per_frame); + /* * Override one player's resolved controller presentation mode for this launch. * This is intentionally a trusted-plugin API, not a generic launcher setting: diff --git a/runtime/licenses/libchdr-NOTICES.txt b/runtime/licenses/libchdr-NOTICES.txt new file mode 100644 index 00000000..7ea4eae4 --- /dev/null +++ b/runtime/licenses/libchdr-NOTICES.txt @@ -0,0 +1,90 @@ +libchdr +======== + +Copyright Romain Tisserand +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the <organization> nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Zstandard 1.5.7 +================ + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +miniz 3.1.1 +=========== + +Copyright 2013-2014 RAD Game Tools and Valve Software +Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC +All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this +permission notice shall be included in all copies or substantial portions of +the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +LZMA SDK 25.01 +============== + +LZMA SDK is placed in the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute the +original LZMA SDK code, either in source code form or as a compiled binary, for +any purpose, commercial or non-commercial, and by any means. diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 673854aa..11c4368c 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -8,6 +8,8 @@ if(NOT DEFINED PSXRECOMP_ROOT) get_filename_component(PSXRECOMP_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) endif() +include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") + # Default to an optimized build. The recompiled game is a huge (~270 MB) block of # generated C; with no CMAKE_BUILD_TYPE the compiler emits it at -O0 and the game # runs at a small fraction of full speed (terrible framerate). A naive @@ -581,6 +583,7 @@ function(psxrecomp_add_runtime_target target) ${generated_sources} ${PSXRT_EXTRAS_SOURCES} ) + target_link_libraries(${target} PRIVATE chdr-static) # Game-specific executable name. Every title instantiates this function with # the same CMake target name ("psx-runtime"), so without this they ALL produce diff --git a/runtime/src/disc_identity.cpp b/runtime/src/disc_identity.cpp index f5c78ac7..ac32d1df 100644 --- a/runtime/src/disc_identity.cpp +++ b/runtime/src/disc_identity.cpp @@ -8,6 +8,7 @@ #include "crc32.h" #include "disc_path.h" +#include "iso_reader.h" #include <algorithm> #include <cctype> @@ -112,6 +113,24 @@ uint32_t crc32_stream(std::ifstream& f) { return crc ^ 0xFFFFFFFFu; } +bool is_chd_path(const fs::path& path) { + std::string ext = path.extension().string(); + for (char& c : ext) c = (char)std::tolower((unsigned char)c); + return ext == ".chd"; +} + +bool crc32_chd(PS1::ISOReader& disc, uint32_t& result) { + uint32_t crc = 0xFFFFFFFFu; + std::vector<uint8_t> sector(2352); + const uint32_t count = disc.GetSectorCount(); + for (uint32_t lba = 0; lba < count; ++lba) { + if (!disc.ReadRawSector(lba, sector.data())) return false; + crc = crc32_update(crc, sector.data(), sector.size()); + } + result = crc ^ 0xFFFFFFFFu; + return true; +} + } // namespace DiscIdentity identify_disc(const fs::path& path, @@ -127,6 +146,74 @@ DiscIdentity identify_disc(const fs::path& path, // the .cue and its .bin must produce the same verdict, serial and CRC32. const DiscPathResolution resolved = resolve_disc_path(path); const fs::path data_path = resolved.data; + PS1::ISOReader disc; + if (is_chd_path(resolved.mount)) { + if (!disc.Open(resolved.mount.string())) { + v.detail = "Could not open or decode the CHD disc image."; + return v; + } + v.opened = true; + + uint8_t sector[2048]; + if (disc.ReadSector(16, sector) && is_iso_pvd(sector)) { + v.has_header = true; + } + if (!v.has_header) { + v.detail = + "No ISO9660 CD001 header was found in the CHD data track."; + v.region = region_from_serial(expected_serial); + return v; + } + + std::string vol((const char*)§or[40], 32); + const size_t last = vol.find_last_not_of(" \t\0"); + if (last != std::string::npos) vol.erase(last + 1); else vol.clear(); + v.volume_id = vol; + + const uint32_t scan_sectors = + std::min<uint32_t>(disc.GetSectorCount(), 8192); + std::vector<uint8_t> scan((size_t)scan_sectors * 2048); + bool scan_ok = true; + for (uint32_t lba = 0; lba < scan_sectors; ++lba) { + if (!disc.ReadSector(lba, scan.data() + (size_t)lba * 2048)) { + scan_ok = false; + break; + } + } + if (scan_ok) { + v.detected_serial = scan_boot_serial(scan); + if (v.expected_serial_given) { + const std::string serial_id = uppercase_ascii(expected_serial); + std::string exe_id = serial_id; + if (serial_id.size() == 10 && serial_id[4] == '-') + exe_id = serial_id.substr(0, 4) + "_" + + serial_id.substr(5, 3) + "." + + serial_id.substr(8, 2); + auto contains = [&](const std::string& needle) { + if (needle.empty() || needle.size() > scan.size()) return false; + return std::search(scan.begin(), scan.end(), + needle.begin(), needle.end()) != scan.end(); + }; + v.serial_matches = contains(serial_id) || contains(exe_id) || + (!v.detected_serial.empty() && + v.detected_serial == serial_id); + } + } else { + v.detail = "The CHD header is present, but its data track could not be scanned."; + } + v.region = region_from_serial( + !v.detected_serial.empty() ? v.detected_serial : expected_serial); + if (compute_crc) { + v.crc_computed = crc32_chd(disc, v.crc); + if (!v.crc_computed) { + v.detail = "The CHD opened, but a raw sector could not be decoded."; + } else if (has_expected_crc) { + v.crc_matches = v.crc == expected_crc; + } + } + return v; + } + std::ifstream f(data_path, std::ios::binary | std::ios::ate); if (!f.is_open()) { v.detail = resolved.note.empty() diff --git a/runtime/src/disc_path.cpp b/runtime/src/disc_path.cpp index 669c6681..3aaa2752 100644 --- a/runtime/src/disc_path.cpp +++ b/runtime/src/disc_path.cpp @@ -18,6 +18,7 @@ namespace { const char* const kImageExtensions[] = { ".bin", ".img", ".iso" }; bool is_cue(const fs::path& p) { return path_has_extension_ci(p, ".cue"); } +bool is_chd(const fs::path& p) { return path_has_extension_ci(p, ".chd"); } bool is_raw_image(const fs::path& p) { for (const char* ext : kImageExtensions) { @@ -121,6 +122,10 @@ DiscPathResolution resolve_disc_path(const fs::path& picked) { if (r.picked.empty()) return r; + // CHD is already a complete image + table of contents. It must not be + // upgraded to a same-stem cue or treated as that cue's raw payload. + if (is_chd(r.picked)) return r; + if (is_cue(r.picked)) { const CueSheet sheet = parse_cue_sheet(r.picked); if (sheet.usable()) { diff --git a/runtime/src/iso_reader.cpp b/runtime/src/iso_reader.cpp index 7667b656..4a76158c 100644 --- a/runtime/src/iso_reader.cpp +++ b/runtime/src/iso_reader.cpp @@ -1,9 +1,12 @@ -#include "iso_reader.h" -#include "cue_sheet.h" -#include <filesystem> -#include <algorithm> -#include <cstring> -#include <cstdio> +#include "iso_reader.h" +#include "cue_sheet.h" +#include <libchdr/cdrom.h> +#include <libchdr/chd.h> +#include <filesystem> +#include <algorithm> +#include <cstring> +#include <cstdio> +#include <limits> namespace PS1 { @@ -17,9 +20,102 @@ constexpr size_t RAW_SECTOR_SIZE = 2352; constexpr size_t RAW_DATA_OFFSET = 24; // Primary Volume Descriptor location -constexpr uint32_t PVD_SECTOR = 16; - -ISOReader::ISOReader() +constexpr uint32_t PVD_SECTOR = 16; + +enum class CHDTrackMode { + Mode1, + Mode1Raw, + Mode2, + Mode2Form1, + Mode2Form2, + Mode2FormMix, + Mode2Raw, + Audio, +}; + +struct CHDSpan { + uint32_t disc_start = 0; + uint32_t sector_count = 0; + uint64_t chd_frame_start = 0; + CHDTrackMode mode = CHDTrackMode::Mode2Raw; + bool stored = true; +}; + +struct CHDState { + chd_file* file = nullptr; + uint32_t hunk_bytes = 0; + uint32_t frames_per_hunk = 0; + uint32_t current_hunk = std::numeric_limits<uint32_t>::max(); + uint32_t disc_sector_count = 0; + std::vector<uint8_t> hunk; + std::vector<CHDSpan> spans; + + ~CHDState() { + if (file) chd_close(file); + } +}; + +static bool parse_chd_track_mode(const char* text, CHDTrackMode& mode) { + if (std::strcmp(text, "MODE1") == 0) mode = CHDTrackMode::Mode1; + else if (std::strcmp(text, "MODE1_RAW") == 0) mode = CHDTrackMode::Mode1Raw; + else if (std::strcmp(text, "MODE2") == 0) mode = CHDTrackMode::Mode2; + else if (std::strcmp(text, "MODE2_FORM1") == 0) mode = CHDTrackMode::Mode2Form1; + else if (std::strcmp(text, "MODE2_FORM2") == 0) mode = CHDTrackMode::Mode2Form2; + else if (std::strcmp(text, "MODE2_FORM_MIX") == 0) mode = CHDTrackMode::Mode2FormMix; + else if (std::strcmp(text, "MODE2_RAW") == 0) mode = CHDTrackMode::Mode2Raw; + else if (std::strcmp(text, "AUDIO") == 0) mode = CHDTrackMode::Audio; + else return false; + return true; +} + +static const CHDSpan* chd_span_for_lba(const CHDState& chd, uint32_t lba) { + for (const CHDSpan& span : chd.spans) { + if (lba >= span.disc_start && + lba - span.disc_start < span.sector_count) { + return &span; + } + } + return nullptr; +} + +static bool read_chd_raw(CHDState& chd, uint32_t lba, uint8_t* buffer, + CHDTrackMode* mode_out) { + const CHDSpan* span = chd_span_for_lba(chd, lba); + if (!span) return false; + if (mode_out) *mode_out = span->mode; + if (!span->stored) { + std::memset(buffer, 0, RAW_SECTOR_SIZE); + return true; + } + + const uint64_t frame = + span->chd_frame_start + (uint64_t)(lba - span->disc_start); + const uint64_t hunk_index64 = frame / chd.frames_per_hunk; + if (hunk_index64 > std::numeric_limits<uint32_t>::max()) return false; + const uint32_t hunk_index = (uint32_t)hunk_index64; + const size_t hunk_offset = + (size_t)(frame % chd.frames_per_hunk) * CD_FRAME_SIZE; + if (hunk_offset + CD_MAX_SECTOR_DATA > chd.hunk.size()) return false; + + if (chd.current_hunk != hunk_index) { + if (chd_read(chd.file, hunk_index, chd.hunk.data()) != CHDERR_NONE) { + chd.current_hunk = std::numeric_limits<uint32_t>::max(); + return false; + } + chd.current_hunk = hunk_index; + } + + std::memcpy(buffer, chd.hunk.data() + hunk_offset, RAW_SECTOR_SIZE); + if (span->mode == CHDTrackMode::Audio) { + // CD audio in CHD is canonical big-endian PCM. The existing BIN/CUE + // path and CD-ROM mixer exchange little-endian signed samples. + for (size_t i = 0; i < RAW_SECTOR_SIZE; i += 2) + std::swap(buffer[i], buffer[i + 1]); + } + return true; +} + +ISOReader::ISOReader() : is_open_(false) { root_dir_.lba = 0; root_dir_.size = 0; @@ -29,15 +125,120 @@ ISOReader::~ISOReader() { Close(); } -bool ISOReader::Open(const std::string& filename) { +bool ISOReader::Open(const std::string& filename) { // Close any previously opened image (also resets tracks/segments) Close(); // Check if file exists - if (!std::filesystem::exists(filename)) { - return false; - } - + if (!std::filesystem::exists(filename)) { + return false; + } + + if (PSXRecompV4::path_has_extension_ci( + std::filesystem::path(filename), ".chd")) { + auto state = std::make_unique<CHDState>(); + if (chd_open(filename.c_str(), CHD_OPEN_READ, nullptr, &state->file) != + CHDERR_NONE) { + return false; + } + const chd_header* header = chd_get_header(state->file); + if (!header || header->hunkbytes == 0 || + (header->hunkbytes % CD_FRAME_SIZE) != 0) { + return false; + } + state->hunk_bytes = header->hunkbytes; + state->frames_per_hunk = header->hunkbytes / CD_FRAME_SIZE; + state->hunk.resize(header->hunkbytes); + + uint32_t disc_lba = 0; + uint64_t chd_frame = 0; + for (uint32_t index = 0; index < CD_MAX_TRACKS; ++index) { + char metadata[256] = {}; + uint32_t metadata_length = 0; + int number = 0; + int frames = 0; + int pregap = 0; + int postgap = 0; + char type[64] = {}; + char subtype[64] = {}; + char pgtype[64] = {}; + char pgsub[64] = {}; + + chd_error err = chd_get_metadata( + state->file, CDROM_TRACK_METADATA2_TAG, index, + metadata, sizeof(metadata) - 1, &metadata_length, nullptr, nullptr); + bool v2 = err == CHDERR_NONE; + if (v2) { + if (std::sscanf( + metadata, + "TRACK:%d TYPE:%63s SUBTYPE:%63s FRAMES:%d " + "PREGAP:%d PGTYPE:%63s PGSUB:%63s POSTGAP:%d", + &number, type, subtype, &frames, &pregap, + pgtype, pgsub, &postgap) != 8) { + return false; + } + } else { + err = chd_get_metadata( + state->file, CDROM_TRACK_METADATA_TAG, index, + metadata, sizeof(metadata) - 1, &metadata_length, nullptr, nullptr); + if (err == CHDERR_METADATA_NOT_FOUND) break; + if (err != CHDERR_NONE || + std::sscanf(metadata, + "TRACK:%d TYPE:%63s SUBTYPE:%63s FRAMES:%d", + &number, type, subtype, &frames) != 4) { + return false; + } + } + + CHDTrackMode mode; + if (number != (int)index + 1 || frames <= 0 || + !parse_chd_track_mode(type, mode)) { + return false; + } + + const bool pregap_stored = + v2 && pregap > 0 && (pgtype[0] == 'V' || pgtype[0] == 'v'); + const uint32_t stored_pregap = + pregap_stored ? std::min<uint32_t>((uint32_t)pregap, + (uint32_t)frames) : 0; + const uint32_t virtual_pregap = + !pregap_stored && pregap > 0 ? (uint32_t)pregap : 0; + const uint32_t pregap_lba = disc_lba; + + if (virtual_pregap) { + state->spans.push_back( + {disc_lba, virtual_pregap, 0, mode, false}); + disc_lba += virtual_pregap; + } + + state->spans.push_back( + {disc_lba, (uint32_t)frames, chd_frame, mode, true}); + CDTrack track; + track.number = number; + track.is_audio = mode == CHDTrackMode::Audio; + track.start_lba = disc_lba + stored_pregap; + track.pregap_lba = pregap_lba; + tracks_.push_back(track); + + disc_lba += (uint32_t)frames; + chd_frame += (uint32_t)frames; + chd_frame = (chd_frame + CD_TRACK_PADDING - 1) & + ~(uint64_t)(CD_TRACK_PADDING - 1); + } + if (tracks_.empty()) return false; + + state->disc_sector_count = disc_lba; + bin_path_ = filename; + chd_ = std::move(state); + is_open_ = true; + if (!ParseVolumeDescriptor()) { + volume_id_.clear(); + root_dir_.lba = 0; + root_dir_.size = 0; + } + return true; + } + // Ordered list of BINARY files backing the disc. A bare .bin/.iso is a // single-entry list; a .cue contributes one entry per FILE line (redump // multi-track dumps ship one file per track). @@ -139,7 +340,8 @@ bool ISOReader::Open(const std::string& filename) { return true; } -void ISOReader::Close() { +void ISOReader::Close() { + chd_.reset(); for (BinSegment& seg : segments_) { if (seg.file.is_open()) { seg.file.close(); @@ -164,12 +366,23 @@ BinSegment* ISOReader::SegmentForLBA(uint32_t lba) { return nullptr; } -bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { +bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { if (!is_open_ || !buffer) { return false; } - BinSegment* seg = SegmentForLBA(lba); + if (chd_) { + uint8_t raw[RAW_SECTOR_SIZE]; + CHDTrackMode mode; + if (!read_chd_raw(*chd_, lba, raw, &mode)) return false; + const size_t offset = + (mode == CHDTrackMode::Mode1 || + mode == CHDTrackMode::Mode1Raw) ? 16 : RAW_DATA_OFFSET; + std::memcpy(buffer, raw + offset, SECTOR_SIZE); + return true; + } + + BinSegment* seg = SegmentForLBA(lba); if (!seg) { return false; } @@ -212,12 +425,14 @@ bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { } } -bool ISOReader::ReadRawSector(uint32_t lba, uint8_t* buffer) { +bool ISOReader::ReadRawSector(uint32_t lba, uint8_t* buffer) { if (!is_open_ || !buffer) { return false; } - BinSegment* seg = SegmentForLBA(lba); + if (chd_) return read_chd_raw(*chd_, lba, buffer, nullptr); + + BinSegment* seg = SegmentForLBA(lba); if (!seg || !seg->raw) { return false; } @@ -250,10 +465,12 @@ std::string ISOReader::GetBinPath() const { return bin_path_; } -uint32_t ISOReader::GetSectorCount() { - if (!is_open_ || segments_.empty()) { - return 0; - } +uint32_t ISOReader::GetSectorCount() { + if (!is_open_) { + return 0; + } + if (chd_) return chd_->disc_sector_count; + if (segments_.empty()) return 0; const BinSegment& last = segments_.back(); return last.start_lba + last.sector_count; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 707f13cd..81e04830 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -615,6 +615,11 @@ static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; static bool g_mod_native_vblank_rate = false; static uint32_t g_mod_native_vblank_fps = 0; +/* Activation-time request. -1 means no enabled mod owns load acceleration. */ +static int g_mod_load_wall_multiplier = -1; +static int g_mod_load_release_frames = -1; +static int g_mod_disc_speed_divisor = -1; +static int g_mod_disc_instant_rate = -1; /* Map the configured tri-state fullscreen mode (g_fullscreen) to the SDL * window-fullscreen flag: used both to open the window in that mode and to @@ -794,6 +799,39 @@ extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { return 1; } +extern "C" int psx_mod_set_load_acceleration( + uint32_t wall_clock_multiplier, uint32_t release_frames) { + if ((wall_clock_multiplier != 0 && + (wall_clock_multiplier < 2 || wall_clock_multiplier > 16)) || + release_frames > 60) { + std::fprintf(stderr, + "psxrecomp: mod rejected load acceleration " + "(multiplier %u, release frames %u)\n", + (unsigned)wall_clock_multiplier, (unsigned)release_frames); + return 0; + } + g_mod_load_wall_multiplier = (int)wall_clock_multiplier; + g_mod_load_release_frames = (int)release_frames; + return 1; +} + +extern "C" int psx_mod_set_disc_speed( + uint32_t divisor, uint32_t instant_max_per_frame) { + if ((divisor != 0 && divisor != 2 && divisor != 4) || + (divisor == 0 && + (instant_max_per_frame < 1 || instant_max_per_frame > 256))) { + std::fprintf(stderr, + "psxrecomp: mod rejected disc speed " + "(divisor %u, instant budget %u)\n", + (unsigned)divisor, (unsigned)instant_max_per_frame); + return 0; + } + g_mod_disc_speed_divisor = (int)divisor; + g_mod_disc_instant_rate = + divisor == 0 ? (int)instant_max_per_frame : -1; + return 1; +} + extern "C" int psx_mod_set_controller_mode_override( uint32_t player, uint32_t controller_mode) { if (player >= 2 || @@ -951,6 +989,9 @@ uint64_t g_turbo_audio_sink_frames = 0; /* guest SPU frames advanced, not queued * advancing normally. */ #define TURBO_LOADS_ENGAGE_FRAMES 4 #define TURBO_LOADS_RELEASE_FRAMES 6 +/* Zero multiplier retains the historical uncapped turbo behavior. */ +static int g_turbo_load_wall_multiplier = 0; +static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; static SDL_AudioDeviceID sdl_audio_device; static int16_t sdl_audio_buf[2048 * 2]; @@ -1435,16 +1476,16 @@ static std::filesystem::path resolve_disc_for_runtime(const std::filesystem::pat (game_id.empty() ? std::string() : " (" + game_id + ")") + " disc image ripped from your own disc.\n\n" "Accepted formats: .cue (preferred, with its .bin next to it), " - ".bin, or .iso.\n\n" + ".bin, .iso, or .chd.\n\n" "(This is NOT the BIOS — the BIOS was already chosen.)"); std::string disc_title = s_picker_game_name + " — Step 2 of 2: select " + s_picker_game_name + - " disc image (.cue / .bin / .iso)"; + " disc image (.cue / .bin / .iso / .chd)"; for (;;) { std::filesystem::path picked; if (!pick_runtime_file( disc_title.c_str(), - "PS1 Disc Images (*.cue;*.bin;*.iso)\0*.cue;*.bin;*.iso\0All Files (*.*)\0*.*\0", + "PS1 Disc Images (*.cue;*.bin;*.iso;*.chd)\0*.cue;*.bin;*.iso;*.chd\0All Files (*.*)\0*.*\0", picked, "--disc")) { return {}; } @@ -2821,13 +2862,16 @@ static bool hybrid_dpad_active(const PlayerInput& p, int player, bool dev_any) { * pad TYPE is left unchanged: a launcher-assigned analog DualShock still presents * as analog (so the game's analog input path / SIO handshake cadence is preserved * exactly), and merged sources only contribute button/stick STATE, never a type - * downgrade. Controlled by PSX_DEV_INPUT (default ON for the dev workflow); set - * PSX_DEV_INPUT=0 to restore strict single-device-per-port routing. */ + * downgrade. This diagnostic convenience must never alter normal player input: + * strict single-device-per-port routing is the default, and PSX_DEV_INPUT must + * be explicitly set to 1/true/yes/on to enable the merge. */ static bool dev_any_input_enabled() { static int cached = -1; if (cached < 0) { const char* e = std::getenv("PSX_DEV_INPUT"); - cached = (e && (e[0] == '0' || e[0] == 'n' || e[0] == 'N' || e[0] == 'f' || e[0] == 'F')) ? 0 : 1; + const std::string value = lower_copy(trim_copy(e ? e : "")); + cached = (value == "1" || value == "true" || + value == "yes" || value == "on") ? 1 : 0; } return cached != 0; } @@ -3679,7 +3723,7 @@ static void sdl_vblank_present(void) { if (load_run < (1 << 20)) load_run++; if (load_run >= TURBO_LOADS_ENGAGE_FRAMES || release_run > 0) { turbo_loads_active = 1; - release_run = TURBO_LOADS_RELEASE_FRAMES; + release_run = g_turbo_load_release_frames; } } else { load_run = 0; @@ -3797,8 +3841,24 @@ static void sdl_vblank_present(void) { * real time (2.2-4.8 sectors/frame against a 32-256 IRQ budget), so * host-speed execution is the lever that compresses load wall-time. * Presents 1-in-30 so visual progress stays visible. */ + bool turbo_load_paced = false; if (turbo_loads_active) { g_turbo_loads_frames++; + /* A mod may request a bounded wall-clock multiplier instead of the + * legacy unpaced host-speed path. Pace every simulated guest frame at + * the divided period; the presentation skip below remains independent + * so rendering cost does not distort the selected multiplier. */ + if (g_turbo_load_wall_multiplier >= 2 && + g_frame_period_ms > 0.0) { + uint64_t perf_start = runtime_perf_section_begin(); + frame_pacer_wait( + &s_frame_pacer, + g_frame_period_ms / (double)g_turbo_load_wall_multiplier); + runtime_perf_section_end( + perf_start, &g_runtime_perf.pacer_ticks); + latency_ring_mark(LAT_PACED); + turbo_load_paced = true; + } const int TL_PRESENT_EVERY = 30; s_turbo_present_skip = (s_turbo_present_skip + 1) % TL_PRESENT_EVERY; if (s_turbo_present_skip != 0) { @@ -3836,7 +3896,7 @@ static void sdl_vblank_present(void) { * AFTER present so Swap overlaps the peer's guest quantum. */ if (!psx_netplay_active()) { uint64_t perf_start = runtime_perf_section_begin(); - if (g_frame_period_ms > 0.0) + if (!turbo_load_paced && g_frame_period_ms > 0.0) frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); @@ -4962,12 +5022,11 @@ int main(int argc, char** argv) { bool memcard1_enabled = true; bool memcard2_enabled = true; /* [controller] device routing (defaults: P1 keyboard/digital, P2 none). */ - /* Dev builds default Player 1 to the first connected controller ("auto"): - * combined with dev-any-input (dev_any_input_enabled(), default ON) the - * selected controller, EVERY other plugged-in controller, AND the keyboard - * all drive P1 with no launcher setup. If no controller is present, "auto" - * opens nothing and the keyboard/any-controller merge still drives P1. - * Release keeps "keyboard" (the launcher assigns devices). */ + /* Dev builds default Player 1 to the first connected controller ("auto"). + * PSX_DEV_INPUT=1 additionally merges every other controller and the + * keyboard for diagnostic convenience; normal routing stays exclusive. + * Release keeps "keyboard" as its pre-launch default (the launcher assigns + * the selected physical device). */ #if defined(PSX_DEBUG_TOOLS) std::string p1_device = "auto"; #else @@ -4990,6 +5049,7 @@ int main(int argc, char** argv) { bool ws_adaptive_view_supported = false; bool frame_interpolation_offered = true; bool skip_fmv_offered = true; + bool turbo_loads_offered = true; bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ int resolved_deadzone = -1; /* <0 => keep input.ini/runtime default (12000) */ /* Localization: the effective language (game.toml default -> settings.toml -> @@ -5251,6 +5311,7 @@ int main(int argc, char** argv) { frame_interpolation_offered = gc.runtime.video_offer_frame_interpolation; skip_fmv_offered = gc.runtime.video_offer_skip_fmv; + turbo_loads_offered = gc.runtime.offer_turbo_loads; vulkan_offered = gc.vulkan_offered; /* Register the [widescreen.backdrop] store PCs so the dirty-RAM * interpreter applies the backdrop screenX squash on the interp @@ -5681,6 +5742,15 @@ int main(int argc, char** argv) { "ignoring the legacy Settings value\n"); g_auto_skip_fmv = 0; } + /* A game may likewise move load acceleration into its mod catalog. The + * activation plugin runs after final plan commit, so clear both the + * game.toml default and stale Settings value before the launcher is seeded. */ + if (!turbo_loads_offered && g_turbo_loads_enabled) { + std::fprintf(stdout, + "psxrecomp: Turbo loads is mod-owned for this title; " + "ignoring the legacy Settings value\n"); + g_turbo_loads_enabled = 0; + } /* Treat presentation interpolation the same way when a title moves it to * Mods. Both the boolean and target rate are cleared so launcher-less * starts cannot revive an old generic Settings selection. */ @@ -5854,7 +5924,8 @@ int main(int argc, char** argv) { seed.screen_kind = g_video_screen; seed.has_screen_kind = true; seed.auto_skip_fmv = (g_auto_skip_fmv != 0); seed.has_auto_skip_fmv = skip_fmv_offered; - seed.turbo_loads = (g_turbo_loads_enabled != 0); seed.has_turbo_loads = true; + seed.turbo_loads = (g_turbo_loads_enabled != 0); + seed.has_turbo_loads = turbo_loads_offered; seed.fast_boot = fast_boot; seed.has_fast_boot = true; seed.bios_hle = bios_hle_requested; seed.has_bios_hle = true; seed.fullscreen = g_fullscreen; seed.has_fullscreen = true; @@ -6079,6 +6150,7 @@ int main(int argc, char** argv) { gi.renderer_labels = kPsxRendererLabels; gi.num_renderers = vulkan_offered ? 3 : 2; gi.has_skip_fmv = skip_fmv_offered ? 1 : 0; + gi.has_turbo_loads = turbo_loads_offered ? 1 : 0; /* Localization menu: shown only when the game declares languages. */ if (!rui_lang_labels.empty()) { gi.language_labels = rui_lang_labels.data(); @@ -6192,7 +6264,8 @@ int main(int argc, char** argv) { seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; seed.auto_skip_fmv = ls.auto_skip_fmv != 0; seed.has_auto_skip_fmv = skip_fmv_offered; - seed.turbo_loads = ls.turbo_loads != 0; seed.has_turbo_loads = true; + seed.turbo_loads = ls.turbo_loads != 0; + seed.has_turbo_loads = turbo_loads_offered; /* Bundled-BIOS builds ignore any launcher-supplied path (the * picker is hidden, but a stale settings file could still * carry one) — resolve_bios_for_runtime would reject it and @@ -6222,8 +6295,10 @@ int main(int argc, char** argv) { seed.aspect_num = caps->aspect_num ? caps->aspect_num : seed.aspect_num; seed.aspect_den = caps->aspect_den ? caps->aspect_den : seed.aspect_den; seed.has_aspect_ratio = true; - seed.turbo_loads = caps->turbo_loads != 0; - seed.has_turbo_loads = true; + if (turbo_loads_offered) { + seed.turbo_loads = caps->turbo_loads != 0; + seed.has_turbo_loads = true; + } if (skip_fmv_offered) { seed.auto_skip_fmv = caps->auto_skip_fmv != 0; seed.has_auto_skip_fmv = true; @@ -6267,7 +6342,8 @@ int main(int argc, char** argv) { g_video_texfilter = seed.texture_filter; g_video_screen = seed.screen_kind; g_auto_skip_fmv = skip_fmv_offered && seed.auto_skip_fmv ? 1 : 0; - g_turbo_loads_enabled = seed.turbo_loads ? 1 : 0; + g_turbo_loads_enabled = + turbo_loads_offered && seed.turbo_loads ? 1 : 0; fast_boot = seed.fast_boot; bios_hle_requested = seed.bios_hle; g_fullscreen = seed.fullscreen; @@ -6331,12 +6407,36 @@ int main(int argc, char** argv) { * leave its prior mode latched across a soft return to the launcher. */ g_mod_controller_mode_override[0] = -1; g_mod_controller_mode_override[1] = -1; + g_mod_load_wall_multiplier = -1; + g_mod_load_release_frames = -1; + g_mod_disc_speed_divisor = -1; + g_mod_disc_instant_rate = -1; + g_turbo_load_wall_multiplier = 0; + g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; + if (!turbo_loads_offered) + g_turbo_loads_enabled = 0; g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); if (g_mod_controller_mode_override[0] >= 0) p1_mode = g_mod_controller_mode_override[0]; if (g_mod_controller_mode_override[1] >= 0) p2_mode = g_mod_controller_mode_override[1]; + if (g_mod_load_wall_multiplier >= 0) { + g_turbo_loads_enabled = 1; + g_turbo_load_wall_multiplier = g_mod_load_wall_multiplier; + g_turbo_load_release_frames = g_mod_load_release_frames; + if (g_turbo_load_wall_multiplier) { + std::fprintf(stdout, + "psxrecomp: mod selected %dx load acceleration " + "(%d release frames)\n", + g_turbo_load_wall_multiplier, g_turbo_load_release_frames); + } else { + std::fprintf(stdout, + "psxrecomp: mod selected uncapped load acceleration " + "(%d release frames)\n", + g_turbo_load_release_frames); + } + } /* Re-apply the resolved language to the translation layer. text_xlate_init * (at config load) only saw the game.toml default; this folds in the @@ -6540,14 +6640,19 @@ session_reboot: if (disc_speed == "instant") divisor = 0; else if (disc_speed == "4x") divisor = 4; else if (disc_speed == "2x") divisor = 2; + if (g_mod_disc_speed_divisor >= 0) + divisor = g_mod_disc_speed_divisor; /* Store for post-BIOS application; boot always runs at 1x so the * BIOS disc-init sequence sees correct timing. */ cdrom_set_game_speed(divisor); - if (instant_rate > 0) cdrom_set_instant_rate(instant_rate); + if (g_mod_disc_instant_rate > 0) + cdrom_set_instant_rate(g_mod_disc_instant_rate); + else if (instant_rate > 0) + cdrom_set_instant_rate(instant_rate); if (divisor != 1) - std::fprintf(stdout, "psxrecomp: disc_speed=%s (applied post-BIOS, " + std::fprintf(stdout, "psxrecomp: disc speed divisor=%d (applied post-BIOS, " "instant budget %d/frame)\n", - disc_speed.c_str(), cdrom_get_instant_rate()); + divisor, cdrom_get_instant_rate()); } { std::string mc1 = memcard1_path.string(); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f1ad513e..acfaf51d 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1,6 +1,7 @@ #include "mod_runtime.h" #include "disc_path.h" +#include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" #include "psx_sha256.h" @@ -140,9 +141,40 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, std::string* error) { out.clear(); if (path.empty()) return true; - const std::filesystem::path input = resolve_disc_path(path).data; + const DiscPathResolution resolved = resolve_disc_path(path); + const std::filesystem::path input = resolved.data; psx_sha256_ctx hash; psx_sha256_init(&hash); + std::string extension = resolved.mount.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + if (extension == ".chd") { + PS1::ISOReader disc; + if (!disc.Open(resolved.mount.string())) { + if (error) *error = + "cannot decode image fingerprint: " + resolved.mount.string(); + return false; + } + std::array<uint8_t, 2352> sector{}; + for (uint32_t lba = 0; lba < disc.GetSectorCount(); ++lba) { + if (!disc.ReadRawSector(lba, sector.data())) { + if (error) *error = + "cannot finish decoding image fingerprint: " + + resolved.mount.string(); + return false; + } + psx_sha256_update(&hash, sector.data(), sector.size()); + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') + << (unsigned)byte; + out = text.str(); + return true; + } + std::array<uint8_t, 1024 * 1024> buffer{}; std::ifstream file(input, std::ios::binary); if (!file) { @@ -207,6 +239,53 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path, bool sha256_disc_range(const std::filesystem::path& image, ModPatchTarget target, uint64_t location, size_t size, std::string& out, std::string* error) { + std::string extension = image.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + if (extension == ".chd") { + PS1::ISOReader disc; + if (!disc.Open(image.string())) { + if (error) *error = "cannot open stock CHD range: " + image.string(); + return false; + } + psx_sha256_ctx hash; + psx_sha256_init(&hash); + std::array<uint8_t, 2352> sector{}; + size_t remaining = size; + uint64_t at = location; + const uint64_t sector_size = + target == ModPatchTarget::DiscRaw ? 2352u : 2048u; + while (remaining != 0) { + const uint64_t lba64 = at / sector_size; + if (lba64 >= disc.GetSectorCount()) { + if (error) *error = "overlay expected range exceeds stock CHD"; + return false; + } + const size_t within = (size_t)(at % sector_size); + const bool read_ok = + target == ModPatchTarget::DiscRaw + ? disc.ReadRawSector((uint32_t)lba64, sector.data()) + : disc.ReadSector((uint32_t)lba64, sector.data()); + if (!read_ok) { + if (error) *error = "cannot decode stock CHD overlay range"; + return false; + } + const size_t chunk = + std::min(remaining, (size_t)sector_size - within); + psx_sha256_update(&hash, sector.data() + within, chunk); + at += chunk; + remaining -= chunk; + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') + << (unsigned)byte; + out = text.str(); + return true; + } + const std::filesystem::path source = raw_image_path(image, error); if (source.empty()) return false; std::ifstream file(source, std::ios::binary); diff --git a/runtime/tests/test_disc_path_resolve.cpp b/runtime/tests/test_disc_path_resolve.cpp index 80d2e7ee..67a1ea39 100644 --- a/runtime/tests/test_disc_path_resolve.cpp +++ b/runtime/tests/test_disc_path_resolve.cpp @@ -151,6 +151,19 @@ void test_bare_image(const fs::path& root) { write_sectors(dir / "game2.iso", 4, 0x33); const auto iso = resolve_disc_path(dir / "game2.iso"); check(same(iso.mount, dir / "game2.iso"), "bare: iso mounts itself"); + + // A CHD embeds its own TOC. Even if a same-stem cue exists, never replace + // the selected compressed image with that cue or its external payload. + write_text(dir / "compressed.chd", "not-a-real-chd"); + write_sectors(dir / "compressed.bin", 4, 0x44); + write_text(dir / "compressed.cue", + "FILE \"compressed.bin\" BINARY\n" + " TRACK 01 MODE2/2352\n" + " INDEX 01 00:00:00\n"); + const auto chd = resolve_disc_path(dir / "compressed.chd"); + check(same(chd.mount, dir / "compressed.chd"), "bare: chd mounts itself"); + check(same(chd.data, dir / "compressed.chd"), "bare: chd identifies itself"); + check(!chd.upgraded_to_cue, "bare: chd never upgrades to a cue"); } // ---- 5. surface-syntax tolerance in the shared cue parser ------------------- diff --git a/runtime/tests/test_hybrid_dev_any_input.py b/runtime/tests/test_hybrid_dev_any_input.py index 83ea6ac6..d514bd80 100644 --- a/runtime/tests/test_hybrid_dev_any_input.py +++ b/runtime/tests/test_hybrid_dev_any_input.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Guard Hybrid mode's physical-controller detection in dev-any routing.""" +"""Guard Hybrid mode's explicitly opt-in dev-any routing.""" from pathlib import Path @@ -7,6 +7,13 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +assert "strict single-device-per-port routing is the default" in MAIN +for truthy in ('"1"', '"true"', '"yes"', '"on"'): + assert f"value == {truthy}" in MAIN +assert "e && (e[0] == '0'" not in MAIN, ( + "PSX_DEV_INPUT must not use the old default-on/explicit-disable predicate" +) + assert "hybrid_stick_active(const PlayerInput& p, bool dev_any)" in MAIN assert "hybrid_dpad_active(const PlayerInput& p, int player, bool dev_any)" in MAIN assert "hybrid_stick_active(p, dev_here)" in MAIN diff --git a/runtime/tests/test_mod_load_acceleration.py b/runtime/tests/test_mod_load_acceleration.py new file mode 100644 index 00000000..b12d34a0 --- /dev/null +++ b/runtime/tests/test_mod_load_acceleration.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Guard mod-owned, bounded load acceleration and its launcher migration.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime/src/main.cpp").read_text(encoding="utf-8") +HEADER = (ROOT / "runtime/include/mod_plugins.h").read_text(encoding="utf-8") +CONFIG_H = (ROOT / "recompiler/src/config_loader.h").read_text(encoding="utf-8") +CONFIG_CPP = (ROOT / "recompiler/src/config_loader.cpp").read_text( + encoding="utf-8" +) + +assert "psx_mod_set_load_acceleration" in HEADER +assert "wall_clock_multiplier accepts 2..16" in HEADER +assert "zero is the precise/speedrun-safe policy" in HEADER +assert "psx_mod_set_disc_speed" in HEADER +assert "this changes" in HEADER + +assert "bool offer_turbo_loads = true;" in CONFIG_H +assert 'runtime.contains("offer_turbo_loads")' in CONFIG_CPP +assert "turbo_loads_offered = gc.runtime.offer_turbo_loads;" in MAIN +assert "gi.has_turbo_loads = turbo_loads_offered ? 1 : 0;" in MAIN +assert "Turbo loads is mod-owned for this title" in MAIN + +reset = """g_mod_load_wall_multiplier = -1; + g_mod_load_release_frames = -1;""" +disc_reset = """g_mod_disc_speed_divisor = -1; + g_mod_disc_instant_rate = -1;""" +disable_mod_owned_baseline = """if (!turbo_loads_offered) + g_turbo_loads_enabled = 0;""" +activate = "mod_runtime_activate_plugins();" +apply = "if (g_mod_load_wall_multiplier >= 0) {" +assert reset in MAIN +assert disc_reset in MAIN +assert disable_mod_owned_baseline in MAIN +assert apply in MAIN +assert ( + MAIN.index(reset) + < MAIN.index(disable_mod_owned_baseline) + < MAIN.index(activate) + < MAIN.index(apply) +) + +assert "release_run = g_turbo_load_release_frames;" in MAIN +assert "g_frame_period_ms / (double)g_turbo_load_wall_multiplier" in MAIN +assert "if (!turbo_load_paced && g_frame_period_ms > 0.0)" in MAIN +assert "if (g_mod_disc_speed_divisor >= 0)" in MAIN + +print("mod-owned load acceleration guard passed") diff --git a/runtime/tests/test_release_zip.py b/runtime/tests/test_release_zip.py new file mode 100644 index 00000000..2431963a --- /dev/null +++ b/runtime/tests/test_release_zip.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Regression guard for portable release ZIP entry names.""" + +from pathlib import Path +import subprocess +import sys +import tempfile +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +HELPER = ROOT / "tools" / "create_release_zip.py" + +with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary) + stage = base / "stage" + (stage / "mods" / "package").mkdir(parents=True) + (stage / "Tomba Recompiled.exe").write_bytes(b"exe") + (stage / "mods" / "package" / "manifest.toml").write_text( + 'id = "test"\n', encoding="utf-8" + ) + output = base / "release.zip" + subprocess.run( + [ + sys.executable, + str(HELPER), + "--source", + str(stage), + "--output", + str(output), + ], + check=True, + ) + with zipfile.ZipFile(output) as archive: + names = archive.namelist() + assert names == sorted(names) + assert "Tomba Recompiled.exe" in names + assert "mods/package/manifest.toml" in names + assert all("\\" not in name for name in names) + assert archive.read("mods/package/manifest.toml") == b'id = "test"\n' + +print("portable release ZIP guard passed") diff --git a/tools/create_release_zip.py b/tools/create_release_zip.py new file mode 100644 index 00000000..78810589 --- /dev/null +++ b/tools/create_release_zip.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Create a portable release ZIP from a staged directory. + +PowerShell's Compress-Archive writes Windows backslashes into entry names. +They are tolerated by Explorer, but are not valid ZIP path separators and +break several Unix extractors. This helper always emits sorted POSIX names. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import os +from pathlib import Path +import tempfile +import zipfile + + +def _zip_datetime(path: Path) -> tuple[int, int, int, int, int, int]: + stamp = dt.datetime.fromtimestamp(path.stat().st_mtime) + if stamp.year < 1980: + stamp = stamp.replace(year=1980, month=1, day=1, hour=0, minute=0, second=0) + return stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second + + +def create_release_zip(source: Path, output: Path) -> None: + source = source.resolve() + output = output.resolve() + if not source.is_dir(): + raise ValueError(f"source is not a directory: {source}") + try: + output.relative_to(source) + except ValueError: + pass + else: + raise ValueError("output ZIP must not be inside the staged source tree") + + output.parent.mkdir(parents=True, exist_ok=True) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{output.name}.", suffix=".tmp", dir=output.parent + ) + os.close(fd) + temporary = Path(temporary_name) + try: + with zipfile.ZipFile( + temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9 + ) as archive: + files = sorted( + (path for path in source.rglob("*") if path.is_file()), + key=lambda path: path.relative_to(source).as_posix(), + ) + for path in files: + entry_name = path.relative_to(source).as_posix() + if "\\" in entry_name or entry_name.startswith("/"): + raise ValueError(f"invalid ZIP entry name: {entry_name!r}") + info = zipfile.ZipInfo(entry_name, _zip_datetime(path)) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = (path.stat().st_mode & 0xFFFF) << 16 + with path.open("rb") as source_file, archive.open(info, "w") as target: + while chunk := source_file.read(1024 * 1024): + target.write(chunk) + os.replace(temporary, output) + finally: + temporary.unlink(missing_ok=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + create_release_zip(args.source, args.output) + print(args.output.resolve()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())