feat(runtime): add CHD discs and mod-owned loading modes

This commit is contained in:
Matthew Stanley
2026-08-03 22:32:10 -07:00
parent 5959ef2fdd
commit 67757efac4
25 changed files with 940 additions and 69 deletions
+2 -1
View File
@@ -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.
+14
View File
@@ -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
+5
View File
@@ -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.
+12 -8
View File
@@ -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
+4
View File
@@ -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<bool>(runtime, "turbo_loads");
}
if (runtime.contains("offer_turbo_loads")) {
rt.offer_turbo_loads =
toml::find<bool>(runtime, "offer_turbo_loads");
}
if (runtime.contains("turbo_audio_sink")) {
rt.turbo_audio_sink = toml::find<bool>(runtime, "turbo_audio_sink");
}
+7
View File
@@ -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.
+1 -1
View File
@@ -45,7 +45,7 @@ struct BiosProfile {
void usage(const char* program) {
fmt::print(
"Usage:\n"
" {} build --disc <game.cue|bin|iso> --bios <PS1_BIOS.BIN> "
" {} build --disc <game.cue|bin|iso|chd> --bios <PS1_BIOS.BIN> "
"--output <directory> [--name <title>]\n\n"
"The output contains generated game/BIOS C, game.toml, CMakeLists.txt,\n"
"and build scripts. No compiler toolchain is bundled.\n"
+4
View File
@@ -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)
+30
View File
@@ -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)
+4 -4
View File
@@ -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
+1
View File
@@ -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
+12 -8
View File
@@ -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
+19
View File
@@ -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:
+90
View File
@@ -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.
+3
View File
@@ -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
+87
View File
@@ -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*)&sector[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()
+5
View File
@@ -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()) {
+240 -23
View File
@@ -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;
+127 -22
View File
@@ -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();
+80 -1
View File
@@ -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);
+13
View File
@@ -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 -------------------
+8 -1
View File
@@ -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
@@ -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")
+42
View File
@@ -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")
+79
View File
@@ -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())