mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
feat(runtime): add CHD discs and mod-owned loading modes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user