Files
PSPRecomp/profiles/vcs/host/vcs_profile.cpp
T
Jessica_Natalia 3bdd8699c2 fixed save games 2
fixed save games 2
2026-08-18 04:09:42 -03:00

13004 lines
651 KiB
C++

#include "vcs_profile.hpp"
#include "vcs_native_fast_paths.hpp"
#include "audio_output.hpp"
#include "display_window.hpp"
#include "vcs_camera_input.hpp"
#include "vcs_vehicle_input.hpp"
#include "vcs_media_decoder.hpp"
#include "vcs_config.hpp"
#include "framebuffer_capture.hpp"
#include "ge_renderer.hpp"
#include "ge_gpu_backend.hpp"
#include "vcs_project2dfx.hpp"
#include "vcs_draw_distance_patch.hpp"
#include "savedata_utility_ui.hpp"
#include "vcs_texture_replacement.hpp"
#include "vcs_runtime_log.hpp"
#include "vcs_tier2_superblocks.hpp"
#include "psprecomp/common.hpp"
#include "psprecomp/deflate.hpp"
#include <algorithm>
#include <array>
#include <bit>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <iostream>
#include <cstdint>
#include <fstream>
#include <filesystem>
#include <ios>
#include <iomanip>
#include <map>
#include <memory>
#include <mutex>
#include <deque>
#include <atomic>
#include <sstream>
#include <string>
#include <stdexcept>
#include <tuple>
#include <thread>
#include <type_traits>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif
namespace psprecomp {
using RuntimePostImportHook = void (*)(Runtime &, AllegrexContext &);
void set_runtime_post_import_hook(RuntimePostImportHook hook) noexcept;
}
namespace vcs {
namespace {
struct DeflateFastPending {
std::uint32_t return_pc{};
std::uint64_t remaining_work{};
std::uint64_t call{};
};
std::unordered_map<std::int32_t, DeflateFastPending> deflate_fast_pending;
std::uint64_t estimate_vcs_deflate_guest_work(std::uint32_t consumed, std::uint32_t produced) {
// Measured against the translated VCS inflater with the same scheduler and
// cross-unit chaining configuration. Exact known streams keep deterministic
// frame timing; the fixed-point model covers later streams conservatively.
if (consumed == 2'267'436u && produced == 6'300'880u) return 21'431u;
if (consumed == 38'278u && produced == 132'636u) return 330u;
if (consumed == 2'236'400u && produced == 3'849'816u) return 19'418u;
if (consumed == 1'741'959u && produced == 3'710'400u) return 15'635u;
const std::uint64_t scaled = static_cast<std::uint64_t>(consumed) * 7'455u +
static_cast<std::uint64_t>(produced) * 727u;
const std::uint64_t estimated = (scaled + 500'000u) / 1'000'000u;
return std::max<std::uint64_t>(1u, estimated > 51u ? estimated - 51u : 1u);
}
void vcs_raw_deflate_fast(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
constexpr std::uint32_t kFastEntry = 0x08B648B0u;
const std::int32_t thread_uid = psprecomp::runtime_thread_uid();
const auto pending = deflate_fast_pending.find(thread_uid);
if (pending != deflate_fast_pending.end()) {
DeflateFastPending &work = pending->second;
if (work.remaining_work > 1u) {
--work.remaining_work;
ctx.pc = kFastEntry;
} else {
const std::uint32_t return_pc = work.return_pc;
const std::uint64_t call = work.call;
deflate_fast_pending.erase(pending);
if (std::getenv("PSPRECOMP_DEFLATE_DIAG") != nullptr)
std::cerr << "[deflate-fast-timing-complete] call=" << call
<< " uid=" << thread_uid << " return=" << psprecomp::hex32(return_pc) << "\n";
ctx.pc = return_pc;
}
return;
}
const std::uint32_t output = ctx.gpr[4];
const std::uint32_t capacity = ctx.gpr[5];
const std::uint32_t input = ctx.gpr[6];
const std::uint32_t consumed_output = ctx.gpr[7];
const std::uint32_t return_pc = ctx.gpr[31];
static std::uint64_t deflate_calls = 0u;
const std::uint64_t deflate_call = ++deflate_calls;
if (std::getenv("PSPRECOMP_DEFLATE_DIAG") != nullptr) {
std::cerr << "[deflate-fast-begin] call=" << deflate_call
<< " input=" << psprecomp::hex32(input)
<< " output=" << psprecomp::hex32(output)
<< " capacity=" << capacity << "\n";
}
const psprecomp::RawDeflateResult result =
psprecomp::inflate_raw_deflate(runtime.memory(), output, capacity, input);
switch (result.status) {
case psprecomp::RawDeflateStatus::Ok:
if (consumed_output != 0u) {
if (!runtime.memory().contains(consumed_output, 4u)) {
ctx.set_gpr(2, 0x80000108u);
break;
}
runtime.memory().store32(consumed_output, input + result.input_consumed);
}
ctx.set_gpr(2, result.output_size);
break;
case psprecomp::RawDeflateStatus::OutputOverflow:
ctx.set_gpr(2, 0x80000104u);
break;
case psprecomp::RawDeflateStatus::InvalidData:
ctx.set_gpr(2, 0x80000108u);
break;
}
std::uint64_t guest_work = 1u;
if (result.status == psprecomp::RawDeflateStatus::Ok)
guest_work = estimate_vcs_deflate_guest_work(result.input_consumed, result.output_size);
if (std::getenv("PSPRECOMP_DEFLATE_DIAG") != nullptr) {
if (deflate_call <= 16u || (deflate_call % 1000u) == 0u) {
std::cerr << "[deflate-fast] call=" << deflate_call
<< " input=" << psprecomp::hex32(input)
<< " output=" << psprecomp::hex32(output)
<< " capacity=" << capacity
<< " produced=" << result.output_size
<< " consumed=" << result.input_consumed
<< " status=" << static_cast<unsigned>(result.status)
<< " guest_work=" << guest_work << "\n";
}
}
// The current host invocation already accounts for one dispatch. Returning
// to the same fast entry for the remaining work preserves scheduler ticks,
// thread preemption and vblank timing while avoiding the expensive Huffman
// and byte-copy loops. Each thread owns its own continuation state.
if (result.status == psprecomp::RawDeflateStatus::Ok && guest_work > 1u &&
std::getenv("PSPRECOMP_NO_FAST_DEFLATE_TIMING") == nullptr) {
deflate_fast_pending.emplace(thread_uid, DeflateFastPending{return_pc, guest_work - 1u, deflate_call});
ctx.pc = kFastEntry;
} else {
ctx.pc = return_pc;
}
}
struct DirectoryHandle {
std::vector<std::filesystem::directory_entry> entries;
std::size_t index{};
};
struct VirtualDiscFile {
std::filesystem::path native_path;
std::uint32_t start_sector{};
std::uint64_t size{};
};
struct VirtualDiscHandle {
std::uint64_t base_offset{};
std::uint64_t length{};
std::uint64_t position{};
};
// Host file kept open across UMD sector reads. VCS streams assets in many
// consecutive sceIoRead calls; reopening the same Windows file for every chunk
// serialized CreateFile/open + metadata work onto the guest CPU thread.
struct VirtualDiscStream {
std::ifstream input;
std::uint64_t position{};
bool position_valid{};
};
struct FileTable {
std::int32_t next_fd{3};
std::uint32_t next_virtual_sector{0x00010000u};
std::unordered_map<std::int32_t, std::fstream> files;
std::unordered_map<std::int32_t, std::filesystem::path> file_paths;
// V8.2.6 save-repro checkpoint: remember the guest open flags so a
// persistent checkpoint can reopen each live PSP descriptor at the same
// host path/position after a rebuilt VCSNative starts.
std::unordered_map<std::int32_t, std::uint32_t> file_open_flags;
// Producer tracking for ATRAC setup. A guest buffer filled directly from an
// AT3/AA3/OMA can be associated with its host source without reopening and
// rescanning candidate files on the audio thread.
std::unordered_map<std::uint32_t, std::filesystem::path> recent_atrac_reads;
std::unordered_set<std::int32_t> synthetic_empty_files;
std::unordered_map<std::int32_t, DirectoryHandle> directories;
std::unordered_map<std::int32_t, VirtualDiscHandle> virtual_disc_handles;
std::unordered_map<std::string, VirtualDiscFile> virtual_files_by_path;
std::map<std::uint32_t, std::string> virtual_path_by_sector;
std::unordered_map<std::string, VirtualDiscStream> virtual_disc_streams;
};
struct ParsedPsmfHeader {
std::uint32_t raw_version{};
std::uint32_t stream_offset{};
std::uint32_t stream_size{};
std::uint64_t first_timestamp{};
std::uint64_t last_timestamp{};
std::uint32_t width{};
std::uint32_t height{};
};
struct MpegStreamState {
std::uint32_t type{};
std::uint32_t number{};
bool needs_reset{true};
};
struct MpegContextState {
std::uint32_t handle_address{};
std::uint32_t ring_address{};
ParsedPsmfHeader header{};
std::unordered_map<std::uint32_t, MpegStreamState> streams;
std::array<bool, 2> avc_es_buffers{};
std::uint32_t video_pixel_mode{3u};
std::uint32_t video_au_count{};
std::uint32_t audio_au_count{};
std::uint32_t decoded_video_frames{};
std::uint32_t consumed_video_packets{};
std::filesystem::path source_path;
VideoStreamDecoder video;
PmfAudioDecoder audio;
std::filesystem::path audio_source;
bool video_eof{};
bool analyzed{};
};
struct ParsedAtracHeader {
std::uint16_t format_tag{};
std::uint16_t channels{};
std::uint32_t sample_rate{};
std::uint32_t average_bytes_per_second{};
std::uint16_t block_align{};
std::uint16_t bits_per_sample{};
std::uint32_t data_offset{};
std::uint32_t data_size{};
std::uint32_t file_size{};
std::uint32_t total_samples{};
std::int32_t loop_start{-1};
std::int32_t loop_end{-1};
bool atrac3plus{};
};
struct AtracContextState {
bool allocated{};
ParsedAtracHeader header{};
std::uint32_t buffer_address{};
std::uint32_t initial_read_size{};
std::uint32_t buffer_size{};
std::uint32_t buffered_encoded_bytes{};
std::uint32_t next_file_offset{};
std::uint32_t write_offset{};
std::uint32_t last_writable_bytes{};
std::uint64_t sample_position{};
std::int32_t loop_num{};
std::uint32_t internal_error{};
std::filesystem::path source_path;
AudioStreamDecoder decoder;
bool decoder_eof{};
};
std::uint32_t read_be32(std::span<const std::uint8_t> bytes, std::size_t offset) {
return (static_cast<std::uint32_t>(bytes[offset]) << 24u) |
(static_cast<std::uint32_t>(bytes[offset + 1u]) << 16u) |
(static_cast<std::uint32_t>(bytes[offset + 2u]) << 8u) |
static_cast<std::uint32_t>(bytes[offset + 3u]);
}
std::uint64_t read_psmf_timestamp(std::span<const std::uint8_t> bytes, std::size_t offset) {
return static_cast<std::uint64_t>(bytes[offset + 5u]) |
(static_cast<std::uint64_t>(bytes[offset + 4u]) << 8u) |
(static_cast<std::uint64_t>(bytes[offset + 3u]) << 16u) |
(static_cast<std::uint64_t>(bytes[offset + 2u]) << 24u) |
(static_cast<std::uint64_t>(bytes[offset + 1u]) << 32u) |
(static_cast<std::uint64_t>(bytes[offset]) << 36u);
}
void write_mpeg_timestamp(psprecomp::GuestMemory &memory, std::uint32_t address, std::uint64_t value) {
// SceMpegAu stores 64-bit timestamps with the two 32-bit words reversed.
memory.store32(address, static_cast<std::uint32_t>(value >> 32u));
memory.store32(address + 4u, static_cast<std::uint32_t>(value));
}
bool parse_psmf_header(std::span<const std::uint8_t> bytes, ParsedPsmfHeader &header) {
if (bytes.size() < 2048u || bytes[0] != 'P' || bytes[1] != 'S' ||
bytes[2] != 'M' || bytes[3] != 'F') return false;
header.raw_version = static_cast<std::uint32_t>(bytes[4]) |
(static_cast<std::uint32_t>(bytes[5]) << 8u) |
(static_cast<std::uint32_t>(bytes[6]) << 16u) |
(static_cast<std::uint32_t>(bytes[7]) << 24u);
const bool known_version = header.raw_version == 0x32313030u ||
header.raw_version == 0x33313030u || header.raw_version == 0x34313030u ||
header.raw_version == 0x35313030u;
if (!known_version) return false;
header.stream_offset = read_be32(bytes, 8u);
header.stream_size = read_be32(bytes, 12u);
header.first_timestamp = read_psmf_timestamp(bytes, 0x54u);
header.last_timestamp = read_psmf_timestamp(bytes, 0x5Au);
header.width = static_cast<std::uint32_t>(bytes[142u]) * 16u;
header.height = static_cast<std::uint32_t>(bytes[143u]) * 16u;
return true;
}
struct AudioChannelState {
bool reserved{};
std::uint32_t sample_count{};
std::uint32_t format{};
std::uint32_t left_volume{};
std::uint32_t right_volume{};
std::uint64_t busy_until_us{};
// sceAudioSRCChReserve picks these per channel. They used to be discarded,
// so a 22050 Hz talk-radio stream was played as if it were 44100 and came
// out at double speed.
std::uint32_t frequency{44100u};
std::uint32_t channel_count{2u};
// Hardware pacing anchor. The DAC consumes queued buffers back to back, so
// the start time of buffer N is the end time of buffer N-1 -- never "now
// plus one buffer". Deriving it from an accumulated frame count keeps the
// channel exactly on the 44100 Hz grid instead of charging the guest's own
// decode time to the audio timeline.
bool queue_active{};
std::uint64_t queue_anchor_us{};
std::uint64_t queued_frames{};
};
enum class ThreadState {
Created,
Ready,
Running,
Sleeping,
Delayed,
IoDeferred,
Completed,
};
struct ThreadRecord {
std::string name;
std::uint32_t entry{};
std::uint32_t priority{};
std::uint32_t stack_size{};
std::uint32_t attributes{};
std::uint32_t stack_top{};
std::uint32_t stack_bottom{};
std::uint32_t kernel_context{};
ThreadState state{ThreadState::Created};
std::uint32_t exit_status{};
bool externally_suspended{};
psprecomp::AllegrexContext suspended_context{};
std::uint32_t wakeup_count{};
std::uint64_t delay_until_us{};
std::uint64_t delay_sequence{};
};
struct ThreadContinuation {
std::int32_t uid{};
psprecomp::AllegrexContext context{};
std::uint64_t ready_sequence{};
};
struct FreeThreadStack {
std::uint32_t bottom{};
std::uint32_t top{};
};
struct PartitionBlock {
std::string name;
std::uint32_t address{};
std::uint32_t size{};
};
struct PartitionTable {
std::int32_t next_uid{0x100};
std::uint32_t next_address{};
std::unordered_map<std::int32_t, PartitionBlock> blocks;
};
struct CallbackRecord {
std::string name;
std::uint32_t function{};
std::uint32_t common{};
std::int32_t owner_uid{};
std::uint32_t notify_count{};
std::uint32_t notify_argument{};
};
struct CallbackTable {
std::int32_t next_uid{0x200};
std::unordered_map<std::int32_t, CallbackRecord> callbacks;
};
struct SemaphoreWaiter {
std::int32_t uid{};
psprecomp::AllegrexContext context{};
std::int32_t requested{};
};
struct SemaphoreRecord {
std::string name;
std::int32_t count{};
std::int32_t maximum{};
std::vector<SemaphoreWaiter> waiters;
};
struct SemaphoreTable {
std::int32_t next_uid{0x300};
std::unordered_map<std::int32_t, SemaphoreRecord> semaphores;
};
struct EventFlagWaiter {
std::int32_t uid{};
psprecomp::AllegrexContext context{};
std::uint32_t requested{};
std::uint32_t mode{};
std::uint32_t output_address{};
};
struct EventFlagRecord {
std::string name;
std::uint32_t attributes{};
std::uint32_t initial_pattern{};
std::uint32_t current_pattern{};
std::vector<EventFlagWaiter> waiters;
};
struct EventFlagTable {
std::int32_t next_uid{0x600};
std::unordered_map<std::int32_t, EventFlagRecord> flags;
};
struct FixedPoolRecord {
std::string name;
std::uint32_t address{};
std::uint32_t block_size{};
std::uint32_t block_count{};
std::vector<bool> allocated;
};
struct FixedPoolTable {
std::int32_t next_uid{0x500};
std::unordered_map<std::int32_t, FixedPoolRecord> pools;
};
struct DeferredIoResume {
// The UMD worker may switch to the request submitter from inside sceIoRead.
// Host I/O is effectively instantaneous, so resuming at the request-pointer
// store is still too early: VCS clears WorldStreamEventFlag immediately
// afterwards and can erase the worker's completion bit. Keep both the PC
// where execution was handed off and the later atomic-commit boundary where
// the submitter has cleared the stale event state and published bit 0x4.
std::int32_t handoff_uid{};
std::uint32_t handoff_pc{};
std::uint32_t release_pc{};
std::uint64_t observed_dispatches{};
};
std::uint32_t io_handoff_release_pc(std::uint32_t handoff_pc) {
switch (handoff_pc) {
case 0x08955E7Cu: return 0x08955EA4u; // single world-stream submission
case 0x08956258u: return 0x08956280u; // batched world-stream submission
default: return handoff_pc;
}
}
// A very small host-backed read can finish before the allocating guest call has
// unwound to 0x08955E7C/0x08956258. In that case the currently restored
// submitter may still be inside the UMD allocator semaphore unlock (for example
// 0x08939C4C), so deriving the release point from ctx.pc is too early. The
// request callback identifies the two VCS world-stream submission paths. When
// manager+0x274 does not yet point at this request, hold the worker until the
// corresponding clear-event/set-0x4 transaction has committed.
std::uint32_t uncommitted_world_stream_release_pc(psprecomp::Runtime &runtime,
std::uint32_t request) {
constexpr std::uint32_t world_stream_manager = 0x08E91200u;
constexpr std::uint32_t active_offset = 628u;
if (request == 0u || !runtime.memory().contains(request, 52u) ||
!runtime.memory().contains(world_stream_manager + active_offset, 4u))
return 0u;
if (runtime.memory().load32(world_stream_manager + active_offset) == request)
return 0u; // already committed; ordinary per-read handoff is sufficient
switch (runtime.memory().load32(request + 48u)) {
case 0x08953990u: return 0x08955EA4u;
case 0x089539CCu: return 0x08956280u;
default: return 0u;
}
}
std::unordered_map<std::int32_t, DeferredIoResume> deferred_io_resumes;
// The post-dispatch callback is needed only while a rare diagnostic/frozen-
// clock guard is active or while a deferred UMD handoff is actually armed.
// Keeping the function pointer installed permanently taxes every outer AOT
// dispatch even though deferred_io_resumes is empty for the normal case.
void vcs_post_dispatch_hook(psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx,
std::uint32_t dispatch_pc, std::int32_t dispatch_thread_uid);
void refresh_vcs_post_dispatch_hook();
// Stage 9 targeted event diagnostics. The general event trace is too noisy
// during a frontend run, so allow filtering by flag name and a bounded poll
// count that captures the scheduler state exactly when progress stops.
std::uint64_t event_diag_poll_count{};
std::uint64_t event_diag_stop_polls{};
bool event_diag_stall_reported{};
struct ThreadTable {
std::int32_t next_uid{1};
std::int32_t current_uid{0};
// PSP user RAM ends at 0x0A000000. User thread stacks are allocated
// downward from the real partition top with 256-byte granularity.
std::uint32_t next_stack_top{0x0A000000u};
std::uint64_t next_ready_sequence{1u};
std::uint64_t next_delay_sequence{1u};
std::unordered_map<std::int32_t, ThreadRecord> threads;
std::vector<ThreadContinuation> continuations;
std::unordered_map<std::int32_t, std::vector<ThreadContinuation>> thread_end_waiters;
std::vector<FreeThreadStack> free_stacks;
};
FileTable file_table;
std::string normalized_native_path(const std::filesystem::path &path) {
std::error_code error;
auto normalized = std::filesystem::weakly_canonical(path, error);
if (error) normalized = std::filesystem::absolute(path, error);
if (error) normalized = path.lexically_normal();
return normalized.generic_string();
}
bool is_atrac_source_path(const std::filesystem::path &path) {
std::string extension = path.extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
return extension == ".AT3" || extension == ".AA3" || extension == ".OMA";
}
const VirtualDiscFile *virtual_disc_file_at_offset(std::uint64_t absolute) {
const std::uint64_t sector64 = absolute / 2048u;
if (sector64 > 0xFFFFFFFFull) return nullptr;
const auto next = file_table.virtual_path_by_sector.upper_bound(static_cast<std::uint32_t>(sector64));
if (next == file_table.virtual_path_by_sector.begin()) return nullptr;
const auto previous = std::prev(next);
const auto found = file_table.virtual_files_by_path.find(previous->second);
if (found == file_table.virtual_files_by_path.end()) return nullptr;
const std::uint64_t start = static_cast<std::uint64_t>(found->second.start_sector) * 2048u;
if (absolute < start || absolute >= start + found->second.size) return nullptr;
return &found->second;
}
const VirtualDiscFile *register_virtual_disc_file(const std::filesystem::path &path) {
std::error_code error;
if (!std::filesystem::is_regular_file(path, error) || error) return nullptr;
const std::string key = normalized_native_path(path);
if (const auto found = file_table.virtual_files_by_path.find(key);
found != file_table.virtual_files_by_path.end()) {
return &found->second;
}
const std::uint64_t size = std::filesystem::file_size(path, error);
if (error) return nullptr;
const std::uint64_t sector_count = std::max<std::uint64_t>(1u, (size + 2047u) / 2048u);
if (sector_count > 0xFFFFFFFFull ||
static_cast<std::uint64_t>(file_table.next_virtual_sector) + sector_count > 0x100000000ull) {
return nullptr;
}
// Indexing here, rather than by walking the asset directory, means only the
// archives the guest actually opens are indexed. Backup copies the user left
// sitting next to the real files are never picked up, and an asset mod that
// replaces an archive is indexed as it is opened.
texture_replacement_index_archive(path);
VirtualDiscFile item{};
item.native_path = path;
item.start_sector = file_table.next_virtual_sector;
item.size = size;
file_table.next_virtual_sector += static_cast<std::uint32_t>(sector_count);
const auto [inserted, ok] = file_table.virtual_files_by_path.emplace(key, std::move(item));
if (!ok) return &inserted->second;
file_table.virtual_path_by_sector.emplace(inserted->second.start_sector, key);
return &inserted->second;
}
const VirtualDiscFile *find_virtual_disc_file(std::uint32_t start_sector, std::uint64_t requested_size) {
const auto sector = file_table.virtual_path_by_sector.find(start_sector);
if (sector == file_table.virtual_path_by_sector.end()) return nullptr;
const auto file = file_table.virtual_files_by_path.find(sector->second);
if (file == file_table.virtual_files_by_path.end()) return nullptr;
if (requested_size != 0u && requested_size > file->second.size) return nullptr;
return &file->second;
}
// Virtual-disc read accounting.
//
// A sector range that no registered file covers is silently zero-filled below.
// The guest cannot tell that apart from real data, so missing world geometry or
// missing collision models look like renderer or physics bugs instead of an
// incomplete sector map. Count both paths so the difference is measurable.
struct DiscReadStats {
std::uint64_t bytes_from_files{};
std::uint64_t bytes_zero_filled{};
std::uint64_t zero_fill_events{};
std::uint64_t short_reads{};
std::uint64_t open_failures{};
std::uint64_t reported_events{};
};
DiscReadStats disc_read_stats;
// Host time physically spent inside sceIoRead. Only accumulated while the
// frame-time diagnostic is enabled, so the production fast path pays no clock
// query cost. This makes cold-storage stalls visible separately from guest AOT.
std::chrono::steady_clock::duration io_host_time_this_vblank{};
bool disc_read_diag_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_DISC_READ_DIAG") != nullptr;
return enabled;
}
std::size_t read_virtual_disc(VirtualDiscHandle &handle, std::span<std::uint8_t> output) {
if (handle.position >= handle.length || output.empty()) return 0u;
const std::uint64_t available = handle.length - handle.position;
const std::size_t requested = static_cast<std::size_t>(
std::min<std::uint64_t>(available, output.size()));
std::fill(output.begin(), output.begin() + requested, 0u);
std::size_t written = 0u;
while (written < requested) {
const std::uint64_t absolute = handle.base_offset + handle.position + written;
const std::uint64_t sector64 = absolute / 2048u;
if (sector64 > 0xFFFFFFFFull) break;
const auto next = file_table.virtual_path_by_sector.upper_bound(static_cast<std::uint32_t>(sector64));
const VirtualDiscFile *file = nullptr;
const std::string *file_key = nullptr;
if (next != file_table.virtual_path_by_sector.begin()) {
const auto previous = std::prev(next);
const auto found = file_table.virtual_files_by_path.find(previous->second);
if (found != file_table.virtual_files_by_path.end()) {
const std::uint64_t file_start = static_cast<std::uint64_t>(found->second.start_sector) * 2048u;
if (absolute >= file_start && absolute < file_start + found->second.size) {
file = &found->second;
file_key = &previous->second;
}
}
}
if (file != nullptr && file_key != nullptr) {
const std::uint64_t file_start = static_cast<std::uint64_t>(file->start_sector) * 2048u;
const std::uint64_t file_offset = absolute - file_start;
const std::size_t chunk = static_cast<std::size_t>(std::min<std::uint64_t>(
requested - written, file->size - file_offset));
// Reuse one host handle per registered disc file. Most world-stream
// requests are sequential, so retain the native stream position too
// and skip seekg() when the next chunk starts where the previous one
// ended. The OS page cache can now do useful read-ahead instead of
// seeing a new open/close lifetime for every PSP read.
VirtualDiscStream &cached = file_table.virtual_disc_streams[*file_key];
if (!cached.input.is_open()) {
cached.input.open(file->native_path, std::ios::binary);
cached.position = 0u;
cached.position_valid = cached.input.good();
}
if (!cached.input) {
++disc_read_stats.open_failures;
cached.position_valid = false;
if (disc_read_diag_enabled()) {
std::cerr << "[disc-read] open failed path=\"" << file->native_path.string()
<< "\"\n";
}
break;
}
if (!cached.position_valid || cached.position != file_offset) {
cached.input.clear();
cached.input.seekg(static_cast<std::streamoff>(file_offset), std::ios::beg);
if (!cached.input) {
++disc_read_stats.short_reads;
cached.position_valid = false;
break;
}
cached.position = file_offset;
cached.position_valid = true;
}
cached.input.read(reinterpret_cast<char *>(output.data() + written),
static_cast<std::streamsize>(chunk));
const auto actual = static_cast<std::size_t>(cached.input.gcount());
cached.position += actual;
cached.position_valid = true;
written += actual;
disc_read_stats.bytes_from_files += actual;
if (actual != chunk) {
++disc_read_stats.short_reads;
// EOF/fail flags are expected after a short read; clear them so
// a later explicit seek can recover this persistent handle.
cached.input.clear();
if (disc_read_diag_enabled()) {
std::cerr << "[disc-read] short read path=\"" << file->native_path.string()
<< "\" wanted=" << chunk << " got=" << actual
<< " file_offset=" << file_offset << "\n";
}
break;
}
continue;
}
std::uint64_t zero_end = handle.base_offset + handle.length;
if (next != file_table.virtual_path_by_sector.end())
zero_end = std::min(zero_end, static_cast<std::uint64_t>(next->first) * 2048u);
if (zero_end <= absolute) zero_end = absolute + 1u;
const std::size_t filled = static_cast<std::size_t>(std::min<std::uint64_t>(
requested - written, zero_end - absolute));
written += filled;
disc_read_stats.bytes_zero_filled += filled;
++disc_read_stats.zero_fill_events;
if (disc_read_diag_enabled() && disc_read_stats.reported_events < 40u) {
++disc_read_stats.reported_events;
std::cerr << "[disc-read] zero-filled bytes=" << filled
<< " absolute=" << absolute
<< " sector=" << (absolute / 2048u)
<< " handle_base=" << handle.base_offset
<< " handle_pos=" << handle.position << "\n";
}
}
handle.position += written;
return written;
}
std::string shell_quote(const std::string &value) {
#if defined(_WIN32)
std::string quoted = "\"";
for (const char ch : value) quoted += ch == '\"' ? "\\\"" : std::string(1, ch);
quoted += "\"";
return quoted;
#else
std::string quoted = "'";
for (const char ch : value) quoted += ch == '\'' ? "'\"'\"'" : std::string(1, ch);
quoted += "'";
return quoted;
#endif
}
// The MPEG and ATRAC HLE paths decode through a host `ffmpeg` process. On
// Windows `_popen` succeeds even when the executable does not exist: the shell
// starts, prints "not recognized" and exits, so every read returns EOF. The
// guest then waits forever for a frame that can never arrive, which looks
// exactly like a hang with no diagnostic. Probe once and fail loudly instead.
// The host ffmpeg process is gone: decoding is in-process through the vendored
// minimal FFmpeg libraries. See host/vcs_media_decoder.cpp.
// Buffers the movie decoder writes decoded pictures into.
//
// The game does not draw a movie as geometry: it hands sceMpegAvcDecode a plain
// RAM buffer and then points the display at that buffer, so a movie frame
// reaches the screen without a single GE draw. That makes these addresses the
// reliable answer to "is a movie on screen right now?" -- compared against the
// displayed framebuffer once per vblank, in the display path below.
//
// Two, in practice, alternating; the set is tiny and cleared when a movie ends.
std::unordered_set<std::uint32_t> movie_output_buffers;
// PSP RAM is visible both cached and uncached, and the display and the decoder
// do not have to agree on which mirror they name.
[[nodiscard]] std::uint32_t normalize_ram_address(std::uint32_t address) noexcept {
return address & 0x1FFFFFFFu;
}
void close_video_decoder(MpegContextState &state) {
state.video.close();
movie_output_buffers.clear();
// The soundtrack belongs to the same movie. Leaving it open meant the
// second cutscene kept reading the first one's exhausted stream and played
// silent.
state.audio.close();
state.audio_source.clear();
state.video_eof = false;
state.decoded_video_frames = 0u;
state.consumed_video_packets = 0u;
}
bool open_video_decoder(MpegContextState &state) {
if (state.video.is_open()) return true;
if (state.source_path.empty() || state.header.width == 0u || state.header.height == 0u) return false;
if (!state.video.open(state.source_path)) return false;
state.video_eof = false;
state.decoded_video_frames = 0u;
state.consumed_video_packets = 0u;
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr)
std::cerr << "[mpeg] decoder opened source=\"" << state.source_path.string() << "\"\n";
return true;
}
bool read_video_frame(MpegContextState &state, std::span<std::uint8_t> frame) {
if (!open_video_decoder(state)) return false;
if (state.video.read(frame) < frame.size()) {
state.video_eof = true;
return false;
}
++state.decoded_video_frames;
return true;
}
std::filesystem::path identify_pmf_source(std::span<const std::uint8_t> header, const ParsedPsmfHeader &parsed) {
const std::uint64_t expected_size = static_cast<std::uint64_t>(parsed.stream_offset) + parsed.stream_size;
for (const auto &[key, file] : file_table.virtual_files_by_path) {
if (file.size != expected_size) continue;
std::string extension = file.native_path.extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
if (extension != ".PMF") continue;
std::array<std::uint8_t, 2048> candidate{};
std::ifstream input(file.native_path, std::ios::binary);
if (!input) continue;
input.read(reinterpret_cast<char *>(candidate.data()), static_cast<std::streamsize>(candidate.size()));
if (input.gcount() == static_cast<std::streamsize>(candidate.size()) &&
std::equal(candidate.begin(), candidate.end(), header.begin())) return file.native_path;
}
return {};
}
[[nodiscard]] bool is_boot_titles_movie(const std::filesystem::path &path) {
if (path.empty()) return false;
std::string name = path.filename().string();
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
return name == "TITLES.PMF";
}
std::uint16_t read_le16(std::span<const std::uint8_t> bytes, std::size_t offset) {
return static_cast<std::uint16_t>(bytes[offset]) |
static_cast<std::uint16_t>(static_cast<std::uint16_t>(bytes[offset + 1u]) << 8u);
}
std::uint32_t read_le32(std::span<const std::uint8_t> bytes, std::size_t offset) {
return static_cast<std::uint32_t>(bytes[offset]) |
(static_cast<std::uint32_t>(bytes[offset + 1u]) << 8u) |
(static_cast<std::uint32_t>(bytes[offset + 2u]) << 16u) |
(static_cast<std::uint32_t>(bytes[offset + 3u]) << 24u);
}
bool parse_atrac_header(std::span<const std::uint8_t> bytes, ParsedAtracHeader &header) {
if (bytes.size() < 12u || std::memcmp(bytes.data(), "RIFF", 4u) != 0 ||
std::memcmp(bytes.data() + 8u, "WAVE", 4u) != 0) return false;
const std::uint64_t declared_file_size = static_cast<std::uint64_t>(read_le32(bytes, 4u)) + 8u;
if (declared_file_size > 0xFFFFFFFFull) return false;
header = ParsedAtracHeader{};
header.file_size = static_cast<std::uint32_t>(declared_file_size);
bool have_fmt = false;
bool have_data = false;
for (std::size_t offset = 12u; offset + 8u <= bytes.size();) {
const std::uint32_t chunk_size = read_le32(bytes, offset + 4u);
const std::size_t payload = offset + 8u;
const std::uint64_t next64 = static_cast<std::uint64_t>(payload) + chunk_size + (chunk_size & 1u);
if (next64 > bytes.size()) {
// A partial streaming buffer is valid as long as the chunk header is present.
if (std::memcmp(bytes.data() + offset, "data", 4u) == 0) {
header.data_offset = static_cast<std::uint32_t>(payload);
header.data_size = chunk_size;
have_data = true;
}
break;
}
if (std::memcmp(bytes.data() + offset, "fmt ", 4u) == 0 && chunk_size >= 16u) {
header.format_tag = read_le16(bytes, payload + 0u);
header.channels = read_le16(bytes, payload + 2u);
header.sample_rate = read_le32(bytes, payload + 4u);
header.average_bytes_per_second = read_le32(bytes, payload + 8u);
header.block_align = read_le16(bytes, payload + 12u);
header.bits_per_sample = read_le16(bytes, payload + 14u);
have_fmt = true;
} else if (std::memcmp(bytes.data() + offset, "fact", 4u) == 0 && chunk_size >= 4u) {
header.total_samples = read_le32(bytes, payload);
} else if (std::memcmp(bytes.data() + offset, "smpl", 4u) == 0 && chunk_size >= 60u) {
const std::uint32_t loop_count = read_le32(bytes, payload + 28u);
if (loop_count != 0u && chunk_size >= 60u) {
header.loop_start = static_cast<std::int32_t>(read_le32(bytes, payload + 44u));
header.loop_end = static_cast<std::int32_t>(read_le32(bytes, payload + 48u));
}
} else if (std::memcmp(bytes.data() + offset, "data", 4u) == 0) {
header.data_offset = static_cast<std::uint32_t>(payload);
header.data_size = chunk_size;
have_data = true;
}
offset = static_cast<std::size_t>(next64);
}
if (!have_fmt || !have_data || header.channels == 0u || header.channels > 2u ||
header.sample_rate == 0u || header.block_align == 0u) return false;
// PSP ATRAC files use WAVE_FORMAT_EXTENSIBLE (0xFFFE) or the legacy ATRAC3 tag.
header.atrac3plus = header.format_tag == 0xFFFEu && header.block_align >= 0x180u;
if (!header.atrac3plus && header.format_tag != 0x0270u && header.format_tag != 0xFFFEu) return false;
if (header.total_samples == 0u) {
const std::uint32_t samples_per_frame = header.atrac3plus ? 2048u : 1024u;
header.total_samples = (header.data_size / header.block_align) * samples_per_frame;
}
return true;
}
std::filesystem::path identify_atrac_source(std::uint32_t guest_buffer,
std::span<const std::uint8_t> header,
const ParsedAtracHeader &parsed) {
const auto started = std::chrono::steady_clock::now();
bool direct_buffer = false;
std::uint32_t fallback_reads = 0u;
std::filesystem::path matched;
if (const auto direct = file_table.recent_atrac_reads.find(guest_buffer);
direct != file_table.recent_atrac_reads.end()) {
matched = direct->second;
direct_buffer = true;
file_table.recent_atrac_reads.erase(direct);
}
const std::size_t compare_size = std::min<std::size_t>(header.size(), 256u);
if (matched.empty()) {
for (const auto &[key, file] : file_table.virtual_files_by_path) {
(void)key;
if (file.size != parsed.file_size || !is_atrac_source_path(file.native_path)) continue;
++fallback_reads;
std::vector<std::uint8_t> candidate(compare_size);
std::ifstream input(file.native_path, std::ios::binary);
if (!input) continue;
input.read(reinterpret_cast<char *>(candidate.data()), static_cast<std::streamsize>(candidate.size()));
if (input.gcount() == static_cast<std::streamsize>(candidate.size()) &&
std::equal(candidate.begin(), candidate.end(), header.begin())) {
matched = file.native_path;
break;
}
}
}
const std::uint64_t elapsed_us = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - started).count());
std::ostringstream line;
line << "ATRAC_SOURCE resolve_us=" << elapsed_us
<< " direct_buffer=" << (direct_buffer ? 1 : 0)
<< " fallback_reads=" << fallback_reads
<< " source=" << (matched.empty() ? "<none>" : matched.filename().string());
runtime_log_line(line.str());
return matched;
}
// sceAtracDecodeData always hands the caller two interleaved channels: the PSP
// decoder upmixes a mono stream instead of returning half-width frames, and
// nothing in the API lets a game ask for anything else (this EBOOT does not
// even import sceAtracGetOutputChannel). Emitting mono PCM for a mono file
// made the game read a stereo-sized buffer out of a half-filled one, so every
// mono stream -- VCPR and the NEWS_* bulletins, i.e. exactly the spoken
// stations -- ran at double speed while the stereo music stations were fine.
constexpr std::uint32_t kAtracOutputChannels = 2u;
void close_atrac_decoder(AtracContextState &state) {
state.decoder.close();
state.decoder_eof = false;
}
bool open_atrac_decoder(AtracContextState &state) {
if (state.decoder.is_open()) return true;
if (state.source_path.empty()) return false;
// Clamp the seek to the stream. A reopen was observed at sample 159,114,619
// on EMOTION.AT3 -- an hour of audio into a track a few minutes long -- which
// sends the demuxer hunting past end of file for a position that cannot
// exist. sample_position accumulates across decodes and nothing bounded it
// here; whatever lets it run away is a separate bug, but the seek itself
// must stay inside the file.
std::uint64_t seek = state.sample_position;
if (state.header.total_samples != 0u && seek > state.header.total_samples) {
if (std::getenv("PSPRECOMP_ATRAC_DIAG") != nullptr)
std::cerr << "[atrac] seek " << seek << " fora do stream (total="
<< state.header.total_samples << "), limitado\n";
seek = state.header.total_samples;
}
if (!state.decoder.open(state.source_path, state.header.sample_rate,
kAtracOutputChannels, seek))
return false;
state.decoder_eof = false;
if (std::getenv("PSPRECOMP_ATRAC_DIAG") != nullptr)
std::cerr << "[atrac] decoder opened id-source=\"" << state.source_path.string()
<< "\" sample=" << state.sample_position << "\n";
return true;
}
std::size_t read_atrac_pcm(AtracContextState &state, std::span<std::uint8_t> output) {
if (!open_atrac_decoder(state)) return 0u;
const std::size_t total = state.decoder.read(output);
if (total < output.size()) state.decoder_eof = true;
return total;
}
std::uint32_t atrac_samples_per_frame(const AtracContextState &state) {
return state.header.atrac3plus ? 2048u : 1024u;
}
// sceAtrac streaming APIs use negative sentinel values once the encoded stream
// is entirely resident in the caller's halfway buffer. VCS checks these
// values directly after every sceAtracDecodeData call: -1/-2 take the
// all-data-resident path, while a non-negative frame count takes the refill
// path. Returning 0 when a short non-loop stream such as NEWS_*.AT3 was fully
// loaded made the game keep treating it as a streaming/refill source even
// though no more encoded bytes existed.
constexpr std::uint32_t kAtracRemainAllDataOnMemory = 0xFFFFFFFFu; // -1
constexpr std::uint32_t kAtracRemainNonLoopOnMemory = 0xFFFFFFFEu; // -2
constexpr std::uint32_t kAtracRemainLoopOnMemory = 0xFFFFFFFDu; // -3
bool atrac_has_active_loop(const AtracContextState &state) noexcept {
return state.loop_num != 0 && state.header.loop_start >= 0 &&
state.header.loop_end >= state.header.loop_start;
}
std::uint32_t atrac_remain_frame_status(const AtracContextState &state) noexcept {
if (state.next_file_offset >= state.header.file_size) {
// VCS creates its radio decoders through SetHalfwayBufferAndGetID, so
// the streaming-specific -2/-3 statuses are the faithful result once
// the whole file has been fed. (-1 belongs to the all-data API case;
// the guest accepts both -1 and -2 for its non-loop fast path.)
return atrac_has_active_loop(state) ?
kAtracRemainLoopOnMemory : kAtracRemainNonLoopOnMemory;
}
if (state.header.block_align == 0u) return 0u;
return state.buffered_encoded_bytes / state.header.block_align;
}
std::uint32_t atrac_bitrate_kbps(const AtracContextState &state) {
if (state.header.atrac3plus) {
const std::uint32_t raw = (static_cast<std::uint32_t>(state.header.block_align) * 352800u) / 1000u;
return ((raw >> 11u) + 8u) & 0xFFFFFFF0u;
}
return (static_cast<std::uint32_t>(state.header.block_align) * 352800u / 1000u + 511u) >> 10u;
}
ThreadTable thread_table;
PartitionTable partition_table;
std::uint32_t partition_arena_base{};
CallbackTable callback_table;
SemaphoreTable semaphore_table;
EventFlagTable event_flag_table;
FixedPoolTable fixed_pool_table;
std::uint32_t compiled_sdk_version{};
std::uint32_t compiler_version{};
std::int32_t next_module_uid{0x400};
std::unordered_map<std::int32_t, bool> loaded_modules;
std::unordered_map<std::uint32_t, MpegContextState> mpeg_contexts;
std::array<AtracContextState, 6> atrac_contexts{};
std::uint32_t next_mpeg_stream_id{1u};
std::array<AudioChannelState, 9> audio_channels{};
std::uint64_t virtual_time_us{};
constexpr std::uint32_t kGuestFrameLimiterBranch = 0x08A070C8u;
constexpr std::uint32_t kGuestFrameLimiterContinue = 0x08A070D0u;
constexpr std::int32_t kGuestFrameCounterGpOffset = -8852;
std::uint32_t configured_game_frame_rate() noexcept {
return vcs_configuration().timing.frame_rate;
}
std::uint32_t virtual_display_refresh_hz() noexcept {
// 30 FPS is the stock game running on the PSP's 60 Hz display. Every
// unlocked mode renders once per virtual vblank.
return std::max(60u, configured_game_frame_rate());
}
std::uint64_t virtual_vblank_period_us() noexcept {
// Preserve the port's existing 59.94 Hz PSP period exactly at 30/60.
const std::uint64_t refresh = virtual_display_refresh_hz();
return std::max<std::uint64_t>(1u, (16683u * 60u + refresh / 2u) / refresh);
}
void unlocked_frame_limiter_patch(psprecomp::Runtime &runtime,
psprecomp::AllegrexContext &ctx) {
// CWCheat 0x202070C8 writes a NOP over the branch at guest 0x08A070C8.
// Its delay-slot load still executes, then control falls through to D0.
ctx.set_gpr(4, runtime.memory().load32(
ctx.gpr[28] + static_cast<std::uint32_t>(kGuestFrameCounterGpOffset)));
ctx.pc = kGuestFrameLimiterContinue;
}
bool volatile_memory_locked{};
std::uint32_t general_purpose_io{};
std::uint32_t ge_edram_translation{};
struct GeCallbackRecord {
std::uint32_t signal_function{};
std::uint32_t signal_argument{};
std::uint32_t finish_function{};
std::uint32_t finish_argument{};
};
struct GeCallbackTable {
std::int32_t next_uid{0};
std::unordered_map<std::int32_t, GeCallbackRecord> callbacks;
};
GeCallbackTable ge_callback_table{};
enum class GeListState : std::uint32_t {
None = 0u,
Queued = 1u,
Running = 2u,
Completed = 3u,
Paused = 4u,
Stalled = 5u,
Error = 6u,
};
struct GeStackEntry {
std::uint32_t pc{};
std::uint32_t offset_address{};
std::uint32_t base_command{};
};
struct GeListRecord {
std::uint32_t guest_id{};
std::uint32_t start_pc{};
std::uint32_t pc{};
std::uint32_t stall{};
std::int32_t callback_id{-1};
std::uint32_t context_address{};
std::uint32_t stack_address{};
std::uint32_t stack_capacity{32u};
GeListState state{GeListState::None};
std::uint8_t signal_behavior{};
std::uint16_t callback_token{};
std::vector<GeStackEntry> stack;
std::array<std::uint64_t, 256> histogram{};
std::uint64_t executed_commands{};
std::uint64_t primitive_commands{};
// sceGeListEnQueue may supply a context buffer. The GE saves the current
// global state before the list and restores it when the list completes.
bool has_saved_context{};
std::array<std::uint32_t, 256> saved_commands{};
GeTransformState saved_transform{};
std::uint32_t saved_offset_address{};
std::uint32_t saved_vertex_address{};
std::uint32_t saved_index_address{};
bool saved_bounding_box_result{};
};
struct GeState {
std::array<std::uint32_t, 256> commands{};
GeTransformState transform{};
std::uint32_t offset_address{};
std::uint32_t vertex_address{};
std::uint32_t index_address{};
bool bounding_box_result{};
};
struct GeListTable {
std::uint32_t next_raw_id{};
std::unordered_map<std::uint32_t, GeListRecord> lists;
std::vector<std::uint32_t> queue;
};
struct GuestCallbackInvocation {
std::uint32_t function{};
std::uint32_t a0{};
std::uint32_t a1{};
std::uint32_t a2{};
};
GeState ge_state{};
// Monotonic generation for GE draw-state commands. Vertex/index pointers and
// PRIM counts change almost every draw but do not alter Vulkan pipeline/texture
// state, so they are excluded below. The renderer uses this to reuse a decoded
// GeGpuDrawDescriptor across consecutive draws with identical state.
std::uint64_t ge_draw_state_revision = 1u;
// Lighting/material state is substantially more expensive to decode than the
// small draw descriptor, but is also much more stable across city geometry.
// Track it independently so the renderer can cache PreparedLighting without
// invalidating it for texture/blend/scissor or per-object matrix changes.
std::uint64_t ge_lighting_state_revision = 1u;
// Camera-only generation for Project2DFX. Unlike the generic draw revision,
// world/model matrix changes must NOT invalidate this: VCS updates those per
// object while the view/projection camera stays identical for hundreds of draws.
std::uint64_t ge_camera_state_revision = 1u;
GeListTable ge_list_table{};
std::unordered_map<std::int32_t, std::vector<GuestCallbackInvocation>> pending_guest_callbacks;
// Stage 45.7: the PSP GE is an independent processor. Previous stages executed
// the complete display list inside sceGeListEnQueue(), serializing translated
// Allegrex work with vertex decode / draw preparation / DX12 accumulation.
// The async worker below turns enqueue into a producer operation and consumes
// lists on one dedicated host thread. The worker remains strictly ordered (one
// GE command stream at a time), while the guest CPU can continue until an
// explicit GE sync or display-vblank visibility boundary requires completion.
struct GeAsyncTask {
std::uint32_t id{};
std::int32_t submitter_uid{};
std::shared_ptr<std::atomic<std::uint32_t>> stall;
};
struct GeAsyncCompletion {
std::int32_t submitter_uid{};
std::vector<GuestCallbackInvocation> callbacks;
};
struct GeAsyncWorkerState {
std::mutex mutex;
std::condition_variable cv;
std::thread thread;
psprecomp::Runtime *runtime{};
bool stop_requested{};
std::atomic<bool> started{false};
std::deque<GeAsyncTask> pending;
std::unordered_map<std::uint32_t, std::shared_ptr<std::atomic<std::uint32_t>>> live_stalls;
std::deque<GeAsyncCompletion> completions;
std::atomic<std::uint32_t> outstanding{0u};
std::atomic<std::uint32_t> completion_count{0u};
std::atomic<std::uint64_t> last_wait_ns{0u};
std::atomic<bool> fatal{false};
std::string fatal_reason;
std::uint64_t submitted{};
std::uint64_t completed{};
std::uint64_t wait_calls{};
std::chrono::steady_clock::duration wait_time{};
};
GeAsyncWorkerState ge_async{};
thread_local bool ge_async_worker_thread = false;
bool ge_async_enabled() noexcept {
// V5 SYNC RECOVERY: the experimental async scheduler is quarantined after
// repeated boot->gameplay deadlocks. Ignore the legacy PSPRECOMP_GE_ASYNC
// variable so a stale shell/BAT cannot silently re-enable the broken path.
// Re-entry is development-only and requires an explicit new opt-in.
static const bool enabled = [] {
const char *value = std::getenv("PSPRECOMP_GE_ASYNC_EXPERIMENTAL");
return value != nullptr && *value != '\0' && std::strcmp(value, "0") != 0;
}();
return enabled;
}
bool ge_async_running() noexcept {
return ge_async.started.load(std::memory_order_acquire);
}
void ge_async_worker_main();
void ge_async_drain_completions();
bool ge_async_wait_idle(psprecomp::Runtime &runtime);
bool ge_async_wait_list(psprecomp::Runtime &runtime, std::uint32_t id);
bool ge_async_check_fatal(psprecomp::Runtime &runtime);
void ge_async_stop_worker();
void ge_async_record_fatal(std::string reason) {
{
std::lock_guard lock(ge_async.mutex);
if (ge_async.fatal_reason.empty()) ge_async.fatal_reason = std::move(reason);
}
ge_async.fatal.store(true, std::memory_order_release);
ge_async.cv.notify_all();
}
void ge_execution_stop(psprecomp::Runtime &runtime, std::string reason) {
if (ge_async_worker_thread) {
ge_async_record_fatal(std::move(reason));
return;
}
runtime.stop(std::move(reason));
}
void ge_async_start_worker(psprecomp::Runtime &runtime) {
if (!ge_async_enabled()) return;
std::lock_guard lock(ge_async.mutex);
if (ge_async.started.load(std::memory_order_acquire)) return;
ge_async.runtime = &runtime;
ge_async.stop_requested = false;
ge_async.started.store(true, std::memory_order_release);
ge_async.thread = std::thread(&ge_async_worker_main);
}
struct GeAsyncLifetimeGuard {
~GeAsyncLifetimeGuard() { ge_async_stop_worker(); }
};
GeAsyncLifetimeGuard ge_async_lifetime_guard{};
constexpr std::array<std::pair<std::uint8_t, std::uint8_t>, 18> kGeContextCommandRanges{{
{0x00u, 0x02u}, {0x10u, 0x10u}, {0x12u, 0x28u}, {0x2Cu, 0x33u},
{0x36u, 0x38u}, {0x42u, 0x4Du}, {0x50u, 0x51u}, {0x53u, 0x58u},
{0x5Bu, 0xB5u}, {0xB8u, 0xC3u}, {0xC5u, 0xD0u}, {0xD2u, 0xE9u},
{0xEBu, 0xECu}, {0xEEu, 0xEEu}, {0xF0u, 0xF6u}, {0xF8u, 0xF9u},
// Empty sentinels keep the table fixed-size and are skipped below.
{0xFFu, 0x00u}, {0xFFu, 0x00u},
}};
std::uint32_t ge_float24_command(std::uint32_t command, float value) {
return (command << 24u) | ((std::bit_cast<std::uint32_t>(value) >> 8u) & 0x00FFFFFFu);
}
bool ge_command_affects_lighting(std::uint32_t command) noexcept {
return command == 0x17u ||
(command >= 0x18u && command <= 0x1Bu) ||
command == 0x53u ||
(command >= 0x54u && command <= 0x58u) ||
(command >= 0x5Bu && command <= 0x5Du) ||
(command >= 0x5Fu && command <= 0x9Au);
}
void write_ge_context_buffer(psprecomp::Runtime &runtime, std::uint32_t address,
const GeState &state) {
runtime.memory().zero(address, 512u * 4u);
runtime.memory().store32(address + 5u * 4u, state.vertex_address);
runtime.memory().store32(address + 6u * 4u, state.index_address);
runtime.memory().store32(address + 7u * 4u, state.offset_address);
std::uint32_t word = 17u;
for (const auto [first, last] : kGeContextCommandRanges) {
if (first > last) continue;
for (std::uint32_t command = first; command <= last; ++command)
runtime.memory().store32(address + word++ * 4u, state.commands[command]);
}
const auto save_matrix = [&](std::uint32_t number_command, std::uint32_t data_command,
const auto &matrix) {
runtime.memory().store32(address + word++ * 4u, number_command << 24u);
for (float value : matrix)
runtime.memory().store32(address + word++ * 4u, ge_float24_command(data_command, value));
};
save_matrix(0x2Au, 0x2Bu, state.transform.bones);
save_matrix(0x3Au, 0x3Bu, state.transform.world);
save_matrix(0x3Cu, 0x3Du, state.transform.view);
save_matrix(0x3Eu, 0x3Fu, state.transform.projection);
save_matrix(0x40u, 0x41u, state.transform.texture);
runtime.memory().store32(address + word++ * 4u, (0x2Au << 24u) | (state.transform.bone_cursor & 0x7Fu));
runtime.memory().store32(address + word++ * 4u, (0x3Au << 24u) | (state.transform.world_cursor & 0xFu));
runtime.memory().store32(address + word++ * 4u, (0x3Cu << 24u) | (state.transform.view_cursor & 0xFu));
runtime.memory().store32(address + word++ * 4u, (0x3Eu << 24u) | (state.transform.projection_cursor & 0xFu));
runtime.memory().store32(address + word++ * 4u, (0x40u << 24u) | (state.transform.texture_cursor & 0xFu));
runtime.memory().store32(address + word++ * 4u, 0x0C000000u);
}
void save_ge_list_context(psprecomp::Runtime &runtime, GeListRecord &record) {
if (record.context_address == 0u) return;
record.has_saved_context = true;
record.saved_commands = ge_state.commands;
record.saved_transform = ge_state.transform;
record.saved_offset_address = ge_state.offset_address;
record.saved_vertex_address = ge_state.vertex_address;
record.saved_index_address = ge_state.index_address;
record.saved_bounding_box_result = ge_state.bounding_box_result;
write_ge_context_buffer(runtime, record.context_address, ge_state);
}
void restore_ge_list_context(const GeListRecord &record) {
if (!record.has_saved_context) return;
ge_state.commands = record.saved_commands;
++ge_draw_state_revision;
++ge_lighting_state_revision;
++ge_camera_state_revision;
ge_state.transform = record.saved_transform;
ge_state.offset_address = record.saved_offset_address;
ge_state.vertex_address = record.saved_vertex_address;
ge_state.index_address = record.saved_index_address;
ge_state.bounding_box_result = record.saved_bounding_box_result;
}
enum class AsyncReturnKind : std::uint8_t {
GeCallbackChain,
SubInterrupt,
MpegRingbuffer,
UserCallback,
};
struct AsyncReturnFrame {
AsyncReturnKind kind{AsyncReturnKind::GeCallbackChain};
psprecomp::AllegrexContext resume{};
std::uint32_t ring_address{};
std::int32_t remaining_packets{};
std::int32_t requested_this_round{};
std::int32_t total_packets{};
std::int32_t callback_uid{};
};
std::unordered_map<std::int32_t, std::vector<AsyncReturnFrame>> async_return_frames;
struct DisplayState {
std::uint32_t mode{};
std::uint32_t width{480u};
std::uint32_t height{272u};
std::uint32_t frame_buffer{};
std::uint32_t buffer_width{512u};
std::uint32_t pixel_format{3u};
std::uint32_t sync_mode{};
};
DisplayState display_state{};
struct SubInterruptRecord {
std::uint32_t handler{};
std::uint32_t argument{};
bool enabled{};
bool occurred{};
};
std::unordered_map<std::uint64_t, SubInterruptRecord> sub_interrupts;
std::uint64_t sub_interrupt_key(std::uint32_t interrupt_number, std::uint32_t sub_number) {
return (static_cast<std::uint64_t>(interrupt_number) << 32u) | sub_number;
}
std::uint32_t memory_stick_fat_state{1u};
struct ControllerState {
std::uint32_t sampling_cycle{};
std::uint32_t sampling_mode{};
std::uint32_t buttons{};
std::uint8_t lx{128u};
std::uint8_t ly{128u};
std::uint8_t rx{128u};
std::uint8_t ry{128u};
};
ControllerState controller_state{};
std::uint64_t display_vblank_index{};
// Stage 9 diagnostic guard. Setting PSPRECOMP_TIME_TICK_DISPATCHES=0 is useful
// for isolated scheduler/I/O ordering tests, but it deliberately disables the
// execution-driven PSP timer. A polling thread can then keep delayed workers
// from ever reaching their deadlines. Detect that configuration before it
// burns hundreds of millions of dispatches while appearing to be a game hang.
std::uint64_t execution_clock_dispatch_interval{256u};
std::uint64_t frozen_clock_guard_limit{5'000'000u};
std::uint64_t frozen_clock_guard_dispatches{};
std::uint64_t frozen_clock_guard_vblank{};
struct ControllerPulseConfig {
std::uint32_t buttons{};
std::uint64_t start_vblank{};
std::uint64_t end_vblank{};
std::uint8_t lx{128u};
std::uint8_t ly{128u};
bool has_lx{};
bool has_ly{};
};
std::uint64_t parse_environment_u64(const char *name, std::uint64_t fallback = 0u) {
const char *text = std::getenv(name);
if (text == nullptr || *text == '\0') return fallback;
char *end = nullptr;
const unsigned long long value = std::strtoull(text, &end, 0);
return end != text && *end == '\0' ? static_cast<std::uint64_t>(value) : fallback;
}
const std::array<ControllerPulseConfig, 8> &controller_pulse_configs() {
static const std::array<ControllerPulseConfig, 8> configs = [] {
std::array<ControllerPulseConfig, 8> values{};
constexpr std::array<const char *, 8> suffixes{"", "2", "3", "4", "5", "6", "7", "8"};
for (std::size_t index = 0; index < values.size(); ++index) {
const std::string suffix = suffixes[index];
const std::string prefix = "PSPRECOMP_CTRL_PULSE" + suffix;
const std::string buttons_name = prefix + "_BUTTONS";
const std::string start_name = prefix + "_START_VBLANK";
const std::string end_name = prefix + "_END_VBLANK";
const std::string lx_name = prefix + "_LX";
const std::string ly_name = prefix + "_LY";
ControllerPulseConfig &value = values[index];
value.buttons = static_cast<std::uint32_t>(parse_environment_u64(buttons_name.c_str()));
value.start_vblank = parse_environment_u64(start_name.c_str());
value.end_vblank = parse_environment_u64(end_name.c_str(), value.start_vblank);
if (value.end_vblank < value.start_vblank) value.end_vblank = value.start_vblank;
if (std::getenv(lx_name.c_str()) != nullptr) {
value.lx = static_cast<std::uint8_t>(std::min<std::uint64_t>(255u, parse_environment_u64(lx_name.c_str(), 128u)));
value.has_lx = true;
}
if (std::getenv(ly_name.c_str()) != nullptr) {
value.ly = static_cast<std::uint8_t>(std::min<std::uint64_t>(255u, parse_environment_u64(ly_name.c_str(), 128u)));
value.has_ly = true;
}
}
return values;
}();
return configs;
}
void dump_ram_if_requested(const psprecomp::GuestMemory &memory) {
struct Config {
std::filesystem::path directory;
std::uint64_t start{};
std::uint64_t end{};
std::uint64_t interval{1u};
bool dump_vram{};
bool enabled{};
};
static const Config config = [] {
Config value{};
const char *directory = std::getenv("PSPRECOMP_RAM_DUMP_DIR");
if (directory == nullptr || *directory == '\0') return value;
value.directory = directory;
value.start = parse_environment_u64("PSPRECOMP_RAM_DUMP_START_VBLANK");
value.end = parse_environment_u64("PSPRECOMP_RAM_DUMP_END_VBLANK", value.start);
value.interval = std::max<std::uint64_t>(1u, parse_environment_u64("PSPRECOMP_RAM_DUMP_INTERVAL", 1u));
value.dump_vram = parse_environment_u64("PSPRECOMP_RAM_DUMP_VRAM") != 0u;
value.enabled = true;
return value;
}();
if (!config.enabled || display_vblank_index < config.start || display_vblank_index > config.end ||
((display_vblank_index - config.start) % config.interval) != 0u) return;
std::filesystem::create_directories(config.directory);
std::ostringstream stem;
stem << "ram_vblank_" << std::setw(6) << std::setfill('0') << display_vblank_index;
const auto write_bytes = [&](const std::filesystem::path &path, std::span<const std::uint8_t> bytes) {
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) throw std::runtime_error("Unable to create RAM diagnostic dump: " + path.string());
output.write(reinterpret_cast<const char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!output) throw std::runtime_error("Unable to write RAM diagnostic dump: " + path.string());
};
const std::filesystem::path ram_path = config.directory / (stem.str() + ".bin");
write_bytes(ram_path, memory.bytes());
if (config.dump_vram)
write_bytes(config.directory / (stem.str() + ".vram.bin"), memory.vram_bytes());
std::cerr << "[ram-dump] vblank=" << display_vblank_index
<< " path=" << ram_path.string()
<< " bytes=" << memory.bytes().size() << "\n";
}
bool controller_pulse_active(const ControllerPulseConfig &pulse) {
return display_vblank_index >= pulse.start_vblank && display_vblank_index <= pulse.end_vblank &&
(pulse.buttons != 0u || pulse.has_lx || pulse.has_ly);
}
std::uint32_t effective_controller_buttons() {
// Live keyboard input is ORed in; deterministic vblank pulses keep scripted
// validation runs reproducible whether or not a window is open.
std::uint32_t buttons = controller_state.buttons | display_window_buttons();
for (const ControllerPulseConfig &pulse : controller_pulse_configs()) {
if (controller_pulse_active(pulse)) buttons |= pulse.buttons;
}
return buttons;
}
std::pair<std::uint8_t, std::uint8_t> effective_controller_analog() {
std::uint8_t analog_x = controller_state.lx;
std::uint8_t analog_y = controller_state.ly;
const HostInputState host = display_window_input();
// The camera axes leave here rather than through sceCtrl: the PSP pad has
// no field for them. The guest reads them through the hook in
// generated_unit_0098.cpp.
vcs_camera_set_axes(host.camera_x, host.camera_y);
// Same reason: throttle and brake reach the guest through the vehicle's own
// accessors, not through the pad's Cross and Square, so that W and S can
// drive without also sprinting and jumping on foot.
vcs_set_host_drive_inputs(host.accelerate, host.brake);
const std::uint8_t window_x = host.analog_x;
const std::uint8_t window_y = host.analog_y;
if (window_x != 128u || window_y != 128u) {
analog_x = window_x;
analog_y = window_y;
}
// Later pulses intentionally win, allowing a scripted route to replace one
// steering segment with the next while buttons remain independently ORed.
for (const ControllerPulseConfig &pulse : controller_pulse_configs()) {
if (!controller_pulse_active(pulse)) continue;
if (pulse.has_lx) analog_x = pulse.lx;
if (pulse.has_ly) analog_y = pulse.ly;
}
return {analog_x, analog_y};
}
enum class SasVoiceType : std::uint8_t {
Off,
Vag,
Noise,
};
enum class SasEnvelopePhase : std::uint8_t {
Attack,
Decay,
Sustain,
Release,
Off,
};
struct SasVoiceState {
SasVoiceType type{SasVoiceType::Off};
std::uint32_t data_address{};
std::int32_t data_size{};
bool loop{};
std::int32_t noise_frequency{};
std::int32_t pitch{0x1000};
std::int32_t left_volume{};
std::int32_t right_volume{};
std::int32_t effect_left_volume{};
std::int32_t effect_right_volume{};
std::array<std::int32_t, 4> adsr_rates{};
// Attack rises, everything else falls -- the same parity rule that
// __sceSasSetADSRmode enforces on the game (even for attack, odd for decay
// and release). Defaulting all four to zero broke that rule: zero is
// "linear increase", so a voice configured through __sceSasSetADSR alone,
// which sets rates and never touches the modes, walked its release phase
// *upward*. The envelope pinned at maximum, `height <= 0` never happened,
// and a keyed-off looping voice -- the vehicle engine -- kept sounding at
// full volume under the pause menu.
std::array<std::int32_t, 4> adsr_modes{0, 1, 1, 1};
std::int32_t sustain_level{};
std::uint32_t simple_adsr1{};
std::uint32_t simple_adsr2{};
bool adsr_configured{};
SasEnvelopePhase envelope_phase{SasEnvelopePhase::Off};
std::uint32_t key_on_delay_samples{};
bool on{};
bool playing{};
bool paused{};
std::uint32_t envelope_height{};
std::uint64_t total_samples{};
std::uint64_t remaining_samples{};
// Stateful VAG decoder. A voice is frequently re-used by the game, so a
// KeyOn must rewind all of these fields rather than resuming at the end of
// the previous sound.
std::uint32_t decode_offset{};
std::int32_t history1{};
std::int32_t history2{};
std::array<std::int16_t, 28> block_samples{};
std::uint32_t block_position{28u};
std::uint32_t loop_start_offset{};
std::int32_t loop_start_history1{};
std::int32_t loop_start_history2{};
bool loop_start_valid{};
bool finished{};
// Pitch interpolation keeps a source sample pair alive across grain
// boundaries. The previous nearest-neighbour stepping clicked badly on
// pitched engine/weapon/ambient effects.
std::int16_t current_sample{};
std::int16_t next_sample{};
bool current_sample_valid{};
bool next_sample_valid{};
std::uint32_t pitch_accumulator{}; // 12-bit fraction, 0x1000 == one sample
// Deterministic noise generator for the SAS noise-voice path.
std::uint32_t noise_lfsr{0x13579BDFu};
std::uint32_t noise_phase{};
std::int16_t noise_sample{};
};
struct SasReverbState {
std::int32_t type{-1};
std::int32_t delay{};
std::int32_t feedback{};
std::uint32_t left_volume{};
std::uint32_t right_volume{};
// PSP SAS starts with the dry bus enabled. Wet processing is opt-in.
bool dry{true};
bool wet{};
// Persistent effect history. This is intentionally owned by the SAS core
// rather than rebuilt per grain so effect-only voices do not disappear at
// grain boundaries and delay tails remain continuous.
std::vector<std::int32_t> history_left;
std::vector<std::int32_t> history_right;
std::size_t history_cursor{};
};
struct SasState {
bool initialized{};
std::uint32_t core_address{};
std::uint32_t grain_size{};
std::uint32_t max_voices{32u};
std::uint32_t output_mode{};
std::uint32_t sample_rate{44100u};
std::array<SasVoiceState, 32> voices{};
// sceSasGetEndFlag exposes a hardware-latched snapshot. The flags are
// refreshed by a completed __sceSasCore/__sceSasCoreWithMix cycle, not by
// arbitrary setters in the middle of a grain.
std::uint32_t end_flags{0xFFFFFFFFu};
SasReverbState reverb{};
};
SasState sas_state{};
std::uint64_t sas_core_mix_calls{};
std::uint64_t sas_core_with_mix_calls{};
bool sas_audio_diagnostics_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr ||
std::getenv("PSPRECOMP_SAS_DIAG") != nullptr;
return enabled;
}
std::size_t sas_playing_voice_count() {
return static_cast<std::size_t>(std::count_if(
sas_state.voices.begin(), sas_state.voices.end(),
[](const SasVoiceState &voice) { return voice.playing && !voice.paused; }));
}
void sas_refresh_end_flags() noexcept {
std::uint32_t flags = 0u;
for (std::size_t i = 0; i < sas_state.voices.size(); ++i) {
if (!sas_state.voices[i].playing) flags |= 1u << i;
}
sas_state.end_flags = flags;
}
void sas_log_mix_checkpoint(const char *kind, std::uint64_t count) {
if (!sas_audio_diagnostics_enabled()) return;
if (count <= 8u || (count % 256u) == 0u) {
std::cerr << "[sas] " << kind << " call=" << count
<< " voices=" << sas_playing_voice_count()
<< " dry=" << sas_state.reverb.dry
<< " wet=" << sas_state.reverb.wet
<< " effect_type=" << sas_state.reverb.type
<< " grain=" << sas_state.grain_size << "\n";
}
}
constexpr std::uint32_t kSasErrorInvalidGrain = 0x80420001u;
constexpr std::uint32_t kSasErrorInvalidMaxVoices = 0x80420002u;
constexpr std::uint32_t kSasErrorInvalidOutputMode = 0x80420003u;
constexpr std::uint32_t kSasErrorInvalidSampleRate = 0x80420004u;
constexpr std::uint32_t kSasErrorBadAddress = 0x80420005u;
constexpr std::uint32_t kSasErrorInvalidVoice = 0x80420010u;
constexpr std::uint32_t kSasErrorInvalidNoiseFrequency = 0x80420011u;
constexpr std::uint32_t kSasErrorInvalidPitch = 0x80420012u;
constexpr std::uint32_t kSasErrorInvalidAdsrMode = 0x80420013u;
constexpr std::uint32_t kSasErrorInvalidParameter = 0x80420014u;
constexpr std::uint32_t kSasErrorInvalidLoop = 0x80420015u;
constexpr std::uint32_t kSasErrorVoicePaused = 0x80420016u;
constexpr std::uint32_t kSasErrorInvalidVolume = 0x80420018u;
constexpr std::uint32_t kSasErrorInvalidAdsrRate = 0x80420019u;
constexpr std::uint32_t kSasErrorReverbType = 0x80420020u;
constexpr std::uint32_t kSasErrorReverbFeedback = 0x80420021u;
constexpr std::uint32_t kSasErrorReverbDelay = 0x80420022u;
constexpr std::uint32_t kSasErrorReverbVolume = 0x80420023u;
constexpr std::uint32_t kSasErrorNotInitialized = 0x80420100u;
constexpr std::uint32_t kSasEnvelopeMaximum = 0x40000000u;
bool sas_valid_core(std::uint32_t core) noexcept {
return sas_state.initialized && core == sas_state.core_address;
}
SasVoiceState *sas_voice(std::uint32_t core, std::int32_t voice, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(core)) {
ctx.set_gpr(2, kSasErrorNotInitialized);
return nullptr;
}
if (voice < 0 || voice >= 32) {
ctx.set_gpr(2, kSasErrorInvalidVoice);
return nullptr;
}
return &sas_state.voices[static_cast<std::size_t>(voice)];
}
void sas_reset_voice_duration(SasVoiceState &voice) noexcept {
if (voice.type == SasVoiceType::Vag && voice.data_size > 0) {
// PSP VAG/ADPCM uses 16-byte blocks producing 28 PCM samples. This is
// an upper bound for streams with an earlier end marker, but it remains
// useful for diagnostics and never drives end-of-voice by itself.
voice.total_samples = static_cast<std::uint64_t>(voice.data_size / 16) * 28u;
voice.remaining_samples = voice.total_samples;
} else {
voice.total_samples = 0u;
voice.remaining_samples = 0u;
}
}
void sas_reset_decoder(SasVoiceState &voice) noexcept {
voice.decode_offset = 0u;
voice.history1 = 0;
voice.history2 = 0;
voice.block_position = 28u;
voice.loop_start_offset = 0u;
voice.loop_start_history1 = 0;
voice.loop_start_history2 = 0;
voice.loop_start_valid = false;
voice.finished = false;
voice.current_sample = 0;
voice.next_sample = 0;
voice.current_sample_valid = false;
voice.next_sample_valid = false;
voice.pitch_accumulator = 0u;
voice.noise_lfsr = 0x13579BDFu;
voice.noise_phase = 0u;
voice.noise_sample = 0;
sas_reset_voice_duration(voice);
}
// When a title never configures ADSR, use a short de-click ramp rather than
// leaving the envelope at zero forever. Once VCS supplies real SAS envelope
// parameters we follow those curves instead.
constexpr std::uint32_t kSasFallbackAttackSamples = 8u;
constexpr std::uint32_t kSasFallbackReleaseSamples = 32u;
constexpr std::uint32_t kSasFallbackAttackStep =
kSasEnvelopeMaximum / kSasFallbackAttackSamples;
constexpr std::uint32_t kSasFallbackReleaseStep =
kSasEnvelopeMaximum / kSasFallbackReleaseSamples;
std::int64_t sas_walk_envelope_curve(std::int64_t height, std::int32_t mode,
std::int32_t rate) noexcept {
const std::int64_t r = std::max<std::int64_t>(0, rate);
switch (mode) {
case 0: // linear increase
return height + r;
case 1: // linear decrease
return height - r;
case 2: // linear bent increase
return height + (height <= static_cast<std::int64_t>(kSasEnvelopeMaximum) * 3 / 4
? r : r / 4);
case 3: { // exponential decrease
std::int64_t delta = height - static_cast<std::int64_t>(kSasEnvelopeMaximum);
delta += ((-delta) * r) >> 32;
return delta + kSasEnvelopeMaximum - (r + 3) / 4;
}
case 4: { // exponential increase
std::int64_t delta = height - static_cast<std::int64_t>(kSasEnvelopeMaximum);
delta += ((-delta) * r) >> 32;
return delta + kSasEnvelopeMaximum + 0x4000;
}
case 5: // direct
return r;
default:
return height;
}
}
std::uint32_t sas_step_envelope(SasVoiceState &voice) noexcept {
if (!voice.playing) return 0u;
if (!voice.adsr_configured) {
if (voice.on) {
if (voice.envelope_height < kSasEnvelopeMaximum) {
voice.envelope_height = std::min<std::uint32_t>(
kSasEnvelopeMaximum, voice.envelope_height + kSasFallbackAttackStep);
}
} else if (voice.envelope_height <= kSasFallbackReleaseStep) {
voice.envelope_height = 0u;
voice.envelope_phase = SasEnvelopePhase::Off;
voice.playing = false;
} else {
voice.envelope_height -= kSasFallbackReleaseStep;
}
return voice.envelope_height;
}
// Real PSP SAS holds the envelope at zero briefly after KeyOn. Keeping the
// delay in samples (rather than grains) avoids a sharp transient and makes
// the same voice deterministic at every grain size.
if (voice.key_on_delay_samples != 0u) {
--voice.key_on_delay_samples;
voice.envelope_height = 0u;
return 0u;
}
if (!voice.on && voice.envelope_phase != SasEnvelopePhase::Off)
voice.envelope_phase = SasEnvelopePhase::Release;
std::int64_t height = voice.envelope_height;
switch (voice.envelope_phase) {
case SasEnvelopePhase::Attack:
height = sas_walk_envelope_curve(height, voice.adsr_modes[0], voice.adsr_rates[0]);
if (height >= static_cast<std::int64_t>(kSasEnvelopeMaximum) || height < 0) {
height = kSasEnvelopeMaximum;
voice.envelope_phase = SasEnvelopePhase::Decay;
}
break;
case SasEnvelopePhase::Decay:
height = sas_walk_envelope_curve(height, voice.adsr_modes[1], voice.adsr_rates[1]);
if (height <= voice.sustain_level) {
height = std::max<std::int64_t>(0, voice.sustain_level);
voice.envelope_phase = SasEnvelopePhase::Sustain;
}
break;
case SasEnvelopePhase::Sustain:
height = sas_walk_envelope_curve(height, voice.adsr_modes[2], voice.adsr_rates[2]);
if (height <= 0) {
height = 0;
voice.envelope_phase = SasEnvelopePhase::Release;
} else if (height > static_cast<std::int64_t>(kSasEnvelopeMaximum)) {
height = kSasEnvelopeMaximum;
}
break;
case SasEnvelopePhase::Release:
// A zero release rate is legal in the compact PSP ADSR encoding, but
// treating it as an actual delta of zero makes a KeyOff voice immortal.
// That is catastrophic for looped vehicle/horn VAGs: the source keeps
// wrapping forever after the game has explicitly keyed it off. Hardware
// still reaches the off state; use the same short de-click ramp we use
// for an unconfigured envelope when the decoded release cannot advance.
if (voice.adsr_rates[3] <= 0)
height -= kSasFallbackReleaseStep;
else
height = sas_walk_envelope_curve(height, voice.adsr_modes[3], voice.adsr_rates[3]);
if (height <= 0) {
height = 0;
voice.envelope_phase = SasEnvelopePhase::Off;
voice.playing = false;
}
break;
case SasEnvelopePhase::Off:
height = 0;
voice.playing = false;
break;
}
height = std::clamp<std::int64_t>(height, 0, kSasEnvelopeMaximum);
voice.envelope_height = static_cast<std::uint32_t>(height);
return voice.envelope_height;
}
std::int32_t sas_simple_rate(std::uint32_t value) noexcept {
value &= 0x7Fu;
if (value == 0x7Fu) return 0;
const std::uint64_t base = static_cast<std::uint64_t>(7u - (value & 3u)) << 26u;
const std::uint64_t rate = base >> (value >> 2u);
return static_cast<std::int32_t>(std::max<std::uint64_t>(1u, rate));
}
std::int32_t sas_exponent_rate(std::uint32_t value) noexcept {
value &= 0x7Fu;
if (value == 0x7Fu) return 0;
const std::uint64_t base = static_cast<std::uint64_t>(7u - (value & 3u)) << 24u;
const std::uint64_t rate = base >> (value >> 2u);
return static_cast<std::int32_t>(std::max<std::uint64_t>(1u, rate));
}
void sas_decode_simple_adsr(SasVoiceState &voice) noexcept {
const std::uint32_t a1 = voice.simple_adsr1;
const std::uint32_t a2 = voice.simple_adsr2;
voice.adsr_rates[0] = sas_simple_rate(a1 >> 8u);
voice.adsr_modes[0] = (a1 & 0x8000u) == 0u ? 0 : 2;
const std::uint32_t decay = (a1 >> 4u) & 0x0Fu;
voice.adsr_rates[1] = decay == 0u ? 0x7FFFFFFF :
static_cast<std::int32_t>(0x80000000u >> decay);
voice.adsr_modes[1] = 3;
voice.adsr_modes[2] = static_cast<std::int32_t>((a2 >> 14u) & 3u);
voice.adsr_rates[2] = voice.adsr_modes[2] == 3
? sas_exponent_rate(a2 >> 6u) : sas_simple_rate(a2 >> 6u);
const std::uint32_t release = a2 & 0x1Fu;
voice.adsr_modes[3] = (a2 & 0x20u) == 0u ? 1 : 3;
if (release == 31u) {
voice.adsr_rates[3] = 0;
} else if (voice.adsr_modes[3] == 1) {
if (release == 30u) voice.adsr_rates[3] = 0x40000000;
else if (release == 29u) voice.adsr_rates[3] = 1;
else voice.adsr_rates[3] = static_cast<std::int32_t>(0x10000000u >> release);
} else {
voice.adsr_rates[3] = release == 0u ? 0x7FFFFFFF :
static_cast<std::int32_t>(0x80000000u >> release);
}
voice.sustain_level = static_cast<std::int32_t>(((a1 & 0x0Fu) + 1u) << 26u);
voice.adsr_configured = true;
}
// PSP VAG is the PS1 ADPCM format: 16-byte blocks holding 28 four-bit samples
// plus a shift/filter byte and a flags byte. The predictor coefficients are the
// standard table, scaled by 64.
constexpr std::int32_t kVagFilter0[16] = {
0, 60, 115, 98, 122, 0, 0, 52, 55, 60, 0, 0, 0, 2, 125, 0
};
constexpr std::int32_t kVagFilter1[16] = {
0, 0, -52, -55, -60, 0, 0, 0, -2, -125, 0, -91, 0, -216, -6, -151
};
// Decodes the next 16-byte block into voice.block_samples. Loop markers follow
// the PSP SAS convention used by VCS: 6 marks the loop start, 3 the loop end,
// and 7 the terminal block. Marker 1 is also accepted as a terminal block for
// ordinary PSX-style VAG assets.
bool sas_decode_next_block(const psprecomp::GuestMemory &memory, SasVoiceState &voice) {
if (voice.data_address == 0u || voice.data_size <= 0 || voice.finished) return false;
const auto rewind_loop = [&]() {
// PSP SAS keeps the ADPCM predictor history across a loop jump.
// Resetting it at the marker makes otherwise seamless ambient loops
// click every time they wrap.
voice.decode_offset = voice.loop_start_valid ? voice.loop_start_offset : 0u;
if (voice.loop_start_valid) {
voice.history1 = voice.loop_start_history1;
voice.history2 = voice.loop_start_history2;
} else {
voice.history1 = 0;
voice.history2 = 0;
}
voice.remaining_samples = voice.total_samples;
};
if (voice.decode_offset + 16u > static_cast<std::uint32_t>(voice.data_size)) {
if (!voice.loop) return false;
rewind_loop();
}
const std::uint32_t relative_offset = voice.decode_offset;
const std::uint32_t base = voice.data_address + relative_offset;
if (!memory.contains(base, 16u)) return false;
const std::uint32_t header = memory.aot_load8(base);
const std::uint32_t flags = memory.aot_load8(base + 1u);
const std::int32_t history_before_1 = voice.history1;
const std::int32_t history_before_2 = voice.history2;
std::int32_t shift = static_cast<std::int32_t>(header & 0x0Fu);
std::int32_t filter = static_cast<std::int32_t>((header >> 4u) & 0x0Fu);
// All four predictor bits and all four shift bits are meaningful on the
// PSP SAS decoder.
filter &= 0x0F;
shift &= 0x0F;
// Flag 7 is a terminal marker block; it does not contribute 28 samples.
if (flags == 7u) {
voice.finished = true;
return false;
}
for (std::uint32_t index = 0u; index < 28u; ++index) {
const std::uint32_t byte = memory.aot_load8(base + 2u + index / 2u);
const std::uint32_t nibble = (index & 1u) != 0u ? (byte >> 4u) : (byte & 0x0Fu);
std::int32_t sample = static_cast<std::int32_t>(nibble << 12u);
if (sample & 0x8000) sample = static_cast<std::int32_t>(sample | 0xFFFF0000u);
sample >>= shift;
sample += (voice.history1 * kVagFilter0[filter] +
voice.history2 * kVagFilter1[filter] + 32) / 64;
sample = std::clamp(sample, -32768, 32767);
voice.block_samples[index] = static_cast<std::int16_t>(sample);
voice.history2 = voice.history1;
voice.history1 = sample;
}
if (flags == 6u) {
// Re-entering the loop must restore the predictor state from immediately
// before the loop-start block, otherwise each pass drifts/clicks.
voice.loop_start_offset = relative_offset;
voice.loop_start_history1 = history_before_1;
voice.loop_start_history2 = history_before_2;
voice.loop_start_valid = true;
}
voice.decode_offset += 16u;
voice.block_position = 0u;
if (flags == 3u) {
if (voice.loop) rewind_loop();
else voice.finished = true;
} else if (flags == 1u) {
voice.finished = true;
}
return true;
}
bool sas_fetch_vag_sample(const psprecomp::GuestMemory &memory, SasVoiceState &voice,
std::int16_t &sample) {
if (voice.block_position >= 28u) {
if (voice.finished || !sas_decode_next_block(memory, voice)) return false;
}
sample = voice.block_samples[voice.block_position++];
if (voice.remaining_samples != 0u) --voice.remaining_samples;
return true;
}
bool sas_prepare_sample_pair(const psprecomp::GuestMemory &memory, SasVoiceState &voice) {
if (!voice.current_sample_valid) {
if (!sas_fetch_vag_sample(memory, voice, voice.current_sample)) return false;
voice.current_sample_valid = true;
}
if (!voice.next_sample_valid) {
std::int16_t next{};
if (sas_fetch_vag_sample(memory, voice, next)) {
voice.next_sample = next;
voice.next_sample_valid = true;
}
}
return true;
}
std::int32_t sas_render_vag_sample(const psprecomp::GuestMemory &memory, SasVoiceState &voice) {
if (!sas_prepare_sample_pair(memory, voice)) {
voice.playing = false;
voice.on = false;
voice.envelope_height = 0u;
return 0;
}
const std::int32_t current = voice.current_sample;
const std::int32_t next = voice.next_sample_valid ? voice.next_sample : current;
const std::int32_t sample = current +
((next - current) * static_cast<std::int32_t>(voice.pitch_accumulator)) / 0x1000;
const std::uint32_t pitch = voice.pitch < 0 ? 0u : static_cast<std::uint32_t>(voice.pitch);
voice.pitch_accumulator += pitch;
while (voice.pitch_accumulator >= 0x1000u && voice.playing) {
voice.pitch_accumulator -= 0x1000u;
if (!voice.next_sample_valid) {
// The current sample was the last one. It has just been rendered;
// retire the voice without creating a discontinuous extra zero.
voice.playing = false;
voice.on = false;
voice.envelope_height = 0u;
break;
}
voice.current_sample = voice.next_sample;
voice.current_sample_valid = true;
std::int16_t following{};
if (sas_fetch_vag_sample(memory, voice, following)) {
voice.next_sample = following;
voice.next_sample_valid = true;
} else {
voice.next_sample_valid = false;
}
}
return sample;
}
std::int32_t sas_render_noise_sample(SasVoiceState &voice) noexcept {
// Map 0..63 to progressively faster LFSR updates. Exact spectral shaping is
// hardware-specific, but implementing the path is crucial: VCS uses SAS
// noise voices for effects that were previously completely silent.
voice.noise_phase += static_cast<std::uint32_t>(voice.noise_frequency + 1);
while (voice.noise_phase >= 64u) {
voice.noise_phase -= 64u;
const std::uint32_t feedback =
((voice.noise_lfsr >> 0u) ^ (voice.noise_lfsr >> 1u) ^
(voice.noise_lfsr >> 21u) ^ (voice.noise_lfsr >> 31u)) & 1u;
voice.noise_lfsr = (voice.noise_lfsr >> 1u) | (feedback << 31u);
voice.noise_sample = (voice.noise_lfsr & 1u) != 0u ? 12288 : -12288;
}
return voice.noise_sample;
}
// Renders one voice into separate dry and effect-send accumulators. PSP SAS
// has two independent volume pairs per voice. The old HLE threw the effect
// pair away entirely, so any sound routed only to the wet bus was silent.
void sas_render_voice(const psprecomp::GuestMemory &memory, SasVoiceState &voice,
std::vector<std::int32_t> &dry_mix,
std::vector<std::int32_t> &effect_send,
std::uint32_t frames) {
if (!voice.playing || voice.paused || voice.type == SasVoiceType::Off) return;
for (std::uint32_t frame = 0u; frame < frames && voice.playing; ++frame) {
const std::uint32_t envelope = sas_step_envelope(voice);
if (!voice.playing || envelope == 0u) continue;
std::int32_t sample = 0;
if (voice.type == SasVoiceType::Vag)
sample = sas_render_vag_sample(memory, voice);
else if (voice.type == SasVoiceType::Noise)
sample = sas_render_noise_sample(voice);
const auto accumulate = [&](std::vector<std::int32_t> &target,
std::int32_t left_volume,
std::int32_t right_volume) {
const std::int64_t left_gain =
(static_cast<std::int64_t>(left_volume) * envelope) >> 30;
const std::int64_t right_gain =
(static_cast<std::int64_t>(right_volume) * envelope) >> 30;
target[frame * 2u] += static_cast<std::int32_t>((sample * left_gain) >> 12);
target[frame * 2u + 1u] += static_cast<std::int32_t>((sample * right_gain) >> 12);
};
accumulate(dry_mix, voice.left_volume, voice.right_volume);
accumulate(effect_send, voice.effect_left_volume, voice.effect_right_volume);
}
}
void sas_render_buses(const psprecomp::GuestMemory &memory, std::uint32_t frames,
std::vector<std::int32_t> &dry_mix,
std::vector<std::int32_t> &effect_send) {
dry_mix.assign(static_cast<std::size_t>(frames) * 2u, 0);
effect_send.assign(static_cast<std::size_t>(frames) * 2u, 0);
for (auto &voice : sas_state.voices)
sas_render_voice(memory, voice, dry_mix, effect_send, frames);
}
// A conservative host-side recreation of the SAS effect-send path. The exact
// PSP presets are hardware-specific, but the important observable semantics are
// preserved here: effectLeft/effectRight feed a persistent wet bus, RevVON can
// select dry/wet independently, RevEVOL scales wet output, and delay/feedback
// persist between grains. Type OFF is a transparent wet send rather than a
// destructive silent sink; this avoids dropping effect-routed SFX while still
// keeping the default (dry on, wet off) bit-for-bit simple.
void sas_process_effect_send(const std::vector<std::int32_t> &effect_send,
std::vector<std::int32_t> &wet_mix,
std::uint32_t frames) {
wet_mix.assign(static_cast<std::size_t>(frames) * 2u, 0);
if (!sas_state.reverb.wet) return;
const std::int64_t global_left = sas_state.reverb.left_volume;
const std::int64_t global_right = sas_state.reverb.right_volume;
if (sas_state.reverb.type < 0) {
for (std::uint32_t frame = 0u; frame < frames; ++frame) {
wet_mix[frame * 2u] = static_cast<std::int32_t>(
(static_cast<std::int64_t>(effect_send[frame * 2u]) * global_left) >> 12);
wet_mix[frame * 2u + 1u] = static_cast<std::int32_t>(
(static_cast<std::int64_t>(effect_send[frame * 2u + 1u]) * global_right) >> 12);
}
return;
}
// Long enough for every legal delay parameter while keeping the history
// tiny compared with the rest of the guest runtime. Preset type changes
// the base spacing; delay and feedback remain the caller-controlled knobs.
constexpr std::size_t kEffectHistoryFrames = 16384u;
auto &reverb = sas_state.reverb;
if (reverb.history_left.size() != kEffectHistoryFrames) {
reverb.history_left.assign(kEffectHistoryFrames, 0);
reverb.history_right.assign(kEffectHistoryFrames, 0);
reverb.history_cursor = 0u;
}
const std::size_t type_offset = static_cast<std::size_t>(std::clamp(reverb.type, 0, 8)) * 73u;
const std::size_t delay_frames = std::clamp<std::size_t>(
64u + type_offset + static_cast<std::size_t>(reverb.delay) * 24u,
1u, kEffectHistoryFrames - 1u);
const std::int64_t feedback = std::clamp<std::int32_t>(reverb.feedback, 0, 127);
for (std::uint32_t frame = 0u; frame < frames; ++frame) {
const std::size_t read_index =
(reverb.history_cursor + kEffectHistoryFrames - delay_frames) % kEffectHistoryFrames;
const std::int64_t delayed_left = reverb.history_left[read_index];
const std::int64_t delayed_right = reverb.history_right[read_index];
const std::int64_t input_left = effect_send[frame * 2u];
const std::int64_t input_right = effect_send[frame * 2u + 1u];
// Wet output includes the current send plus one delayed component. The
// feedback line itself is bounded to signed 24-bit-ish headroom so a
// pathological guest setting cannot accumulate indefinitely.
const std::int64_t effect_left = input_left + delayed_left;
const std::int64_t effect_right = input_right + delayed_right;
const std::int64_t next_left = input_left + (delayed_left * feedback) / 128;
const std::int64_t next_right = input_right + (delayed_right * feedback) / 128;
reverb.history_left[reverb.history_cursor] = static_cast<std::int32_t>(
std::clamp<std::int64_t>(next_left, -0x7FFFFF, 0x7FFFFF));
reverb.history_right[reverb.history_cursor] = static_cast<std::int32_t>(
std::clamp<std::int64_t>(next_right, -0x7FFFFF, 0x7FFFFF));
reverb.history_cursor = (reverb.history_cursor + 1u) % kEffectHistoryFrames;
wet_mix[frame * 2u] = static_cast<std::int32_t>((effect_left * global_left) >> 12);
wet_mix[frame * 2u + 1u] = static_cast<std::int32_t>((effect_right * global_right) >> 12);
}
}
// Mixed output mode: CoreWithMix starts from caller PCM, then the selected SAS
// dry/wet buses are added. Plain Core uses the same path without caller input.
void sas_mix_into(psprecomp::Runtime &rt, std::uint32_t output, std::uint32_t frames,
bool include_input = false,
std::uint32_t input_left = 0x1000u,
std::uint32_t input_right = 0x1000u) {
static thread_local std::vector<std::int32_t> dry_mix;
static thread_local std::vector<std::int32_t> effect_send;
static thread_local std::vector<std::int32_t> wet_mix;
sas_render_buses(rt.memory(), frames, dry_mix, effect_send);
sas_process_effect_send(effect_send, wet_mix, frames);
for (std::uint32_t frame = 0u; frame < frames; ++frame) {
std::int64_t l = 0;
std::int64_t r = 0;
if (include_input) {
const auto input_l = static_cast<std::int16_t>(
rt.memory().aot_load16(output + frame * 4u));
const auto input_r = static_cast<std::int16_t>(
rt.memory().aot_load16(output + frame * 4u + 2u));
l += (static_cast<std::int64_t>(input_l) * input_left) >> 12;
r += (static_cast<std::int64_t>(input_r) * input_right) >> 12;
}
if (sas_state.reverb.dry) {
l += dry_mix[frame * 2u];
r += dry_mix[frame * 2u + 1u];
}
if (sas_state.reverb.wet) {
l += wet_mix[frame * 2u];
r += wet_mix[frame * 2u + 1u];
}
rt.memory().store16(output + frame * 4u, static_cast<std::uint16_t>(
static_cast<std::int16_t>(std::clamp<std::int64_t>(l, -32768, 32767))));
rt.memory().store16(output + frame * 4u + 2u, static_cast<std::uint16_t>(
static_cast<std::int16_t>(std::clamp<std::int64_t>(r, -32768, 32767))));
}
sas_refresh_end_flags();
}
// Raw output mode exposes four non-interleaved planes: dry L, dry R, effect L,
// effect R. The previous HLE incorrectly treated mode 1 as mono and allocated
// only two bytes per frame, which could both lose send-routed sounds and write
// the wrong guest buffer layout.
void sas_mix_raw(psprecomp::Runtime &rt, std::uint32_t output, std::uint32_t frames) {
static thread_local std::vector<std::int32_t> dry_mix;
static thread_local std::vector<std::int32_t> effect_send;
sas_render_buses(rt.memory(), frames, dry_mix, effect_send);
const std::uint32_t left_base = output;
const std::uint32_t right_base = output + frames * 2u;
const std::uint32_t send_left_base = output + frames * 4u;
const std::uint32_t send_right_base = output + frames * 6u;
for (std::uint32_t frame = 0u; frame < frames; ++frame) {
const auto store = [&](std::uint32_t base, std::int32_t value) {
rt.memory().store16(base + frame * 2u, static_cast<std::uint16_t>(
static_cast<std::int16_t>(std::clamp(value, -32768, 32767))));
};
store(left_base, dry_mix[frame * 2u]);
store(right_base, dry_mix[frame * 2u + 1u]);
store(send_left_base, effect_send[frame * 2u]);
store(send_right_base, effect_send[frame * 2u + 1u]);
}
sas_refresh_end_flags();
}
enum class UtilityStatus : std::uint32_t {
None = 0u,
Init = 1u,
Visible = 2u,
Quit = 3u,
Finished = 4u,
};
struct SavedataUtilityState {
UtilityStatus status{UtilityStatus::None};
std::uint32_t parameter_address{};
std::uint32_t mode{};
bool operation_complete{};
bool ui_initialized{};
std::vector<SavedataSlotEntry> slots;
std::size_t selected{};
std::uint32_t previous_buttons{};
SavedataUtilityUiPrompt prompt{SavedataUtilityUiPrompt::List};
bool confirm_yes{};
std::uint32_t last_result{};
// V9 load-only startup: the first retail AUTOLOAD/LOAD is presented through
// the in-frame LISTLOAD picker instead of being allowed to auto-select a
// save or fall into New Game. The guest parameter keeps its original mode;
// only this host-side UI state is promoted to LISTLOAD.
bool startup_picker{};
bool direct_load_picker{};
};
SavedataUtilityState savedata_utility{};
bool startup_load_picker_consumed = false;
// -------------------------------------------------------------------------
// V8.2.6 SAVE REPRO CAPTURE
// -------------------------------------------------------------------------
// The post-mission save bug is expensive to reach manually. This diagnostic
// checkpoint captures the PSP-visible machine/kernel state at a completed
// vblank and persists it beside VCSNative.exe. A later rebuilt executable can
// restore that state before Runtime::run(), turning one long mission playthrough
// into a repeatable save repro.
//
// F8 : arm/capture a checkpoint at the next safe vblank.
// F10 : dump the circular dispatch/HLE trace after the save black-screen occurs.
// RESTORE_SAVE_REPRO.bat sets PSPRECOMP_SAVE_REPRO_AUTO_RESTORE=1.
constexpr std::uint64_t kSaveReproMagic = 0x3632435253435650ull; // "PVCSRC26"
constexpr std::uint32_t kSaveReproVersion = 826u;
constexpr std::size_t kSaveReproTraceCapacity = 131072u;
constexpr std::uint32_t kSaveReproDispatchSampleStride = 64u;
struct SaveReproWriter {
std::vector<std::uint8_t> bytes;
template <typename T>
void pod(const T &value) {
static_assert(std::is_trivially_copyable_v<T>);
const auto *src = reinterpret_cast<const std::uint8_t *>(&value);
bytes.insert(bytes.end(), src, src + sizeof(T));
}
void raw(std::span<const std::uint8_t> value) {
const std::uint64_t size = value.size();
pod(size);
bytes.insert(bytes.end(), value.begin(), value.end());
}
void string(std::string_view value) {
const std::uint64_t size = value.size();
pod(size);
bytes.insert(bytes.end(), value.begin(), value.end());
}
void path(const std::filesystem::path &value) { string(value.generic_string()); }
};
struct SaveReproReader {
std::span<const std::uint8_t> bytes;
std::size_t offset{};
std::string error;
template <typename T>
bool pod(T &value) {
static_assert(std::is_trivially_copyable_v<T>);
if (offset > bytes.size() || sizeof(T) > bytes.size() - offset) {
error = "checkpoint truncated while reading POD";
return false;
}
std::memcpy(&value, bytes.data() + offset, sizeof(T));
offset += sizeof(T);
return true;
}
bool raw(std::span<std::uint8_t> destination) {
std::uint64_t size{};
if (!pod(size)) return false;
if (size != destination.size()) {
error = "checkpoint memory section size mismatch";
return false;
}
if (offset > bytes.size() || size > bytes.size() - offset) {
error = "checkpoint truncated while reading byte section";
return false;
}
std::memcpy(destination.data(), bytes.data() + offset, static_cast<std::size_t>(size));
offset += static_cast<std::size_t>(size);
return true;
}
bool string(std::string &value) {
std::uint64_t size{};
if (!pod(size)) return false;
if (size > (64ull * 1024ull * 1024ull) || offset > bytes.size() || size > bytes.size() - offset) {
error = "checkpoint invalid string length";
return false;
}
value.assign(reinterpret_cast<const char *>(bytes.data() + offset), static_cast<std::size_t>(size));
offset += static_cast<std::size_t>(size);
return true;
}
bool path(std::filesystem::path &value) {
std::string text;
if (!string(text)) return false;
value = std::filesystem::path(text);
return true;
}
};
std::uint64_t save_repro_fnv1a(std::span<const std::uint8_t> bytes) noexcept {
std::uint64_t hash = 1469598103934665603ull;
for (const std::uint8_t value : bytes) {
hash ^= value;
hash *= 1099511628211ull;
}
return hash;
}
std::filesystem::path save_repro_checkpoint_path(const psprecomp::Runtime &runtime) {
return runtime.game_root().parent_path() / "VCS_SAVE_REPRO_CHECKPOINT.bin";
}
std::filesystem::path save_repro_trace_path(const psprecomp::Runtime &runtime) {
return runtime.game_root().parent_path() / "VCS_SAVE_REPRO_TRACE.txt";
}
struct SaveReproTraceRecord {
enum class Kind : std::uint8_t { Dispatch, Hle };
Kind kind{Kind::Dispatch};
std::uint64_t sequence{};
std::uint64_t vblank{};
std::uint64_t guest_us{};
std::int32_t uid{};
std::uint32_t pc{};
std::uint32_t next_pc{};
std::uint32_t a0{};
std::uint32_t a1{};
std::uint32_t a2{};
std::uint32_t a3{};
std::uint32_t sp{};
std::uint32_t ra{};
std::uint32_t nid{};
std::array<char, 32> library{};
std::array<char, 64> name{};
};
std::array<SaveReproTraceRecord, kSaveReproTraceCapacity> save_repro_trace{};
std::size_t save_repro_trace_count{};
std::size_t save_repro_trace_next{};
std::uint64_t save_repro_trace_sequence{};
bool save_repro_trace_enabled{};
bool save_repro_checkpoint_available{};
// V8.2.7 SAVE THREAD LIFECYCLE FIX. Old V8.2.6 checkpoints can contain
// worker threads which already called sceKernelExitDeleteThread but were left
// behind as dormant/Completed records by the old HLE. The live HLE fix below
// prevents future leaks; this narrowly-scoped migration repairs the already
// captured post-mission checkpoint without making the user replay the mission.
struct LegacyExitDeleteRepairStats {
std::uint32_t threads{};
std::uint64_t stack_bytes{};
std::uint32_t stack_top_before{};
std::uint32_t stack_top_after{};
};
LegacyExitDeleteRepairStats repair_legacy_vcs_exit_delete_threads();
void recompute_partition_frontier();
bool save_repro_capture_requested{};
bool save_repro_capture_error_reported{};
bool save_repro_self_test_mode{};
void save_repro_copy_text(auto &destination, std::string_view text) {
destination.fill('\0');
const std::size_t count = std::min(destination.size() - 1u, text.size());
std::memcpy(destination.data(), text.data(), count);
}
void save_repro_push_trace(const SaveReproTraceRecord &record) {
if (!save_repro_trace_enabled) return;
save_repro_trace[save_repro_trace_next] = record;
save_repro_trace_next = (save_repro_trace_next + 1u) % save_repro_trace.size();
save_repro_trace_count = std::min(save_repro_trace_count + 1u, save_repro_trace.size());
}
void save_repro_trace_dispatch(std::uint32_t dispatch_pc, std::int32_t uid,
const psprecomp::AllegrexContext &ctx) {
if (!save_repro_trace_enabled) return;
// Preserve seconds of history without turning the diagnostic build into a
// different scheduler workload. Every HLE is recorded separately; regular
// guest dispatches are sampled at a fixed power-of-two cadence.
static std::uint32_t sample_ticket = 0u;
if ((sample_ticket++ & (kSaveReproDispatchSampleStride - 1u)) != 0u) return;
SaveReproTraceRecord item{};
item.kind = SaveReproTraceRecord::Kind::Dispatch;
item.sequence = ++save_repro_trace_sequence;
item.vblank = display_vblank_index;
item.guest_us = virtual_time_us;
item.uid = uid;
item.pc = dispatch_pc;
item.next_pc = ctx.pc;
item.a0 = ctx.gpr[4]; item.a1 = ctx.gpr[5]; item.a2 = ctx.gpr[6]; item.a3 = ctx.gpr[7];
item.sp = ctx.gpr[29]; item.ra = ctx.gpr[31];
save_repro_push_trace(item);
}
void save_repro_write_context(SaveReproWriter &writer, const psprecomp::AllegrexContext &ctx) {
writer.pod(ctx);
}
bool save_repro_read_context(SaveReproReader &reader, psprecomp::AllegrexContext &ctx) {
return reader.pod(ctx);
}
void save_repro_write_thread_record(SaveReproWriter &w, const ThreadRecord &r) {
w.string(r.name); w.pod(r.entry); w.pod(r.priority); w.pod(r.stack_size); w.pod(r.attributes);
w.pod(r.stack_top); w.pod(r.stack_bottom); w.pod(r.kernel_context); w.pod(r.state);
w.pod(r.exit_status); w.pod(r.externally_suspended); save_repro_write_context(w, r.suspended_context);
w.pod(r.wakeup_count); w.pod(r.delay_until_us); w.pod(r.delay_sequence);
}
bool save_repro_read_thread_record(SaveReproReader &r, ThreadRecord &out) {
return r.string(out.name) && r.pod(out.entry) && r.pod(out.priority) && r.pod(out.stack_size) &&
r.pod(out.attributes) && r.pod(out.stack_top) && r.pod(out.stack_bottom) &&
r.pod(out.kernel_context) && r.pod(out.state) && r.pod(out.exit_status) &&
r.pod(out.externally_suspended) && save_repro_read_context(r, out.suspended_context) &&
r.pod(out.wakeup_count) && r.pod(out.delay_until_us) && r.pod(out.delay_sequence);
}
void save_repro_write_continuation(SaveReproWriter &w, const ThreadContinuation &c) {
w.pod(c.uid); save_repro_write_context(w, c.context); w.pod(c.ready_sequence);
}
bool save_repro_read_continuation(SaveReproReader &r, ThreadContinuation &c) {
return r.pod(c.uid) && save_repro_read_context(r, c.context) && r.pod(c.ready_sequence);
}
void save_repro_write_kernel_state(SaveReproWriter &w) {
w.pod(thread_table.next_uid); w.pod(thread_table.current_uid); w.pod(thread_table.next_stack_top);
w.pod(thread_table.next_ready_sequence); w.pod(thread_table.next_delay_sequence);
w.pod(static_cast<std::uint64_t>(thread_table.threads.size()));
for (const auto &[uid, record] : thread_table.threads) { w.pod(uid); save_repro_write_thread_record(w, record); }
w.pod(static_cast<std::uint64_t>(thread_table.continuations.size()));
for (const auto &c : thread_table.continuations) save_repro_write_continuation(w, c);
w.pod(static_cast<std::uint64_t>(thread_table.thread_end_waiters.size()));
for (const auto &[uid, list] : thread_table.thread_end_waiters) {
w.pod(uid); w.pod(static_cast<std::uint64_t>(list.size()));
for (const auto &c : list) save_repro_write_continuation(w, c);
}
w.pod(static_cast<std::uint64_t>(thread_table.free_stacks.size()));
for (const auto &block : thread_table.free_stacks) w.pod(block);
w.pod(partition_table.next_uid); w.pod(partition_table.next_address);
w.pod(static_cast<std::uint64_t>(partition_table.blocks.size()));
for (const auto &[uid, block] : partition_table.blocks) {
w.pod(uid); w.string(block.name); w.pod(block.address); w.pod(block.size);
}
w.pod(callback_table.next_uid); w.pod(static_cast<std::uint64_t>(callback_table.callbacks.size()));
for (const auto &[uid, cb] : callback_table.callbacks) {
w.pod(uid); w.string(cb.name); w.pod(cb.function); w.pod(cb.common); w.pod(cb.owner_uid);
w.pod(cb.notify_count); w.pod(cb.notify_argument);
}
w.pod(semaphore_table.next_uid); w.pod(static_cast<std::uint64_t>(semaphore_table.semaphores.size()));
for (const auto &[uid, sema] : semaphore_table.semaphores) {
w.pod(uid); w.string(sema.name); w.pod(sema.count); w.pod(sema.maximum);
w.pod(static_cast<std::uint64_t>(sema.waiters.size()));
for (const auto &waiter : sema.waiters) {
w.pod(waiter.uid); save_repro_write_context(w, waiter.context); w.pod(waiter.requested);
}
}
w.pod(event_flag_table.next_uid); w.pod(static_cast<std::uint64_t>(event_flag_table.flags.size()));
for (const auto &[uid, flag] : event_flag_table.flags) {
w.pod(uid); w.string(flag.name); w.pod(flag.attributes); w.pod(flag.initial_pattern); w.pod(flag.current_pattern);
w.pod(static_cast<std::uint64_t>(flag.waiters.size()));
for (const auto &waiter : flag.waiters) {
w.pod(waiter.uid); save_repro_write_context(w, waiter.context); w.pod(waiter.requested);
w.pod(waiter.mode); w.pod(waiter.output_address);
}
}
w.pod(fixed_pool_table.next_uid); w.pod(static_cast<std::uint64_t>(fixed_pool_table.pools.size()));
for (const auto &[uid, pool] : fixed_pool_table.pools) {
w.pod(uid); w.string(pool.name); w.pod(pool.address); w.pod(pool.block_size); w.pod(pool.block_count);
w.pod(static_cast<std::uint64_t>(pool.allocated.size()));
for (const bool bit : pool.allocated) { const std::uint8_t value = bit ? 1u : 0u; w.pod(value); }
}
}
bool save_repro_read_kernel_state(SaveReproReader &r) {
ThreadTable threads{};
if (!r.pod(threads.next_uid) || !r.pod(threads.current_uid) || !r.pod(threads.next_stack_top) ||
!r.pod(threads.next_ready_sequence) || !r.pod(threads.next_delay_sequence)) return false;
std::uint64_t count{};
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; ThreadRecord record{};
if (!r.pod(uid) || !save_repro_read_thread_record(r, record)) return false;
threads.threads.emplace(uid, std::move(record));
}
if (!r.pod(count) || count > 16384u) return false;
threads.continuations.resize(static_cast<std::size_t>(count));
for (auto &c : threads.continuations) if (!save_repro_read_continuation(r, c)) return false;
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; std::uint64_t n{};
if (!r.pod(uid) || !r.pod(n) || n > 16384u) return false;
auto &list = threads.thread_end_waiters[uid]; list.resize(static_cast<std::size_t>(n));
for (auto &c : list) if (!save_repro_read_continuation(r, c)) return false;
}
if (!r.pod(count) || count > 16384u) return false;
threads.free_stacks.resize(static_cast<std::size_t>(count));
for (auto &block : threads.free_stacks) if (!r.pod(block)) return false;
thread_table = std::move(threads);
PartitionTable partitions{};
if (!r.pod(partitions.next_uid) || !r.pod(partitions.next_address) || !r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; PartitionBlock block{};
if (!r.pod(uid) || !r.string(block.name) || !r.pod(block.address) || !r.pod(block.size)) return false;
partitions.blocks.emplace(uid, std::move(block));
}
partition_table = std::move(partitions);
CallbackTable callbacks{};
if (!r.pod(callbacks.next_uid) || !r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; CallbackRecord cb{};
if (!r.pod(uid) || !r.string(cb.name) || !r.pod(cb.function) || !r.pod(cb.common) ||
!r.pod(cb.owner_uid) || !r.pod(cb.notify_count) || !r.pod(cb.notify_argument)) return false;
callbacks.callbacks.emplace(uid, std::move(cb));
}
callback_table = std::move(callbacks);
SemaphoreTable semas{};
if (!r.pod(semas.next_uid) || !r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; SemaphoreRecord sema{}; std::uint64_t n{};
if (!r.pod(uid) || !r.string(sema.name) || !r.pod(sema.count) || !r.pod(sema.maximum) || !r.pod(n) || n > 16384u) return false;
sema.waiters.resize(static_cast<std::size_t>(n));
for (auto &waiter : sema.waiters)
if (!r.pod(waiter.uid) || !save_repro_read_context(r, waiter.context) || !r.pod(waiter.requested)) return false;
semas.semaphores.emplace(uid, std::move(sema));
}
semaphore_table = std::move(semas);
EventFlagTable flags{};
if (!r.pod(flags.next_uid) || !r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; EventFlagRecord flag{}; std::uint64_t n{};
if (!r.pod(uid) || !r.string(flag.name) || !r.pod(flag.attributes) || !r.pod(flag.initial_pattern) ||
!r.pod(flag.current_pattern) || !r.pod(n) || n > 16384u) return false;
flag.waiters.resize(static_cast<std::size_t>(n));
for (auto &waiter : flag.waiters)
if (!r.pod(waiter.uid) || !save_repro_read_context(r, waiter.context) || !r.pod(waiter.requested) ||
!r.pod(waiter.mode) || !r.pod(waiter.output_address)) return false;
flags.flags.emplace(uid, std::move(flag));
}
event_flag_table = std::move(flags);
FixedPoolTable pools{};
if (!r.pod(pools.next_uid) || !r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; FixedPoolRecord pool{}; std::uint64_t n{};
if (!r.pod(uid) || !r.string(pool.name) || !r.pod(pool.address) || !r.pod(pool.block_size) ||
!r.pod(pool.block_count) || !r.pod(n) || n > 1'000'000u) return false;
pool.allocated.resize(static_cast<std::size_t>(n));
for (std::size_t j = 0; j < pool.allocated.size(); ++j) {
std::uint8_t value{}; if (!r.pod(value)) return false; pool.allocated[j] = value != 0u;
}
pools.pools.emplace(uid, std::move(pool));
}
fixed_pool_table = std::move(pools);
return true;
}
void save_repro_write_file_state(SaveReproWriter &w) {
w.pod(file_table.next_fd); w.pod(file_table.next_virtual_sector);
w.pod(static_cast<std::uint64_t>(file_table.files.size()));
for (auto &[fd, stream] : file_table.files) {
w.pod(fd);
const auto p = file_table.file_paths.find(fd);
w.path(p != file_table.file_paths.end() ? p->second : std::filesystem::path{});
const auto f = file_table.file_open_flags.find(fd);
w.pod(f != file_table.file_open_flags.end() ? f->second : 0x0001u);
const std::ios::iostate old_state = stream.rdstate();
stream.clear();
std::int64_t position = static_cast<std::int64_t>(stream.tellg());
if (position < 0) {
stream.clear();
position = static_cast<std::int64_t>(stream.tellp());
}
if (position < 0) position = 0;
stream.clear(old_state);
w.pod(position);
}
w.pod(static_cast<std::uint64_t>(file_table.synthetic_empty_files.size()));
for (const auto fd : file_table.synthetic_empty_files) w.pod(fd);
w.pod(static_cast<std::uint64_t>(file_table.recent_atrac_reads.size()));
for (const auto &[address, path] : file_table.recent_atrac_reads) { w.pod(address); w.path(path); }
w.pod(static_cast<std::uint64_t>(file_table.directories.size()));
for (const auto &[fd, directory] : file_table.directories) {
w.pod(fd); w.pod(static_cast<std::uint64_t>(directory.index));
w.pod(static_cast<std::uint64_t>(directory.entries.size()));
for (const auto &entry : directory.entries) w.path(entry.path());
}
w.pod(static_cast<std::uint64_t>(file_table.virtual_disc_handles.size()));
for (const auto &[fd, handle] : file_table.virtual_disc_handles) { w.pod(fd); w.pod(handle); }
w.pod(static_cast<std::uint64_t>(file_table.virtual_files_by_path.size()));
for (const auto &[key, file] : file_table.virtual_files_by_path) {
w.string(key); w.path(file.native_path); w.pod(file.start_sector); w.pod(file.size);
}
}
bool save_repro_read_file_state(SaveReproReader &r) {
FileTable restored{};
if (!r.pod(restored.next_fd) || !r.pod(restored.next_virtual_sector)) return false;
std::uint64_t count{};
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t fd{}; std::filesystem::path path; std::uint32_t flags{}; std::int64_t position{};
if (!r.pod(fd) || !r.path(path) || !r.pod(flags) || !r.pod(position)) return false;
std::ios::openmode mode = std::ios::binary;
if ((flags & 0x0001u) != 0u) mode |= std::ios::in;
if ((flags & 0x0002u) != 0u) mode |= std::ios::out;
if ((mode & (std::ios::in | std::ios::out)) == std::ios::openmode{}) mode |= std::ios::in;
std::fstream stream(path, mode);
if (!stream) { r.error = "checkpoint could not reopen PSP fd " + std::to_string(fd) + " path=" + path.string(); return false; }
stream.clear();
stream.seekg(static_cast<std::streamoff>(position), std::ios::beg);
if ((flags & 0x0002u) != 0u) stream.seekp(static_cast<std::streamoff>(position), std::ios::beg);
stream.clear();
restored.files.emplace(fd, std::move(stream));
restored.file_paths.emplace(fd, path);
restored.file_open_flags.emplace(fd, flags);
}
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) { std::int32_t fd{}; if (!r.pod(fd)) return false; restored.synthetic_empty_files.insert(fd); }
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::uint32_t address{}; std::filesystem::path path;
if (!r.pod(address) || !r.path(path)) return false;
restored.recent_atrac_reads.emplace(address, std::move(path));
}
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t fd{}; std::uint64_t index{}, n{};
if (!r.pod(fd) || !r.pod(index) || !r.pod(n) || n > 1'000'000u) return false;
DirectoryHandle dir{}; dir.index = static_cast<std::size_t>(index); dir.entries.reserve(static_cast<std::size_t>(n));
for (std::uint64_t j = 0; j < n; ++j) { std::filesystem::path path; if (!r.path(path)) return false; dir.entries.emplace_back(path); }
restored.directories.emplace(fd, std::move(dir));
}
if (!r.pod(count) || count > 4096u) return false;
for (std::uint64_t i = 0; i < count; ++i) { std::int32_t fd{}; VirtualDiscHandle h{}; if (!r.pod(fd) || !r.pod(h)) return false; restored.virtual_disc_handles.emplace(fd, h); }
if (!r.pod(count) || count > 1'000'000u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::string key; VirtualDiscFile file{};
if (!r.string(key) || !r.path(file.native_path) || !r.pod(file.start_sector) || !r.pod(file.size)) return false;
restored.virtual_path_by_sector[file.start_sector] = key;
restored.virtual_files_by_path.emplace(std::move(key), std::move(file));
}
file_table = std::move(restored);
return true;
}
void save_repro_write_media_audio_state(SaveReproWriter &w) {
w.pod(next_mpeg_stream_id);
// PMF decoders own opaque codec state. A valid post-mission capture should
// have no live movie context; record the count as a safety contract.
w.pod(static_cast<std::uint64_t>(mpeg_contexts.size()));
for (const auto &state : atrac_contexts) {
w.pod(state.allocated); w.pod(state.header); w.pod(state.buffer_address); w.pod(state.initial_read_size);
w.pod(state.buffer_size); w.pod(state.buffered_encoded_bytes); w.pod(state.next_file_offset);
w.pod(state.write_offset); w.pod(state.last_writable_bytes); w.pod(state.sample_position);
w.pod(state.loop_num); w.pod(state.internal_error); w.path(state.source_path); w.pod(state.decoder_eof);
}
w.pod(audio_channels);
w.pod(sas_state.initialized); w.pod(sas_state.core_address); w.pod(sas_state.grain_size);
w.pod(sas_state.max_voices); w.pod(sas_state.output_mode); w.pod(sas_state.sample_rate);
w.pod(sas_state.voices); w.pod(sas_state.end_flags);
w.pod(sas_state.reverb.type); w.pod(sas_state.reverb.delay); w.pod(sas_state.reverb.feedback);
w.pod(sas_state.reverb.left_volume); w.pod(sas_state.reverb.right_volume);
w.pod(sas_state.reverb.dry); w.pod(sas_state.reverb.wet);
w.pod(static_cast<std::uint64_t>(sas_state.reverb.history_left.size()));
for (const auto value : sas_state.reverb.history_left) w.pod(value);
w.pod(static_cast<std::uint64_t>(sas_state.reverb.history_right.size()));
for (const auto value : sas_state.reverb.history_right) w.pod(value);
w.pod(static_cast<std::uint64_t>(sas_state.reverb.history_cursor));
w.pod(sas_core_mix_calls); w.pod(sas_core_with_mix_calls);
}
bool save_repro_read_media_audio_state(SaveReproReader &r) {
std::uint64_t mpeg_count{};
if (!r.pod(next_mpeg_stream_id) || !r.pod(mpeg_count)) return false;
if (mpeg_count != 0u) { r.error = "checkpoint was captured while a PMF movie decoder was live; recapture after mission control returns"; return false; }
for (auto &state : atrac_contexts) close_atrac_decoder(state);
atrac_contexts = {};
for (auto &state : atrac_contexts) {
if (!r.pod(state.allocated) || !r.pod(state.header) || !r.pod(state.buffer_address) ||
!r.pod(state.initial_read_size) || !r.pod(state.buffer_size) || !r.pod(state.buffered_encoded_bytes) ||
!r.pod(state.next_file_offset) || !r.pod(state.write_offset) || !r.pod(state.last_writable_bytes) ||
!r.pod(state.sample_position) || !r.pod(state.loop_num) || !r.pod(state.internal_error) ||
!r.path(state.source_path) || !r.pod(state.decoder_eof)) return false;
state.decoder_eof = false; // decoder is reopened lazily at sample_position.
}
if (!r.pod(audio_channels)) return false;
// Host waveOut/XAudio queue itself is not part of the PSP machine. Keep
// reservations/formats but restart queue timing at the restored guest time.
for (auto &channel : audio_channels) {
channel.busy_until_us = virtual_time_us;
channel.queue_active = false;
channel.queue_anchor_us = virtual_time_us;
channel.queued_frames = 0u;
}
SasState sas{};
if (!r.pod(sas.initialized) || !r.pod(sas.core_address) || !r.pod(sas.grain_size) ||
!r.pod(sas.max_voices) || !r.pod(sas.output_mode) || !r.pod(sas.sample_rate) ||
!r.pod(sas.voices) || !r.pod(sas.end_flags) || !r.pod(sas.reverb.type) ||
!r.pod(sas.reverb.delay) || !r.pod(sas.reverb.feedback) || !r.pod(sas.reverb.left_volume) ||
!r.pod(sas.reverb.right_volume) || !r.pod(sas.reverb.dry) || !r.pod(sas.reverb.wet)) return false;
std::uint64_t n{};
if (!r.pod(n) || n > 2'000'000u) return false; sas.reverb.history_left.resize(static_cast<std::size_t>(n));
for (auto &v : sas.reverb.history_left) if (!r.pod(v)) return false;
if (!r.pod(n) || n > 2'000'000u) return false; sas.reverb.history_right.resize(static_cast<std::size_t>(n));
for (auto &v : sas.reverb.history_right) if (!r.pod(v)) return false;
if (!r.pod(n)) return false; sas.reverb.history_cursor = static_cast<std::size_t>(n);
if (!r.pod(sas_core_mix_calls) || !r.pod(sas_core_with_mix_calls)) return false;
sas_state = std::move(sas);
return true;
}
void save_repro_write_ge_state(SaveReproWriter &w) {
w.pod(ge_callback_table.next_uid); w.pod(static_cast<std::uint64_t>(ge_callback_table.callbacks.size()));
for (const auto &[uid, cb] : ge_callback_table.callbacks) { w.pod(uid); w.pod(cb); }
w.pod(ge_state);
w.pod(ge_list_table.next_raw_id); w.pod(static_cast<std::uint64_t>(ge_list_table.lists.size()));
for (const auto &[id, list] : ge_list_table.lists) {
w.pod(id); w.pod(list.guest_id); w.pod(list.start_pc); w.pod(list.pc); w.pod(list.stall);
w.pod(list.callback_id); w.pod(list.context_address); w.pod(list.stack_address); w.pod(list.stack_capacity);
w.pod(list.state); w.pod(list.signal_behavior); w.pod(list.callback_token);
w.pod(static_cast<std::uint64_t>(list.stack.size())); for (const auto &entry : list.stack) w.pod(entry);
w.pod(list.histogram); w.pod(list.executed_commands); w.pod(list.primitive_commands);
w.pod(list.has_saved_context); w.pod(list.saved_commands); w.pod(list.saved_transform);
w.pod(list.saved_offset_address); w.pod(list.saved_vertex_address); w.pod(list.saved_index_address);
w.pod(list.saved_bounding_box_result);
}
w.pod(static_cast<std::uint64_t>(ge_list_table.queue.size())); for (const auto id : ge_list_table.queue) w.pod(id);
w.pod(static_cast<std::uint64_t>(pending_guest_callbacks.size()));
for (const auto &[uid, list] : pending_guest_callbacks) {
w.pod(uid); w.pod(static_cast<std::uint64_t>(list.size())); for (const auto &cb : list) w.pod(cb);
}
w.pod(static_cast<std::uint64_t>(async_return_frames.size()));
for (const auto &[uid, frames] : async_return_frames) {
w.pod(uid); w.pod(static_cast<std::uint64_t>(frames.size())); for (const auto &frame : frames) w.pod(frame);
}
w.pod(static_cast<std::uint64_t>(deferred_io_resumes.size()));
for (const auto &[uid, item] : deferred_io_resumes) { w.pod(uid); w.pod(item); }
}
bool save_repro_read_ge_state(SaveReproReader &r) {
ge_async_stop_worker();
GeCallbackTable callbacks{}; std::uint64_t count{};
if (!r.pod(callbacks.next_uid) || !r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) { std::int32_t uid{}; GeCallbackRecord cb{}; if (!r.pod(uid) || !r.pod(cb)) return false; callbacks.callbacks.emplace(uid, cb); }
if (!r.pod(ge_state)) return false;
GeListTable lists{};
if (!r.pod(lists.next_raw_id) || !r.pod(count) || count > 65536u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::uint32_t id{}; GeListRecord list{}; std::uint64_t n{};
if (!r.pod(id) || !r.pod(list.guest_id) || !r.pod(list.start_pc) || !r.pod(list.pc) || !r.pod(list.stall) ||
!r.pod(list.callback_id) || !r.pod(list.context_address) || !r.pod(list.stack_address) || !r.pod(list.stack_capacity) ||
!r.pod(list.state) || !r.pod(list.signal_behavior) || !r.pod(list.callback_token) || !r.pod(n) || n > 4096u) return false;
list.stack.resize(static_cast<std::size_t>(n)); for (auto &entry : list.stack) if (!r.pod(entry)) return false;
if (!r.pod(list.histogram) || !r.pod(list.executed_commands) || !r.pod(list.primitive_commands) ||
!r.pod(list.has_saved_context) || !r.pod(list.saved_commands) || !r.pod(list.saved_transform) ||
!r.pod(list.saved_offset_address) || !r.pod(list.saved_vertex_address) || !r.pod(list.saved_index_address) ||
!r.pod(list.saved_bounding_box_result)) return false;
lists.lists.emplace(id, std::move(list));
}
if (!r.pod(count) || count > 65536u) return false; lists.queue.resize(static_cast<std::size_t>(count));
for (auto &id : lists.queue) if (!r.pod(id)) return false;
ge_callback_table = std::move(callbacks); ge_list_table = std::move(lists);
pending_guest_callbacks.clear();
if (!r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; std::uint64_t n{}; if (!r.pod(uid) || !r.pod(n) || n > 65536u) return false;
auto &list = pending_guest_callbacks[uid]; list.resize(static_cast<std::size_t>(n)); for (auto &cb : list) if (!r.pod(cb)) return false;
}
async_return_frames.clear();
if (!r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) {
std::int32_t uid{}; std::uint64_t n{}; if (!r.pod(uid) || !r.pod(n) || n > 65536u) return false;
auto &frames = async_return_frames[uid]; frames.resize(static_cast<std::size_t>(n)); for (auto &frame : frames) if (!r.pod(frame)) return false;
}
deferred_io_resumes.clear();
if (!r.pod(count) || count > 8192u) return false;
for (std::uint64_t i = 0; i < count; ++i) { std::int32_t uid{}; DeferredIoResume item{}; if (!r.pod(uid) || !r.pod(item)) return false; deferred_io_resumes.emplace(uid, item); }
++ge_draw_state_revision; ++ge_lighting_state_revision; ++ge_camera_state_revision;
return true;
}
void save_repro_write_misc_state(SaveReproWriter &w) {
w.pod(virtual_time_us); w.pod(display_vblank_index); w.pod(compiled_sdk_version); w.pod(compiler_version);
w.pod(next_module_uid); w.pod(static_cast<std::uint64_t>(loaded_modules.size()));
for (const auto &[uid, loaded] : loaded_modules) { w.pod(uid); w.pod(loaded); }
w.pod(volatile_memory_locked); w.pod(general_purpose_io); w.pod(ge_edram_translation);
w.pod(display_state); w.pod(controller_state); w.pod(memory_stick_fat_state);
w.pod(static_cast<std::uint64_t>(sub_interrupts.size())); for (const auto &[key, record] : sub_interrupts) { w.pod(key); w.pod(record); }
w.pod(startup_load_picker_consumed);
w.pod(static_cast<std::uint64_t>(deflate_fast_pending.size()));
for (const auto &[uid, pending] : deflate_fast_pending) { w.pod(uid); w.pod(pending); }
}
bool save_repro_read_misc_state(SaveReproReader &r) {
if (!r.pod(virtual_time_us) || !r.pod(display_vblank_index) || !r.pod(compiled_sdk_version) || !r.pod(compiler_version) || !r.pod(next_module_uid)) return false;
std::uint64_t count{}; if (!r.pod(count) || count > 8192u) return false; loaded_modules.clear();
for (std::uint64_t i = 0; i < count; ++i) { std::int32_t uid{}; bool loaded{}; if (!r.pod(uid) || !r.pod(loaded)) return false; loaded_modules.emplace(uid, loaded); }
if (!r.pod(volatile_memory_locked) || !r.pod(general_purpose_io) || !r.pod(ge_edram_translation) ||
!r.pod(display_state) || !r.pod(controller_state) || !r.pod(memory_stick_fat_state)) return false;
if (!r.pod(count) || count > 8192u) return false; sub_interrupts.clear();
for (std::uint64_t i = 0; i < count; ++i) { std::uint64_t key{}; SubInterruptRecord record{}; if (!r.pod(key) || !r.pod(record)) return false; sub_interrupts.emplace(key, record); }
if (!r.pod(startup_load_picker_consumed) || !r.pod(count) || count > 8192u) return false; deflate_fast_pending.clear();
for (std::uint64_t i = 0; i < count; ++i) { std::int32_t uid{}; DeflateFastPending pending{}; if (!r.pod(uid) || !r.pod(pending)) return false; deflate_fast_pending.emplace(uid, pending); }
return true;
}
bool save_repro_write_checkpoint(psprecomp::Runtime &runtime,
const psprecomp::AllegrexContext &resume_context,
std::string &error) {
if (savedata_utility.status != UtilityStatus::None) {
error = "savedata utility is active; capture before entering the save marker";
return false;
}
if (!mpeg_contexts.empty()) {
error = "movie decoder still active; wait until post-mission gameplay has returned";
return false;
}
const auto current = thread_table.threads.find(thread_table.current_uid);
if (current == thread_table.threads.end() || current->second.state != ThreadState::Running) {
error = "no running PSP thread at capture boundary";
return false;
}
SaveReproWriter w;
w.bytes.reserve(static_cast<std::size_t>(runtime.memory().size()) + runtime.memory().vram_size() + 2u * 1024u * 1024u);
w.pod(kSaveReproMagic); w.pod(kSaveReproVersion);
w.pod(runtime.memory().size()); w.pod(runtime.memory().vram_size());
save_repro_write_context(w, resume_context);
std::vector<std::uint8_t> ram_snapshot(runtime.memory().size());
std::vector<std::uint8_t> vram_snapshot(runtime.memory().vram_size());
runtime.memory().copy_out(psprecomp::GuestMemory::kPhysicalBase, ram_snapshot);
runtime.memory().copy_out(psprecomp::GuestMemory::kVramPhysicalBase, vram_snapshot);
w.raw(ram_snapshot);
w.raw(vram_snapshot);
save_repro_write_misc_state(w);
save_repro_write_kernel_state(w);
save_repro_write_file_state(w);
save_repro_write_media_audio_state(w);
save_repro_write_ge_state(w);
const std::uint64_t checksum = save_repro_fnv1a(w.bytes);
w.pod(checksum);
const std::filesystem::path output = save_repro_checkpoint_path(runtime);
const std::filesystem::path temporary = output.string() + ".tmp";
std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
if (!file) { error = "cannot create checkpoint: " + temporary.string(); return false; }
file.write(reinterpret_cast<const char *>(w.bytes.data()), static_cast<std::streamsize>(w.bytes.size()));
file.close();
if (!file) { error = "checkpoint write failed: " + temporary.string(); return false; }
std::error_code ec;
std::filesystem::remove(output, ec); ec.clear();
std::filesystem::rename(temporary, output, ec);
if (ec) { error = "checkpoint rename failed: " + ec.message(); return false; }
save_repro_checkpoint_available = true;
save_repro_trace_enabled = true;
save_repro_trace_count = save_repro_trace_next = 0u;
save_repro_trace_sequence = 0u;
refresh_vcs_post_dispatch_hook();
std::ostringstream line;
line << "SAVE_REPRO checkpoint=captured path=" << output.string()
<< " bytes=" << w.bytes.size() << " vblank=" << display_vblank_index
<< " guest_us=" << virtual_time_us << " uid=" << thread_table.current_uid
<< " pc=" << psprecomp::hex32(resume_context.pc)
<< " threads=" << thread_table.threads.size();
runtime_log_line(line.str());
if (!save_repro_self_test_mode)
std::cerr << "\n[SAVE-REPRO] CHECKPOINT CAPTURADO: " << output.string()
<< "\n[SAVE-REPRO] Agora tente salvar. Se ficar preto, pressione F10.\n";
return true;
}
bool save_repro_restore_checkpoint_impl(psprecomp::Runtime &runtime, std::string &error) {
const std::filesystem::path input_path = save_repro_checkpoint_path(runtime);
std::ifstream file(input_path, std::ios::binary | std::ios::ate);
if (!file) { error = "checkpoint not found: " + input_path.string(); return false; }
const std::streamoff size = file.tellg();
if (size < static_cast<std::streamoff>(64u) || size > static_cast<std::streamoff>(128ull * 1024ull * 1024ull)) {
error = "checkpoint size is invalid"; return false;
}
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
file.seekg(0, std::ios::beg); file.read(reinterpret_cast<char *>(bytes.data()), size);
if (!file) { error = "checkpoint read failed"; return false; }
std::uint64_t expected{}; std::memcpy(&expected, bytes.data() + bytes.size() - sizeof(expected), sizeof(expected));
const std::uint64_t actual = save_repro_fnv1a(std::span<const std::uint8_t>(bytes.data(), bytes.size() - sizeof(expected)));
if (actual != expected) { error = "checkpoint checksum mismatch"; return false; }
SaveReproReader r{std::span<const std::uint8_t>(bytes.data(), bytes.size() - sizeof(expected))};
std::uint64_t magic{}; std::uint32_t version{}, ram_size{}, vram_size{};
if (!r.pod(magic) || !r.pod(version) || !r.pod(ram_size) || !r.pod(vram_size)) { error = r.error; return false; }
if (magic != kSaveReproMagic || version != kSaveReproVersion) { error = "checkpoint version mismatch"; return false; }
if (ram_size != runtime.memory().size() || vram_size != runtime.memory().vram_size()) { error = "checkpoint RAM/EDRAM size mismatch"; return false; }
psprecomp::AllegrexContext resume{};
std::vector<std::uint8_t> ram_snapshot(ram_size);
std::vector<std::uint8_t> vram_snapshot(vram_size);
if (!save_repro_read_context(r, resume) ||
!r.raw(ram_snapshot) ||
!r.raw(vram_snapshot) ||
!save_repro_read_misc_state(r) || !save_repro_read_kernel_state(r) ||
!save_repro_read_file_state(r) || !save_repro_read_media_audio_state(r) ||
!save_repro_read_ge_state(r)) {
error = !r.error.empty() ? r.error : "checkpoint payload parse failed";
return false;
}
if (r.offset != r.bytes.size()) { error = "checkpoint has unexpected trailing payload"; return false; }
runtime.memory().copy_in(psprecomp::GuestMemory::kPhysicalBase, ram_snapshot);
runtime.memory().copy_in(psprecomp::GuestMemory::kVramPhysicalBase, vram_snapshot);
const std::uint32_t partition_before = partition_table.next_address;
recompute_partition_frontier();
const std::uint32_t partition_after = partition_table.next_address;
const LegacyExitDeleteRepairStats exitdelete_repair = repair_legacy_vcs_exit_delete_threads();
if (exitdelete_repair.threads != 0u || partition_before != partition_after) {
std::ostringstream repair_line;
const std::uint32_t margin_before = exitdelete_repair.stack_top_before >= partition_before
? exitdelete_repair.stack_top_before - partition_before : 0u;
const std::uint32_t margin_after = exitdelete_repair.stack_top_after >= partition_after
? exitdelete_repair.stack_top_after - partition_after : 0u;
repair_line << "SAVE_REPRO legacy_memory_repair exitdelete_threads=" << exitdelete_repair.threads
<< " stack_bytes=" << exitdelete_repair.stack_bytes
<< " partition_before=" << psprecomp::hex32(partition_before)
<< " partition_after=" << psprecomp::hex32(partition_after)
<< " stack_top_before=" << psprecomp::hex32(exitdelete_repair.stack_top_before)
<< " stack_top_after=" << psprecomp::hex32(exitdelete_repair.stack_top_after)
<< " margin_before=" << margin_before
<< " margin_after=" << margin_after;
runtime_log_line(repair_line.str());
if (!save_repro_self_test_mode) std::cerr << "[SAVE-REPRO] " << repair_line.str() << "\n";
}
if (thread_table.threads.find(thread_table.current_uid) == thread_table.threads.end()) {
error = "checkpoint current thread UID missing"; return false;
}
runtime.cpu() = resume;
auto &current = thread_table.threads.at(thread_table.current_uid);
current.state = ThreadState::Running;
psprecomp::set_runtime_thread_identity(thread_table.current_uid, current.name);
savedata_utility_ui_end(); display_window_set_system_utility_mode(false); savedata_utility = SavedataUtilityState{};
vcs::audio_output_shutdown();
frozen_clock_guard_dispatches = 0u; frozen_clock_guard_vblank = display_vblank_index;
save_repro_checkpoint_available = true; save_repro_trace_enabled = true;
save_repro_trace_count = save_repro_trace_next = 0u; save_repro_trace_sequence = 0u;
refresh_vcs_post_dispatch_hook();
std::ostringstream line;
line << "SAVE_REPRO checkpoint=restored path=" << input_path.string()
<< " bytes=" << bytes.size() << " vblank=" << display_vblank_index
<< " guest_us=" << virtual_time_us << " uid=" << thread_table.current_uid
<< " pc=" << psprecomp::hex32(runtime.cpu().pc)
<< " threads=" << thread_table.threads.size();
runtime_log_line(line.str());
if (!save_repro_self_test_mode)
std::cerr << "\n[SAVE-REPRO] CHECKPOINT RESTAURADO: " << input_path.string()
<< "\n[SAVE-REPRO] Va direto ao save. Se ficar preto, pressione F10.\n";
return true;
}
void save_repro_dump_trace(psprecomp::Runtime &runtime, std::string_view reason) {
if (!save_repro_trace_enabled) return;
const std::filesystem::path output = save_repro_trace_path(runtime);
std::ofstream file(output, std::ios::out | std::ios::trunc);
if (!file) { runtime_log_line("SAVE_REPRO trace=dump_failed path=" + output.string()); return; }
file << "VCS V8.2.6 SAVE REPRO TRACE\n"
<< "reason=" << reason << "\n"
<< "vblank=" << display_vblank_index << " guest_us=" << virtual_time_us
<< " current_uid=" << thread_table.current_uid << " current_pc=" << psprecomp::hex32(runtime.cpu().pc) << "\n"
<< "records=" << save_repro_trace_count << " capacity=" << save_repro_trace.size()
<< " dispatch_sample_stride=" << kSaveReproDispatchSampleStride << "\n\n";
file << "THREADS\n";
for (const auto &[uid, thread] : thread_table.threads) {
file << "uid=" << uid << " name=\"" << thread.name << "\" state=" << static_cast<std::uint32_t>(thread.state)
<< " priority=" << thread.priority << " pc=" << psprecomp::hex32(thread.suspended_context.pc)
<< " ra=" << psprecomp::hex32(thread.suspended_context.gpr[31])
<< " delay_until=" << thread.delay_until_us << " wakeups=" << thread.wakeup_count
<< " externally_suspended=" << thread.externally_suspended << "\n";
}
file << "\nEVENT_FLAGS\n";
for (const auto &[uid, flag] : event_flag_table.flags)
file << "uid=" << uid << " name=\"" << flag.name << "\" pattern=" << psprecomp::hex32(flag.current_pattern) << " waiters=" << flag.waiters.size() << "\n";
file << "\nSEMAPHORES\n";
for (const auto &[uid, sema] : semaphore_table.semaphores)
file << "uid=" << uid << " name=\"" << sema.name << "\" count=" << sema.count << " max=" << sema.maximum << " waiters=" << sema.waiters.size() << "\n";
file << "\nSAVEDATA status=" << static_cast<std::uint32_t>(savedata_utility.status)
<< " mode=" << savedata_utility.mode << " param=" << psprecomp::hex32(savedata_utility.parameter_address)
<< " operation_complete=" << savedata_utility.operation_complete << " last_result=" << psprecomp::hex32(savedata_utility.last_result) << "\n";
file << "\nTRACE\n";
const std::size_t first = (save_repro_trace_next + save_repro_trace.size() - save_repro_trace_count) % save_repro_trace.size();
for (std::size_t index = 0; index < save_repro_trace_count; ++index) {
const auto &item = save_repro_trace[(first + index) % save_repro_trace.size()];
file << item.sequence << " vb=" << item.vblank << " us=" << item.guest_us << " uid=" << item.uid;
if (item.kind == SaveReproTraceRecord::Kind::Hle)
file << " HLE " << item.library.data() << "::" << item.name.data() << " nid=" << psprecomp::hex32(item.nid);
else
file << " DISPATCH";
file << " pc=" << psprecomp::hex32(item.pc) << " next=" << psprecomp::hex32(item.next_pc)
<< " a0=" << psprecomp::hex32(item.a0) << " a1=" << psprecomp::hex32(item.a1)
<< " a2=" << psprecomp::hex32(item.a2) << " a3=" << psprecomp::hex32(item.a3)
<< " sp=" << psprecomp::hex32(item.sp) << " ra=" << psprecomp::hex32(item.ra) << "\n";
}
file.close();
runtime_log_line("SAVE_REPRO trace=dumped reason=" + std::string(reason) + " path=" + output.string() + " records=" + std::to_string(save_repro_trace_count));
std::cerr << "\n[SAVE-REPRO] TRACE GRAVADO: " << output.string() << "\n";
}
void save_repro_vblank_hotkeys(psprecomp::Runtime &runtime, const psprecomp::AllegrexContext &ctx) {
// Shipped builds keep SAVE_REPRO disabled. Return before even touching the
// UI command atomic so normal gameplay follows the V8.2.7 path exactly.
if (!save_repro_testing_enabled()) return;
// The UI thread records F8/F10 edges in WindowState. Before F8/restore this
// stays one atomic exchange per vblank, preserving the V8.2.5 mission and
// cutscene path. Once tracing is armed we additionally poll F10 on Windows:
// VK_F10 can be intercepted by the desktop/window menu path while the guest
// is on its black loading screen, which made V8.2.6A silently miss the dump.
std::uint32_t commands = display_window_take_save_repro_commands();
#if defined(_WIN32)
static bool f10_async_was_down = false;
if (save_repro_trace_enabled) {
const bool f10_async_down = (GetAsyncKeyState(VK_F10) & 0x8000) != 0;
if (f10_async_down && !f10_async_was_down) commands |= 0x2u;
f10_async_was_down = f10_async_down;
} else {
f10_async_was_down = false;
}
#endif
if ((commands & 0x1u) != 0u) {
save_repro_capture_requested = true;
save_repro_capture_error_reported = false;
refresh_vcs_post_dispatch_hook();
runtime_log_line("SAVE_REPRO checkpoint=armed vblank=" + std::to_string(display_vblank_index));
std::cerr << "\n[SAVE-REPRO] F8: CAPTURA ARMADA. Aguarde a mensagem CHECKPOINT CAPTURADO.\n";
}
if ((commands & 0x2u) != 0u) save_repro_dump_trace(runtime, "manual-F10");
(void)ctx;
}
constexpr std::uint32_t kPspUtilityStart = 0x000008u;
constexpr std::uint32_t kPspUtilityUp = 0x000010u;
constexpr std::uint32_t kPspUtilityRight = 0x000020u;
constexpr std::uint32_t kPspUtilityDown = 0x000040u;
constexpr std::uint32_t kPspUtilityLeft = 0x000080u;
constexpr std::uint32_t kPspUtilityCircle = 0x002000u;
constexpr std::uint32_t kPspUtilityCross = 0x004000u;
constexpr std::uint32_t kUtilityCommonResultOffset = 0x1Cu;
// PSP utility dialog common.result values. Keep these separate from savedata
// I/O error codes: user cancellation is CANCEL (1), while ABORT (2) is a
// different firmware result. VCS needs a legacy ABORT workaround only for
// the promoted LISTLOAD path; normal LISTSAVE/LISTDELETE cancellation must use
// the real PSP CANCEL result or the retail frontend enters its loading path.
constexpr std::uint32_t kPspUtilityDialogResultSuccess = 0u;
constexpr std::uint32_t kPspUtilityDialogResultCancel = 1u;
constexpr std::uint32_t kPspUtilityDialogResultAbort = 2u;
constexpr std::uint32_t kSavedataModeOffset = 0x30u;
constexpr std::uint32_t kSavedataGameNameOffset = 0x3Cu;
constexpr std::uint32_t kSavedataSaveNameOffset = 0x4Cu;
constexpr std::uint32_t kSavedataSaveNameListOffset = 0x60u;
constexpr std::uint32_t kSavedataFileNameOffset = 0x64u;
constexpr std::uint32_t kSavedataDataBufferOffset = 0x74u;
constexpr std::uint32_t kSavedataDataBufferSizeOffset = 0x78u;
constexpr std::uint32_t kSavedataDataSizeOffset = 0x7Cu;
constexpr std::uint32_t kSavedataSfoOffset = 0x80u;
constexpr std::uint32_t kSavedataSfoTitleOffset = kSavedataSfoOffset + 0x000u;
constexpr std::uint32_t kSavedataSfoSavedataTitleOffset = kSavedataSfoOffset + 0x080u;
constexpr std::uint32_t kSavedataSfoDetailOffset = kSavedataSfoOffset + 0x100u;
constexpr std::uint32_t kSavedataSfoTitleSize = 0x80u;
constexpr std::uint32_t kSavedataSfoSavedataTitleSize = 0x80u;
constexpr std::uint32_t kSavedataSfoDetailSize = 0x400u;
constexpr std::uint32_t kSavedataIcon0Offset = 0x584u;
constexpr std::uint32_t kSavedataIcon1Offset = 0x594u;
constexpr std::uint32_t kSavedataPic1Offset = 0x5A4u;
constexpr std::uint32_t kSavedataSnd0Offset = 0x5B4u;
constexpr std::uint32_t kSavedataAbortStatusOffset = 0x5CCu;
constexpr std::uint32_t kSavedataIdListOffset = 0x5F4u;
constexpr std::uint32_t kSavedataFileListOffset = 0x5F8u;
constexpr std::uint32_t kSavedataSizeInfoOffset = 0x5FCu;
constexpr std::uint32_t kSavedataParameterMinimumSize = 0x600u;
std::string read_fixed_string(const psprecomp::GuestMemory &memory, std::uint32_t address, std::size_t size) {
std::string result;
result.reserve(size);
for (std::size_t index = 0; index < size; ++index) {
const char value = static_cast<char>(memory.load8(address + static_cast<std::uint32_t>(index)));
if (value == '\0') break;
result.push_back(value);
}
return result;
}
std::string safe_savedata_component(std::string value) {
value.erase(std::remove_if(value.begin(), value.end(), [](unsigned char c) {
return c == '/' || c == '\\' || c == ':' || c < 0x20u;
}), value.end());
return value;
}
std::filesystem::path savedata_root(const psprecomp::Runtime &runtime) {
// Beside the executable, not inside the game data. A player pointing the
// runtime at an extracted disc should not have to dig through it to find
// their progress, and that copy may well be read-only or shared between
// builds. Falls back to the game root when the configuration has not been
// initialized, which is only the case in tests.
const ::vcs::VcsConfiguration &config = ::vcs::vcs_configuration();
if (config.initialized && !config.executable_directory.empty())
return config.executable_directory / "SAVEDATA";
return runtime.game_root() / "PSP" / "SAVEDATA";
}
std::filesystem::path savedata_directory(const psprecomp::Runtime &runtime, std::uint32_t parameter_address) {
const std::string game = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataGameNameOffset, 13u));
const std::string save = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataSaveNameOffset, 20u));
return savedata_root(runtime) / (game + save);
}
struct SavedataDisplayMetadata {
std::string title;
std::string savedata_title;
std::string detail;
};
SavedataDisplayMetadata savedata_metadata_from_guest(psprecomp::Runtime &runtime,
std::uint32_t parameter_address) {
SavedataDisplayMetadata metadata;
if (!runtime.memory().contains(parameter_address + kSavedataSfoOffset,
kSavedataSfoDetailOffset + kSavedataSfoDetailSize - kSavedataSfoOffset))
return metadata;
metadata.title = read_fixed_string(runtime.memory(),
parameter_address + kSavedataSfoTitleOffset,
kSavedataSfoTitleSize);
metadata.savedata_title = read_fixed_string(runtime.memory(),
parameter_address + kSavedataSfoSavedataTitleOffset,
kSavedataSfoSavedataTitleSize);
metadata.detail = read_fixed_string(runtime.memory(),
parameter_address + kSavedataSfoDetailOffset,
kSavedataSfoDetailSize);
return metadata;
}
constexpr std::string_view kSavedataMetadataMagic = "VCSMETA1";
void write_u32_le(std::ofstream &out, std::uint32_t value) {
const char bytes[4] = {
static_cast<char>(value & 0xFFu),
static_cast<char>((value >> 8u) & 0xFFu),
static_cast<char>((value >> 16u) & 0xFFu),
static_cast<char>((value >> 24u) & 0xFFu),
};
out.write(bytes, 4);
}
bool read_u32_le(std::ifstream &in, std::uint32_t &value) {
unsigned char bytes[4]{};
if (!in.read(reinterpret_cast<char *>(bytes), 4)) return false;
value = static_cast<std::uint32_t>(bytes[0]) |
(static_cast<std::uint32_t>(bytes[1]) << 8u) |
(static_cast<std::uint32_t>(bytes[2]) << 16u) |
(static_cast<std::uint32_t>(bytes[3]) << 24u);
return true;
}
bool write_savedata_metadata_file(const std::filesystem::path &directory,
const SavedataDisplayMetadata &metadata) {
std::error_code error;
std::filesystem::create_directories(directory, error);
if (error) return false;
std::ofstream out(directory / "VCSNative.meta", std::ios::binary | std::ios::trunc);
if (!out) return false;
out.write(kSavedataMetadataMagic.data(), static_cast<std::streamsize>(kSavedataMetadataMagic.size()));
const auto write_string = [&](const std::string &value) {
const std::uint32_t length = static_cast<std::uint32_t>(
std::min<std::size_t>(value.size(), 64u * 1024u));
write_u32_le(out, length);
if (length != 0u) out.write(value.data(), static_cast<std::streamsize>(length));
};
write_string(metadata.title);
write_string(metadata.savedata_title);
write_string(metadata.detail);
return out.good();
}
SavedataDisplayMetadata read_savedata_metadata_file(const std::filesystem::path &directory) {
SavedataDisplayMetadata metadata;
std::ifstream in(directory / "VCSNative.meta", std::ios::binary);
if (!in) return metadata;
std::string magic(kSavedataMetadataMagic.size(), '\0');
if (!in.read(magic.data(), static_cast<std::streamsize>(magic.size())) ||
magic != kSavedataMetadataMagic)
return {};
const auto read_string = [&](std::string &value) -> bool {
std::uint32_t length = 0u;
if (!read_u32_le(in, length) || length > 64u * 1024u) return false;
value.assign(length, '\0');
return length == 0u || static_cast<bool>(in.read(value.data(), static_cast<std::streamsize>(length)));
};
if (!read_string(metadata.title) ||
!read_string(metadata.savedata_title) ||
!read_string(metadata.detail))
return {};
return metadata;
}
// Imported PSP savedata directories may already contain a standard
// PARAM.SFO. Read the three user-facing strings directly so pre-existing saves
// can show their title/mission metadata without first being re-saved by
// VCSNative. This is deliberately a tiny bounded PSF reader, not a general SFO
// implementation.
SavedataDisplayMetadata read_savedata_param_sfo(const std::filesystem::path &directory) {
SavedataDisplayMetadata metadata;
std::ifstream in(directory / "PARAM.SFO", std::ios::binary | std::ios::ate);
if (!in) return metadata;
const std::streamoff end = in.tellg();
if (end < 20 || end > static_cast<std::streamoff>(1024 * 1024)) return metadata;
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(end));
in.seekg(0, std::ios::beg);
if (!in.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size())))
return metadata;
const auto u16 = [&](std::size_t offset, std::uint16_t &value) -> bool {
if (offset + 2u > bytes.size()) return false;
value = static_cast<std::uint16_t>(bytes[offset]) |
static_cast<std::uint16_t>(bytes[offset + 1u] << 8u);
return true;
};
const auto u32 = [&](std::size_t offset, std::uint32_t &value) -> bool {
if (offset + 4u > bytes.size()) return false;
value = static_cast<std::uint32_t>(bytes[offset]) |
(static_cast<std::uint32_t>(bytes[offset + 1u]) << 8u) |
(static_cast<std::uint32_t>(bytes[offset + 2u]) << 16u) |
(static_cast<std::uint32_t>(bytes[offset + 3u]) << 24u);
return true;
};
std::uint32_t magic = 0u, key_table = 0u, data_table = 0u, count = 0u;
if (!u32(0u, magic) || magic != 0x46535000u ||
!u32(8u, key_table) || !u32(12u, data_table) || !u32(16u, count) ||
count > 256u || key_table >= bytes.size() || data_table >= bytes.size())
return metadata;
for (std::uint32_t i = 0u; i < count; ++i) {
const std::size_t entry = 20u + static_cast<std::size_t>(i) * 16u;
std::uint16_t key_offset = 0u, format = 0u;
std::uint32_t data_len = 0u, data_offset = 0u;
if (!u16(entry, key_offset) || !u16(entry + 2u, format) ||
!u32(entry + 4u, data_len) || !u32(entry + 12u, data_offset))
break;
const std::size_t key_pos = static_cast<std::size_t>(key_table) + key_offset;
const std::size_t data_pos = static_cast<std::size_t>(data_table) + data_offset;
if (key_pos >= bytes.size() || data_pos >= bytes.size()) continue;
std::size_t key_end = key_pos;
while (key_end < bytes.size() && bytes[key_end] != 0u && key_end - key_pos < 96u) ++key_end;
if (key_end == bytes.size() || key_end - key_pos >= 96u) continue;
const std::string key(reinterpret_cast<const char *>(bytes.data() + key_pos), key_end - key_pos);
// UTF-8/string PSF entries use a string-ish format; accepting any
// non-empty data here is harmless because only known string keys below
// are consumed and the first NUL terminates the visible text.
(void)format;
const std::size_t available = bytes.size() - data_pos;
const std::size_t length = std::min<std::size_t>(data_len, available);
std::size_t visible = 0u;
while (visible < length && bytes[data_pos + visible] != 0u) ++visible;
const std::string value(reinterpret_cast<const char *>(bytes.data() + data_pos), visible);
if (key == "TITLE") metadata.title = value;
else if (key == "SAVEDATA_TITLE") metadata.savedata_title = value;
else if (key == "SAVEDATA_DETAIL") metadata.detail = value;
}
return metadata;
}
SavedataSlotEntry make_savedata_slot_entry(psprecomp::Runtime &runtime,
std::uint32_t parameter_address,
std::string name,
bool exists,
bool saving) {
SavedataSlotEntry slot;
slot.save_name = std::move(name);
slot.exists = exists;
if (exists) {
const std::string game = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataGameNameOffset, 13u));
const std::filesystem::path directory = savedata_root(runtime) / (game + slot.save_name);
auto metadata = read_savedata_metadata_file(directory);
if (metadata.title.empty() && metadata.savedata_title.empty() && metadata.detail.empty())
metadata = read_savedata_param_sfo(directory);
slot.title = metadata.title;
slot.savedata_title = metadata.savedata_title;
slot.detail = metadata.detail;
const std::filesystem::path icon0 = directory / "ICON0.PNG";
std::error_code icon_error;
if (std::filesystem::is_regular_file(icon0, icon_error) && !icon_error)
slot.icon0_path = icon0.string();
} else if (saving) {
const auto metadata = savedata_metadata_from_guest(runtime, parameter_address);
slot.title = metadata.title;
slot.savedata_title = metadata.savedata_title;
slot.detail = metadata.detail;
}
return slot;
}
void write_fixed_string(psprecomp::GuestMemory &memory, std::uint32_t address,
std::size_t capacity, std::string_view value) {
if (capacity == 0u) return;
memory.zero(address, capacity);
const std::size_t count = std::min<std::size_t>(capacity - 1u, value.size());
for (std::size_t i = 0; i < count; ++i)
memory.store8(address + static_cast<std::uint32_t>(i),
static_cast<std::uint8_t>(value[i]));
}
std::vector<SavedataSlotEntry> savedata_slot_entries(psprecomp::Runtime &runtime,
std::uint32_t parameter_address,
bool saving) {
std::vector<SavedataSlotEntry> slots;
const std::string game = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataGameNameOffset, 13u));
const auto root = savedata_root(runtime);
const std::uint32_t list = runtime.memory().load32(parameter_address + kSavedataSaveNameListOffset);
if (list != 0u) {
// SceUtilitySavedataParam::saveNameList is char (*)[20]. The list is
// terminated by an empty entry. Keep a hard cap so malformed guest data
// cannot turn a host system dialog into an unbounded memory walk.
for (std::uint32_t i = 0u; i < 128u; ++i) {
const std::uint32_t entry = list + i * 20u;
if (!runtime.memory().contains(entry, 20u)) break;
std::string name = safe_savedata_component(read_fixed_string(runtime.memory(), entry, 20u));
if (name.empty()) break;
const bool exists = std::filesystem::is_directory(root / (game + name));
if (saving || exists) slots.push_back(make_savedata_slot_entry(runtime, parameter_address, std::move(name), exists, saving));
}
}
// Some titles leave saveNameList null and rely on the utility to discover
// matching directories. Reconstruct that list from our host SAVEDATA root.
if (slots.empty()) {
std::error_code error;
if (std::filesystem::is_directory(root, error)) {
for (std::filesystem::directory_iterator it(root, error), end;
it != end && !error; it.increment(error)) {
if (!it->is_directory(error)) continue;
const std::string full = it->path().filename().string();
if (full.size() < game.size() || full.compare(0u, game.size(), game) != 0) continue;
std::string suffix = full.substr(game.size());
if (suffix.empty() || suffix.size() >= 20u) continue;
slots.push_back(make_savedata_slot_entry(runtime, parameter_address, std::move(suffix), true, saving));
}
std::sort(slots.begin(), slots.end(), [](const auto &a, const auto &b) {
return a.save_name < b.save_name;
});
}
}
const std::string current = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataSaveNameOffset, 20u));
if (!current.empty() && std::none_of(slots.begin(), slots.end(), [&](const auto &slot) {
return slot.save_name == current;
})) {
const bool exists = std::filesystem::is_directory(root / (game + current));
if (saving || exists) slots.push_back(make_savedata_slot_entry(runtime, parameter_address, current, exists, saving));
}
return slots;
}
bool savedata_mode_has_list_ui(std::uint32_t mode) noexcept {
return mode == 4u || mode == 5u || mode == 6u;
}
void initialize_savedata_list_ui(psprecomp::Runtime &runtime) {
if (!savedata_mode_has_list_ui(savedata_utility.mode) || savedata_utility.ui_initialized)
return;
savedata_utility.slots = savedata_slot_entries(
runtime, savedata_utility.parameter_address, savedata_utility.mode == 5u);
savedata_utility.selected = 0u;
const std::string current = safe_savedata_component(read_fixed_string(
runtime.memory(), savedata_utility.parameter_address + kSavedataSaveNameOffset, 20u));
if (!current.empty()) {
const auto found = std::find_if(savedata_utility.slots.begin(), savedata_utility.slots.end(),
[&](const SavedataSlotEntry &slot) { return slot.save_name == current; });
if (found != savedata_utility.slots.end())
savedata_utility.selected = static_cast<std::size_t>(
std::distance(savedata_utility.slots.begin(), found));
}
runtime.memory().store32(savedata_utility.parameter_address + kSavedataAbortStatusOffset, 0u);
savedata_utility.prompt = savedata_utility.slots.empty()
? SavedataUtilityUiPrompt::NoData : SavedataUtilityUiPrompt::List;
savedata_utility.previous_buttons = 0u;
savedata_utility.ui_initialized = true;
savedata_utility_ui_begin(savedata_utility.mode, savedata_utility.slots,
savedata_utility.selected);
std::cout << "[savedata] V9.6 list UI initialized mode=" << savedata_utility.mode
<< " slots=" << savedata_utility.slots.size()
<< " selected=" << savedata_utility.selected << "\n";
}
bool write_guest_file(psprecomp::Runtime &runtime, const std::filesystem::path &path,
std::uint32_t buffer, std::uint32_t size) {
if (size == 0u) return true;
if (buffer == 0u || !runtime.memory().contains(buffer, size)) return false;
std::filesystem::create_directories(path.parent_path());
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) return false;
std::vector<std::uint8_t> data(size);
for (std::uint32_t index = 0u; index < size; ++index)
data[index] = runtime.memory().load8(buffer + index);
output.write(reinterpret_cast<const char *>(data.data()), static_cast<std::streamsize>(data.size()));
return output.good();
}
struct SavedataPendingWrite {
std::filesystem::path target;
std::vector<std::uint8_t> bytes;
};
bool snapshot_guest_bytes(psprecomp::Runtime &runtime, std::uint32_t buffer,
std::uint32_t size, std::vector<std::uint8_t> &bytes) {
bytes.clear();
if (size == 0u) return true;
if (buffer == 0u || !runtime.memory().contains(buffer, size)) return false;
bytes.resize(size);
runtime.memory().copy_out(buffer, bytes);
return true;
}
bool snapshot_savedata_auxiliary(psprecomp::Runtime &runtime,
std::uint32_t parameter_address,
std::uint32_t descriptor_offset,
const char *filename,
std::vector<SavedataPendingWrite> &writes) {
const std::uint32_t descriptor = parameter_address + descriptor_offset;
const std::uint32_t buffer = runtime.memory().load32(descriptor);
const std::uint32_t buffer_size = runtime.memory().load32(descriptor + 4u);
const std::uint32_t actual_size = runtime.memory().load32(descriptor + 8u);
if (buffer == 0u || actual_size == 0u) return true;
if (actual_size > buffer_size) return false;
SavedataPendingWrite write{};
write.target = savedata_directory(runtime, parameter_address) / filename;
if (!snapshot_guest_bytes(runtime, buffer, actual_size, write.bytes)) return false;
writes.push_back(std::move(write));
return true;
}
bool write_savedata_host_bytes(const std::filesystem::path &path,
std::span<const std::uint8_t> bytes) {
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
if (error) return false;
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output) return false;
if (!bytes.empty())
output.write(reinterpret_cast<const char *>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
output.flush();
return output.good();
}
bool commit_savedata_writes(std::vector<SavedataPendingWrite> &writes) {
struct CommitPath {
std::filesystem::path target;
std::filesystem::path temporary;
std::filesystem::path backup;
bool had_target{};
bool backup_moved{};
bool committed{};
};
const std::string tag = std::to_string(static_cast<unsigned long long>(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::vector<CommitPath> paths;
paths.reserve(writes.size());
// Stage every file first. No existing save data is touched until all guest
// buffers have validated and every temporary host write has completed.
for (std::size_t i = 0u; i < writes.size(); ++i) {
CommitPath entry{};
entry.target = writes[i].target;
entry.temporary = entry.target;
entry.temporary += ".vcsnative.tmp." + tag + "." + std::to_string(i);
entry.backup = entry.target;
entry.backup += ".vcsnative.bak." + tag + "." + std::to_string(i);
if (!write_savedata_host_bytes(entry.temporary, writes[i].bytes)) {
std::error_code ignore;
for (const auto &old : paths) std::filesystem::remove(old.temporary, ignore);
std::filesystem::remove(entry.temporary, ignore);
return false;
}
paths.push_back(std::move(entry));
}
auto rollback = [&]() noexcept {
std::error_code error;
for (auto it = paths.rbegin(); it != paths.rend(); ++it) {
if (it->committed) std::filesystem::remove(it->target, error);
error.clear();
if (it->backup_moved && std::filesystem::exists(it->backup, error)) {
error.clear();
std::filesystem::rename(it->backup, it->target, error);
}
error.clear();
std::filesystem::remove(it->temporary, error);
}
};
for (auto &entry : paths) {
std::error_code error;
entry.had_target = std::filesystem::exists(entry.target, error) && !error;
if (entry.had_target) {
std::filesystem::remove(entry.backup, error);
error.clear();
std::filesystem::rename(entry.target, entry.backup, error);
if (error) { rollback(); return false; }
entry.backup_moved = true;
}
error.clear();
std::filesystem::rename(entry.temporary, entry.target, error);
if (error) { rollback(); return false; }
entry.committed = true;
}
std::error_code ignore;
for (auto &entry : paths) {
if (entry.backup_moved) std::filesystem::remove(entry.backup, ignore);
std::filesystem::remove(entry.temporary, ignore);
}
return true;
}
std::uint32_t load_savedata_file(psprecomp::Runtime &runtime, std::uint32_t parameter_address,
const std::filesystem::path &path, bool raw_mode) {
if (!std::filesystem::is_regular_file(path)) {
return raw_mode ? 0x80110329u : 0x80110307u;
}
std::ifstream input(path, std::ios::binary | std::ios::ate);
if (!input) return raw_mode ? 0x80110329u : 0x80110305u;
const auto end = input.tellg();
if (end < 0) return 0x80110305u;
const auto file_size = static_cast<std::uint64_t>(end);
const std::uint32_t destination = runtime.memory().load32(parameter_address + kSavedataDataBufferOffset);
const std::uint32_t capacity = runtime.memory().load32(parameter_address + kSavedataDataBufferSizeOffset);
if (file_size > capacity || file_size > 0xFFFFFFFFull ||
(file_size != 0u && (destination == 0u || !runtime.memory().contains(destination, static_cast<std::size_t>(file_size))))) {
return raw_mode ? 0x80110328u : 0x80110308u;
}
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(file_size));
input.seekg(0, std::ios::beg);
if (!bytes.empty()) input.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!input && !bytes.empty()) return 0x80110305u;
if (!bytes.empty()) runtime.memory().copy_in(destination, bytes);
runtime.memory().store32(parameter_address + kSavedataDataSizeOffset, static_cast<std::uint32_t>(bytes.size()));
return 0u;
}
std::uint32_t save_savedata_file(psprecomp::Runtime &runtime, std::uint32_t parameter_address,
const std::filesystem::path &path, bool raw_mode) {
const std::uint32_t source = runtime.memory().load32(parameter_address + kSavedataDataBufferOffset);
const std::uint32_t capacity = runtime.memory().load32(parameter_address + kSavedataDataBufferSizeOffset);
const std::uint32_t size = runtime.memory().load32(parameter_address + kSavedataDataSizeOffset);
if (size > capacity || (size != 0u && (source == 0u || !runtime.memory().contains(source, size)))) {
return raw_mode ? 0x80110328u : 0x80110388u;
}
// Snapshot every guest buffer before touching the existing slot. Some
// missions change the optional savedata payloads; the old path truncated
// DATA.BIN first and only then discovered an invalid auxiliary descriptor.
// A failed save could therefore leave a half-updated slot and a frontend
// completion state that looked successful until VCS tried to leave it.
std::vector<SavedataPendingWrite> writes;
SavedataPendingWrite main_write{};
main_write.target = path;
if (!snapshot_guest_bytes(runtime, source, size, main_write.bytes))
return raw_mode ? 0x80110328u : 0x80110388u;
writes.push_back(std::move(main_write));
if (!raw_mode) {
if (!snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataIcon0Offset, "ICON0.PNG", writes) ||
!snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataIcon1Offset, "ICON1.PMF", writes) ||
!snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataPic1Offset, "PIC1.PNG", writes) ||
!snapshot_savedata_auxiliary(runtime, parameter_address, kSavedataSnd0Offset, "SND0.AT3", writes)) {
return 0x80110388u;
}
}
if (!commit_savedata_writes(writes)) return raw_mode ? 0x80110329u : 0x80110385u;
if (!raw_mode) {
// Metadata is host-only UI decoration; it must never turn a valid game
// save into a failed firmware operation.
(void)write_savedata_metadata_file(path.parent_path(),
savedata_metadata_from_guest(runtime, parameter_address));
}
return 0u;
}
std::uint32_t list_savedata_directories(psprecomp::Runtime &runtime, std::uint32_t parameter_address) {
const std::uint32_t info = runtime.memory().load32(parameter_address + kSavedataIdListOffset);
if (info == 0u || !runtime.memory().contains(info, 12u)) return 0x80110328u;
const std::int32_t max_count = static_cast<std::int32_t>(runtime.memory().load32(info));
const std::uint32_t entries = runtime.memory().load32(info + 8u);
if (max_count < 0 || (max_count > 0 && (entries == 0u || !runtime.memory().contains(entries, static_cast<std::size_t>(max_count) * 72u)))) {
return 0x80110328u;
}
const std::string game = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataGameNameOffset, 13u));
std::vector<std::string> names;
const auto root = savedata_root(runtime);
if (std::filesystem::is_directory(root)) {
for (const auto &entry : std::filesystem::directory_iterator(root)) {
if (!entry.is_directory()) continue;
const std::string directory_name = entry.path().filename().string();
if (!directory_name.starts_with(game)) continue;
names.push_back(directory_name.substr(game.size()));
}
}
std::sort(names.begin(), names.end());
if (names.size() > static_cast<std::size_t>(max_count)) names.resize(static_cast<std::size_t>(max_count));
for (std::size_t index = 0; index < names.size(); ++index) {
const std::uint32_t entry = entries + static_cast<std::uint32_t>(index * 72u);
runtime.memory().zero(entry, 72u);
runtime.memory().store32(entry, 0x11FFu);
std::vector<std::uint8_t> bytes(names[index].begin(), names[index].end());
if (bytes.size() > 19u) bytes.resize(19u);
bytes.push_back(0u);
runtime.memory().copy_in(entry + 52u, bytes);
}
runtime.memory().store32(info + 4u, static_cast<std::uint32_t>(names.size()));
return 0u;
}
std::uint64_t directory_size_bytes(const std::filesystem::path &directory) {
std::uint64_t total = 0u;
if (!std::filesystem::is_directory(directory)) return total;
std::error_code error;
for (std::filesystem::recursive_directory_iterator it(directory, error), end; it != end && !error; it.increment(error)) {
if (it->is_regular_file(error)) total += it->file_size(error);
}
return total;
}
void write_small_size_string(psprecomp::GuestMemory &memory, std::uint32_t address, std::uint64_t kilobytes) {
const std::string text = kilobytes > 99999u ? "99999KB" : std::to_string(kilobytes) + "KB";
memory.zero(address, 8u);
std::vector<std::uint8_t> bytes(text.begin(), text.end());
if (bytes.size() > 7u) bytes.resize(7u);
bytes.push_back(0u);
memory.copy_in(address, bytes);
}
void write_used_data_info(psprecomp::GuestMemory &memory, std::uint32_t address,
std::uint64_t used_bytes, std::uint32_t cluster_size) {
const std::uint64_t clusters = (used_bytes + cluster_size - 1u) / cluster_size;
const std::uint64_t used_kb = (used_bytes + 1023u) / 1024u;
const std::uint64_t used_32kb = clusters * (cluster_size / 1024u);
memory.store32(address + 0u, static_cast<std::uint32_t>(std::min<std::uint64_t>(clusters, 0xFFFFFFFFull)));
memory.store32(address + 4u, static_cast<std::uint32_t>(std::min<std::uint64_t>(used_kb, 0xFFFFFFFFull)));
write_small_size_string(memory, address + 8u, used_kb);
memory.store32(address + 16u, static_cast<std::uint32_t>(std::min<std::uint64_t>(used_32kb, 0xFFFFFFFFull)));
write_small_size_string(memory, address + 20u, used_32kb);
}
std::uint32_t query_savedata_sizes(psprecomp::Runtime &runtime, std::uint32_t parameter_address) {
constexpr std::uint32_t cluster_size = 32u * 1024u;
const auto root = savedata_root(runtime);
std::error_code error;
std::filesystem::create_directories(root, error);
const auto space = std::filesystem::space(root, error);
const std::uint64_t available = error ? 512ull * 1024ull * 1024ull : space.available;
const std::uint64_t free_clusters = available / cluster_size;
const std::uint64_t free_kb = available / 1024u;
const std::uint64_t used = directory_size_bytes(savedata_directory(runtime, parameter_address));
const std::uint32_t ms_free = runtime.memory().load32(parameter_address + 0x5D0u);
if (ms_free != 0u) {
if (!runtime.memory().contains(ms_free, 20u)) return 0x801103C8u;
runtime.memory().store32(ms_free + 0u, cluster_size);
runtime.memory().store32(ms_free + 4u, static_cast<std::uint32_t>(std::min<std::uint64_t>(free_clusters, 0xFFFFFFFFull)));
runtime.memory().store32(ms_free + 8u, static_cast<std::uint32_t>(std::min<std::uint64_t>(free_kb, 0xFFFFFFFFull)));
write_small_size_string(runtime.memory(), ms_free + 12u, free_kb);
}
const std::uint32_t ms_data = runtime.memory().load32(parameter_address + 0x5D4u);
if (ms_data != 0u) {
if (!runtime.memory().contains(ms_data, 64u)) return 0x801103C8u;
runtime.memory().zero(ms_data, 64u);
for (std::uint32_t index = 0u; index < 13u; ++index)
runtime.memory().store8(ms_data + index, runtime.memory().load8(parameter_address + kSavedataGameNameOffset + index));
for (std::uint32_t index = 0u; index < 20u; ++index)
runtime.memory().store8(ms_data + 16u + index, runtime.memory().load8(parameter_address + kSavedataSaveNameOffset + index));
write_used_data_info(runtime.memory(), ms_data + 36u, used, cluster_size);
}
const std::uint32_t utility_data = runtime.memory().load32(parameter_address + 0x5D8u);
if (utility_data != 0u) {
if (!runtime.memory().contains(utility_data, 28u)) return 0x801103C8u;
write_used_data_info(runtime.memory(), utility_data, used, cluster_size);
}
return 0u;
}
std::uint32_t execute_savedata_operation(psprecomp::Runtime &runtime, std::uint32_t parameter_address) {
const std::uint32_t mode = runtime.memory().load32(parameter_address + kSavedataModeOffset);
const std::string file_name_value = safe_savedata_component(read_fixed_string(
runtime.memory(), parameter_address + kSavedataFileNameOffset, 13u));
const std::string file_name = file_name_value.empty() ? "DATA.BIN" : file_name_value;
const auto directory = savedata_directory(runtime, parameter_address);
const auto data_path = directory / file_name;
switch (mode) {
case 0u: // AUTOLOAD
case 2u: // LOAD
case 4u: // LISTLOAD (selected saveName is already supplied by the game)
return load_savedata_file(runtime, parameter_address, data_path, false);
case 1u: // AUTOSAVE
case 3u: // SAVE
case 5u: // LISTSAVE
return save_savedata_file(runtime, parameter_address, data_path, false);
case 6u: // LISTDELETE (slot was selected by the utility UI)
case 9u: // AUTODELETE
case 10u: // DELETE
if (!std::filesystem::exists(directory)) return 0x80110347u;
return std::filesystem::remove_all(directory) != 0u ? 0u : 0x80110345u;
case 11u: // LIST
return list_savedata_directories(runtime, parameter_address);
case 13u: // MAKEDATASECURE
case 14u: // MAKEDATA
case 17u: // WRITEDATASECURE
case 18u: // WRITEDATA
return save_savedata_file(runtime, parameter_address, data_path, true);
case 15u: // READDATASECURE
case 16u: // READDATA
return load_savedata_file(runtime, parameter_address, data_path, true);
case 19u: // ERASESECURE
case 20u: // ERASE
case 21u: // DELETEDATA
if (!std::filesystem::is_regular_file(data_path)) return 0x80110329u;
return std::filesystem::remove(data_path) ? 0u : 0x80110329u;
case 8u: // SIZES
return query_savedata_sizes(runtime, parameter_address);
case 12u: // FILES
case 22u: // GETSIZE
return 0u;
default:
return 0x80110300u;
}
}
const char *savedata_success_message(std::uint32_t mode) noexcept {
switch (mode) {
case 4u: return "LOAD COMPLETED.";
case 5u: return "SAVE COMPLETED.";
case 6u: return "DELETE COMPLETED.";
default: return "OPERATION COMPLETED.";
}
}
const char *savedata_failure_message(std::uint32_t mode) noexcept {
switch (mode) {
case 4u: return "LOAD FAILED.";
case 5u: return "SAVE FAILED.";
case 6u: return "DELETE FAILED.";
default: return "OPERATION FAILED.";
}
}
void cancel_savedata_list_utility(psprecomp::Runtime &runtime) {
// Real PSP savedata list dialogs report a user Back/Cancel as
// common.result = CANCEL (1) and do not set abortStatus. V9.4 deliberately
// used ABORT (2) + abortStatus=1 to get VCS' promoted LOAD->LISTLOAD path
// back into gameplay; applying that same workaround to LISTSAVE was wrong:
// VCS interprets it as a load/restore transition and shows a black LOADING
// screen. Keep the proven LOAD compatibility workaround, but use exact PSP
// cancellation semantics for SAVE/DELETE.
const bool load_cancel_workaround = savedata_utility.mode == 4u ||
savedata_utility.startup_picker || savedata_utility.direct_load_picker;
const std::uint32_t common_result = load_cancel_workaround
? kPspUtilityDialogResultAbort
: kPspUtilityDialogResultCancel;
const std::uint32_t abort_status = load_cancel_workaround ? 1u : 0u;
runtime.memory().store32(savedata_utility.parameter_address +
kSavedataAbortStatusOffset, abort_status);
runtime.memory().store32(savedata_utility.parameter_address +
kUtilityCommonResultOffset, common_result);
savedata_utility.operation_complete = true;
savedata_utility.status = UtilityStatus::Quit;
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
std::cout << "[savedata] V9.6 picker cancelled mode=" << savedata_utility.mode
<< " commonResult=" << common_result
<< " abortStatus=" << abort_status
<< (load_cancel_workaround ? " policy=load-compat-abort"
: " policy=psp-user-cancel")
<< "\n";
}
void execute_selected_savedata_slot(psprecomp::Runtime &runtime) {
if (savedata_utility.slots.empty() ||
savedata_utility.selected >= savedata_utility.slots.size()) {
cancel_savedata_list_utility(runtime);
return;
}
const SavedataSlotEntry &slot = savedata_utility.slots[savedata_utility.selected];
runtime.memory().store32(savedata_utility.parameter_address + kSavedataAbortStatusOffset, 0u);
write_fixed_string(runtime.memory(), savedata_utility.parameter_address + kSavedataSaveNameOffset,
20u, slot.save_name);
const std::uint32_t result = execute_savedata_operation(runtime, savedata_utility.parameter_address);
runtime.memory().store32(savedata_utility.parameter_address + kUtilityCommonResultOffset, result);
savedata_utility.operation_complete = true;
savedata_utility.last_result = result;
if (savedata_utility.mode == 5u) {
const std::uint32_t data_size = runtime.memory().load32(
savedata_utility.parameter_address + kSavedataDataSizeOffset);
const auto aux_size = [&](std::uint32_t offset) {
return runtime.memory().load32(savedata_utility.parameter_address + offset + 8u);
};
std::ostringstream line;
line << "SAVEDATA_SAVE result=0x" << std::hex << std::uppercase << result
<< std::nouppercase << std::dec
<< " data=" << data_size
<< " icon0=" << aux_size(kSavedataIcon0Offset)
<< " icon1=" << aux_size(kSavedataIcon1Offset)
<< " pic1=" << aux_size(kSavedataPic1Offset)
<< " snd0=" << aux_size(kSavedataSnd0Offset);
runtime_log_line(line.str());
}
if ((savedata_utility.startup_picker || savedata_utility.direct_load_picker) && result == 0u) {
// A successful first-boot choice should hand control back to the retail
// LOAD completion path immediately. There is no GAME frontend in V9.
savedata_utility.status = UtilityStatus::Quit;
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
std::cout << "[savedata] V9.6 LOAD selected slot=" << slot.save_name
<< " result=0\n";
} else {
savedata_utility.prompt = SavedataUtilityUiPrompt::Result;
savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt::Result,
result == 0u ? savedata_success_message(savedata_utility.mode)
: savedata_failure_message(savedata_utility.mode),
result == 0u);
}
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] savedata list operation result=0x" << std::hex << std::uppercase << result
<< std::nouppercase << std::dec << "\n";
}
}
void update_savedata_list_utility(psprecomp::Runtime &runtime) {
initialize_savedata_list_ui(runtime);
const std::uint32_t buttons = effective_controller_buttons();
const std::uint32_t pressed = buttons & ~savedata_utility.previous_buttons;
savedata_utility.previous_buttons = buttons;
if (savedata_utility.prompt == SavedataUtilityUiPrompt::NoData) {
if (savedata_utility.startup_picker) return;
if ((pressed & (kPspUtilityCircle | kPspUtilityStart)) != 0u) {
const std::uint32_t result = savedata_utility.mode == 4u ? 0x80110307u
: (savedata_utility.mode == 6u ? 0x80110347u : 0u);
runtime.memory().store32(savedata_utility.parameter_address + kUtilityCommonResultOffset, result);
savedata_utility.operation_complete = true;
savedata_utility.last_result = result;
savedata_utility.status = UtilityStatus::Quit;
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
}
return;
}
if (savedata_utility.prompt == SavedataUtilityUiPrompt::Result) {
if ((pressed & (kPspUtilityCircle | kPspUtilityStart)) != 0u) {
if (savedata_utility.last_result != 0u) {
// PSP LIST operations return to the list after an I/O failure
// so another slot can be tried instead of tearing down utility.
savedata_utility.operation_complete = false;
savedata_utility.prompt = SavedataUtilityUiPrompt::List;
savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt::List);
} else {
savedata_utility.status = UtilityStatus::Quit;
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
if (savedata_utility.mode == 5u)
runtime_log_line("SAVEDATA_SAVE acknowledged status=QUIT");
}
}
return;
}
if (savedata_utility.prompt == SavedataUtilityUiPrompt::Confirm) {
if ((pressed & (kPspUtilityCircle | kPspUtilityStart)) != 0u) {
savedata_utility.prompt = SavedataUtilityUiPrompt::List;
savedata_utility.confirm_yes = false;
savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt::List);
} else {
if ((pressed & kPspUtilityLeft) != 0u) savedata_utility.confirm_yes = true;
if ((pressed & kPspUtilityRight) != 0u) savedata_utility.confirm_yes = false;
savedata_utility_ui_set_confirm_choice(savedata_utility.confirm_yes);
if ((pressed & kPspUtilityCross) != 0u) {
if (savedata_utility.confirm_yes)
execute_selected_savedata_slot(runtime);
else {
savedata_utility.prompt = SavedataUtilityUiPrompt::List;
savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt::List);
}
}
}
return;
}
if ((pressed & (kPspUtilityCircle | kPspUtilityStart)) != 0u) {
if (!savedata_utility.startup_picker)
cancel_savedata_list_utility(runtime);
return;
}
if ((pressed & kPspUtilityUp) != 0u && savedata_utility.selected > 0u) {
--savedata_utility.selected;
savedata_utility_ui_set_selected(savedata_utility.selected);
}
if ((pressed & kPspUtilityDown) != 0u &&
savedata_utility.selected + 1u < savedata_utility.slots.size()) {
++savedata_utility.selected;
savedata_utility_ui_set_selected(savedata_utility.selected);
}
if ((pressed & kPspUtilityCross) != 0u && !savedata_utility.slots.empty()) {
const SavedataSlotEntry &slot = savedata_utility.slots[savedata_utility.selected];
if (savedata_utility.mode == 4u ||
(savedata_utility.mode == 5u && !slot.exists)) {
// LISTLOAD immediately starts loading; LISTSAVE only asks before
// overwriting an existing slot. This matches the PSP utility flow.
execute_selected_savedata_slot(runtime);
} else {
savedata_utility.prompt = SavedataUtilityUiPrompt::Confirm;
savedata_utility.confirm_yes = false; // PSP confirm dialogs default to No.
const char *message = savedata_utility.mode == 6u
? "THIS SAVE DATA WILL BE DELETED. CONTINUE?"
: "DO YOU WANT TO OVERWRITE THE DATA?";
savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt::Confirm, message, true);
savedata_utility_ui_set_confirm_choice(false);
}
}
}
std::uint64_t system_time_microseconds() {
return virtual_time_us;
}
std::uint32_t audio_remaining_samples(const AudioChannelState &channel) {
if (!channel.reserved || channel.busy_until_us <= virtual_time_us) return 0u;
const std::uint64_t remaining_us = channel.busy_until_us - virtual_time_us;
const std::uint64_t rate = channel.frequency == 0u ? 44100u : channel.frequency;
const std::uint64_t samples = (remaining_us * rate + 999999u) / 1000000u;
return static_cast<std::uint32_t>(std::min<std::uint64_t>(samples, channel.sample_count));
}
std::uint32_t audio_buffer_duration_us(std::uint32_t samples) {
return static_cast<std::uint32_t>((static_cast<std::uint64_t>(samples) * 1000000u + 44099u) / 44100u);
}
constexpr std::uint32_t audio_resample_success_value(bool return_queued_samples,
std::uint32_t sample_count) noexcept {
return return_queued_samples ? sample_count : 0u;
}
// Queues one buffer on a channel and returns the virtual time at which it
// starts playing.
//
// This models what the PSP audio hardware actually does. The previous code
// slept the guest for a whole buffer duration counted from the moment of the
// call, so the period between two submissions was buffer_duration *plus* the
// guest's own work in between -- the 2300 us charged by sceAtracDecodeData
// alone put the stream ~5% ahead of the mix, which is the constant "timeline
// resync" the host sink was reporting on every single submission. On hardware
// the blocking call returns when the *previous* buffer has drained, so guest
// CPU time is absorbed and consecutive buffers are exactly contiguous.
//
// The start time is rebuilt from an accumulated frame count each call, so the
// per-buffer integer rounding cannot pile up into audible drift.
std::uint64_t audio_queue_buffer(AudioChannelState &channel, std::uint32_t frames) {
const std::uint64_t rate = channel.frequency == 0u ? 44100u : channel.frequency;
const auto elapsed_us = [&](std::uint64_t sample_frames) {
return (sample_frames * 1000000ull) / rate;
};
std::uint64_t start = channel.queue_anchor_us + elapsed_us(channel.queued_frames);
if (!channel.queue_active) {
channel.queue_active = true;
channel.queue_anchor_us = virtual_time_us;
channel.queued_frames = 0u;
start = virtual_time_us;
} else if (start < virtual_time_us) {
channel.queue_anchor_us = virtual_time_us;
channel.queued_frames = 0u;
start = virtual_time_us;
}
channel.queued_frames += frames;
channel.busy_until_us = channel.queue_anchor_us + elapsed_us(channel.queued_frames);
return start;
}
void set_success(psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0u); }
class O32VarArgs {
public:
O32VarArgs(psprecomp::Runtime &runtime, const psprecomp::AllegrexContext &ctx)
: runtime_(runtime), ctx_(ctx) {}
std::uint32_t next_u32() {
// The VCS PSP build's variadic call convention keeps consuming the
// argument register window through a2, a3, t0, t1, t2 and t3 before
// spilling additional words to the caller argument area.
if (index_ < 6u) {
return ctx_.gpr[6u + index_++];
}
// Spilled words start at sp+0, not at the sp+16 an o32 caller would
// use. This build fills a2, a3 and t0..t3 first, so by the time it
// reaches the stack the four-word argument save area is not what it is
// writing into -- it simply continues at the bottom of the frame.
//
// Measured at the save-description call (0x08AB73E0), whose format
// "%s\n%s %s\n%s %d, $%d.\n%.1f%% %s" is the first in the game to need
// more than six variadic words. The caller emits exactly three stores:
//
// sw v0, 0(sp) low half of the %.1f double
// sw v1, 4(sp) high half
// sw s7, 8(sp) the pointer for the trailing %s
//
// Reading those from sp+16 gave the last %s whatever happened to be
// above the frame, and formatting it as a string dereferenced it: that
// is the "guest memory access outside PSP RAM at 0x00000160" that
// killed the game the moment a save was written. Nothing else hit it
// because no other format in the EBOOT spills.
const std::uint32_t address = ctx_.gpr[29] + static_cast<std::uint32_t>((index_ - 6u) * 4u);
++index_;
return runtime_.memory().load32(address);
}
std::uint64_t next_u64_aligned() {
// O32 aligns 64-bit variadic values to an even word slot. Our slot 0
// corresponds to physical argument register a2, which is already even.
if ((index_ & 1u) != 0u) ++index_;
const std::uint64_t low = next_u32();
const std::uint64_t high = next_u32();
return low | (high << 32u);
}
double next_double() {
return std::bit_cast<double>(next_u64_aligned());
}
private:
psprecomp::Runtime &runtime_;
const psprecomp::AllegrexContext &ctx_;
std::size_t index_{};
};
std::string format_integer(std::uint64_t value, bool negative, unsigned base, bool upper,
int width, int precision, bool left, bool zero, bool plus,
bool blank, bool alternate) {
const char *digits = upper ? "0123456789ABCDEF" : "0123456789abcdef";
std::string number;
do {
number.push_back(digits[value % base]);
value /= base;
} while (value != 0u);
std::reverse(number.begin(), number.end());
if (precision == 0 && number == "0") number.clear();
while (static_cast<int>(number.size()) < precision) number.insert(number.begin(), '0');
std::string prefix;
if (negative) prefix = "-";
else if (plus) prefix = "+";
else if (blank) prefix = " ";
if (alternate && base == 16u && !number.empty() && number != "0") prefix += upper ? "0X" : "0x";
if (alternate && base == 8u && (number.empty() || number.front() != '0')) prefix += "0";
std::string result = prefix + number;
if (width > static_cast<int>(result.size())) {
const std::size_t padding = static_cast<std::size_t>(width - static_cast<int>(result.size()));
if (left) result.append(padding, ' ');
else if (zero && precision < 0) result = prefix + std::string(padding, '0') + number;
else result.insert(0, padding, ' ');
}
return result;
}
std::uint32_t rot_mix_lookup2(std::span<const std::uint8_t> key, std::uint32_t init_value) {
auto read_le32 = [](const std::uint8_t *p) -> std::uint32_t {
return static_cast<std::uint32_t>(p[0]) |
(static_cast<std::uint32_t>(p[1]) << 8u) |
(static_cast<std::uint32_t>(p[2]) << 16u) |
(static_cast<std::uint32_t>(p[3]) << 24u);
};
auto mix = [](std::uint32_t &a, std::uint32_t &b, std::uint32_t &c) {
a -= b; a -= c; a ^= c >> 13u;
b -= c; b -= a; b ^= a << 8u;
c -= a; c -= b; c ^= b >> 13u;
a -= b; a -= c; a ^= c >> 12u;
b -= c; b -= a; b ^= a << 16u;
c -= a; c -= b; c ^= b >> 5u;
a -= b; a -= c; a ^= c >> 3u;
b -= c; b -= a; b ^= a << 10u;
c -= a; c -= b; c ^= b >> 15u;
};
std::uint32_t a = 0x9E3779B9u;
std::uint32_t b = 0x9E3779B9u;
std::uint32_t c = init_value;
const std::uint32_t original_length = static_cast<std::uint32_t>(key.size());
std::size_t offset = 0u;
while (key.size() - offset >= 12u) {
a += read_le32(key.data() + offset);
b += read_le32(key.data() + offset + 4u);
c += read_le32(key.data() + offset + 8u);
mix(a, b, c);
offset += 12u;
}
c += original_length;
const std::uint8_t *tail = key.data() + offset;
switch (key.size() - offset) {
case 11: c += static_cast<std::uint32_t>(tail[10]) << 24u; [[fallthrough]];
case 10: c += static_cast<std::uint32_t>(tail[9]) << 16u; [[fallthrough]];
case 9: c += static_cast<std::uint32_t>(tail[8]) << 8u; [[fallthrough]];
case 8: b += static_cast<std::uint32_t>(tail[7]) << 24u; [[fallthrough]];
case 7: b += static_cast<std::uint32_t>(tail[6]) << 16u; [[fallthrough]];
case 6: b += static_cast<std::uint32_t>(tail[5]) << 8u; [[fallthrough]];
case 5: b += tail[4]; [[fallthrough]];
case 4: a += static_cast<std::uint32_t>(tail[3]) << 24u; [[fallthrough]];
case 3: a += static_cast<std::uint32_t>(tail[2]) << 16u; [[fallthrough]];
case 2: a += static_cast<std::uint32_t>(tail[1]) << 8u; [[fallthrough]];
case 1: a += tail[0];
default: break;
}
mix(a, b, c);
return c;
}
void vcs_load_codec_modules(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const std::uint32_t table = ctx.gpr[28] + 0x0C90u;
const std::string prefix = runtime.memory().read_c_string(0x08B88280u, 1024u);
std::size_t loaded = 0u;
for (std::size_t index = 0u; index < 64u; ++index) {
const std::uint32_t entry = table + static_cast<std::uint32_t>(index * 8u);
const std::uint32_t name_pointer = runtime.memory().load32(entry);
if (name_pointer == 0u) break;
const std::int32_t existing = static_cast<std::int32_t>(runtime.memory().load32(entry + 4u));
if (existing != -1) continue;
std::string module_name = runtime.memory().read_c_string(name_pointer, 1024u);
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] codec entry " << index << " name=" << module_name
<< " existing=" << existing << "\n";
}
std::string guest_path = prefix + module_name;
std::transform(guest_path.begin(), guest_path.end(), guest_path.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
const auto native = runtime.translate_path(guest_path);
if (!std::filesystem::is_regular_file(native)) {
runtime.stop("Required PSP module is missing: " + guest_path + " -> " + native.string());
return;
}
runtime.memory().store32(entry + 4u, static_cast<std::uint32_t>(next_module_uid++));
++loaded;
}
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] codec modules ready: " << loaded << " prefix=" << prefix << "\n";
}
ctx.set_gpr(2, 0u);
ctx.pc = ctx.gpr[31];
}
void vcs_path_hash(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const std::string path = runtime.memory().read_c_string(ctx.gpr[4], 65536u);
const auto bytes = std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t *>(path.data()), path.size());
ctx.set_gpr(2, rot_mix_lookup2(bytes, 0x04C11DB7u));
ctx.pc = ctx.gpr[31];
}
void vcs_sprintf(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const std::uint32_t destination = ctx.gpr[4];
const std::string format = runtime.memory().read_c_string(ctx.gpr[5], 4096u);
O32VarArgs args(runtime, ctx);
std::string output;
output.reserve(format.size() + 64u);
for (std::size_t i = 0; i < format.size(); ++i) {
if (format[i] != '%') {
output.push_back(format[i]);
continue;
}
if (++i >= format.size()) break;
if (format[i] == '%') {
output.push_back('%');
continue;
}
bool left = false, plus = false, blank = false, alternate = false, zero = false;
for (;;) {
if (format[i] == '-') left = true;
else if (format[i] == '+') plus = true;
else if (format[i] == ' ') blank = true;
else if (format[i] == '#') alternate = true;
else if (format[i] == '0') zero = true;
else break;
if (++i >= format.size()) break;
}
int width = 0;
if (i < format.size() && format[i] == '*') {
width = static_cast<std::int32_t>(args.next_u32());
if (width < 0) { left = true; width = -width; }
++i;
} else {
while (i < format.size() && format[i] >= '0' && format[i] <= '9') {
width = width * 10 + (format[i] - '0');
++i;
}
}
int precision = -1;
if (i < format.size() && format[i] == '.') {
++i;
precision = 0;
if (i < format.size() && format[i] == '*') {
precision = static_cast<std::int32_t>(args.next_u32());
++i;
} else {
while (i < format.size() && format[i] >= '0' && format[i] <= '9') {
precision = precision * 10 + (format[i] - '0');
++i;
}
}
if (precision < 0) precision = -1;
}
int length = 0;
if (i < format.size() && format[i] == 'l') {
length = 1;
if (++i < format.size() && format[i] == 'l') { length = 2; ++i; }
} else if (i < format.size() && format[i] == 'h') {
length = -1;
if (++i < format.size() && format[i] == 'h') { length = -2; ++i; }
}
if (i >= format.size()) break;
const char conversion = format[i];
if (conversion == 's') {
const std::uint32_t pointer = args.next_u32();
std::string value = pointer == 0u ? "(null)" : runtime.memory().read_c_string(pointer, 65536u);
if (precision >= 0 && static_cast<int>(value.size()) > precision) value.resize(static_cast<std::size_t>(precision));
if (width > static_cast<int>(value.size())) {
const std::size_t padding = static_cast<std::size_t>(width - static_cast<int>(value.size()));
if (left) value.append(padding, ' '); else value.insert(0, padding, ' ');
}
output += value;
} else if (conversion == 'c') {
std::string value(1u, static_cast<char>(args.next_u32() & 0xFFu));
if (width > 1) {
if (left) value.append(static_cast<std::size_t>(width - 1), ' ');
else value.insert(0, static_cast<std::size_t>(width - 1), ' ');
}
output += value;
} else if (conversion == 'd' || conversion == 'i') {
std::int64_t signed_value = static_cast<std::int32_t>(args.next_u32());
if (length == 2) {
const std::uint64_t lo = static_cast<std::uint64_t>(static_cast<std::uint32_t>(signed_value));
const std::uint64_t hi = args.next_u32();
signed_value = static_cast<std::int64_t>((hi << 32u) | lo);
}
const bool negative = signed_value < 0;
const std::uint64_t magnitude = negative ? static_cast<std::uint64_t>(-(signed_value + 1)) + 1u : static_cast<std::uint64_t>(signed_value);
output += format_integer(magnitude, negative, 10u, false, width, precision, left, zero, plus, blank, false);
} else if (conversion == 'u' || conversion == 'o' || conversion == 'x' || conversion == 'X' || conversion == 'p') {
std::uint64_t value = args.next_u32();
if (length == 2) value |= static_cast<std::uint64_t>(args.next_u32()) << 32u;
const unsigned base = conversion == 'o' ? 8u : ((conversion == 'x' || conversion == 'X' || conversion == 'p') ? 16u : 10u);
const bool pointer = conversion == 'p';
output += format_integer(value, false, base, conversion == 'X', width, precision, left, zero, false, false, alternate || pointer);
} else if (conversion == 'f' || conversion == 'F' || conversion == 'e' || conversion == 'E' ||
conversion == 'g' || conversion == 'G') {
const double value = args.next_double();
std::string specification{"%"};
if (left) specification.push_back('-');
if (plus) specification.push_back('+');
if (blank) specification.push_back(' ');
if (alternate) specification.push_back('#');
if (zero) specification.push_back('0');
if (width > 0) specification += std::to_string(width);
if (precision >= 0) specification += "." + std::to_string(precision);
specification.push_back(conversion);
const int required = std::snprintf(nullptr, 0, specification.c_str(), value);
if (required < 0) {
runtime.stop("VCS sprintf floating conversion failed for " + specification);
return;
}
std::vector<char> formatted(static_cast<std::size_t>(required) + 1u);
const int written = std::snprintf(formatted.data(), formatted.size(), specification.c_str(), value);
if (written != required) {
runtime.stop("VCS sprintf floating conversion length mismatch for " + specification);
return;
}
output.append(formatted.data(), static_cast<std::size_t>(written));
} else if (conversion == 'n') {
const std::uint32_t pointer = args.next_u32();
runtime.memory().store32(pointer, static_cast<std::uint32_t>(output.size()));
} else {
runtime.stop(std::string("Unsupported VCS sprintf conversion %") + conversion);
return;
}
}
if (!runtime.memory().contains(destination, output.size() + 1u)) {
runtime.stop("VCS sprintf destination outside guest memory");
return;
}
std::vector<std::uint8_t> bytes(output.begin(), output.end());
bytes.push_back(0u);
runtime.memory().copy_in(destination, bytes);
ctx.set_gpr(2, static_cast<std::uint32_t>(output.size()));
ctx.pc = ctx.gpr[31];
}
bool maybe_start_pending_guest_callback(psprecomp::AllegrexContext &ctx);
void enqueue_continuation(std::int32_t uid, const psprecomp::AllegrexContext &context) {
auto thread = thread_table.threads.find(uid);
if (thread != thread_table.threads.end()) {
thread->second.state = ThreadState::Ready;
thread->second.suspended_context = context;
if (thread->second.externally_suspended) {
thread_table.continuations.erase(
std::remove_if(thread_table.continuations.begin(), thread_table.continuations.end(),
[uid](const ThreadContinuation &item) { return item.uid == uid; }),
thread_table.continuations.end());
return;
}
}
const auto existing = std::find_if(
thread_table.continuations.begin(), thread_table.continuations.end(),
[uid](const ThreadContinuation &item) { return item.uid == uid; });
if (existing != thread_table.continuations.end()) {
existing->context = context;
} else {
thread_table.continuations.push_back(
ThreadContinuation{uid, context, thread_table.next_ready_sequence++});
}
}
bool activate_next_thread(psprecomp::AllegrexContext &ctx, const char *reason);
std::uint32_t thread_priority(std::int32_t uid) {
const auto found = thread_table.threads.find(uid);
return found != thread_table.threads.end() ? found->second.priority : 0xFFFFFFFFu;
}
auto best_ready_thread() {
return std::min_element(
thread_table.continuations.begin(), thread_table.continuations.end(),
[](const ThreadContinuation &left, const ThreadContinuation &right) {
const std::uint32_t left_priority = thread_priority(left.uid);
const std::uint32_t right_priority = thread_priority(right.uid);
if (left_priority != right_priority) return left_priority < right_priority;
return left.ready_sequence < right.ready_sequence;
});
}
bool preempt_if_higher_priority(psprecomp::AllegrexContext &ctx, const char *reason) {
const auto current = thread_table.threads.find(thread_table.current_uid);
if (current == thread_table.threads.end() || current->second.state != ThreadState::Running) return false;
const auto best = best_ready_thread();
if (best == thread_table.continuations.end() ||
thread_priority(best->uid) >= thread_priority(thread_table.current_uid)) {
return false;
}
const std::int32_t caller_uid = thread_table.current_uid;
const std::int32_t target_uid = best->uid;
const std::uint32_t target_priority = thread_priority(target_uid);
psprecomp::AllegrexContext caller = ctx;
caller.pc = ctx.gpr[31];
enqueue_continuation(caller_uid, caller);
if (std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr || std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[sched] preempt reason=" << reason
<< " caller=" << caller_uid
<< " caller_priority=" << thread_priority(caller_uid)
<< " target=" << target_uid
<< " target_priority=" << target_priority << "\n";
}
return activate_next_thread(ctx, reason);
}
void promote_expired_delays() {
struct ExpiredDelay {
std::int32_t uid{};
std::uint64_t deadline{};
std::uint64_t sequence{};
};
std::vector<ExpiredDelay> expired;
expired.reserve(thread_table.threads.size());
for (const auto &[uid, thread] : thread_table.threads) {
if (thread.state == ThreadState::Delayed && thread.delay_until_us <= virtual_time_us)
expired.push_back(ExpiredDelay{uid, thread.delay_until_us, thread.delay_sequence});
}
// unordered_map iteration must never decide PSP scheduling order. Kernel
// wakeups are replayed by deadline and by the order in which the waits were
// armed, with UID only as a final total-order guard.
std::sort(expired.begin(), expired.end(), [](const ExpiredDelay &left, const ExpiredDelay &right) {
if (left.deadline != right.deadline) return left.deadline < right.deadline;
if (left.sequence != right.sequence) return left.sequence < right.sequence;
return left.uid < right.uid;
});
for (const ExpiredDelay &item : expired) {
const auto thread = thread_table.threads.find(item.uid);
if (thread != thread_table.threads.end()) enqueue_continuation(item.uid, thread->second.suspended_context);
}
}
bool activate_next_thread(psprecomp::AllegrexContext &ctx, const char *reason) {
const std::int32_t previous_uid = thread_table.current_uid;
promote_expired_delays();
thread_table.continuations.erase(
std::remove_if(thread_table.continuations.begin(), thread_table.continuations.end(),
[](const ThreadContinuation &item) {
const auto thread = thread_table.threads.find(item.uid);
return thread == thread_table.threads.end() || thread->second.externally_suspended;
}),
thread_table.continuations.end());
if (thread_table.continuations.empty()) {
std::uint64_t earliest = UINT64_MAX;
for (const auto &[uid, thread] : thread_table.threads) {
(void)uid;
if (thread.state == ThreadState::Delayed)
earliest = std::min(earliest, thread.delay_until_us);
}
if (earliest != UINT64_MAX) {
// The recomp runtime uses deterministic virtual PSP time. When no
// thread is runnable, advance directly to the next kernel wakeup.
virtual_time_us = std::max(virtual_time_us, earliest);
promote_expired_delays();
}
}
if (thread_table.continuations.empty()) return false;
// PSP priorities are inverted: a smaller numeric value means a higher
// scheduling priority. Equal-priority threads keep explicit FIFO order.
const auto selected = best_ready_thread();
ThreadContinuation continuation = *selected;
thread_table.continuations.erase(selected);
thread_table.current_uid = continuation.uid;
std::string thread_name = "unknown";
if (auto thread = thread_table.threads.find(continuation.uid); thread != thread_table.threads.end()) {
thread->second.state = ThreadState::Running;
thread_name = thread->second.name;
}
ctx = continuation.context;
psprecomp::set_runtime_thread_identity(continuation.uid, thread_name);
if (std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr || std::getenv("PSPRECOMP_TRACE") != nullptr) {
const auto pending = pending_guest_callbacks.find(continuation.uid);
const auto frames = async_return_frames.find(continuation.uid);
std::cerr << "[sched] reason=" << reason
<< " from_uid=" << previous_uid
<< " to_uid=" << continuation.uid
<< " name=" << thread_name
<< " priority=" << thread_priority(continuation.uid)
<< " pc=" << psprecomp::hex32(ctx.pc)
<< " sp=" << psprecomp::hex32(ctx.gpr[29])
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " gp=" << psprecomp::hex32(ctx.gpr[28])
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a2=" << psprecomp::hex32(ctx.gpr[6])
<< " a3=" << psprecomp::hex32(ctx.gpr[7])
<< " t0=" << psprecomp::hex32(ctx.gpr[8])
<< " t1=" << psprecomp::hex32(ctx.gpr[9])
<< " t2=" << psprecomp::hex32(ctx.gpr[10])
<< " t3=" << psprecomp::hex32(ctx.gpr[11])
<< " s0=" << psprecomp::hex32(ctx.gpr[16])
<< " s1=" << psprecomp::hex32(ctx.gpr[17])
<< " s2=" << psprecomp::hex32(ctx.gpr[18])
<< " s3=" << psprecomp::hex32(ctx.gpr[19])
<< " s4=" << psprecomp::hex32(ctx.gpr[20])
<< " s5=" << psprecomp::hex32(ctx.gpr[21])
<< " s6=" << psprecomp::hex32(ctx.gpr[22])
<< " s7=" << psprecomp::hex32(ctx.gpr[23])
<< " ready=" << thread_table.continuations.size()
<< " pending_callbacks=" << (pending == pending_guest_callbacks.end() ? 0u : pending->second.size())
<< " async_frames=" << (frames == async_return_frames.end() ? 0u : frames->second.size())
<< "\n";
}
(void)maybe_start_pending_guest_callback(ctx);
return true;
}
bool yield_current_thread(psprecomp::AllegrexContext &ctx) {
psprecomp::AllegrexContext suspended = ctx;
suspended.set_gpr(2, 0u);
suspended.pc = ctx.gpr[31];
enqueue_continuation(thread_table.current_uid, suspended);
return activate_next_thread(ctx, "yield");
}
psprecomp::AllegrexContext make_wait_context(const psprecomp::AllegrexContext &ctx) {
psprecomp::AllegrexContext suspended = ctx;
suspended.set_gpr(2, 0u);
suspended.pc = ctx.gpr[31];
return suspended;
}
bool delay_current_thread(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx,
std::uint32_t delay_microseconds, std::uint32_t return_value = 0u) {
auto current = thread_table.threads.find(thread_table.current_uid);
if (current == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return false;
}
psprecomp::AllegrexContext suspended = make_wait_context(ctx);
suspended.set_gpr(2, return_value);
current->second.state = ThreadState::Delayed;
current->second.suspended_context = suspended;
current->second.delay_until_us = virtual_time_us + delay_microseconds;
current->second.delay_sequence = thread_table.next_delay_sequence++;
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[sched] delay uid=" << thread_table.current_uid
<< " usec=" << delay_microseconds
<< " resume=" << psprecomp::hex32(suspended.pc) << "\n";
}
if (!activate_next_thread(ctx, "delay")) {
runtime.stop("PSP scheduler deadlock while delaying thread");
return false;
}
return true;
}
bool defer_current_thread_for_io_handoff(psprecomp::Runtime &runtime,
psprecomp::AllegrexContext &ctx,
std::uint32_t return_value,
std::uint32_t release_pc_hint = 0u) {
const std::int32_t worker_uid = thread_table.current_uid;
auto worker = thread_table.threads.find(worker_uid);
if (worker == thread_table.threads.end()) {
ctx.set_gpr(2, return_value);
return false;
}
// With nobody else ready there is no submitter/worker race to break.
if (best_ready_thread() == thread_table.continuations.end()) {
ctx.set_gpr(2, return_value);
return false;
}
psprecomp::AllegrexContext suspended = make_wait_context(ctx);
suspended.set_gpr(2, return_value);
worker->second.state = ThreadState::IoDeferred;
worker->second.suspended_context = suspended;
if (!activate_next_thread(ctx, "io-handoff")) {
worker->second.state = ThreadState::Running;
ctx = suspended;
psprecomp::set_runtime_thread_identity(worker_uid, worker->second.name);
return false;
}
const std::uint32_t handoff_pc = ctx.pc;
const std::uint32_t release_pc = release_pc_hint != 0u
? release_pc_hint
: io_handoff_release_pc(handoff_pc);
deferred_io_resumes[worker_uid] =
DeferredIoResume{thread_table.current_uid, handoff_pc, release_pc, 0u};
refresh_vcs_post_dispatch_hook();
if (std::getenv("PSPRECOMP_UMD_STREAM_DIAG") != nullptr ||
std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr) {
std::cerr << "[io-handoff] arm worker=" << worker_uid
<< " worker_name=" << worker->second.name
<< " worker_resume=" << psprecomp::hex32(suspended.pc)
<< " handoff_uid=" << thread_table.current_uid
<< " handoff_pc=" << psprecomp::hex32(handoff_pc)
<< " release_pc=" << psprecomp::hex32(release_pc)
<< " result=" << return_value << "\n";
}
return true;
}
bool suspend_current_thread(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx,
const psprecomp::AllegrexContext &suspended,
const std::string &reason) {
if (auto current = thread_table.threads.find(thread_table.current_uid);
current != thread_table.threads.end()) {
current->second.state = ThreadState::Sleeping;
current->second.suspended_context = suspended;
}
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
const auto found = thread_table.threads.find(thread_table.current_uid);
std::cerr << "[sched] block uid=" << thread_table.current_uid
<< " name=" << (found != thread_table.threads.end() ? found->second.name : "unknown")
<< " reason=" << reason << " resume=" << psprecomp::hex32(suspended.pc) << "\n";
}
if (!activate_next_thread(ctx, reason.c_str())) {
runtime.stop("PSP scheduler deadlock while waiting for " + reason);
return false;
}
return true;
}
bool sleep_current_thread(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
auto current = thread_table.threads.find(thread_table.current_uid);
if (current == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return false;
}
if (current->second.wakeup_count != 0u) {
--current->second.wakeup_count;
set_success(ctx);
return true;
}
const psprecomp::AllegrexContext suspended = make_wait_context(ctx);
current->second.state = ThreadState::Sleeping;
current->second.suspended_context = suspended;
if (!activate_next_thread(ctx, "sleep")) {
runtime.stop("PSP scheduler deadlock: every thread is sleeping");
return false;
}
return true;
}
std::uint32_t wake_thread(std::int32_t uid) {
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) return 0x80020198u;
ThreadRecord &thread = found->second;
if (thread.state == ThreadState::Completed || thread.state == ThreadState::Created)
return 0x800201A2u;
if (thread.state == ThreadState::Sleeping) {
enqueue_continuation(uid, thread.suspended_context);
} else {
++thread.wakeup_count;
}
return 0u;
}
struct UserArenaRange {
std::uint32_t begin{};
std::uint32_t end{};
};
std::uint32_t fixed_pool_reserved_size(const FixedPoolRecord &pool) noexcept {
const std::uint64_t bytes = static_cast<std::uint64_t>(pool.block_size) * pool.block_count;
const std::uint64_t aligned = (bytes + 0xFFull) & ~0xFFull;
return aligned <= 0xFFFFFFFFull ? static_cast<std::uint32_t>(aligned) : 0u;
}
std::vector<UserArenaRange> active_user_arena_ranges() {
std::vector<UserArenaRange> ranges;
ranges.reserve(partition_table.blocks.size() + fixed_pool_table.pools.size());
for (const auto &[uid, block] : partition_table.blocks) {
(void)uid;
if (block.size != 0u && block.address <= 0xFFFFFFFFu - block.size)
ranges.push_back({block.address, block.address + block.size});
}
for (const auto &[uid, pool] : fixed_pool_table.pools) {
(void)uid;
const std::uint32_t reserved = fixed_pool_reserved_size(pool);
if (reserved != 0u && pool.address <= 0xFFFFFFFFu - reserved)
ranges.push_back({pool.address, pool.address + reserved});
}
std::sort(ranges.begin(), ranges.end(), [](const UserArenaRange &left, const UserArenaRange &right) {
if (left.begin != right.begin) return left.begin < right.begin;
return left.end < right.end;
});
return ranges;
}
void recompute_partition_frontier() {
std::uint32_t frontier = partition_arena_base;
for (const UserArenaRange range : active_user_arena_ranges())
frontier = std::max(frontier, range.end);
partition_table.next_address = (frontier + 0xFFu) & ~0xFFu;
}
bool allocate_user_arena_range(std::uint32_t size, std::uint32_t alignment, std::uint32_t &address) {
if (size == 0u || alignment == 0u || (alignment & (alignment - 1u)) != 0u) return false;
const std::uint64_t mask = static_cast<std::uint64_t>(alignment - 1u);
auto align_up = [mask](std::uint64_t value) { return (value + mask) & ~mask; };
std::uint64_t candidate = align_up(partition_arena_base);
const std::uint64_t ceiling = thread_table.next_stack_top;
const std::uint64_t wanted = size;
for (const UserArenaRange range : active_user_arena_ranges()) {
if (range.end <= candidate) continue;
if (candidate + wanted <= range.begin) {
address = static_cast<std::uint32_t>(candidate);
return candidate + wanted <= ceiling;
}
candidate = align_up(std::max<std::uint64_t>(candidate, range.end));
if (candidate > ceiling) return false;
}
if (candidate + wanted > ceiling || candidate + wanted > 0x100000000ull) return false;
address = static_cast<std::uint32_t>(candidate);
return true;
}
void release_thread_stack(const ThreadRecord &thread) {
if (thread.stack_bottom == 0u || thread.stack_top <= thread.stack_bottom) return;
thread_table.free_stacks.push_back({thread.stack_bottom, thread.stack_top});
std::sort(thread_table.free_stacks.begin(), thread_table.free_stacks.end(),
[](const FreeThreadStack &left, const FreeThreadStack &right) {
return left.bottom < right.bottom;
});
std::vector<FreeThreadStack> merged;
for (const FreeThreadStack block : thread_table.free_stacks) {
if (!merged.empty() && block.bottom <= merged.back().top) {
merged.back().top = std::max(merged.back().top, block.top);
} else {
merged.push_back(block);
}
}
thread_table.free_stacks = std::move(merged);
// Collapse any free block adjacent to the downward allocation frontier.
for (;;) {
const auto adjacent = std::find_if(thread_table.free_stacks.begin(), thread_table.free_stacks.end(),
[](const FreeThreadStack &block) { return block.bottom == thread_table.next_stack_top; });
if (adjacent == thread_table.free_stacks.end()) break;
thread_table.next_stack_top = adjacent->top;
thread_table.free_stacks.erase(adjacent);
}
}
bool allocate_thread_stack(std::uint32_t stack_size, std::uint32_t &bottom, std::uint32_t &top) {
// Reuse a deleted thread stack first. Allocate from the high end to retain
// the PSP's top-down stack layout and leave any remainder reusable.
auto best = thread_table.free_stacks.end();
for (auto it = thread_table.free_stacks.begin(); it != thread_table.free_stacks.end(); ++it) {
const std::uint32_t size = it->top - it->bottom;
if (size < stack_size) continue;
if (best == thread_table.free_stacks.end() || size < best->top - best->bottom) best = it;
}
if (best != thread_table.free_stacks.end()) {
top = best->top;
bottom = top - stack_size;
if (bottom == best->bottom)
thread_table.free_stacks.erase(best);
else
best->top = bottom;
return true;
}
top = thread_table.next_stack_top & ~0xFFu;
if (top < stack_size) return false;
bottom = top - stack_size;
if (bottom < partition_table.next_address) return false;
thread_table.next_stack_top = bottom;
return true;
}
void remove_thread_from_wait_queues(std::int32_t uid) {
for (auto &[semaphore_uid, semaphore] : semaphore_table.semaphores) {
(void)semaphore_uid;
semaphore.waiters.erase(std::remove_if(semaphore.waiters.begin(), semaphore.waiters.end(),
[uid](const SemaphoreWaiter &waiter) { return waiter.uid == uid; }), semaphore.waiters.end());
}
for (auto &[flag_uid, flag] : event_flag_table.flags) {
(void)flag_uid;
flag.waiters.erase(std::remove_if(flag.waiters.begin(), flag.waiters.end(),
[uid](const EventFlagWaiter &waiter) { return waiter.uid == uid; }), flag.waiters.end());
}
for (auto &[target_uid, waiters] : thread_table.thread_end_waiters) {
(void)target_uid;
waiters.erase(std::remove_if(waiters.begin(), waiters.end(),
[uid](const ThreadContinuation &waiter) { return waiter.uid == uid; }), waiters.end());
}
std::erase_if(thread_table.thread_end_waiters,
[](const auto &entry) { return entry.second.empty(); });
std::erase_if(callback_table.callbacks,
[uid](const auto &entry) { return entry.second.owner_uid == uid; });
}
void wake_thread_end_waiters(std::int32_t completed_uid, std::uint32_t result = 0u) {
const auto found = thread_table.thread_end_waiters.find(completed_uid);
if (found == thread_table.thread_end_waiters.end()) return;
for (auto &waiter : found->second) {
waiter.context.set_gpr(2, result);
enqueue_continuation(waiter.uid, waiter.context);
}
thread_table.thread_end_waiters.erase(found);
}
void erase_thread_runtime_state(std::int32_t uid) {
thread_table.continuations.erase(
std::remove_if(thread_table.continuations.begin(), thread_table.continuations.end(),
[uid](const ThreadContinuation &item) { return item.uid == uid; }),
thread_table.continuations.end());
pending_guest_callbacks.erase(uid);
async_return_frames.erase(uid);
deferred_io_resumes.erase(uid);
remove_thread_from_wait_queues(uid);
}
void exit_delete_current_thread(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const std::int32_t deleted_uid = thread_table.current_uid;
const auto current = thread_table.threads.find(deleted_uid);
if (current == thread_table.threads.end()) {
runtime.stop("sceKernelExitDeleteThread called without a current PSP thread");
return;
}
// sceKernelExitDeleteThread is not the same operation as
// sceKernelExitThread: the thread object and its user stack cease to exist
// as part of the call. Keeping it as Completed leaks top-down PSP user
// memory until later thread creation fails.
current->second.exit_status = ctx.gpr[4];
ThreadRecord deleted = current->second;
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[thread] exit-delete uid=" << deleted_uid
<< " name=" << deleted.name
<< " status=" << psprecomp::hex32(deleted.exit_status)
<< " stack=" << psprecomp::hex32(deleted.stack_bottom)
<< "-" << psprecomp::hex32(deleted.stack_top) << "\n";
}
erase_thread_runtime_state(deleted_uid);
// A waiter already blocked in sceKernelWaitThreadEnd must observe thread
// termination even though the object is deleted immediately afterward.
wake_thread_end_waiters(deleted_uid, 0u);
release_thread_stack(deleted);
thread_table.threads.erase(deleted_uid);
refresh_vcs_post_dispatch_hook();
if (!activate_next_thread(ctx, "thread-exit-delete")) {
ctx.set_gpr(2, 0u);
runtime.stop("All PSP threads completed after sceKernelExitDeleteThread");
}
}
bool is_legacy_vcs_exit_delete_worker(const ThreadRecord &thread) {
if (thread.state != ThreadState::Completed) return false;
// These three entry points are VCS workers whose generated guest code ends
// by calling import 0x08B734F4 -> ThreadManForUser::0x809CE29B. They are
// the leaked records present in the already captured V8.2.6 checkpoint.
return (thread.entry == 0x08934734u && thread.name == "stupidthread") ||
(thread.entry == 0x08AB5AA0u && thread.name == "memstick") ||
(thread.entry == 0x08986B50u && thread.name == "sfx bank load thread");
}
LegacyExitDeleteRepairStats repair_legacy_vcs_exit_delete_threads() {
LegacyExitDeleteRepairStats stats{};
stats.stack_top_before = thread_table.next_stack_top;
std::vector<std::int32_t> leaked;
leaked.reserve(thread_table.threads.size());
for (const auto &[uid, thread] : thread_table.threads) {
if (uid != thread_table.current_uid && is_legacy_vcs_exit_delete_worker(thread))
leaked.push_back(uid);
}
std::sort(leaked.begin(), leaked.end());
for (const std::int32_t uid : leaked) {
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) continue;
const ThreadRecord deleted = found->second;
erase_thread_runtime_state(uid);
// The broken historical ExitDelete path already woke end waiters when
// it marked the worker Completed. Calling this is harmless if none
// remain and makes migration robust to an interrupted checkpoint.
wake_thread_end_waiters(uid, 0u);
stats.stack_bytes += static_cast<std::uint64_t>(deleted.stack_top - deleted.stack_bottom);
release_thread_stack(deleted);
thread_table.threads.erase(found);
++stats.threads;
}
refresh_vcs_post_dispatch_hook();
stats.stack_top_after = thread_table.next_stack_top;
return stats;
}
constexpr std::uint32_t kGeListIdMagic = 0x35000000u;
constexpr std::uint32_t kGeCommandNop = 0x00u;
constexpr std::uint32_t kGeCommandVertexAddress = 0x01u;
constexpr std::uint32_t kGeCommandIndexAddress = 0x02u;
constexpr std::uint32_t kGeCommandPrimitive = 0x04u;
constexpr std::uint32_t kGeCommandBoundingBox = 0x07u;
constexpr std::uint32_t kGeCommandJump = 0x08u;
constexpr std::uint32_t kGeCommandBoundingBoxJump = 0x09u;
constexpr std::uint32_t kGeCommandCall = 0x0Au;
constexpr std::uint32_t kGeCommandReturn = 0x0Bu;
constexpr std::uint32_t kGeCommandEnd = 0x0Cu;
constexpr std::uint32_t kGeCommandSignal = 0x0Eu;
constexpr std::uint32_t kGeCommandFinish = 0x0Fu;
constexpr std::uint32_t kGeCommandBase = 0x10u;
constexpr std::uint32_t kGeCommandOffsetAddress = 0x13u;
// Stage 45.1 safe frontend optimization: GeGpuDrawDescriptor only depends on
// the registers below. Matrix/light/control-flow writes are intentionally not
// part of this revision. This keeps the proven Stage 44.7 renderer semantics
// while avoiding repeated ~60-register descriptor rebuilds in dense lists.
constexpr bool ge_command_affects_gpu_draw_descriptor(std::uint32_t command) noexcept {
if (command >= 0xA0u && command <= 0xAFu) return true; // texture addresses/strides
if (command >= 0xB8u && command <= 0xBFu) return true; // texture sizes
switch (command) {
case 0x12u: // vertex type / through mode
case 0x1Eu: case 0x1Fu: // texture/fog enable
case 0x21u: case 0x22u: case 0x23u: // blend/alpha/depth enable
case 0x9Cu: case 0x9Du: // framebuffer address/stride
case 0xB0u: case 0xB1u: // CLUT address
case 0xC2u: case 0xC3u: case 0xC5u: case 0xC6u: case 0xC7u:
case 0xC8u: case 0xC9u: case 0xCAu:
case 0xCDu: case 0xCEu: case 0xCFu: case 0xD0u:
case 0xD2u: case 0xD3u: case 0xD4u: case 0xD5u:
case 0xDBu: case 0xDEu: case 0xDFu:
case 0xE0u: case 0xE1u: case 0xE7u: case 0xE8u: case 0xE9u:
return true;
default:
return false;
}
}
constexpr std::uint32_t kGeCommandOrigin = 0x14u;
constexpr std::uint8_t kGeSignalNone = 0x00u;
constexpr std::uint8_t kGeSignalHandlerSuspend = 0x01u;
constexpr std::uint8_t kGeSignalHandlerContinue = 0x02u;
constexpr std::uint8_t kGeSignalHandlerPause = 0x03u;
constexpr std::uint8_t kGeSignalSync = 0x08u;
constexpr std::uint8_t kGeSignalJump = 0x10u;
constexpr std::uint8_t kGeSignalCall = 0x11u;
constexpr std::uint8_t kGeSignalReturn = 0x12u;
constexpr std::uint8_t kGeSignalRelativeJump = 0x13u;
constexpr std::uint8_t kGeSignalRelativeCall = 0x14u;
constexpr std::uint8_t kGeSignalOriginJump = 0x15u;
constexpr std::uint8_t kGeSignalOriginCall = 0x16u;
std::uint32_t ge_relative_address(std::uint32_t data) {
const std::uint32_t base_extended = ((ge_state.commands[kGeCommandBase] & 0x000F0000u) << 8u) |
(data & 0x00FFFFFFu);
return (ge_state.offset_address + base_extended) & 0x0FFFFFFFu;
}
std::uint32_t ge_list_status(const GeListRecord &list) {
switch (list.state) {
case GeListState::Completed:
case GeListState::None:
return 0u;
case GeListState::Queued:
return 1u;
case GeListState::Running:
return 2u;
case GeListState::Stalled:
return 3u;
case GeListState::Paused:
return 4u;
case GeListState::Error:
return 0x80000100u;
}
return 0x80000100u;
}
const char *ge_command_name(std::uint32_t command) {
switch (command) {
case 0x00: return "NOP";
case 0x01: return "VADDR";
case 0x02: return "IADDR";
case 0x04: return "PRIM";
case 0x05: return "BEZIER";
case 0x06: return "SPLINE";
case 0x07: return "BBOX";
case 0x08: return "JUMP";
case 0x09: return "BJUMP";
case 0x0A: return "CALL";
case 0x0B: return "RET";
case 0x0C: return "END";
case 0x0E: return "SIGNAL";
case 0x0F: return "FINISH";
case 0x10: return "BASE";
case 0x12: return "VTYPE";
case 0x13: return "OFFSET";
case 0x14: return "ORIGIN";
case 0x9C: return "FBPTR";
case 0x9D: return "FBWIDTH";
case 0x9E: return "ZBPTR";
case 0x9F: return "ZBWIDTH";
case 0xD2: return "FBFORMAT";
case 0xD3: return "CLEARMODE";
case 0xEA: return "TRANSFERSTART";
default: return nullptr;
}
}
bool ge_histogram_diag_enabled() noexcept {
static const bool enabled = std::getenv("PSPRECOMP_GE_DIAG") != nullptr;
return enabled;
}
void log_ge_histogram(const GeListRecord &list) {
if (!ge_histogram_diag_enabled()) return;
std::vector<std::pair<std::uint32_t, std::uint64_t>> used;
for (std::uint32_t command = 0; command < list.histogram.size(); ++command) {
if (list.histogram[command] != 0u)
used.emplace_back(command, list.histogram[command]);
}
std::sort(used.begin(), used.end(), [](const auto &left, const auto &right) {
if (left.second != right.second) return left.second > right.second;
return left.first < right.first;
});
std::cerr << "[ge] list=" << psprecomp::hex32(list.guest_id)
<< " start=" << psprecomp::hex32(list.start_pc)
<< " endpc=" << psprecomp::hex32(list.pc)
<< " commands=" << list.executed_commands
<< " prim=" << list.primitive_commands
<< " state=" << static_cast<std::uint32_t>(list.state) << "\n";
for (const auto &[command, count] : used) {
std::cerr << "[ge] cmd=0x" << std::hex << std::setw(2) << std::setfill('0') << command
<< std::dec << " count=" << count;
if (const char *name = ge_command_name(command)) std::cerr << " name=" << name;
std::cerr << " last=" << psprecomp::hex32(ge_state.commands[command]) << "\n";
}
}
bool start_next_guest_callback(psprecomp::AllegrexContext &ctx, bool begin_chain) {
const std::int32_t uid = thread_table.current_uid;
const auto found = pending_guest_callbacks.find(uid);
if (found == pending_guest_callbacks.end() || found->second.empty()) return false;
auto &frames = async_return_frames[uid];
if (begin_chain) {
// A GE callback is interrupt-like, but must never re-enter another guest
// callback or a sub-interrupt already running on this thread.
if (!frames.empty()) return false;
frames.push_back(AsyncReturnFrame{AsyncReturnKind::GeCallbackChain, ctx});
} else if (frames.empty() || frames.back().kind != AsyncReturnKind::GeCallbackChain) {
return false;
}
const GuestCallbackInvocation invocation = found->second.front();
found->second.erase(found->second.begin());
if (found->second.empty()) pending_guest_callbacks.erase(found);
ctx.set_gpr(4, invocation.a0);
ctx.set_gpr(5, invocation.a1);
ctx.set_gpr(6, invocation.a2);
ctx.set_gpr(31, 0x00000004u);
ctx.pc = invocation.function;
if (std::getenv("PSPRECOMP_GE_DIAG") != nullptr || std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr) {
std::cerr << "[callback] start uid=" << uid
<< " function=" << psprecomp::hex32(invocation.function)
<< " a0=" << psprecomp::hex32(invocation.a0)
<< " a1=" << psprecomp::hex32(invocation.a1)
<< " a2=" << psprecomp::hex32(invocation.a2)
<< " remaining=" << (pending_guest_callbacks.contains(uid) ? pending_guest_callbacks[uid].size() : 0u)
<< "\n";
}
return true;
}
bool maybe_start_pending_guest_callback(psprecomp::AllegrexContext &ctx) {
const auto thread = thread_table.threads.find(thread_table.current_uid);
if (thread == thread_table.threads.end() || thread->second.state != ThreadState::Running) return false;
const auto frames = async_return_frames.find(thread_table.current_uid);
if (frames != async_return_frames.end() && !frames->second.empty()) return false;
return start_next_guest_callback(ctx, true);
}
void queue_guest_callback_chain(psprecomp::AllegrexContext &ctx,
const psprecomp::AllegrexContext &resume,
std::vector<GuestCallbackInvocation> callbacks) {
callbacks.erase(std::remove_if(callbacks.begin(), callbacks.end(), [](const GuestCallbackInvocation &item) {
return item.function == 0u;
}), callbacks.end());
ctx = resume;
if (callbacks.empty()) return;
auto &pending = pending_guest_callbacks[thread_table.current_uid];
pending.insert(pending.end(), callbacks.begin(), callbacks.end());
if (std::getenv("PSPRECOMP_GE_DIAG") != nullptr || std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr) {
std::cerr << "[callback] queued uid=" << thread_table.current_uid
<< " count=" << callbacks.size()
<< " total=" << pending.size()
<< " resume=" << psprecomp::hex32(resume.pc) << "\n";
}
}
void vcs_post_import_hook(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
if (ge_async_running()) {
ge_async_drain_completions();
if (!ge_async_check_fatal(runtime)) return;
}
(void)maybe_start_pending_guest_callback(ctx);
}
struct CollisionChainTraceFrame {
std::uint32_t target{};
std::uint32_t a0{};
std::uint32_t a1{};
std::uint32_t a2{};
std::uint32_t a3{};
std::uint32_t sp{};
std::uint32_t ra{};
bool traced{};
};
std::vector<CollisionChainTraceFrame> collision_chain_trace_stack;
struct CollisionRootProbeState {
std::uint64_t emitted{};
};
CollisionRootProbeState collision_root_probe_state;
struct PhysicsVcallCensusState {
std::uint64_t emitted{};
};
PhysicsVcallCensusState physics_vcall_census_state;
bool physics_vcall_census_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_PHYSICS_VCALL_CENSUS") != nullptr;
return enabled;
}
bool physics_vcall_census_in_window() {
if (!physics_vcall_census_enabled()) return false;
static const std::uint64_t start =
parse_environment_u64("PSPRECOMP_PHYSICS_VCALL_CENSUS_START_VBLANK");
static const std::uint64_t end =
parse_environment_u64("PSPRECOMP_PHYSICS_VCALL_CENSUS_END_VBLANK", start);
return display_vblank_index >= start && display_vblank_index <= end;
}
bool physics_vcall_census_can_emit() {
static const std::uint64_t limit =
parse_environment_u64("PSPRECOMP_PHYSICS_VCALL_CENSUS_MAX", 2048u);
return physics_vcall_census_in_window() &&
(limit == 0u || physics_vcall_census_state.emitted < limit);
}
bool collision_root_probe_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_COLLISION_ROOT_PROBE") != nullptr;
return enabled;
}
bool collision_root_probe_in_window() {
if (!collision_root_probe_enabled()) return false;
static const std::uint64_t start =
parse_environment_u64("PSPRECOMP_COLLISION_ROOT_PROBE_START_VBLANK");
static const std::uint64_t end =
parse_environment_u64("PSPRECOMP_COLLISION_ROOT_PROBE_END_VBLANK", start);
return display_vblank_index >= start && display_vblank_index <= end;
}
std::uint32_t collision_root_probe_pc_start() {
static const std::uint32_t value = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_ROOT_PROBE_PC", 0x0899F9ECu));
return value;
}
std::uint32_t collision_root_probe_pc_end() {
static const std::uint32_t value = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_ROOT_PROBE_PC_END",
collision_root_probe_pc_start()));
return value;
}
bool collision_root_probe_matches(std::uint32_t pc) {
return pc >= collision_root_probe_pc_start() && pc <= collision_root_probe_pc_end();
}
bool collision_probe_a0_matches(std::uint32_t a0) {
static const std::uint32_t wanted = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_PROBE_A0", 0u));
return wanted == 0u || a0 == wanted;
}
bool collision_root_probe_can_emit() {
static const std::uint64_t limit =
parse_environment_u64("PSPRECOMP_COLLISION_ROOT_PROBE_MAX", 256u);
return collision_root_probe_in_window() &&
(limit == 0u || collision_root_probe_state.emitted < limit);
}
std::string collision_probe_object_words(psprecomp::Runtime &rt, std::uint32_t address) {
if (!rt.memory().contains(address, 0x80u)) return "invalid";
constexpr std::array<std::uint32_t, 16> offsets{
0x00u, 0x04u, 0x08u, 0x0Cu,
0x30u, 0x34u, 0x38u, 0x3Cu,
0x48u, 0x50u, 0x54u, 0x58u,
0x70u, 0x74u, 0x78u, 0x7Cu};
std::ostringstream out;
bool first = true;
for (const std::uint32_t offset : offsets) {
if (!first) out << ',';
first = false;
const std::uint32_t bits = rt.memory().load32(address + offset);
out << std::hex << offset << ':' << psprecomp::hex32(bits);
}
return out.str();
}
std::string collision_probe_pointer70_words(psprecomp::Runtime &rt, std::uint32_t address) {
if (!rt.memory().contains(address + 0x70u, 4u)) return "invalid-a0";
const std::uint32_t pointer = rt.memory().load32(address + 0x70u);
std::ostringstream out;
out << "ptr=" << psprecomp::hex32(pointer) << ';';
if (!rt.memory().contains(pointer, 0x60u)) { out << "invalid"; return out.str(); }
constexpr std::array<std::uint32_t, 12> offsets{
0x00u, 0x10u, 0x20u, 0x30u,
0x40u, 0x48u, 0x4Cu, 0x50u,
0x54u, 0x58u, 0x5Cu, 0x60u};
bool first = true;
for (const std::uint32_t offset : offsets) {
if (!first) out << ',';
first = false;
if (!rt.memory().contains(pointer + offset, 4u)) { out << std::hex << offset << ":invalid"; continue; }
out << std::hex << offset << ':' << psprecomp::hex32(rt.memory().load32(pointer + offset));
}
return out.str();
}
bool collision_chain_trace_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_COLLISION_CHAIN_TRACE") != nullptr;
return enabled;
}
bool collision_chain_trace_in_window() {
if (!collision_chain_trace_enabled()) return false;
static const std::uint64_t start =
parse_environment_u64("PSPRECOMP_COLLISION_CHAIN_TRACE_START_VBLANK");
static const std::uint64_t end =
parse_environment_u64("PSPRECOMP_COLLISION_CHAIN_TRACE_END_VBLANK", start);
return display_vblank_index >= start && display_vblank_index <= end;
}
std::uint32_t collision_chain_trace_root_start() {
static const std::uint32_t value = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_CHAIN_TRACE_ROOT_START", 0x0899F9ECu));
return value;
}
std::uint32_t collision_chain_trace_root_end() {
static const std::uint32_t value = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_CHAIN_TRACE_ROOT_END",
collision_chain_trace_root_start()));
return value;
}
bool collision_point_trace_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_COLLISION_POINT_TRACE") != nullptr;
return enabled;
}
bool dispatch_collision_diagnostics_enabled() {
return collision_root_probe_enabled() || collision_chain_trace_enabled();
}
bool chained_call_collision_diagnostics_enabled() {
return dispatch_collision_diagnostics_enabled() || physics_vcall_census_enabled();
}
void refresh_vcs_post_dispatch_hook() {
const bool frozen_clock_guard_needed =
execution_clock_dispatch_interval == 0u && frozen_clock_guard_limit != 0u;
const bool needed = save_repro_trace_enabled || save_repro_capture_requested || !deferred_io_resumes.empty() ||
dispatch_collision_diagnostics_enabled() || frozen_clock_guard_needed;
psprecomp::set_runtime_post_dispatch_hook(needed ? &vcs_post_dispatch_hook : nullptr);
}
std::string collision_trace_words(psprecomp::Runtime &rt, std::uint32_t address) {
if (!rt.memory().contains(address, 16u)) return "invalid";
std::ostringstream out;
out << psprecomp::hex32(rt.memory().load32(address + 0u)) << ','
<< psprecomp::hex32(rt.memory().load32(address + 4u)) << ','
<< psprecomp::hex32(rt.memory().load32(address + 8u)) << ','
<< psprecomp::hex32(rt.memory().load32(address + 12u));
return out.str();
}
std::string collision_trace_colpoint(psprecomp::Runtime &rt, std::uint32_t address) {
if (!rt.memory().contains(address, 32u)) return "invalid";
const auto as_float = [&](std::uint32_t offset) {
return std::bit_cast<float>(rt.memory().load32(address + offset));
};
std::ostringstream out;
out << "point=(" << as_float(0u) << ',' << as_float(4u) << ',' << as_float(8u) << ')'
<< " depth=" << as_float(12u)
<< " normal=(" << as_float(16u) << ',' << as_float(20u) << ',' << as_float(24u) << ')'
<< " surfaces=" << psprecomp::hex32(rt.memory().load32(address + 28u));
return out.str();
}
void vcs_pre_chained_call_hook(psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx,
std::uint32_t target_pc, std::uint32_t native_depth) {
const std::uint32_t root_start = collision_chain_trace_root_start();
const std::uint32_t root_end = collision_chain_trace_root_end();
const bool inherited = !collision_chain_trace_stack.empty() &&
collision_chain_trace_stack.back().traced;
const std::uint32_t outer_pc = psprecomp::runtime_dispatch_pc();
const bool root = collision_chain_trace_in_window() && collision_probe_a0_matches(ctx.gpr[4]) &&
((target_pc >= root_start && target_pc <= root_end) ||
(outer_pc >= root_start && outer_pc <= root_end));
const bool traced = inherited || root;
collision_chain_trace_stack.push_back(CollisionChainTraceFrame{
target_pc, ctx.gpr[4], ctx.gpr[5], ctx.gpr[6], ctx.gpr[7], ctx.gpr[29], ctx.gpr[31], traced});
if (physics_vcall_census_can_emit() && native_depth == 0u) {
static const std::uint32_t wanted_outer = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_PHYSICS_VCALL_CENSUS_OUTER_PC", 0x08898F70u));
static const std::uint32_t wanted_ra = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_PHYSICS_VCALL_CENSUS_RA", 0x0889912Cu));
if (outer_pc == wanted_outer && (wanted_ra == 0u || ctx.gpr[31] == wanted_ra)) {
++physics_vcall_census_state.emitted;
std::cerr << "[physics-vcall] vblank=" << display_vblank_index
<< " target=" << psprecomp::hex32(target_pc)
<< " outer=" << psprecomp::hex32(outer_pc)
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a2=" << psprecomp::hex32(ctx.gpr[6])
<< " a3=" << psprecomp::hex32(ctx.gpr[7])
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " a0_words=" << collision_probe_object_words(rt, ctx.gpr[4])
<< "\n";
}
}
if (collision_root_probe_can_emit() && collision_root_probe_matches(target_pc) && collision_probe_a0_matches(ctx.gpr[4])) {
++collision_root_probe_state.emitted;
std::cerr << "[collision-root-enter] vblank=" << display_vblank_index
<< " native_depth=" << native_depth
<< " target=" << psprecomp::hex32(target_pc)
<< " outer=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a2=" << psprecomp::hex32(ctx.gpr[6])
<< " a3=" << psprecomp::hex32(ctx.gpr[7])
<< " sp=" << psprecomp::hex32(ctx.gpr[29])
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " f12=" << ctx.fpr[12]
<< " f13=" << ctx.fpr[13]
<< " f14=" << ctx.fpr[14]
<< " a0_words=" << collision_probe_object_words(rt, ctx.gpr[4])
<< " a0_ptr70=" << collision_probe_pointer70_words(rt, ctx.gpr[4])
<< " a1_words=" << collision_trace_words(rt, ctx.gpr[5])
<< "\n";
}
if (!traced) return;
std::cerr << "[collision-chain-enter] vblank=" << display_vblank_index
<< " level=" << (collision_chain_trace_stack.size() - 1u)
<< " native_depth=" << native_depth
<< " target=" << psprecomp::hex32(target_pc)
<< " outer=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a2=" << psprecomp::hex32(ctx.gpr[6])
<< " a3=" << psprecomp::hex32(ctx.gpr[7])
<< " sp=" << psprecomp::hex32(ctx.gpr[29])
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " f12=" << ctx.fpr[12]
<< " f13=" << ctx.fpr[13]
<< " f14=" << ctx.fpr[14]
<< " a1_words=" << collision_trace_words(rt, ctx.gpr[5])
<< "\n";
}
void vcs_pre_dispatch_hook(psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx,
std::uint32_t dispatch_pc, std::int32_t dispatch_thread_uid) {
if (collision_root_probe_can_emit() && collision_root_probe_matches(dispatch_pc) && collision_probe_a0_matches(ctx.gpr[4])) {
++collision_root_probe_state.emitted;
std::cerr << "[collision-root-outer-enter] vblank=" << display_vblank_index
<< " uid=" << dispatch_thread_uid
<< " pc=" << psprecomp::hex32(dispatch_pc)
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a2=" << psprecomp::hex32(ctx.gpr[6])
<< " a3=" << psprecomp::hex32(ctx.gpr[7])
<< " sp=" << psprecomp::hex32(ctx.gpr[29])
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " f12=" << ctx.fpr[12]
<< " f13=" << ctx.fpr[13]
<< " f14=" << ctx.fpr[14]
<< " a0_words=" << collision_probe_object_words(rt, ctx.gpr[4])
<< " a0_ptr70=" << collision_probe_pointer70_words(rt, ctx.gpr[4])
<< " a1_words=" << collision_trace_words(rt, ctx.gpr[5])
<< "\n";
}
if (!collision_chain_trace_in_window()) return;
const std::uint32_t root_start = collision_chain_trace_root_start();
const std::uint32_t root_end = collision_chain_trace_root_end();
if (dispatch_pc < root_start || dispatch_pc > root_end) return;
std::cerr << "[collision-outer-enter] vblank=" << display_vblank_index
<< " uid=" << dispatch_thread_uid
<< " pc=" << psprecomp::hex32(dispatch_pc)
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a2=" << psprecomp::hex32(ctx.gpr[6])
<< " a3=" << psprecomp::hex32(ctx.gpr[7])
<< " sp=" << psprecomp::hex32(ctx.gpr[29])
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " f12=" << ctx.fpr[12]
<< " f13=" << ctx.fpr[13]
<< " f14=" << ctx.fpr[14]
<< " a1_words=" << collision_trace_words(rt, ctx.gpr[5])
<< "\n";
}
void vcs_post_chained_call_hook(psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx,
std::uint32_t target_pc, std::uint32_t native_depth) {
if (collision_chain_trace_stack.empty()) return;
const CollisionChainTraceFrame frame = collision_chain_trace_stack.back();
collision_chain_trace_stack.pop_back();
if (collision_root_probe_in_window() && collision_root_probe_matches(frame.target) && collision_probe_a0_matches(frame.a0)) {
std::cerr << "[collision-root-exit] vblank=" << display_vblank_index
<< " native_depth=" << native_depth
<< " target=" << psprecomp::hex32(frame.target)
<< " next=" << psprecomp::hex32(ctx.pc)
<< " v0=" << psprecomp::hex32(ctx.gpr[2])
<< " v1=" << psprecomp::hex32(ctx.gpr[3])
<< " f0=" << ctx.fpr[0]
<< " saved_a0=" << psprecomp::hex32(frame.a0)
<< " a0_words=" << collision_probe_object_words(rt, frame.a0)
<< " a0_ptr70=" << collision_probe_pointer70_words(rt, frame.a0)
<< " saved_a1=" << psprecomp::hex32(frame.a1)
<< " a1_words=" << collision_trace_words(rt, frame.a1)
<< "\n";
}
if (frame.traced && frame.target == 0x0893084Cu &&
collision_point_trace_enabled()) {
std::cerr << "[collision-point] vblank=" << display_vblank_index
<< " entity=" << psprecomp::hex32(frame.a0)
<< " other=" << psprecomp::hex32(frame.a1)
<< " count=" << ctx.gpr[2]
<< " col0=" << collision_trace_colpoint(rt, frame.a2)
<< "\n";
}
if (!frame.traced) return;
std::cerr << "[collision-chain-exit] vblank=" << display_vblank_index
<< " level=" << collision_chain_trace_stack.size()
<< " native_depth=" << native_depth
<< " target=" << psprecomp::hex32(target_pc)
<< " next=" << psprecomp::hex32(ctx.pc)
<< " v0=" << psprecomp::hex32(ctx.gpr[2])
<< " v1=" << psprecomp::hex32(ctx.gpr[3])
<< " f0=" << ctx.fpr[0]
<< " saved_a1=" << psprecomp::hex32(frame.a1)
<< " a1_words=" << collision_trace_words(rt, frame.a1)
<< "\n";
}
void vcs_post_dispatch_hook(psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx,
std::uint32_t dispatch_pc, std::int32_t dispatch_thread_uid) {
if (save_repro_capture_requested) {
std::string error;
if (save_repro_write_checkpoint(rt, ctx, error)) {
save_repro_capture_requested = false;
save_repro_capture_error_reported = false;
refresh_vcs_post_dispatch_hook();
} else if (!save_repro_capture_error_reported) {
save_repro_capture_error_reported = true;
runtime_log_line("SAVE_REPRO checkpoint=waiting error=" + error);
std::cerr << "\n[SAVE-REPRO] Captura ainda aguardando boundary valido: " << error << "\n";
}
}
save_repro_trace_dispatch(dispatch_pc, dispatch_thread_uid, ctx);
if (collision_root_probe_in_window() && collision_root_probe_matches(dispatch_pc)) {
std::cerr << "[collision-root-outer-exit] vblank=" << display_vblank_index
<< " uid=" << dispatch_thread_uid
<< " pc=" << psprecomp::hex32(dispatch_pc)
<< " next=" << psprecomp::hex32(ctx.pc)
<< " v0=" << psprecomp::hex32(ctx.gpr[2])
<< " v1=" << psprecomp::hex32(ctx.gpr[3])
<< " f0=" << ctx.fpr[0]
<< " a0=" << psprecomp::hex32(ctx.gpr[4])
<< " a0_words=" << collision_probe_object_words(rt, ctx.gpr[4])
<< " a0_ptr70=" << collision_probe_pointer70_words(rt, ctx.gpr[4])
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a1_words=" << collision_trace_words(rt, ctx.gpr[5])
<< "\n";
}
if (collision_chain_trace_in_window()) {
const std::uint32_t root_start = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_CHAIN_TRACE_ROOT_START", 0x0899F9ECu));
const std::uint32_t root_end = static_cast<std::uint32_t>(
parse_environment_u64("PSPRECOMP_COLLISION_CHAIN_TRACE_ROOT_END", root_start));
if (dispatch_pc >= root_start && dispatch_pc <= root_end) {
std::cerr << "[collision-outer-exit] vblank=" << display_vblank_index
<< " uid=" << dispatch_thread_uid
<< " pc=" << psprecomp::hex32(dispatch_pc)
<< " next=" << psprecomp::hex32(ctx.pc)
<< " v0=" << psprecomp::hex32(ctx.gpr[2])
<< " v1=" << psprecomp::hex32(ctx.gpr[3])
<< " f0=" << ctx.fpr[0]
<< " a1=" << psprecomp::hex32(ctx.gpr[5])
<< " a1_words=" << collision_trace_words(rt, ctx.gpr[5])
<< "\n";
}
}
if (execution_clock_dispatch_interval == 0u && frozen_clock_guard_limit != 0u) {
const bool has_delayed_thread = std::any_of(
thread_table.threads.begin(), thread_table.threads.end(), [](const auto &item) {
return item.second.state == ThreadState::Delayed;
});
if (display_vblank_index != frozen_clock_guard_vblank || !has_delayed_thread) {
frozen_clock_guard_vblank = display_vblank_index;
frozen_clock_guard_dispatches = 0u;
} else if (++frozen_clock_guard_dispatches >= frozen_clock_guard_limit) {
std::cerr << "[frozen-clock-guard] vblank=" << display_vblank_index
<< " dispatches=" << frozen_clock_guard_dispatches
<< " pc=" << psprecomp::hex32(dispatch_pc)
<< " current_uid=" << thread_table.current_uid
<< " delayed=";
bool first = true;
std::size_t reported = 0u;
for (const auto &[uid, thread] : thread_table.threads) {
if (thread.state != ThreadState::Delayed) continue;
if (!first) std::cerr << ',';
std::cerr << uid << ':' << thread.name << "@" << thread.delay_until_us;
first = false;
if (++reported == 8u) break;
}
std::cerr << " virtual_time_us=" << virtual_time_us << "\n";
rt.stop(
"Execution-driven PSP clock is disabled while delayed threads are pending. "
"Remove PSPRECOMP_TIME_TICK_DISPATCHES=0 (VCSNative default is 4096), or set "
"PSPRECOMP_FROZEN_CLOCK_GUARD_DISPATCHES=0 only for an isolated ordering test.");
return;
}
}
if (deferred_io_resumes.empty()) return;
std::vector<std::int32_t> completed;
completed.reserve(deferred_io_resumes.size());
for (auto &[worker_uid, barrier] : deferred_io_resumes) {
++barrier.observed_dispatches;
if (dispatch_thread_uid == barrier.handoff_uid && dispatch_pc == barrier.release_pc)
completed.push_back(worker_uid);
}
if (completed.empty()) return;
for (const std::int32_t worker_uid : completed) {
const auto barrier = deferred_io_resumes.find(worker_uid);
const auto worker = thread_table.threads.find(worker_uid);
if (barrier == deferred_io_resumes.end()) continue;
if (worker != thread_table.threads.end() && worker->second.state == ThreadState::IoDeferred) {
if (std::getenv("PSPRECOMP_UMD_STREAM_DIAG") != nullptr ||
std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr) {
std::cerr << "[io-handoff] release worker=" << worker_uid
<< " handoff_uid=" << barrier->second.handoff_uid
<< " handoff_pc=" << psprecomp::hex32(barrier->second.handoff_pc)
<< " release_pc=" << psprecomp::hex32(barrier->second.release_pc)
<< " observed=" << barrier->second.observed_dispatches
<< " resume=" << psprecomp::hex32(worker->second.suspended_context.pc)
<< "\n";
}
if (worker->second.externally_suspended) {
worker->second.state = ThreadState::Ready;
} else {
enqueue_continuation(worker_uid, worker->second.suspended_context);
}
}
deferred_io_resumes.erase(barrier);
}
refresh_vcs_post_dispatch_hook();
// The completed read worker normally has higher PSP priority than the
// submitter. The return dispatch is now finished, so this boundary is the
// first safe point at which the real kernel could schedule it again.
const auto current = thread_table.threads.find(thread_table.current_uid);
const auto best = best_ready_thread();
if (current == thread_table.threads.end() || current->second.state != ThreadState::Running ||
best == thread_table.continuations.end() ||
thread_priority(best->uid) >= thread_priority(thread_table.current_uid)) return;
enqueue_continuation(thread_table.current_uid, ctx);
(void)activate_next_thread(ctx, "io-handoff-complete");
}
bool append_ge_callback(const GeListRecord &list, bool signal, std::uint16_t token,
std::uint32_t next_pc, std::vector<GuestCallbackInvocation> &callbacks) {
const auto found = ge_callback_table.callbacks.find(list.callback_id);
if (found == ge_callback_table.callbacks.end()) return false;
const GeCallbackRecord &registered = found->second;
const std::uint32_t function = signal ? registered.signal_function : registered.finish_function;
const std::uint32_t argument = signal ? registered.signal_argument : registered.finish_argument;
if (function == 0u) return false;
callbacks.push_back(GuestCallbackInvocation{
function,
token,
argument,
compiled_sdk_version <= 0x02000010u ? 0u : next_pc,
});
return true;
}
// Wall-clock split between translated guest execution and software
// rasterization. Reported per vblank under PSPRECOMP_FRAME_TIME_DIAG so the
// frame budget can be attributed instead of guessed at.
struct FrameTimeStats {
std::chrono::steady_clock::duration ge_time{};
// Frame assembly, swapchain blit, present and any fence wait they imply.
// It used to be folded into cpu_us, where it was indistinguishable from
// recompiled MIPS execution -- the two are attacked in completely different
// ways, so the split has to be visible.
std::chrono::steady_clock::duration present_time{};
std::chrono::steady_clock::time_point last_vblank{};
std::uint64_t ge_calls{};
std::uint64_t last_guest_time{};
bool started{};
};
FrameTimeStats frame_time_stats;
// Presentation census, reported at shutdown. See the call site for why.
std::uint64_t swapchain_presents{};
std::uint64_t software_presents{};
std::uint64_t software_presents_after_gpu{};
bool gpu_has_presented{};
// GE command words interpreted since the last vblank report. list_us without
// it cannot say whether display-list execution is slow per command or simply
// has an enormous number of them, and those have opposite fixes.
std::uint64_t ge_commands_this_vblank{};
struct RealtimeSpeedSample {
double host_us_per_vblank{};
double guest_us_per_vblank{};
double simulated_vblank_hz{};
double emulation_speed_percent{};
};
RealtimeSpeedSample calculate_realtime_speed_sample(std::uint64_t host_us,
std::uint64_t guest_us,
std::uint64_t vblanks) {
if (host_us == 0u || vblanks == 0u) return {};
const double host = static_cast<double>(host_us);
const double guest = static_cast<double>(guest_us);
const double frames = static_cast<double>(vblanks);
return RealtimeSpeedSample{
host / frames,
guest / frames,
frames * 1'000'000.0 / host,
guest * 100.0 / host,
};
}
struct RealtimeSpeedStats {
std::chrono::steady_clock::time_point host_start{};
std::uint64_t guest_start{};
std::uint64_t vblank_start{};
bool started{};
};
RealtimeSpeedStats realtime_speed_stats;
bool realtime_speed_diag_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_REALTIME_SPEED_DIAG") != nullptr;
return enabled;
}
std::uint64_t realtime_speed_diag_interval() {
static const std::uint64_t interval = std::max<std::uint64_t>(1u,
parse_environment_u64("PSPRECOMP_REALTIME_SPEED_INTERVAL", 120u));
return interval;
}
std::uint64_t gpu_dump_vblank() noexcept {
static const std::uint64_t value = [] {
const char *text = std::getenv("PSPRECOMP_GE_GPU_DUMP_VBLANK");
if (text == nullptr || *text == '\0') return std::uint64_t{0};
char *end = nullptr;
const unsigned long long parsed = std::strtoull(text, &end, 10);
return end != text && *end == '\0' ? static_cast<std::uint64_t>(parsed) : 0u;
}();
return value;
}
bool gpu_color_preview_enabled() noexcept {
static const bool enabled = [] {
const char *text = std::getenv("PSPRECOMP_GE_GPU_COLOR_PREVIEW");
return text != nullptr && *text != '\0' && std::strcmp(text, "0") != 0;
}();
return enabled;
}
void dump_gpu_internal_frame_if_requested(std::uint64_t vblank) {
static bool dumped = false;
const std::uint64_t requested = gpu_dump_vblank();
if (dumped || requested == 0u || vblank < requested) return;
const GeGpuBackendReport report = ge_gpu_backend_report();
if (report.game_frame_vblank == 0u || report.offscreen_width == 0u ||
report.offscreen_height == 0u || report.game_frame_readback_bytes == 0u) return;
std::vector<std::byte> rgba(report.game_frame_readback_bytes);
if (!ge_gpu_backend_copy_game_frame_rgba(rgba)) return;
std::filesystem::path output_path;
if (const char *path = std::getenv("PSPRECOMP_GE_GPU_DUMP_PATH");
path != nullptr && *path != '\0') {
output_path = path;
} else {
std::ostringstream name;
name << "VCSNative_internal_" << report.offscreen_width << 'x'
<< report.offscreen_height << "_vblank_" << std::setw(6)
<< std::setfill('0') << report.game_frame_vblank << ".ppm";
output_path = name.str();
}
std::ofstream output(output_path, std::ios::binary | std::ios::trunc);
if (!output) return;
output << "P6\n" << report.offscreen_width << ' ' << report.offscreen_height << "\n255\n";
for (std::size_t offset = 0u; offset + 3u < rgba.size(); offset += 4u) {
const char rgb[3]{
static_cast<char>(rgba[offset + 0u]),
static_cast<char>(rgba[offset + 1u]),
static_cast<char>(rgba[offset + 2u]),
};
output.write(rgb, sizeof(rgb));
}
if (output.good()) {
dumped = true;
std::cerr << "[gpu-internal-frame] vblank=" << report.game_frame_vblank
<< " resolution=" << report.offscreen_width << 'x' << report.offscreen_height
<< " changed_pixels=" << report.game_frame_changed_pixels
<< " checksum=" << report.game_frame_checksum
<< " depth_attachment=" << report.depth_attachment_active
<< " depth_variants=" << report.depth_pipeline_variants_created
<< " depth_tested_draws=" << report.depth_tested_game_draw_calls
<< " depth_writing_draws=" << report.depth_writing_game_draw_calls
<< " alpha_shader=" << report.alpha_test_shader_active
<< " alpha_tested_draws=" << report.alpha_tested_game_draw_calls
<< " blend_active=" << report.standard_alpha_blend_pipeline_active
<< " observed_blends_active=" << report.observed_blend_modes_pipeline_active
<< " blend_variants=" << report.blend_pipeline_variants_created
<< " standard_blended_draws=" << report.standard_alpha_blended_game_draw_calls
<< " fixed_replace_blended_draws=" << report.fixed_replace_blended_game_draw_calls
<< " additive_blended_draws=" << report.additive_blended_game_draw_calls
<< " unsupported_blend_draws=" << report.unsupported_blend_game_draw_calls
<< " texture_function_active=" << report.observed_texture_function_shader_active
<< " complete_texture_functions=" << report.complete_texture_function_shader_active
<< " modulate_texture_draws=" << report.modulate_texture_game_draw_calls
<< " decal_texture_draws=" << report.decal_texture_game_draw_calls
<< " blend_texture_draws=" << report.blend_texture_game_draw_calls
<< " replace_texture_draws=" << report.replace_texture_game_draw_calls
<< " add_texture_draws=" << report.add_texture_game_draw_calls
<< " double_color_texture_draws=" << report.double_color_texture_game_draw_calls
<< " unsupported_texture_function_draws=" << report.unsupported_texture_function_game_draw_calls
<< " color_mask_active=" << report.color_write_mask_pipeline_active
<< " color_mask_variants=" << report.color_mask_pipeline_variants_created
<< " masked_color_draws=" << report.masked_color_game_draw_calls
<< " unsupported_partial_color_masks=" << report.unsupported_partial_color_mask_game_draw_calls
<< " base_texture_formats=" << report.base_texture_formats_active
<< " decoded_direct16=" << report.decoded_direct16_textures
<< " decoded_direct32=" << report.decoded_direct32_textures
<< " decoded_indexed16=" << report.decoded_indexed16_textures
<< " decoded_indexed32=" << report.decoded_indexed32_textures
<< " compressed_texture_formats=" << report.compressed_texture_formats_active
<< " decoded_dxt1=" << report.decoded_dxt1_textures
<< " decoded_dxt3=" << report.decoded_dxt3_textures
<< " decoded_dxt5=" << report.decoded_dxt5_textures
<< " mipmap_state=" << report.mipmap_state_active
<< " mipmapped_draws=" << report.mipmapped_game_draw_calls
<< " mip_linear_draws=" << report.mip_linear_game_draw_calls
<< " fixed_lod_draws=" << report.fixed_lod_game_draw_calls
<< " selected_nonzero_mip_draws=" << report.selected_nonzero_mip_game_draw_calls
<< " fog_shader=" << report.fog_shader_active
<< " fogged_draws=" << report.fogged_game_draw_calls
<< " path=" << output_path.string() << "\n";
}
}
void report_realtime_speed_if_requested() {
if (!realtime_speed_diag_enabled()) return;
const auto now = std::chrono::steady_clock::now();
if (!realtime_speed_stats.started) {
realtime_speed_stats.host_start = now;
realtime_speed_stats.guest_start = virtual_time_us;
realtime_speed_stats.vblank_start = display_vblank_index;
realtime_speed_stats.started = true;
return;
}
const std::uint64_t vblanks = display_vblank_index - realtime_speed_stats.vblank_start;
if (vblanks < realtime_speed_diag_interval()) return;
const std::uint64_t host_us = static_cast<std::uint64_t>(
std::max<std::int64_t>(1, std::chrono::duration_cast<std::chrono::microseconds>(
now - realtime_speed_stats.host_start).count()));
const std::uint64_t guest_us = virtual_time_us - realtime_speed_stats.guest_start;
const RealtimeSpeedSample sample = calculate_realtime_speed_sample(host_us, guest_us, vblanks);
const char *diagnosis = "normal";
// A correct PSP clock should average close to one 59.94 Hz period per
// logical vblank. If that is correct but wall-clock throughput is lower,
// the observed slow motion is performance-bound rather than a doubled tick.
if (sample.guest_us_per_vblank < 15'000.0 || sample.guest_us_per_vblank > 18'500.0) {
diagnosis = "guest-clock-mismatch";
} else if (sample.emulation_speed_percent < 90.0) {
diagnosis = "host-cannot-keep-up-slow-motion";
} else if (sample.emulation_speed_percent > 110.0) {
diagnosis = "running-faster-than-realtime";
}
// Composed and written once: see write_diag_line. The local stream also
// keeps std::fixed/setprecision off std::cerr itself, which used to leak
// into every later diagnostic on the stream.
std::ostringstream speed_line;
speed_line << std::fixed << std::setprecision(3)
<< "[realtime-speed] vblank=" << display_vblank_index
<< " window_vblanks=" << vblanks
<< " host_us=" << host_us
<< " guest_us=" << guest_us
<< " host_us_per_vblank=" << sample.host_us_per_vblank
<< " guest_us_per_vblank=" << sample.guest_us_per_vblank
<< " simulated_vblank_hz=" << sample.simulated_vblank_hz
<< " emulation_speed_percent=" << sample.emulation_speed_percent
<< " diagnosis=" << diagnosis << "\n";
const std::string speed_text = speed_line.str();
std::cerr.write(speed_text.data(), static_cast<std::streamsize>(speed_text.size()));
realtime_speed_stats.host_start = now;
realtime_speed_stats.guest_start = virtual_time_us;
realtime_speed_stats.vblank_start = display_vblank_index;
}
bool frame_time_diag_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_FRAME_TIME_DIAG") != nullptr;
return enabled;
}
bool perf_telemetry_enabled() {
static const bool enabled = [] {
const char *text = std::getenv("PSPRECOMP_PERF_TELEMETRY");
return text != nullptr && *text != '\0' && std::strcmp(text, "0") != 0 &&
std::strcmp(text, "false") != 0 && std::strcmp(text, "FALSE") != 0;
}();
return enabled;
}
std::uint64_t perf_telemetry_interval() {
static const std::uint64_t value = std::max<std::uint64_t>(10u,
parse_environment_u64("PSPRECOMP_PERF_TELEMETRY_INTERVAL", 60u));
return value;
}
bool perf_timing_enabled() { return frame_time_diag_enabled() || perf_telemetry_enabled(); }
struct PerfTelemetryAccumulator {
std::uint64_t frames{};
std::uint64_t frame_us_sum{}, frame_us_min{UINT64_MAX}, frame_us_max{};
std::uint64_t guest_cpu_us_sum{}, ge_us_sum{}, ge_wait_us_sum{}, present_us_sum{}, io_us_sum{};
std::uint64_t ge_calls_sum{};
GeGpuBackendReport previous_report{};
bool report_started{};
};
PerfTelemetryAccumulator perf_telemetry;
std::uint32_t guest_hotspot_perf_windows{};
void report_guest_hotspot_window(std::uint64_t vblank) {
const psprecomp::GuestHotspotSnapshot snap = psprecomp::consume_guest_hotspot_profile(16u, 24u);
std::ostringstream summary;
summary << "GUESTHOT summary vblank=" << vblank
<< " stride=" << snap.sample_stride
<< " unit_calls=" << snap.total_unit_calls
<< " samples=" << snap.total_samples
<< " units=" << snap.units.size()
<< " pcs=" << snap.pcs.size();
runtime_log_line(summary.str());
std::size_t rank = 0u;
for (const auto &e : snap.units) {
const std::uint64_t avg_ns = e.samples ? e.inclusive_sample_ns / e.samples : 0u;
const std::uint64_t est_us = (e.inclusive_sample_ns * snap.sample_stride) / 1000u;
std::ostringstream line;
line << "GUESTHOT_UNIT rank=" << (++rank)
<< " unit=" << std::setw(4) << std::setfill('0') << e.unit << std::setfill(' ')
<< " calls=" << e.calls
<< " samples=" << e.samples
<< " avg_inclusive_ns=" << avg_ns
<< " est_inclusive_us=" << est_us;
runtime_log_line(line.str());
}
rank = 0u;
for (const auto &e : snap.pcs) {
const std::uint64_t avg_ns = e.samples ? e.inclusive_sample_ns / e.samples : 0u;
const std::uint64_t est_us = (e.inclusive_sample_ns * snap.sample_stride) / 1000u;
std::ostringstream line;
line << "GUESTHOT_PC rank=" << (++rank)
<< " unit=" << std::setw(4) << std::setfill('0') << e.unit << std::setfill(' ')
<< " pc=" << psprecomp::hex32(e.pc)
<< " samples=" << e.samples
<< " avg_inclusive_ns=" << avg_ns
<< " est_inclusive_us=" << est_us;
runtime_log_line(line.str());
}
}
bool ge_phase_diag_line_enabled() {
static const bool enabled = std::getenv("PSPRECOMP_GE_PHASE_DIAG") != nullptr;
return enabled;
}
bool gpu_timing_diag_line_enabled() {
static const bool enabled = [] {
const char *text = std::getenv("PSPRECOMP_GPU_TIMING_DIAG");
return text != nullptr && *text != '\0' && std::strcmp(text, "0") != 0;
}();
// PERF TELEMETRY uses the same backend timing counters, but intentionally
// suppresses the legacy per-vblank stderr line: logging the profiler every
// frame was itself measured at >1 ms/frame on Windows.
return enabled && !perf_telemetry_enabled();
}
struct GpuTimingCensus {
GeGpuBackendReport previous{};
bool started{};
};
GpuTimingCensus gpu_timing_census;
// std::cerr is unit-buffered: every operator<< flushes, which on Windows is a
// separate WriteFile on the redirected handle. A per-vblank diagnostic line is
// a dozen of those, and measured against each other two runs showed one such
// line costing over a millisecond per vblank -- the profiler was reporting a
// frame budget the game does not actually have, and the stutter it produced was
// visible while playing. Compose the line first, emit it with one write.
void write_diag_line(const std::ostringstream &line) {
const std::string text = line.str();
std::cerr.write(text.data(), static_cast<std::streamsize>(text.size()));
}
// Paces the vblank loop against the guest clock.
//
// There was no frame limiter anywhere in the host: the loop ran vblanks as fast
// as the machine allowed. A light scene therefore played at 200-400% speed --
// the game "starting accelerated" -- while a heavy one fell to 46%, and the two
// together read as wildly inconsistent speed rather than as a slow section.
// VCS derives its logic from the vblank clock (see the previous handoff's §7),
// so pacing vblanks is what makes wall-clock speed match the guest's own idea
// of time.
//
// Falling behind is not repaid: catching up by running the next vblanks early
// would turn a slow section into a fast-forward. The anchor is reset instead,
// so a slow stretch is simply slow and normal speed resumes after it.
//
// PSPRECOMP_FRAME_LIMIT=0 disables it, which is what performance measurement
// needs -- with the limiter on, frame_us just reads back the target period.
void limit_frame_rate() {
static const bool enabled = [] {
const char *text = std::getenv("PSPRECOMP_FRAME_LIMIT");
return text == nullptr || (*text != '\0' && std::strcmp(text, "0") != 0);
}();
if (!enabled) return;
static bool anchored = false;
static std::chrono::steady_clock::time_point wall_anchor{};
static std::uint64_t guest_anchor = 0u;
if (!anchored) {
anchored = true;
wall_anchor = std::chrono::steady_clock::now();
guest_anchor = virtual_time_us;
return;
}
const auto target = wall_anchor + std::chrono::microseconds(virtual_time_us - guest_anchor);
const auto now = std::chrono::steady_clock::now();
if (now >= target) {
// The selected rate is a ceiling, not a promise that this renderer can
// finish inside the budget. When it misses, advance guest time by the
// wall-clock deficit so the game runs at NORMAL SPEED at whatever FPS
// the host sustains.
//
// This used to happen only above 60 Hz. Below it, the code either let
// the debt accumulate or re-anchored and forgave it -- and both leave
// the guest clock permanently behind the wall clock, which is slow
// motion by definition. That is what "the counter says 24 fps but it
// feels much slower" was, and the audio rides the same clock, so the
// radio and everything else dragged with it.
//
// The correction is capped per vblank. Uncapped at 240 Hz a 30 ms frame
// moved the guest clock seven periods at once, and the audio mixer
// sealed and queued in bursts until the device ring sat permanently
// full: 24 of 24 blocks, 0.68 s of latency and 3387 timeline resyncs.
// Capping keeps the clock honest without the leap.
const auto behind = std::chrono::duration_cast<std::chrono::microseconds>(
now - target).count();
if (behind > 0) {
const std::uint64_t cap = virtual_vblank_period_us() * 4u;
virtual_time_us += std::min(static_cast<std::uint64_t>(behind), cap);
}
wall_anchor = now;
guest_anchor = virtual_time_us;
return;
}
// Sleep the bulk, spin the tail: a plain sleep_until overshoots by up to a
// scheduler tick, which at 60 Hz is most of a frame.
constexpr auto spin_margin = std::chrono::microseconds(1500);
if (target - now > spin_margin) std::this_thread::sleep_until(target - spin_margin);
while (std::chrono::steady_clock::now() < target) std::this_thread::yield();
}
bool execute_ge_list(psprecomp::Runtime &runtime, GeListRecord &list,
std::vector<GuestCallbackInvocation> &callbacks,
const std::atomic<std::uint32_t> *async_stall = nullptr) {
constexpr std::uint64_t kMaximumCommandsPerRun = 4'000'000u;
const bool time_ge = perf_timing_enabled();
const bool ge_histogram = ge_histogram_diag_enabled();
const bool count_ge_commands = ge_phase_diag_line_enabled();
const auto ge_entry_time = time_ge
? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
struct GeTimeGuard {
bool active;
std::chrono::steady_clock::time_point entry;
~GeTimeGuard() {
if (!active) return;
frame_time_stats.ge_time += std::chrono::steady_clock::now() - entry;
++frame_time_stats.ge_calls;
}
} ge_time_guard{time_ge, ge_entry_time};
// Stage 41: GE lists are overwhelmingly sequential command streams. The
// old loop called contains() and then aot_load32() for every 32-bit command,
// resolving/canonicalizing the same guest range twice. Cache a direct RAM/
// VRAM window for up to 64 KiB and only re-resolve when control flow leaves
// it. GuestMemory storage is fixed after construction, so the pointer stays
// valid for the duration of this synchronous GE run.
const std::uint8_t *ge_fetch_pointer = nullptr;
std::uint32_t ge_fetch_guest_base = 0u;
std::size_t ge_fetch_bytes = 0u;
const auto read_le32_unaligned = [](const std::uint8_t *source) noexcept {
std::uint32_t value{};
std::memcpy(&value, source, sizeof(value));
if constexpr (std::endian::native == std::endian::big) {
value = ((value & 0x000000FFu) << 24u) |
((value & 0x0000FF00u) << 8u) |
((value & 0x00FF0000u) >> 8u) |
((value & 0xFF000000u) >> 24u);
}
return value;
};
const auto fetch_ge_command = [&](std::uint32_t pc, std::uint32_t &value) {
if (ge_fetch_pointer != nullptr && pc >= ge_fetch_guest_base) {
const std::size_t offset = static_cast<std::size_t>(pc - ge_fetch_guest_base);
if (offset <= ge_fetch_bytes && ge_fetch_bytes - offset >= 4u) {
value = read_le32_unaligned(ge_fetch_pointer + offset);
return true;
}
}
ge_fetch_pointer = nullptr;
ge_fetch_bytes = 0u;
ge_fetch_guest_base = pc;
constexpr std::array<std::size_t, 7> kFetchSizes{
65536u, 16384u, 4096u, 1024u, 256u, 64u, 4u};
for (const std::size_t bytes : kFetchSizes) {
if (const std::uint8_t *pointer = runtime.memory().raw_pointer(pc, bytes)) {
ge_fetch_pointer = pointer;
ge_fetch_bytes = bytes;
value = read_le32_unaligned(pointer);
return true;
}
}
return false;
};
list.state = GeListState::Running;
while (list.executed_commands < kMaximumCommandsPerRun) {
const std::uint32_t effective_stall = async_stall != nullptr
? async_stall->load(std::memory_order_acquire) : list.stall;
if (effective_stall != 0u && list.pc == effective_stall) {
list.stall = effective_stall;
list.state = GeListState::Stalled;
log_ge_histogram(list);
return true;
}
std::uint32_t op{};
if ((list.pc & 3u) != 0u || !fetch_ge_command(list.pc, op)) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE display list PC is invalid: " + psprecomp::hex32(list.pc));
return false;
}
const std::uint32_t op_pc = list.pc;
const std::uint32_t command = op >> 24u;
const std::uint32_t data = op & 0x00FFFFFFu;
const std::uint32_t previous_command = ge_state.commands[command];
ge_state.commands[command] = op;
if (op != previous_command && ge_command_affects_gpu_draw_descriptor(command))
++ge_draw_state_revision;
if (op != previous_command && ge_command_affects_lighting(command))
++ge_lighting_state_revision;
// Camera matrices are streaming DATA registers: even an identical 24-bit
// payload advances the cursor and can update a different matrix element.
// View/projection cursor/data therefore always advance the camera
// generation; viewport/offset registers do so only when their value changes.
if (command >= 0x3Cu && command <= 0x3Fu)
++ge_camera_state_revision;
else if (((command >= 0x42u && command <= 0x47u) ||
command == 0x4Cu || command == 0x4Du) && op != previous_command)
++ge_camera_state_revision;
// Only matrix/morph commands 0x2A..0x3F are consumed here. Avoid a
// function call + switch for the much larger population of unrelated
// GE state commands on every display-list word.
if (command >= 0x2Au && command <= 0x3Fu)
update_ge_transform_state(ge_state.transform, command, data);
if (ge_histogram) ++list.histogram[command];
++list.executed_commands;
if (count_ge_commands) ++ge_commands_this_vblank;
std::uint32_t next_pc = (op_pc + 4u) & 0x0FFFFFFFu;
switch (command) {
case kGeCommandNop:
break;
case kGeCommandVertexAddress:
ge_state.vertex_address = ge_relative_address(data);
break;
case kGeCommandIndexAddress:
ge_state.index_address = ge_relative_address(data);
break;
case kGeCommandPrimitive: {
if (ge_histogram) ++list.primitive_commands;
const std::uint32_t draw_vertex_address = ge_state.vertex_address;
const std::uint32_t draw_index_address = ge_state.index_address;
// reference-style deferred PRIM extension: consecutive triangle lists
// with no state command between them are one logical
// vertex stream. Folding them here removes repeated renderer setup,
// texture/state lookup and decode dispatch while preserving PSP
// command accounting. This is valid for contiguous indexed and
// non-indexed triangle lists; other primitive families keep the
// exact old path.
std::uint32_t render_data = data;
std::uint32_t logical_primitive_count = 1u;
const std::uint32_t prim = (data >> 16u) & 7u;
std::uint32_t total_count = data & 0xFFFFu;
if (prim == 3u && total_count != 0u && (total_count % 3u) == 0u) {
std::uint32_t cursor = next_pc;
while (total_count < 0xFFFFu) {
const std::uint32_t merge_stall = async_stall != nullptr
? async_stall->load(std::memory_order_relaxed) : list.stall;
if (merge_stall != 0u && cursor == merge_stall) break;
std::uint32_t next_op{};
if (!fetch_ge_command(cursor, next_op)) break;
if ((next_op >> 24u) != kGeCommandPrimitive) break;
const std::uint32_t next_data = next_op & 0x00FFFFFFu;
const std::uint32_t next_prim = (next_data >> 16u) & 7u;
const std::uint32_t next_count = next_data & 0xFFFFu;
if (next_prim != 3u || next_count == 0u ||
(next_count % 3u) != 0u || next_count > 0xFFFFu - total_count)
break;
total_count += next_count;
++logical_primitive_count;
if (ge_histogram) ++list.histogram[kGeCommandPrimitive];
++list.executed_commands;
if (count_ge_commands) ++ge_commands_this_vblank;
if (ge_histogram) ++list.primitive_commands;
ge_state.commands[kGeCommandPrimitive] = next_op;
cursor = (cursor + 4u) & 0x0FFFFFFFu;
}
if (logical_primitive_count > 1u) {
render_data = (3u << 16u) | total_count;
next_pc = cursor;
}
}
// GeRenderStats contains expensive per-vertex clip/screen bounds that are
// useful only for explicit render diagnostics. The production DX12 path
// needs only next_vertex/next_index, so leave the diagnostic bookkeeping
// cold instead of doing min/max/isfinite work on every city vertex.
static const bool collect_ge_render_stats =
std::getenv("PSPRECOMP_GE_RENDER_TRACE") != nullptr ||
std::getenv("PSPRECOMP_GE_RENDER_DIAG") != nullptr;
GeRenderStats render_stats{};
std::string render_error;
if (!render_ge_primitive(runtime.memory(), ge_state.commands, ge_state.transform,
draw_vertex_address, draw_index_address,
render_data, render_stats, render_error,
logical_primitive_count, ge_draw_state_revision,
ge_camera_state_revision, ge_lighting_state_revision,
collect_ge_render_stats)) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE rasterizer failed at " + psprecomp::hex32(op_pc) + ": " + render_error);
return false;
}
ge_state.vertex_address = render_stats.next_vertex_address;
ge_state.index_address = render_stats.next_index_address;
// Read once: this sits on the per-draw-call path, and getenv walks
// the whole environment block on every call.
static const bool render_trace_enabled =
std::getenv("PSPRECOMP_GE_RENDER_TRACE") != nullptr;
if (render_trace_enabled) {
const std::uint64_t trace_start = parse_environment_u64(
"PSPRECOMP_GE_RENDER_TRACE_START_VBLANK");
const std::uint64_t trace_end = parse_environment_u64(
"PSPRECOMP_GE_RENDER_TRACE_END_VBLANK", trace_start);
const std::uint64_t min_pixels = parse_environment_u64(
"PSPRECOMP_GE_RENDER_TRACE_MIN_PIXELS", 0u);
const std::uint64_t max_lines = parse_environment_u64(
"PSPRECOMP_GE_RENDER_TRACE_MAX_LINES", 200000u);
static std::uint64_t trace_lines{};
const bool in_window = display_vblank_index >= trace_start &&
display_vblank_index <= trace_end;
const float screen_span_x = render_stats.has_screen_bounds
? render_stats.screen_max_x - render_stats.screen_min_x : 0.0f;
const float screen_span_y = render_stats.has_screen_bounds
? render_stats.screen_max_y - render_stats.screen_min_y : 0.0f;
const bool suspicious = render_stats.nonfinite_clip_vertices != 0u ||
render_stats.min_abs_w < 1.0e-5f ||
render_stats.max_abs_screen_coordinate > 4096.0f ||
screen_span_x > 2048.0f || screen_span_y > 2048.0f;
const bool suspicious_only =
std::getenv("PSPRECOMP_GE_RENDER_TRACE_SUSPICIOUS_ONLY") != nullptr;
if (in_window && trace_lines < max_lines &&
render_stats.pixels_tested >= min_pixels &&
(!suspicious_only || suspicious)) {
++trace_lines;
const std::uint32_t vtype = ge_state.commands[0x12u] & 0x00FFFFFFu;
const std::uint32_t primitive = (render_data >> 16u) & 7u;
const std::uint32_t count = render_data & 0xFFFFu;
std::cerr << std::fixed << std::setprecision(5)
<< "[ge-render-trace] vblank=" << display_vblank_index
<< " line=" << trace_lines
<< " pc=" << psprecomp::hex32(op_pc)
<< " prim=" << primitive
<< " count=" << count
<< " vtype=" << psprecomp::hex32(vtype)
<< " vaddr=" << psprecomp::hex32(draw_vertex_address)
<< " iaddr=" << psprecomp::hex32(draw_index_address)
<< " texfmt=" << (ge_state.commands[0xC3u] & 0xFu)
<< " texen=" << (ge_state.commands[0x1Eu] & 1u)
<< " clear=" << (ge_state.commands[0xD3u] & 0x701u)
<< " depthclip=" << (ge_state.commands[0x1Cu] & 1u)
<< " ztest=" << (ge_state.commands[0x23u] & 1u)
<< " cull=" << (ge_state.commands[0x1Du] & 1u)
<< " tested=" << render_stats.pixels_tested
<< " written=" << render_stats.pixels_written
<< " tris=" << render_stats.triangles
<< " culled=" << render_stats.culled_triangles
<< " skin=" << render_stats.skinned_vertices
<< " morph=" << render_stats.morphed_vertices
<< " nonfinite=" << render_stats.nonfinite_clip_vertices
<< " minabsw=" << render_stats.min_abs_w
<< " clip=[" << render_stats.clip_min_x << ','
<< render_stats.clip_min_y << ',' << render_stats.clip_min_z << ','
<< render_stats.clip_min_w << ":" << render_stats.clip_max_x << ','
<< render_stats.clip_max_y << ',' << render_stats.clip_max_z << ','
<< render_stats.clip_max_w << "]"
<< " screen=[" << render_stats.screen_min_x << ','
<< render_stats.screen_min_y << ':' << render_stats.screen_max_x << ','
<< render_stats.screen_max_y << "]"
<< " maxscreen=" << render_stats.max_abs_screen_coordinate
<< " cursors=" << ge_state.transform.bone_cursor << ','
<< ge_state.transform.world_cursor << ','
<< ge_state.transform.view_cursor << ','
<< ge_state.transform.projection_cursor << ','
<< ge_state.transform.texture_cursor
<< " worldT=" << ge_state.transform.world[9] << ','
<< ge_state.transform.world[10] << ',' << ge_state.transform.world[11]
<< " viewT=" << ge_state.transform.view[9] << ','
<< ge_state.transform.view[10] << ',' << ge_state.transform.view[11]
<< " suspicious=" << suspicious
<< "\n";
std::cerr.unsetf(std::ios::floatfield);
}
}
static const bool render_diag_enabled =
std::getenv("PSPRECOMP_GE_RENDER_DIAG") != nullptr;
if (render_diag_enabled) {
static std::unordered_set<std::uint64_t> reported_signatures;
const std::uint64_t signature =
(static_cast<std::uint64_t>((data >> 16u) & 7u) << 56u) |
(static_cast<std::uint64_t>(ge_state.commands[0x12u] & 0x00FFFFFFu) << 24u) |
static_cast<std::uint64_t>(ge_state.commands[0xC3u] & 0xFu);
if (reported_signatures.insert(signature).second) {
std::cerr << "[ge-render] first pc=" << psprecomp::hex32(op_pc)
<< " prim=" << ((data >> 16u) & 7u)
<< " count=" << (data & 0xFFFFu)
<< " vtype=" << psprecomp::hex32(ge_state.commands[0x12u] & 0x00FFFFFFu)
<< " texfmt=" << (ge_state.commands[0xC3u] & 0xFu)
<< " points=" << render_stats.points
<< " lines=" << render_stats.lines
<< " triangles=" << render_stats.triangles
<< " rectangles=" << render_stats.rectangles
<< " uvgen=" << render_stats.generated_uv_vertices
<< " culled=" << render_stats.culled_triangles
<< " flat=" << render_stats.flat_shaded_primitives
<< " tested=" << render_stats.pixels_tested
<< " written=" << render_stats.pixels_written
<< " unsupported=" << render_stats.unsupported_primitives
<< "\n";
}
}
break;
}
case kGeCommandBoundingBox: {
GeBoundingBoxResult result{};
std::string bbox_error;
if (!test_ge_bounding_box(runtime.memory(), ge_state.commands, ge_state.transform,
ge_state.vertex_address, ge_state.index_address,
data & 0xFFFFu, result, bbox_error)) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE BBOX failed at " + psprecomp::hex32(op_pc) + ": " + bbox_error);
return false;
}
ge_state.bounding_box_result = result.visible;
ge_state.vertex_address = result.next_vertex_address;
ge_state.index_address = result.next_index_address;
break;
}
case kGeCommandJump:
next_pc = ge_relative_address(data & 0x00FFFFFCu);
break;
case kGeCommandBoundingBoxJump:
if (!ge_state.bounding_box_result)
next_pc = ge_relative_address(data & 0x00FFFFFCu);
break;
case kGeCommandCall:
if (list.stack.size() >= list.stack_capacity) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE display-list CALL stack overflow at " + psprecomp::hex32(op_pc));
return false;
}
list.stack.push_back(GeStackEntry{next_pc, ge_state.offset_address, ge_state.commands[kGeCommandBase]});
next_pc = ge_relative_address(data & 0x00FFFFFCu);
break;
case kGeCommandReturn:
if (list.stack.empty()) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE display-list RET with empty stack at " + psprecomp::hex32(op_pc));
return false;
} else {
const GeStackEntry entry = list.stack.back();
list.stack.pop_back();
ge_state.offset_address = entry.offset_address;
next_pc = entry.pc & 0x0FFFFFFFu;
}
break;
case kGeCommandOffsetAddress:
ge_state.offset_address = op << 8u;
break;
case kGeCommandOrigin:
ge_state.offset_address = op_pc;
break;
case kGeCommandEnd: {
if (op_pc < 4u || !runtime.memory().contains(op_pc - 4u, 4u)) break;
const std::uint32_t previous = runtime.memory().load32(op_pc - 4u);
const std::uint32_t previous_command = previous >> 24u;
if (previous_command == kGeCommandSignal) {
const std::uint8_t behavior = static_cast<std::uint8_t>((previous >> 16u) & 0xFFu);
const std::uint16_t token = static_cast<std::uint16_t>(previous & 0xFFFFu);
const std::uint32_t end_data = op & 0xFFFFu;
list.signal_behavior = behavior;
list.callback_token = token;
switch (behavior) {
case kGeSignalHandlerSuspend:
case kGeSignalHandlerContinue:
(void)append_ge_callback(list, true, token, next_pc, callbacks);
break;
case kGeSignalHandlerPause:
// The callback is delivered by the next FINISH/END pair.
break;
case kGeSignalSync:
runtime.memory().memory_barrier();
break;
case kGeSignalJump:
case kGeSignalRelativeJump:
case kGeSignalOriginJump: {
const std::uint32_t combined = ((static_cast<std::uint32_t>(token) << 16u) | end_data) & 0x0FFFFFFCu;
if (behavior == kGeSignalRelativeJump)
next_pc = (combined + op_pc - 4u) & 0x0FFFFFFFu;
else if (behavior == kGeSignalOriginJump)
next_pc = ge_relative_address(combined);
else
next_pc = combined;
break;
}
case kGeSignalCall:
case kGeSignalRelativeCall:
case kGeSignalOriginCall: {
if (list.stack.size() >= list.stack_capacity) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE SIGNAL CALL stack overflow at " + psprecomp::hex32(op_pc));
return false;
}
const std::uint32_t combined = ((static_cast<std::uint32_t>(token) << 16u) | end_data) & 0x0FFFFFFCu;
std::uint32_t target = combined;
if (behavior == kGeSignalRelativeCall)
target = (combined + op_pc - 4u) & 0x0FFFFFFFu;
else if (behavior == kGeSignalOriginCall)
target = ge_relative_address(combined);
list.stack.push_back(GeStackEntry{next_pc, ge_state.offset_address, ge_state.commands[kGeCommandBase]});
next_pc = target;
break;
}
case kGeSignalReturn:
if (list.stack.empty()) {
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE SIGNAL RET with empty stack at " + psprecomp::hex32(op_pc));
return false;
} else {
const GeStackEntry entry = list.stack.back();
list.stack.pop_back();
ge_state.offset_address = entry.offset_address;
if (ge_state.commands[kGeCommandBase] != entry.base_command) {
ge_state.commands[kGeCommandBase] = entry.base_command;
++ge_draw_state_revision;
}
next_pc = entry.pc & 0x0FFFFFFFu;
}
break;
default:
list.state = GeListState::Error;
ge_execution_stop(runtime, "Unsupported GE SIGNAL behavior " + std::to_string(behavior) +
" at " + psprecomp::hex32(op_pc));
return false;
}
} else if (previous_command == kGeCommandFinish) {
const std::uint16_t token = static_cast<std::uint16_t>(previous & 0xFFFFu);
list.callback_token = token;
if (list.signal_behavior == kGeSignalHandlerPause) {
list.state = GeListState::Paused;
(void)append_ge_callback(list, true, token, next_pc, callbacks);
} else {
list.state = GeListState::Completed;
restore_ge_list_context(list);
(void)append_ge_callback(list, false, token, next_pc, callbacks);
}
list.pc = next_pc;
log_ge_histogram(list);
return true;
}
break;
}
default:
// State-setting commands are retained in ge_state.commands and are
// consumed by the renderer as support is added.
break;
}
list.pc = next_pc;
}
list.state = GeListState::Error;
ge_execution_stop(runtime, "GE display list exceeded the command safety limit at " + psprecomp::hex32(list.pc));
return false;
}
void ge_async_worker_main() {
ge_async_worker_thread = true;
for (;;) {
GeAsyncTask task{};
GeListRecord local{};
psprecomp::Runtime *runtime = nullptr;
{
std::unique_lock lock(ge_async.mutex);
ge_async.cv.wait(lock, [] { return ge_async.stop_requested || !ge_async.pending.empty(); });
if (ge_async.stop_requested && ge_async.pending.empty()) break;
task = std::move(ge_async.pending.front());
ge_async.pending.pop_front();
runtime = ge_async.runtime;
const auto found = ge_list_table.lists.find(task.id);
if (runtime == nullptr || found == ge_list_table.lists.end() ||
found->second.state == GeListState::None ||
found->second.state == GeListState::Completed ||
found->second.state == GeListState::Error) {
ge_async.live_stalls.erase(task.id);
ge_async.outstanding.fetch_sub(1u, std::memory_order_acq_rel);
ge_async.cv.notify_all();
continue;
}
local = found->second;
local.state = GeListState::Running;
found->second.state = GeListState::Running;
}
std::vector<GuestCallbackInvocation> callbacks;
const bool ok = execute_ge_list(*runtime, local, callbacks, task.stall.get());
{
std::lock_guard lock(ge_async.mutex);
const auto found = ge_list_table.lists.find(task.id);
if (found != ge_list_table.lists.end()) found->second = std::move(local);
ge_async.live_stalls.erase(task.id);
if (!callbacks.empty()) {
ge_async.completions.push_back(GeAsyncCompletion{task.submitter_uid, std::move(callbacks)});
ge_async.completion_count.fetch_add(1u, std::memory_order_release);
}
++ge_async.completed;
ge_async.outstanding.fetch_sub(1u, std::memory_order_acq_rel);
if (!ok && !ge_async.fatal.load(std::memory_order_acquire)) {
ge_async.fatal_reason = "Asynchronous GE display-list execution failed";
ge_async.fatal.store(true, std::memory_order_release);
}
}
ge_async.cv.notify_all();
}
ge_async_worker_thread = false;
}
void ge_async_stop_worker() {
std::thread worker;
{
std::lock_guard lock(ge_async.mutex);
if (!ge_async.started.load(std::memory_order_acquire)) return;
ge_async.stop_requested = true;
ge_async.cv.notify_all();
worker = std::move(ge_async.thread);
}
if (worker.joinable()) worker.join();
std::lock_guard lock(ge_async.mutex);
ge_async.started.store(false, std::memory_order_release);
ge_async.runtime = nullptr;
ge_async.pending.clear();
ge_async.live_stalls.clear();
ge_async.completions.clear();
ge_async.outstanding.store(0u, std::memory_order_release);
ge_async.completion_count.store(0u, std::memory_order_release);
ge_async.last_wait_ns.store(0u, std::memory_order_release);
}
void ge_async_drain_completions() {
if (!ge_async_enabled() || ge_async.completion_count.load(std::memory_order_acquire) == 0u) return;
std::deque<GeAsyncCompletion> ready;
{
std::lock_guard lock(ge_async.mutex);
ready.swap(ge_async.completions);
ge_async.completion_count.store(0u, std::memory_order_release);
}
while (!ready.empty()) {
GeAsyncCompletion completion = std::move(ready.front());
ready.pop_front();
auto &pending = pending_guest_callbacks[completion.submitter_uid];
pending.insert(pending.end(), completion.callbacks.begin(), completion.callbacks.end());
}
}
bool ge_async_check_fatal(psprecomp::Runtime &runtime) {
if (!ge_async.fatal.load(std::memory_order_acquire)) return true;
std::string reason;
{
std::lock_guard lock(ge_async.mutex);
reason = ge_async.fatal_reason.empty() ? "Asynchronous GE worker failed" : ge_async.fatal_reason;
}
runtime.stop(std::move(reason));
return false;
}
bool ge_async_wait_idle(psprecomp::Runtime &runtime) {
if (!ge_async_enabled() || !ge_async.started.load(std::memory_order_acquire)) return true;
if (ge_async.outstanding.load(std::memory_order_acquire) == 0u) {
ge_async.last_wait_ns.store(0u, std::memory_order_release);
ge_async_drain_completions();
return ge_async_check_fatal(runtime);
}
const auto begin = std::chrono::steady_clock::now();
{
std::unique_lock lock(ge_async.mutex);
++ge_async.wait_calls;
ge_async.cv.wait(lock, [] {
return ge_async.outstanding.load(std::memory_order_acquire) == 0u;
});
const auto elapsed = std::chrono::steady_clock::now() - begin;
ge_async.wait_time += elapsed;
ge_async.last_wait_ns.store(
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count()),
std::memory_order_release);
}
runtime.memory().memory_barrier();
ge_async_drain_completions();
return ge_async_check_fatal(runtime);
}
bool ge_async_wait_list(psprecomp::Runtime &runtime, std::uint32_t id) {
if (!ge_async_enabled() || !ge_async.started.load(std::memory_order_acquire)) return true;
const auto begin = std::chrono::steady_clock::now();
{
std::unique_lock lock(ge_async.mutex);
++ge_async.wait_calls;
ge_async.cv.wait(lock, [id] {
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) return true;
return found->second.state != GeListState::Queued &&
found->second.state != GeListState::Running;
});
ge_async.wait_time += std::chrono::steady_clock::now() - begin;
}
runtime.memory().memory_barrier();
ge_async_drain_completions();
return ge_async_check_fatal(runtime);
}
std::uint32_t allocate_ge_list_id() {
for (std::uint32_t attempt = 0; attempt < 64u; ++attempt) {
const std::uint32_t raw = (ge_list_table.next_raw_id + attempt) % 64u;
const std::uint32_t guest = kGeListIdMagic ^ raw;
const auto found = ge_list_table.lists.find(guest);
if (found == ge_list_table.lists.end() || found->second.state == GeListState::Completed ||
found->second.state == GeListState::None) {
ge_list_table.next_raw_id = (raw + 1u) % 64u;
return guest;
}
}
return 0u;
}
void enqueue_ge_display_list(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx, bool head) {
const std::uint32_t list_address = ctx.gpr[4] & 0x0FFFFFFFu;
const std::uint32_t stall_address = ctx.gpr[5] & 0x0FFFFFFFu;
const std::int32_t callback_id = static_cast<std::int32_t>(ctx.gpr[6]);
const std::uint32_t option_address = ctx.gpr[7];
if ((list_address & 3u) != 0u || (stall_address & 3u) != 0u ||
!runtime.memory().contains(list_address, 4u)) {
ctx.set_gpr(2, 0x80000103u);
return;
}
GeListRecord record{};
record.start_pc = list_address;
record.pc = list_address;
record.stall = stall_address;
record.callback_id = callback_id;
record.state = GeListState::Queued;
record.stack_capacity = 32u;
if (option_address != 0u) {
if (!runtime.memory().contains(option_address, 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
const std::uint32_t size = runtime.memory().load32(option_address);
if (size >= 8u) {
if (!runtime.memory().contains(option_address, 8u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
record.context_address = runtime.memory().load32(option_address + 4u);
}
if (size >= 16u) {
if (!runtime.memory().contains(option_address, 16u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
const std::uint32_t stack_count = runtime.memory().load32(option_address + 8u);
record.stack_address = runtime.memory().load32(option_address + 12u);
if (stack_count >= 256u) {
ctx.set_gpr(2, 0x80000104u);
return;
}
if (stack_count != 0u) record.stack_capacity = stack_count;
}
}
if (record.context_address != 0u &&
!runtime.memory().contains(record.context_address, 512u * 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
if (ge_async_enabled()) {
// Context save/restore snapshots the global GE register file. Lists using
// that uncommon feature establish an explicit serialization boundary;
// normal VCS gameplay lists stay fully asynchronous.
if (record.context_address != 0u && !ge_async_wait_idle(runtime)) return;
ge_async_start_worker(runtime);
std::uint32_t guest_id = 0u;
std::uint32_t log_stack = record.stack_capacity;
{
std::lock_guard lock(ge_async.mutex);
for (const auto &[id, active] : ge_list_table.lists) {
(void)id;
if (active.start_pc == list_address && active.state != GeListState::Completed &&
active.state != GeListState::None && active.state != GeListState::Error) {
ctx.set_gpr(2, 0x80000021u);
return;
}
}
guest_id = allocate_ge_list_id();
if (guest_id == 0u) {
ctx.set_gpr(2, 0x80020190u);
return;
}
record.guest_id = guest_id;
if (record.context_address != 0u) save_ge_list_context(runtime, record);
auto [found, inserted] = ge_list_table.lists.insert_or_assign(guest_id, std::move(record));
(void)inserted;
if (head)
ge_list_table.queue.insert(ge_list_table.queue.begin(), guest_id);
else
ge_list_table.queue.push_back(guest_id);
auto stall = std::make_shared<std::atomic<std::uint32_t>>(found->second.stall);
ge_async.live_stalls[guest_id] = stall;
GeAsyncTask task{guest_id, thread_table.current_uid, stall};
if (head)
ge_async.pending.push_front(std::move(task));
else
ge_async.pending.push_back(std::move(task));
ge_async.outstanding.fetch_add(1u, std::memory_order_release);
++ge_async.submitted;
}
if (ge_histogram_diag_enabled()) {
std::cerr << "[ge-async] enqueue id=" << psprecomp::hex32(guest_id)
<< " list=" << psprecomp::hex32(list_address)
<< " stall=" << psprecomp::hex32(stall_address)
<< " cbid=" << callback_id
<< " option=" << psprecomp::hex32(option_address)
<< " stack=" << log_stack << "\n";
}
runtime.memory().memory_barrier();
ge_async.cv.notify_one();
ctx.set_gpr(2, guest_id);
return;
}
for (const auto &[id, active] : ge_list_table.lists) {
(void)id;
if (active.start_pc == list_address && active.state != GeListState::Completed &&
active.state != GeListState::None && active.state != GeListState::Error) {
ctx.set_gpr(2, 0x80000021u);
return;
}
}
const std::uint32_t guest_id = allocate_ge_list_id();
if (guest_id == 0u) {
ctx.set_gpr(2, 0x80020190u);
return;
}
record.guest_id = guest_id;
if (record.context_address != 0u) save_ge_list_context(runtime, record);
auto [found, inserted] = ge_list_table.lists.insert_or_assign(guest_id, std::move(record));
(void)inserted;
if (head)
ge_list_table.queue.insert(ge_list_table.queue.begin(), guest_id);
else
ge_list_table.queue.push_back(guest_id);
GeListRecord &list = found->second;
if (ge_histogram_diag_enabled()) {
std::cerr << "[ge] enqueue id=" << psprecomp::hex32(guest_id)
<< " list=" << psprecomp::hex32(list.start_pc)
<< " stall=" << psprecomp::hex32(list.stall)
<< " cbid=" << callback_id
<< " option=" << psprecomp::hex32(option_address)
<< " stack=" << list.stack_capacity << "\n";
}
std::vector<GuestCallbackInvocation> callbacks;
if (!execute_ge_list(runtime, list, callbacks)) return;
psprecomp::AllegrexContext resume = ctx;
resume.set_gpr(2, guest_id);
resume.pc = ctx.gpr[31];
queue_guest_callback_chain(ctx, resume, std::move(callbacks));
}
void complete_current_thread(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const std::int32_t completed_uid = thread_table.current_uid;
if (auto current = thread_table.threads.find(completed_uid); current != thread_table.threads.end())
current->second.state = ThreadState::Completed;
thread_table.continuations.erase(
std::remove_if(thread_table.continuations.begin(), thread_table.continuations.end(),
[completed_uid](const ThreadContinuation &item) { return item.uid == completed_uid; }),
thread_table.continuations.end());
pending_guest_callbacks.erase(completed_uid);
async_return_frames.erase(completed_uid);
deferred_io_resumes.erase(completed_uid);
refresh_vcs_post_dispatch_hook();
wake_thread_end_waiters(completed_uid);
if (!activate_next_thread(ctx, "thread-complete")) {
ctx.set_gpr(2, 0u);
runtime.stop("All PSP threads completed");
}
}
void vcs_module_thread_return(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
complete_current_thread(runtime, ctx);
}
bool continue_mpeg_ringbuffer_callback(psprecomp::Runtime &runtime,
psprecomp::AllegrexContext &ctx,
AsyncReturnFrame &frame) {
if (!runtime.memory().contains(frame.ring_address, 48u)) {
runtime.stop("MPEG ringbuffer callback returned to an invalid ringbuffer");
return false;
}
const auto callback_result = static_cast<std::int32_t>(ctx.gpr[2]);
const std::int32_t packets = static_cast<std::int32_t>(runtime.memory().load32(frame.ring_address));
std::int32_t write_position = static_cast<std::int32_t>(runtime.memory().load32(frame.ring_address + 8u));
std::int32_t packets_available = static_cast<std::int32_t>(runtime.memory().load32(frame.ring_address + 12u));
if (packets <= 0) {
runtime.stop("MPEG ringbuffer callback returned to a ring with no packets");
return false;
}
if (callback_result > 0) {
const std::int32_t accepted = std::min({callback_result, frame.requested_this_round,
std::max(0, packets - packets_available)});
frame.total_packets += accepted;
write_position += accepted;
packets_available += accepted;
runtime.memory().store32(frame.ring_address + 4u,
runtime.memory().load32(frame.ring_address + 4u) + static_cast<std::uint32_t>(accepted));
runtime.memory().store32(frame.ring_address + 8u, static_cast<std::uint32_t>(write_position));
runtime.memory().store32(frame.ring_address + 12u, static_cast<std::uint32_t>(packets_available));
}
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr) {
std::cerr << "[mpeg] ring callback returned=" << callback_result
<< " total=" << frame.total_packets
<< " remaining=" << frame.remaining_packets
<< " write_pos=" << write_position
<< " used=" << packets_available << "\n";
}
if (callback_result > 0 && frame.remaining_packets > 0 && packets_available < packets) {
const std::int32_t write_offset = write_position % packets;
const std::int32_t desired = std::min({frame.remaining_packets, packets - write_offset,
packets - packets_available});
if (desired > 0) {
frame.remaining_packets -= desired;
frame.requested_this_round = desired;
const std::uint32_t data = runtime.memory().load32(frame.ring_address + 20u);
const std::uint32_t callback = runtime.memory().load32(frame.ring_address + 24u);
const std::uint32_t argument = runtime.memory().load32(frame.ring_address + 28u);
ctx = frame.resume;
ctx.set_gpr(4, data + static_cast<std::uint32_t>(write_offset) * 2048u);
ctx.set_gpr(5, static_cast<std::uint32_t>(desired));
ctx.set_gpr(6, argument);
ctx.set_gpr(31, 0x00000004u);
ctx.pc = callback;
return true;
}
}
ctx = frame.resume;
if (callback_result < 0 && frame.total_packets == 0)
ctx.set_gpr(2, static_cast<std::uint32_t>(callback_result));
else
ctx.set_gpr(2, static_cast<std::uint32_t>(frame.total_packets));
return false;
}
void vcs_interrupt_return(psprecomp::Runtime &runtime, psprecomp::AllegrexContext &ctx) {
const std::int32_t uid = thread_table.current_uid;
const auto found = async_return_frames.find(uid);
if (found == async_return_frames.end() || found->second.empty()) {
runtime.stop("PSP interrupt/callback return without a saved thread context");
return;
}
const AsyncReturnKind kind = found->second.back().kind;
if (kind == AsyncReturnKind::MpegRingbuffer) {
if (continue_mpeg_ringbuffer_callback(runtime, ctx, found->second.back())) return;
found->second.pop_back();
if (found->second.empty()) async_return_frames.erase(found);
(void)maybe_start_pending_guest_callback(ctx);
return;
}
if (kind == AsyncReturnKind::UserCallback) {
ctx = found->second.back().resume;
found->second.pop_back();
if (found->second.empty()) async_return_frames.erase(found);
(void)maybe_start_pending_guest_callback(ctx);
return;
}
if (kind == AsyncReturnKind::GeCallbackChain) {
// Restore the interrupted guest state before every callback in the
// chain. A callback must not leak SP, callee-saved GPRs, FPU or VFPU
// state into the next callback merely because both were queued by one
// display list.
ctx = found->second.back().resume;
if (start_next_guest_callback(ctx, false)) return;
}
ctx = found->second.back().resume;
found->second.pop_back();
if (found->second.empty()) async_return_frames.erase(found);
if (std::getenv("PSPRECOMP_GE_DIAG") != nullptr || std::getenv("PSPRECOMP_SCHED_DIAG") != nullptr) {
std::cerr << "[async-return] uid=" << uid
<< " kind=" << (kind == AsyncReturnKind::GeCallbackChain ? "ge" : "subintr")
<< " resume=" << psprecomp::hex32(ctx.pc) << "\n";
}
// A pending GE callback may now run only after the interrupted frame has
// completely unwound. Never inject it into a nested callback/interrupt.
(void)maybe_start_pending_guest_callback(ctx);
}
}
const char *thread_state_name(ThreadState state) {
switch (state) {
case ThreadState::Created: return "Created";
case ThreadState::Ready: return "Ready";
case ThreadState::Running: return "Running";
case ThreadState::Sleeping: return "Sleeping";
case ThreadState::Delayed: return "Delayed";
case ThreadState::IoDeferred: return "IoDeferred";
case ThreadState::Completed: return "Completed";
}
return "Unknown";
}
bool event_diag_matches(const EventFlagRecord &flag) {
if (std::getenv("PSPRECOMP_EVENT_DIAG") == nullptr) return false;
const char *filter = std::getenv("PSPRECOMP_EVENT_DIAG_FILTER");
return filter == nullptr || *filter == '\0' || flag.name.find(filter) != std::string::npos;
}
void dump_event_stall_state(const EventFlagRecord &flag, std::int32_t flag_uid,
const psprecomp::AllegrexContext &ctx) {
std::cerr << "[event-stall] uid=" << flag_uid
<< " name=\"" << flag.name << "\""
<< " pattern=" << psprecomp::hex32(flag.current_pattern)
<< " polls=" << event_diag_poll_count
<< " vblank=" << display_vblank_index
<< " virtual_time_us=" << virtual_time_us
<< " dispatch_pc=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " ctx_pc=" << psprecomp::hex32(ctx.pc)
<< " ra=" << psprecomp::hex32(ctx.gpr[31])
<< " current_uid=" << thread_table.current_uid
<< " ready=" << thread_table.continuations.size()
<< " deferred_io=" << deferred_io_resumes.size() << "\n";
std::vector<std::int32_t> uids;
uids.reserve(thread_table.threads.size());
for (const auto &[uid, unused] : thread_table.threads) {
(void)unused;
uids.push_back(uid);
}
std::sort(uids.begin(), uids.end());
for (const std::int32_t uid : uids) {
const ThreadRecord &thread = thread_table.threads.at(uid);
std::cerr << "[event-stall-thread] uid=" << uid
<< " name=\"" << thread.name << "\""
<< " priority=" << thread.priority
<< " state=" << thread_state_name(thread.state)
<< " external=" << (thread.externally_suspended ? 1 : 0)
<< " pc=" << psprecomp::hex32(thread.suspended_context.pc)
<< " ra=" << psprecomp::hex32(thread.suspended_context.gpr[31])
<< " delay_until=" << thread.delay_until_us
<< " wakeups=" << thread.wakeup_count << "\n";
}
for (const ThreadContinuation &ready : thread_table.continuations) {
std::cerr << "[event-stall-ready] uid=" << ready.uid
<< " pc=" << psprecomp::hex32(ready.context.pc)
<< " ra=" << psprecomp::hex32(ready.context.gpr[31])
<< " sequence=" << ready.ready_sequence << "\n";
}
for (const auto &[worker_uid, barrier] : deferred_io_resumes) {
std::cerr << "[event-stall-io] worker=" << worker_uid
<< " handoff_uid=" << barrier.handoff_uid
<< " handoff_pc=" << psprecomp::hex32(barrier.handoff_pc)
<< " release_pc=" << psprecomp::hex32(barrier.release_pc)
<< " observed=" << barrier.observed_dispatches << "\n";
}
for (const auto &[uid, item] : event_flag_table.flags) {
if (item.name.find("World") == std::string::npos &&
item.name.find("Umd") == std::string::npos) continue;
std::cerr << "[event-stall-flag] uid=" << uid
<< " name=\"" << item.name << "\""
<< " pattern=" << psprecomp::hex32(item.current_pattern)
<< " waiters=" << item.waiters.size() << "\n";
for (const EventFlagWaiter &waiter : item.waiters) {
std::cerr << "[event-stall-waiter] flag_uid=" << uid
<< " thread_uid=" << waiter.uid
<< " requested=" << psprecomp::hex32(waiter.requested)
<< " mode=" << psprecomp::hex32(waiter.mode)
<< " pc=" << psprecomp::hex32(waiter.context.pc)
<< " ra=" << psprecomp::hex32(waiter.context.gpr[31]) << "\n";
}
}
for (const auto &[uid, semaphore] : semaphore_table.semaphores) {
if (semaphore.name.find("Stream") == std::string::npos &&
semaphore.name.find("stream") == std::string::npos) continue;
std::cerr << "[event-stall-sema] uid=" << uid
<< " name=\"" << semaphore.name << "\""
<< " count=" << semaphore.count
<< " maximum=" << semaphore.maximum
<< " waiters=" << semaphore.waiters.size() << "\n";
}
}
bool event_flag_matches(const EventFlagRecord &flag, std::uint32_t requested, std::uint32_t mode) {
if ((mode & 1u) != 0u) return (flag.current_pattern & requested) != 0u;
return (flag.current_pattern & requested) == requested;
}
void consume_event_flag(EventFlagRecord &flag, std::uint32_t requested, std::uint32_t mode) {
if ((mode & 0x20u) != 0u) flag.current_pattern &= ~requested;
if ((mode & 0x10u) != 0u) flag.current_pattern = 0u;
}
void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start) {
install_native_fast_paths(runtime);
if (configured_game_frame_rate() > 30u) {
runtime.register_function(kGuestFrameLimiterBranch,
&unlocked_frame_limiter_patch,
"vcs_unlocked_frame_limiter");
}
std::cerr << "[frame-rate] target=" << configured_game_frame_rate()
<< " virtual_display=" << virtual_display_refresh_hz() << " Hz\n";
file_table = FileTable{};
for (auto &[address, state] : mpeg_contexts) close_video_decoder(state);
mpeg_contexts.clear();
for (auto &state : atrac_contexts) close_atrac_decoder(state);
atrac_contexts = {};
next_mpeg_stream_id = 1u;
vcs::audio_output_shutdown();
audio_channels = {};
sas_state = SasState{};
thread_table = ThreadTable{};
deferred_io_resumes.clear();
event_diag_poll_count = 0u;
event_diag_stop_polls = parse_environment_u64("PSPRECOMP_EVENT_DIAG_STOP_POLLS", 0u);
event_diag_stall_reported = false;
partition_table = PartitionTable{};
partition_arena_base = (user_arena_start + 0xFFu) & ~0xFFu;
partition_table.next_address = partition_arena_base;
callback_table = CallbackTable{};
semaphore_table = SemaphoreTable{};
event_flag_table = EventFlagTable{};
fixed_pool_table = FixedPoolTable{};
loaded_modules.clear();
next_module_uid = 0x400;
ThreadRecord module_thread{};
module_thread.name = "module_start";
module_thread.priority = 32u;
module_thread.stack_size = 0x10000u;
// Keep the loader/module stack at the top of user RAM. The game arena grows
// upward from the aligned end of the ELF, and subsequent thread stacks grow
// downward below this reserved loader stack.
module_thread.stack_top = 0x0A000000u;
module_thread.stack_bottom = module_thread.stack_top - module_thread.stack_size;
module_thread.kernel_context = module_thread.stack_top - 0x100u;
thread_table.next_stack_top = module_thread.stack_bottom;
module_thread.state = ThreadState::Running;
if (!runtime.memory().contains(module_thread.stack_bottom, module_thread.stack_size))
throw psprecomp::Error("VCS module_start stack falls outside PSP user RAM");
runtime.memory().zero(module_thread.stack_bottom, module_thread.stack_size);
runtime.memory().store32(module_thread.stack_bottom, 0u);
runtime.memory().store32(module_thread.kernel_context + 0xC0u, 0u);
runtime.memory().store32(module_thread.kernel_context + 0xC8u, module_thread.stack_bottom);
runtime.memory().store32(module_thread.kernel_context + 0xF8u, 0xFFFFFFFFu);
runtime.memory().store32(module_thread.kernel_context + 0xFCu, 0xFFFFFFFFu);
runtime.cpu().set_gpr(26, module_thread.kernel_context);
runtime.cpu().set_gpr(29, module_thread.kernel_context);
thread_table.threads.emplace(0, std::move(module_thread));
virtual_time_us = 0u;
volatile_memory_locked = false;
general_purpose_io = 0u;
ge_edram_translation = 0u;
ge_async_stop_worker();
ge_callback_table = GeCallbackTable{};
ge_state = GeState{};
++ge_draw_state_revision;
++ge_lighting_state_revision;
++ge_camera_state_revision;
reset_ge_transform_state(ge_state.transform);
ge_list_table = GeListTable{};
{
std::lock_guard lock(ge_async.mutex);
ge_async.stop_requested = false;
ge_async.fatal.store(false, std::memory_order_release);
ge_async.fatal_reason.clear();
ge_async.submitted = 0u;
ge_async.completed = 0u;
ge_async.wait_calls = 0u;
ge_async.wait_time = std::chrono::steady_clock::duration{};
ge_async.last_wait_ns.store(0u, std::memory_order_release);
}
pending_guest_callbacks.clear();
async_return_frames.clear();
display_state = DisplayState{};
display_vblank_index = 0u;
frame_time_stats = FrameTimeStats{};
gpu_timing_census = GpuTimingCensus{};
realtime_speed_stats = RealtimeSpeedStats{};
frozen_clock_guard_dispatches = 0u;
frozen_clock_guard_vblank = 0u;
sub_interrupts.clear();
memory_stick_fat_state = 1u;
controller_state = ControllerState{};
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
savedata_utility = SavedataUtilityState{};
save_repro_trace_enabled = false;
save_repro_checkpoint_available = false;
save_repro_capture_requested = false;
save_repro_capture_error_reported = false;
save_repro_self_test_mode = false;
save_repro_trace_count = save_repro_trace_next = 0u;
save_repro_trace_sequence = 0u;
deflate_fast_pending.clear();
collision_chain_trace_stack.clear();
psprecomp::set_runtime_post_import_hook(&vcs_post_import_hook);
// Pre-dispatch and chained-call hooks are diagnostic-only. Do not put them
// on the gameplay hot path unless their trace was explicitly requested.
// The post-dispatch hook stays installed because it also performs
// deferred-I/O handoff and the frozen-clock safety guard.
psprecomp::set_runtime_pre_dispatch_hook(
dispatch_collision_diagnostics_enabled() ? &vcs_pre_dispatch_hook : nullptr);
refresh_vcs_post_dispatch_hook();
const bool chained_diagnostics = chained_call_collision_diagnostics_enabled();
psprecomp::set_runtime_pre_chained_call_hook(
chained_diagnostics ? &vcs_pre_chained_call_hook : nullptr);
psprecomp::set_runtime_post_chained_call_hook(
chained_diagnostics ? &vcs_post_chained_call_hook : nullptr);
reset_frame_capture();
psprecomp::set_runtime_thread_identity(0, "module_start");
runtime.register_function(0x00000000u, &vcs_module_thread_return, "psp_thread_return");
runtime.register_function(0x00000004u, &vcs_interrupt_return, "psp_interrupt_return");
runtime.register_function(0x08B562D8u, &vcs_sprintf, "vcs_sprintf");
runtime.register_function(0x088B4FA8u, &vcs_path_hash, "vcs_path_hash");
runtime.register_function(0x08B1B36Cu, &vcs_load_codec_modules, "vcs_load_codec_modules");
if (std::getenv("PSPRECOMP_NO_FAST_DEFLATE") == nullptr)
runtime.register_function(0x08B648B0u, &vcs_raw_deflate_fast, "vcs_raw_deflate_fast");
runtime.register_hle("SysMemUserForUser", 0x7591C7DBu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
compiled_sdk_version = ctx.gpr[4];
set_success(ctx);
});
runtime.register_hle("SysMemUserForUser", 0xF77D77CBu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
compiler_version = ctx.gpr[4];
set_success(ctx);
});
runtime.register_hle("SysMemUserForUser", 0xA291F107u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
// The bootstrap owns one contiguous user arena growing upward,
// while thread stacks grow downward. Report the actual gap.
const std::uint32_t low = (partition_table.next_address + 0xFFu) & ~0xFFu;
const std::uint32_t high = thread_table.next_stack_top & ~0xFFu;
ctx.set_gpr(2, high > low ? high - low : 0u);
});
runtime.register_hle("SysMemUserForUser", 0x237DBD4Fu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string name = ctx.gpr[5] != 0u ? rt.memory().read_c_string(ctx.gpr[5], 128u) : "partition";
const std::uint32_t size = ctx.gpr[7];
const std::uint32_t alignment = 0x100u;
if (size > 0xFFFFFFFFu - (alignment - 1u)) {
ctx.set_gpr(2, 0x80020190u);
return;
}
const std::uint32_t aligned_size = (size + alignment - 1u) & ~(alignment - 1u);
std::uint32_t address{};
if (aligned_size == 0u || !allocate_user_arena_range(aligned_size, alignment, address) ||
!rt.memory().contains(address, aligned_size)) {
runtime_log_line("PARTITION_ALLOC failed name=" + name +
" size=" + std::to_string(aligned_size) +
" frontier=" + psprecomp::hex32(partition_table.next_address) +
" stack_top=" + psprecomp::hex32(thread_table.next_stack_top));
ctx.set_gpr(2, 0x80020190u);
return;
}
rt.memory().zero(address, aligned_size);
const std::int32_t uid = partition_table.next_uid++;
partition_table.blocks.emplace(uid, PartitionBlock{name, address, aligned_size});
recompute_partition_frontier();
if (std::getenv("PSPRECOMP_PARTITION_DIAG") != nullptr) {
std::cerr << "[partition] alloc uid=" << uid << " name=\"" << name
<< "\" addr=" << psprecomp::hex32(address)
<< " size=" << psprecomp::hex32(aligned_size)
<< " next=" << psprecomp::hex32(partition_table.next_address)
<< " stack_top=" << psprecomp::hex32(thread_table.next_stack_top) << "\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("SysMemUserForUser", 0x9D9A5BA1u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto it = partition_table.blocks.find(uid);
ctx.set_gpr(2, it == partition_table.blocks.end() ? 0u : it->second.address);
});
runtime.register_hle("SysMemUserForUser", 0xB6D61D02u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto it = partition_table.blocks.find(uid);
if (std::getenv("PSPRECOMP_PARTITION_DIAG") != nullptr) {
std::cerr << "[partition] free uid=" << uid;
if (it != partition_table.blocks.end()) {
std::cerr << " name=\"" << it->second.name << "\" addr="
<< psprecomp::hex32(it->second.address)
<< " size=" << psprecomp::hex32(it->second.size);
}
std::cerr << "\n";
}
if (partition_table.blocks.erase(uid) == 1u) {
recompute_partition_frontier();
ctx.set_gpr(2, 0u);
} else {
ctx.set_gpr(2, 0x800200CBu);
}
});
runtime.register_hle("ThreadManForUser", 0x446D8DE6u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string name = ctx.gpr[4] != 0u ? rt.memory().read_c_string(ctx.gpr[4], 128u) : "unnamed";
const std::uint32_t requested_stack = ctx.gpr[7];
if (requested_stack < 0x200u) {
ctx.set_gpr(2, 0x80020194u);
return;
}
const std::uint32_t stack_size = (requested_stack + 0xFFu) & ~0xFFu;
std::uint32_t stack_bottom = 0u;
std::uint32_t stack_top = 0u;
if (!allocate_thread_stack(stack_size, stack_bottom, stack_top) ||
!rt.memory().contains(stack_bottom, stack_size)) {
std::ostringstream failure;
failure << "THREAD_CREATE stack_alloc_failed name=" << name
<< " requested=" << requested_stack
<< " aligned=" << stack_size
<< " partition_next=" << psprecomp::hex32(partition_table.next_address)
<< " stack_top=" << psprecomp::hex32(thread_table.next_stack_top)
<< " free_ranges=" << thread_table.free_stacks.size();
runtime_log_line(failure.str());
ctx.set_gpr(2, 0x80020190u);
return;
}
ThreadRecord record{
name,
ctx.gpr[5],
ctx.gpr[6],
stack_size,
ctx.gpr[8],
};
const std::int32_t uid = thread_table.next_uid++;
record.stack_top = stack_top;
record.stack_bottom = stack_bottom;
record.kernel_context = stack_top - 0x100u;
rt.memory().zero(stack_bottom, stack_size);
rt.memory().store32(stack_bottom, static_cast<std::uint32_t>(uid));
rt.memory().store32(record.kernel_context + 0xC0u, static_cast<std::uint32_t>(uid));
rt.memory().store32(record.kernel_context + 0xC8u, stack_bottom);
rt.memory().store32(record.kernel_context + 0xF8u, 0xFFFFFFFFu);
rt.memory().store32(record.kernel_context + 0xFCu, 0xFFFFFFFFu);
if (std::getenv("PSPRECOMP_TRACE") != nullptr || std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[sched] create uid=" << uid << " name=" << record.name
<< " entry=" << psprecomp::hex32(record.entry)
<< " priority=" << record.priority << " stack=" << record.stack_size
<< " range=" << psprecomp::hex32(record.stack_bottom) << "-"
<< psprecomp::hex32(record.stack_top) << "\n";
}
thread_table.threads.emplace(uid, std::move(record));
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ThreadManForUser", 0xF475845Du,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto it = thread_table.threads.find(uid);
if (it == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return;
}
ThreadRecord &thread = it->second;
if (thread.state != ThreadState::Created) {
ctx.set_gpr(2, 0x800201A4u);
return;
}
const std::uint32_t arg_size = ctx.gpr[5];
const std::uint32_t arg_ptr = ctx.gpr[6];
std::uint32_t sp = thread.kernel_context;
psprecomp::AllegrexContext next{};
if (arg_ptr != 0u && arg_size != 0u) {
const std::uint32_t aligned_args = (arg_size + 0xFu) & ~0xFu;
if (sp < thread.stack_bottom + aligned_args + 64u ||
!rt.memory().contains(arg_ptr, arg_size)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
sp -= aligned_args;
std::vector<std::uint8_t> arguments(arg_size);
rt.memory().copy_out(arg_ptr, arguments);
rt.memory().copy_in(sp, arguments);
next.set_gpr(4, arg_size);
next.set_gpr(5, sp);
} else {
next.set_gpr(4, 0u);
next.set_gpr(5, 0u);
}
// The PSP kernel consumes another 64 bytes and places the thread
// return trampoline at the bottom of that frame. Address zero is
// registered as the native thread-return target in this runtime.
sp -= 64u;
next.set_gpr(26, thread.kernel_context);
next.set_gpr(28, ctx.gpr[28]);
next.set_gpr(29, sp);
next.set_gpr(30, sp);
next.set_gpr(31, 0u);
next.pc = thread.entry;
enqueue_continuation(uid, next);
const std::int32_t caller_uid = thread_table.current_uid;
const std::uint32_t caller_priority = thread_priority(caller_uid);
if (std::getenv("PSPRECOMP_TRACE") != nullptr || std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[sched] start uid=" << uid << " name=" << thread.name
<< " entry=" << psprecomp::hex32(thread.entry)
<< " priority=" << thread.priority
<< " caller=" << caller_uid
<< " caller_priority=" << caller_priority << "\n";
}
// Starting a thread does not automatically hand it the CPU. It
// only preempts when its numeric PSP priority is strictly higher.
if (thread.priority < caller_priority) {
psprecomp::AllegrexContext caller = ctx;
caller.set_gpr(2, 0u);
caller.pc = ctx.gpr[31];
enqueue_continuation(caller_uid, caller);
(void)activate_next_thread(ctx, "thread-control");
} else {
set_success(ctx);
}
});
runtime.register_hle("ThreadManForUser", 0x809CE29Bu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
exit_delete_current_thread(rt, ctx);
});
runtime.register_hle("ThreadManForUser", 0x383F7BCCu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::int32_t uid = static_cast<std::int32_t>(ctx.gpr[4]);
if (uid == 0 || uid == thread_table.current_uid) {
ctx.set_gpr(2, 0x80020197u); // SCE_KERNEL_ERROR_ILLEGAL_THID
return;
}
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u); // SCE_KERNEL_ERROR_UNKNOWN_THID
return;
}
const bool was_active = found->second.state != ThreadState::Created &&
found->second.state != ThreadState::Completed;
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[thread] terminate-delete uid=" << uid
<< " name=" << found->second.name
<< " active=" << (was_active ? 1 : 0) << "\n";
}
thread_table.continuations.erase(
std::remove_if(thread_table.continuations.begin(), thread_table.continuations.end(),
[uid](const ThreadContinuation &item) { return item.uid == uid; }),
thread_table.continuations.end());
pending_guest_callbacks.erase(uid);
async_return_frames.erase(uid);
deferred_io_resumes.erase(uid);
refresh_vcs_post_dispatch_hook();
if (was_active) wake_thread_end_waiters(uid, 0x800201ACu);
else wake_thread_end_waiters(uid, 0u);
remove_thread_from_wait_queues(uid);
release_thread_stack(found->second);
thread_table.threads.erase(found);
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0x9FA03CD3u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::int32_t uid = static_cast<std::int32_t>(ctx.gpr[4]);
if (uid == 0 || uid == thread_table.current_uid) {
ctx.set_gpr(2, 0x800201A4u); // SCE_KERNEL_ERROR_NOT_DORMANT
return;
}
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return;
}
if (found->second.state != ThreadState::Created &&
found->second.state != ThreadState::Completed) {
ctx.set_gpr(2, 0x800201A4u);
return;
}
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr)
std::cerr << "[thread] delete uid=" << uid << " name=" << found->second.name << "\n";
remove_thread_from_wait_queues(uid);
pending_guest_callbacks.erase(uid);
async_return_frames.erase(uid);
deferred_io_resumes.erase(uid);
refresh_vcs_post_dispatch_hook();
release_thread_stack(found->second);
thread_table.threads.erase(found);
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0x9944F31Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::int32_t uid = static_cast<std::int32_t>(ctx.gpr[4]);
// The PSP does not accept 0 as an alias for the current thread
// here. Suspending the caller (explicitly or through 0) is
// illegal; only another live thread may be suspended.
if (uid == 0 || uid == thread_table.current_uid) {
ctx.set_gpr(2, 0x80020197u); // SCE_KERNEL_ERROR_ILLEGAL_THID
return;
}
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u); // SCE_KERNEL_ERROR_UNKNOWN_THID
return;
}
ThreadRecord &thread = found->second;
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[thread] suspend requested=" << uid
<< " current=" << thread_table.current_uid
<< " name=" << thread.name << " state=" << static_cast<int>(thread.state)
<< " continuations=" << thread_table.continuations.size() << "\n";
}
if (thread.state == ThreadState::Completed || thread.state == ThreadState::Created) {
ctx.set_gpr(2, 0x800201A2u); // SCE_KERNEL_ERROR_DORMANT
return;
}
if (thread.externally_suspended) {
ctx.set_gpr(2, 0x800201A3u); // SCE_KERNEL_ERROR_SUSPEND
return;
}
thread.externally_suspended = true;
const auto continuation = std::find_if(
thread_table.continuations.begin(), thread_table.continuations.end(),
[uid](const ThreadContinuation &item) { return item.uid == uid; });
if (continuation != thread_table.continuations.end()) {
thread.suspended_context = continuation->context;
thread.state = ThreadState::Ready;
thread_table.continuations.erase(continuation);
}
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0x75156E8Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::int32_t uid = static_cast<std::int32_t>(ctx.gpr[4]);
if (uid == 0 || uid == thread_table.current_uid) {
ctx.set_gpr(2, 0x80020197u); // SCE_KERNEL_ERROR_ILLEGAL_THID
return;
}
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u); // SCE_KERNEL_ERROR_UNKNOWN_THID
return;
}
ThreadRecord &thread = found->second;
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[thread] resume requested=" << uid
<< " current=" << thread_table.current_uid
<< " name=" << thread.name << " state=" << static_cast<int>(thread.state)
<< " continuations=" << thread_table.continuations.size() << "\n";
}
if (!thread.externally_suspended) {
ctx.set_gpr(2, 0x800201A5u); // SCE_KERNEL_ERROR_NOT_SUSPEND
return;
}
thread.externally_suspended = false;
if (thread.state == ThreadState::Ready)
enqueue_continuation(uid, thread.suspended_context);
set_success(ctx);
(void)preempt_if_higher_priority(ctx, "thread-resume");
});
runtime.register_hle("ThreadManForUser", 0x293B45B8u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, static_cast<std::uint32_t>(thread_table.current_uid));
});
runtime.register_hle("ThreadManForUser", 0x71BC9871u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
std::int32_t uid = static_cast<std::int32_t>(ctx.gpr[4]);
if (uid == 0)
uid = thread_table.current_uid;
std::uint32_t priority = ctx.gpr[5];
if (priority == 0u)
priority = thread_priority(thread_table.current_uid);
const auto found = thread_table.threads.find(uid);
if (found == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u); // SCE_KERNEL_ERROR_UNKNOWN_THID
return;
}
ThreadRecord &thread = found->second;
if (thread.state == ThreadState::Created || thread.state == ThreadState::Completed) {
ctx.set_gpr(2, 0x800201A2u); // SCE_KERNEL_ERROR_DORMANT
return;
}
if (priority < 0x08u || priority > 0x77u) {
ctx.set_gpr(2, 0x80020193u); // SCE_KERNEL_ERROR_ILLEGAL_PRIORITY
return;
}
thread.priority = priority;
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr) {
std::cerr << "[thread] priority uid=" << uid
<< " current=" << thread_table.current_uid
<< " value=" << priority << "\n";
}
// Changing priority is a scheduling point on the PSP. Save the
// current HLE return state only when a strictly higher-priority
// ready thread exists, then let the normal dispatcher select it.
const auto best = best_ready_thread();
const std::uint32_t current_priority = thread_priority(thread_table.current_uid);
if (best != thread_table.continuations.end() &&
thread_priority(best->uid) < current_priority) {
const std::int32_t caller_uid = thread_table.current_uid;
psprecomp::AllegrexContext caller = ctx;
caller.set_gpr(2, 0u);
caller.pc = ctx.gpr[31];
enqueue_continuation(caller_uid, caller);
(void)activate_next_thread(ctx, "thread-control");
return;
}
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0x110DEC9Au,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t output = ctx.gpr[5];
if (!rt.memory().contains(output, 8u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
rt.memory().store32(output, ctx.gpr[4]);
rt.memory().store32(output + 4u, 0u);
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0xC8CD158Cu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, ctx.gpr[4]);
ctx.set_gpr(3, 0u);
});
runtime.register_hle("ThreadManForUser", 0xBA6B92E2u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t clock = ctx.gpr[4];
const std::uint32_t seconds_out = ctx.gpr[5];
const std::uint32_t usec_out = ctx.gpr[6];
if (!rt.memory().contains(clock, 8u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
const std::uint64_t ticks = static_cast<std::uint64_t>(rt.memory().load32(clock)) |
(static_cast<std::uint64_t>(rt.memory().load32(clock + 4u)) << 32u);
if (rt.memory().contains(seconds_out, 4u))
rt.memory().store32(seconds_out, static_cast<std::uint32_t>(ticks / 1'000'000u));
if (rt.memory().contains(usec_out, 4u))
rt.memory().store32(usec_out, static_cast<std::uint32_t>(ticks % 1'000'000u));
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0xE1619D7Cu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint64_t ticks = static_cast<std::uint64_t>(ctx.gpr[4]) |
(static_cast<std::uint64_t>(ctx.gpr[5]) << 32u);
if (rt.memory().contains(ctx.gpr[6], 4u))
rt.memory().store32(ctx.gpr[6], static_cast<std::uint32_t>(ticks / 1'000'000u));
if (rt.memory().contains(ctx.gpr[7], 4u))
rt.memory().store32(ctx.gpr[7], static_cast<std::uint32_t>(ticks % 1'000'000u));
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0xDB738F35u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t output = ctx.gpr[4];
if (!rt.memory().contains(output, 8u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
const std::uint64_t usec = system_time_microseconds();
rt.memory().store32(output, static_cast<std::uint32_t>(usec));
rt.memory().store32(output + 4u, static_cast<std::uint32_t>(usec >> 32u));
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0x82BC5777u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint64_t usec = system_time_microseconds();
ctx.set_gpr(2, static_cast<std::uint32_t>(usec));
ctx.set_gpr(3, static_cast<std::uint32_t>(usec >> 32u));
});
runtime.register_hle("ThreadManForUser", 0x369ED59Du,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, static_cast<std::uint32_t>(system_time_microseconds()));
});
// The PSP profiler query APIs return a null profiler register block in
// ordinary user-mode execution. VCS probes both during startup.
const auto refer_profiler = [](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, 0u);
};
runtime.register_hle("ThreadManForUser", 0x64D4540Eu, refer_profiler);
runtime.register_hle("ThreadManForUser", 0x8218B4DDu, refer_profiler);
runtime.register_hle("ThreadManForUser", 0xEA748E31u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
// PSPSDK documents the first argument as reserved/zero. VCS uses
// this call to opt the current thread into VFPU context handling.
const std::uint32_t reserved = ctx.gpr[4];
const std::uint32_t attributes = ctx.gpr[5];
if (reserved != 0u) {
ctx.set_gpr(2, 0x800200D2u);
return;
}
if (auto current = thread_table.threads.find(thread_table.current_uid);
current != thread_table.threads.end()) {
current->second.attributes |= attributes;
}
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] sceKernelChangeCurrentThreadAttr uid="
<< thread_table.current_uid << " add=0x" << std::hex
<< std::uppercase << attributes << std::dec << "\n";
}
set_success(ctx);
});
auto sleep_thread = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
(void)sleep_current_thread(rt, ctx);
};
runtime.register_hle("ThreadManForUser", 0x9ACE131Eu, sleep_thread);
runtime.register_hle("ThreadManForUser", 0x82826F70u, sleep_thread);
runtime.register_hle("ThreadManForUser", 0xD59EAD2Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t result = wake_thread(static_cast<std::int32_t>(ctx.gpr[4]));
ctx.set_gpr(2, result);
if (result == 0u) (void)preempt_if_higher_priority(ctx, "thread-wakeup");
});
runtime.register_hle("ThreadManForUser", 0xFCCFAD26u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto found = thread_table.threads.find(static_cast<std::int32_t>(ctx.gpr[4]));
if (found == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return;
}
const std::uint32_t previous = found->second.wakeup_count;
found->second.wakeup_count = 0u;
ctx.set_gpr(2, previous);
});
runtime.register_hle("ThreadManForUser", 0xAA73C935u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (auto current = thread_table.threads.find(thread_table.current_uid);
current != thread_table.threads.end()) {
current->second.exit_status = ctx.gpr[4];
if (std::getenv("PSPRECOMP_THREAD_DIAG") != nullptr)
std::cerr << "[thread] exit uid=" << thread_table.current_uid
<< " name=" << current->second.name
<< " status=" << psprecomp::hex32(ctx.gpr[4]) << "\n";
}
complete_current_thread(rt, ctx);
});
runtime.register_hle("ThreadManForUser", 0x278C0DF5u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto it = thread_table.threads.find(uid);
if (uid <= 0 || it == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return;
}
if (it->second.state == ThreadState::Completed) {
set_success(ctx);
return;
}
psprecomp::AllegrexContext waiter = ctx;
waiter.set_gpr(2, 0u);
waiter.pc = ctx.gpr[31];
thread_table.thread_end_waiters[uid].push_back({thread_table.current_uid, waiter});
if (auto current = thread_table.threads.find(thread_table.current_uid);
current != thread_table.threads.end()) {
current->second.state = ThreadState::Sleeping;
current->second.suspended_context = waiter;
}
if (!activate_next_thread(ctx, "kernel-wait")) {
rt.stop("PSP thread wait deadlock on uid " + std::to_string(uid));
}
});
auto delay_thread = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
(void)delay_current_thread(rt, ctx, ctx.gpr[4]);
};
runtime.register_hle("ThreadManForUser", 0xCEADEB47u, delay_thread);
runtime.register_hle("ThreadManForUser", 0x68DA9E36u, delay_thread);
runtime.register_hle("ThreadManForUser", 0xE81CAF8Fu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string name = ctx.gpr[4] != 0u ? rt.memory().read_c_string(ctx.gpr[4], 128u) : "callback";
const std::int32_t uid = callback_table.next_uid++;
callback_table.callbacks.emplace(uid, CallbackRecord{
name, ctx.gpr[5], ctx.gpr[6], thread_table.current_uid, 0u, 0u});
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ThreadManForUser", 0xEDBA5844u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
ctx.set_gpr(2, callback_table.callbacks.erase(uid) == 1u ? 0u : 0x800201A1u);
});
runtime.register_hle("ThreadManForUser", 0x349D6D6Cu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
// Even an empty callback checkpoint consumes CPU time on real PSP
// hardware. Without this, a busy polling thread can freeze virtual
// time forever and starve delayed video/audio workers.
virtual_time_us += 25u;
promote_expired_delays();
auto pending = std::find_if(callback_table.callbacks.begin(), callback_table.callbacks.end(),
[](const auto &item) {
return item.second.owner_uid == thread_table.current_uid &&
item.second.notify_count != 0u && item.second.function != 0u;
});
if (pending == callback_table.callbacks.end()) {
set_success(ctx);
(void)preempt_if_higher_priority(ctx, "check-callback");
return;
}
CallbackRecord &callback = pending->second;
const std::uint32_t count = callback.notify_count;
const std::uint32_t argument = callback.notify_argument;
callback.notify_count = 0u;
psprecomp::AllegrexContext resume = ctx;
resume.pc = ctx.gpr[31];
resume.set_gpr(2, 1u);
auto &frames = async_return_frames[thread_table.current_uid];
if (!frames.empty()) {
ctx.set_gpr(2, 0u);
return;
}
frames.push_back(AsyncReturnFrame{AsyncReturnKind::UserCallback, resume, 0u, 0, 0, 0, pending->first});
ctx.set_gpr(4, count);
ctx.set_gpr(5, argument);
ctx.set_gpr(6, callback.common);
ctx.set_gpr(31, 0x00000004u);
ctx.pc = callback.function;
});
runtime.register_hle("ThreadManForUser", 0xD6DA4BA1u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string name = ctx.gpr[4] != 0u ? rt.memory().read_c_string(ctx.gpr[4], 128u) : "semaphore";
const auto initial = static_cast<std::int32_t>(ctx.gpr[6]);
const auto maximum = static_cast<std::int32_t>(ctx.gpr[7]);
if (initial < 0 || maximum <= 0 || initial > maximum) {
ctx.set_gpr(2, 0x800201B0u);
return;
}
const std::int32_t uid = semaphore_table.next_uid++;
semaphore_table.semaphores.emplace(uid, SemaphoreRecord{name, initial, maximum, {}});
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ThreadManForUser", 0x28B6489Cu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto found = semaphore_table.semaphores.find(uid);
if (found == semaphore_table.semaphores.end()) {
ctx.set_gpr(2, 0x80020199u);
return;
}
for (auto &waiter : found->second.waiters) {
waiter.context.set_gpr(2, 0x800201A7u);
enqueue_continuation(waiter.uid, waiter.context);
}
semaphore_table.semaphores.erase(found);
set_success(ctx);
(void)preempt_if_higher_priority(ctx, "semaphore-delete");
});
runtime.register_hle("ThreadManForUser", 0x3F53E640u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto amount = static_cast<std::int32_t>(ctx.gpr[5]);
const auto it = semaphore_table.semaphores.find(uid);
if (it == semaphore_table.semaphores.end() || amount <= 0 ||
static_cast<std::int64_t>(it->second.count) + amount > it->second.maximum) {
ctx.set_gpr(2, 0x80020199u);
return;
}
SemaphoreRecord &semaphore = it->second;
semaphore.count += amount;
auto waiter = semaphore.waiters.begin();
while (waiter != semaphore.waiters.end()) {
if (semaphore.count >= waiter->requested) {
semaphore.count -= waiter->requested;
waiter->context.set_gpr(2, 0u);
enqueue_continuation(waiter->uid, waiter->context);
waiter = semaphore.waiters.erase(waiter);
} else {
++waiter;
}
}
set_success(ctx);
(void)preempt_if_higher_priority(ctx, "semaphore-signal");
});
auto semaphore_wait = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto amount = static_cast<std::int32_t>(ctx.gpr[5]);
const auto it = semaphore_table.semaphores.find(uid);
if (it == semaphore_table.semaphores.end() || amount <= 0 || amount > it->second.maximum) {
ctx.set_gpr(2, 0x80020199u);
return;
}
if (it->second.count >= amount) {
it->second.count -= amount;
set_success(ctx);
return;
}
const psprecomp::AllegrexContext suspended = make_wait_context(ctx);
it->second.waiters.push_back(SemaphoreWaiter{thread_table.current_uid, suspended, amount});
(void)suspend_current_thread(rt, ctx, suspended, "semaphore " + std::to_string(uid));
};
runtime.register_hle("ThreadManForUser", 0x4E3A1105u, semaphore_wait);
runtime.register_hle("ThreadManForUser", 0x6D212BACu, semaphore_wait);
runtime.register_hle("ThreadManForUser", 0x58B1F937u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto amount = static_cast<std::int32_t>(ctx.gpr[5]);
const auto it = semaphore_table.semaphores.find(uid);
if (it == semaphore_table.semaphores.end() || amount <= 0 || amount > it->second.maximum) {
ctx.set_gpr(2, 0x80020199u);
return;
}
if (it->second.count < amount) {
ctx.set_gpr(2, 0x800201AEu);
return;
}
it->second.count -= amount;
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0x55C20A00u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string name = ctx.gpr[4] != 0u ? rt.memory().read_c_string(ctx.gpr[4], 128u) : "event_flag";
const std::int32_t uid = event_flag_table.next_uid++;
event_flag_table.flags.emplace(uid, EventFlagRecord{name, ctx.gpr[5], ctx.gpr[6], ctx.gpr[6], {}});
if (event_diag_matches(event_flag_table.flags.at(uid))) {
std::cerr << "[event] create uid=" << uid << " name=\"" << name << "\""
<< " attr=" << psprecomp::hex32(ctx.gpr[5])
<< " initial=" << psprecomp::hex32(ctx.gpr[6])
<< " thread=" << thread_table.current_uid
<< " dispatch_pc=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " ra=" << psprecomp::hex32(ctx.gpr[31]) << "\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ThreadManForUser", 0xEF9E4C70u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto found = event_flag_table.flags.find(uid);
if (found == event_flag_table.flags.end()) {
ctx.set_gpr(2, 0x8002019Au);
return;
}
for (auto &waiter : found->second.waiters) {
waiter.context.set_gpr(2, 0x800201A7u);
enqueue_continuation(waiter.uid, waiter.context);
}
event_flag_table.flags.erase(found);
set_success(ctx);
(void)preempt_if_higher_priority(ctx, "event-flag-delete");
});
runtime.register_hle("ThreadManForUser", 0x1FB15A32u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto it = event_flag_table.flags.find(static_cast<std::int32_t>(ctx.gpr[4]));
if (it == event_flag_table.flags.end()) {
ctx.set_gpr(2, 0x8002019Au);
return;
}
EventFlagRecord &flag = it->second;
const std::uint32_t previous_pattern = flag.current_pattern;
flag.current_pattern |= ctx.gpr[5];
if (event_diag_matches(flag)) {
std::cerr << "[event] set uid=" << static_cast<std::int32_t>(ctx.gpr[4])
<< " name=\"" << flag.name << "\""
<< " bits=" << psprecomp::hex32(ctx.gpr[5])
<< " old=" << psprecomp::hex32(previous_pattern)
<< " new=" << psprecomp::hex32(flag.current_pattern)
<< " thread=" << thread_table.current_uid
<< " dispatch_pc=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " ctx_pc=" << psprecomp::hex32(ctx.pc)
<< " ra=" << psprecomp::hex32(ctx.gpr[31]) << "\n";
}
auto waiter = flag.waiters.begin();
while (waiter != flag.waiters.end()) {
if (!event_flag_matches(flag, waiter->requested, waiter->mode)) {
++waiter;
continue;
}
if (waiter->output_address != 0u && rt.memory().contains(waiter->output_address, 4u))
rt.memory().store32(waiter->output_address, flag.current_pattern);
consume_event_flag(flag, waiter->requested, waiter->mode);
waiter->context.set_gpr(2, 0u);
enqueue_continuation(waiter->uid, waiter->context);
waiter = flag.waiters.erase(waiter);
}
set_success(ctx);
(void)preempt_if_higher_priority(ctx, "event-flag-set");
});
runtime.register_hle("ThreadManForUser", 0x812346E4u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto it = event_flag_table.flags.find(static_cast<std::int32_t>(ctx.gpr[4]));
if (it == event_flag_table.flags.end()) {
ctx.set_gpr(2, 0x8002019Au);
return;
}
// PSP clear semantics retain only the bits present in the mask.
const std::uint32_t previous_pattern = it->second.current_pattern;
it->second.current_pattern &= ctx.gpr[5];
if (event_diag_matches(it->second)) {
std::cerr << "[event] clear uid=" << static_cast<std::int32_t>(ctx.gpr[4])
<< " name=\"" << it->second.name << "\""
<< " mask=" << psprecomp::hex32(ctx.gpr[5])
<< " old=" << psprecomp::hex32(previous_pattern)
<< " new=" << psprecomp::hex32(it->second.current_pattern)
<< " thread=" << thread_table.current_uid
<< " dispatch_pc=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " ctx_pc=" << psprecomp::hex32(ctx.pc)
<< " ra=" << psprecomp::hex32(ctx.gpr[31]) << "\n";
}
set_success(ctx);
});
auto event_flag_wait = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto it = event_flag_table.flags.find(uid);
if (it == event_flag_table.flags.end()) {
ctx.set_gpr(2, 0x8002019Au);
return;
}
const std::uint32_t requested = ctx.gpr[5];
const std::uint32_t mode = ctx.gpr[6];
if (requested == 0u || (mode & ~0x31u) != 0u) {
ctx.set_gpr(2, 0x800201B1u);
return;
}
if (event_flag_matches(it->second, requested, mode)) {
if (ctx.gpr[7] != 0u && rt.memory().contains(ctx.gpr[7], 4u))
rt.memory().store32(ctx.gpr[7], it->second.current_pattern);
consume_event_flag(it->second, requested, mode);
set_success(ctx);
return;
}
const psprecomp::AllegrexContext suspended = make_wait_context(ctx);
it->second.waiters.push_back(EventFlagWaiter{
thread_table.current_uid, suspended, requested, mode, ctx.gpr[7]});
(void)suspend_current_thread(rt, ctx, suspended, "event flag " + std::to_string(uid));
};
runtime.register_hle("ThreadManForUser", 0x402FCF22u, event_flag_wait);
runtime.register_hle("ThreadManForUser", 0x328C546Au, event_flag_wait);
runtime.register_hle("ThreadManForUser", 0x30FD48F0u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto it = event_flag_table.flags.find(static_cast<std::int32_t>(ctx.gpr[4]));
if (it == event_flag_table.flags.end()) {
ctx.set_gpr(2, 0x8002019Au);
return;
}
const std::uint32_t requested = ctx.gpr[5];
const std::uint32_t mode = ctx.gpr[6];
const bool diag = event_diag_matches(it->second);
if (diag) ++event_diag_poll_count;
if (diag && (event_diag_poll_count <= 32u || event_diag_poll_count % 100000u == 0u)) {
std::cerr << "[event] poll count=" << event_diag_poll_count
<< " uid=" << static_cast<std::int32_t>(ctx.gpr[4])
<< " name=\"" << it->second.name << "\""
<< " requested=" << psprecomp::hex32(requested)
<< " mode=" << psprecomp::hex32(mode)
<< " current=" << psprecomp::hex32(it->second.current_pattern)
<< " match=" << (event_flag_matches(it->second, requested, mode) ? 1 : 0)
<< " thread=" << thread_table.current_uid
<< " dispatch_pc=" << psprecomp::hex32(psprecomp::runtime_dispatch_pc())
<< " ctx_pc=" << psprecomp::hex32(ctx.pc)
<< " ra=" << psprecomp::hex32(ctx.gpr[31]) << "\n";
}
if (diag && !event_diag_stall_reported && event_diag_stop_polls != 0u &&
event_diag_poll_count >= event_diag_stop_polls) {
event_diag_stall_reported = true;
dump_event_stall_state(it->second, static_cast<std::int32_t>(ctx.gpr[4]), ctx);
rt.stop("Target event flag exceeded PSPRECOMP_EVENT_DIAG_STOP_POLLS; scheduler state captured.");
return;
}
if (!event_flag_matches(it->second, requested, mode)) {
if (ctx.gpr[7] != 0u && rt.memory().contains(ctx.gpr[7], 4u))
rt.memory().store32(ctx.gpr[7], it->second.current_pattern);
ctx.set_gpr(2, 0x800201AFu);
return;
}
if (ctx.gpr[7] != 0u && rt.memory().contains(ctx.gpr[7], 4u))
rt.memory().store32(ctx.gpr[7], it->second.current_pattern);
consume_event_flag(it->second, requested, mode);
set_success(ctx);
});
runtime.register_hle("ThreadManForUser", 0xC07BB470u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string name = ctx.gpr[4] != 0u ? rt.memory().read_c_string(ctx.gpr[4], 128u) : "fpl";
const std::uint32_t block_size = ctx.gpr[7];
const std::uint32_t block_count = ctx.gpr[8];
if (block_size == 0u || block_count == 0u ||
block_size > 0xFFFFFFFFu / block_count) {
ctx.set_gpr(2, 0x800201B0u);
return;
}
const std::uint32_t alignment = 0x100u;
const std::uint32_t total = block_size * block_count;
if (total > 0xFFFFFFFFu - (alignment - 1u)) {
ctx.set_gpr(2, 0x80020190u);
return;
}
const std::uint32_t reserved = (total + alignment - 1u) & ~(alignment - 1u);
std::uint32_t address{};
if (!allocate_user_arena_range(reserved, alignment, address) ||
!rt.memory().contains(address, reserved)) {
runtime_log_line("FPL_CREATE failed name=" + name +
" size=" + std::to_string(reserved) +
" frontier=" + psprecomp::hex32(partition_table.next_address) +
" stack_top=" + psprecomp::hex32(thread_table.next_stack_top));
ctx.set_gpr(2, 0x80020190u);
return;
}
rt.memory().zero(address, reserved);
const std::int32_t uid = fixed_pool_table.next_uid++;
fixed_pool_table.pools.emplace(uid, FixedPoolRecord{name, address, block_size, block_count,
std::vector<bool>(block_count, false)});
recompute_partition_frontier();
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] sceKernelCreateFpl uid=" << uid << " name=" << name
<< " block=0x" << std::hex << std::uppercase << block_size
<< " count=" << std::dec << block_count << " base=0x"
<< std::hex << std::uppercase << address << std::dec << "\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ThreadManForUser", 0xD979E9BFu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const std::uint32_t output = ctx.gpr[5];
const auto it = fixed_pool_table.pools.find(uid);
if (it == fixed_pool_table.pools.end() || !rt.memory().contains(output, 4u)) {
ctx.set_gpr(2, 0x800201A8u);
return;
}
auto &pool = it->second;
const auto free_it = std::find(pool.allocated.begin(), pool.allocated.end(), false);
if (free_it == pool.allocated.end()) {
ctx.set_gpr(2, 0x80020190u);
return;
}
const std::size_t index = static_cast<std::size_t>(free_it - pool.allocated.begin());
pool.allocated[index] = true;
rt.memory().store32(output, pool.address + static_cast<std::uint32_t>(index) * pool.block_size);
set_success(ctx);
});
// sceKernelTryAllocateFpl. The allocation above never blocks, so the
// try-form is the same call: it either has a free block or it does not.
runtime.register_hle("ThreadManForUser", 0x623AE665u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
rt.invoke_import("ThreadManForUser", 0xD979E9BFu, ctx);
});
// sceKernelFreeFpl.
runtime.register_hle("ThreadManForUser", 0xF6414A71u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const std::uint32_t block = ctx.gpr[5];
const auto it = fixed_pool_table.pools.find(uid);
if (it == fixed_pool_table.pools.end()) {
ctx.set_gpr(2, 0x800201A8u);
return;
}
auto &pool = it->second;
if (block < pool.address || pool.block_size == 0u) {
ctx.set_gpr(2, 0x800201A9u);
return;
}
const std::uint32_t offset = block - pool.address;
const std::size_t index = offset / pool.block_size;
if (offset % pool.block_size != 0u || index >= pool.allocated.size()) {
ctx.set_gpr(2, 0x800201A9u);
return;
}
pool.allocated[index] = false;
set_success(ctx);
});
// sceKernelDeleteFpl. Loading a saved game from inside the game tears down
// the previous session's pools, which is why this only ever mattered there:
// the import was missing and the runtime stopped on a black screen.
runtime.register_hle("ThreadManForUser", 0xED1410E0u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto it = fixed_pool_table.pools.find(uid);
if (it == fixed_pool_table.pools.end()) {
ctx.set_gpr(2, 0x800201A8u);
return;
}
fixed_pool_table.pools.erase(it);
recompute_partition_frontier();
set_success(ctx);
});
auto volatile_mem_lock = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
constexpr std::uint32_t volatile_base = 0x08400000u;
constexpr std::uint32_t volatile_size = 0x00400000u;
if (ctx.gpr[4] != 0u) {
ctx.set_gpr(2, 0x80000107u);
return;
}
if (volatile_memory_locked) {
ctx.set_gpr(2, 0x80000021u);
return;
}
if (!rt.memory().contains(volatile_base, volatile_size) ||
!rt.memory().contains(ctx.gpr[5], 4u) || !rt.memory().contains(ctx.gpr[6], 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
rt.memory().store32(ctx.gpr[5], volatile_base);
rt.memory().store32(ctx.gpr[6], volatile_size);
rt.memory().zero(volatile_base, volatile_size);
volatile_memory_locked = true;
set_success(ctx);
};
runtime.register_hle("sceSuspendForUser", 0x3E0271D3u, volatile_mem_lock);
runtime.register_hle("sceSuspendForUser", 0xA14F40B2u, volatile_mem_lock);
runtime.register_hle("sceSuspendForUser", 0xA569E425u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] != 0u) {
ctx.set_gpr(2, 0x80000107u);
return;
}
if (!volatile_memory_locked) {
ctx.set_gpr(2, 0x800201AEu);
return;
}
volatile_memory_locked = false;
set_success(ctx);
});
runtime.register_hle("sceSuspendForUser", 0xEADB1BD7u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, ctx.gpr[4] == 0u ? 0u : 0x80000107u);
});
runtime.register_hle("sceSuspendForUser", 0x3AEE7261u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, ctx.gpr[4] == 0u ? 0u : 0x80000107u);
});
runtime.register_hle("sceSuspendForUser", 0x090CCB3Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("UtilsForUser", 0x37FB5C42u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, general_purpose_io);
});
runtime.register_hle("UtilsForUser", 0x6AD345D7u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
general_purpose_io = ctx.gpr[4];
set_success(ctx);
});
// The statically recompiled CPU and host share one coherent guest-memory
// backing store. Data-cache maintenance is therefore complete at the
// call boundary. Instruction-cache invalidation is recorded as success;
// dynamically loaded executable modules are handled by the PRX loader,
// rather than by mutating the generated host code in place.
auto cache_maintenance = [](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
set_success(ctx);
};
runtime.register_hle("UtilsForUser", 0xBFA98062u, cache_maintenance);
runtime.register_hle("UtilsForUser", 0x79D1C3FAu, cache_maintenance);
runtime.register_hle("UtilsForUser", 0xB435DEC5u, cache_maintenance);
runtime.register_hle("UtilsForUser", 0x3EE30821u, cache_maintenance);
runtime.register_hle("UtilsForUser", 0x34B9FA9Eu, cache_maintenance);
runtime.register_hle("UtilsForUser", 0x920F104Au, cache_maintenance);
runtime.register_hle("UtilsForUser", 0xC2DF770Eu, cache_maintenance);
runtime.register_hle("UtilsForUser", 0x80001C4Cu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0u); });
runtime.register_hle("UtilsForUser", 0x16641D70u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0u); });
runtime.register_hle("UtilsForUser", 0x4FD31C9Du,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0u); });
runtime.register_hle("UtilsForUser", 0xFB05FAD0u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0u); });
runtime.register_hle("sceCtrl", 0x6A2774F3u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t previous = controller_state.sampling_cycle;
controller_state.sampling_cycle = ctx.gpr[4];
ctx.set_gpr(2, previous);
});
runtime.register_hle("sceCtrl", 0x02BAAD91u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] == 0u || !rt.memory().contains(ctx.gpr[4], 4u)) {
ctx.set_gpr(2, 0x80000103u);
return;
}
rt.memory().store32(ctx.gpr[4], controller_state.sampling_cycle);
set_success(ctx);
});
runtime.register_hle("sceCtrl", 0x1F4011E6u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] > 1u) {
ctx.set_gpr(2, 0x80000107u);
return;
}
const std::uint32_t previous = controller_state.sampling_mode;
controller_state.sampling_mode = ctx.gpr[4];
ctx.set_gpr(2, previous);
});
runtime.register_hle("sceCtrl", 0xDA6B76A1u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] == 0u || !rt.memory().contains(ctx.gpr[4], 4u)) {
ctx.set_gpr(2, 0x80000103u);
return;
}
rt.memory().store32(ctx.gpr[4], controller_state.sampling_mode);
set_success(ctx);
});
auto write_controller_samples = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx, bool negative) {
const std::uint32_t destination = ctx.gpr[4];
const std::uint32_t count = ctx.gpr[5];
constexpr std::uint32_t sample_size = 16u;
if (count == 0u) {
ctx.set_gpr(2, 0u);
return;
}
if (count > 64u || !rt.memory().contains(destination, static_cast<std::size_t>(count) * sample_size)) {
ctx.set_gpr(2, 0x80000103u);
return;
}
for (std::uint32_t index = 0u; index < count; ++index) {
const std::uint32_t sample = destination + index * sample_size;
rt.memory().store32(sample, static_cast<std::uint32_t>(system_time_microseconds()));
const std::uint32_t buttons = negative ? ~effective_controller_buttons() : effective_controller_buttons();
rt.memory().store32(sample + 4u, buttons);
const auto [analog_x, analog_y] = effective_controller_analog();
rt.memory().store8(sample + 8u, controller_state.sampling_mode != 0u ? analog_x : 128u);
rt.memory().store8(sample + 9u, controller_state.sampling_mode != 0u ? analog_y : 128u);
rt.memory().store8(sample + 10u, controller_state.rx);
rt.memory().store8(sample + 11u, controller_state.ry);
rt.memory().zero(sample + 12u, 4u);
}
ctx.set_gpr(2, count);
};
runtime.register_hle("sceCtrl", 0x3A622550u,
[write_controller_samples](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { write_controller_samples(rt, ctx, false); });
runtime.register_hle("sceCtrl", 0x1F803938u,
[write_controller_samples](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { write_controller_samples(rt, ctx, false); });
runtime.register_hle("sceCtrl", 0xC152080Au,
[write_controller_samples](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { write_controller_samples(rt, ctx, true); });
runtime.register_hle("sceCtrl", 0x60B81F86u,
[write_controller_samples](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { write_controller_samples(rt, ctx, true); });
auto controller_latch = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] == 0u || !rt.memory().contains(ctx.gpr[4], 16u)) {
ctx.set_gpr(2, 0x80000103u);
return;
}
rt.memory().store32(ctx.gpr[4] + 0u, 0u);
rt.memory().store32(ctx.gpr[4] + 4u, 0u);
rt.memory().store32(ctx.gpr[4] + 8u, effective_controller_buttons());
rt.memory().store32(ctx.gpr[4] + 12u, ~effective_controller_buttons());
ctx.set_gpr(2, 0u);
};
runtime.register_hle("sceCtrl", 0xB1D0E5CDu, controller_latch);
runtime.register_hle("sceCtrl", 0x0B588501u, controller_latch);
runtime.register_hle("sceGe_user", 0xE47E40E4u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, psprecomp::GuestMemory::kVramPhysicalBase);
});
runtime.register_hle("sceGe_user", 0x1F6752ADu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, psprecomp::GuestMemory::kVramSize);
});
runtime.register_hle("sceGe_user", 0xB77905EAu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t requested = ctx.gpr[4];
const bool valid_range = requested == 0u || (requested >= 0x200u && requested <= 0x1000u);
const bool power_of_two = requested == 0u || (requested & (requested - 1u)) == 0u;
if (!valid_range || !power_of_two) {
ctx.set_gpr(2, 0x800001FEu);
return;
}
const std::uint32_t previous = ge_edram_translation;
ge_edram_translation = requested;
ctx.set_gpr(2, previous);
});
runtime.register_hle("sceGe_user", 0xA4FC06A4u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!ge_async_wait_idle(rt)) return;
const std::uint32_t callback_data = ctx.gpr[4];
if (callback_data == 0u || !rt.memory().contains(callback_data, 16u)) {
ctx.set_gpr(2, 0x800200D3u); // SCE_KERNEL_ERROR_ILLEGAL_ADDR
return;
}
GeCallbackRecord record{
rt.memory().load32(callback_data + 0u),
rt.memory().load32(callback_data + 4u),
rt.memory().load32(callback_data + 8u),
rt.memory().load32(callback_data + 12u),
};
const std::int32_t uid = ge_callback_table.next_uid++;
ge_callback_table.callbacks.emplace(uid, record);
if (std::getenv("PSPRECOMP_GE_DIAG") != nullptr) {
std::cerr << "[ge] callback uid=" << uid
<< " signal=" << psprecomp::hex32(record.signal_function)
<< " finish=" << psprecomp::hex32(record.finish_function) << "\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("sceGe_user", 0x05DB22CEu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!ge_async_wait_idle(rt)) return;
const std::int32_t uid = static_cast<std::int32_t>(ctx.gpr[4]);
ctx.set_gpr(2, ge_callback_table.callbacks.erase(uid) == 1u ? 0u : 0x80000100u);
});
runtime.register_hle("sceGe_user", 0xAB49E76Au,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
enqueue_ge_display_list(rt, ctx, false);
});
runtime.register_hle("sceGe_user", 0x1C0D95A6u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
enqueue_ge_display_list(rt, ctx, true);
});
runtime.register_hle("sceGe_user", 0x5FB86AB0u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t id = ctx.gpr[4];
if (ge_async_running()) {
std::lock_guard lock(ge_async.mutex);
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) {
ctx.set_gpr(2, 0x80000100u);
return;
}
if (found->second.state == GeListState::Running) {
ctx.set_gpr(2, 0x800201A7u);
return;
}
if (found->second.state == GeListState::Queued) {
const auto before = ge_async.pending.size();
std::erase_if(ge_async.pending, [id](const GeAsyncTask &task) { return task.id == id; });
if (ge_async.pending.size() != before)
ge_async.outstanding.fetch_sub(1u, std::memory_order_acq_rel);
ge_async.live_stalls.erase(id);
}
found->second.state = GeListState::None;
ge_list_table.queue.erase(std::remove(ge_list_table.queue.begin(), ge_list_table.queue.end(), id),
ge_list_table.queue.end());
ge_async.cv.notify_all();
set_success(ctx);
return;
}
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) {
ctx.set_gpr(2, 0x80000100u);
return;
}
if (found->second.state == GeListState::Running) {
ctx.set_gpr(2, 0x800201A7u);
return;
}
found->second.state = GeListState::None;
ge_list_table.queue.erase(std::remove(ge_list_table.queue.begin(), ge_list_table.queue.end(), id),
ge_list_table.queue.end());
set_success(ctx);
});
runtime.register_hle("sceGe_user", 0xE0D68148u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t id = ctx.gpr[4];
if ((ctx.gpr[5] & 3u) != 0u) {
ctx.set_gpr(2, 0x80000103u);
return;
}
const std::uint32_t new_stall = ctx.gpr[5] & 0x0FFFFFFFu;
if (ge_async_running()) {
ge_async_start_worker(rt);
bool resumed = false;
{
std::lock_guard lock(ge_async.mutex);
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) {
ctx.set_gpr(2, 0x80000100u);
return;
}
found->second.stall = new_stall;
if (const auto active = ge_async.live_stalls.find(id);
active != ge_async.live_stalls.end()) {
active->second->store(new_stall, std::memory_order_release);
} else if (found->second.state == GeListState::Stalled) {
auto stall = std::make_shared<std::atomic<std::uint32_t>>(new_stall);
ge_async.live_stalls[id] = stall;
found->second.state = GeListState::Queued;
ge_async.pending.push_back(GeAsyncTask{id, thread_table.current_uid, stall});
ge_async.outstanding.fetch_add(1u, std::memory_order_release);
++ge_async.submitted;
resumed = true;
}
}
if (resumed) ge_async.cv.notify_one();
set_success(ctx);
return;
}
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) {
ctx.set_gpr(2, 0x80000100u);
return;
}
found->second.stall = new_stall;
std::vector<GuestCallbackInvocation> callbacks;
if (found->second.state == GeListState::Stalled && !execute_ge_list(rt, found->second, callbacks)) return;
psprecomp::AllegrexContext resume = ctx;
resume.set_gpr(2, 0u);
resume.pc = ctx.gpr[31];
queue_guest_callback_chain(ctx, resume, std::move(callbacks));
});
runtime.register_hle("sceGe_user", 0x03444EB4u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t id = ctx.gpr[4];
if (ctx.gpr[5] > 1u) {
ctx.set_gpr(2, 0x800001FEu);
return;
}
if (ge_async_running()) {
if (ctx.gpr[5] == 0u && !ge_async_wait_list(rt, id)) return;
std::lock_guard lock(ge_async.mutex);
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) {
ctx.set_gpr(2, 0x80000100u);
return;
}
ctx.set_gpr(2, ctx.gpr[5] == 1u ? ge_list_status(found->second) :
(found->second.state == GeListState::Completed ? 0u : ge_list_status(found->second)));
return;
}
const auto found = ge_list_table.lists.find(id);
if (found == ge_list_table.lists.end()) {
ctx.set_gpr(2, 0x80000100u);
return;
}
ctx.set_gpr(2, ctx.gpr[5] == 1u ? ge_list_status(found->second) :
(found->second.state == GeListState::Completed ? 0u : ge_list_status(found->second)));
});
runtime.register_hle("sceGe_user", 0xB287BD61u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] > 1u) {
ctx.set_gpr(2, 0x800001FEu);
return;
}
if (ge_async_running()) {
if (ctx.gpr[4] == 0u) {
if (!ge_async_wait_idle(rt)) return;
ctx.set_gpr(2, 0u);
return;
}
std::lock_guard lock(ge_async.mutex);
std::uint32_t state = 0u;
for (const auto &[id, list] : ge_list_table.lists) {
(void)id;
state = std::max(state, ge_list_status(list));
}
ctx.set_gpr(2, state);
return;
}
std::uint32_t state = 0u;
for (const auto &[id, list] : ge_list_table.lists) {
(void)id;
state = std::max(state, ge_list_status(list));
}
ctx.set_gpr(2, ctx.gpr[4] == 0u ? 0u : state);
});
runtime.register_hle("sceGe_user", 0xDC93CFEFu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!ge_async_wait_idle(rt)) return;
const std::uint32_t command = ctx.gpr[4];
ctx.set_gpr(2, command < ge_state.commands.size() ? ge_state.commands[command] : 0x80000102u);
});
runtime.register_hle("sceGe_user", 0x438A385Au,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!ge_async_wait_idle(rt)) return;
if (ctx.gpr[4] == 0u || !rt.memory().contains(ctx.gpr[4], 512u * 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
for (std::uint32_t index = 0u; index < ge_state.commands.size(); ++index)
rt.memory().store32(ctx.gpr[4] + index * 4u, ge_state.commands[index]);
for (std::uint32_t index = static_cast<std::uint32_t>(ge_state.commands.size()); index < 512u; ++index)
rt.memory().store32(ctx.gpr[4] + index * 4u, 0u);
set_success(ctx);
});
runtime.register_hle("sceGe_user", 0x0BF608FBu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!ge_async_wait_idle(rt)) return;
if (ctx.gpr[4] == 0u || !rt.memory().contains(ctx.gpr[4], 512u * 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
for (std::uint32_t index = 0u; index < ge_state.commands.size(); ++index)
ge_state.commands[index] = rt.memory().load32(ctx.gpr[4] + index * 4u);
++ge_draw_state_revision;
++ge_lighting_state_revision;
++ge_camera_state_revision;
ge_state.offset_address = ge_state.commands[kGeCommandOffsetAddress] << 8u;
set_success(ctx);
});
runtime.register_hle("InterruptManager", 0xCA04A2B9u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t interrupt_number = ctx.gpr[4];
const std::uint32_t sub_number = ctx.gpr[5];
const std::uint32_t handler = ctx.gpr[6];
const std::uint32_t argument = ctx.gpr[7];
if (interrupt_number >= 67u || handler == 0u) {
ctx.set_gpr(2, 0x80020064u);
return;
}
const std::uint64_t key = sub_interrupt_key(interrupt_number, sub_number);
if (sub_interrupts.contains(key)) {
ctx.set_gpr(2, 0x80020067u); // handler already present
return;
}
sub_interrupts.emplace(key, SubInterruptRecord{handler, argument, false, false});
if (std::getenv("PSPRECOMP_GE_DIAG") != nullptr) {
std::cerr << "[intr] register int=" << interrupt_number << " sub=" << sub_number
<< " handler=" << psprecomp::hex32(handler)
<< " arg=" << psprecomp::hex32(argument) << "\n";
}
set_success(ctx);
});
runtime.register_hle("InterruptManager", 0xD61E6961u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint64_t key = sub_interrupt_key(ctx.gpr[4], ctx.gpr[5]);
ctx.set_gpr(2, sub_interrupts.erase(key) == 1u ? 0u : 0x80020068u);
});
runtime.register_hle("InterruptManager", 0xFB8E22ECu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto found = sub_interrupts.find(sub_interrupt_key(ctx.gpr[4], ctx.gpr[5]));
if (found == sub_interrupts.end()) { ctx.set_gpr(2, 0x80020068u); return; }
found->second.enabled = true;
set_success(ctx);
});
runtime.register_hle("InterruptManager", 0x8A389411u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto found = sub_interrupts.find(sub_interrupt_key(ctx.gpr[4], ctx.gpr[5]));
if (found == sub_interrupts.end()) { ctx.set_gpr(2, 0x80020068u); return; }
found->second.enabled = false;
set_success(ctx);
});
runtime.register_hle("InterruptManager", 0x5CB5A78Bu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto found = sub_interrupts.find(sub_interrupt_key(ctx.gpr[4], ctx.gpr[5]));
if (found == sub_interrupts.end()) { ctx.set_gpr(2, 0x80020068u); return; }
if (ctx.gpr[6] != 0u) {
if (!rt.memory().contains(ctx.gpr[6], 4u)) { ctx.set_gpr(2, 0x800200D3u); return; }
rt.memory().store32(ctx.gpr[6], found->second.enabled ? 1u : 0u);
}
found->second.enabled = false;
set_success(ctx);
});
runtime.register_hle("InterruptManager", 0x7860E0DCu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto found = sub_interrupts.find(sub_interrupt_key(ctx.gpr[4], ctx.gpr[5]));
if (found == sub_interrupts.end()) { ctx.set_gpr(2, 0x80020068u); return; }
found->second.enabled = ctx.gpr[6] != 0u;
set_success(ctx);
});
runtime.register_hle("InterruptManager", 0xFC4374B8u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto found = sub_interrupts.find(sub_interrupt_key(ctx.gpr[4], ctx.gpr[5]));
ctx.set_gpr(2, found != sub_interrupts.end() && found->second.occurred ? 1u : 0u);
});
runtime.register_hle("sceDisplay", 0x0E20F177u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t mode = ctx.gpr[4];
const std::uint32_t width = ctx.gpr[5];
const std::uint32_t height = ctx.gpr[6];
if (mode != 0u || width == 0u || width > 480u || height == 0u || height > 272u) {
ctx.set_gpr(2, 0x80000107u);
return;
}
display_state.mode = mode;
display_state.width = width;
display_state.height = height;
if (std::getenv("PSPRECOMP_DISPLAY_DIAG") != nullptr) {
std::cerr << "[display] mode=" << mode << " " << width << "x" << height << "\n";
}
set_success(ctx);
});
runtime.register_hle("sceDisplay", 0xDEA197D4u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (ctx.gpr[4] != 0u) {
if (!rt.memory().contains(ctx.gpr[4], 4u)) { ctx.set_gpr(2, 0x800200D3u); return; }
rt.memory().store32(ctx.gpr[4], display_state.mode);
}
if (ctx.gpr[5] != 0u) {
if (!rt.memory().contains(ctx.gpr[5], 4u)) { ctx.set_gpr(2, 0x800200D3u); return; }
rt.memory().store32(ctx.gpr[5], display_state.width);
}
if (ctx.gpr[6] != 0u) {
if (!rt.memory().contains(ctx.gpr[6], 4u)) { ctx.set_gpr(2, 0x800200D3u); return; }
rt.memory().store32(ctx.gpr[6], display_state.height);
}
set_success(ctx);
});
runtime.register_hle("sceDisplay", 0x289D82FEu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t address = ctx.gpr[4];
const std::uint32_t stride = ctx.gpr[5];
const std::uint32_t format = ctx.gpr[6];
const std::uint32_t sync = ctx.gpr[7];
if (address != 0u && !rt.memory().contains(address, 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
if (stride != 0u && (stride < display_state.width || stride > 2048u)) {
ctx.set_gpr(2, 0x80000107u);
return;
}
if (format > 3u || sync > 1u) {
ctx.set_gpr(2, 0x80000107u);
return;
}
display_state.frame_buffer = address;
display_state.buffer_width = stride;
display_state.pixel_format = format;
display_state.sync_mode = sync;
if (std::getenv("PSPRECOMP_DISPLAY_DIAG") != nullptr) {
std::cerr << "[display] framebuffer=" << psprecomp::hex32(address)
<< " stride=" << stride << " format=" << format
<< " sync=" << sync << "\n";
}
set_success(ctx);
});
runtime.register_hle("sceDisplay", 0xEEDA2E54u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t address_out = ctx.gpr[4];
const std::uint32_t stride_out = ctx.gpr[5];
const std::uint32_t format_out = ctx.gpr[6];
const std::uint32_t sync = ctx.gpr[7];
if (sync > 1u) { ctx.set_gpr(2, 0x80000107u); return; }
for (const auto [ptr, value] : std::array<std::pair<std::uint32_t, std::uint32_t>, 3>{
std::pair{address_out, display_state.frame_buffer},
std::pair{stride_out, display_state.buffer_width},
std::pair{format_out, display_state.pixel_format}}) {
if (ptr != 0u) {
if (!rt.memory().contains(ptr, 4u)) { ctx.set_gpr(2, 0x800200D3u); return; }
rt.memory().store32(ptr, value);
}
}
set_success(ctx);
});
runtime.register_hle("sceDisplay", 0xDBA6C4C4u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.fpr[0] = 59.94005994f *
(static_cast<float>(virtual_display_refresh_hz()) / 60.0f);
});
runtime.register_hle("sceDisplay", 0x9C6EAAD7u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, static_cast<std::uint32_t>(
(virtual_time_us * virtual_display_refresh_hz()) / 1000000u));
});
runtime.register_hle("sceDisplay", 0x4D4E10ECu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint64_t period = virtual_vblank_period_us();
const std::uint64_t blank = std::max<std::uint64_t>(
1u, (731u * 60u) / virtual_display_refresh_hz());
const std::uint64_t phase = virtual_time_us % period;
ctx.set_gpr(2, phase < blank ? 1u : 0u);
});
auto wait_vblank = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
// The display consumes the completed GE frame. This is a real PSP
// visibility boundary: allow guest/GE overlap during the frame, then
// wait only here before framebuffer presentation and vblank callbacks.
if (!ge_async_wait_idle(rt)) return;
++display_vblank_index;
// First-boot frontend + native pause-menu mouse state. TITLES.PMF
// completion is signalled directly by the MPEG HLE, so this vblank path
// never guesses intro completion from framebuffer timing and never
// injects Start during a movie.
vcs::audio_output_advance(virtual_time_us);
report_realtime_speed_if_requested();
if (perf_timing_enabled()) {
const auto now = std::chrono::steady_clock::now();
if (frame_time_stats.started) {
const auto frame = now - frame_time_stats.last_vblank;
const auto frame_us = std::chrono::duration_cast<std::chrono::microseconds>(frame).count();
const auto ge_us = std::chrono::duration_cast<std::chrono::microseconds>(
frame_time_stats.ge_time).count();
// guest_us is the delta the game itself observes. If it holds a
// steady ~16683 the guest believes it is running at 60 Hz no
// matter how slow the host wall clock is, and any physics step
// derived from it is unaffected by emulator speed.
const auto present_us = std::chrono::duration_cast<std::chrono::microseconds>(
frame_time_stats.present_time).count();
const auto io_us = std::chrono::duration_cast<std::chrono::microseconds>(
io_host_time_this_vblank).count();
const auto ge_async_wait_us = ge_async_running()
? static_cast<std::int64_t>(ge_async.last_wait_ns.load(std::memory_order_acquire) / 1000u)
: 0;
// In async mode ge_us is worker CPU time that overlaps Allegrex
// execution, so subtracting it from wall time would under-report
// guest work. Only the actual GE visibility wait is serialized.
const auto accounted_non_guest = ge_async_running()
? present_us + io_us + ge_async_wait_us
: ge_us + present_us + io_us;
std::ostringstream frame_line;
frame_line << "[frame-time] vblank=" << display_vblank_index
<< " frame_us=" << frame_us
<< " ge_us=" << ge_us
<< " ge_async_wait_us=" << ge_async_wait_us
<< " present_us=" << present_us
<< " io_us=" << io_us
<< " cpu_us=" << (ge_async_running()
? (frame_us > present_us + io_us ? frame_us - present_us - io_us : 0)
: (frame_us > ge_us ? frame_us - ge_us : 0))
<< " guest_cpu_us="
<< (frame_us > accounted_non_guest ? frame_us - accounted_non_guest : 0)
<< " guest_us=" << (virtual_time_us - frame_time_stats.last_guest_time)
<< " ge_calls=" << frame_time_stats.ge_calls
<< " fps=" << (frame_us > 0 ? 1000000 / frame_us : 0) << "\n";
if (frame_time_diag_enabled()) write_diag_line(frame_line);
const auto guest_cpu_us = static_cast<std::uint64_t>(
frame_us > accounted_non_guest ? frame_us - accounted_non_guest : 0);
if (perf_telemetry_enabled()) {
auto &a = perf_telemetry;
++a.frames;
a.frame_us_sum += static_cast<std::uint64_t>(std::max<std::int64_t>(0, frame_us));
a.frame_us_min = std::min(a.frame_us_min, static_cast<std::uint64_t>(std::max<std::int64_t>(0, frame_us)));
a.frame_us_max = std::max(a.frame_us_max, static_cast<std::uint64_t>(std::max<std::int64_t>(0, frame_us)));
a.guest_cpu_us_sum += guest_cpu_us;
a.ge_us_sum += static_cast<std::uint64_t>(std::max<std::int64_t>(0, ge_us));
a.ge_wait_us_sum += static_cast<std::uint64_t>(std::max<std::int64_t>(0, ge_async_wait_us));
a.present_us_sum += static_cast<std::uint64_t>(std::max<std::int64_t>(0, present_us));
a.io_us_sum += static_cast<std::uint64_t>(std::max<std::int64_t>(0, io_us));
a.ge_calls_sum += frame_time_stats.ge_calls;
if (a.frames >= perf_telemetry_interval()) {
const GeGpuBackendReport r = ge_gpu_backend_report();
const GeGpuBackendReport &o = a.previous_report;
const auto d = [](std::uint64_t n, std::uint64_t p) { return n >= p ? n - p : 0u; };
const std::uint64_t n = a.frames;
std::ostringstream t;
t << "PERF window=" << n
<< " vblank=" << display_vblank_index
<< " fps_avg=" << (a.frame_us_sum ? (1000000.0 * n / a.frame_us_sum) : 0.0)
<< " fps_min=" << (a.frame_us_max ? (1000000.0 / a.frame_us_max) : 0.0)
<< " fps_max=" << (a.frame_us_min && a.frame_us_min != UINT64_MAX ? (1000000.0 / a.frame_us_min) : 0.0)
<< " frame_us_avg=" << (a.frame_us_sum / n)
<< " guest_cpu_us_avg=" << (a.guest_cpu_us_sum / n)
<< " ge_us_avg=" << (a.ge_us_sum / n)
<< " ge_wait_us_avg=" << (a.ge_wait_us_sum / n)
<< " present_us_avg=" << (a.present_us_sum / n)
<< " io_us_avg=" << (a.io_us_sum / n)
<< " ge_calls_avg=" << (a.ge_calls_sum / n)
<< " game_draws=" << d(r.game_draw_calls, o.game_draw_calls)
<< " gpu_draws=" << d(r.dx12_gpu_draw_calls, o.dx12_gpu_draw_calls)
<< " batch_appends=" << d(r.dx12_batch_appends, o.dx12_batch_appends)
<< " batch_merges=" << d(r.dx12_batch_merges, o.dx12_batch_merges)
<< " indirect_exec=" << d(r.dx12_indirect_executes, o.dx12_indirect_executes)
<< " indirect_draws=" << d(r.dx12_indirect_draws, o.dx12_indirect_draws)
<< " indirect_saved=" << d(r.dx12_indirect_saved_api_draws, o.dx12_indirect_saved_api_draws)
<< " tex_req=" << d(r.texture_decode_requests, o.texture_decode_requests)
<< " tex_hits=" << d(r.texture_cache_hits, o.texture_cache_hits)
<< " tex_uploads=" << d(r.decoded_texture_uploads, o.decoded_texture_uploads)
<< " tex_upload_bytes=" << d(r.decoded_texture_bytes, o.decoded_texture_bytes)
<< " tex_evict=" << d(r.evicted_textures, o.evicted_textures)
<< " srv_recycled=" << d(r.recycled_texture_descriptor_sets, o.recycled_texture_descriptor_sets)
<< " transfer_submits=" << d(r.transfer_submissions, o.transfer_submissions)
<< " transfer_bytes=" << d(r.transfer_bytes, o.transfer_bytes)
<< " fb_hits=" << d(r.dx12_framebuffer_target_hits, o.dx12_framebuffer_target_hits)
<< " fb_creates=" << d(r.dx12_framebuffer_target_creates, o.dx12_framebuffer_target_creates)
<< " fb_live=" << r.dx12_native_framebuffer_targets
<< " fb_feedback=" << d(r.dx12_gpu_feedback_draws, o.dx12_gpu_feedback_draws)
<< " fb_selfsnap=" << d(r.dx12_self_feedback_snapshots, o.dx12_self_feedback_snapshots)
<< " srv_high=" << r.dx12_srv_high_water
<< " submit_us=" << (d(r.perf_queue_submit_ns, o.perf_queue_submit_ns) / 1000u)
<< " presentq_us=" << (d(r.perf_queue_present_ns, o.perf_queue_present_ns) / 1000u)
<< " fence_us=" << (d(r.perf_wait_for_frame_ns, o.perf_wait_for_frame_ns) / 1000u)
<< " finish_us=" << (d(r.perf_finish_frame_ns, o.perf_finish_frame_ns) / 1000u);
runtime_log_line(t.str());
#if defined(PSPRECOMP_TIER2_DEEP_TELEMETRY)
// Tier-2 V2 coverage is reported at the same 60-vblank cadence as
// PERF. Counters are thread-local to the Allegrex execution thread,
// so there are no atomics on the hot superblock edges.
if (tier2_superblocks_enabled()) {
const Tier2CountersSnapshot tier2 = consume_tier2_counters();
std::uint64_t total_entries = 0u;
std::uint64_t total_tail = 0u;
std::uint64_t total_calls = 0u;
std::uint64_t total_cold = 0u;
std::uint64_t total_fallback = 0u;
std::uint64_t total_sample_ns = 0u;
std::uint64_t total_sample_entries = 0u;
std::ostringstream tier2_line;
tier2_line << "TIER2 window=" << n << " vblank=" << display_vblank_index;
for (std::size_t i = 0; i < kTier2ClusterCount; ++i) {
const auto id = static_cast<Tier2ClusterId>(i);
const Tier2ClusterCounters &c = tier2.cluster[i];
total_entries += c.entries;
total_tail += c.fused_tail_edges;
total_calls += c.fused_calls;
total_cold += c.cold_exits;
total_fallback += c.fallbacks;
total_sample_ns += c.sampled_ns;
total_sample_entries += c.sampled_entries;
const std::uint64_t estimated_us = (c.sampled_ns * 256u) / 1000u;
tier2_line << ' ' << tier2_cluster_name(id) << "_e=" << c.entries
<< ' ' << tier2_cluster_name(id) << "_x="
<< (c.fused_tail_edges + c.fused_calls)
<< ' ' << tier2_cluster_name(id) << "_est_us=" << estimated_us;
}
tier2_line << " total_entries=" << total_entries
<< " fused_tail=" << total_tail
<< " fused_calls=" << total_calls
<< " cold=" << total_cold
<< " fallback=" << total_fallback
<< " sampled=" << total_sample_entries
<< " sampled_us=" << (total_sample_ns / 1000u);
runtime_log_line(tier2_line.str());
}
#endif
if (vcs_configuration().diagnostics.guest_hotspot_profile &&
++guest_hotspot_perf_windows >= 5u) {
report_guest_hotspot_window(display_vblank_index);
guest_hotspot_perf_windows = 0u;
}
a = {};
a.previous_report = r;
a.report_started = true;
}
}
// Splits ge_us into the per-fragment pixel loop and everything
// else, which is per-triangle geometry. Says directly which of
// the two a heavy frame is actually spent on.
if (ge_phase_diag_line_enabled()) {
const vcs::GePhaseTotals phases = vcs::ge_phase_totals();
const std::int64_t pixel_us =
static_cast<std::int64_t>(phases.pixel_loop_ns / 1000u);
const auto us = [](std::uint64_t ns) {
return static_cast<std::int64_t>(ns / 1000u);
};
// geometry_us stays as it was (ge_us minus the pixel loop) so
// older logs remain comparable; the named sub-phases below
// account for it and their sum plus pixel_us should be close
// to ge_us, the remainder being GE list interpretation.
const std::int64_t geometry_us = ge_us > pixel_us ? ge_us - pixel_us : 0;
const std::int64_t accounted = pixel_us + us(phases.draw_setup_ns) +
us(phases.texture_upload_ns) + us(phases.vertex_decode_ns) +
us(phases.gpu_stage_ns) + us(phases.triangle_prep_ns) +
us(phases.gpu_accumulate_ns);
std::ostringstream phase_line;
phase_line << "[ge-phase] vblank=" << display_vblank_index
<< " ge_us=" << ge_us
<< " pixel_us=" << pixel_us
<< " geometry_us=" << geometry_us
<< " triangles=" << phases.triangles
<< " draws=" << phases.primitives
<< " verts=" << phases.vertices
<< " setup_us=" << us(phases.draw_setup_ns)
<< " texupload_us=" << us(phases.texture_upload_ns)
<< " vdecode_us=" << us(phases.vertex_decode_ns)
<< " stage_us=" << us(phases.gpu_stage_ns)
<< " triprep_us=" << us(phases.triangle_prep_ns)
<< " accum_us=" << us(phases.gpu_accumulate_ns)
<< " list_us=" << (ge_us > accounted ? ge_us - accounted : 0)
<< " ge_commands=" << ge_commands_this_vblank
<< "\n";
write_diag_line(phase_line);
ge_commands_this_vblank = 0u;
vcs::reset_ge_phase_totals();
}
if (gpu_timing_diag_line_enabled()) {
const GeGpuBackendReport current = ge_gpu_backend_report();
if (gpu_timing_census.started) {
const GeGpuBackendReport &previous = gpu_timing_census.previous;
const auto delta = [](std::uint64_t now_value, std::uint64_t old_value) {
return now_value >= old_value ? now_value - old_value : 0u;
};
const auto ns_to_us = [](std::uint64_t ns) { return ns / 1000u; };
std::ostringstream gpu_line;
gpu_line << "[gpu-time] vblank=" << display_vblank_index
<< " finish_calls=" << delta(current.perf_finish_frame_calls, previous.perf_finish_frame_calls)
<< " finish_us=" << ns_to_us(delta(current.perf_finish_frame_ns, previous.perf_finish_frame_ns))
<< " fence_calls=" << delta(current.perf_wait_for_frame_calls, previous.perf_wait_for_frame_calls)
<< " fence_us=" << ns_to_us(delta(current.perf_wait_for_frame_ns, previous.perf_wait_for_frame_ns))
<< " flush_wait_calls=" << delta(current.perf_upload_flush_wait_calls, previous.perf_upload_flush_wait_calls)
<< " flush_wait_us=" << ns_to_us(delta(current.perf_upload_flush_wait_ns, previous.perf_upload_flush_wait_ns))
<< " acquire_calls=" << delta(current.perf_acquire_calls, previous.perf_acquire_calls)
<< " acquire_us=" << ns_to_us(delta(current.perf_acquire_ns, previous.perf_acquire_ns))
<< " submit_calls=" << delta(current.perf_queue_submit_calls, previous.perf_queue_submit_calls)
<< " submit_us=" << ns_to_us(delta(current.perf_queue_submit_ns, previous.perf_queue_submit_ns))
<< " present_calls=" << delta(current.perf_queue_present_calls, previous.perf_queue_present_calls)
<< " queue_present_us=" << ns_to_us(delta(current.perf_queue_present_ns, previous.perf_queue_present_ns))
<< " tex_requests=" << delta(current.texture_decode_requests, previous.texture_decode_requests)
<< " tex_hits=" << delta(current.texture_cache_hits, previous.texture_cache_hits)
<< " tex_uploads=" << delta(current.decoded_texture_uploads, previous.decoded_texture_uploads)
<< " tex_evictions=" << delta(current.evicted_textures, previous.evicted_textures)
<< " transfer_submits=" << delta(current.transfer_submissions, previous.transfer_submissions)
<< " game_draws=" << delta(current.game_draw_calls, previous.game_draw_calls)
<< " game_tris=" << delta(current.game_triangles, previous.game_triangles)
<< " swapchain=" << current.swapchain_active
<< " direct_present=" << current.gpu_frame_presented_to_window
<< "\n";
write_diag_line(gpu_line);
}
gpu_timing_census.previous = current;
gpu_timing_census.started = true;
}
}
frame_time_stats.started = true;
frame_time_stats.last_vblank = now;
frame_time_stats.last_guest_time = virtual_time_us;
frame_time_stats.ge_time = std::chrono::steady_clock::duration{};
frame_time_stats.present_time = std::chrono::steady_clock::duration{};
io_host_time_this_vblank = std::chrono::steady_clock::duration{};
frame_time_stats.ge_calls = 0u;
}
const FramebufferDescription displayed{
display_state.frame_buffer,
display_state.width,
display_state.height,
display_state.buffer_width,
display_state.pixel_format,
};
capture_frame_if_requested(rt.memory(), displayed);
dump_ram_if_requested(rt.memory());
ge_gpu_backend_set_display_framebuffer(display_state.frame_buffer);
project2dfx_render_frame(
rt.memory(), ctx.gpr[28], display_vblank_index, display_state.frame_buffer);
// Draw-distance world/far-clip maintenance is tied to a real vblank
// rather than fragile AOT entry hooks, which local generated gotos can bypass.
draw_distance_vblank_tick(rt, ctx.gpr[28], display_vblank_index);
// A movie frame is a finished 480x272 picture with no more image at the
// sides, so widening it can only stretch it. Present it black-barred at
// its own shape instead; gameplay keeps the widescreen treatment.
display_window_set_aspect_lock(
!movie_output_buffers.empty() &&
movie_output_buffers.count(normalize_ram_address(display_state.frame_buffer)) != 0u);
const auto present_entry = perf_timing_enabled()
? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
// PSP firmware-owned savedata utility: render its HLE surface into the
// same GE target before the frame is finalized. No desktop/Win32 chooser.
savedata_utility_ui_render_frame(display_state.frame_buffer);
const bool gpu_frame_ready = ge_gpu_backend_finish_color_frame(display_vblank_index);
// VCS only fills the displayed framebuffer on every other vblank, so the
// GPU path produces a frame at half the vblank rate. Presenting the
// software framebuffer in between alternated two differently scaled
// images at 60 Hz, which reads as flicker. Hold the last GPU frame
// instead, and only hand the window back to software when the GPU has
// been silent for several vblanks (menus, videos, backend shutdown).
static std::uint64_t vblanks_since_gpu_frame = 0u;
static bool holding_gpu_frame = false;
if (gpu_frame_ready) {
holding_gpu_frame = true;
vblanks_since_gpu_frame = 0u;
} else if (holding_gpu_frame && ++vblanks_since_gpu_frame > 4u) {
holding_gpu_frame = false;
}
bool presented_gpu_frame = false;
if (ge_gpu_backend_presents_directly()) {
// The backend blitted straight into the swapchain. Dropped vblanks
// simply leave the previous image on screen, which is the hold
// behaviour for free, and mixing a GDI blit into the same window
// would fight the presentation engine.
presented_gpu_frame = true;
} else if ((gpu_frame_ready || holding_gpu_frame) &&
(ge_gpu_backend_active() || gpu_color_preview_enabled())) {
const GeGpuBackendReport gpu = ge_gpu_backend_report();
const std::span<const std::byte> rgba = ge_gpu_backend_game_frame_rgba();
if (!rgba.empty()) {
display_window_present_rgba(rgba, gpu.offscreen_width, gpu.offscreen_height);
ge_gpu_backend_mark_window_presented();
presented_gpu_frame = true;
}
}
if (!presented_gpu_frame) {
holding_gpu_frame = false;
// The window is showing the guest framebuffer that the software GE
// filled. Counting these tells whether
// PSPRECOMP_GE_GPU_SKIP_DISPLAYED_RASTER could ever put a stale
// surface on screen: the flag only skips while the swapchain is
// presenting, so what matters is how often the run leaves that state
// after having entered it.
++software_presents;
if (gpu_has_presented) ++software_presents_after_gpu;
display_window_present(rt.memory(), displayed);
} else if (ge_gpu_backend_presents_directly()) {
gpu_has_presented = true;
++swapchain_presents;
}
if (gpu_frame_ready) dump_gpu_internal_frame_if_requested(display_vblank_index);
if (perf_timing_enabled())
frame_time_stats.present_time += std::chrono::steady_clock::now() - present_entry;
limit_frame_rate();
// limit_frame_rate() may advance virtual_time_us when the host misses the
// target. Seal the audio timeline immediately at that corrected guest
// time instead of leaving waveOut one vblank behind during heavy frames.
vcs::audio_output_advance(virtual_time_us);
if (display_window_close_requested()) {
// F10 is preferred, but never lose an expensive post-mission repro
// just because the window was closed while the black screen was up.
if (save_repro_trace_enabled) save_repro_dump_trace(rt, "window-close");
ctx.set_gpr(2, 0u);
rt.stop("Display window closed by the user");
return;
}
static const std::uint64_t stop_vblank =
parse_environment_u64("PSPRECOMP_STOP_VBLANK");
if (stop_vblank != 0u && display_vblank_index >= stop_vblank) {
ctx.set_gpr(2, 0u);
rt.stop("VBlank diagnostic stop at " + std::to_string(display_vblank_index));
return;
}
// Capture only after the completed frame has been presented and audio
// has been advanced. The restored PC returns from this wait import, so
// no half-presented frame or half-sealed audio interval enters the file.
save_repro_vblank_hotkeys(rt, ctx);
const std::uint64_t period = virtual_vblank_period_us();
const std::uint32_t delay = static_cast<std::uint32_t>(period - (virtual_time_us % period));
const auto current = thread_table.threads.find(thread_table.current_uid);
if (current == thread_table.threads.end()) {
ctx.set_gpr(2, 0x80020198u);
return;
}
const auto interrupt = sub_interrupts.find(sub_interrupt_key(30u, 15u));
if (interrupt == sub_interrupts.end() || !interrupt->second.enabled || interrupt->second.handler == 0u) {
(void)delay_current_thread(rt, ctx, delay);
return;
}
const psprecomp::AllegrexContext resume = make_wait_context(ctx);
async_return_frames[thread_table.current_uid].push_back(
AsyncReturnFrame{AsyncReturnKind::SubInterrupt, resume});
psprecomp::AllegrexContext handler = ctx;
handler.set_gpr(4, 15u);
handler.set_gpr(5, interrupt->second.argument);
handler.set_gpr(31, 0x00000004u);
handler.pc = interrupt->second.handler;
current->second.state = ThreadState::Delayed;
current->second.suspended_context = handler;
current->second.delay_until_us = virtual_time_us + delay;
current->second.delay_sequence = thread_table.next_delay_sequence++;
interrupt->second.occurred = true;
if (std::getenv("PSPRECOMP_GE_DIAG") != nullptr) {
std::cerr << "[intr] schedule vblank uid=" << thread_table.current_uid
<< " handler=" << psprecomp::hex32(handler.pc)
<< " resume=" << psprecomp::hex32(resume.pc) << "\n";
}
if (!activate_next_thread(ctx, "vblank-wait"))
rt.stop("PSP scheduler deadlock while waiting for VBlank interrupt");
};
runtime.register_hle("sceDisplay", 0x36CDFADEu, wait_vblank);
runtime.register_hle("sceDisplay", 0x8EB9EC49u, wait_vblank);
runtime.register_hle("sceDisplay", 0x984C27E7u, wait_vblank);
runtime.register_hle("sceDisplay", 0x46F186C3u, wait_vblank);
runtime.register_hle("sceDisplay", 0xB4F378FAu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 1u); });
runtime.register_hle("sceUtility", 0x50C4CD57u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t parameter = ctx.gpr[4];
if (savedata_utility.status != UtilityStatus::None) {
ctx.set_gpr(2, 0x80110001u);
return;
}
if (parameter == 0u || !rt.memory().contains(parameter, 4u)) {
ctx.set_gpr(2, 0x80110004u);
return;
}
const std::uint32_t declared_size = rt.memory().load32(parameter);
if (declared_size < 0x5C0u || !rt.memory().contains(parameter, std::min<std::uint32_t>(declared_size, kSavedataParameterMinimumSize))) {
ctx.set_gpr(2, 0x80110004u);
return;
}
savedata_utility = SavedataUtilityState{};
savedata_utility.status = UtilityStatus::Init;
savedata_utility.parameter_address = parameter;
savedata_utility.mode = rt.memory().load32(parameter + kSavedataModeOffset);
rt.memory().store32(parameter + kUtilityCommonResultOffset, 0u);
const std::uint32_t guest_mode = savedata_utility.mode;
const bool first_boot_autoload = !startup_load_picker_consumed && guest_mode == 0u;
const bool direct_load = guest_mode == 2u;
if (first_boot_autoload || direct_load) {
// Clean V9 rebase: AUTOLOAD is promoted only on the first boot,
// while every explicit LOAD request gets a LISTLOAD presentation.
// The guest parameter block remains untouched, so after a slot is
// selected the original mode 0/2 operation still performs the load.
if (guest_mode == 0u) {
startup_load_picker_consumed = true;
savedata_utility.startup_picker = true;
} else {
savedata_utility.direct_load_picker = true;
}
savedata_utility.mode = 4u;
initialize_savedata_list_ui(rt);
display_window_set_system_utility_mode(true);
savedata_utility.previous_buttons = effective_controller_buttons();
std::cout << "[savedata] V9.6 LOAD picker active; guest mode="
<< guest_mode << " slots=" << savedata_utility.slots.size() << "\n";
} else if (savedata_mode_has_list_ui(savedata_utility.mode)) {
initialize_savedata_list_ui(rt);
display_window_set_system_utility_mode(true);
// Latch the button that opened LOAD/SAVE so a held Cross/Enter
// cannot instantly confirm the first slot in the PSP utility.
savedata_utility.previous_buttons = effective_controller_buttons();
}
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] savedata init mode=" << rt.memory().load32(parameter + kSavedataModeOffset)
<< " game=" << read_fixed_string(rt.memory(), parameter + kSavedataGameNameOffset, 13u)
<< " save=" << read_fixed_string(rt.memory(), parameter + kSavedataSaveNameOffset, 20u)
<< " file=" << read_fixed_string(rt.memory(), parameter + kSavedataFileNameOffset, 13u)
<< " size=0x" << std::hex << declared_size << std::dec << "\n";
}
set_success(ctx);
});
runtime.register_hle("sceUtility", 0xD4B95FFBu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (savedata_utility.status == UtilityStatus::None || savedata_utility.status == UtilityStatus::Finished) {
ctx.set_gpr(2, 0x80110001u);
return;
}
if (savedata_utility.status == UtilityStatus::Init) {
savedata_utility.status = UtilityStatus::Visible;
} else if (savedata_utility.status == UtilityStatus::Visible) {
if (savedata_mode_has_list_ui(savedata_utility.mode)) {
update_savedata_list_utility(rt);
} else if (!savedata_utility.operation_complete) {
const std::uint32_t result = execute_savedata_operation(
rt, savedata_utility.parameter_address);
rt.memory().store32(savedata_utility.parameter_address +
kUtilityCommonResultOffset, result);
savedata_utility.operation_complete = true;
savedata_utility.status = UtilityStatus::Quit;
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] savedata operation result=0x" << std::hex << std::uppercase << result
<< std::nouppercase << std::dec << "\n";
}
}
}
set_success(ctx);
});
runtime.register_hle("sceUtility", 0x8874DBE0u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const UtilityStatus reported = savedata_utility.status;
ctx.set_gpr(2, static_cast<std::uint32_t>(reported));
if (reported == UtilityStatus::Init) {
// PSP utility initialization completes on its own access thread.
// Expose INIT once, then make the firmware-owned utility visible.
savedata_utility.status = UtilityStatus::Visible;
} else if (reported == UtilityStatus::Finished) {
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
savedata_utility = SavedataUtilityState{};
}
});
runtime.register_hle("sceUtility", 0x9790B33Cu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (savedata_utility.status != UtilityStatus::Quit) {
ctx.set_gpr(2, 0x80110001u);
return;
}
if (savedata_utility.mode == 5u)
runtime_log_line("SAVEDATA_SAVE shutdown status=FINISHED");
savedata_utility.status = UtilityStatus::Finished;
savedata_utility_ui_end();
display_window_set_system_utility_mode(false);
set_success(ctx);
});
auto reserve_audio_channel = [](psprecomp::AllegrexContext &ctx) {
std::int32_t channel = static_cast<std::int32_t>(ctx.gpr[4]);
const std::uint32_t sample_count = ctx.gpr[5];
const std::uint32_t format = ctx.gpr[6];
if (channel < 0) {
channel = -1;
for (std::int32_t candidate = 7; candidate >= 1; --candidate) {
if (!audio_channels[static_cast<std::size_t>(candidate)].reserved) {
channel = candidate;
break;
}
}
if (channel < 0) { ctx.set_gpr(2, 0x80260005u); return; }
}
if (channel >= 8) { ctx.set_gpr(2, 0x80260003u); return; }
if (sample_count == 0u || (sample_count & 63u) != 0u || sample_count > 65472u) {
ctx.set_gpr(2, 0x80260006u);
return;
}
if (format != 0u && format != 0x10u) { ctx.set_gpr(2, 0x80260007u); return; }
auto &state = audio_channels[static_cast<std::size_t>(channel)];
if (state.reserved) { ctx.set_gpr(2, 0x80268002u); return; }
vcs::audio_output_reset_channel(static_cast<std::uint32_t>(channel));
state = AudioChannelState{true, sample_count, format, 0u, 0u, 0u};
if (std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr)
std::cerr << "[audio] reserve channel=" << channel << " samples=" << sample_count
<< " format=" << format << "\n";
ctx.set_gpr(2, static_cast<std::uint32_t>(channel));
};
runtime.register_hle("sceAudio", 0x5EC81C55u,
[reserve_audio_channel](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { reserve_audio_channel(ctx); });
runtime.register_hle("sceAudio", 0x6FC46853u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t channel = ctx.gpr[4];
if (channel >= 8u) { ctx.set_gpr(2, 0x80260003u); return; }
if (!audio_channels[channel].reserved) { ctx.set_gpr(2, 0x80260001u); return; }
audio_channels[channel] = {};
vcs::audio_output_reset_channel(channel);
set_success(ctx);
});
runtime.register_hle("sceAudio", 0xB011922Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t channel = ctx.gpr[4];
if (channel >= 8u) { ctx.set_gpr(2, 0x80260003u); return; }
ctx.set_gpr(2, audio_remaining_samples(audio_channels[channel]));
});
runtime.register_hle("sceAudio", 0xCB2E439Eu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t channel = ctx.gpr[4];
const std::uint32_t length = ctx.gpr[5];
if (channel >= 8u) { ctx.set_gpr(2, 0x80260003u); return; }
if (!audio_channels[channel].reserved) { ctx.set_gpr(2, 0x80260001u); return; }
if (length == 0u || (length & 63u) != 0u || length > 65472u) {
ctx.set_gpr(2, 0x80260006u); return;
}
audio_channels[channel].sample_count = length;
set_success(ctx);
});
runtime.register_hle("sceAudio", 0x95FD0C2Du,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t channel = ctx.gpr[4];
const std::uint32_t format = ctx.gpr[5];
if (channel >= 8u) { ctx.set_gpr(2, 0x80260003u); return; }
if (!audio_channels[channel].reserved) { ctx.set_gpr(2, 0x80260008u); return; }
if (format != 0u && format != 0x10u) { ctx.set_gpr(2, 0x80260007u); return; }
audio_channels[channel].format = format;
set_success(ctx);
});
runtime.register_hle("sceAudio", 0xB7E1D8E7u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t channel = ctx.gpr[4];
if (channel >= 8u) { ctx.set_gpr(2, 0x80260003u); return; }
if (!audio_channels[channel].reserved) { ctx.set_gpr(2, 0x80260008u); return; }
if (ctx.gpr[5] > 0xFFFFu || ctx.gpr[6] > 0xFFFFu) { ctx.set_gpr(2, 0x8026000Bu); return; }
audio_channels[channel].left_volume = ctx.gpr[5];
audio_channels[channel].right_volume = ctx.gpr[6];
set_success(ctx);
});
auto audio_output_panned = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx, bool blocking) {
const std::uint32_t channel = ctx.gpr[4];
const std::uint32_t left = ctx.gpr[5];
const std::uint32_t right = ctx.gpr[6];
const std::uint32_t samples = ctx.gpr[7];
if (channel >= 8u) { ctx.set_gpr(2, 0x80260003u); return; }
auto &state = audio_channels[channel];
if (!state.reserved) { ctx.set_gpr(2, 0x80260001u); return; }
if (left > 0xFFFFu || right > 0xFFFFu) { ctx.set_gpr(2, 0x8026000Bu); return; }
if (!blocking && audio_remaining_samples(state) != 0u) { ctx.set_gpr(2, 0x80260002u); return; }
const std::uint32_t channels = state.format == 0x10u ? 1u : 2u;
const std::size_t bytes = static_cast<std::size_t>(state.sample_count) * channels * 2u;
if (samples != 0u && !rt.memory().contains(samples, bytes)) { ctx.set_gpr(2, 0x800200D3u); return; }
state.left_volume = left;
state.right_volume = right;
// The buffer used to stop here: the HLE paced the guest correctly and
// discarded the samples, which is why everything ran at the right speed
// in total silence. Hand them to the host device.
//
// The buffer is scheduled at the instant the hardware would actually
// start playing it, not at the instant of the call, so the host mix
// stays contiguous no matter how much guest time the caller burned
// producing it.
const std::uint64_t start_us = audio_queue_buffer(state, state.sample_count);
if (samples != 0u && vcs::audio_output_enabled()) {
std::vector<std::int16_t> pcm(bytes / sizeof(std::int16_t));
for (std::size_t index = 0u; index < pcm.size(); ++index) {
pcm[index] = static_cast<std::int16_t>(
rt.memory().aot_load16(samples +
static_cast<std::uint32_t>(index * sizeof(std::int16_t))));
}
vcs::audio_output_submit(pcm, state.sample_count, channels == 2u, left, right,
state.frequency, channel, start_us);
}
// A blocking submission returns when the *previous* buffer finished,
// which is exactly when this one starts.
const std::uint64_t wait_us =
start_us > virtual_time_us ? start_us - virtual_time_us : 0u;
if (std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr)
std::cerr << "[audio] output channel=" << channel << " samples=" << state.sample_count
<< " blocking=" << blocking << " start_us=" << start_us
<< " wait_us=" << wait_us << "\n";
if (blocking) {
(void)delay_current_thread(rt, ctx, static_cast<std::uint32_t>(wait_us),
state.sample_count);
} else {
ctx.set_gpr(2, state.sample_count);
}
};
runtime.register_hle("sceAudio", 0xE2D56B2Du,
[audio_output_panned](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { audio_output_panned(rt, ctx, false); });
runtime.register_hle("sceAudio", 0x13F592BCu,
[audio_output_panned](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) { audio_output_panned(rt, ctx, true); });
runtime.register_hle("sceAudio", 0x136CAF51u,
[audio_output_panned](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t volume = ctx.gpr[5];
const std::uint32_t buffer = ctx.gpr[6];
ctx.set_gpr(6, volume);
ctx.set_gpr(7, buffer);
audio_output_panned(rt, ctx, true);
});
runtime.register_hle("sceAudio", 0x01562BA3u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
// sceAudioOutput2Reserve takes ONE argument. Reading gpr[5]/gpr[6]
// as a rate and a channel count picked up unrelated register
// contents -- a diagnostic run reported freq=167495392, plainly a
// pointer -- which drove the buffer duration to zero, stopped the
// channel from blocking and starved the mix into constant
// stuttering. Output2 is 44100 Hz stereo.
const std::uint32_t samples = ctx.gpr[4] & 0x7FFFFFFFu;
auto &state = audio_channels[8];
if (samples < 17u || samples > 4111u) { ctx.set_gpr(2, 0x80000104u); return; }
if (state.reserved) { ctx.set_gpr(2, 0x80268002u); return; }
vcs::audio_output_reset_channel(8u);
state = AudioChannelState{true, samples, 0u, 0u, 0u, 0u};
if (std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr)
std::cerr << "[audio] output2 reserve samples=" << samples << "\n";
set_success(ctx);
});
// sceAudioSRCChReserve(sampleCount, frequency, channels) -- this is the call
// that actually carries a rate, and it was not implemented at all. Radio
// streams that run below 44100 were the ones playing back at chipmunk pitch.
runtime.register_hle("sceAudio", 0x38553111u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const std::uint32_t samples = ctx.gpr[4] & 0x7FFFFFFFu;
const std::uint32_t frequency = ctx.gpr[5];
const std::uint32_t channel_count = ctx.gpr[6];
auto &state = audio_channels[8];
if (samples < 17u || samples > 4111u) { ctx.set_gpr(2, 0x80000104u); return; }
if (state.reserved) { ctx.set_gpr(2, 0x80268002u); return; }
vcs::audio_output_reset_channel(8u);
state = AudioChannelState{true, samples, 0u, 0u, 0u, 0u};
// 0 means "keep the current rate" on hardware; 44100 is the default.
state.frequency = frequency == 0u ? 44100u : frequency;
state.channel_count = channel_count == 1u ? 1u : 2u;
if (std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr)
std::cerr << "[audio] src reserve samples=" << samples
<< " freq=" << state.frequency
<< " channels=" << state.channel_count << "\n";
set_success(ctx);
});
runtime.register_hle("sceAudio", 0x5C37C0AEu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
audio_channels[8] = {};
vcs::audio_output_reset_channel(8u);
set_success(ctx);
});
// Output2 and SRC share the single resampling hardware path, but they do
// NOT share the same ABI result. sceAudioOutput2OutputBlocking returns 0
// on success while sceAudioSRCOutputBlocking returns the queued sample
// count. VCS imports Output2, so returning 512 here was leaking a false
// non-zero result out of its radio mixer every buffer.
const auto audio_resample_output =
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx,
bool return_queued_samples) {
auto &state = audio_channels[8];
const std::uint32_t volume = ctx.gpr[4];
const std::uint32_t buffer = ctx.gpr[5];
if (volume > 0xFFFFFu) { ctx.set_gpr(2, 0x8026000Bu); return; }
if (!state.reserved) { ctx.set_gpr(2, 0x80260008u); return; }
// sceAtracDecodeData always writes two interleaved channels (see the
// note by kAtracOutputChannels), and Output2 is the port VCS feeds
// from it. A mono source -- the radio news bulletins are mono while
// the music is stereo -- still arrives here as stereo PCM, but
// channel_count reports 1. Reading that buffer as mono took half the
// bytes and advanced half a frame per sample, which plays the
// bulletin at half rate: the dragging, slowed-down audio.
const std::uint32_t effective_channels = 2u;
if (state.channel_count != effective_channels &&
std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr) {
static bool once = false;
if (!once) {
once = true;
std::cerr << "[audio] output2 channel_count=" << state.channel_count
<< " tratado como " << effective_channels
<< " (ATRAC entrega estereo intercalado)\n";
}
}
const std::size_t bytes =
static_cast<std::size_t>(state.sample_count) * effective_channels * 2u;
if (buffer != 0u && !rt.memory().contains(buffer, bytes)) { ctx.set_gpr(2, 0x800200D3u); return; }
state.left_volume = volume; state.right_volume = volume;
// audio_queue_buffer already paces at the channel's own frequency,
// so a stream that is not 44100 neither starves nor floods the mix.
const std::uint64_t start_us =
audio_queue_buffer(state, state.sample_count);
if (buffer != 0u && vcs::audio_output_enabled()) {
std::vector<std::int16_t> pcm(bytes / sizeof(std::int16_t));
for (std::size_t index = 0u; index < pcm.size(); ++index) {
pcm[index] = static_cast<std::int16_t>(
rt.memory().aot_load16(buffer +
static_cast<std::uint32_t>(index * sizeof(std::int16_t))));
}
vcs::audio_output_submit(pcm, state.sample_count, true,
volume, volume, state.frequency, 8u, start_us);
}
const std::uint64_t wait_us =
start_us > virtual_time_us ? start_us - virtual_time_us : 0u;
if (std::getenv("PSPRECOMP_AUDIO_DIAG") != nullptr)
std::cerr << "[audio] output2 samples=" << state.sample_count
<< " channels=" << state.channel_count
<< " freq=" << state.frequency
<< " start_us=" << start_us << " wait_us=" << wait_us << "\n";
const std::uint32_t success_value =
audio_resample_success_value(return_queued_samples, state.sample_count);
(void)delay_current_thread(rt, ctx, static_cast<std::uint32_t>(wait_us),
success_value);
};
runtime.register_hle("sceAudio", 0x2D53F36Eu,
[audio_resample_output](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
audio_resample_output(rt, ctx, false);
});
runtime.register_hle("sceAudio", 0xE0727056u,
[audio_resample_output](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
audio_resample_output(rt, ctx, true);
});
constexpr std::uint32_t kAtracErrorApiFail = 0x80630002u;
constexpr std::uint32_t kAtracErrorNoId = 0x80630003u;
constexpr std::uint32_t kAtracErrorBadId = 0x80630005u;
constexpr std::uint32_t kAtracErrorUnknownFormat = 0x80630006u;
constexpr std::uint32_t kAtracErrorAllDataLoaded = 0x80630009u;
constexpr std::uint32_t kAtracErrorNoData = 0x80630010u;
constexpr std::uint32_t kAtracErrorIncorrectReadSize = 0x80630013u;
constexpr std::uint32_t kAtracErrorBadAddress = 0x800200D3u;
const auto get_atrac = [](std::uint32_t id) -> AtracContextState * {
if (id >= atrac_contexts.size() || !atrac_contexts[id].allocated) return nullptr;
return &atrac_contexts[id];
};
runtime.register_hle("sceAtrac3plus", 0x0FAE370Eu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t buffer = ctx.gpr[4];
const std::uint32_t read_size = ctx.gpr[5];
const std::uint32_t buffer_size = ctx.gpr[6];
if (read_size > buffer_size) { ctx.set_gpr(2, kAtracErrorIncorrectReadSize); return; }
if (read_size < 12u || !rt.memory().contains(buffer, read_size)) {
ctx.set_gpr(2, kAtracErrorUnknownFormat); return;
}
std::vector<std::uint8_t> header_bytes(read_size);
rt.memory().copy_out(buffer, header_bytes);
ParsedAtracHeader parsed{};
if (!parse_atrac_header(header_bytes, parsed)) {
ctx.set_gpr(2, kAtracErrorUnknownFormat); return;
}
std::size_t id = atrac_contexts.size();
for (std::size_t i = 0u; i < atrac_contexts.size(); ++i) {
if (!atrac_contexts[i].allocated) { id = i; break; }
}
if (id == atrac_contexts.size()) { ctx.set_gpr(2, kAtracErrorNoId); return; }
auto &state = atrac_contexts[id];
close_atrac_decoder(state);
state = AtracContextState{};
state.allocated = true;
state.header = parsed;
state.buffer_address = buffer;
state.initial_read_size = read_size;
state.buffer_size = buffer_size;
state.buffered_encoded_bytes = read_size > parsed.data_offset ? read_size - parsed.data_offset : 0u;
state.buffered_encoded_bytes = std::min(state.buffered_encoded_bytes, parsed.data_size);
state.next_file_offset = std::min(read_size, parsed.file_size);
state.write_offset = buffer_size == 0u ? 0u : read_size % buffer_size;
state.source_path = identify_atrac_source(buffer, header_bytes, parsed);
if (!state.source_path.empty()) {
const std::string source_name = state.source_path.filename().string();
if (source_name.rfind("NEWS_", 0u) == 0u) {
std::ostringstream line;
line << "ATRAC_NEWS_META source=" << source_name
<< " codec=" << (parsed.atrac3plus ? "at3plus" : "at3")
<< " channels=" << parsed.channels
<< " rate=" << parsed.sample_rate
<< " block=" << parsed.block_align
<< " total_samples=" << parsed.total_samples
<< " initial_read=" << read_size
<< " buffer=" << buffer_size
<< " file=" << parsed.file_size;
runtime_log_line(line.str());
}
}
if (std::getenv("PSPRECOMP_ATRAC_DIAG") != nullptr) {
std::cerr << "[atrac] set-halfway id=" << id
<< " buffer=" << psprecomp::hex32(buffer)
<< " read=" << read_size << " capacity=" << buffer_size
<< " file=" << parsed.file_size << " frame=" << parsed.block_align
<< " samples=" << parsed.total_samples
<< " source=\"" << state.source_path.string() << "\"\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(id));
});
runtime.register_hle("sceAtrac3plus", 0x61EB33F5u,
[get_atrac](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
close_atrac_decoder(*state);
*state = AtracContextState{};
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x5D268707u,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
const std::uint32_t write_ptr_addr = ctx.gpr[5];
const std::uint32_t writable_addr = ctx.gpr[6];
const std::uint32_t read_offset_addr = ctx.gpr[7];
for (const std::uint32_t address : {write_ptr_addr, writable_addr, read_offset_addr}) {
if (address != 0u && !rt.memory().contains(address, 4u)) {
ctx.set_gpr(2, kAtracErrorBadAddress); return;
}
}
const std::uint32_t remaining_file = state->next_file_offset < state->header.file_size ?
state->header.file_size - state->next_file_offset : 0u;
const std::uint32_t free_bytes = state->buffer_size > state->buffered_encoded_bytes ?
state->buffer_size - state->buffered_encoded_bytes : 0u;
const std::uint32_t contiguous = state->buffer_size == 0u ? 0u : state->buffer_size - state->write_offset;
const std::uint32_t writable = std::min({remaining_file, free_bytes, contiguous});
state->last_writable_bytes = writable;
if (write_ptr_addr != 0u) rt.memory().store32(write_ptr_addr, state->buffer_address + state->write_offset);
if (writable_addr != 0u) rt.memory().store32(writable_addr, writable);
if (read_offset_addr != 0u) rt.memory().store32(read_offset_addr, state->next_file_offset);
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x7DB31251u,
[get_atrac](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
const std::uint32_t bytes = ctx.gpr[5];
if (state->next_file_offset >= state->header.file_size) {
ctx.set_gpr(2, bytes == 0u ? 0u : kAtracErrorAllDataLoaded); return;
}
if (bytes > state->last_writable_bytes) {
ctx.set_gpr(2, kAtracErrorIncorrectReadSize); return;
}
state->buffered_encoded_bytes = std::min(state->buffer_size, state->buffered_encoded_bytes + bytes);
state->next_file_offset = std::min(state->header.file_size, state->next_file_offset + bytes);
if (state->buffer_size != 0u) state->write_offset = (state->write_offset + bytes) % state->buffer_size;
state->last_writable_bytes = 0u;
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x6A8C3CD5u,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
const std::uint32_t output = ctx.gpr[5];
const std::uint32_t samples_addr = ctx.gpr[6];
const std::uint32_t finish_addr = ctx.gpr[7];
const std::uint32_t remain_addr = ctx.gpr[8];
for (const std::uint32_t address : {samples_addr, finish_addr, remain_addr}) {
if (address != 0u && !rt.memory().contains(address, 4u)) {
ctx.set_gpr(2, kAtracErrorBadAddress); return;
}
}
const std::uint32_t max_samples = atrac_samples_per_frame(*state);
const std::size_t max_bytes =
static_cast<std::size_t>(max_samples) * kAtracOutputChannels * 2u;
if (output != 0u && !rt.memory().contains(output, max_bytes)) {
ctx.set_gpr(2, kAtracErrorBadAddress); return;
}
if (state->source_path.empty()) {
state->internal_error = kAtracErrorUnknownFormat;
ctx.set_gpr(2, kAtracErrorApiFail); return;
}
auto restart_for_loop = [&]() -> bool {
if (state->loop_num == 0) return false;
// A stream with no loop region must not be restarted. The line
// below fell back to sample 0 when loop_start was negative, so a
// clip that simply ended -- a radio news bulletin -- was played
// again from the top instead of finishing and handing the
// station back to the music. loop_num survives in a reused
// context, so the bulletin inherited the music's loop.
if (state->header.loop_start < 0) {
if (std::getenv("PSPRECOMP_ATRAC_DIAG") != nullptr)
std::cerr << "[atrac] fim de stream sem regiao de loop: "
<< state->source_path.filename().string()
<< " (loop_num=" << state->loop_num
<< " ignorado)\n";
return false;
}
if (state->loop_num > 0) --state->loop_num;
state->sample_position = state->header.loop_start >= 0 ?
static_cast<std::uint32_t>(state->header.loop_start) : 0u;
close_atrac_decoder(*state);
return open_atrac_decoder(*state);
};
if (state->sample_position >= state->header.total_samples && !restart_for_loop()) {
if (samples_addr != 0u) rt.memory().store32(samples_addr, 0u);
if (finish_addr != 0u) rt.memory().store32(finish_addr, 1u);
if (remain_addr != 0u)
rt.memory().store32(remain_addr, atrac_remain_frame_status(*state));
set_success(ctx);
return;
}
const std::uint32_t requested_samples = static_cast<std::uint32_t>(std::min<std::uint64_t>(
max_samples, state->header.total_samples - state->sample_position));
// Reused across calls. This was a fresh std::vector every
// sceAtracDecodeData -- allocate, zero a few kilobytes, decode into
// it, free -- on the hottest audio import there is, and the radio
// runs two of these at once whenever a news bulletin plays over the
// music. The buffer only ever grows, and only one guest audio thread
// reaches this import at a time.
static std::vector<std::uint8_t> pcm;
const std::size_t pcm_bytes =
static_cast<std::size_t>(requested_samples) * kAtracOutputChannels * 2u;
if (pcm.size() < pcm_bytes) pcm.resize(pcm_bytes);
const std::span<std::uint8_t> pcm_span(pcm.data(), pcm_bytes);
static const bool audio_summary_enabled = [] {
const char *text = std::getenv("PSPRECOMP_AUDIO_SUMMARY");
return text != nullptr && *text != '\0' && std::strcmp(text, "0") != 0;
}();
const bool decoder_was_open = state->decoder.is_open();
const auto decode_started = std::chrono::steady_clock::now();
std::size_t got = read_atrac_pcm(*state, pcm_span);
if (got == 0u && restart_for_loop()) {
got = read_atrac_pcm(*state, pcm_span);
}
const std::uint64_t decode_elapsed_us = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - decode_started).count());
if (!decoder_was_open || decode_elapsed_us >= 2000u) {
std::ostringstream line;
line << "ATRAC_DECODE source=" << state->source_path.filename().string()
<< " open=" << (!decoder_was_open ? 1 : 0)
<< " decode_us=" << decode_elapsed_us
<< " bytes=" << got
<< " sample=" << state->sample_position;
runtime_log_line(line.str());
}
if (audio_summary_enabled) {
static std::uint64_t decode_calls = 0u;
static std::uint64_t decode_total_ns = 0u;
static std::uint64_t decode_max_ns = 0u;
const std::uint64_t elapsed_ns = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now() - decode_started).count());
++decode_calls;
decode_total_ns += elapsed_ns;
decode_max_ns = std::max(decode_max_ns, elapsed_ns);
if ((decode_calls & 255u) == 0u) {
std::cerr << "[atrac-summary] calls=" << decode_calls
<< " avg_us=" << decode_total_ns / decode_calls / 1000u
<< " max_us=" << decode_max_ns / 1000u << "\n";
}
}
const std::size_t bytes_per_sample = static_cast<std::size_t>(kAtracOutputChannels) * 2u;
const std::uint32_t samples = static_cast<std::uint32_t>(got / bytes_per_sample);
got = static_cast<std::size_t>(samples) * bytes_per_sample;
if (output != 0u && got != 0u) rt.memory().copy_in(output, std::span<const std::uint8_t>(pcm.data(), got));
state->sample_position += samples;
if (samples != 0u && state->header.block_align != 0u) {
if (state->buffered_encoded_bytes >= state->header.block_align)
state->buffered_encoded_bytes -= state->header.block_align;
else
state->buffered_encoded_bytes = 0u;
}
const bool finished = samples == 0u ||
(state->sample_position >= state->header.total_samples && state->loop_num == 0);
const std::uint32_t remaining_frames = atrac_remain_frame_status(*state);
if (samples_addr != 0u) rt.memory().store32(samples_addr, samples);
if (finish_addr != 0u) rt.memory().store32(finish_addr, finished ? 1u : 0u);
if (remain_addr != 0u) rt.memory().store32(remain_addr, remaining_frames);
if (std::getenv("PSPRECOMP_ATRAC_DIAG") != nullptr) {
std::cerr << "[atrac] decode id=" << ctx.gpr[4] << " samples=" << samples
<< " stream_channels=" << state->header.channels
<< " stream_rate=" << state->header.sample_rate
<< " position=" << state->sample_position << " finish=" << finished
<< " buffered_frames=" << remaining_frames << "\n";
}
// Decoding above is synchronous host work. Delaying the guest
// audio thread by another 2300 us double-counted that work and,
// once the city became busy, made it miss its 512-frame feeding
// cadence. sceAudioOutput2OutputBlocking already provides the
// hardware pacing at the end of the pipeline; ATRAC decode itself
// must return as soon as its PCM is ready.
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x9AE849A7u,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
if (!rt.memory().contains(ctx.gpr[5], 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
rt.memory().store32(ctx.gpr[5], atrac_remain_frame_status(*state));
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0xA554A158u,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
if (!rt.memory().contains(ctx.gpr[5], 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
rt.memory().store32(ctx.gpr[5], atrac_bitrate_kbps(*state));
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0xA2BBA8BEu,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
const std::array<std::pair<std::uint32_t, std::uint32_t>, 3> outputs{{
{ctx.gpr[5], state->header.total_samples == 0u ? 0u : state->header.total_samples - 1u},
{ctx.gpr[6], static_cast<std::uint32_t>(state->header.loop_start)},
{ctx.gpr[7], static_cast<std::uint32_t>(state->header.loop_end)},
}};
for (const auto &[address, value] : outputs) {
if (address != 0u) {
if (!rt.memory().contains(address, 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
rt.memory().store32(address, value);
}
}
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0xFAA4F89Bu,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
if (ctx.gpr[5] != 0u) {
if (!rt.memory().contains(ctx.gpr[5], 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
rt.memory().store32(ctx.gpr[5], static_cast<std::uint32_t>(state->loop_num));
}
if (ctx.gpr[6] != 0u) {
if (!rt.memory().contains(ctx.gpr[6], 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
rt.memory().store32(ctx.gpr[6], state->header.loop_start >= 0 ? 1u : 0u);
}
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x868120B5u,
[get_atrac](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
state->loop_num = static_cast<std::int32_t>(ctx.gpr[5]);
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0xE88F759Bu,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
if (ctx.gpr[5] != 0u) {
if (!rt.memory().contains(ctx.gpr[5], 4u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
rt.memory().store32(ctx.gpr[5], state->internal_error);
}
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x2DD3E298u,
[get_atrac](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
const std::uint32_t sample = ctx.gpr[5];
const std::uint32_t info = ctx.gpr[6];
if (!rt.memory().contains(info, 32u)) { ctx.set_gpr(2, kAtracErrorBadAddress); return; }
const std::uint32_t frame = sample / atrac_samples_per_frame(*state);
const std::uint64_t pos64 = static_cast<std::uint64_t>(state->header.data_offset) +
static_cast<std::uint64_t>(frame) * state->header.block_align;
const std::uint32_t file_pos = static_cast<std::uint32_t>(std::min<std::uint64_t>(pos64, state->header.file_size));
const std::uint32_t writable = std::min(state->buffer_size, state->header.file_size - file_pos);
rt.memory().store32(info + 0u, state->buffer_address);
rt.memory().store32(info + 4u, writable);
rt.memory().store32(info + 8u, std::min<std::uint32_t>(writable, state->header.block_align));
rt.memory().store32(info + 12u, file_pos);
rt.memory().zero(info + 16u, 16u);
set_success(ctx);
});
runtime.register_hle("sceAtrac3plus", 0x644E5607u,
[get_atrac](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *state = get_atrac(ctx.gpr[4]);
if (!state) { ctx.set_gpr(2, kAtracErrorBadId); return; }
const std::uint32_t sample = std::min(ctx.gpr[5], state->header.total_samples);
const std::uint32_t bytes_first = ctx.gpr[6];
const std::uint32_t frame = sample / atrac_samples_per_frame(*state);
const std::uint64_t pos64 = static_cast<std::uint64_t>(state->header.data_offset) +
static_cast<std::uint64_t>(frame) * state->header.block_align;
state->sample_position = sample;
state->next_file_offset = static_cast<std::uint32_t>(std::min<std::uint64_t>(pos64 + bytes_first, state->header.file_size));
state->buffered_encoded_bytes = std::min(bytes_first, state->buffer_size);
state->write_offset = state->buffer_size == 0u ? 0u : bytes_first % state->buffer_size;
close_atrac_decoder(*state);
set_success(ctx);
});
// Native bring-up is intentionally offline. Report the physical WLAN
// switch as off rather than claiming a connected/powered radio.
runtime.register_hle("sceWlanDrv", 0xD7763699u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0u); });
runtime.register_hle("sceSasCore", 0x42778A9Fu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t core = ctx.gpr[4];
const std::uint32_t grain = ctx.gpr[5];
const std::uint32_t max_voices = ctx.gpr[6];
const std::uint32_t output_mode = ctx.gpr[7];
const std::uint32_t sample_rate = ctx.gpr[8];
if ((core & 0x3Fu) != 0u || !rt.memory().contains(core, 64u)) {
ctx.set_gpr(2, kSasErrorBadAddress); return;
}
if (max_voices == 0u || max_voices > 32u) {
ctx.set_gpr(2, kSasErrorInvalidMaxVoices); return;
}
if (grain < 0x40u || grain > 0x800u || (grain & 0x1Fu) != 0u) {
ctx.set_gpr(2, kSasErrorInvalidGrain); return;
}
if (output_mode > 1u) {
ctx.set_gpr(2, kSasErrorInvalidOutputMode); return;
}
if (sample_rate != 44100u) {
ctx.set_gpr(2, kSasErrorInvalidSampleRate); return;
}
sas_state = SasState{};
sas_core_mix_calls = 0u;
sas_core_with_mix_calls = 0u;
sas_state.initialized = true;
sas_state.core_address = core;
sas_state.grain_size = grain;
// Hardware exposes all 32 voices even when maxVoices is smaller.
sas_state.max_voices = 32u;
sas_state.output_mode = output_mode;
sas_state.sample_rate = sample_rate;
for (auto &voice : sas_state.voices) voice.pitch = 0x1000;
rt.memory().zero(core, 64u);
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x99944089u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
const std::uint32_t address = ctx.gpr[6];
std::int32_t size = static_cast<std::int32_t>(ctx.gpr[7]);
const std::int32_t loop = static_cast<std::int32_t>(ctx.gpr[8]);
if (size == 0 || (static_cast<std::uint32_t>(size) & 0xFu) != 0u) {
ctx.set_gpr(2, kSasErrorInvalidParameter); return;
}
if (loop != 0 && loop != 1) {
ctx.set_gpr(2, kSasErrorInvalidLoop); return;
}
if (size < 0) size = 0;
if (size > 0 && !rt.memory().contains(address, static_cast<std::size_t>(size))) {
// PSP ignores an invalid VAG pointer, leaving the voice configured.
set_success(ctx); return;
}
voice->type = SasVoiceType::Vag;
voice->data_address = address;
voice->data_size = size;
voice->loop = loop != 0;
sas_reset_decoder(*voice);
if (voice->on) voice->playing = true;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xB7660A23u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
const std::int32_t frequency = static_cast<std::int32_t>(ctx.gpr[6]);
if (frequency < 0 || frequency >= 64) {
ctx.set_gpr(2, kSasErrorInvalidNoiseFrequency); return;
}
voice->type = SasVoiceType::Noise;
voice->noise_frequency = frequency;
sas_reset_decoder(*voice);
if (voice->on) voice->playing = true;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xAD84D37Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
const std::int32_t pitch = static_cast<std::int32_t>(ctx.gpr[6]);
if (pitch < 0 || pitch > 0x4000) {
ctx.set_gpr(2, kSasErrorInvalidPitch); return;
}
voice->pitch = pitch;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x440CA7D8u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
const std::array<std::int32_t, 4> volumes{
static_cast<std::int32_t>(ctx.gpr[6]), static_cast<std::int32_t>(ctx.gpr[7]),
static_cast<std::int32_t>(ctx.gpr[8]), static_cast<std::int32_t>(ctx.gpr[9])};
for (const auto volume : volumes) {
if (static_cast<std::int64_t>(volume) < -0x1000ll || static_cast<std::int64_t>(volume) > 0x1000ll) {
ctx.set_gpr(2, kSasErrorInvalidVolume); return;
}
}
voice->left_volume = volumes[0]; voice->right_volume = volumes[1];
voice->effect_left_volume = volumes[2]; voice->effect_right_volume = volumes[3];
if (sas_audio_diagnostics_enabled() &&
(volumes[2] != 0 || volumes[3] != 0)) {
std::cerr << "[sas] volume voice=" << static_cast<std::int32_t>(ctx.gpr[5])
<< " dry=" << volumes[0] << "," << volumes[1]
<< " effect=" << volumes[2] << "," << volumes[3] << "\n";
}
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x019B25EBu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
const std::uint32_t flags = ctx.gpr[6] & 0xFu;
const std::array<std::int32_t, 4> rates{
static_cast<std::int32_t>(ctx.gpr[7]), static_cast<std::int32_t>(ctx.gpr[8]),
static_cast<std::int32_t>(ctx.gpr[9]), static_cast<std::int32_t>(ctx.gpr[10])};
for (std::size_t i = 0; i < rates.size(); ++i) {
if ((flags & (1u << i)) != 0u && rates[i] < 0) {
ctx.set_gpr(2, kSasErrorInvalidAdsrRate); return;
}
}
for (std::size_t i = 0; i < rates.size(); ++i)
if ((flags & (1u << i)) != 0u) voice->adsr_rates[i] = rates[i];
if (flags != 0u) voice->adsr_configured = true;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x9EC3676Au,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
const std::uint32_t flags = ctx.gpr[6] & 0xFu;
std::array<std::int32_t, 4> modes{
static_cast<std::int32_t>(ctx.gpr[7] & 0x7FFFFFFFu), static_cast<std::int32_t>(ctx.gpr[8] & 0x7FFFFFFFu),
static_cast<std::int32_t>(ctx.gpr[9] & 0x7FFFFFFFu), static_cast<std::int32_t>(ctx.gpr[10] & 0x7FFFFFFFu)};
const bool invalid_attack = modes[0] > 5 || (modes[0] & 1) != 0;
const bool invalid_decay = modes[1] > 5 || (modes[1] & 1) != 1;
const bool invalid_sustain = modes[2] > 5;
const bool invalid_release = modes[3] > 5 || (modes[3] & 1) != 1;
const std::array<bool, 4> invalid{invalid_attack, invalid_decay, invalid_sustain, invalid_release};
for (std::size_t i = 0; i < invalid.size(); ++i) {
if ((flags & (1u << i)) != 0u && invalid[i]) {
ctx.set_gpr(2, kSasErrorInvalidAdsrMode); return;
}
}
for (std::size_t i = 0; i < modes.size(); ++i)
if ((flags & (1u << i)) != 0u) voice->adsr_modes[i] = modes[i];
if (flags != 0u) voice->adsr_configured = true;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x5F9529F6u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
voice->sustain_level = static_cast<std::int32_t>(ctx.gpr[6]);
voice->adsr_configured = true;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xCBCD4F79u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
if (((ctx.gpr[7] >> 13u) & 1u) != 0u) {
ctx.set_gpr(2, kSasErrorInvalidAdsrMode); return;
}
voice->simple_adsr1 = ctx.gpr[6] & 0xFFFFu;
voice->simple_adsr2 = ctx.gpr[7] & 0xFFFFu;
sas_decode_simple_adsr(*voice);
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x76F01ACAu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
if (voice->paused || voice->on) {
ctx.set_gpr(2, kSasErrorVoicePaused); return;
}
sas_reset_decoder(*voice);
voice->on = true;
voice->playing = voice->type != SasVoiceType::Off;
voice->envelope_height = 0u;
voice->envelope_phase = SasEnvelopePhase::Attack;
voice->key_on_delay_samples = voice->adsr_configured
? (voice->type == SasVoiceType::Vag ? 33u : 32u) : 0u;
if (sas_audio_diagnostics_enabled())
std::cerr << "[sas] keyon voice=" << static_cast<std::int32_t>(ctx.gpr[5])
<< " type=" << static_cast<int>(voice->type)
<< " pitch=" << voice->pitch
<< " loop=" << voice->loop << "\n";
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xA0CF2FA4u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
if (voice->paused || !voice->on) {
ctx.set_gpr(2, kSasErrorVoicePaused); return;
}
voice->on = false;
voice->envelope_phase = SasEnvelopePhase::Release;
// A release rate of zero never walks the envelope down, so a looping
// voice keyed off here would sound forever. Log it: this is the
// remaining suspect for the vehicle engine that keeps running under
// the pause menu.
if (sas_audio_diagnostics_enabled())
std::cerr << "[sas] keyoff voice=" << static_cast<std::int32_t>(ctx.gpr[5])
<< " loop=" << voice->loop
<< " release_mode=" << voice->adsr_modes[3]
<< " release_rate=" << voice->adsr_rates[3]
<< " height=" << voice->envelope_height << "\n";
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x787D04D5u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
std::uint32_t mask = ctx.gpr[5];
const bool pause = ctx.gpr[6] != 0u;
for (std::size_t i = 0; i < sas_state.voices.size(); ++i)
if ((mask & (1u << i)) != 0u) sas_state.voices[i].paused = pause;
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x2C8E6AB3u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
std::uint32_t flags = 0u;
for (std::size_t i = 0; i < sas_state.voices.size(); ++i)
if (sas_state.voices[i].paused) flags |= 1u << i;
ctx.set_gpr(2, flags);
});
runtime.register_hle("sceSasCore", 0x68A46B95u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
ctx.set_gpr(2, sas_state.end_flags);
});
runtime.register_hle("sceSasCore", 0x74AE582Au,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
auto *voice = sas_voice(ctx.gpr[4], static_cast<std::int32_t>(ctx.gpr[5]), ctx);
if (!voice) return;
ctx.set_gpr(2, voice->envelope_height);
});
runtime.register_hle("sceSasCore", 0x33D4AB37u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
const std::int32_t type = static_cast<std::int32_t>(ctx.gpr[5]);
if (type < -1 || type > 8) { ctx.set_gpr(2, kSasErrorReverbType); return; }
if (sas_state.reverb.type != type) {
sas_state.reverb.type = type;
sas_state.reverb.history_left.clear();
sas_state.reverb.history_right.clear();
sas_state.reverb.history_cursor = 0u;
}
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x267A6DD2u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
const std::int32_t delay = static_cast<std::int32_t>(ctx.gpr[5]);
const std::int32_t feedback = static_cast<std::int32_t>(ctx.gpr[6]);
if (delay < 0 || delay >= 128) { ctx.set_gpr(2, kSasErrorReverbDelay); return; }
if (feedback < 0 || feedback >= 128) { ctx.set_gpr(2, kSasErrorReverbFeedback); return; }
sas_state.reverb.delay = delay; sas_state.reverb.feedback = feedback; set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xD5A229C9u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
if (ctx.gpr[5] > 0x1000u || ctx.gpr[6] > 0x1000u) {
ctx.set_gpr(2, kSasErrorReverbVolume); return;
}
sas_state.reverb.left_volume = ctx.gpr[5]; sas_state.reverb.right_volume = ctx.gpr[6]; set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xF983B186u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
sas_state.reverb.dry = ctx.gpr[5] != 0u; sas_state.reverb.wet = ctx.gpr[6] != 0u; set_success(ctx);
});
runtime.register_hle("sceSasCore", 0xA3589D81u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
const std::uint32_t output = ctx.gpr[5];
const std::size_t bytes = static_cast<std::size_t>(sas_state.grain_size) *
(sas_state.output_mode == 0u ? 4u : 8u);
if (!rt.memory().contains(output, bytes)) { ctx.set_gpr(2, kSasErrorInvalidParameter); return; }
++sas_core_mix_calls;
sas_log_mix_checkpoint("core", sas_core_mix_calls);
if (sas_state.output_mode == 0u)
sas_mix_into(rt, output, sas_state.grain_size);
else
sas_mix_raw(rt, output, sas_state.grain_size);
set_success(ctx);
});
runtime.register_hle("sceSasCore", 0x50A14DFCu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (!sas_valid_core(ctx.gpr[4])) { ctx.set_gpr(2, kSasErrorNotInitialized); return; }
if (sas_state.output_mode == 1u) { ctx.set_gpr(2, 0x800001FFu); return; }
const std::uint32_t inout = ctx.gpr[5];
const std::size_t bytes = static_cast<std::size_t>(sas_state.grain_size) * 4u;
if (!rt.memory().contains(inout, bytes)) { ctx.set_gpr(2, kSasErrorInvalidParameter); return; }
const std::uint32_t input_left = ctx.gpr[6];
const std::uint32_t input_right = ctx.gpr[7];
if (input_left > 0x1000u || input_right > 0x1000u) {
ctx.set_gpr(2, kSasErrorInvalidVolume); return;
}
++sas_core_with_mix_calls;
sas_log_mix_checkpoint("core-with-mix", sas_core_with_mix_calls);
sas_mix_into(rt, inout, sas_state.grain_size, true, input_left, input_right);
set_success(ctx);
});
runtime.register_hle("scePower", 0x04B7766Eu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("scePower", 0xDFA8BAF8u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceUmdUser", 0xAEE7404Du,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceUmdUser", 0xBD2BDE07u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceUmdUser", 0x46EBB729u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 1u); });
runtime.register_hle("sceUmdUser", 0x6B4A146Cu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { ctx.set_gpr(2, 0x32u); });
runtime.register_hle("sceUmdUser", 0x8EF08FCEu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceUmdUser", 0xC6183D47u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("LoadExecForUser", 0x4AC57943u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceMpeg", 0x682A619Bu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceMpeg", 0x874624D6u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("sceMpeg", 0xD7A29F46u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto packets = static_cast<std::int32_t>(ctx.gpr[4]);
if (packets < 0) {
ctx.set_gpr(2, 0x80610103u);
return;
}
ctx.set_gpr(2, static_cast<std::uint32_t>(packets) * (2048u + 104u));
});
runtime.register_hle("sceMpeg", 0xC132E22Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
// VCS ships with the 1.05+ MPEG module ABI.
ctx.set_gpr(2, 0x00010000u);
});
runtime.register_hle("sceMpeg", 0x37295ED8u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t ring = ctx.gpr[4];
const auto packets = static_cast<std::int32_t>(ctx.gpr[5]);
const std::uint32_t data = ctx.gpr[6];
const std::uint32_t size = ctx.gpr[7];
// PSP user ABI continues arguments through t0-t3 before the stack.
const std::uint32_t callback = ctx.gpr[8];
const std::uint32_t callback_arg = ctx.gpr[9];
if (packets < 0 || !rt.memory().contains(ring, 48u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
const std::uint64_t required = static_cast<std::uint64_t>(packets) * (2048u + 104u);
if (required > size || !rt.memory().contains(data, static_cast<std::size_t>(packets) * 2048u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
rt.memory().zero(ring, 48u);
rt.memory().store32(ring + 0u, static_cast<std::uint32_t>(packets));
rt.memory().store32(ring + 4u, 0u); // packetsRead
rt.memory().store32(ring + 8u, 0u); // packetsWritePos
rt.memory().store32(ring + 12u, 0u); // packetsAvail
rt.memory().store32(ring + 16u, 2048u); // packetSize
rt.memory().store32(ring + 20u, data);
rt.memory().store32(ring + 24u, callback);
rt.memory().store32(ring + 28u, callback_arg);
rt.memory().store32(ring + 32u, data + static_cast<std::uint32_t>(packets) * 2048u);
rt.memory().store32(ring + 36u, 0u); // semaID/padding
rt.memory().store32(ring + 40u, 0u); // mpeg pointer, set by Create
rt.memory().store32(ring + 44u, ctx.gpr[28]);
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0xD8C5F121u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t mpeg_out = ctx.gpr[4];
const std::uint32_t data = ctx.gpr[5];
const std::uint32_t size = ctx.gpr[6];
const std::uint32_t ring = ctx.gpr[7];
const std::uint32_t frame_width = ctx.gpr[8];
const std::uint32_t mode = ctx.gpr[9];
const std::uint32_t ddr_top = ctx.gpr[10];
(void)mode;
(void)ddr_top;
if (size < 0x10000u || !rt.memory().contains(mpeg_out, 4u) ||
!rt.memory().contains(data, size) || !rt.memory().contains(ring, 48u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
const std::uint32_t handle = data + 0x30u;
if (!rt.memory().contains(handle, 24u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
rt.memory().store32(mpeg_out, handle);
const std::array<std::uint8_t, 8> magic{'L','I','B','M','P','E','G',0};
const std::array<std::uint8_t, 4> version{'0','0','1',0};
rt.memory().copy_in(handle, magic);
rt.memory().copy_in(handle + 8u, version);
rt.memory().store32(handle + 12u, 0xFFFFFFFFu);
rt.memory().store32(handle + 16u, ring);
rt.memory().store32(handle + 20u, rt.memory().load32(ring + 32u));
rt.memory().store32(ring + 40u, mpeg_out);
MpegContextState state{};
state.handle_address = handle;
state.ring_address = ring;
state.video_pixel_mode = 3u;
mpeg_contexts[mpeg_out] = std::move(state);
(void)frame_width;
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x21FF80E4u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t mpeg = ctx.gpr[4];
const std::uint32_t buffer = ctx.gpr[5];
const std::uint32_t output = ctx.gpr[6];
const auto state = mpeg_contexts.find(mpeg);
if (state == mpeg_contexts.end() || !rt.memory().contains(buffer, 2048u) ||
!rt.memory().contains(output, 4u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
std::array<std::uint8_t, 2048> bytes{};
rt.memory().copy_out(buffer, bytes);
ParsedPsmfHeader header{};
if (!parse_psmf_header(bytes, header)) {
rt.memory().store32(output, 0u);
ctx.set_gpr(2, 0x806101FEu);
return;
}
if (header.stream_offset == 0u || (header.stream_offset & 2047u) != 0u) {
rt.memory().store32(output, 0u);
ctx.set_gpr(2, 0x806101FEu);
return;
}
const auto source = identify_pmf_source(bytes, header);
if (state->second.source_path != source) {
close_video_decoder(state->second);
state->second.source_path = source;
// A new PSMF is a new timestamp domain even when the game
// reuses the same SceMpeg work area and ringbuffer. AU
// counters are per stream, not lifetime totals.
state->second.video_au_count = 0u;
state->second.audio_au_count = 0u;
for (auto &[id, stream] : state->second.streams) {
(void)id;
stream.needs_reset = true;
}
}
state->second.header = header;
state->second.analyzed = true;
rt.memory().store32(output, header.stream_offset);
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr) {
std::cerr << "[mpeg] PSMF version=" << std::string(bytes.begin() + 4, bytes.begin() + 8)
<< " offset=" << header.stream_offset << " size=" << header.stream_size
<< " dimensions=" << header.width << "x" << header.height
<< " first_pts=" << header.first_timestamp << " last_pts=" << header.last_timestamp
<< " source=\"" << state->second.source_path.string() << "\"\n";
}
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x611E9E11u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t buffer = ctx.gpr[4];
const std::uint32_t output = ctx.gpr[5];
if (!rt.memory().contains(buffer, 2048u) || !rt.memory().contains(output, 4u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
std::array<std::uint8_t, 2048> bytes{};
rt.memory().copy_out(buffer, bytes);
ParsedPsmfHeader header{};
if (!parse_psmf_header(bytes, header) || (header.stream_offset & 2047u) != 0u) {
rt.memory().store32(output, 0u);
ctx.set_gpr(2, 0x806101FEu);
return;
}
rt.memory().store32(output, header.stream_size);
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x42560F23u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
if (state == mpeg_contexts.end()) {
ctx.set_gpr(2, 0x806101FEu);
return;
}
const std::uint32_t stream_id = next_mpeg_stream_id++;
state->second.streams.emplace(stream_id, MpegStreamState{ctx.gpr[5], ctx.gpr[6], true});
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr)
std::cerr << "[mpeg] register stream id=" << stream_id << " type=" << ctx.gpr[5]
<< " number=" << ctx.gpr[6] << "\n";
ctx.set_gpr(2, stream_id);
});
runtime.register_hle("sceMpeg", 0x591A4AA2u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
if (state == mpeg_contexts.end() || state->second.streams.erase(ctx.gpr[5]) != 1u) {
ctx.set_gpr(2, 0x806101FEu);
return;
}
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x707B7629u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
if (state == mpeg_contexts.end()) { ctx.set_gpr(2, 0x806101FEu); return; }
state->second.analyzed = false;
state->second.video_au_count = 0u;
state->second.audio_au_count = 0u;
close_video_decoder(state->second);
for (auto &[id, stream] : state->second.streams) stream.needs_reset = true;
const std::uint32_t ring = state->second.ring_address;
if (ring != 0u && rt.memory().contains(ring, 48u)) {
rt.memory().store32(ring + 4u, 0u);
rt.memory().store32(ring + 8u, 0u);
rt.memory().store32(ring + 12u, 0u);
}
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0xA780CF7Eu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
if (state == mpeg_contexts.end()) { ctx.set_gpr(2, 0x806101FEu); return; }
for (std::size_t index = 0; index < state->second.avc_es_buffers.size(); ++index) {
if (!state->second.avc_es_buffers[index]) {
state->second.avc_es_buffers[index] = true;
ctx.set_gpr(2, static_cast<std::uint32_t>(index + 1u));
return;
}
}
ctx.set_gpr(2, 0u);
});
runtime.register_hle("sceMpeg", 0xCEB870B1u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t buffer = ctx.gpr[5];
if (state == mpeg_contexts.end() || buffer == 0u || buffer > 2u ||
!state->second.avc_es_buffers[buffer - 1u]) {
ctx.set_gpr(2, 0x806101FEu);
return;
}
state->second.avc_es_buffers[buffer - 1u] = false;
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x167AFD9Eu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t buffer = ctx.gpr[5];
const std::uint32_t au = ctx.gpr[6];
if (state == mpeg_contexts.end() || !rt.memory().contains(au, 24u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
const bool avc = buffer >= 1u && buffer <= 2u && state->second.avc_es_buffers[buffer - 1u];
rt.memory().zero(au, 24u);
if (!avc) {
rt.memory().store32(au + 8u, 0xFFFFFFFFu);
rt.memory().store32(au + 12u, 0xFFFFFFFFu);
}
rt.memory().store32(au + 20u, avc ? 2048u : 2112u);
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0xF8DCB679u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
if (mpeg_contexts.find(ctx.gpr[4]) == mpeg_contexts.end() ||
!rt.memory().contains(ctx.gpr[5], 4u) || !rt.memory().contains(ctx.gpr[6], 4u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
rt.memory().store32(ctx.gpr[5], 2112u);
rt.memory().store32(ctx.gpr[6], 8192u);
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x800C44DFu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t au = ctx.gpr[5];
const std::uint32_t output = ctx.gpr[6];
if (state == mpeg_contexts.end() || !rt.memory().contains(au, 24u) ||
!rt.memory().contains(output, 8192u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
// The movie's own soundtrack. It lives in private_stream_1 packets
// that no generic demuxer surfaces, so PmfAudioDecoder walks the
// container itself; see vcs_media_decoder.cpp. Silence remains the
// fallback, because a mute intro beats a stalled one.
MpegContextState &mpeg = state->second;
// Reopen when the movie changes, not merely when nothing is open: a
// context is reused across cutscenes, and an exhausted stream from
// the previous one still reports itself as open.
if (!mpeg.source_path.empty() &&
(!mpeg.audio.is_open() || mpeg.audio_source != mpeg.source_path)) {
mpeg.audio_source = mpeg.source_path;
(void)mpeg.audio.open(mpeg.source_path);
}
std::array<std::uint8_t, 8192u> pcm{};
const std::size_t decoded = mpeg.audio.is_open()
? mpeg.audio.read(pcm) : 0u;
if (decoded != 0u) {
rt.memory().copy_in(output, std::span<const std::uint8_t>(pcm.data(), pcm.size()));
} else {
rt.memory().zero(output, 8192u);
}
const std::uint64_t pts = state->second.header.first_timestamp +
static_cast<std::uint64_t>(state->second.audio_au_count) * 4180u;
write_mpeg_timestamp(rt.memory(), au, pts);
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr && state->second.audio_au_count <= 3u)
std::cerr << "[mpeg] ATRAC decode bytes=" << decoded
<< " pts=" << pts
<< " output=" << psprecomp::hex32(output) << "\n";
(void)delay_current_thread(rt, ctx, 3000u, 0u);
});
runtime.register_hle("sceMpeg", 0x0E3C2E9Du,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t au = ctx.gpr[5];
std::uint32_t frame_width = ctx.gpr[6];
const std::uint32_t buffer_pointer = ctx.gpr[7];
const std::uint32_t status_pointer = ctx.gpr[8];
if (state == mpeg_contexts.end() || !state->second.analyzed ||
!rt.memory().contains(au, 24u) || !rt.memory().contains(buffer_pointer, 4u) ||
!rt.memory().contains(status_pointer, 4u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
if (frame_width == 0u) frame_width = state->second.header.width;
if (frame_width < state->second.header.width) { ctx.set_gpr(2, 0x806201FEu); return; }
const std::uint32_t destination = rt.memory().load32(buffer_pointer);
const std::size_t frame_bytes = static_cast<std::size_t>(state->second.header.width) *
state->second.header.height * 4u;
const std::size_t destination_bytes = static_cast<std::size_t>(frame_width) *
state->second.header.height * 4u;
if (destination == 0u || !rt.memory().contains(destination, destination_bytes)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
std::vector<std::uint8_t> frame(frame_bytes);
if (!read_video_frame(state->second, frame)) {
// Natural end of TITLES.PMF is the earliest exact hand-off to
// the retail startup flow. Release the native-menu boot gate
// here; skipped movies are covered by Reset/Delete below.
rt.memory().store32(status_pointer, 0u);
ctx.set_gpr(2, 0x80628002u);
return;
}
movie_output_buffers.insert(normalize_ram_address(destination));
const std::size_t source_stride = static_cast<std::size_t>(state->second.header.width) * 4u;
const std::size_t destination_stride = static_cast<std::size_t>(frame_width) * 4u;
for (std::uint32_t y = 0u; y < state->second.header.height; ++y) {
rt.memory().copy_in(destination + static_cast<std::uint32_t>(y * destination_stride),
std::span<const std::uint8_t>(frame.data() + y * source_stride, source_stride));
}
rt.memory().store32(status_pointer, 1u);
const std::uint32_t total_frames = std::max<std::uint32_t>(1u, static_cast<std::uint32_t>(
(state->second.header.last_timestamp - state->second.header.first_timestamp) / 3003u));
const std::uint32_t total_packets = (state->second.header.stream_size + 2047u) / 2048u;
const std::uint32_t target_consumed = static_cast<std::uint32_t>(std::min<std::uint64_t>(
total_packets, static_cast<std::uint64_t>(state->second.decoded_video_frames) * total_packets / total_frames));
const std::uint32_t consume = target_consumed - state->second.consumed_video_packets;
state->second.consumed_video_packets = target_consumed;
const std::uint32_t ring = state->second.ring_address;
if (consume != 0u && rt.memory().contains(ring, 48u)) {
const std::uint32_t used = rt.memory().load32(ring + 12u);
rt.memory().store32(ring + 12u, used > consume ? used - consume : 0u);
}
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr &&
(state->second.decoded_video_frames <= 3u || state->second.decoded_video_frames % 30u == 0u)) {
std::cerr << "[mpeg] decoded frame=" << state->second.decoded_video_frames
<< " destination=" << psprecomp::hex32(destination)
<< " stride=" << frame_width << " consume=" << consume << "\n";
}
const std::uint32_t delay = state->second.decoded_video_frames <= 1u ? 3600u : 5400u;
(void)delay_current_thread(rt, ctx, delay, 0u);
});
runtime.register_hle("sceMpeg", 0x740FCCD1u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
// sceMpegAvcDecodeStop(mpeg, frameWidth, bufferAddr, statusAddr).
// Our sequential decoder does not retain a delayed final frame, so
// the correct drain result is a zero status without modifying the
// caller's framebuffer pointer.
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t buffer_pointer = ctx.gpr[6];
const std::uint32_t status_pointer = ctx.gpr[7];
if (state == mpeg_contexts.end()) {
ctx.set_gpr(2, 0x806101FEu);
return;
}
if (!rt.memory().contains(buffer_pointer, 4u) ||
!rt.memory().contains(status_pointer, 4u)) {
ctx.set_gpr(2, 0x80610103u);
return;
}
rt.memory().store32(status_pointer, 0u);
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr)
std::cerr << "[mpeg] AVC decode stop: no pending frame\n";
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0xE1CE83A7u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t au = ctx.gpr[6];
const std::uint32_t attributes = ctx.gpr[7];
if (state == mpeg_contexts.end()) { ctx.set_gpr(2, 0x806101FEu); return; }
const auto stream = state->second.streams.find(ctx.gpr[5]);
if (stream == state->second.streams.end() ||
(stream->second.type != 1u && stream->second.type != 15u) ||
!rt.memory().contains(au, 24u)) {
ctx.set_gpr(2, 0x806101FEu);
return;
}
const std::uint32_t ring = state->second.ring_address;
if (!rt.memory().contains(ring, 48u) || rt.memory().load32(ring + 12u) == 0u) {
write_mpeg_timestamp(rt.memory(), au, 0u);
write_mpeg_timestamp(rt.memory(), au + 8u, 0u);
ctx.set_gpr(2, 0x80618001u);
return;
}
const std::uint64_t pts = state->second.header.first_timestamp +
static_cast<std::uint64_t>(state->second.audio_au_count) * 4180u;
write_mpeg_timestamp(rt.memory(), au, pts);
write_mpeg_timestamp(rt.memory(), au + 8u, pts);
rt.memory().store32(au + 16u, stream->second.number);
rt.memory().store32(au + 20u, 2112u);
if (attributes != 0u && rt.memory().contains(attributes, 4u)) rt.memory().store32(attributes, 0u);
stream->second.needs_reset = false;
++state->second.audio_au_count;
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr)
std::cerr << "[mpeg] ATRAC AU stream=" << ctx.gpr[5] << " pts=" << pts
<< " used_packets=" << rt.memory().load32(ring + 12u) << "\n";
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0xFE246728u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto state = mpeg_contexts.find(ctx.gpr[4]);
const std::uint32_t au = ctx.gpr[6];
const std::uint32_t attributes = ctx.gpr[7];
if (state == mpeg_contexts.end()) { ctx.set_gpr(2, 0x806101FEu); return; }
const auto stream = state->second.streams.find(ctx.gpr[5]);
if (stream == state->second.streams.end() ||
stream->second.type != 0u || !rt.memory().contains(au, 24u)) {
ctx.set_gpr(2, 0x806101FEu);
return;
}
const std::uint32_t ring = state->second.ring_address;
if (!rt.memory().contains(ring, 48u) || rt.memory().load32(ring + 12u) == 0u) {
write_mpeg_timestamp(rt.memory(), au, 0u);
write_mpeg_timestamp(rt.memory(), au + 8u, 0u);
ctx.set_gpr(2, 0x80618001u);
return;
}
const std::uint64_t pts = state->second.header.first_timestamp +
static_cast<std::uint64_t>(state->second.video_au_count) * 3003u;
const std::uint64_t dts = pts >= 3003u ? pts - 3003u : 0u;
write_mpeg_timestamp(rt.memory(), au, pts);
write_mpeg_timestamp(rt.memory(), au + 8u, dts);
rt.memory().store32(au + 16u, stream->second.number);
rt.memory().store32(au + 20u, 2048u);
if (attributes != 0u && rt.memory().contains(attributes, 4u)) rt.memory().store32(attributes, 1u);
stream->second.needs_reset = false;
++state->second.video_au_count;
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr)
std::cerr << "[mpeg] AVC AU stream=" << ctx.gpr[5] << " pts=" << pts << " dts=" << dts
<< " used_packets=" << rt.memory().load32(ring + 12u) << "\n";
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0xB240A59Eu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t ring = ctx.gpr[4];
std::int32_t requested = static_cast<std::int32_t>(ctx.gpr[5]);
const std::int32_t caller_available = static_cast<std::int32_t>(ctx.gpr[6]);
if (!rt.memory().contains(ring, 48u)) { ctx.set_gpr(2, 0x80610103u); return; }
const std::int32_t packets = static_cast<std::int32_t>(rt.memory().load32(ring));
const std::int32_t used = static_cast<std::int32_t>(rt.memory().load32(ring + 12u));
const std::int32_t write_position = static_cast<std::int32_t>(rt.memory().load32(ring + 8u));
const std::uint32_t data = rt.memory().load32(ring + 20u);
const std::uint32_t callback = rt.memory().load32(ring + 24u);
const std::uint32_t callback_argument = rt.memory().load32(ring + 28u);
if (packets <= 0 || callback == 0u) { ctx.set_gpr(2, 0x806101FEu); return; }
requested = std::min({requested, caller_available, std::max(0, packets - used)});
if (requested <= 0) { set_success(ctx); return; }
const std::int32_t write_offset = write_position % packets;
const std::int32_t desired = std::min(requested, packets - write_offset);
psprecomp::AllegrexContext resume = ctx;
resume.pc = ctx.gpr[31];
resume.set_gpr(2, 0u);
auto &frames = async_return_frames[thread_table.current_uid];
if (!frames.empty()) {
rt.stop("Nested MPEG ringbuffer callback on one PSP thread");
return;
}
frames.push_back(AsyncReturnFrame{AsyncReturnKind::MpegRingbuffer, resume, ring,
requested - desired, desired, 0});
ctx.set_gpr(4, data + static_cast<std::uint32_t>(write_offset) * 2048u);
ctx.set_gpr(5, static_cast<std::uint32_t>(desired));
ctx.set_gpr(6, callback_argument);
ctx.set_gpr(31, 0x00000004u);
ctx.pc = callback;
if (std::getenv("PSPRECOMP_MPEG_DIAG") != nullptr) {
std::cerr << "[mpeg] ring put ring=" << psprecomp::hex32(ring)
<< " callback=" << psprecomp::hex32(callback)
<< " data=" << psprecomp::hex32(ctx.gpr[4])
<< " desired=" << desired << " remaining=" << requested - desired << "\n";
}
});
runtime.register_hle("sceMpeg", 0xB5F6DC87u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t ring = ctx.gpr[4];
if (!rt.memory().contains(ring, 48u)) { ctx.set_gpr(2, 0x800200D3u); return; }
const std::int32_t packets = static_cast<std::int32_t>(rt.memory().load32(ring));
const std::int32_t used = static_cast<std::int32_t>(rt.memory().load32(ring + 12u));
ctx.set_gpr(2, static_cast<std::uint32_t>(std::max(0, packets - used)));
});
runtime.register_hle("sceMpeg", 0x606A4649u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
// sceMpegDelete receives the address of the SceMpeg handle. The
// firmware tears down decoder-side state while leaving ownership
// of the caller-provided work buffer with the game.
const std::uint32_t mpeg_out = ctx.gpr[4];
if (mpeg_out == 0u || !rt.memory().contains(mpeg_out, 4u)) {
ctx.set_gpr(2, 0x800200D3u);
return;
}
const std::uint32_t handle = rt.memory().load32(mpeg_out);
if (handle != 0u && rt.memory().contains(handle, 24u)) {
const std::uint32_t ring = rt.memory().load32(handle + 16u);
if (ring != 0u && rt.memory().contains(ring, 48u)) {
rt.memory().store32(ring + 40u, 0u);
}
}
if (auto state = mpeg_contexts.find(mpeg_out); state != mpeg_contexts.end()) {
close_video_decoder(state->second);
}
mpeg_contexts.erase(mpeg_out);
set_success(ctx);
});
runtime.register_hle("sceMpeg", 0x13407F13u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint32_t ring = ctx.gpr[4];
if (ring != 0u && rt.memory().contains(ring, 48u)) {
rt.memory().store32(ring + 12u, 0u);
rt.memory().store32(ring + 40u, 0u);
}
set_success(ctx);
});
runtime.register_hle("ModuleMgrForUser", 0xB7F46618u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
if (!file_table.files.contains(fd) && !file_table.synthetic_empty_files.contains(fd)) {
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr)
std::cerr << "[module] sceKernelLoadModuleByID rejected fd=" << fd << "\n";
ctx.set_gpr(2, 0x80010009u);
return;
}
const std::int32_t uid = next_module_uid++;
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr)
std::cerr << "[module] sceKernelLoadModuleByID fd=" << fd << " -> uid=" << uid << "\n";
loaded_modules.emplace(uid, false);
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ModuleMgrForUser", 0x50F0C1ECu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto found = loaded_modules.find(uid);
if (found == loaded_modules.end()) {
ctx.set_gpr(2, 0x8002012Eu);
return;
}
// Fifth O32 argument: optional SceKernelSMOption*. The fourth
// argument is the module_start status output.
const std::uint32_t status = ctx.gpr[7];
if (status != 0u && rt.memory().contains(status, 4u)) rt.memory().store32(status, 0u);
found->second = true;
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr)
std::cerr << "[module] sceKernelStartModule uid=" << uid << " status=0\n";
ctx.set_gpr(2, static_cast<std::uint32_t>(uid));
});
runtime.register_hle("ModuleMgrForUser", 0xD1FF982Au,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
const auto found = loaded_modules.find(uid);
if (found == loaded_modules.end()) {
ctx.set_gpr(2, 0x8002012Eu);
return;
}
const std::uint32_t status = ctx.gpr[7];
if (status != 0u && rt.memory().contains(status, 4u)) rt.memory().store32(status, 0u);
found->second = false;
ctx.set_gpr(2, 0u);
});
runtime.register_hle("ModuleMgrForUser", 0x2E0911AAu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto uid = static_cast<std::int32_t>(ctx.gpr[4]);
ctx.set_gpr(2, loaded_modules.erase(uid) == 1u ? 0u : 0x8002012Eu);
});
runtime.register_hle("IoFileMgrForUser", 0x54F5FB11u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string device = ctx.gpr[4] != 0u ? rt.memory().read_c_string(ctx.gpr[4], 128u) : std::string{};
const std::uint32_t command = ctx.gpr[5];
const std::uint32_t input = ctx.gpr[6];
const std::uint32_t input_length = ctx.gpr[7];
const std::uint32_t output = rt.memory().contains(ctx.gpr[29] + 16u, 8u)
? rt.memory().load32(ctx.gpr[29] + 16u) : 0u;
const std::uint32_t output_length = rt.memory().contains(ctx.gpr[29] + 20u, 4u)
? rt.memory().load32(ctx.gpr[29] + 20u) : 0u;
if (command == 0x02425823u && (device == "fatms0:" || device == "ms0:")) {
if (output == 0u || !rt.memory().contains(output, 4u)) {
ctx.set_gpr(2, 0x80010016u);
return;
}
rt.memory().store32(output, memory_stick_fat_state);
set_success(ctx);
return;
}
if (command == 0x02415823u && (device == "fatms0:" || device == "ms0:")) {
if (input == 0u || input_length < 4u || !rt.memory().contains(input, 4u)) {
ctx.set_gpr(2, 0x80010016u);
return;
}
memory_stick_fat_state = rt.memory().load32(input) != 0u ? 1u : 0u;
set_success(ctx);
return;
}
if (command == 0x02425824u && (device == "fatms0:" || device == "ms0:")) {
if (output == 0u || output_length < 4u || !rt.memory().contains(output, 4u)) {
ctx.set_gpr(2, 0x80010016u);
return;
}
rt.memory().store32(output, 0u);
set_success(ctx);
return;
}
if (command == 0x02025806u && (device == "mscmhc0:" || device == "ms0:")) {
if (output == 0u || output_length < 4u || !rt.memory().contains(output, 4u)) {
ctx.set_gpr(2, 0x80010016u);
return;
}
rt.memory().store32(output, 1u);
set_success(ctx);
return;
}
if (std::getenv("PSPRECOMP_TRACE") != nullptr) {
std::cerr << "[hle] unsupported sceIoDevctl device=" << device
<< " cmd=0x" << std::hex << std::uppercase << command
<< " in=0x" << input << "/" << std::dec << input_length
<< " out=0x" << std::hex << output << "/" << std::dec << output_length << "\n";
}
ctx.set_gpr(2, 0x80010016u);
});
runtime.register_hle("IoFileMgrForUser", 0xB293727Fu,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { set_success(ctx); });
runtime.register_hle("IoFileMgrForUser", 0xB29DDF9Cu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
try {
const auto native = rt.translate_path(rt.memory().read_c_string(ctx.gpr[4]));
if (!std::filesystem::is_directory(native)) {
ctx.set_gpr(2, 0x80010002u);
return;
}
DirectoryHandle handle;
for (const auto &entry : std::filesystem::directory_iterator(native)) handle.entries.push_back(entry);
std::sort(handle.entries.begin(), handle.entries.end(), [](const auto &a, const auto &b) {
return a.path().filename().string() < b.path().filename().string();
});
const auto fd = file_table.next_fd++;
file_table.directories.emplace(fd, std::move(handle));
ctx.set_gpr(2, static_cast<std::uint32_t>(fd));
} catch (...) {
ctx.set_gpr(2, 0x80010002u);
}
});
runtime.register_hle("IoFileMgrForUser", 0xE3EB004Cu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
const std::uint32_t dirent = ctx.gpr[5];
const auto it = file_table.directories.find(fd);
if (it == file_table.directories.end() || !rt.memory().contains(dirent, 0x160u)) {
ctx.set_gpr(2, 0x80010009u);
return;
}
if (it->second.index >= it->second.entries.size()) {
ctx.set_gpr(2, 0u);
return;
}
const auto &entry = it->second.entries[it->second.index++];
rt.memory().zero(dirent, 0x160u);
const bool is_directory = entry.is_directory();
const std::uint32_t mode = is_directory ? 0x1000u : 0x2000u;
rt.memory().store32(dirent, mode);
if (!is_directory) {
if (const auto *disc_file = register_virtual_disc_file(entry.path())) {
rt.memory().store32(dirent + 8u, static_cast<std::uint32_t>(disc_file->size));
rt.memory().store32(dirent + 12u, static_cast<std::uint32_t>(disc_file->size >> 32u));
rt.memory().store32(dirent + 0x40u, disc_file->start_sector);
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
std::cerr << "[io] sceIoDread file=\"" << entry.path().filename().string()
<< "\" sector=" << disc_file->start_sector
<< " size=" << disc_file->size << "\n";
}
}
}
const std::string name = entry.path().filename().string();
std::vector<std::uint8_t> bytes(name.begin(), name.end());
bytes.push_back(0u);
if (bytes.size() > 256u) bytes.resize(256u);
rt.memory().copy_in(dirent + 0x58u, bytes);
ctx.set_gpr(2, 1u);
});
runtime.register_hle("IoFileMgrForUser", 0xEB092469u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
ctx.set_gpr(2, file_table.directories.erase(fd) == 1u ? 0u : 0x80010009u);
});
// sceRtc. None of it existed, and the saved-game list needs it: it turns
// each save's timestamp into a tick to sort and display it, so opening the
// load menu stopped the runtime on a missing import.
//
// A PSP tick is microseconds since 0001-01-01 00:00:00, and ScePspDateTime
// is year, month, day, hour, minute, second as 16-bit fields followed by a
// 32-bit microsecond -- 16 bytes.
{
// Howard Hinnant's civil-date algorithms, which are exact over the
// whole proleptic Gregorian range rather than only near the epoch.
const auto days_from_civil = [](std::int64_t y, unsigned m, unsigned d) -> std::int64_t {
y -= m <= 2;
const std::int64_t era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = static_cast<unsigned>(y - era * 400);
const unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2u) / 5u + d - 1u;
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
return era * 146097 + static_cast<std::int64_t>(doe) - 719468;
};
const auto civil_from_days = [](std::int64_t z, int &y, unsigned &m, unsigned &d) {
z += 719468;
const std::int64_t era = (z >= 0 ? z : z - 146096) / 146097;
const unsigned doe = static_cast<unsigned>(z - era * 146097);
const unsigned yoe = (doe - doe / 1460u + doe / 36524u - doe / 146096u) / 365u;
const std::int64_t yr = static_cast<std::int64_t>(yoe) + era * 400;
const unsigned doy = doe - (365u * yoe + yoe / 4u - yoe / 100u);
const unsigned mp = (5u * doy + 2u) / 153u;
d = doy - (153u * mp + 2u) / 5u + 1u;
m = mp + (mp < 10u ? 3u : -9u);
y = static_cast<int>(yr + (m <= 2u ? 1 : 0));
};
// Days from 0001-01-01 to 1970-01-01.
constexpr std::int64_t kDaysToUnixEpoch = 719162;
constexpr std::uint64_t kMicrosecondsPerDay = 86400ull * 1000000ull;
struct RtcHelpers {
std::function<std::uint64_t(psprecomp::Runtime &, std::uint32_t)> read_tick;
std::function<void(psprecomp::Runtime &, std::uint32_t, std::uint64_t)> write_date;
};
static RtcHelpers helpers;
helpers.read_tick = [days_from_civil](psprecomp::Runtime &rt, std::uint32_t address) -> std::uint64_t {
const std::uint32_t year = rt.memory().load16(address + 0u);
const std::uint32_t month = rt.memory().load16(address + 2u);
const std::uint32_t day = rt.memory().load16(address + 4u);
const std::uint32_t hour = rt.memory().load16(address + 6u);
const std::uint32_t minute = rt.memory().load16(address + 8u);
const std::uint32_t second = rt.memory().load16(address + 10u);
const std::uint32_t microsecond = rt.memory().load32(address + 12u);
const std::int64_t days = days_from_civil(static_cast<std::int64_t>(year),
month == 0u ? 1u : month,
day == 0u ? 1u : day) + kDaysToUnixEpoch;
return static_cast<std::uint64_t>(days) * kMicrosecondsPerDay +
(hour * 3600ull + minute * 60ull + second) * 1000000ull + microsecond;
};
helpers.write_date = [civil_from_days](psprecomp::Runtime &rt, std::uint32_t address,
std::uint64_t tick) {
const std::uint64_t day_index = tick / kMicrosecondsPerDay;
const std::uint64_t remainder = tick % kMicrosecondsPerDay;
int year = 1;
unsigned month = 1u;
unsigned day = 1u;
civil_from_days(static_cast<std::int64_t>(day_index) - kDaysToUnixEpoch, year, month, day);
rt.memory().store16(address + 0u, static_cast<std::uint16_t>(year));
rt.memory().store16(address + 2u, static_cast<std::uint16_t>(month));
rt.memory().store16(address + 4u, static_cast<std::uint16_t>(day));
rt.memory().store16(address + 6u, static_cast<std::uint16_t>(remainder / 3600000000ull));
rt.memory().store16(address + 8u, static_cast<std::uint16_t>((remainder / 60000000ull) % 60ull));
rt.memory().store16(address + 10u, static_cast<std::uint16_t>((remainder / 1000000ull) % 60ull));
rt.memory().store32(address + 12u, static_cast<std::uint32_t>(remainder % 1000000ull));
};
const auto current_tick = []() -> std::uint64_t {
// The wall clock, not the guest's virtual time: a save stamped with
// the emulated uptime would read as the year 1 in the list.
const auto now = std::chrono::system_clock::now().time_since_epoch();
const auto micros = std::chrono::duration_cast<std::chrono::microseconds>(now).count();
return static_cast<std::uint64_t>(kDaysToUnixEpoch) * kMicrosecondsPerDay +
static_cast<std::uint64_t>(micros);
};
static const auto tick_now = current_tick;
runtime.register_hle("sceRtc", 0xC41C2853u, // sceRtcGetTickResolution
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
ctx.set_gpr(2, 1000000u);
});
runtime.register_hle("sceRtc", 0x3F7AD767u, // sceRtcGetCurrentTick
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint64_t tick = tick_now();
rt.memory().store32(ctx.gpr[4], static_cast<std::uint32_t>(tick));
rt.memory().store32(ctx.gpr[4] + 4u, static_cast<std::uint32_t>(tick >> 32u));
set_success(ctx);
});
runtime.register_hle("sceRtc", 0x6FF40ACCu, // sceRtcGetTick
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint64_t tick = helpers.read_tick(rt, ctx.gpr[4]);
rt.memory().store32(ctx.gpr[5], static_cast<std::uint32_t>(tick));
rt.memory().store32(ctx.gpr[5] + 4u, static_cast<std::uint32_t>(tick >> 32u));
set_success(ctx);
});
runtime.register_hle("sceRtc", 0x7ED29E40u, // sceRtcSetTick
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::uint64_t tick =
static_cast<std::uint64_t>(rt.memory().load32(ctx.gpr[5])) |
(static_cast<std::uint64_t>(rt.memory().load32(ctx.gpr[5] + 4u)) << 32u);
helpers.write_date(rt, ctx.gpr[4], tick);
set_success(ctx);
});
const auto current_clock = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
helpers.write_date(rt, ctx.gpr[4], tick_now());
set_success(ctx);
};
runtime.register_hle("sceRtc", 0x4CFA57B0u, current_clock); // sceRtcGetCurrentClock
runtime.register_hle("sceRtc", 0xE7C27D1Bu, current_clock); // ...LocalTime
// No time zone is modelled: the host clock is already local, so both
// conversions are the identity rather than a wrong offset.
const auto copy_date = [](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
for (std::uint32_t offset = 0u; offset < 16u; offset += 4u)
rt.memory().store32(ctx.gpr[4] + offset, rt.memory().load32(ctx.gpr[5] + offset));
set_success(ctx);
};
runtime.register_hle("sceRtc", 0x34885E0Du, copy_date); // ConvertUtcToLocalTime
runtime.register_hle("sceRtc", 0x779242A2u, copy_date); // ConvertLocalTimeToUTC
runtime.register_hle("sceRtc", 0x9ED0AE87u, // sceRtcCompareTick
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto load = [&](std::uint32_t address) {
return static_cast<std::uint64_t>(rt.memory().load32(address)) |
(static_cast<std::uint64_t>(rt.memory().load32(address + 4u)) << 32u);
};
const std::uint64_t first = load(ctx.gpr[4]);
const std::uint64_t second = load(ctx.gpr[5]);
ctx.set_gpr(2, first < second ? 0xFFFFFFFFu : (first > second ? 1u : 0u));
});
}
// sceIoGetstat. VCS calls it while listing saved games: the load screen
// asks for each entry's type and size before it will show it, and with the
// import missing the runtime stopped on a black screen the moment the load
// menu was opened.
//
// SceIoStat is 0x58 bytes: mode, attr, a 64-bit size, three 16-byte
// ScePspDateTime stamps and six private words. Only mode, attr and size
// are read here; the timestamps are zeroed, which the dialog accepts.
runtime.register_hle("IoFileMgrForUser", 0xACE946E8u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string path = rt.memory().read_c_string(ctx.gpr[4]);
const std::uint32_t stat_address = ctx.gpr[5];
if (stat_address == 0u || !rt.memory().contains(stat_address, 0x58u)) {
ctx.set_gpr(2, 0x80010016u); // EINVAL
return;
}
std::error_code error;
const auto native = rt.translate_path(path);
const bool directory = std::filesystem::is_directory(native, error);
const bool regular = std::filesystem::is_regular_file(native, error);
if (!directory && !regular) {
ctx.set_gpr(2, 0x80010002u); // ENOENT
return;
}
rt.memory().zero(stat_address, 0x58u);
// FIO_S_IFDIR/FIO_S_IFREG with read/write/execute for all three
// classes, which is what a memory stick reports.
rt.memory().store32(stat_address + 0x00u, (directory ? 0x1000u : 0x2000u) | 0x01FFu);
// FIO_SO_IFDIR/FIO_SO_IFREG.
rt.memory().store32(stat_address + 0x04u, directory ? 0x0010u : 0x0020u);
const std::uint64_t size = regular
? static_cast<std::uint64_t>(std::filesystem::file_size(native, error)) : 0u;
rt.memory().store32(stat_address + 0x08u, static_cast<std::uint32_t>(size));
rt.memory().store32(stat_address + 0x0Cu, static_cast<std::uint32_t>(size >> 32u));
// The three ScePspDateTime stamps at 0x10, 0x20 and 0x30. The saved
// game list shows the modification time, so leaving these zero put
// every save in the year zero.
const auto written = std::filesystem::last_write_time(native, error);
const auto system_time = std::chrono::clock_cast<std::chrono::system_clock>(written);
const std::time_t seconds = std::chrono::system_clock::to_time_t(system_time);
std::tm parts{};
#if defined(_WIN32)
localtime_s(&parts, &seconds);
#else
localtime_r(&seconds, &parts);
#endif
for (std::uint32_t stamp : {0x10u, 0x20u, 0x30u}) {
const std::uint32_t base = stat_address + stamp;
rt.memory().store16(base + 0u, static_cast<std::uint16_t>(parts.tm_year + 1900));
rt.memory().store16(base + 2u, static_cast<std::uint16_t>(parts.tm_mon + 1));
rt.memory().store16(base + 4u, static_cast<std::uint16_t>(parts.tm_mday));
rt.memory().store16(base + 6u, static_cast<std::uint16_t>(parts.tm_hour));
rt.memory().store16(base + 8u, static_cast<std::uint16_t>(parts.tm_min));
rt.memory().store16(base + 10u, static_cast<std::uint16_t>(parts.tm_sec));
}
set_success(ctx);
});
runtime.register_hle("IoFileMgrForUser", 0x109F50BCu,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const std::string path = rt.memory().read_c_string(ctx.gpr[4]);
const auto native = rt.translate_path(path);
std::ios::openmode mode = std::ios::binary;
const std::uint32_t flags = ctx.gpr[5];
const bool file_object_diag = std::getenv("PSPRECOMP_FILE_OBJECT_DIAG") != nullptr;
// The PSP accepts pseudo paths such as
// disc0:/sce_lbn0x0_size0x000 for raw UMD ranges. VCS uses the
// zero-length form as a capability probe before loading codec
// modules. It is a valid empty handle and does not require ISO
// contents.
if (path.rfind("disc0:/sce_lbn0x", 0u) == 0u) {
const auto size_marker = path.find("_size0x");
if (size_marker != std::string::npos) {
const std::string lbn_text = path.substr(16u, size_marker - 16u);
const std::string size_text = path.substr(size_marker + 7u);
char *lbn_end = nullptr;
char *size_end = nullptr;
const unsigned long long raw_lbn = std::strtoull(lbn_text.c_str(), &lbn_end, 16);
const unsigned long long raw_size = std::strtoull(size_text.c_str(), &size_end, 16);
const bool parsed = lbn_end != lbn_text.c_str() && *lbn_end == '\0' &&
size_end != size_text.c_str() && *size_end == '\0' && raw_lbn <= 0xFFFFFFFFull;
if (parsed && raw_size == 0u) {
const auto fd = file_table.next_fd++;
file_table.synthetic_empty_files.insert(fd);
ctx.set_gpr(2, static_cast<std::uint32_t>(fd));
return;
}
if (parsed) {
if (const auto *disc_file = find_virtual_disc_file(
static_cast<std::uint32_t>(raw_lbn), raw_size)) {
std::fstream stream(disc_file->native_path, std::ios::binary | std::ios::in);
if (stream) {
const auto fd = file_table.next_fd++;
file_table.files.emplace(fd, std::move(stream));
file_table.file_paths.emplace(fd, disc_file->native_path);
file_table.file_open_flags.emplace(fd, 0x0001u);
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
std::cerr << "[io] raw UMD open lbn=" << raw_lbn
<< " size=" << raw_size
<< " native=\"" << disc_file->native_path.string() << "\"\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(fd));
return;
}
}
const std::uint64_t base_offset = raw_lbn * 2048ull;
const std::uint64_t virtual_disc_size =
static_cast<std::uint64_t>(file_table.next_virtual_sector) * 2048ull;
if (raw_size <= virtual_disc_size && base_offset <= virtual_disc_size - raw_size) {
const auto fd = file_table.next_fd++;
file_table.virtual_disc_handles.emplace(fd, VirtualDiscHandle{base_offset, raw_size, 0u});
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
std::cerr << "[io] virtual UMD range fd=" << fd << " lbn=" << raw_lbn
<< " size=" << raw_size << " disc_size=" << virtual_disc_size << "\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(fd));
return;
}
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
std::cerr << "[io] unresolved raw UMD open lbn=" << raw_lbn
<< " size=" << raw_size << " path=\"" << path << "\"\n";
}
}
}
}
if ((flags & 0x0001u) != 0u) mode |= std::ios::in;
if ((flags & 0x0002u) != 0u) mode |= std::ios::out;
std::fstream stream(native, mode);
if (!stream) {
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
static std::unordered_set<std::string> reported_paths;
if (reported_paths.insert(path).second) {
std::cerr << "[io] sceIoOpen failed psp=\"" << path
<< "\" native=\"" << native.string()
<< "\" flags=" << psprecomp::hex32(flags) << "\n";
}
}
if (file_object_diag) {
std::cerr << "[fileobj-hle] open-fail path=\"" << path
<< "\" native=\"" << native.string()
<< "\" flags=" << psprecomp::hex32(flags) << "\n";
}
ctx.set_gpr(2, 0x80010002u);
return;
}
const auto fd = file_table.next_fd++;
file_table.files.emplace(fd, std::move(stream));
file_table.file_paths.emplace(fd, native);
file_table.file_open_flags.emplace(fd, flags);
if (file_object_diag) {
std::cerr << "[fileobj-hle] open-ok fd=" << fd << " path=\"" << path
<< "\" native=\"" << native.string()
<< "\" flags=" << psprecomp::hex32(flags) << "\n";
}
ctx.set_gpr(2, static_cast<std::uint32_t>(fd));
});
runtime.register_hle("IoFileMgrForUser", 0x27EB27B8u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
const std::uint64_t raw_offset = static_cast<std::uint64_t>(ctx.gpr[6]) |
(static_cast<std::uint64_t>(ctx.gpr[7]) << 32u);
const auto offset = static_cast<std::int64_t>(raw_offset);
const auto whence = static_cast<std::int32_t>(ctx.gpr[8]);
if (auto virtual_handle = file_table.virtual_disc_handles.find(fd);
virtual_handle != file_table.virtual_disc_handles.end()) {
std::int64_t base = 0;
if (whence == 1) base = static_cast<std::int64_t>(virtual_handle->second.position);
else if (whence == 2) base = static_cast<std::int64_t>(virtual_handle->second.length);
else if (whence != 0) {
ctx.set_gpr(2, 0x80010016u);
ctx.set_gpr(3, 0xFFFFFFFFu);
return;
}
const std::int64_t position = base + offset;
if (position < 0 || static_cast<std::uint64_t>(position) > virtual_handle->second.length) {
ctx.set_gpr(2, 0x80010016u);
ctx.set_gpr(3, 0xFFFFFFFFu);
return;
}
virtual_handle->second.position = static_cast<std::uint64_t>(position);
ctx.set_gpr(2, static_cast<std::uint32_t>(position));
ctx.set_gpr(3, static_cast<std::uint32_t>(static_cast<std::uint64_t>(position) >> 32u));
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr)
std::cerr << "[io] sceIoLseek virtual fd=" << fd << " -> " << position << "\n";
return;
}
const auto it = file_table.files.find(fd);
if (it == file_table.files.end() || whence < 0 || whence > 2) {
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
std::cerr << "[io] sceIoLseek rejected fd=" << fd << " offset=" << offset
<< " whence=" << whence << " open=" << (it != file_table.files.end()) << "\n";
}
ctx.set_gpr(2, 0x80010009u);
ctx.set_gpr(3, 0xFFFFFFFFu);
return;
}
std::ios_base::seekdir direction = std::ios::beg;
if (whence == 1) direction = std::ios::cur;
if (whence == 2) direction = std::ios::end;
it->second.clear();
it->second.seekg(static_cast<std::streamoff>(offset), direction);
if (!it->second) {
ctx.set_gpr(2, 0x80010016u);
ctx.set_gpr(3, 0xFFFFFFFFu);
return;
}
const auto position = static_cast<std::int64_t>(it->second.tellg());
if (position < 0) {
ctx.set_gpr(2, 0x80010016u);
ctx.set_gpr(3, 0xFFFFFFFFu);
return;
}
const auto result = static_cast<std::uint64_t>(position);
ctx.set_gpr(2, static_cast<std::uint32_t>(result));
ctx.set_gpr(3, static_cast<std::uint32_t>(result >> 32u));
if (std::getenv("PSPRECOMP_IO_DIAG") != nullptr) {
std::cerr << "[io] sceIoLseek fd=" << fd << " offset=" << offset
<< " whence=" << whence << " -> " << position << "\n";
}
});
runtime.register_hle("IoFileMgrForUser", 0x68963324u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
const auto offset = static_cast<std::int32_t>(ctx.gpr[5]);
const auto whence = static_cast<std::int32_t>(ctx.gpr[6]);
if (auto virtual_handle = file_table.virtual_disc_handles.find(fd);
virtual_handle != file_table.virtual_disc_handles.end()) {
std::int64_t base = 0;
if (whence == 1) base = static_cast<std::int64_t>(virtual_handle->second.position);
else if (whence == 2) base = static_cast<std::int64_t>(virtual_handle->second.length);
else if (whence != 0) { ctx.set_gpr(2, 0x80010016u); return; }
const std::int64_t position = base + offset;
if (position < 0 || static_cast<std::uint64_t>(position) > virtual_handle->second.length ||
position > 0x7FFFFFFFll) {
ctx.set_gpr(2, 0x80010016u);
return;
}
virtual_handle->second.position = static_cast<std::uint64_t>(position);
ctx.set_gpr(2, static_cast<std::uint32_t>(position));
return;
}
const auto it = file_table.files.find(fd);
if (it == file_table.files.end() || whence < 0 || whence > 2) {
ctx.set_gpr(2, 0x80010009u);
return;
}
std::ios_base::seekdir direction = std::ios::beg;
if (whence == 1) direction = std::ios::cur;
if (whence == 2) direction = std::ios::end;
it->second.clear();
it->second.seekg(static_cast<std::streamoff>(offset), direction);
if (!it->second) {
ctx.set_gpr(2, 0x80010016u);
return;
}
const auto position = static_cast<std::int64_t>(it->second.tellg());
if (position < 0 || position > 0x7FFFFFFFll) {
ctx.set_gpr(2, 0x80010016u);
return;
}
ctx.set_gpr(2, static_cast<std::uint32_t>(position));
});
runtime.register_hle("IoFileMgrForUser", 0x810C4BC3u,
[](psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
const bool closed = file_table.files.erase(fd) == 1u ||
file_table.synthetic_empty_files.erase(fd) == 1u ||
file_table.virtual_disc_handles.erase(fd) == 1u;
file_table.file_paths.erase(fd);
file_table.file_open_flags.erase(fd);
if (std::getenv("PSPRECOMP_FILE_OBJECT_DIAG") != nullptr)
std::cerr << "[fileobj-hle] close fd=" << fd << " closed=" << closed << "\n";
ctx.set_gpr(2, closed ? 0u : 0x80010009u);
});
runtime.register_hle("IoFileMgrForUser", 0x6A638D83u,
[](psprecomp::Runtime &rt, psprecomp::AllegrexContext &ctx) {
const auto fd = static_cast<std::int32_t>(ctx.gpr[4]);
const std::uint32_t dst = ctx.gpr[5];
const std::uint32_t size = ctx.gpr[6];
if (auto virtual_handle = file_table.virtual_disc_handles.find(fd);
virtual_handle != file_table.virtual_disc_handles.end()) {
if (!rt.memory().contains(dst, size)) {
ctx.set_gpr(2, 0x80010009u);
return;
}
std::uint32_t stream_request = 0u;
std::uint32_t release_pc_hint = 0u;
if (thread_table.current_uid == 5 &&
rt.memory().contains(ctx.gpr[22] + 6916u, 4u)) {
stream_request = rt.memory().load32(ctx.gpr[22] + 6916u);
release_pc_hint = uncommitted_world_stream_release_pc(rt, stream_request);
}
std::uint8_t *guest_destination = rt.memory().raw_pointer(dst, size);
if (guest_destination == nullptr) {
ctx.set_gpr(2, 0x80010009u);
return;
}
const std::uint64_t read_absolute =
virtual_handle->second.base_offset + virtual_handle->second.position;
const VirtualDiscFile *read_file = virtual_disc_file_at_offset(read_absolute);
file_table.recent_atrac_reads.erase(dst);
const bool time_io = perf_timing_enabled();
const auto io_entry = time_io ? std::chrono::steady_clock::now()
: std::chrono::steady_clock::time_point{};
const std::size_t read = read_virtual_disc(
virtual_handle->second,
std::span<std::uint8_t>(guest_destination, static_cast<std::size_t>(size)));
if (time_io) io_host_time_this_vblank += std::chrono::steady_clock::now() - io_entry;
if (read != 0u && read_file != nullptr && is_atrac_source_path(read_file->native_path)) {
const std::uint64_t file_start =
static_cast<std::uint64_t>(read_file->start_sector) * 2048u;
if (read_absolute >= file_start &&
read_absolute + read <= file_start + read_file->size) {
if (file_table.recent_atrac_reads.size() >= 32u)
file_table.recent_atrac_reads.erase(file_table.recent_atrac_reads.begin());
file_table.recent_atrac_reads[dst] = read_file->native_path;
}
}
static const bool io_diag = std::getenv("PSPRECOMP_IO_DIAG") != nullptr;
static const bool umd_stream_diag = std::getenv("PSPRECOMP_UMD_STREAM_DIAG") != nullptr;
if (io_diag)
std::cerr << "[io] sceIoRead virtual fd=" << fd << " size=" << size << " -> " << read << "\n";
if (umd_stream_diag &&
thread_table.current_uid == 5 &&
rt.memory().contains(ctx.gpr[22] + 6916u, 4u)) {
static std::uint64_t stream_read_count = 0u;
++stream_read_count;
const std::uint32_t request = stream_request;
if (request != 0u && rt.memory().contains(request, 52u)) {
const std::uint32_t remaining = rt.memory().load32(request + 24u);
const std::uint32_t progressed = rt.memory().load32(request + 28u);
const std::uint32_t callback = rt.memory().load32(request + 48u);
if (stream_read_count <= 128u || remaining <= read || callback != 0u ||
stream_read_count % 4096u == 0u) {
std::cerr << "[umdstream] read#" << stream_read_count
<< " req=" << psprecomp::hex32(request)
<< " source=" << psprecomp::hex32(rt.memory().load32(request + 16u))
<< " offset=" << rt.memory().load32(request + 20u)
<< " remaining=" << remaining
<< " progressed=" << progressed
<< " callback=" << psprecomp::hex32(callback)
<< " asked=" << size << " got=" << read
<< " return_pc=" << psprecomp::hex32(ctx.gpr[31])
<< " release_hint=" << psprecomp::hex32(release_pc_hint) << "\n";
}
}
}
// A host-backed UMD read may finish in the same native timeslice in
// which a higher-priority worker was woken. Physical PSP I/O could not:
// the request submitter had time to return and store its request pointer.
// Defer the worker until that exact translated return dispatch completes.
// This is independent of the optional execution-driven virtual clock, so
// PSPRECOMP_TIME_TICK_DISPATCHES=0 cannot strand the worker forever.
if (read != 0u) {
(void)defer_current_thread_for_io_handoff(
rt, ctx, static_cast<std::uint32_t>(read), release_pc_hint);
} else {
ctx.set_gpr(2, 0u);
}
return;
}
if (file_table.synthetic_empty_files.contains(fd)) {
ctx.set_gpr(2, 0u);
return;
}
const auto it = file_table.files.find(fd);
if (it == file_table.files.end() || !rt.memory().contains(dst, size)) {
ctx.set_gpr(2, 0x80010009u);
return;
}
std::uint8_t *guest_destination = rt.memory().raw_pointer(dst, size);
if (guest_destination == nullptr) {
ctx.set_gpr(2, 0x80010009u);
return;
}
file_table.recent_atrac_reads.erase(dst);
const bool time_io = perf_timing_enabled();
const auto io_entry = time_io ? std::chrono::steady_clock::now()
: std::chrono::steady_clock::time_point{};
it->second.read(reinterpret_cast<char *>(guest_destination),
static_cast<std::streamsize>(size));
const auto read = static_cast<std::size_t>(it->second.gcount());
if (time_io) io_host_time_this_vblank += std::chrono::steady_clock::now() - io_entry;
if (read != 0u) {
if (const auto path_it = file_table.file_paths.find(fd);
path_it != file_table.file_paths.end() && is_atrac_source_path(path_it->second)) {
if (file_table.recent_atrac_reads.size() >= 32u)
file_table.recent_atrac_reads.erase(file_table.recent_atrac_reads.begin());
file_table.recent_atrac_reads[dst] = path_it->second;
}
}
ctx.set_gpr(2, static_cast<std::uint32_t>(read));
});
}
namespace {
// Microseconds of guest time credited per outer dispatch. A 333 MHz Allegrex
// retires roughly a few hundred instructions in a microsecond, and one chained
// dispatch covers a comparable amount of translated work, so a quarter of a
// microsecond per dispatch is the right order of magnitude. Only monotonicity
// and rough scale matter: every consumer compares relative deadlines.
std::uint64_t starvation_tick_microseconds = 1u;
void vcs_starvation_tick(psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) {
virtual_time_us += starvation_tick_microseconds;
promote_expired_delays();
const auto current = thread_table.threads.find(thread_table.current_uid);
if (current == thread_table.threads.end() || current->second.state != ThreadState::Running) return;
const auto best = best_ready_thread();
if (best == thread_table.continuations.end()) return;
if (thread_priority(best->uid) >= thread_priority(thread_table.current_uid)) return;
// Resume exactly here. Unlike an HLE-boundary preemption the thread is not
// inside a call, so ctx.pc -- not $ra -- is the continuation point.
enqueue_continuation(thread_table.current_uid, ctx);
(void)activate_next_thread(ctx, "timer-preempt");
}
} // namespace
bool restore_save_repro_checkpoint_if_requested(psprecomp::Runtime &runtime, std::string &error) {
if (!save_repro_testing_enabled()) return false;
const char *value = std::getenv("PSPRECOMP_SAVE_REPRO_AUTO_RESTORE");
if (value == nullptr || *value == '\0' || std::string_view(value) == "0") return false;
return save_repro_restore_checkpoint_impl(runtime, error);
}
void report_disc_read_stats() {
const std::uint64_t total =
disc_read_stats.bytes_from_files + disc_read_stats.bytes_zero_filled;
if (total == 0u) return;
std::cerr << "[disc-read-summary] from_files=" << disc_read_stats.bytes_from_files
<< " zero_filled=" << disc_read_stats.bytes_zero_filled
<< " zero_fill_events=" << disc_read_stats.zero_fill_events
<< " short_reads=" << disc_read_stats.short_reads
<< " open_failures=" << disc_read_stats.open_failures
<< " zero_percent="
<< (disc_read_stats.bytes_zero_filled * 100.0 / static_cast<double>(total)) << "\n";
}
void report_present_stats() {
// Runtime::run() has returned but the Runtime object is still alive here.
// Join the Stage 45.7 GE consumer now so no global worker can retain a
// dangling Runtime pointer during process/static destruction.
const bool async_was_running = ge_async_running();
std::uint64_t async_submitted = 0u;
std::uint64_t async_completed = 0u;
std::uint64_t async_wait_calls = 0u;
std::uint64_t async_wait_us = 0u;
if (async_was_running) {
{
std::lock_guard lock(ge_async.mutex);
async_submitted = ge_async.submitted;
async_completed = ge_async.completed;
async_wait_calls = ge_async.wait_calls;
async_wait_us = static_cast<std::uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(ge_async.wait_time).count());
}
ge_async_stop_worker();
}
const GeGpuBackendReport gpu = ge_gpu_backend_report();
std::cerr << "[present-census] swapchain=" << swapchain_presents
<< " software=" << software_presents
<< " software_after_gpu=" << software_presents_after_gpu
<< " display_fb_sampled_draws=" << gpu.display_framebuffer_sampled_draws << "\n";
if (async_was_running) {
std::cerr << "[ge-async-summary] submitted=" << async_submitted
<< " completed=" << async_completed
<< " wait_calls=" << async_wait_calls
<< " wait_us=" << async_wait_us << "\n";
}
}
void install_starvation_preemption() {
const std::uint64_t interval = parse_environment_u64("PSPRECOMP_TIME_TICK_DISPATCHES", 256u);
execution_clock_dispatch_interval = interval;
frozen_clock_guard_limit = parse_environment_u64(
"PSPRECOMP_FROZEN_CLOCK_GUARD_DISPATCHES", 5'000'000u);
frozen_clock_guard_dispatches = 0u;
frozen_clock_guard_vblank = display_vblank_index;
starvation_tick_microseconds = std::max<std::uint64_t>(1u, interval / 4u);
psprecomp::set_runtime_starvation_hook(interval == 0u ? nullptr : &vcs_starvation_tick, interval);
std::cerr << "[scheduler-clock] dispatch_interval=" << interval
<< " tick_us=" << (interval == 0u ? 0u : starvation_tick_microseconds)
<< " frozen_guard=" << (interval == 0u ? frozen_clock_guard_limit : 0u)
<< "\n";
if (interval == 0u) {
std::cerr << "[scheduler-clock] warning: execution-driven PSP time is disabled; "
"use this only for isolated ordering diagnostics, not a full frontend/world run.\n";
}
}
void install_display_heartbeat() {
if (!display_window_enabled()) return;
psprecomp::set_runtime_heartbeat_hook(
[](std::uint64_t dispatch, std::uint32_t pc) {
std::ostringstream status;
status << "vblank " << display_vblank_index << " | dispatch "
<< (dispatch / 1000000u) << "M | pc " << psprecomp::hex32(pc);
display_window_set_status(status.str().c_str());
},
4'000'000u);
}
bool run_profile_self_tests(std::string &error) {
const auto require = [](bool condition, const char *message) {
if (!condition) throw std::runtime_error(message);
};
const auto reset = [] {
thread_table = ThreadTable{};
pending_guest_callbacks.clear();
async_return_frames.clear();
virtual_time_us = 0u;
psprecomp::set_runtime_thread_identity(-1, "none");
};
try {
{
const RealtimeSpeedSample realtime =
calculate_realtime_speed_sample(1'000'000u, 1'000'000u, 60u);
require(std::abs(realtime.emulation_speed_percent - 100.0) < 0.001,
"real-time speed diagnostic misreported a 1:1 clock");
require(std::abs(realtime.guest_us_per_vblank - 16'666.6666667) < 0.01,
"real-time speed diagnostic miscomputed guest time per vblank");
const RealtimeSpeedSample half_speed =
calculate_realtime_speed_sample(2'000'000u, 1'000'000u, 60u);
require(std::abs(half_speed.emulation_speed_percent - 50.0) < 0.001,
"real-time speed diagnostic did not detect half-speed execution");
require(std::abs(half_speed.host_us_per_vblank - 33'333.3333333) < 0.01,
"real-time speed diagnostic miscomputed host frame time");
}
require(estimate_vcs_deflate_guest_work(2'267'436u, 6'300'880u) == 21'431u,
"VCS deflate timing calibration for the large bootstrap stream changed");
require(estimate_vcs_deflate_guest_work(38'278u, 132'636u) == 330u,
"VCS deflate timing calibration for the small stream changed");
require(estimate_vcs_deflate_guest_work(1u, 1u) >= 1u,
"VCS deflate timing estimator returned zero work");
// Stage 45.7 asynchronous GE smoke test. Run this branch when the test
// process opts into PSPRECOMP_GE_ASYNC=1: enqueue must return before the
// worker consumes the list, and the explicit wait must observe FINISH.
if (ge_async_enabled()) {
ge_async_stop_worker();
ge_list_table = GeListTable{};
ge_callback_table = GeCallbackTable{};
ge_state = GeState{};
reset_ge_transform_state(ge_state.transform);
{
std::lock_guard lock(ge_async.mutex);
ge_async.stop_requested = false;
ge_async.fatal.store(false, std::memory_order_release);
ge_async.fatal_reason.clear();
}
psprecomp::Runtime ge_runtime;
constexpr std::uint32_t list_pc = 0x08810000u;
ge_runtime.memory().store32(list_pc + 0u, (kGeCommandFinish << 24u) | 0x1234u);
ge_runtime.memory().store32(list_pc + 4u, kGeCommandEnd << 24u);
psprecomp::AllegrexContext ge_ctx{};
ge_ctx.gpr[4] = list_pc;
ge_ctx.gpr[5] = 0u;
ge_ctx.gpr[6] = 0xFFFFFFFFu;
ge_ctx.gpr[7] = 0u;
ge_ctx.gpr[31] = 0x08820000u;
enqueue_ge_display_list(ge_runtime, ge_ctx, false);
const std::uint32_t id = ge_ctx.gpr[2];
require(id != 0u && (id & 0xFF000000u) == (kGeListIdMagic & 0xFF000000u),
"async GE enqueue did not return a list id");
require(ge_async_wait_idle(ge_runtime), "async GE worker reported a fatal error");
{
std::lock_guard lock(ge_async.mutex);
const auto found = ge_list_table.lists.find(id);
require(found != ge_list_table.lists.end() &&
found->second.state == GeListState::Completed &&
found->second.callback_token == 0x1234u,
"async GE worker did not complete FINISH/END in order");
}
ge_async_stop_worker();
ge_list_table = GeListTable{};
}
{
std::array<std::uint8_t, 2048> psmf{};
psmf[0] = 'P'; psmf[1] = 'S'; psmf[2] = 'M'; psmf[3] = 'F';
psmf[4] = '0'; psmf[5] = '0'; psmf[6] = '1'; psmf[7] = '4';
psmf[8] = 0; psmf[9] = 0; psmf[10] = 8; psmf[11] = 0;
psmf[12] = 0; psmf[13] = 8; psmf[14] = 40; psmf[15] = 0;
psmf[142] = 30; psmf[143] = 17;
ParsedPsmfHeader parsed{};
require(parse_psmf_header(psmf, parsed), "valid PSMF header was rejected");
require(parsed.stream_offset == 2048u && parsed.stream_size == 534528u,
"PSMF big-endian stream fields were decoded incorrectly");
require(parsed.width == 480u && parsed.height == 272u,
"PSMF dimensions were decoded incorrectly");
}
// A blocking audio submission must be scheduled where the hardware
// would really start playing it: back to back with the previous buffer,
// regardless of how much guest time the caller burned decoding. The
// old "now plus one buffer" pacing let sceAtracDecodeData's 2300 us push
// the stream ~5% ahead of the mix, which the host sink then papered over
// with a timeline resync -- an audible click -- on every submission.
{
const std::uint64_t previous_time = virtual_time_us;
AudioChannelState channel{};
channel.reserved = true;
channel.sample_count = 2048u;
const auto elapsed_us = [](std::uint64_t frames) {
return (frames * 1'000'000ull) / 44'100ull;
};
virtual_time_us = 1'000'000u;
const std::uint64_t first = audio_queue_buffer(channel, channel.sample_count);
require(first == 1'000'000u, "the first audio buffer must start immediately");
// The queue was empty, so the first submission did not block: the
// guest spends 2300 us decoding and submits again well before the
// buffer it just queued has finished playing.
virtual_time_us = first + 2'300u;
const std::uint64_t second = audio_queue_buffer(channel, channel.sample_count);
require(second == first + elapsed_us(2048u),
"audio buffers were not scheduled contiguously");
// From here the guest is paced by the hardware: it wakes when the
// previous buffer drains, decodes, and submits again.
virtual_time_us = second + 2'300u;
const std::uint64_t third = audio_queue_buffer(channel, channel.sample_count);
require(third == first + elapsed_us(4096u),
"guest decode time leaked into the audio timeline");
require(channel.queued_frames == 6144u,
"the audio queue lost track of the submitted frame count");
// A gap larger than the queue really is a drained channel and has to
// re-anchor rather than schedule into the past.
virtual_time_us = channel.busy_until_us + 5'000'000u;
require(audio_queue_buffer(channel, channel.sample_count) == virtual_time_us,
"a drained audio channel did not re-anchor to the current time");
require(channel.queued_frames == 2048u,
"re-anchoring an audio channel did not restart its frame count");
virtual_time_us = previous_time;
}
// sceAudioGetChannelRestLength reports samples in the channel's source
// rate. Returning a 44.1-kHz count for a 22.05/24/32-kHz SRC channel
// makes the guest believe much more audio remains than the DAC will
// actually consume, which eventually stretches low-rate radio/news.
{
const std::uint64_t previous_time = virtual_time_us;
virtual_time_us = 2'000'000u;
AudioChannelState channel{};
channel.reserved = true;
channel.sample_count = 4096u;
channel.frequency = 22050u;
channel.busy_until_us = virtual_time_us + 10'000u;
require(audio_remaining_samples(channel) == 221u,
"audio remaining length ignored the SRC channel frequency");
channel.frequency = 24000u;
require(audio_remaining_samples(channel) == 240u,
"audio remaining length drifted for a 24-kHz channel");
virtual_time_us = previous_time;
}
// Output2 and SRC share hardware but not their success ABI. VCS uses
// Output2; returning 512 instead of 0 escapes from its mixer loop and
// changes guest control flow even though the PCM buffer was accepted.
{
require(audio_resample_success_value(false, 512u) == 0u,
"sceAudioOutput2OutputBlocking success must be zero");
require(audio_resample_success_value(true, 512u) == 512u,
"sceAudioSRCOutputBlocking must report queued samples");
}
// VCS branches directly on the negative remain-frame sentinels after
// sceAtracDecodeData. A fully-fed non-loop halfway stream (NEWS) must
// report -2; a fully-fed looping stream reports -3; only an incomplete
// stream may report a non-negative buffered frame count.
{
AtracContextState stream{};
stream.header.file_size = 16'384u;
stream.header.block_align = 384u;
stream.header.loop_start = -1;
stream.header.loop_end = -1;
stream.buffered_encoded_bytes = 1'152u;
stream.next_file_offset = 8'192u;
require(atrac_remain_frame_status(stream) == 3u,
"partial ATRAC stream did not report buffered frame count");
stream.next_file_offset = stream.header.file_size;
require(atrac_remain_frame_status(stream) == kAtracRemainNonLoopOnMemory,
"fully-fed non-loop halfway stream must report -2");
stream.header.loop_start = 1024;
stream.header.loop_end = 8191;
stream.loop_num = -1;
require(atrac_remain_frame_status(stream) == kAtracRemainLoopOnMemory,
"fully-fed looping halfway stream must report -3");
stream.loop_num = 0;
require(atrac_remain_frame_status(stream) == kAtracRemainNonLoopOnMemory,
"disabled ATRAC loop must report the non-loop resident status");
}
// A voice configured through __sceSasSetADSR alone -- rates only, no
// call to __sceSasSetADSRmode -- must still retire when the game keys
// it off. VCS does exactly this for the vehicle engine, and the old
// all-zero mode defaults made "release" walk the envelope upward, so
// the engine kept sounding under the pause menu.
{
SasVoiceState voice{};
require(voice.adsr_modes[0] == 0, "default attack curve must rise");
require(voice.adsr_modes[1] == 1 && voice.adsr_modes[2] == 1 &&
voice.adsr_modes[3] == 1,
"default decay/sustain/release curves must fall");
voice.type = SasVoiceType::Vag;
voice.adsr_configured = true;
voice.playing = true;
voice.on = false;
voice.envelope_height = kSasEnvelopeMaximum;
voice.envelope_phase = SasEnvelopePhase::Release;
voice.adsr_rates[3] = 0x10000000; // the rate VCS actually sets
std::uint32_t steps = 0u;
while (voice.playing && steps < 64u) {
sas_step_envelope(voice);
++steps;
}
require(!voice.playing, "a keyed-off voice never released its envelope");
require(voice.envelope_height == 0u,
"a released voice was retired with a non-zero envelope");
}
// A zero decoded release rate is not allowed to make a looped VAG
// immortal after KeyOff. Vehicle engine and horn voices are exactly the
// kind of long/looping effects where this turns into an obvious stuck
// sound, so use the short de-click fallback release in that case.
{
SasVoiceState voice{};
voice.type = SasVoiceType::Vag;
voice.adsr_configured = true;
voice.loop = true;
voice.playing = true;
voice.on = false;
voice.envelope_height = kSasEnvelopeMaximum;
voice.envelope_phase = SasEnvelopePhase::Release;
voice.adsr_rates[3] = 0;
for (std::uint32_t step = 0u; step < 64u && voice.playing; ++step)
sas_step_envelope(voice);
require(!voice.playing && voice.envelope_height == 0u,
"zero-rate KeyOff left a looping SAS voice alive forever");
}
// End flags are a post-Core snapshot. Setters may change a voice in the
// middle of a grain, but GetEndFlag must not expose that transition until
// the next completed mixer cycle refreshes the hardware-visible flags.
{
const SasState previous = sas_state;
sas_state = SasState{};
auto &voice = sas_state.voices[0];
voice.type = SasVoiceType::Vag;
voice.playing = true;
require((sas_state.end_flags & 1u) != 0u,
"SAS end flag changed before a Core refresh");
sas_refresh_end_flags();
require((sas_state.end_flags & 1u) == 0u,
"SAS Core refresh did not clear the playing voice end flag");
voice.playing = false;
require((sas_state.end_flags & 1u) == 0u,
"SAS end flag was not latched between Core cycles");
sas_refresh_end_flags();
require((sas_state.end_flags & 1u) != 0u,
"SAS Core refresh did not publish the ended voice");
sas_state = previous;
}
// A VAG loop jump must restore the predictor state captured immediately
// before the loop-start block. Keeping the history from the loop-end block
// changes the waveform on every pass and can make a vehicle loop drift.
{
psprecomp::Runtime loop_runtime;
constexpr std::uint32_t vag = 0x08850000u;
std::array<std::uint8_t, 32> blocks{};
blocks[0] = 0u; blocks[1] = 6u; // loop start
blocks[16] = 0u; blocks[17] = 3u; // loop end
loop_runtime.memory().copy_in(vag, blocks);
SasVoiceState voice{};
voice.type = SasVoiceType::Vag;
voice.data_address = vag;
voice.data_size = 32;
voice.loop = true;
voice.history1 = 123;
voice.history2 = -45;
require(sas_decode_next_block(loop_runtime.memory(), voice),
"VAG loop-start block failed to decode");
require(voice.loop_start_valid && voice.loop_start_history1 == 123 &&
voice.loop_start_history2 == -45,
"VAG loop-start predictor state was not captured");
require(sas_decode_next_block(loop_runtime.memory(), voice),
"VAG loop-end block failed to decode");
require(voice.decode_offset == 0u && voice.history1 == 123 && voice.history2 == -45,
"VAG loop jump did not restore predictor history");
}
// Transactional savedata writes must never destroy the existing main file
// if a later auxiliary file cannot be staged.
{
const std::filesystem::path root = std::filesystem::temp_directory_path() /
("vcsnative_savedata_tx_" + std::to_string(static_cast<unsigned long long>(
std::chrono::steady_clock::now().time_since_epoch().count())));
const std::filesystem::path existing = root / "DATA.BIN";
const std::filesystem::path blocker = root / "blocker";
std::error_code error;
std::filesystem::create_directories(root, error);
require(!error, "could not create savedata transaction fixture");
{ std::ofstream out(existing, std::ios::binary); out.write("OLD", 3); }
{ std::ofstream out(blocker, std::ios::binary); out.write("X", 1); }
std::vector<SavedataPendingWrite> writes;
writes.push_back(SavedataPendingWrite{existing, {'N','E','W'}});
writes.push_back(SavedataPendingWrite{blocker / "AUX.DAT", {'B','A','D'}});
require(!commit_savedata_writes(writes),
"savedata transaction accepted an impossible auxiliary target");
std::ifstream in(existing, std::ios::binary);
std::string old((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
require(old == "OLD", "failed savedata staging destroyed the previous slot");
std::filesystem::remove_all(root, error);
}
// A GE context supplied to sceGeListEnQueue is a real serialized PSP
// context, not merely a command-memory snapshot. It must include matrix
// DATA words and the global renderer state must be restored after END.
{
const GeState previous_ge_state = ge_state;
psprecomp::Runtime ge_runtime;
constexpr std::uint32_t context_address = 0x08830000u;
ge_state = GeState{};
++ge_draw_state_revision;
++ge_lighting_state_revision;
reset_ge_transform_state(ge_state.transform);
ge_state.commands[0x42u] = ge_float24_command(0x42u, 240.0f);
ge_state.commands[0x43u] = ge_float24_command(0x43u, 136.0f);
ge_state.offset_address = 0x00123000u;
ge_state.vertex_address = 0x08901000u;
ge_state.index_address = 0x08902000u;
ge_state.bounding_box_result = true;
ge_state.transform.world[9] = 123.5f;
ge_state.transform.view[10] = -7.25f;
ge_state.transform.projection[15] = 0.5f;
ge_state.transform.bones[95] = 3.75f;
ge_state.transform.bone_cursor = 101u;
ge_state.transform.world_cursor = 14u;
GeListRecord record{};
record.context_address = context_address;
save_ge_list_context(ge_runtime, record);
require(record.has_saved_context, "GE context was not captured for a list");
require(ge_runtime.memory().load32(context_address + 5u * 4u) == 0x08901000u &&
ge_runtime.memory().load32(context_address + 6u * 4u) == 0x08902000u &&
ge_runtime.memory().load32(context_address + 7u * 4u) == 0x00123000u,
"GE context header did not serialize stream addresses");
bool found_world_translation = false;
for (std::uint32_t word = 17u; word + 1u < 512u; ++word) {
const std::uint32_t value = ge_runtime.memory().load32(context_address + word * 4u);
const std::uint32_t next = ge_runtime.memory().load32(context_address + (word + 1u) * 4u);
if ((value >> 24u) == 0x3Au && (next >> 24u) == 0x3Bu &&
(next & 0x00FFFFFFu) == (ge_float24_command(0x3Bu, ge_state.transform.world[0]) & 0x00FFFFFFu)) {
for (std::uint32_t i = 1u; i < 12u; ++i) {
const std::uint32_t data = ge_runtime.memory().load32(context_address + (word + 1u + i) * 4u);
if (i == 9u && (data & 0x00FFFFFFu) ==
(ge_float24_command(0x3Bu, 123.5f) & 0x00FFFFFFu))
found_world_translation = true;
}
break;
}
}
require(found_world_translation, "GE context omitted expanded world matrix DATA commands");
ge_state = GeState{};
++ge_draw_state_revision;
++ge_lighting_state_revision;
reset_ge_transform_state(ge_state.transform);
restore_ge_list_context(record);
require(ge_state.commands[0x42u] == ge_float24_command(0x42u, 240.0f) &&
ge_state.offset_address == 0x00123000u &&
ge_state.vertex_address == 0x08901000u && ge_state.index_address == 0x08902000u &&
ge_state.bounding_box_result && ge_state.transform.world[9] == 123.5f &&
ge_state.transform.view[10] == -7.25f && ge_state.transform.projection[15] == 0.5f &&
ge_state.transform.bones[95] == 3.75f && ge_state.transform.bone_cursor == 101u &&
ge_state.transform.world_cursor == 14u,
"GE list completion did not restore the complete saved context");
ge_state = previous_ge_state;
}
// Multiple delayed threads can expire on one virtual-time jump. Their
// order must be independent of unordered_map bucket layout.
reset();
for (const auto [uid, sequence, pc] : std::array<std::tuple<std::int32_t, std::uint64_t, std::uint32_t>, 3>{
std::tuple{30, 3u, 0x3000u}, std::tuple{10, 1u, 0x1000u}, std::tuple{20, 2u, 0x2000u}}) {
ThreadRecord record{};
record.name = "delay-" + std::to_string(uid);
record.priority = 32u;
record.state = ThreadState::Delayed;
record.delay_until_us = 100u;
record.delay_sequence = sequence;
record.suspended_context.pc = pc;
thread_table.threads.emplace(uid, std::move(record));
}
virtual_time_us = 100u;
promote_expired_delays();
require(thread_table.continuations.size() == 3u, "expired delays were not promoted");
require(thread_table.continuations[0].uid == 10 && thread_table.continuations[1].uid == 20 &&
thread_table.continuations[2].uid == 30,
"expired delay order is not deterministic FIFO");
// Higher PSP priority wins; equal priorities retain ready-queue FIFO.
reset();
for (const auto [uid, priority] : std::array<std::pair<std::int32_t, std::uint32_t>, 3>{
std::pair{1, 40u}, std::pair{2, 20u}, std::pair{3, 20u}}) {
ThreadRecord record{};
record.name = "ready-" + std::to_string(uid);
record.priority = priority;
record.state = ThreadState::Ready;
thread_table.threads.emplace(uid, std::move(record));
psprecomp::AllegrexContext context{};
context.pc = 0x8000u + static_cast<std::uint32_t>(uid) * 4u;
enqueue_continuation(uid, context);
}
psprecomp::AllegrexContext selected{};
require(activate_next_thread(selected, "self-test"), "ready queue did not select a thread");
require(thread_table.current_uid == 2, "higher-priority ready thread was not selected");
require(activate_next_thread(selected, "self-test"), "ready queue lost equal-priority peer");
require(thread_table.current_uid == 3, "equal-priority FIFO order was not preserved");
// Waking a higher-priority thread is an immediate kernel scheduling
// point. The caller must remain ready with its post-HLE return state.
reset();
ThreadRecord low{};
low.name = "low";
low.priority = 40u;
low.state = ThreadState::Running;
thread_table.threads.emplace(1, std::move(low));
ThreadRecord high{};
high.name = "high";
high.priority = 16u;
high.state = ThreadState::Sleeping;
high.suspended_context.pc = 0x08809000u;
thread_table.threads.emplace(2, std::move(high));
thread_table.current_uid = 1;
psprecomp::AllegrexContext wake_context{};
wake_context.pc = 0x08B70000u;
wake_context.set_gpr(2, 0u);
wake_context.set_gpr(31, 0x08808000u);
require(wake_thread(2) == 0u, "higher-priority sleeping thread did not wake");
require(preempt_if_higher_priority(wake_context, "self-test-wakeup"),
"higher-priority wakeup did not preempt the caller");
require(thread_table.current_uid == 2 && wake_context.pc == 0x08809000u,
"woken higher-priority thread did not receive the CPU");
const auto saved_caller = std::find_if(
thread_table.continuations.begin(), thread_table.continuations.end(),
[](const ThreadContinuation &item) { return item.uid == 1; });
require(saved_caller != thread_table.continuations.end() && saved_caller->context.pc == 0x08808000u &&
saved_caller->context.gpr[2] == 0u,
"preempted caller did not preserve its post-HLE return context");
// Host-backed UMD reads complete much faster than a physical PSP drive.
// The worker must remain blocked until the request submitter completes
// its translated return dispatch and stores the active request pointer.
// This barrier must work even when execution-driven virtual time is off.
{
psprecomp::Runtime io_runtime;
install_profile(io_runtime, 0x08E00000u);
thread_table = ThreadTable{};
deferred_io_resumes.clear();
pending_guest_callbacks.clear();
async_return_frames.clear();
virtual_time_us = 0u;
constexpr std::int32_t submitter_uid = 3;
constexpr std::int32_t worker_uid = 5;
constexpr std::int32_t fd = 42;
constexpr std::uint32_t destination = 0x08824000u;
constexpr std::uint32_t byte_count = 16u;
constexpr std::uint32_t submitter_pc = 0x08955E7Cu;
constexpr std::uint32_t submission_commit_pc = 0x08955EA4u;
constexpr std::uint32_t submitter_next_pc = 0x08955E90u;
constexpr std::uint32_t worker_return_pc = 0x08826000u;
constexpr std::uint32_t active_slot = 0x08827000u;
constexpr std::uint32_t event_pattern_slot = 0x08827004u;
constexpr std::uint32_t request_pointer = 0x08828000u;
ThreadRecord submitter{};
submitter.name = "threadmain";
submitter.priority = 56u;
submitter.state = ThreadState::Ready;
thread_table.threads.emplace(submitter_uid, std::move(submitter));
psprecomp::AllegrexContext submitter_context{};
submitter_context.pc = submitter_pc;
enqueue_continuation(submitter_uid, submitter_context);
ThreadRecord worker{};
worker.name = "UmdStreamThread";
worker.priority = 32u;
worker.state = ThreadState::Running;
thread_table.threads.emplace(worker_uid, std::move(worker));
thread_table.current_uid = worker_uid;
psprecomp::set_runtime_thread_identity(worker_uid, "UmdStreamThread");
// A virtual-disc gap is defined as zero-filled readable media, so
// this exercises the exact HLE path without a temporary host file.
file_table.virtual_disc_handles.emplace(fd, VirtualDiscHandle{0u, byte_count, 0u});
for (std::uint32_t offset = 0u; offset < byte_count; offset += 4u)
io_runtime.memory().store32(destination + offset, 0xA5A5A5A5u);
io_runtime.memory().store32(active_slot, 0u);
io_runtime.memory().store32(event_pattern_slot, 0x1u);
psprecomp::AllegrexContext read_context{};
read_context.set_gpr(4u, static_cast<std::uint32_t>(fd));
read_context.set_gpr(5u, destination);
read_context.set_gpr(6u, byte_count);
read_context.set_gpr(31u, worker_return_pc);
io_runtime.invoke_import("IoFileMgrForUser", 0x6A638D83u, read_context);
require(!io_runtime.stopped(), "virtual UMD read stopped the runtime");
require(thread_table.current_uid == submitter_uid && read_context.pc == submitter_pc,
"virtual UMD read did not hand execution back to the request submitter");
const auto deferred_worker = thread_table.threads.find(worker_uid);
require(deferred_worker != thread_table.threads.end() &&
deferred_worker->second.state == ThreadState::IoDeferred &&
deferred_worker->second.suspended_context.pc == worker_return_pc &&
deferred_worker->second.suspended_context.gpr[2] == byte_count,
"virtual UMD read did not preserve the worker return state behind the dispatch barrier");
require(deferred_io_resumes.contains(worker_uid) &&
deferred_io_resumes.at(worker_uid).handoff_uid == submitter_uid &&
deferred_io_resumes.at(worker_uid).handoff_pc == submitter_pc &&
deferred_io_resumes.at(worker_uid).release_pc == submission_commit_pc,
"virtual UMD read armed the barrier for the wrong atomic submission boundary");
require(io_handoff_release_pc(0x08956258u) == 0x08956280u,
"batched world-stream submission did not map to its atomic commit boundary");
require(io_handoff_release_pc(0x08801234u) == 0x08801234u,
"non-VCS I/O handoff unexpectedly changed its release PC");
for (std::uint32_t offset = 0u; offset < byte_count; offset += 4u)
require(io_runtime.memory().load32(destination + offset) == 0u,
"virtual UMD gap read did not copy deterministic zero data");
// Returning from the read import and unrelated dispatches must not
// release the worker before the active request store executes.
vcs_post_dispatch_hook(io_runtime, read_context, 0x08B70000u, worker_uid);
vcs_post_dispatch_hook(io_runtime, read_context, submitter_pc + 4u, submitter_uid);
require(thread_table.current_uid == submitter_uid &&
thread_table.threads.at(worker_uid).state == ThreadState::IoDeferred &&
io_runtime.memory().load32(active_slot) == 0u,
"UMD worker escaped its barrier before the submitter return dispatch");
// Model the translated 0x08955E7C unit. Storing manager+0x274 is
// not yet a safe release point: VCS still clears WorldStreamEventFlag
// and would erase a completion bit published by the fast host worker.
io_runtime.memory().store32(active_slot, request_pointer);
read_context.pc = submitter_next_pc;
vcs_post_dispatch_hook(io_runtime, read_context, submitter_pc, submitter_uid);
require(deferred_io_resumes.contains(worker_uid) &&
thread_table.current_uid == submitter_uid &&
thread_table.threads.at(worker_uid).state == ThreadState::IoDeferred,
"UMD worker resumed before the world-stream event reset/arm sequence");
// Model 0x08955E8C..0x08955EA4: clear stale completion state and
// publish submission bit 0x4. Only after the translated 0x08955EA4
// dispatch has completed may the higher-priority worker run.
io_runtime.memory().store32(event_pattern_slot, 0u);
io_runtime.memory().store32(event_pattern_slot,
io_runtime.memory().load32(event_pattern_slot) | 0x4u);
vcs_post_dispatch_hook(io_runtime, read_context, submission_commit_pc, submitter_uid);
require(virtual_time_us == 0u,
"UMD dispatch barrier unexpectedly depended on virtual-time advancement");
require(!deferred_io_resumes.contains(worker_uid),
"completed UMD atomic-submission barrier was not removed");
require(thread_table.current_uid == worker_uid && read_context.pc == worker_return_pc &&
read_context.gpr[2] == byte_count,
"UMD worker did not resume with its preserved read result after submission commit");
require(io_runtime.memory().load32(active_slot) == request_pointer,
"UMD worker resumed before the submitter's active request store was visible");
require(io_runtime.memory().load32(event_pattern_slot) == 0x4u,
"UMD worker resumed before WorldStreamEventFlag submission bit was published");
io_runtime.memory().store32(event_pattern_slot,
io_runtime.memory().load32(event_pattern_slot) | 0x1u);
require(io_runtime.memory().load32(event_pattern_slot) == 0x5u,
"worker completion bit was lost after the atomic submission boundary");
file_table = FileTable{};
}
// A tiny first read can complete before the world-stream allocator has
// unwound to the request-pointer store. Reproduce the 272-byte Stage 9
// race where threadmain is still inside the allocator semaphore unlock
// at 0x08939C4C. The request callback, rather than that intermediate PC,
// must hold the worker through the batched submission commit.
{
psprecomp::Runtime io_runtime;
install_profile(io_runtime, 0x08E00000u);
thread_table = ThreadTable{};
deferred_io_resumes.clear();
pending_guest_callbacks.clear();
async_return_frames.clear();
virtual_time_us = 0u;
constexpr std::int32_t submitter_uid = 3;
constexpr std::int32_t worker_uid = 5;
constexpr std::int32_t fd = 43;
constexpr std::uint32_t destination = 0x0882A000u;
constexpr std::uint32_t byte_count = 272u;
constexpr std::uint32_t allocator_unlock_pc = 0x08939C4Cu;
constexpr std::uint32_t batch_commit_pc = 0x08956280u;
constexpr std::uint32_t worker_return_pc = 0x0893A48Cu;
constexpr std::uint32_t umd_manager = 0x08E8F000u;
constexpr std::uint32_t request_pointer = 0x08E90C68u;
constexpr std::uint32_t world_stream_manager = 0x08E91200u;
constexpr std::uint32_t active_slot = world_stream_manager + 628u;
constexpr std::uint32_t event_pattern_slot = 0x0882B000u;
ThreadRecord submitter{};
submitter.name = "threadmain";
submitter.priority = 56u;
submitter.state = ThreadState::Ready;
thread_table.threads.emplace(submitter_uid, std::move(submitter));
psprecomp::AllegrexContext submitter_context{};
submitter_context.pc = allocator_unlock_pc;
enqueue_continuation(submitter_uid, submitter_context);
ThreadRecord worker{};
worker.name = "UmdStreamThread";
worker.priority = 32u;
worker.state = ThreadState::Running;
thread_table.threads.emplace(worker_uid, std::move(worker));
thread_table.current_uid = worker_uid;
psprecomp::set_runtime_thread_identity(worker_uid, "UmdStreamThread");
file_table.virtual_disc_handles.emplace(fd, VirtualDiscHandle{0u, byte_count, 0u});
io_runtime.memory().store32(umd_manager + 6916u, request_pointer);
io_runtime.memory().store32(request_pointer + 16u, 0x08E8F708u);
io_runtime.memory().store32(request_pointer + 20u, 997376u);
io_runtime.memory().store32(request_pointer + 24u, byte_count);
io_runtime.memory().store32(request_pointer + 28u, 0u);
io_runtime.memory().store32(request_pointer + 48u, 0x089539CCu);
io_runtime.memory().store32(active_slot, 0u);
io_runtime.memory().store32(event_pattern_slot, 0x1u);
require(uncommitted_world_stream_release_pc(io_runtime, request_pointer) == batch_commit_pc,
"tiny batched request did not select the final world-stream commit");
io_runtime.memory().store32(request_pointer + 48u, 0x08953990u);
require(uncommitted_world_stream_release_pc(io_runtime, request_pointer) == 0x08955EA4u,
"tiny single request did not select the final world-stream commit");
io_runtime.memory().store32(request_pointer + 48u, 0x089539CCu);
io_runtime.memory().store32(active_slot, request_pointer);
require(uncommitted_world_stream_release_pc(io_runtime, request_pointer) == 0u,
"already-published world-stream request was treated as uncommitted");
io_runtime.memory().store32(active_slot, 0u);
psprecomp::AllegrexContext read_context{};
read_context.set_gpr(4u, static_cast<std::uint32_t>(fd));
read_context.set_gpr(5u, destination);
read_context.set_gpr(6u, byte_count);
read_context.set_gpr(22u, umd_manager);
read_context.set_gpr(31u, worker_return_pc);
io_runtime.invoke_import("IoFileMgrForUser", 0x6A638D83u, read_context);
require(!io_runtime.stopped(), "tiny virtual UMD read stopped the runtime");
require(thread_table.current_uid == submitter_uid && read_context.pc == allocator_unlock_pc,
"tiny UMD read did not restore the allocator-side submitter");
require(deferred_io_resumes.contains(worker_uid) &&
deferred_io_resumes.at(worker_uid).handoff_pc == allocator_unlock_pc &&
deferred_io_resumes.at(worker_uid).release_pc == batch_commit_pc,
"tiny UMD read used the allocator unlock as its release boundary");
// Neither the allocator unlock nor the eventual request-pointer store
// is sufficient; the completion bit would still be erased by the
// following event clear.
vcs_post_dispatch_hook(io_runtime, read_context, allocator_unlock_pc, submitter_uid);
io_runtime.memory().store32(active_slot, request_pointer);
vcs_post_dispatch_hook(io_runtime, read_context, 0x08956258u, submitter_uid);
require(deferred_io_resumes.contains(worker_uid) &&
thread_table.threads.at(worker_uid).state == ThreadState::IoDeferred,
"tiny UMD worker resumed before the batched event transaction committed");
io_runtime.memory().store32(event_pattern_slot, 0u);
io_runtime.memory().store32(event_pattern_slot, 0x4u);
vcs_post_dispatch_hook(io_runtime, read_context, batch_commit_pc, submitter_uid);
require(!deferred_io_resumes.contains(worker_uid) &&
thread_table.current_uid == worker_uid &&
read_context.pc == worker_return_pc &&
read_context.gpr[2] == byte_count,
"tiny UMD worker did not resume at the final batched commit");
io_runtime.memory().store32(event_pattern_slot,
io_runtime.memory().load32(event_pattern_slot) | 0x1u);
require(io_runtime.memory().load32(event_pattern_slot) == 0x5u,
"tiny request completion bit was lost after the final commit");
file_table = FileTable{};
}
// A callback chain must restore the complete original Allegrex state
// before every callback and after the final callback.
reset();
ThreadRecord callback_thread{};
callback_thread.name = "callback-test";
callback_thread.priority = 32u;
callback_thread.state = ThreadState::Running;
thread_table.threads.emplace(7, std::move(callback_thread));
thread_table.current_uid = 7;
psprecomp::AllegrexContext original{};
for (std::uint32_t i = 1u; i < original.gpr.size(); ++i) original.gpr[i] = 0x10000000u + i;
original.hi = 0xA1A2A3A4u;
original.lo = 0xB1B2B3B4u;
original.pc = 0x08801234u;
for (std::size_t i = 0u; i < original.fpr.size(); ++i) original.fpr[i] = static_cast<float>(i) + 0.25f;
original.fcr31 = 0x01020304u;
for (std::size_t i = 0u; i < original.vfpu.size(); ++i) original.vfpu[i] = static_cast<float>(i) - 3.5f;
for (std::size_t i = 0u; i < original.vfpu_ctrl.size(); ++i)
original.vfpu_ctrl[i] = 0x20000000u + static_cast<std::uint32_t>(i);
pending_guest_callbacks[7] = {
GuestCallbackInvocation{0x08810000u, 1u, 2u, 3u},
GuestCallbackInvocation{0x08820000u, 4u, 5u, 6u},
};
psprecomp::AllegrexContext callback = original;
require(maybe_start_pending_guest_callback(callback), "first callback did not start");
require(callback.pc == 0x08810000u && callback.gpr[4] == 1u && callback.gpr[5] == 2u && callback.gpr[6] == 3u,
"first callback arguments are incorrect");
callback.gpr.fill(0xDEADBEEFu);
callback.hi = callback.lo = 0xDEADBEEFu;
callback.pc = 4u;
callback.fpr.fill(-99.0f);
callback.fcr31 = 0xFFFFFFFFu;
callback.vfpu.fill(-88.0f);
callback.vfpu_ctrl.fill(0xFFFFFFFFu);
psprecomp::Runtime runtime;
vcs_interrupt_return(runtime, callback);
require(callback.pc == 0x08820000u && callback.gpr[4] == 4u && callback.gpr[5] == 5u && callback.gpr[6] == 6u,
"second callback did not start from the restored frame");
require(callback.gpr[16] == original.gpr[16] && callback.gpr[29] == original.gpr[29] &&
callback.hi == original.hi && callback.lo == original.lo && callback.fpr[7] == original.fpr[7] &&
callback.fcr31 == original.fcr31 && callback.vfpu[60] == original.vfpu[60] &&
callback.vfpu_ctrl[9] == original.vfpu_ctrl[9],
"callback leaked guest CPU/FPU/VFPU state into the next callback");
callback.gpr.fill(0xCAFEBABEu);
callback.fpr.fill(-77.0f);
callback.vfpu.fill(-66.0f);
callback.vfpu_ctrl.fill(0xEEEEEEEEu);
callback.pc = 4u;
vcs_interrupt_return(runtime, callback);
require(callback.gpr == original.gpr && callback.hi == original.hi && callback.lo == original.lo &&
callback.pc == original.pc && callback.fpr == original.fpr && callback.fcr31 == original.fcr31 &&
callback.vfpu == original.vfpu && callback.vfpu_ctrl == original.vfpu_ctrl,
"final callback return did not restore the complete guest context");
require(async_return_frames.empty() && pending_guest_callbacks.empty(),
"callback bookkeeping remained after the callback chain ended");
// VCS variadic ABI consumes a2/a3 and t0-t3 before the caller stack.
{
psprecomp::Runtime sprintf_runtime;
psprecomp::AllegrexContext sprintf_context{};
constexpr std::uint32_t destination = 0x08810000u;
constexpr std::uint32_t format_address = 0x08810100u;
constexpr std::uint32_t strings_address = 0x08810200u;
constexpr std::uint32_t stack_address = 0x08811000u;
const std::string format = "%s%s%s%s%s%s%s";
std::vector<std::uint8_t> format_bytes(format.begin(), format.end());
format_bytes.push_back(0u);
sprintf_runtime.memory().copy_in(format_address, format_bytes);
for (std::uint32_t i = 0u; i < 7u; ++i) {
const std::array<std::uint8_t, 2> text{static_cast<std::uint8_t>('A' + i), 0u};
sprintf_runtime.memory().copy_in(strings_address + i * 4u, text);
}
sprintf_context.set_gpr(4u, destination);
sprintf_context.set_gpr(5u, format_address);
for (std::uint32_t i = 0u; i < 6u; ++i)
sprintf_context.set_gpr(6u + i, strings_address + i * 4u);
sprintf_context.set_gpr(29u, stack_address);
sprintf_context.set_gpr(31u, 0x08812000u);
// Spilled words sit at sp+0. These two self-tests used to place
// them at sp+16, which is where an ordinary o32 caller would, but
// that was written to match the reader rather than the game: the
// save-description call stores its spilled words at 0(sp), 4(sp)
// and 8(sp). See O32VarArgs::next_u32().
sprintf_runtime.memory().store32(stack_address, strings_address + 24u);
vcs_sprintf(sprintf_runtime, sprintf_context);
require(!sprintf_runtime.stopped(), "VCS sprintf variadic ABI self-test stopped runtime");
require(sprintf_runtime.memory().read_c_string(destination, 32u) == "ABCDEFG",
"VCS sprintf did not consume a2/a3/t0-t3 before stack arguments");
require(sprintf_context.gpr[2] == 7u && sprintf_context.pc == 0x08812000u,
"VCS sprintf return state is incorrect");
}
{
psprecomp::Runtime sprintf_runtime;
psprecomp::AllegrexContext sprintf_context{};
constexpr std::uint32_t destination = 0x08812000u;
constexpr std::uint32_t format_address = 0x08812100u;
constexpr std::uint32_t first_string = 0x08812200u;
constexpr std::uint32_t last_string = 0x08812210u;
constexpr std::uint32_t stack_address = 0x08813000u;
const std::string format = "%s %.2f %.1e %.3g %s";
std::vector<std::uint8_t> format_bytes(format.begin(), format.end());
format_bytes.push_back(0u);
sprintf_runtime.memory().copy_in(format_address, format_bytes);
const std::array<std::uint8_t, 2> first_text{'X', 0u};
const std::array<std::uint8_t, 2> last_text{'Y', 0u};
sprintf_runtime.memory().copy_in(first_string, first_text);
sprintf_runtime.memory().copy_in(last_string, last_text);
const auto set_pair = [&](std::uint32_t low_reg, double value) {
const std::uint64_t bits = std::bit_cast<std::uint64_t>(value);
sprintf_context.set_gpr(low_reg, static_cast<std::uint32_t>(bits));
sprintf_context.set_gpr(low_reg + 1u, static_cast<std::uint32_t>(bits >> 32u));
};
sprintf_context.set_gpr(4u, destination);
sprintf_context.set_gpr(5u, format_address);
sprintf_context.set_gpr(6u, first_string);
sprintf_context.set_gpr(7u, 0xDEADBEEFu); // skipped for 64-bit alignment
set_pair(8u, 1.25);
set_pair(10u, 2.5);
sprintf_context.set_gpr(29u, stack_address);
sprintf_context.set_gpr(31u, 0x08814000u);
const std::uint64_t third_bits = std::bit_cast<std::uint64_t>(3.75);
sprintf_runtime.memory().store32(stack_address, static_cast<std::uint32_t>(third_bits));
sprintf_runtime.memory().store32(stack_address + 4u, static_cast<std::uint32_t>(third_bits >> 32u));
sprintf_runtime.memory().store32(stack_address + 8u, last_string);
vcs_sprintf(sprintf_runtime, sprintf_context);
require(!sprintf_runtime.stopped(), "VCS sprintf floating ABI self-test stopped runtime");
require(sprintf_runtime.memory().read_c_string(destination, 128u) == "X 1.25 2.5e+00 3.75 Y",
"VCS sprintf floating conversions or 64-bit alignment are incorrect");
}
{
psprecomp::Runtime wlan_runtime;
install_profile(wlan_runtime, 0x08E8AC00u);
psprecomp::AllegrexContext wlan_context{};
wlan_context.set_gpr(2u, 0xFFFFFFFFu);
wlan_runtime.invoke_import("sceWlanDrv", 0xD7763699u, wlan_context);
require(!wlan_runtime.stopped(), "sceWlanGetSwitchState is not registered");
require(wlan_context.gpr[2] == 0u,
"offline native profile did not report the WLAN switch as off");
psprecomp::AllegrexContext profiler_context{};
profiler_context.set_gpr(2u, 0xFFFFFFFFu);
wlan_runtime.invoke_import("ThreadManForUser", 0x64D4540Eu, profiler_context);
require(!wlan_runtime.stopped() && profiler_context.gpr[2] == 0u,
"sceKernelReferThreadProfiler did not return a null profiler block");
profiler_context.set_gpr(2u, 0xFFFFFFFFu);
wlan_runtime.invoke_import("ThreadManForUser", 0x8218B4DDu, profiler_context);
require(!wlan_runtime.stopped() && profiler_context.gpr[2] == 0u,
"sceKernelReferGlobalProfiler did not return a null profiler block");
// Standard streamed RIFF/ATRAC3+ initialization and metadata flow.
constexpr std::uint32_t atrac_buffer = 0x08818000u;
constexpr std::uint32_t atrac_outputs = 0x08819000u;
std::vector<std::uint8_t> atrac_header(0x100u, 0u);
const auto put16 = [&](std::size_t offset, std::uint16_t value) {
atrac_header[offset] = static_cast<std::uint8_t>(value);
atrac_header[offset + 1u] = static_cast<std::uint8_t>(value >> 8u);
};
const auto put32 = [&](std::size_t offset, std::uint32_t value) {
for (std::size_t i = 0u; i < 4u; ++i)
atrac_header[offset + i] = static_cast<std::uint8_t>(value >> (i * 8u));
};
std::memcpy(atrac_header.data() + 0u, "RIFF", 4u);
put32(4u, 0x1000u - 8u);
std::memcpy(atrac_header.data() + 8u, "WAVE", 4u);
std::memcpy(atrac_header.data() + 12u, "fmt ", 4u);
put32(16u, 0x34u);
put16(20u, 0xFFFEu); put16(22u, 2u); put32(24u, 44100u);
put32(28u, 12058u); put16(32u, 560u); put16(34u, 0u);
std::memcpy(atrac_header.data() + 72u, "fact", 4u);
put32(76u, 8u); put32(80u, 4096u); put32(84u, 0x800u);
std::memcpy(atrac_header.data() + 88u, "data", 4u);
put32(92u, 0x1000u - 96u);
wlan_runtime.memory().copy_in(atrac_buffer, atrac_header);
psprecomp::AllegrexContext atrac_context{};
atrac_context.set_gpr(4u, atrac_buffer);
atrac_context.set_gpr(5u, 0x100u);
atrac_context.set_gpr(6u, 0x400u);
wlan_runtime.invoke_import("sceAtrac3plus", 0x0FAE370Eu, atrac_context);
require(!wlan_runtime.stopped() && atrac_context.gpr[2] == 0u,
"sceAtracSetHalfwayBufferAndGetID rejected a valid ATRAC3+ RIFF header");
atrac_context = {};
atrac_context.set_gpr(4u, 0u); atrac_context.set_gpr(5u, atrac_outputs);
wlan_runtime.invoke_import("sceAtrac3plus", 0xA554A158u, atrac_context);
require(atrac_context.gpr[2] == 0u && wlan_runtime.memory().load32(atrac_outputs) == 96u,
"sceAtracGetBitrate did not derive the ATRAC3+ bitrate from block alignment");
atrac_context = {};
atrac_context.set_gpr(4u, 0u);
atrac_context.set_gpr(5u, atrac_outputs + 4u);
atrac_context.set_gpr(6u, atrac_outputs + 8u);
atrac_context.set_gpr(7u, atrac_outputs + 12u);
wlan_runtime.invoke_import("sceAtrac3plus", 0x5D268707u, atrac_context);
require(atrac_context.gpr[2] == 0u &&
wlan_runtime.memory().load32(atrac_outputs + 4u) == atrac_buffer + 0x100u &&
wlan_runtime.memory().load32(atrac_outputs + 8u) == 0x300u &&
wlan_runtime.memory().load32(atrac_outputs + 12u) == 0x100u,
"sceAtracGetStreamDataInfo returned an incorrect ring-buffer window");
atrac_context = {};
atrac_context.set_gpr(4u, 0u); atrac_context.set_gpr(5u, 560u);
wlan_runtime.invoke_import("sceAtrac3plus", 0x7DB31251u, atrac_context);
require(atrac_context.gpr[2] == 0u, "sceAtracAddStreamData rejected its advertised write size");
atrac_context = {};
atrac_context.set_gpr(4u, 0u);
wlan_runtime.invoke_import("sceAtrac3plus", 0x61EB33F5u, atrac_context);
require(atrac_context.gpr[2] == 0u, "sceAtracReleaseAtracID failed for a valid context");
constexpr std::uint32_t sas_core = 0x08820000u;
constexpr std::uint32_t sas_data = 0x08821000u;
constexpr std::uint32_t sas_loop_data = 0x08821100u;
constexpr std::uint32_t sas_output = 0x08822000u;
// Two deliberately non-zero PSX-ADPCM blocks. Filter 0/shift 0
// makes nibble 1 decode to +4096 and nibble 2 to +8192, giving the
// mixer test a deterministic audible signal instead of validating
// the old all-zero bring-up stub.
std::array<std::uint8_t, 32> finite_vag{};
finite_vag[0] = 0x00u; finite_vag[1] = 0x00u;
std::fill(finite_vag.begin() + 2, finite_vag.begin() + 16, 0x11u);
finite_vag[16] = 0x00u; finite_vag[17] = 0x07u;
std::fill(finite_vag.begin() + 18, finite_vag.end(), 0x22u);
wlan_runtime.memory().copy_in(sas_data, finite_vag);
// Loop-start / loop-end markers exercise the PSP SAS loop semantics.
std::array<std::uint8_t, 32> loop_vag{};
loop_vag[0] = 0x00u; loop_vag[1] = 0x06u;
std::fill(loop_vag.begin() + 2, loop_vag.begin() + 16, 0x11u);
loop_vag[16] = 0x00u; loop_vag[17] = 0x03u;
std::fill(loop_vag.begin() + 18, loop_vag.end(), 0x22u);
wlan_runtime.memory().copy_in(sas_loop_data, loop_vag);
psprecomp::AllegrexContext sas_context{};
sas_context.set_gpr(4u, sas_core);
sas_context.set_gpr(5u, 0x100u);
sas_context.set_gpr(6u, 32u);
sas_context.set_gpr(7u, 0u);
sas_context.set_gpr(8u, 44100u);
wlan_runtime.invoke_import("sceSasCore", 0x42778A9Fu, sas_context);
require(!wlan_runtime.stopped() && sas_context.gpr[2] == 0u,
"__sceSasInit rejected a valid mixer configuration");
const auto configure_voice = [&](std::uint32_t address, std::uint32_t loop) {
psprecomp::AllegrexContext c{};
c.set_gpr(4u, sas_core); c.set_gpr(5u, 0u);
c.set_gpr(6u, address); c.set_gpr(7u, 0x20u); c.set_gpr(8u, loop);
wlan_runtime.invoke_import("sceSasCore", 0x99944089u, c);
require(c.gpr[2] == 0u, "__sceSasSetVoice rejected valid VAG metadata");
c = {};
c.set_gpr(4u, sas_core); c.set_gpr(5u, 0u);
c.set_gpr(6u, 0x1000u); c.set_gpr(7u, 0x1000u);
c.set_gpr(8u, 0u); c.set_gpr(9u, 0u);
wlan_runtime.invoke_import("sceSasCore", 0x440CA7D8u, c);
require(c.gpr[2] == 0u, "__sceSasSetVolume rejected unity dry volume");
};
const auto key_on_voice0 = [&] {
psprecomp::AllegrexContext c{};
c.set_gpr(4u, sas_core); c.set_gpr(5u, 0u);
wlan_runtime.invoke_import("sceSasCore", 0x76F01ACAu, c);
require(c.gpr[2] == 0u, "__sceSasSetKeyOn failed");
};
const auto output_has_nonzero_pcm = [&] {
for (std::uint32_t frame = 0u; frame < 0x100u; ++frame) {
const auto l = static_cast<std::int16_t>(wlan_runtime.memory().load16(sas_output + frame * 4u));
const auto r = static_cast<std::int16_t>(wlan_runtime.memory().load16(sas_output + frame * 4u + 2u));
if (l != 0 || r != 0) return true;
}
return false;
};
configure_voice(sas_data, 0u);
key_on_voice0();
sas_context = {};
sas_context.set_gpr(4u, sas_core);
wlan_runtime.invoke_import("sceSasCore", 0x68A46B95u, sas_context);
require((sas_context.gpr[2] & 1u) != 0u,
"SAS end flag changed before the first Core refresh");
wlan_runtime.memory().zero(sas_output, 0x400u);
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, sas_output);
wlan_runtime.invoke_import("sceSasCore", 0xA3589D81u, sas_context);
require(sas_context.gpr[2] == 0u && output_has_nonzero_pcm(),
"__sceSasCore failed to render non-zero VAG PCM");
sas_context = {};
sas_context.set_gpr(4u, sas_core);
wlan_runtime.invoke_import("sceSasCore", 0x68A46B95u, sas_context);
require((sas_context.gpr[2] & 1u) != 0u,
"finite non-looping SAS voice did not reach its end flag");
// Re-triggering the exact same voice must rewind the ADPCM decoder.
// This catches the old bug where a reused gunshot/footstep resumed at
// EOF and therefore vanished after its first play.
key_on_voice0();
wlan_runtime.memory().zero(sas_output, 0x400u);
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, sas_output);
wlan_runtime.invoke_import("sceSasCore", 0xA3589D81u, sas_context);
require(sas_context.gpr[2] == 0u && output_has_nonzero_pcm(),
"SAS KeyOn did not rewind/replay a reused VAG voice");
// CoreWithMix must scale the caller's existing PCM and then add SAS
// voices. VCS uses this path for real effects; preserving the input
// unchanged (the old stub) made those voices completely inaudible.
configure_voice(sas_loop_data, 1u);
key_on_voice0();
for (std::uint32_t frame = 0u; frame < 0x100u; ++frame) {
wlan_runtime.memory().store16(sas_output + frame * 4u, static_cast<std::uint16_t>(1000));
wlan_runtime.memory().store16(sas_output + frame * 4u + 2u,
static_cast<std::uint16_t>(static_cast<std::int16_t>(-1000)));
}
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, sas_output);
sas_context.set_gpr(6u, 0x800u); sas_context.set_gpr(7u, 0x800u);
wlan_runtime.invoke_import("sceSasCore", 0x50A14DFCu, sas_context);
const auto mixed_l = static_cast<std::int16_t>(wlan_runtime.memory().load16(sas_output));
const auto mixed_r = static_cast<std::int16_t>(wlan_runtime.memory().load16(sas_output + 2u));
require(sas_context.gpr[2] == 0u && mixed_l > 500 && mixed_r > -500,
"__sceSasCoreWithMix did not scale input and add the SAS voice");
sas_context = {};
sas_context.set_gpr(4u, sas_core);
wlan_runtime.invoke_import("sceSasCore", 0x68A46B95u, sas_context);
require((sas_context.gpr[2] & 1u) == 0u,
"loop-marker SAS voice ended instead of returning to its loop start");
// Noise voices used to be accepted by the HLE but never rendered.
// Use voice 1 so the looped VAG above also verifies multi-voice sum.
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 1u); sas_context.set_gpr(6u, 63u);
wlan_runtime.invoke_import("sceSasCore", 0xB7660A23u, sas_context);
require(sas_context.gpr[2] == 0u, "__sceSasSetNoise rejected a valid frequency");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 1u);
sas_context.set_gpr(6u, 0x1000u); sas_context.set_gpr(7u, 0x1000u);
sas_context.set_gpr(8u, 0u); sas_context.set_gpr(9u, 0u);
wlan_runtime.invoke_import("sceSasCore", 0x440CA7D8u, sas_context);
require(sas_context.gpr[2] == 0u, "noise voice volume setup failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 1u);
wlan_runtime.invoke_import("sceSasCore", 0x76F01ACAu, sas_context);
require(sas_context.gpr[2] == 0u, "noise voice KeyOn failed");
wlan_runtime.memory().zero(sas_output, 0x400u);
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, sas_output);
wlan_runtime.invoke_import("sceSasCore", 0xA3589D81u, sas_context);
require(sas_context.gpr[2] == 0u && output_has_nonzero_pcm(),
"SAS noise voice was configured but rendered silence");
// Effect-only routing used to be dropped because effectLeft/effectRight
// were stored but never mixed. Reinitialize the core, send voice 0
// only to the wet bus, and require audible output with dry disabled.
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0x100u);
sas_context.set_gpr(6u, 32u); sas_context.set_gpr(7u, 0u);
sas_context.set_gpr(8u, 44100u);
wlan_runtime.invoke_import("sceSasCore", 0x42778A9Fu, sas_context);
require(sas_context.gpr[2] == 0u, "SAS re-init before wet-bus test failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0u);
sas_context.set_gpr(6u, sas_data); sas_context.set_gpr(7u, 0x20u);
sas_context.set_gpr(8u, 0u);
wlan_runtime.invoke_import("sceSasCore", 0x99944089u, sas_context);
require(sas_context.gpr[2] == 0u, "wet-bus VAG setup failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0u);
sas_context.set_gpr(6u, 0u); sas_context.set_gpr(7u, 0u);
sas_context.set_gpr(8u, 0x1000u); sas_context.set_gpr(9u, 0x1000u);
wlan_runtime.invoke_import("sceSasCore", 0x440CA7D8u, sas_context);
require(sas_context.gpr[2] == 0u, "wet-bus volume setup failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0x1000u);
sas_context.set_gpr(6u, 0x1000u);
wlan_runtime.invoke_import("sceSasCore", 0xD5A229C9u, sas_context);
require(sas_context.gpr[2] == 0u, "wet-bus global volume setup failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0u);
sas_context.set_gpr(6u, 1u);
wlan_runtime.invoke_import("sceSasCore", 0xF983B186u, sas_context);
require(sas_context.gpr[2] == 0u, "wet-only RevVON setup failed");
key_on_voice0();
wlan_runtime.memory().zero(sas_output, 0x400u);
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, sas_output);
wlan_runtime.invoke_import("sceSasCore", 0xA3589D81u, sas_context);
require(sas_context.gpr[2] == 0u && output_has_nonzero_pcm(),
"effect-only SAS voice disappeared from the wet bus");
// Raw SAS output is four signed-16 planes (dry L/R, send L/R), not
// the mono buffer used by the old HLE. Validate both the larger
// layout and the effect-send planes.
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0x100u);
sas_context.set_gpr(6u, 32u); sas_context.set_gpr(7u, 1u);
sas_context.set_gpr(8u, 44100u);
wlan_runtime.invoke_import("sceSasCore", 0x42778A9Fu, sas_context);
require(sas_context.gpr[2] == 0u, "SAS raw-mode init failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0u);
sas_context.set_gpr(6u, sas_data); sas_context.set_gpr(7u, 0x20u);
sas_context.set_gpr(8u, 0u);
wlan_runtime.invoke_import("sceSasCore", 0x99944089u, sas_context);
require(sas_context.gpr[2] == 0u, "raw-mode VAG setup failed");
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, 0u);
sas_context.set_gpr(6u, 0x1000u); sas_context.set_gpr(7u, 0x800u);
sas_context.set_gpr(8u, 0x600u); sas_context.set_gpr(9u, 0x400u);
wlan_runtime.invoke_import("sceSasCore", 0x440CA7D8u, sas_context);
require(sas_context.gpr[2] == 0u, "raw-mode volume setup failed");
key_on_voice0();
wlan_runtime.memory().zero(sas_output, 0x800u);
sas_context = {};
sas_context.set_gpr(4u, sas_core); sas_context.set_gpr(5u, sas_output);
wlan_runtime.invoke_import("sceSasCore", 0xA3589D81u, sas_context);
bool raw_dry_nonzero = false;
bool raw_send_nonzero = false;
for (std::uint32_t frame = 0u; frame < 0x100u; ++frame) {
raw_dry_nonzero |= static_cast<std::int16_t>(
wlan_runtime.memory().load16(sas_output + frame * 2u)) != 0;
raw_send_nonzero |= static_cast<std::int16_t>(
wlan_runtime.memory().load16(sas_output + 0x400u + frame * 2u)) != 0;
}
require(sas_context.gpr[2] == 0u && raw_dry_nonzero && raw_send_nonzero,
"SAS raw-mode did not expose dry/effect planes");
}
// V8.2.7: partition memory is a reusable arena, not a one-way bump
// pointer. Freeing blocks out of order must expose their holes to new
// allocations, and freeing the final live block must collapse the
// frontier back to the arena base.
{
reset();
partition_table = PartitionTable{};
fixed_pool_table = FixedPoolTable{};
partition_arena_base = 0x08810000u;
partition_table.next_address = partition_arena_base;
thread_table.next_stack_top = 0x08820000u;
std::uint32_t first{}, second{}, reused{};
require(allocate_user_arena_range(0x1000u, 0x100u, first) && first == 0x08810000u,
"partition arena did not allocate the first low block");
partition_table.blocks.emplace(0x100, PartitionBlock{"first", first, 0x1000u});
recompute_partition_frontier();
require(allocate_user_arena_range(0x1000u, 0x100u, second) && second == 0x08811000u,
"partition arena did not allocate the second block after the first");
partition_table.blocks.emplace(0x101, PartitionBlock{"second", second, 0x1000u});
recompute_partition_frontier();
partition_table.blocks.erase(0x100);
recompute_partition_frontier();
require(partition_table.next_address == 0x08812000u,
"partition frontier collapsed across a still-live upper block");
require(allocate_user_arena_range(0x800u, 0x100u, reused) && reused == 0x08810000u,
"partition allocator did not reuse a freed lower hole");
partition_table.blocks.emplace(0x102, PartitionBlock{"reused", reused, 0x800u});
recompute_partition_frontier();
partition_table.blocks.erase(0x101);
partition_table.blocks.erase(0x102);
recompute_partition_frontier();
require(partition_table.next_address == partition_arena_base,
"partition frontier did not return to the arena base after all frees");
}
// V8.2.7: sceKernelExitDeleteThread must destroy the current thread
// object and reclaim its stack immediately. The old implementation
// only marked it Completed, leaking PSP user memory until later thread
// creation (including the pre-save transition) failed.
{
reset();
partition_table = PartitionTable{};
partition_table.next_address = 0x08810000u;
thread_table.next_stack_top = 0x0A000000u;
std::uint32_t deleted_bottom{}, deleted_top{};
require(allocate_thread_stack(0x800u, deleted_bottom, deleted_top),
"ExitDelete self-test could not allocate worker stack");
ThreadRecord deleted{};
deleted.name = "exit-delete-worker";
deleted.entry = 0x08986B50u;
deleted.priority = 32u;
deleted.stack_size = 0x800u;
deleted.stack_bottom = deleted_bottom;
deleted.stack_top = deleted_top;
deleted.state = ThreadState::Running;
thread_table.threads.emplace(10, deleted);
thread_table.current_uid = 10;
ThreadRecord successor{};
successor.name = "exit-delete-successor";
successor.priority = 20u;
successor.state = ThreadState::Ready;
successor.suspended_context.pc = 0x08812340u;
thread_table.threads.emplace(11, successor);
enqueue_continuation(11, successor.suspended_context);
ThreadRecord waiter{};
waiter.name = "exit-delete-waiter";
waiter.priority = 80u;
waiter.state = ThreadState::Sleeping;
thread_table.threads.emplace(12, waiter);
ThreadContinuation wait{};
wait.uid = 12;
wait.context.pc = 0x08845670u;
wait.context.set_gpr(2u, 0xDEADBEEFu);
thread_table.thread_end_waiters[10].push_back(wait);
psprecomp::Runtime exitdelete_runtime;
psprecomp::AllegrexContext exitdelete_context{};
exitdelete_context.set_gpr(4u, 0x1234u);
exit_delete_current_thread(exitdelete_runtime, exitdelete_context);
require(!thread_table.threads.contains(10),
"sceKernelExitDeleteThread left a Completed thread record behind");
require(thread_table.next_stack_top == 0x0A000000u && thread_table.free_stacks.empty(),
"sceKernelExitDeleteThread did not reclaim the worker stack");
require(thread_table.current_uid == 11 && exitdelete_context.pc == 0x08812340u,
"sceKernelExitDeleteThread did not schedule the next ready thread");
const auto awakened = std::find_if(
thread_table.continuations.begin(), thread_table.continuations.end(),
[](const ThreadContinuation &item) { return item.uid == 12; });
require(awakened != thread_table.continuations.end() && awakened->context.gpr[2] == 0u,
"sceKernelExitDeleteThread did not wake thread-end waiters");
}
// V8.2.7 checkpoint migration is deliberately narrow: only completed
// VCS worker entry points proven to terminate through ExitDelete are
// reclaimed. Ordinary Completed threads remain available for normal
// ExitThread/DeleteThread semantics.
{
reset();
partition_table = PartitionTable{};
fixed_pool_table = FixedPoolTable{};
partition_arena_base = 0x09F00000u;
partition_table.next_address = 0x09F99000u; // stale V8.2.6 bump cursor
thread_table.next_stack_top = 0x09F99800u;
thread_table.current_uid = 13;
ThreadRecord current{}; current.name = "mix sound thread"; current.state = ThreadState::Running;
thread_table.threads.emplace(13, current);
ThreadRecord leaked_sfx{};
leaked_sfx.name = "sfx bank load thread"; leaked_sfx.entry = 0x08986B50u;
leaked_sfx.state = ThreadState::Completed; leaked_sfx.stack_bottom = 0x09F99800u;
leaked_sfx.stack_top = 0x09F9A000u; leaked_sfx.stack_size = 0x800u;
thread_table.threads.emplace(14, leaked_sfx);
ThreadRecord leaked_memstick{};
leaked_memstick.name = "memstick"; leaked_memstick.entry = 0x08AB5AA0u;
leaked_memstick.state = ThreadState::Completed; leaked_memstick.stack_bottom = 0x09F9A000u;
leaked_memstick.stack_top = 0x09F9C000u; leaked_memstick.stack_size = 0x2000u;
thread_table.threads.emplace(15, leaked_memstick);
ThreadRecord ordinary{};
ordinary.name = "user_main"; ordinary.entry = 0x08810000u; ordinary.state = ThreadState::Completed;
ordinary.stack_bottom = 0x09FA0000u; ordinary.stack_top = 0x09FA8000u; ordinary.stack_size = 0x8000u;
thread_table.threads.emplace(1, ordinary);
recompute_partition_frontier();
require(partition_table.next_address == partition_arena_base,
"legacy checkpoint partition cursor was not rebuilt from live allocations");
const LegacyExitDeleteRepairStats repaired = repair_legacy_vcs_exit_delete_threads();
require(repaired.threads == 2u && repaired.stack_bytes == 0x2800u,
"legacy ExitDelete migration did not reclaim the expected worker stacks");
require(repaired.stack_top_before == 0x09F99800u && repaired.stack_top_after == 0x09F9C000u,
"legacy ExitDelete migration did not recover the top-down stack frontier");
require(!thread_table.threads.contains(14) && !thread_table.threads.contains(15) &&
thread_table.threads.contains(1) && thread_table.threads.contains(13),
"legacy ExitDelete migration removed the wrong thread records");
}
// V8.2.6 persistent save-repro format: exercise the primitive framing
// used by every checkpoint section without touching the filesystem.
{
SaveReproWriter writer;
const std::uint32_t marker = 0x8265A5A5u;
const std::array<std::uint8_t, 5> payload{1u, 3u, 5u, 7u, 9u};
writer.pod(marker);
writer.string("save-repro-roundtrip");
writer.raw(payload);
const std::uint64_t checksum = save_repro_fnv1a(writer.bytes);
require(checksum != 0u, "save-repro checkpoint checksum unexpectedly zero");
SaveReproReader reader{writer.bytes};
std::uint32_t restored_marker{};
std::string restored_text;
std::array<std::uint8_t, 5> restored_payload{};
require(reader.pod(restored_marker) && reader.string(restored_text) && reader.raw(restored_payload),
"save-repro primitive reader/writer roundtrip failed");
require(restored_marker == marker && restored_text == "save-repro-roundtrip" &&
restored_payload == payload && reader.offset == reader.bytes.size(),
"save-repro primitive roundtrip changed checkpoint data");
}
// Exercise the complete persistent checkpoint on a fresh profile state.
// This writes the real RAM/EDRAM payload, then mutates RAM/time/CPU and
// proves restore reconstructs the captured state. The test file lives in
// the host temp directory and is removed immediately.
{
const auto temp_root = std::filesystem::temp_directory_path() / "psprecomp_v826_save_repro_selftest";
std::error_code cleanup_error;
std::filesystem::remove_all(temp_root, cleanup_error);
std::filesystem::create_directories(temp_root / "PSP_DATA", cleanup_error);
require(!cleanup_error, "save-repro self-test could not create temp directory");
psprecomp::Runtime checkpoint_runtime;
checkpoint_runtime.set_game_root(temp_root / "PSP_DATA");
install_profile(checkpoint_runtime, 0x08810000u);
save_repro_self_test_mode = true;
constexpr std::uint32_t marker_address = 0x08824000u;
checkpoint_runtime.memory().store32(marker_address, 0x8260CAFEu);
virtual_time_us = 0x12345678ull;
display_vblank_index = 0x4321ull;
controller_state.buttons = 0x00004000u;
psprecomp::AllegrexContext captured = checkpoint_runtime.cpu();
captured.pc = 0x08958D28u;
captured.set_gpr(2u, 0x11223344u);
captured.set_gpr(29u, 0x09FFF000u);
std::string checkpoint_error;
if (!save_repro_write_checkpoint(checkpoint_runtime, captured, checkpoint_error))
throw std::runtime_error("save-repro full checkpoint write failed: " + checkpoint_error);
checkpoint_runtime.memory().store32(marker_address, 0u);
virtual_time_us = 0u;
display_vblank_index = 0u;
controller_state.buttons = 0u;
checkpoint_runtime.cpu() = {};
checkpoint_error.clear();
if (!save_repro_restore_checkpoint_impl(checkpoint_runtime, checkpoint_error))
throw std::runtime_error("save-repro full checkpoint restore failed: " + checkpoint_error);
require(checkpoint_runtime.memory().load32(marker_address) == 0x8260CAFEu &&
virtual_time_us == 0x12345678ull && display_vblank_index == 0x4321ull &&
controller_state.buttons == 0x00004000u &&
checkpoint_runtime.cpu().pc == captured.pc &&
checkpoint_runtime.cpu().gpr[2] == captured.gpr[2] &&
checkpoint_runtime.cpu().gpr[29] == captured.gpr[29],
"save-repro full checkpoint did not restore RAM/time/controller/CPU exactly");
save_repro_self_test_mode = false;
std::filesystem::remove_all(temp_root, cleanup_error);
}
reset();
error.clear();
return true;
} catch (const std::exception &exception) {
error = exception.what();
return false;
}
}
} // namespace vcs