diff --git a/cmake/psx_sha256_file.cmake b/cmake/psx_sha256_file.cmake new file mode 100644 index 00000000..c109599c --- /dev/null +++ b/cmake/psx_sha256_file.cmake @@ -0,0 +1,19 @@ +include_guard(GLOBAL) + +# Host utility only: both guest execution profiles retain exactly the same +# file identity. No OpenSSL installation is required for a portable build. +function(psxrecomp_sha256_file target) + target_sources(${target} PRIVATE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../runtime/src/psx_sha256_file.cpp") + target_include_directories(${target} PRIVATE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../runtime/include") + if(WIN32) + target_link_libraries(${target} PRIVATE bcrypt) + else() + find_package(OpenSSL QUIET COMPONENTS Crypto) + if(TARGET OpenSSL::Crypto) + target_compile_definitions(${target} PRIVATE PSX_SHA256_FILE_OPENSSL=1) + target_link_libraries(${target} PRIVATE OpenSSL::Crypto) + endif() + endif() +endfunction() diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 9ea5aa69..6fc50dfe 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -157,3 +157,30 @@ It excludes retail disc/BIOS and player saves, retains audited native AOT pairs, and passes execution identity/catalog/ZIP checks. It predates the shader batching comparison and is not final parity acceptance. No Linux candidate or measured V8 load-speed gain is claimed. + +The shader-only V8 comparison did eliminate filter-change splits and reduced +CPU flush time, but it did not establish a GPU/FPS improvement. Even the first +window's similar primitive counts hid different textured fractions; later +windows and camera images diverged. Host precision/mod-renderer state around +the saved checkpoint needs investigation. The earlier b0/d8 title candidate +remains qualified, with the 7c executable and observations retained separately. +Use a fixed-work renderer probe before drawing a performance conclusion. + +Shared regular-file identity hashing now uses Windows BCrypt or available +Linux OpenSSL, with the portable SHA-256 implementation as fallback. Failed +native initialization, update or finalization restarts the entire file; +I/O failure leaves the caller's digest untouched. The existing C SHA context +and CHD decoded-sector fingerprint path are unchanged. This host utility +preserves exact identity in both execution profiles; it is not a guest HLE +selection or a shortcut that skips content verification. + +The Windows native/portable suites, mod runtime, resident-loading and PGXP +session CTests pass (5/5). Linux OpenSSL and portable builds pass 68 and 67 +checks respectively, including Unicode paths, SHA block/read-chunk boundaries, +known vectors, partially consumed native failures and unreadable inputs. +An isolated interleaved six-sample Windows measurement on V8's 257,200,608-byte +data track matches Python's SHA-256 in every sample: portable median 0.656533 s, +native median 0.130985 s (5.012x for hashing). Receipts are private under +`_build-parity-render-perf-20261006/{validate_hash,linux-hash-validation,benchmark_hash}.json`. +This saves about half a second for that operation, not the entire multi-second +launch delay observed on the busy host. Overall startup still needs attribution. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5490b54e..f1e8b3de 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,24 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + # White-box failure injection checks restart after partial native hashing. + foreach(_hash_variant native portable) + add_executable(sha256_file_${_hash_variant}_test + tests/test_psx_sha256_file.cpp src/psx_sha256.c) + target_include_directories(sha256_file_${_hash_variant}_test PRIVATE include) + if(_hash_variant STREQUAL "portable") + target_compile_definitions(sha256_file_${_hash_variant}_test PRIVATE + PSX_SHA256_FILE_PORTABLE=1) + elseif(WIN32) + target_link_libraries(sha256_file_${_hash_variant}_test PRIVATE bcrypt) + elseif(TARGET OpenSSL::Crypto) + target_compile_definitions(sha256_file_${_hash_variant}_test PRIVATE + PSX_SHA256_FILE_OPENSSL=1) + target_link_libraries(sha256_file_${_hash_variant}_test PRIVATE OpenSSL::Crypto) + endif() + add_test(NAME sha256_file_${_hash_variant}_test + COMMAND sha256_file_${_hash_variant}_test) + endforeach() add_executable(disasm_test tests/test_disasm.cpp src/disasm_shim.cpp ../recompiler/src/mips_decoder.cpp) target_include_directories(disasm_test PRIVATE include ../recompiler/include) @@ -522,6 +540,7 @@ if(BUILD_TESTING) target_compile_definitions(pgxp_session_test PRIVATE PSX_BUILTIN_PGXP_MANIFEST="${CMAKE_CURRENT_SOURCE_DIR}/../mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml") target_link_libraries(pgxp_session_test PRIVATE chdr-static) + psxrecomp_sha256_file(pgxp_session_test) if(WIN32) if(MSVC) target_link_options(pgxp_session_test PRIVATE /STACK:16777216) @@ -862,8 +881,8 @@ if(BUILD_TESTING) ../recompiler/include ../recompiler/lib/toml11) target_link_libraries(mod_runtime_test PRIVATE chdr-static) - # The real disc hasher uses a 1 MiB scratch buffer. Match the runtime's - # stack allowance instead of overflowing MinGW's 1 MiB executable default. + psxrecomp_sha256_file(mod_runtime_test) + # Match the runtime's stack allowance for the remaining disc/mod helpers. if(WIN32) if(MSVC) target_link_options(mod_runtime_test PRIVATE /STACK:16777216) @@ -889,6 +908,7 @@ if(BUILD_TESTING) ../recompiler/include ../recompiler/lib/toml11) target_link_libraries(mod_resident_test PRIVATE chdr-static) + psxrecomp_sha256_file(mod_resident_test) if(WIN32) if(MSVC) target_link_options(mod_resident_test PRIVATE /STACK:16777216) diff --git a/runtime/include/psx_sha256_file.h b/runtime/include/psx_sha256_file.h new file mode 100644 index 00000000..f31c5f06 --- /dev/null +++ b/runtime/include/psx_sha256_file.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +enum class PsxFileHashResult { Ok, OpenError, ReadError }; + +// Hash the file's exact bytes, including an empty file. Publish out only on +// success. Host crypto accelerates the calculation; unavailable/failed native +// hashing restarts with the maintained portable implementation. +PsxFileHashResult psx_sha256_file(const std::filesystem::path& path, + uint8_t out[32]); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 3740e83b..f06e1f7e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -16,6 +16,7 @@ set(PSX_PGO "" CACHE STRING "PGO mode: empty, generate, or use") set_property(CACHE PSX_PGO PROPERTY STRINGS "" generate use) include("${PSXRECOMP_ROOT}/cmake/psx_runtime_ipo.cmake") include("${PSXRECOMP_ROOT}/cmake/psx_execution_profile.cmake") +include("${PSXRECOMP_ROOT}/cmake/psx_sha256_file.cmake") include("${PSXRECOMP_ROOT}/cmake/psx_dependency_archive.cmake") include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") @@ -1571,6 +1572,7 @@ function(psxrecomp_add_runtime_target target) ${PSXRT_EXTRAS_SOURCES} ) target_link_libraries(${target} PRIVATE chdr-static) + psxrecomp_sha256_file(${target}) set(_execution_contract "${PSXRECOMP_ROOT}/runtime/src/gte.cpp" "${PSXRECOMP_ROOT}/runtime/src/pgxp.cpp" diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 3e4fa78d..ecefdffa 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -9,6 +9,7 @@ #include "psx_memory.h" #include "render_pass_projection.h" #include "psx_sha256.h" +#include "psx_sha256_file.h" #include "cpu_state.h" #include "psx_lobby_client.h" @@ -351,12 +352,12 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, if (path.empty()) return true; 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") { + psx_sha256_ctx hash; + psx_sha256_init(&hash); PS1::ISOReader disc; if (!disc.Open(resolved.mount.string())) { if (error) *error = @@ -383,24 +384,17 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, return true; } - std::array buffer{}; - std::ifstream file(input, std::ios::binary); - if (!file) { + uint8_t digest[32]; + const auto result = psx_sha256_file(input, digest); + if (result == PsxFileHashResult::OpenError) { if (error) *error = "cannot fingerprint image: " + input.string(); return false; } - while (file) { - file.read((char*)buffer.data(), (std::streamsize)buffer.size()); - const std::streamsize got = file.gcount(); - if (got > 0) psx_sha256_update(&hash, buffer.data(), (size_t)got); - } - if (!file.eof()) { + if (result != PsxFileHashResult::Ok) { if (error) *error = "cannot finish fingerprinting image: " + input.string(); return false; } - 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; diff --git a/runtime/src/psx_sha256_file.cpp b/runtime/src/psx_sha256_file.cpp new file mode 100644 index 00000000..578b3838 --- /dev/null +++ b/runtime/src/psx_sha256_file.cpp @@ -0,0 +1,120 @@ +#include "psx_sha256_file.h" +#include "psx_sha256.h" + +#include +#include +#include + +#if defined(_WIN32) && !defined(PSX_SHA256_FILE_PORTABLE) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#elif defined(PSX_SHA256_FILE_OPENSSL) && !defined(PSX_SHA256_FILE_PORTABLE) +#include +#endif + +namespace { + +class NativeHash { +public: + NativeHash() = default; + NativeHash(const NativeHash&) = delete; + NativeHash& operator=(const NativeHash&) = delete; + +#if defined(_WIN32) && !defined(PSX_SHA256_FILE_PORTABLE) + ~NativeHash() { + if (hash_) BCryptDestroyHash(hash_); + if (algorithm_) BCryptCloseAlgorithmProvider(algorithm_, 0); + } + bool init() { + return BCryptOpenAlgorithmProvider(&algorithm_, BCRYPT_SHA256_ALGORITHM, + nullptr, 0) >= 0 && + BCryptCreateHash(algorithm_, &hash_, nullptr, 0, + nullptr, 0, 0) >= 0; + } + bool update(const uint8_t* data, size_t size) { + // The reader passes at most 1 MiB, within BCrypt's ULONG length. + return BCryptHashData(hash_, const_cast(data), + static_cast(size), 0) >= 0; + } + bool finish(uint8_t out[32]) { + return BCryptFinishHash(hash_, out, 32, 0) >= 0; + } +private: + BCRYPT_ALG_HANDLE algorithm_ = nullptr; + BCRYPT_HASH_HANDLE hash_ = nullptr; +#elif defined(PSX_SHA256_FILE_OPENSSL) && !defined(PSX_SHA256_FILE_PORTABLE) + ~NativeHash() { EVP_MD_CTX_free(ctx_); } + bool init() { + ctx_ = EVP_MD_CTX_new(); + return ctx_ && EVP_DigestInit_ex(ctx_, EVP_sha256(), nullptr) == 1; + } + bool update(const uint8_t* data, size_t size) { + return EVP_DigestUpdate(ctx_, data, size) == 1; + } + bool finish(uint8_t out[32]) { + unsigned int size = 0; + return EVP_DigestFinal_ex(ctx_, out, &size) == 1 && size == 32; + } +private: + EVP_MD_CTX* ctx_ = nullptr; +#else + bool init() { return false; } + bool update(const uint8_t*, size_t) { return false; } + bool finish(uint8_t[32]) { return false; } +#endif +}; + +// A backend error may occur after consuming input. Restart the whole stream, +// never mix states from different implementations or publish a partial hash. +template +bool try_native(std::ifstream& file, std::vector& buffer, + uint8_t digest[32], Hash& native) { + if (!native.init()) return false; + while (file) { + file.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size())); + const auto got = file.gcount(); + if (got > 0 && !native.update(buffer.data(), static_cast(got))) + return false; + } + return !file.bad() && file.eof() && native.finish(digest); +} + +template +PsxFileHashResult hash_file(const std::filesystem::path& path, + uint8_t out[32], Hash& native) { + std::ifstream file(path, std::ios::binary); + if (!file) return PsxFileHashResult::OpenError; + std::vector buffer(1024 * 1024); + uint8_t digest[32]; + if (!try_native(file, buffer, digest, native)) { + if (file.bad()) return PsxFileHashResult::ReadError; + file.clear(); + file.seekg(0, std::ios::beg); + if (!file) return PsxFileHashResult::ReadError; + psx_sha256_ctx portable; + psx_sha256_init(&portable); + while (file) { + file.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size())); + const auto got = file.gcount(); + if (got > 0) + psx_sha256_update(&portable, buffer.data(), static_cast(got)); + } + if (file.bad() || !file.eof()) return PsxFileHashResult::ReadError; + psx_sha256_final(&portable, digest); + } + std::copy(digest, digest + 32, out); + return PsxFileHashResult::Ok; +} + +} // namespace + +PsxFileHashResult psx_sha256_file(const std::filesystem::path& path, + uint8_t out[32]) { + NativeHash native; + return hash_file(path, out, native); +} diff --git a/runtime/tests/test_psx_sha256_file.cpp b/runtime/tests/test_psx_sha256_file.cpp new file mode 100644 index 00000000..13413d6c --- /dev/null +++ b/runtime/tests/test_psx_sha256_file.cpp @@ -0,0 +1,125 @@ +// Include the maintained implementation to inject backend failures without a +// product hook or a second copy of its stream/fallback logic. +#include "../src/psx_sha256_file.cpp" + +#include +#include +#include +#include +#include + +static unsigned checks; +static void check(bool value, const char* message) { + ++checks; + if (!value) { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +static std::string hex(const uint8_t* digest) { + constexpr char alphabet[] = "0123456789abcdef"; + std::string result; + for (size_t i = 0; i < 32; ++i) { + result += alphabet[digest[i] >> 4]; + result += alphabet[digest[i] & 15]; + } + return result; +} + +struct FailedBackend { + int stage; + unsigned calls = 0; + bool failed = false; + bool init() { return !(failed = stage == 0); } + bool update(const uint8_t*, size_t) { + ++calls; + return !(failed = stage == 1 && calls == 2); + } + bool finish(uint8_t out[32]) { + std::fill(out, out + 32, 0x19); // failure must never publish this + failed = true; + return false; + } +}; + +int main(int argc, char** argv) { + // Optional private file probe: one real host backend or portable build, + // suitable for separate isolated throughput measurements. + if (argc == 2) { + uint8_t digest[32]; + const auto begin = std::chrono::steady_clock::now(); + const auto result = psx_sha256_file(std::filesystem::u8path(argv[1]), digest); + const double seconds = std::chrono::duration( + std::chrono::steady_clock::now() - begin).count(); + if (result != PsxFileHashResult::Ok) return 2; + std::cout << hex(digest) << " " << seconds << '\n'; + return 0; + } + +#if !defined(PSX_SHA256_FILE_PORTABLE) && (defined(_WIN32) || defined(PSX_SHA256_FILE_OPENSSL)) + NativeHash available; + check(available.init(), "native backend is available on qualification host"); +#endif + + const auto unique = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + const auto root = std::filesystem::temp_directory_path() / + ("psx-sha256-" + std::to_string(unique)); + check(std::filesystem::create_directory(root), "private fixture directory"); + const auto path = root / std::filesystem::u8path(u8"disc-\u00e9-\u65e5.bin"); + std::vector bytes; + constexpr size_t lengths[] = { + 0, 1, 3, 55, 56, 63, 64, 65, 119, 120, 127, 128, 129, + 1048575, 1048576, 1048577, 2097169}; + uint32_t rng = 0x53484132; + for (size_t size : lengths) { + bytes.resize(size); + for (auto& byte : bytes) { + rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; + byte = static_cast(rng); + } + { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!bytes.empty()) file.write(reinterpret_cast(bytes.data()), bytes.size()); + check(static_cast(file), "write fixture"); + } + std::array expected{}, observed{}; + psx_sha256_compute(bytes.data(), bytes.size(), expected.data()); + check(psx_sha256_file(path, observed.data()) == PsxFileHashResult::Ok, + "hash file"); + check(observed == expected, "native/portable exact digest"); + if (!size) check(hex(observed.data()) == + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "standard empty vector"); + } + // The largest vector consumes more than one native chunk before fallback. + std::array expected{}, observed{}; + psx_sha256_compute(bytes.data(), bytes.size(), expected.data()); + for (int stage = 0; stage < 3; ++stage) { + FailedBackend backend{stage}; + check(hash_file(path, observed.data(), backend) == PsxFileHashResult::Ok, + "native failure falls back"); + check(backend.failed && observed == expected, "fallback restarts at byte zero"); + if (stage == 1) check(backend.calls == 2, "injected partial update failure"); + } + { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + file << "abc"; + } + check(psx_sha256_file(path, observed.data()) == PsxFileHashResult::Ok && + hex(observed.data()) == + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "standard abc vector"); + observed.fill(0xa5); + expected = observed; + check(psx_sha256_file(root / "absent", observed.data()) == PsxFileHashResult::OpenError, + "missing file rejected"); + check(observed == expected, "missing file leaves output untouched"); + check(psx_sha256_file(root, observed.data()) != PsxFileHashResult::Ok, + "directory rejected"); + check(observed == expected, "unreadable file leaves output untouched"); + // Delete only our two exact fixture paths; no recursive cleanup. + check(std::filesystem::remove(path), "remove fixture file"); + check(std::filesystem::remove(root), "remove empty fixture directory"); + std::cout << checks << " SHA-256 file checks passed\n"; +}