mirror of
https://github.com/jessicanataliagta/PSPRecomp
synced 2026-09-26 08:41:08 -04:00
lodscreen and save screen
lodscreen and save screen
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,16 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
for %%I in ("%~dp0..\..") do set "REPO=%%~fI"
|
||||
set "BUILD=%REPO%\out\vcs-release-ninja"
|
||||
set "MARKER=%BUILD%\.v9_3_clean_rebase_objects_done"
|
||||
|
||||
call "%~dp0FORCE_V9_3_CLEAN_REBASE_OBJECTS.bat"
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
|
||||
call "%~dp0scripts\build_release_ninja.bat"
|
||||
exit /b %errorlevel%
|
||||
set "RC=%errorlevel%"
|
||||
if "%RC%"=="0" (
|
||||
if not exist "%BUILD%" mkdir "%BUILD%" >nul 2>nul
|
||||
>"%MARKER%" echo V9.3 clean rebase successfully rebuilt critical objects.
|
||||
)
|
||||
exit /b %RC%
|
||||
|
||||
@@ -124,7 +124,7 @@ set(VCS_HOST_SOURCES
|
||||
host/vcs_native_fast_paths.cpp
|
||||
host/framebuffer_capture.cpp
|
||||
host/display_window.cpp
|
||||
host/savedata_dialog.cpp
|
||||
host/savedata_utility_ui.cpp
|
||||
host/audio_output.cpp
|
||||
host/vcs_config.cpp
|
||||
host/vcs_camera_input.cpp
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
for %%I in ("%~dp0..\..") do set "REPO=%%~fI"
|
||||
set "BUILD=%REPO%\out\vcs-release-ninja"
|
||||
set "MARKER=%BUILD%\.v9_3_clean_rebase_objects_done"
|
||||
|
||||
if exist "%MARKER%" exit /b 0
|
||||
|
||||
echo [V9.3] CLEAN REBASE: invalidating stale incremental objects...
|
||||
if not exist "%BUILD%" (
|
||||
echo [V9.3] Build tree does not exist yet; normal build will create it.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$build=[IO.Path]::GetFullPath('%BUILD%');" ^
|
||||
"$names=@('vcs_profile.cpp.obj','savedata_utility_ui.cpp.obj','ge_renderer.cpp.obj','display_window.cpp.obj','main.cpp.obj','vcs_config.cpp.obj','generated_unit_0127.cpp.obj','generated_unit_0172.cpp.obj');" ^
|
||||
"$found=Get-ChildItem -LiteralPath $build -Recurse -File -Filter '*.obj' -ErrorAction SilentlyContinue ^| Where-Object { $names -contains $_.Name };" ^
|
||||
"Write-Host ('[V9.3] stale objects found: ' + $found.Count);" ^
|
||||
"foreach($f in $found){Write-Host (' deleting: ' + $f.FullName); Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop};" ^
|
||||
"$src=@('%REPO%\profiles\vcs\host\vcs_profile.cpp','%REPO%\profiles\vcs\host\savedata_utility_ui.cpp','%REPO%\profiles\vcs\host\ge_renderer.cpp','%REPO%\profiles\vcs\host\display_window.cpp','%REPO%\profiles\vcs\host\main.cpp','%REPO%\profiles\vcs\host\vcs_config.cpp','%REPO%\profiles\vcs\generated\generated_unit_0127.cpp','%REPO%\profiles\vcs\generated\generated_unit_0172.cpp');" ^
|
||||
"$now=Get-Date; foreach($p in $src){if(Test-Path -LiteralPath $p){(Get-Item -LiteralPath $p).LastWriteTime=$now}}"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: failed to invalidate V9.3 rollback objects.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [V9.3] Critical objects invalidated. Ninja must compile them again.
|
||||
exit /b 0
|
||||
@@ -79,6 +79,12 @@ World=1.50
|
||||
Vehicles=1.50
|
||||
NPCs=1.50
|
||||
|
||||
[Frontend]
|
||||
; V9 load-only startup: the removed BootToGameMenu path can no longer be
|
||||
; enabled. After the intro, the first AUTOLOAD/LOAD is presented as the
|
||||
; in-frame PSP Load Game slot picker instead.
|
||||
; false = keyboard/gamepad only; no desktop cursor or mouse clicks in savedata.
|
||||
MouseMenu=false
|
||||
[Controls]
|
||||
CameraStick=true
|
||||
MouseSensitivity=12
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <iostream>
|
||||
@@ -138,6 +140,36 @@ struct WindowState {
|
||||
std::atomic<std::int32_t> mouse_dx{0};
|
||||
std::atomic<std::int32_t> mouse_dy{0};
|
||||
std::atomic<std::int32_t> wheel{0};
|
||||
// Guest native pause/frontend state, observed from VCS itself every vblank.
|
||||
// This is deliberately separate from menu_mouse_mode: with MouseMenu=false
|
||||
// the game is still paused, but the OS cursor stays hidden and mouse clicks
|
||||
// are ignored instead of being translated into menu input.
|
||||
std::atomic<bool> guest_frontend_active{false};
|
||||
// Pause/frontend mouse mode. True only while the guest frontend is actually
|
||||
// active AND [Frontend] MouseMenu=true. Never toggled from Escape/Start.
|
||||
std::atomic<bool> menu_mouse_mode{false};
|
||||
// Firmware-owned PSP utility (savedata etc.) takes the pointer/buttons away
|
||||
// from gameplay while its in-frame HLE surface is visible.
|
||||
std::atomic<bool> system_utility_mode{false};
|
||||
// First boot is the *native guest* VCS frontend. The host never draws a
|
||||
// replacement menu; these flags only gate desktop input and queue the two
|
||||
// native R-trigger presses that move the guest pause frontend from MAP to
|
||||
// GAME after the guest itself reports the menu active.
|
||||
std::atomic<bool> native_boot_armed{false};
|
||||
std::atomic<bool> native_boot_active{false};
|
||||
std::atomic<bool> native_boot_game_tab_queued{false};
|
||||
std::atomic<bool> native_boot_game_tab_ready{false};
|
||||
std::atomic<bool> native_boot_user_committed{false};
|
||||
std::atomic<bool> native_boot_lock{false};
|
||||
// After the automatic MAP->BRIEF->GAME navigation finishes, require the
|
||||
// physical pad/keyboard to be fully released before any face/menu button
|
||||
// is allowed through. This prevents the Cross/Space used to skip an intro
|
||||
// from immediately activating GAME's first row (LOAD GAME).
|
||||
std::atomic<bool> native_boot_release_ready{false};
|
||||
std::atomic<std::uint32_t> native_boot_release_neutral_polls{0u};
|
||||
std::mutex synthetic_mutex;
|
||||
std::deque<std::uint32_t> synthetic_buttons;
|
||||
std::atomic<int> last_hover_row{-1};
|
||||
// Set while a movie is on screen; see display_window_set_aspect_lock.
|
||||
// Atomic because the guest thread raises it and the window thread paints.
|
||||
std::atomic<bool> aspect_lock{false};
|
||||
@@ -163,6 +195,148 @@ WindowState &window_state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
bool mouse_menu_enabled() noexcept {
|
||||
const VcsConfiguration &config = vcs_configuration();
|
||||
return config.initialized && config.frontend.mouse_menu;
|
||||
}
|
||||
|
||||
bool native_boot_locked(const WindowState &state) noexcept {
|
||||
return state.native_boot_lock.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool native_boot_ready(const WindowState &state) noexcept {
|
||||
return state.native_boot_game_tab_ready.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void refresh_menu_mouse_mode(WindowState &state) noexcept {
|
||||
const bool desired = mouse_menu_enabled() &&
|
||||
state.guest_frontend_active.load(std::memory_order_relaxed) &&
|
||||
!state.system_utility_mode.load(std::memory_order_relaxed);
|
||||
const bool previous = state.menu_mouse_mode.exchange(desired, std::memory_order_relaxed);
|
||||
if (previous == desired) return;
|
||||
|
||||
// Never let raw deltas/wheel movement accumulated while a menu owned the
|
||||
// mouse explode into the camera on the first gameplay frame after closing.
|
||||
state.mouse_dx.store(0, std::memory_order_relaxed);
|
||||
state.mouse_dy.store(0, std::memory_order_relaxed);
|
||||
state.wheel.store(0, std::memory_order_relaxed);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
if (HWND hwnd = state.window.load(std::memory_order_relaxed)) {
|
||||
SetCursor(desired ? LoadCursorW(nullptr, MAKEINTRESOURCEW(32512)) : nullptr);
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
void clear_synthetic_buttons(WindowState &state) {
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
state.synthetic_buttons.clear();
|
||||
}
|
||||
|
||||
void commit_native_boot_action(WindowState &state) noexcept {
|
||||
state.native_boot_user_committed.store(true, std::memory_order_relaxed);
|
||||
state.native_boot_lock.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void enqueue_synthetic_pulse(WindowState &state, std::uint32_t button, int neutral_polls = 2) {
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
state.synthetic_buttons.push_back(button);
|
||||
for (int i = 0; i < neutral_polls; ++i) state.synthetic_buttons.push_back(0u);
|
||||
}
|
||||
|
||||
void enqueue_synthetic_delay(WindowState &state, int polls) {
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
for (int i = 0; i < polls; ++i) state.synthetic_buttons.push_back(0u);
|
||||
}
|
||||
|
||||
void enqueue_menu_row_exact(WindowState &state, int row, bool activate) {
|
||||
row = std::clamp(row, 0, 10);
|
||||
// Mouse clicks must be deterministic even if keyboard/pad navigation moved
|
||||
// the guest selection since the previous click. Clamp to the first row with
|
||||
// repeated Up edges, then walk down to the requested row. This costs a few
|
||||
// controller polls but cannot drift or accumulate the "random" movement the
|
||||
// old hover-relative queue produced.
|
||||
for (int i = 0; i < 10; ++i) enqueue_synthetic_pulse(state, kPspUp, 1);
|
||||
for (int i = 0; i < row; ++i) enqueue_synthetic_pulse(state, kPspDown, 1);
|
||||
state.last_hover_row.store(row, std::memory_order_relaxed);
|
||||
if (activate) enqueue_synthetic_pulse(state, kPspCross, 2);
|
||||
}
|
||||
|
||||
void enqueue_menu_tab(WindowState &state, int tab_index) {
|
||||
tab_index = std::clamp(tab_index, 0, 7);
|
||||
// L repeatedly clamps the pause frontend to MAP, then R reaches the exact
|
||||
// requested tab. This avoids needing a guest-side selected-tab address.
|
||||
for (int i = 0; i < 10; ++i) enqueue_synthetic_pulse(state, kPspLTrigger, 1);
|
||||
for (int i = 0; i < tab_index; ++i) enqueue_synthetic_pulse(state, kPspRTrigger, 1);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int frontend_row_from_point(HWND window, int x, int y) {
|
||||
RECT client{};
|
||||
GetClientRect(window, &client);
|
||||
const int w = client.right - client.left;
|
||||
const int h = client.bottom - client.top;
|
||||
if (w <= 0 || h <= 0) return -1;
|
||||
const double nx = static_cast<double>(x) / static_cast<double>(w);
|
||||
const double ny = static_cast<double>(y) / static_cast<double>(h);
|
||||
if (nx < 0.20 || nx > 0.78 || ny < 0.20 || ny > 0.70) return -1;
|
||||
const double row_position = (ny - 0.27) / 0.074;
|
||||
const int row = static_cast<int>(std::lround(row_position));
|
||||
return row >= 0 && row <= 8 ? row : -1;
|
||||
}
|
||||
|
||||
int frontend_tab_from_point(HWND window, int x, int y) {
|
||||
RECT client{};
|
||||
GetClientRect(window, &client);
|
||||
const int w = client.right - client.left;
|
||||
const int h = client.bottom - client.top;
|
||||
if (w <= 0 || h <= 0) return -1;
|
||||
const double nx = static_cast<double>(x) / static_cast<double>(w);
|
||||
const double ny = static_cast<double>(y) / static_cast<double>(h);
|
||||
if (ny >= 0.79 && ny < 0.90) {
|
||||
if (nx >= 0.13 && nx < 0.22) return 0; // Map
|
||||
if (nx >= 0.22 && nx < 0.31) return 1; // Brief
|
||||
if (nx >= 0.31 && nx < 0.41) return 2; // Game
|
||||
if (nx >= 0.41 && nx < 0.51) return 3; // Stats
|
||||
if (nx >= 0.51 && nx < 0.66) return 4; // Controls
|
||||
}
|
||||
if (ny >= 0.89 && ny <= 0.99) {
|
||||
if (nx >= 0.13 && nx < 0.25) return 5; // Audio
|
||||
if (nx >= 0.25 && nx < 0.39) return 6; // Display
|
||||
if (nx >= 0.39 && nx < 0.58) return 7; // Multiplayer
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void enqueue_native_boot_game_tab(WindowState &state) {
|
||||
// The native VCS pause frontend opens on MAP during gameplay. Two genuine
|
||||
// R-trigger edges therefore select GAME (MAP -> BRIEF -> GAME). The menu is
|
||||
// already active before this runs, so these are consumed by the game's own
|
||||
// frontend controller path; no host menu is being navigated or drawn.
|
||||
enqueue_synthetic_delay(state, 2);
|
||||
enqueue_synthetic_pulse(state, kPspRTrigger, 2);
|
||||
enqueue_synthetic_pulse(state, kPspRTrigger, 2);
|
||||
enqueue_synthetic_delay(state, 2);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
state.native_boot_game_tab_queued.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::uint32_t dequeue_synthetic_buttons(WindowState &state) {
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
if (state.synthetic_buttons.empty()) {
|
||||
if (state.native_boot_game_tab_queued.load(std::memory_order_relaxed) &&
|
||||
state.native_boot_active.load(std::memory_order_relaxed))
|
||||
state.native_boot_game_tab_ready.store(true, std::memory_order_relaxed);
|
||||
return 0u;
|
||||
}
|
||||
const std::uint32_t value = state.synthetic_buttons.front();
|
||||
state.synthetic_buttons.pop_front();
|
||||
if (state.synthetic_buttons.empty() &&
|
||||
state.native_boot_game_tab_queued.load(std::memory_order_relaxed) &&
|
||||
state.native_boot_active.load(std::memory_order_relaxed))
|
||||
state.native_boot_game_tab_ready.store(true, std::memory_order_relaxed);
|
||||
return value;
|
||||
}
|
||||
|
||||
bool key_down(int virtual_key) noexcept {
|
||||
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0;
|
||||
}
|
||||
@@ -274,8 +448,10 @@ LRESULT CALLBACK window_procedure(HWND window, UINT message, WPARAM wparam, LPAR
|
||||
state.focused.store(false, std::memory_order_relaxed);
|
||||
return 0;
|
||||
case WM_KEYDOWN:
|
||||
// Escape is the pause button now that it is bound to Start, the way it
|
||||
// is in San Andreas. Alt+F4 and the window's close box still close.
|
||||
// Keyboard state is sampled with GetAsyncKeyState. Do not infer pause
|
||||
// menu ownership from Escape here: the guest may consume the press for
|
||||
// an intro/transition. Cursor mode is driven only by VCS' real native
|
||||
// frontend-active byte through display_window_set_guest_frontend_active.
|
||||
return 0;
|
||||
case WM_INPUT: {
|
||||
// Raw mouse deltas. Sized from the message rather than assumed: the
|
||||
@@ -297,18 +473,86 @@ LRESULT CALLBACK window_procedure(HWND window, UINT message, WPARAM wparam, LPAR
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSEMOVE:
|
||||
// Do not synthesize D-pad edges on hover. The old hover-relative queue
|
||||
// could still be draining while the pointer crossed another row, which
|
||||
// made the highlight move seemingly at random. Mouse movement now only
|
||||
// moves the OS cursor; a click performs one exact navigation transaction.
|
||||
return 0;
|
||||
case WM_LBUTTONDOWN:
|
||||
if (state.system_utility_mode.load(std::memory_order_relaxed)) {
|
||||
if (mouse_menu_enabled()) enqueue_synthetic_pulse(state, kPspCross, 2);
|
||||
return 0;
|
||||
}
|
||||
if (mouse_menu_enabled() && state.menu_mouse_mode.load(std::memory_order_relaxed)) {
|
||||
if (native_boot_locked(state) && !native_boot_ready(state)) return 0;
|
||||
const int x = static_cast<int>(static_cast<short>(LOWORD(lparam)));
|
||||
const int y = static_cast<int>(static_cast<short>(HIWORD(lparam)));
|
||||
const int tab = frontend_tab_from_point(window, x, y);
|
||||
if (tab >= 0) {
|
||||
// One click replaces any older mouse-navigation transaction.
|
||||
// The initial boot frontend remains pinned to GAME until an
|
||||
// actual Game-page action is selected.
|
||||
if (!native_boot_locked(state) || tab == 2) {
|
||||
clear_synthetic_buttons(state);
|
||||
enqueue_menu_tab(state, tab);
|
||||
}
|
||||
} else {
|
||||
const int row = frontend_row_from_point(window, x, y);
|
||||
// The native GAME page has exactly four actions. Reject lower
|
||||
// hitbox rows while the first-boot lock owns that page.
|
||||
if (row >= 0 && (!native_boot_locked(state) || row <= 3)) {
|
||||
clear_synthetic_buttons(state);
|
||||
enqueue_menu_row_exact(state, row, true);
|
||||
if (native_boot_locked(state)) commit_native_boot_action(state);
|
||||
} else {
|
||||
// A click outside a recognized item does not punch/fire
|
||||
// through the menu into the paused world.
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_RBUTTONDOWN:
|
||||
if (state.system_utility_mode.load(std::memory_order_relaxed)) {
|
||||
if (mouse_menu_enabled()) enqueue_synthetic_pulse(state, kPspCircle, 2);
|
||||
return 0;
|
||||
}
|
||||
if (mouse_menu_enabled() && state.menu_mouse_mode.load(std::memory_order_relaxed)) {
|
||||
// On the initial native GAME screen Circle/Back is deliberately
|
||||
// blocked. Once the user commits to New/Load/Delete/Reset the guest
|
||||
// regains normal back behavior in its confirmation/submenus.
|
||||
if (!native_boot_locked(state)) enqueue_synthetic_pulse(state, kPspCircle, 2);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSEWHEEL:
|
||||
state.wheel.fetch_add(GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA,
|
||||
std::memory_order_relaxed);
|
||||
if (state.system_utility_mode.load(std::memory_order_relaxed)) {
|
||||
if (mouse_menu_enabled()) {
|
||||
const int notches = GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA;
|
||||
if (notches != 0)
|
||||
enqueue_synthetic_pulse(state, notches > 0 ? kPspUp : kPspDown, 2);
|
||||
}
|
||||
} else if (mouse_menu_enabled() && state.menu_mouse_mode.load(std::memory_order_relaxed)) {
|
||||
if (native_boot_locked(state) && !native_boot_ready(state)) return 0;
|
||||
const int notches = GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA;
|
||||
if (notches != 0)
|
||||
enqueue_synthetic_pulse(state, notches > 0 ? kPspUp : kPspDown, 2);
|
||||
} else {
|
||||
state.wheel.fetch_add(GET_WHEEL_DELTA_WPARAM(wparam) / WHEEL_DELTA,
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
return 0;
|
||||
case WM_SETCURSOR:
|
||||
// Hide the pointer over the client area: the mouse is aiming the
|
||||
// camera, not pointing at anything. Answering WM_SETCURSOR rather than
|
||||
// calling ShowCursor avoids its counter, which has to be balanced
|
||||
// exactly and leaves the cursor invisible everywhere if it is not.
|
||||
// The non-client area keeps its arrow so the title bar stays usable.
|
||||
// Gameplay uses raw mouse deltas and hides the OS pointer. Pause/menu
|
||||
// mode does the opposite: show a normal arrow and turn mouse clicks
|
||||
// into PSP front-end navigation.
|
||||
if (LOWORD(lparam) == HTCLIENT) {
|
||||
SetCursor(nullptr);
|
||||
if ((mouse_menu_enabled() && state.system_utility_mode.load(std::memory_order_relaxed)) ||
|
||||
(mouse_menu_enabled() && state.menu_mouse_mode.load(std::memory_order_relaxed)))
|
||||
SetCursor(LoadCursorW(nullptr, MAKEINTRESOURCEW(32512)));
|
||||
else
|
||||
SetCursor(nullptr);
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
@@ -739,10 +983,17 @@ HostInputState display_window_input() {
|
||||
const std::int32_t wheel = state.wheel.exchange(0, std::memory_order_relaxed);
|
||||
if (!state.focused.load(std::memory_order_relaxed)) return publish();
|
||||
|
||||
const bool menu_mode = state.system_utility_mode.load(std::memory_order_relaxed) ||
|
||||
state.guest_frontend_active.load(std::memory_order_relaxed);
|
||||
|
||||
for (const KeyBinding &binding : kKeyBindings)
|
||||
if (key_down(binding.virtual_key)) input.buttons |= binding.psp_button;
|
||||
for (const KeyBinding &binding : kMouseBindings)
|
||||
if (key_down(binding.virtual_key)) input.buttons |= binding.psp_button;
|
||||
// While the pause/frontend cursor is active, mouse clicks belong to the
|
||||
// menu and must never leak through as punch/fire/aim/look-behind.
|
||||
if (!menu_mode) {
|
||||
for (const KeyBinding &binding : kMouseBindings)
|
||||
if (key_down(binding.virtual_key)) input.buttons |= binding.psp_button;
|
||||
}
|
||||
|
||||
// Driving and walking want opposite things from the same keys, and the
|
||||
// guest tells us which one is happening: only vehicle code reads the
|
||||
@@ -752,12 +1003,14 @@ HostInputState display_window_input() {
|
||||
|
||||
int move_x = 0;
|
||||
int move_y = 0;
|
||||
if (key_down(kMoveLeft)) move_x -= 1;
|
||||
if (key_down(kMoveRight)) move_x += 1;
|
||||
if (!driving) {
|
||||
if (!menu_mode) {
|
||||
if (key_down(kMoveLeft)) move_x -= 1;
|
||||
if (key_down(kMoveRight)) move_x += 1;
|
||||
}
|
||||
if (!menu_mode && !driving) {
|
||||
if (key_down(kMoveForward)) move_y -= 1;
|
||||
if (key_down(kMoveBack)) move_y += 1;
|
||||
} else {
|
||||
} else if (!menu_mode) {
|
||||
// In a vehicle the stick's Y axis is lean, not throttle, so W and S
|
||||
// must keep out of it -- feeding it made the bike wheelie every time
|
||||
// the player accelerated. San Andreas leans with the arrow keys, and
|
||||
@@ -767,8 +1020,8 @@ HostInputState display_window_input() {
|
||||
}
|
||||
// W and S drive whatever the context: the accessors they reach are the
|
||||
// vehicle's own, so on foot the guest never asks and nothing happens.
|
||||
input.accelerate = key_down(kMoveForward);
|
||||
input.brake = key_down(kMoveBack);
|
||||
input.accelerate = !menu_mode && key_down(kMoveForward);
|
||||
input.brake = !menu_mode && key_down(kMoveBack);
|
||||
// Left Alt is San Andreas' walk modifier: half deflection instead of full.
|
||||
const int reach = key_down(VK_LMENU) ? 60 : 127;
|
||||
input.analog_x = static_cast<std::uint8_t>(std::clamp(128 + move_x * reach, 0, 255));
|
||||
@@ -826,11 +1079,13 @@ HostInputState display_window_input() {
|
||||
const double magnitude = 127.0 * scaled / (scaled + 12.0);
|
||||
return static_cast<int>(std::lround(delta < 0 ? -magnitude : magnitude));
|
||||
};
|
||||
input.camera_x = camera_response(mouse_dx);
|
||||
// Negated: raw mouse Y grows downwards, and the axis the game reads treats
|
||||
// positive as looking up. Pushing the mouse forward has to raise the view.
|
||||
input.camera_y = camera_response(-mouse_dy);
|
||||
if (controls.invert_camera_y) input.camera_y = -input.camera_y;
|
||||
if (!menu_mode) {
|
||||
input.camera_x = camera_response(mouse_dx);
|
||||
// Negated: raw mouse Y grows downwards, and the axis the game reads treats
|
||||
// positive as looking up. Pushing the mouse forward has to raise the view.
|
||||
input.camera_y = camera_response(-mouse_dy);
|
||||
if (controls.invert_camera_y) input.camera_y = -input.camera_y;
|
||||
}
|
||||
|
||||
if (const PfnXInputGetState get_state = xinput_get_state()) {
|
||||
XInputStatePacket pad{};
|
||||
@@ -847,6 +1102,8 @@ HostInputState display_window_input() {
|
||||
if (b & kPadRightShoulder) input.buttons |= kPspRTrigger;
|
||||
if (b & kPadStart) input.buttons |= kPspStart;
|
||||
if (b & kPadBack) input.buttons |= kPspSelect;
|
||||
// Start is only PSP input. Do not use it to guess whether a pause
|
||||
// menu opened; VCS' real frontend-active flag owns cursor state.
|
||||
if (b & kPadDpadUp) input.buttons |= kPspUp;
|
||||
if (b & kPadDpadDown) input.buttons |= kPspDown;
|
||||
if (b & kPadDpadLeft) input.buttons |= kPspLeft;
|
||||
@@ -870,8 +1127,8 @@ HostInputState display_window_input() {
|
||||
// accelerates in a car and still aims out of one, and it needs no
|
||||
// help from ModernControlScheme -- that option is about which pad
|
||||
// button the game itself reads, which is a different question.
|
||||
if (pad.gamepad.right_trigger > 64u) input.accelerate = true;
|
||||
if (pad.gamepad.left_trigger > 64u) input.brake = true;
|
||||
if (!menu_mode && pad.gamepad.right_trigger > 64u) input.accelerate = true;
|
||||
if (!menu_mode && pad.gamepad.left_trigger > 64u) input.brake = true;
|
||||
|
||||
const std::uint8_t pad_x = stick_to_psp(pad.gamepad.lx, false);
|
||||
// PSP Y grows downwards, the stick's grows upwards.
|
||||
@@ -885,15 +1142,169 @@ HostInputState display_window_input() {
|
||||
const int camera_x = stick_to_psp(pad.gamepad.rx, false) - 128;
|
||||
int camera_y = stick_to_psp(pad.gamepad.ry, false) - 128;
|
||||
if (controls.invert_camera_y) camera_y = -camera_y;
|
||||
if (camera_x != 0 || camera_y != 0) {
|
||||
if (!menu_mode && (camera_x != 0 || camera_y != 0)) {
|
||||
input.camera_x = std::clamp(camera_x, -127, 127);
|
||||
input.camera_y = std::clamp(camera_y, -127, 127);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial native-frontend boot lock. This code is reached only AFTER the
|
||||
// guest has opened its real pause frontend. Intro movies are never locked.
|
||||
// While the two synthetic R edges select GAME, all physical input is
|
||||
// neutral. Even after those edges finish, physical input remains neutral
|
||||
// until every button has been released for two controller polls. This
|
||||
// specifically prevents a held/repeated Space/Cross used to skip the last
|
||||
// intro from becoming a fresh Cross edge on LOAD GAME.
|
||||
static bool boot_cross_was_down = false;
|
||||
const bool boot_locked = native_boot_locked(state);
|
||||
const bool boot_ready = native_boot_ready(state);
|
||||
const std::uint32_t physical_buttons = input.buttons;
|
||||
const bool physical_cross_down = (physical_buttons & kPspCross) != 0u;
|
||||
if (boot_locked) {
|
||||
bool release_ready = state.native_boot_release_ready.load(std::memory_order_relaxed);
|
||||
if (boot_ready && !release_ready) {
|
||||
if (physical_buttons == 0u) {
|
||||
const std::uint32_t neutral =
|
||||
state.native_boot_release_neutral_polls.fetch_add(1u, std::memory_order_relaxed) + 1u;
|
||||
if (neutral >= 2u) {
|
||||
state.native_boot_release_ready.store(true, std::memory_order_relaxed);
|
||||
release_ready = true;
|
||||
boot_cross_was_down = false;
|
||||
}
|
||||
} else {
|
||||
state.native_boot_release_neutral_polls.store(0u, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!boot_ready || !release_ready) {
|
||||
input.buttons = 0u;
|
||||
input.analog_x = 128u;
|
||||
input.analog_y = 128u;
|
||||
input.camera_x = 0;
|
||||
input.camera_y = 0;
|
||||
input.accelerate = false;
|
||||
input.brake = false;
|
||||
} else {
|
||||
if (physical_cross_down && !boot_cross_was_down)
|
||||
commit_native_boot_action(state);
|
||||
input.buttons &= ~(kPspCircle | kPspStart | kPspSelect |
|
||||
kPspLTrigger | kPspRTrigger);
|
||||
boot_cross_was_down = physical_cross_down;
|
||||
}
|
||||
} else {
|
||||
boot_cross_was_down = physical_cross_down;
|
||||
}
|
||||
|
||||
// One queued synthetic value is consumed per controller sample. The queue
|
||||
// contains explicit neutral polls between presses so the guest sees proper
|
||||
// PSP button edges. Synthetic front-end navigation is ORed last and cannot
|
||||
// be lost to physical input mapping above.
|
||||
input.buttons |= dequeue_synthetic_buttons(state);
|
||||
return publish();
|
||||
}
|
||||
|
||||
void display_window_arm_native_boot_menu(bool armed) noexcept {
|
||||
WindowState &state = window_state();
|
||||
state.native_boot_armed.store(armed, std::memory_order_relaxed);
|
||||
state.native_boot_active.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_game_tab_queued.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_game_tab_ready.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_user_committed.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_release_ready.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_release_neutral_polls.store(0u, std::memory_order_relaxed);
|
||||
// Arming is passive. Do not lock any physical input during logos/FMVs or
|
||||
// ordinary startup. The lock begins only after the guest's real
|
||||
// menu-active flag is observed in display_window_notify_native_boot_menu_active().
|
||||
state.native_boot_lock.store(false, std::memory_order_relaxed);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
state.guest_frontend_active.store(false, std::memory_order_relaxed);
|
||||
state.menu_mouse_mode.store(false, std::memory_order_relaxed);
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
state.synthetic_buttons.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void display_window_notify_native_boot_menu_active() noexcept {
|
||||
WindowState &state = window_state();
|
||||
if (!state.native_boot_armed.load(std::memory_order_relaxed)) return;
|
||||
bool expected = false;
|
||||
if (!state.native_boot_active.compare_exchange_strong(
|
||||
expected, true, std::memory_order_relaxed))
|
||||
return;
|
||||
|
||||
state.native_boot_game_tab_queued.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_game_tab_ready.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_user_committed.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_release_ready.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_release_neutral_polls.store(0u, std::memory_order_relaxed);
|
||||
state.native_boot_lock.store(true, std::memory_order_relaxed);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
refresh_menu_mouse_mode(state);
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
state.synthetic_buttons.clear();
|
||||
}
|
||||
enqueue_native_boot_game_tab(state);
|
||||
if (HWND hwnd = state.window.load(std::memory_order_relaxed))
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
|
||||
void display_window_notify_native_boot_menu_closed() noexcept {
|
||||
WindowState &state = window_state();
|
||||
state.native_boot_active.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_armed.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_game_tab_queued.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_game_tab_ready.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_release_ready.store(false, std::memory_order_relaxed);
|
||||
state.native_boot_release_neutral_polls.store(0u, std::memory_order_relaxed);
|
||||
state.native_boot_lock.store(false, std::memory_order_relaxed);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
refresh_menu_mouse_mode(state);
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state.synthetic_mutex);
|
||||
state.synthetic_buttons.clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool display_window_native_boot_user_committed() noexcept {
|
||||
return window_state().native_boot_user_committed.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void display_window_set_guest_frontend_active(bool active) noexcept {
|
||||
WindowState &state = window_state();
|
||||
const bool previous = state.guest_frontend_active.exchange(active, std::memory_order_relaxed);
|
||||
if (previous == active) return;
|
||||
|
||||
// Menu transitions are authoritative. Clear stale mouse-navigation pulses
|
||||
// and stale raw deltas so neither can leak across the pause boundary.
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
state.mouse_dx.store(0, std::memory_order_relaxed);
|
||||
state.mouse_dy.store(0, std::memory_order_relaxed);
|
||||
state.wheel.store(0, std::memory_order_relaxed);
|
||||
if (!active) clear_synthetic_buttons(state);
|
||||
refresh_menu_mouse_mode(state);
|
||||
}
|
||||
|
||||
void display_window_set_system_utility_mode(bool active) noexcept {
|
||||
WindowState &state = window_state();
|
||||
state.system_utility_mode.store(active, std::memory_order_relaxed);
|
||||
state.mouse_dx.store(0, std::memory_order_relaxed);
|
||||
state.mouse_dy.store(0, std::memory_order_relaxed);
|
||||
state.wheel.store(0, std::memory_order_relaxed);
|
||||
state.last_hover_row.store(-1, std::memory_order_relaxed);
|
||||
if (active) {
|
||||
// The click that opened Load/Save has already been consumed by the
|
||||
// guest frontend. Do not let any remaining frontend navigation pulse
|
||||
// leak into the firmware utility as an accidental confirmation.
|
||||
clear_synthetic_buttons(state);
|
||||
}
|
||||
refresh_menu_mouse_mode(state);
|
||||
if (HWND hwnd = state.window.load(std::memory_order_relaxed))
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
|
||||
bool display_window_close_requested() {
|
||||
if (!display_window_enabled()) return false;
|
||||
return window_state().close_requested.load(std::memory_order_relaxed);
|
||||
@@ -939,6 +1350,12 @@ DisplayWindowSurface display_window_surface() { return {}; }
|
||||
std::uint32_t display_window_buttons() { return 0u; }
|
||||
void display_window_analog(std::uint8_t &x, std::uint8_t &y) { x = 128u; y = 128u; }
|
||||
HostInputState display_window_input() { return {}; }
|
||||
void display_window_arm_native_boot_menu(bool) noexcept {}
|
||||
void display_window_notify_native_boot_menu_active() noexcept {}
|
||||
void display_window_notify_native_boot_menu_closed() noexcept {}
|
||||
void display_window_set_guest_frontend_active(bool) noexcept {}
|
||||
bool display_window_native_boot_user_committed() noexcept { return false; }
|
||||
void display_window_set_system_utility_mode(bool) noexcept {}
|
||||
bool display_window_close_requested() { return false; }
|
||||
void display_window_shutdown() {}
|
||||
|
||||
|
||||
@@ -78,6 +78,24 @@ struct HostInputState {
|
||||
};
|
||||
[[nodiscard]] HostInputState display_window_input();
|
||||
|
||||
// Native VCS first-boot frontend integration. These functions do not draw a
|
||||
// replacement menu. The boot hook uses VCS' original guest pause-request path
|
||||
// only after TITLES.PMF has ended, then R edges select the real GAME tab.
|
||||
void display_window_arm_native_boot_menu(bool armed) noexcept;
|
||||
void display_window_notify_native_boot_menu_active() noexcept;
|
||||
void display_window_notify_native_boot_menu_closed() noexcept;
|
||||
[[nodiscard]] bool display_window_native_boot_user_committed() noexcept;
|
||||
|
||||
// Mirrors the actual native VCS frontend-active state observed from guest RAM.
|
||||
// MouseMenu=true only affects cursor/click translation while this is true;
|
||||
// MouseMenu=false leaves the mouse completely out of the pause menu.
|
||||
void display_window_set_guest_frontend_active(bool active) noexcept;
|
||||
|
||||
// PSP firmware utility ownership (savedata, message dialogs, etc.). This only
|
||||
// changes desktop input/cursor routing: the utility pixels are rendered into
|
||||
// the PSP framebuffer by the HLE renderer, never by a host window.
|
||||
void display_window_set_system_utility_mode(bool active) noexcept;
|
||||
|
||||
// True once the user closed the window or pressed Escape.
|
||||
[[nodiscard]] bool display_window_close_requested();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "vcs_config.hpp"
|
||||
#include "vcs_project2dfx.hpp"
|
||||
#include "vcs_fps_overlay.hpp"
|
||||
#include "savedata_utility_ui.hpp"
|
||||
|
||||
#include "psprecomp/common.hpp"
|
||||
|
||||
@@ -4213,8 +4214,10 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory,
|
||||
cloud_viewport, cloud_camera_position,
|
||||
gpu_draw, count);
|
||||
}
|
||||
if (!gpu_draw.clear_mode && primitive >= 3u && primitive <= 6u)
|
||||
if (!gpu_draw.clear_mode && primitive >= 3u && primitive <= 6u) {
|
||||
fps_overlay_observe_draw(gpu_draw, count);
|
||||
savedata_utility_ui_observe_draw(gpu_draw, count);
|
||||
}
|
||||
if (!gpu_draw.through && !gpu_draw.clear_mode &&
|
||||
primitive >= 3u && primitive <= 5u &&
|
||||
!project2dfx_observe_camera_hot(gpu_draw, count, camera_state_revision)) {
|
||||
|
||||
@@ -296,6 +296,7 @@ int main(int argc, char **argv) {
|
||||
const std::uint64_t max_dispatches = configured_max_dispatches();
|
||||
std::cout << "Dispatch cap: " << max_dispatches << "\n";
|
||||
vcs::display_window_start();
|
||||
|
||||
vcs::install_display_heartbeat();
|
||||
vcs::install_starvation_preemption();
|
||||
runtime.run(elf.runtime_entry(), max_dispatches);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// V9: retired. First boot is load-only and no native GAME frontend hook is linked.
|
||||
@@ -0,0 +1 @@
|
||||
// V9: retired. First boot is load-only and no native GAME frontend hook is linked.
|
||||
@@ -1,549 +1,6 @@
|
||||
#include "savedata_dialog.hpp"
|
||||
#include "display_window.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace vcs {
|
||||
namespace {
|
||||
|
||||
SavedataDialogChoice deterministic_choice(const std::vector<SavedataSlotEntry> &slots,
|
||||
bool saving,
|
||||
std::string_view current) {
|
||||
if (!current.empty()) {
|
||||
const auto found = std::find_if(slots.begin(), slots.end(), [&](const auto &slot) {
|
||||
return slot.save_name == current && (saving || slot.exists);
|
||||
});
|
||||
if (found != slots.end()) return {true, found->save_name};
|
||||
}
|
||||
const auto found = std::find_if(slots.begin(), slots.end(), [&](const auto &slot) {
|
||||
return saving || slot.exists;
|
||||
});
|
||||
if (found != slots.end()) return {true, found->save_name};
|
||||
return {};
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::wstring utf8_to_wide(std::string_view text) {
|
||||
if (text.empty()) return {};
|
||||
const int needed = MultiByteToWideChar(CP_UTF8, 0, text.data(),
|
||||
static_cast<int>(text.size()), nullptr, 0);
|
||||
if (needed <= 0) return std::wstring(text.begin(), text.end());
|
||||
std::wstring result(static_cast<std::size_t>(needed), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast<int>(text.size()),
|
||||
result.data(), needed);
|
||||
return result;
|
||||
}
|
||||
|
||||
constexpr int kListId = 1001;
|
||||
constexpr int kAcceptId = 1002;
|
||||
constexpr int kCancelId = 1003;
|
||||
|
||||
constexpr COLORREF kBgTop = RGB(139, 121, 195);
|
||||
constexpr COLORREF kBgBottom = RGB(125, 163, 218);
|
||||
constexpr COLORREF kHorizon = RGB(82, 123, 187);
|
||||
constexpr COLORREF kPanelFill = RGB(16, 28, 47);
|
||||
constexpr COLORREF kPanelBorder = RGB(220, 228, 244);
|
||||
constexpr COLORREF kListFill = RGB(238, 244, 251);
|
||||
constexpr COLORREF kListEmptyText = RGB(70, 80, 104);
|
||||
constexpr COLORREF kListSavedText = RGB(20, 41, 76);
|
||||
constexpr COLORREF kSelection = RGB(44, 126, 218);
|
||||
constexpr COLORREF kButtonPrimary = RGB(255, 165, 77);
|
||||
constexpr COLORREF kButtonPrimaryHover = RGB(255, 183, 101);
|
||||
constexpr COLORREF kButtonSecondary = RGB(226, 231, 241);
|
||||
constexpr COLORREF kButtonSecondaryHover = RGB(240, 244, 250);
|
||||
constexpr COLORREF kButtonTextDark = RGB(26, 33, 53);
|
||||
constexpr COLORREF kButtonTextLight = RGB(255, 255, 255);
|
||||
constexpr COLORREF kWhite = RGB(255, 255, 255);
|
||||
constexpr COLORREF kShadow = RGB(28, 33, 56);
|
||||
|
||||
struct DialogState {
|
||||
const std::vector<SavedataSlotEntry> *slots{};
|
||||
bool saving{};
|
||||
int selected{-1};
|
||||
bool done{};
|
||||
bool confirmed{};
|
||||
HWND list{};
|
||||
HWND accept_button{};
|
||||
HWND cancel_button{};
|
||||
HFONT title_font{};
|
||||
HFONT subtitle_font{};
|
||||
HFONT list_font{};
|
||||
HFONT button_font{};
|
||||
HBRUSH list_brush{};
|
||||
HBRUSH dialog_brush{};
|
||||
};
|
||||
|
||||
std::wstring slot_label(const SavedataSlotEntry &slot, std::size_t index) {
|
||||
std::wstring line = L"SLOT " + std::to_wstring(index + 1u) + L" " + utf8_to_wide(slot.save_name);
|
||||
line += slot.exists ? L" SAVED" : L" EMPTY";
|
||||
return line;
|
||||
}
|
||||
|
||||
void create_dialog_fonts(DialogState &state) {
|
||||
state.title_font = CreateFontW(-26, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
VARIABLE_PITCH, L"Trebuchet MS");
|
||||
state.subtitle_font = CreateFontW(-16, 0, 0, 0, FW_SEMIBOLD, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
VARIABLE_PITCH, L"Segoe UI");
|
||||
state.list_font = CreateFontW(-18, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
VARIABLE_PITCH, L"Trebuchet MS");
|
||||
state.button_font = CreateFontW(-17, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
VARIABLE_PITCH, L"Segoe UI");
|
||||
}
|
||||
|
||||
void destroy_dialog_resources(DialogState &state) {
|
||||
if (state.title_font) DeleteObject(state.title_font);
|
||||
if (state.subtitle_font) DeleteObject(state.subtitle_font);
|
||||
if (state.list_font) DeleteObject(state.list_font);
|
||||
if (state.button_font) DeleteObject(state.button_font);
|
||||
if (state.list_brush) DeleteObject(state.list_brush);
|
||||
if (state.dialog_brush) DeleteObject(state.dialog_brush);
|
||||
state.title_font = nullptr;
|
||||
state.subtitle_font = nullptr;
|
||||
state.list_font = nullptr;
|
||||
state.button_font = nullptr;
|
||||
state.list_brush = nullptr;
|
||||
state.dialog_brush = nullptr;
|
||||
}
|
||||
|
||||
COLORREF lerp_color(COLORREF a, COLORREF b, float t) {
|
||||
const auto mix = [&](int x, int y) {
|
||||
return static_cast<int>(static_cast<float>(x) + (static_cast<float>(y - x) * t));
|
||||
};
|
||||
return RGB(mix(GetRValue(a), GetRValue(b)),
|
||||
mix(GetGValue(a), GetGValue(b)),
|
||||
mix(GetBValue(a), GetBValue(b)));
|
||||
}
|
||||
|
||||
void fill_vertical_gradient(HDC hdc, const RECT &rect, COLORREF top, COLORREF bottom) {
|
||||
const int raw_height = rect.bottom - rect.top;
|
||||
const int height = raw_height > 1 ? raw_height : 1;
|
||||
for (int y = 0; y < height; ++y) {
|
||||
const float t = static_cast<float>(y) / static_cast<float>(height - 1 <= 0 ? 1 : height - 1);
|
||||
const HBRUSH brush = CreateSolidBrush(lerp_color(top, bottom, t));
|
||||
RECT band{rect.left, rect.top + y, rect.right, rect.top + y + 1};
|
||||
FillRect(hdc, &band, brush);
|
||||
DeleteObject(brush);
|
||||
}
|
||||
}
|
||||
|
||||
void draw_skyline(HDC hdc, const RECT &client) {
|
||||
const int width = client.right - client.left;
|
||||
const int height = client.bottom - client.top;
|
||||
const int horizon_y = client.top + static_cast<int>(height * 0.67f);
|
||||
|
||||
RECT horizon{client.left, horizon_y, client.right, client.bottom};
|
||||
fill_vertical_gradient(hdc, horizon, RGB(64, 111, 183), RGB(106, 174, 225));
|
||||
|
||||
HPEN line_pen = CreatePen(PS_SOLID, 1, RGB(91, 143, 202));
|
||||
HPEN old_pen = static_cast<HPEN>(SelectObject(hdc, line_pen));
|
||||
MoveToEx(hdc, client.left, horizon_y, nullptr);
|
||||
LineTo(hdc, client.right, horizon_y);
|
||||
SelectObject(hdc, old_pen);
|
||||
DeleteObject(line_pen);
|
||||
|
||||
const RECT water_reflection{client.left, horizon_y + 8, client.right, client.bottom};
|
||||
for (int y = water_reflection.top; y < water_reflection.bottom; y += 4) {
|
||||
const int alpha_band = (y - water_reflection.top) / 4;
|
||||
const COLORREF c = alpha_band % 2 == 0 ? RGB(105, 165, 215) : RGB(95, 152, 205);
|
||||
HBRUSH brush = CreateSolidBrush(c);
|
||||
const int band_bottom = (y + 2) < water_reflection.bottom ? (y + 2) : water_reflection.bottom;
|
||||
RECT band{water_reflection.left, y, water_reflection.right, band_bottom};
|
||||
FillRect(hdc, &band, brush);
|
||||
DeleteObject(brush);
|
||||
}
|
||||
|
||||
HBRUSH skyline_brush = CreateSolidBrush(kHorizon);
|
||||
HBRUSH old_brush = static_cast<HBRUSH>(SelectObject(hdc, skyline_brush));
|
||||
HPEN skyline_pen = CreatePen(PS_SOLID, 1, kHorizon);
|
||||
old_pen = static_cast<HPEN>(SelectObject(hdc, skyline_pen));
|
||||
|
||||
auto tower = [&](int x, int w, int h) {
|
||||
Rectangle(hdc, x, horizon_y - h, x + w, horizon_y);
|
||||
};
|
||||
tower(client.left + width * 6 / 100, width * 4 / 100, height * 9 / 100);
|
||||
tower(client.left + width * 13 / 100, width * 6 / 100, height * 7 / 100);
|
||||
tower(client.left + width * 24 / 100, width * 5 / 100, height * 10 / 100);
|
||||
tower(client.left + width * 32 / 100, width * 8 / 100, height * 11 / 100);
|
||||
tower(client.left + width * 44 / 100, width * 5 / 100, height * 8 / 100);
|
||||
tower(client.left + width * 53 / 100, width * 4 / 100, height * 6 / 100);
|
||||
tower(client.left + width * 66 / 100, width * 6 / 100, height * 13 / 100);
|
||||
tower(client.left + width * 79 / 100, width * 5 / 100, height * 8 / 100);
|
||||
tower(client.left + width * 87 / 100, width * 4 / 100, height * 18 / 100);
|
||||
tower(client.left + width * 91 / 100, width * 3 / 100, height * 12 / 100);
|
||||
|
||||
auto palm = [&](int x, int trunk_h, int lean) {
|
||||
MoveToEx(hdc, x, horizon_y, nullptr);
|
||||
LineTo(hdc, x + lean, horizon_y - trunk_h);
|
||||
const int top_x = x + lean;
|
||||
const int top_y = horizon_y - trunk_h;
|
||||
MoveToEx(hdc, top_x, top_y, nullptr);
|
||||
LineTo(hdc, top_x - 16, top_y - 7);
|
||||
MoveToEx(hdc, top_x, top_y, nullptr);
|
||||
LineTo(hdc, top_x + 17, top_y - 5);
|
||||
MoveToEx(hdc, top_x, top_y, nullptr);
|
||||
LineTo(hdc, top_x - 14, top_y + 6);
|
||||
MoveToEx(hdc, top_x, top_y, nullptr);
|
||||
LineTo(hdc, top_x + 13, top_y + 5);
|
||||
};
|
||||
palm(client.left + width * 12 / 100, height * 10 / 100, -8);
|
||||
palm(client.left + width * 20 / 100, height * 12 / 100, 6);
|
||||
palm(client.left + width * 48 / 100, height * 10 / 100, -4);
|
||||
palm(client.left + width * 59 / 100, height * 13 / 100, 8);
|
||||
|
||||
SelectObject(hdc, old_pen);
|
||||
SelectObject(hdc, old_brush);
|
||||
DeleteObject(skyline_pen);
|
||||
DeleteObject(skyline_brush);
|
||||
}
|
||||
|
||||
void paint_background(HDC hdc, HWND window, const DialogState &state) {
|
||||
RECT client{};
|
||||
GetClientRect(window, &client);
|
||||
fill_vertical_gradient(hdc, client, kBgTop, kBgBottom);
|
||||
draw_skyline(hdc, client);
|
||||
|
||||
RECT shadow{58, 48, client.right - 54, client.bottom - 44};
|
||||
OffsetRect(&shadow, 4, 5);
|
||||
HBRUSH shadow_brush = CreateSolidBrush(kShadow);
|
||||
FillRect(hdc, &shadow, shadow_brush);
|
||||
DeleteObject(shadow_brush);
|
||||
|
||||
RECT panel{56, 44, client.right - 56, client.bottom - 48};
|
||||
HBRUSH panel_brush = CreateSolidBrush(kPanelFill);
|
||||
FillRect(hdc, &panel, panel_brush);
|
||||
DeleteObject(panel_brush);
|
||||
|
||||
HPEN border_pen = CreatePen(PS_SOLID, 2, kPanelBorder);
|
||||
HPEN old_pen = static_cast<HPEN>(SelectObject(hdc, border_pen));
|
||||
HBRUSH hollow = static_cast<HBRUSH>(GetStockObject(HOLLOW_BRUSH));
|
||||
HBRUSH old_brush = static_cast<HBRUSH>(SelectObject(hdc, hollow));
|
||||
Rectangle(hdc, panel.left, panel.top, panel.right, panel.bottom);
|
||||
SelectObject(hdc, old_pen);
|
||||
SelectObject(hdc, old_brush);
|
||||
DeleteObject(border_pen);
|
||||
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
|
||||
RECT title{86, 62, client.right - 86, 96};
|
||||
HFONT old_font = static_cast<HFONT>(SelectObject(hdc, state.title_font));
|
||||
SetTextColor(hdc, kWhite);
|
||||
DrawTextW(hdc, state.saving ? L"SAVE GAME" : L"LOAD GAME", -1, &title,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
RECT subtitle{88, 100, client.right - 88, 126};
|
||||
SelectObject(hdc, state.subtitle_font);
|
||||
SetTextColor(hdc, RGB(232, 237, 245));
|
||||
DrawTextW(hdc,
|
||||
state.saving ? L"Choose a slot to save your progress." : L"Choose a saved game to continue.",
|
||||
-1, &subtitle, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
RECT tag{client.right - 230, 70, client.right - 88, 98};
|
||||
SetTextColor(hdc, RGB(255, 196, 125));
|
||||
DrawTextW(hdc, L"VICE CITY STORIES", -1, &tag,
|
||||
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
RECT hint{88, client.bottom - 92, client.right - 88, client.bottom - 72};
|
||||
SetTextColor(hdc, RGB(220, 228, 244));
|
||||
DrawTextW(hdc, L"Double-click a slot or press Confirm.", -1, &hint,
|
||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
SelectObject(hdc, old_font);
|
||||
}
|
||||
|
||||
void accept_selection(HWND window, DialogState &state) {
|
||||
const LRESULT selected = SendMessageW(state.list, LB_GETCURSEL, 0, 0);
|
||||
if (selected == LB_ERR) return;
|
||||
const int index = static_cast<int>(selected);
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= state.slots->size()) return;
|
||||
if (!state.saving && !(*state.slots)[static_cast<std::size_t>(index)].exists) return;
|
||||
state.selected = index;
|
||||
state.confirmed = true;
|
||||
state.done = true;
|
||||
DestroyWindow(window);
|
||||
}
|
||||
|
||||
void draw_button(const DRAWITEMSTRUCT &dis, bool primary) {
|
||||
HDC hdc = dis.hDC;
|
||||
RECT rc = dis.rcItem;
|
||||
const bool pressed = (dis.itemState & ODS_SELECTED) != 0;
|
||||
const bool disabled = (dis.itemState & ODS_DISABLED) != 0;
|
||||
const COLORREF fill = primary
|
||||
? (pressed ? kButtonPrimaryHover : kButtonPrimary)
|
||||
: (pressed ? kButtonSecondaryHover : kButtonSecondary);
|
||||
const COLORREF text = primary ? kButtonTextDark : kButtonTextDark;
|
||||
|
||||
HBRUSH brush = CreateSolidBrush(fill);
|
||||
HPEN pen = CreatePen(PS_SOLID, 1, primary ? RGB(255, 212, 166) : RGB(212, 220, 234));
|
||||
HBRUSH old_brush = static_cast<HBRUSH>(SelectObject(hdc, brush));
|
||||
HPEN old_pen = static_cast<HPEN>(SelectObject(hdc, pen));
|
||||
RoundRect(hdc, rc.left, rc.top, rc.right, rc.bottom, 10, 10);
|
||||
SelectObject(hdc, old_brush);
|
||||
SelectObject(hdc, old_pen);
|
||||
DeleteObject(brush);
|
||||
DeleteObject(pen);
|
||||
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
SetTextColor(hdc, disabled ? RGB(132, 138, 151) : text);
|
||||
wchar_t buffer[64]{};
|
||||
GetWindowTextW(dis.hwndItem, buffer, 64);
|
||||
DrawTextW(hdc, buffer, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
if (dis.itemState & ODS_FOCUS) {
|
||||
RECT focus = rc;
|
||||
InflateRect(&focus, -4, -4);
|
||||
DrawFocusRect(hdc, &focus);
|
||||
}
|
||||
}
|
||||
|
||||
void draw_list_item(const DRAWITEMSTRUCT &dis, const DialogState &state) {
|
||||
if (dis.itemID == static_cast<unsigned int>(-1) || !state.slots) return;
|
||||
const auto &slot = (*state.slots)[dis.itemID];
|
||||
const bool selected = (dis.itemState & ODS_SELECTED) != 0;
|
||||
const bool exists = slot.exists;
|
||||
|
||||
COLORREF back = exists ? kListFill : RGB(244, 246, 251);
|
||||
COLORREF fore = exists ? kListSavedText : kListEmptyText;
|
||||
if (selected) {
|
||||
back = kSelection;
|
||||
fore = kWhite;
|
||||
}
|
||||
|
||||
HBRUSH back_brush = CreateSolidBrush(back);
|
||||
FillRect(dis.hDC, &dis.rcItem, back_brush);
|
||||
DeleteObject(back_brush);
|
||||
|
||||
RECT text_rc = dis.rcItem;
|
||||
InflateRect(&text_rc, -12, 0);
|
||||
SetBkMode(dis.hDC, TRANSPARENT);
|
||||
SetTextColor(dis.hDC, fore);
|
||||
std::wstring line = slot_label(slot, dis.itemID);
|
||||
DrawTextW(dis.hDC, line.c_str(), -1, &text_rc, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
if (dis.itemState & ODS_FOCUS) {
|
||||
RECT focus = dis.rcItem;
|
||||
InflateRect(&focus, -2, -2);
|
||||
DrawFocusRect(dis.hDC, &focus);
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK savedata_window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
DialogState *state = reinterpret_cast<DialogState *>(GetWindowLongPtrW(window, GWLP_USERDATA));
|
||||
if (message == WM_NCCREATE) {
|
||||
const auto *create = reinterpret_cast<const CREATESTRUCTW *>(lparam);
|
||||
state = static_cast<DialogState *>(create->lpCreateParams);
|
||||
SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(state));
|
||||
}
|
||||
|
||||
switch (message) {
|
||||
case WM_CREATE: {
|
||||
if (!state) return -1;
|
||||
create_dialog_fonts(*state);
|
||||
state->list_brush = CreateSolidBrush(kListFill);
|
||||
state->dialog_brush = CreateSolidBrush(kPanelFill);
|
||||
|
||||
state->list = CreateWindowExW(0, L"LISTBOX", L"",
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_NOINTEGRALHEIGHT,
|
||||
86, 136, 588, 240, window,
|
||||
reinterpret_cast<HMENU>(static_cast<INT_PTR>(kListId)), nullptr, nullptr);
|
||||
if (!state->list) return -1;
|
||||
SendMessageW(state->list, WM_SETFONT, reinterpret_cast<WPARAM>(state->list_font), TRUE);
|
||||
for (std::size_t i = 0; i < state->slots->size(); ++i) {
|
||||
std::wstring line = slot_label((*state->slots)[i], i);
|
||||
SendMessageW(state->list, LB_ADDSTRING, 0, reinterpret_cast<LPARAM>(line.c_str()));
|
||||
}
|
||||
|
||||
state->accept_button = CreateWindowExW(0, L"BUTTON", state->saving ? L"CONFIRM" : L"LOAD",
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_OWNERDRAW,
|
||||
468, 404, 98, 34, window,
|
||||
reinterpret_cast<HMENU>(static_cast<INT_PTR>(kAcceptId)), nullptr, nullptr);
|
||||
state->cancel_button = CreateWindowExW(0, L"BUTTON", L"CANCEL",
|
||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_OWNERDRAW,
|
||||
576, 404, 98, 34, window,
|
||||
reinterpret_cast<HMENU>(static_cast<INT_PTR>(kCancelId)), nullptr, nullptr);
|
||||
SendMessageW(state->accept_button, WM_SETFONT, reinterpret_cast<WPARAM>(state->button_font), TRUE);
|
||||
SendMessageW(state->cancel_button, WM_SETFONT, reinterpret_cast<WPARAM>(state->button_font), TRUE);
|
||||
return 0;
|
||||
}
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps{};
|
||||
HDC hdc = BeginPaint(window, &ps);
|
||||
if (hdc && state) paint_background(hdc, window, *state);
|
||||
EndPaint(window, &ps);
|
||||
return 0;
|
||||
}
|
||||
case WM_MEASUREITEM: {
|
||||
auto *measure = reinterpret_cast<MEASUREITEMSTRUCT *>(lparam);
|
||||
if (measure && measure->CtlID == kListId) {
|
||||
measure->itemHeight = 28;
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WM_DRAWITEM: {
|
||||
auto *draw = reinterpret_cast<DRAWITEMSTRUCT *>(lparam);
|
||||
if (!draw || !state) break;
|
||||
if (draw->CtlID == kListId) {
|
||||
draw_list_item(*draw, *state);
|
||||
return TRUE;
|
||||
}
|
||||
if (draw->CtlID == kAcceptId) {
|
||||
SelectObject(draw->hDC, state->button_font);
|
||||
draw_button(*draw, true);
|
||||
return TRUE;
|
||||
}
|
||||
if (draw->CtlID == kCancelId) {
|
||||
SelectObject(draw->hDC, state->button_font);
|
||||
draw_button(*draw, false);
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WM_CTLCOLORLISTBOX:
|
||||
if (state && reinterpret_cast<HWND>(lparam) == state->list) {
|
||||
HDC hdc = reinterpret_cast<HDC>(wparam);
|
||||
SetBkColor(hdc, kListFill);
|
||||
SetTextColor(hdc, kListSavedText);
|
||||
return reinterpret_cast<INT_PTR>(state->list_brush);
|
||||
}
|
||||
break;
|
||||
case WM_COMMAND:
|
||||
if (!state) break;
|
||||
if (LOWORD(wparam) == kAcceptId ||
|
||||
(LOWORD(wparam) == kListId && HIWORD(wparam) == LBN_DBLCLK)) {
|
||||
accept_selection(window, *state);
|
||||
return 0;
|
||||
}
|
||||
if (LOWORD(wparam) == kCancelId) {
|
||||
state->done = true;
|
||||
state->confirmed = false;
|
||||
DestroyWindow(window);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KEYDOWN:
|
||||
if (state && wparam == VK_RETURN) {
|
||||
accept_selection(window, *state);
|
||||
return 0;
|
||||
}
|
||||
if (state && wparam == VK_ESCAPE) {
|
||||
state->done = true;
|
||||
state->confirmed = false;
|
||||
DestroyWindow(window);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_CLOSE:
|
||||
if (state) {
|
||||
state->done = true;
|
||||
state->confirmed = false;
|
||||
}
|
||||
DestroyWindow(window);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
if (state) {
|
||||
state->done = true;
|
||||
destroy_dialog_resources(*state);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
SavedataDialogChoice native_choice(const std::vector<SavedataSlotEntry> &slots,
|
||||
bool saving,
|
||||
std::string_view current) {
|
||||
static const wchar_t *kClassName = L"VCSNativeSavedataSlotDialog";
|
||||
static bool registered = false;
|
||||
HINSTANCE instance = GetModuleHandleW(nullptr);
|
||||
if (!registered) {
|
||||
WNDCLASSW cls{};
|
||||
cls.lpfnWndProc = savedata_window_proc;
|
||||
cls.hInstance = instance;
|
||||
cls.hCursor = LoadCursorW(nullptr, MAKEINTRESOURCEW(32512));
|
||||
cls.hbrBackground = reinterpret_cast<HBRUSH>(static_cast<INT_PTR>(COLOR_WINDOW + 1));
|
||||
cls.lpszClassName = kClassName;
|
||||
registered = RegisterClassW(&cls) != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS;
|
||||
}
|
||||
if (!registered) return deterministic_choice(slots, saving, current);
|
||||
|
||||
DialogState state{&slots, saving};
|
||||
HWND owner = static_cast<HWND>(display_window_surface().window);
|
||||
HWND window = CreateWindowExW(WS_EX_DLGMODALFRAME | WS_EX_TOPMOST,
|
||||
kClassName, saving ? L"GTA Vice City Stories - Save Game" : L"GTA Vice City Stories - Load Game",
|
||||
WS_CAPTION | WS_SYSMENU,
|
||||
CW_USEDEFAULT, CW_USEDEFAULT, 760, 500,
|
||||
owner, nullptr, instance, &state);
|
||||
if (!window) return deterministic_choice(slots, saving, current);
|
||||
|
||||
int initial = -1;
|
||||
for (std::size_t i = 0; i < slots.size(); ++i) {
|
||||
if (slots[i].save_name == current && (saving || slots[i].exists)) {
|
||||
initial = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (initial < 0) {
|
||||
for (std::size_t i = 0; i < slots.size(); ++i) {
|
||||
if (saving || slots[i].exists) { initial = static_cast<int>(i); break; }
|
||||
}
|
||||
}
|
||||
if (initial >= 0) {
|
||||
SendMessageW(state.list, LB_SETCURSEL, static_cast<WPARAM>(initial), 0);
|
||||
SetFocus(state.list);
|
||||
}
|
||||
|
||||
RECT rect{};
|
||||
GetWindowRect(window, &rect);
|
||||
const int width = rect.right - rect.left;
|
||||
const int height = rect.bottom - rect.top;
|
||||
const int screen_w = GetSystemMetrics(SM_CXSCREEN);
|
||||
const int screen_h = GetSystemMetrics(SM_CYSCREEN);
|
||||
SetWindowPos(window, HWND_TOPMOST, (screen_w - width) / 2, (screen_h - height) / 2,
|
||||
0, 0, SWP_NOSIZE | SWP_SHOWWINDOW);
|
||||
|
||||
MSG message{};
|
||||
while (!state.done && GetMessageW(&message, nullptr, 0, 0) > 0) {
|
||||
if (!IsDialogMessageW(window, &message)) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
}
|
||||
if (state.confirmed && state.selected >= 0 &&
|
||||
static_cast<std::size_t>(state.selected) < slots.size()) {
|
||||
return {true, slots[static_cast<std::size_t>(state.selected)].save_name};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
SavedataDialogChoice choose_savedata_slot(const std::vector<SavedataSlotEntry> &slots,
|
||||
bool saving,
|
||||
std::string_view current_save_name) {
|
||||
if (slots.empty()) return {};
|
||||
const char *disable = std::getenv("PSPRECOMP_SAVEDATA_NATIVE_DIALOG");
|
||||
const bool native_enabled = disable == nullptr ||
|
||||
(*disable != '\0' && std::string_view(disable) != "0" &&
|
||||
std::string_view(disable) != "false" && std::string_view(disable) != "off");
|
||||
#ifdef _WIN32
|
||||
if (native_enabled) return native_choice(slots, saving, current_save_name);
|
||||
#else
|
||||
(void)native_enabled;
|
||||
#endif
|
||||
return deterministic_choice(slots, saving, current_save_name);
|
||||
}
|
||||
|
||||
} // namespace vcs
|
||||
// Retired on 2026-08-15.
|
||||
//
|
||||
// LISTLOAD/LISTSAVE/LISTDELETE no longer create a Win32/host dialog. The PSP
|
||||
// firmware utility HLE lives in savedata_utility_ui.cpp and renders into the
|
||||
// game's GE framebuffer. This tombstone intentionally contains no UI code so a
|
||||
// hotfix extracted over an older tree also erases the rejected implementation.
|
||||
|
||||
@@ -1,27 +1,2 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace vcs {
|
||||
|
||||
struct SavedataSlotEntry {
|
||||
std::string save_name;
|
||||
bool exists{};
|
||||
};
|
||||
|
||||
struct SavedataDialogChoice {
|
||||
bool confirmed{};
|
||||
std::string save_name;
|
||||
};
|
||||
|
||||
// Native replacement for the PSP LISTLOAD/LISTSAVE system utility screen.
|
||||
// On Windows a small host dialog is shown. On non-Windows/headless validation
|
||||
// the choice is deterministic so automated tests never block on UI.
|
||||
[[nodiscard]] SavedataDialogChoice choose_savedata_slot(
|
||||
const std::vector<SavedataSlotEntry> &slots,
|
||||
bool saving,
|
||||
std::string_view current_save_name);
|
||||
|
||||
} // namespace vcs
|
||||
// Retired compatibility tombstone. See savedata_utility_ui.hpp.
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
#include "savedata_utility_ui.hpp"
|
||||
|
||||
#include "ge_gpu_backend.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/imgutils.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
|
||||
namespace vcs {
|
||||
namespace {
|
||||
|
||||
struct DecodedIcon {
|
||||
std::uint32_t width{};
|
||||
std::uint32_t height{};
|
||||
std::vector<std::byte> rgba;
|
||||
std::uint64_t cache_key{};
|
||||
};
|
||||
|
||||
struct UiState {
|
||||
bool active{};
|
||||
std::uint32_t mode{};
|
||||
std::vector<SavedataSlotEntry> slots;
|
||||
std::vector<DecodedIcon> icons;
|
||||
std::size_t selected{};
|
||||
SavedataUtilityUiPrompt prompt{SavedataUtilityUiPrompt::List};
|
||||
std::string message;
|
||||
bool operation_ok{true};
|
||||
bool confirm_yes{};
|
||||
};
|
||||
|
||||
UiState g_ui{};
|
||||
bool g_logged_missing_target = false;
|
||||
bool g_logged_first_frame = false;
|
||||
|
||||
struct TrackedTarget {
|
||||
std::uint32_t address{};
|
||||
GeGpuDrawDescriptor draw{};
|
||||
bool valid{};
|
||||
};
|
||||
constexpr std::size_t kTrackedTargets = 8u;
|
||||
std::array<TrackedTarget, kTrackedTargets> g_targets{};
|
||||
std::uint32_t g_last_observed_target{};
|
||||
|
||||
std::uint64_t icon_cache_key(std::string_view path, std::span<const std::byte> rgba) noexcept {
|
||||
std::uint64_t hash = 1469598103934665603ull;
|
||||
auto mix = [&](std::uint8_t value) {
|
||||
hash ^= value;
|
||||
hash *= 1099511628211ull;
|
||||
};
|
||||
for (unsigned char ch : path) mix(ch);
|
||||
for (const std::byte value : rgba) mix(static_cast<std::uint8_t>(value));
|
||||
// Zero means "derive the normal PSP texture key" to the backend, so keep
|
||||
// this host-only cache identity nonzero.
|
||||
return hash != 0u ? hash : 1u;
|
||||
}
|
||||
|
||||
DecodedIcon decode_png_icon(const SavedataSlotEntry &slot) noexcept {
|
||||
DecodedIcon out;
|
||||
if (slot.icon0_path.empty()) return out;
|
||||
try {
|
||||
std::ifstream file(slot.icon0_path, std::ios::binary | std::ios::ate);
|
||||
if (!file) return out;
|
||||
const std::streamoff length = file.tellg();
|
||||
if (length <= 0 || length > 4 * 1024 * 1024 ||
|
||||
length > static_cast<std::streamoff>(std::numeric_limits<int>::max())) return out;
|
||||
file.seekg(0, std::ios::beg);
|
||||
std::vector<std::uint8_t> compressed(static_cast<std::size_t>(length));
|
||||
file.read(reinterpret_cast<char *>(compressed.data()), length);
|
||||
if (!file) return out;
|
||||
|
||||
const AVCodec *decoder = avcodec_find_decoder(AV_CODEC_ID_PNG);
|
||||
if (decoder == nullptr) return out;
|
||||
AVCodecContext *codec = avcodec_alloc_context3(decoder);
|
||||
AVPacket *packet = av_packet_alloc();
|
||||
AVFrame *frame = av_frame_alloc();
|
||||
if (codec == nullptr || packet == nullptr || frame == nullptr) {
|
||||
if (frame != nullptr) av_frame_free(&frame);
|
||||
if (packet != nullptr) av_packet_free(&packet);
|
||||
if (codec != nullptr) avcodec_free_context(&codec);
|
||||
return out;
|
||||
}
|
||||
bool ok = false;
|
||||
if (avcodec_open2(codec, decoder, nullptr) >= 0 &&
|
||||
av_new_packet(packet, static_cast<int>(compressed.size())) >= 0) {
|
||||
std::memcpy(packet->data, compressed.data(), compressed.size());
|
||||
if (avcodec_send_packet(codec, packet) >= 0 && avcodec_receive_frame(codec, frame) >= 0 &&
|
||||
frame->width > 0 && frame->height > 0 && frame->width <= 2048 && frame->height <= 2048) {
|
||||
const std::size_t pixels = static_cast<std::size_t>(frame->width) * frame->height;
|
||||
if (pixels <= (16u * 1024u * 1024u) / 4u) {
|
||||
out.rgba.resize(pixels * 4u);
|
||||
SwsContext *sws = sws_getContext(frame->width, frame->height,
|
||||
static_cast<AVPixelFormat>(frame->format), frame->width, frame->height,
|
||||
AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
if (sws != nullptr) {
|
||||
std::uint8_t *dst_data[4]{reinterpret_cast<std::uint8_t *>(out.rgba.data()), nullptr, nullptr, nullptr};
|
||||
int dst_linesize[4]{frame->width * 4, 0, 0, 0};
|
||||
if (sws_scale(sws, frame->data, frame->linesize, 0, frame->height,
|
||||
dst_data, dst_linesize) == frame->height) {
|
||||
out.width = static_cast<std::uint32_t>(frame->width);
|
||||
out.height = static_cast<std::uint32_t>(frame->height);
|
||||
out.cache_key = icon_cache_key(slot.icon0_path, out.rgba);
|
||||
ok = true;
|
||||
}
|
||||
sws_freeContext(sws);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
av_frame_free(&frame);
|
||||
av_packet_free(&packet);
|
||||
avcodec_free_context(&codec);
|
||||
if (!ok) return DecodedIcon{};
|
||||
} catch (...) {
|
||||
return DecodedIcon{};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
struct IconQuad {
|
||||
std::size_t index{};
|
||||
float x{};
|
||||
float y{};
|
||||
float w{};
|
||||
float h{};
|
||||
};
|
||||
|
||||
// Compact 5x7 firmware-style bitmap font. The system savedata utility only
|
||||
// needs readable UI/metadata, so lower case is folded to upper case and unknown
|
||||
// codepoints become '?'. This keeps the renderer independent from a Windows
|
||||
// font API or an external font asset.
|
||||
struct Glyph { char c; std::array<std::uint8_t, 7> r; };
|
||||
constexpr std::array<Glyph, 43> kGlyphs{{
|
||||
{' ', {0,0,0,0,0,0,0}}, {'0',{14,17,19,21,25,17,14}},
|
||||
{'1',{4,12,4,4,4,4,14}}, {'2',{14,17,1,2,4,8,31}},
|
||||
{'3',{30,1,1,14,1,1,30}}, {'4',{2,6,10,18,31,2,2}},
|
||||
{'5',{31,16,16,30,1,1,30}}, {'6',{14,16,16,30,17,17,14}},
|
||||
{'7',{31,1,2,4,8,8,8}}, {'8',{14,17,17,14,17,17,14}},
|
||||
{'9',{14,17,17,15,1,1,14}},
|
||||
{'A',{14,17,17,31,17,17,17}}, {'B',{30,17,17,30,17,17,30}},
|
||||
{'C',{14,17,16,16,16,17,14}}, {'D',{30,17,17,17,17,17,30}},
|
||||
{'E',{31,16,16,30,16,16,31}}, {'F',{31,16,16,30,16,16,16}},
|
||||
{'G',{14,17,16,23,17,17,15}}, {'H',{17,17,17,31,17,17,17}},
|
||||
{'I',{14,4,4,4,4,4,14}}, {'J',{7,2,2,2,18,18,12}},
|
||||
{'K',{17,18,20,24,20,18,17}}, {'L',{16,16,16,16,16,16,31}},
|
||||
{'M',{17,27,21,21,17,17,17}}, {'N',{17,25,21,19,17,17,17}},
|
||||
{'O',{14,17,17,17,17,17,14}}, {'P',{30,17,17,30,16,16,16}},
|
||||
{'Q',{14,17,17,17,21,18,13}}, {'R',{30,17,17,30,20,18,17}},
|
||||
{'S',{15,16,16,14,1,1,30}}, {'T',{31,4,4,4,4,4,4}},
|
||||
{'U',{17,17,17,17,17,17,14}}, {'V',{17,17,17,17,17,10,4}},
|
||||
{'W',{17,17,17,21,21,21,10}}, {'X',{17,17,10,4,10,17,17}},
|
||||
{'Y',{17,17,10,4,4,4,4}}, {'Z',{31,1,2,4,8,16,31}},
|
||||
{'.',{0,0,0,0,0,12,12}}, {':',{0,12,12,0,12,12,0}},
|
||||
{'-',{0,0,0,31,0,0,0}}, {'/',{1,2,4,8,16,0,0}},
|
||||
{'?',{14,17,1,2,4,0,4}}, {'%',{17,2,4,8,17,0,0}},
|
||||
}};
|
||||
|
||||
const Glyph *glyph_for(char value) noexcept {
|
||||
unsigned char u = static_cast<unsigned char>(value);
|
||||
if (u >= 'a' && u <= 'z') value = static_cast<char>(u - 'a' + 'A');
|
||||
for (const Glyph &glyph : kGlyphs) if (glyph.c == value) return &glyph;
|
||||
for (const Glyph &glyph : kGlyphs) if (glyph.c == '?') return &glyph;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void quad(std::vector<GeGpuVertex> &v, float x0, float y0, float x1, float y1,
|
||||
std::uint32_t color) {
|
||||
GeGpuVertex a{}, b{}, c{}, d{};
|
||||
a.x=x0; a.y=y0; a.rgba=color;
|
||||
b.x=x1; b.y=y0; b.rgba=color;
|
||||
c.x=x1; c.y=y1; c.rgba=color;
|
||||
d.x=x0; d.y=y1; d.rgba=color;
|
||||
v.push_back(a); v.push_back(b); v.push_back(c);
|
||||
v.push_back(a); v.push_back(c); v.push_back(d);
|
||||
}
|
||||
|
||||
void gradient_quad(std::vector<GeGpuVertex> &v, float x0, float y0, float x1, float y1,
|
||||
std::uint32_t top_left, std::uint32_t top_right,
|
||||
std::uint32_t bottom_left, std::uint32_t bottom_right) {
|
||||
GeGpuVertex a{}, b{}, c{}, d{};
|
||||
a.x=x0; a.y=y0; a.rgba=top_left;
|
||||
b.x=x1; b.y=y0; b.rgba=top_right;
|
||||
c.x=x1; c.y=y1; c.rgba=bottom_right;
|
||||
d.x=x0; d.y=y1; d.rgba=bottom_left;
|
||||
v.push_back(a); v.push_back(b); v.push_back(c);
|
||||
v.push_back(a); v.push_back(c); v.push_back(d);
|
||||
}
|
||||
|
||||
void text(std::vector<GeGpuVertex> &v, std::string_view value, float x, float y,
|
||||
float scale, std::uint32_t color, std::size_t max_chars = 64u) {
|
||||
float pen = x;
|
||||
std::size_t emitted = 0u;
|
||||
for (char ch : value) {
|
||||
if (emitted++ >= max_chars || ch == '\n' || ch == '\r') break;
|
||||
const Glyph *g = glyph_for(ch);
|
||||
if (g != nullptr) {
|
||||
for (std::size_t row=0; row<7; ++row) {
|
||||
for (std::size_t col=0; col<5; ++col) {
|
||||
if ((g->r[row] & (1u << (4u-col))) == 0u) continue;
|
||||
const float px=pen+static_cast<float>(col)*scale;
|
||||
const float py=y+static_cast<float>(row)*scale;
|
||||
quad(v,px,py,px+scale,py+scale,color);
|
||||
}
|
||||
}
|
||||
}
|
||||
pen += 6.0f * scale;
|
||||
}
|
||||
}
|
||||
|
||||
std::string first_line(std::string value, std::size_t max_chars) {
|
||||
const std::size_t line = value.find_first_of("\r\n");
|
||||
if (line != std::string::npos) value.resize(line);
|
||||
if (value.size() > max_chars) {
|
||||
if (max_chars > 3u) value = value.substr(0, max_chars - 3u) + "...";
|
||||
else value.resize(max_chars);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string slot_primary(const SavedataSlotEntry &slot) {
|
||||
if (!slot.exists) return "NEW SAVE DATA";
|
||||
if (!slot.savedata_title.empty()) return first_line(slot.savedata_title, 34u);
|
||||
if (!slot.title.empty()) return first_line(slot.title, 34u);
|
||||
return slot.save_name;
|
||||
}
|
||||
|
||||
std::string slot_detail(const SavedataSlotEntry &slot) {
|
||||
if (!slot.exists) return "EMPTY SLOT";
|
||||
if (!slot.detail.empty()) return first_line(slot.detail, 44u);
|
||||
return slot.save_name;
|
||||
}
|
||||
|
||||
const char *heading(std::uint32_t mode) noexcept {
|
||||
switch (mode) {
|
||||
case 4u: return "LOAD GAME";
|
||||
case 5u: return "SAVE GAME";
|
||||
case 6u: return "DELETE";
|
||||
default: return "SAVED DATA";
|
||||
}
|
||||
}
|
||||
|
||||
const char *confirm_message(std::uint32_t mode) noexcept {
|
||||
switch (mode) {
|
||||
case 5u: return "OVERWRITE THIS SAVE DATA?";
|
||||
case 6u: return "DELETE THIS SAVE DATA?";
|
||||
default: return "LOAD THIS SAVE DATA?";
|
||||
}
|
||||
}
|
||||
|
||||
TrackedTarget *target_for(std::uint32_t address) noexcept {
|
||||
for (TrackedTarget &target : g_targets)
|
||||
if (target.valid && target.address == address) return ⌖
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void configure_overlay_draw(GeGpuDrawDescriptor &draw, std::size_t vertex_count) noexcept {
|
||||
draw.primitive = 3u;
|
||||
draw.vertex_count = static_cast<std::uint32_t>(vertex_count);
|
||||
draw.vertex_type = 0u;
|
||||
draw.through = true;
|
||||
draw.widescreen_hud = false;
|
||||
draw.texture_enabled = false;
|
||||
draw.texture_address = 0u;
|
||||
draw.texture_format = 0u;
|
||||
draw.texture_content_signature = 0u;
|
||||
draw.texture_cache_key_hint = 0u;
|
||||
draw.texture_image_key_hint = 0u;
|
||||
draw.texture_use_alpha = false;
|
||||
draw.texture_double_color = false;
|
||||
draw.blend_enabled = false;
|
||||
draw.color_write_mask = 0u;
|
||||
draw.alpha_test_enabled = false;
|
||||
draw.depth_test_enabled = false;
|
||||
draw.depth_write_enabled = false;
|
||||
draw.fog_enabled = false;
|
||||
draw.clear_mode = false;
|
||||
draw.scissor_x0 = 0; draw.scissor_y0 = 0;
|
||||
draw.scissor_x1 = 479; draw.scissor_y1 = 271;
|
||||
}
|
||||
|
||||
void text_wrapped(std::vector<GeGpuVertex> &v, std::string value,
|
||||
float x, float y, float scale, std::uint32_t color,
|
||||
std::size_t chars_per_line, std::size_t max_lines) {
|
||||
for (char &ch : value) if (ch == '\r' || ch == '\n' || ch == '\t') ch = ' ';
|
||||
std::size_t pos = 0u;
|
||||
for (std::size_t line = 0u; line < max_lines && pos < value.size(); ++line) {
|
||||
while (pos < value.size() && value[pos] == ' ') ++pos;
|
||||
std::size_t take = std::min(chars_per_line, value.size() - pos);
|
||||
if (pos + take < value.size()) {
|
||||
const std::size_t space = value.rfind(' ', pos + take);
|
||||
if (space != std::string::npos && space > pos) take = space - pos;
|
||||
}
|
||||
text(v, std::string_view(value).substr(pos, take), x,
|
||||
y + static_cast<float>(line) * (8.0f * scale + 2.0f), scale, color, take);
|
||||
pos += take;
|
||||
}
|
||||
}
|
||||
|
||||
void draw_banner(std::vector<GeGpuVertex> &v, std::uint32_t white) {
|
||||
// PSP utility banner geometry mirrored from the firmware-style HLE path:
|
||||
// 480x23 translucent bar, small system icon at x=10, title at x=30.
|
||||
quad(v, 0, 0, 480, 23, 0xFF585863u);
|
||||
quad(v, 10, 6, 22, 18, 0xFFB0B0B0u);
|
||||
quad(v, 13, 9, 19, 15, 0xFF585863u);
|
||||
text(v, heading(g_ui.mode), 30, 7, 1.45f, white, 16);
|
||||
}
|
||||
|
||||
void draw_save_thumbnail(std::vector<GeGpuVertex> &v, std::vector<IconQuad> &icons,
|
||||
float x, float y, float w, float h, bool selected,
|
||||
bool exists, std::size_t index) {
|
||||
const std::uint32_t fill = exists ? 0xFF505050u : 0xFF333333u;
|
||||
const std::uint32_t border = selected ? 0xFFF0F0F0u : 0xFF777777u;
|
||||
quad(v, x, y, x + w, y + h, fill);
|
||||
if (exists && index < g_ui.icons.size() && !g_ui.icons[index].rgba.empty())
|
||||
icons.push_back(IconQuad{index, x, y, w, h});
|
||||
const float b = selected ? 1.5f : 0.75f;
|
||||
quad(v, x-b, y-b, x+w+b, y, border);
|
||||
quad(v, x-b, y+h, x+w+b, y+h+b, border);
|
||||
quad(v, x-b, y, x, y+h, border);
|
||||
quad(v, x+w, y, x+w+b, y+h, border);
|
||||
if (index >= g_ui.icons.size() || g_ui.icons[index].rgba.empty()) {
|
||||
char label[16]{};
|
||||
std::snprintf(label, sizeof(label), "%u", static_cast<unsigned>(index + 1u));
|
||||
text(v, label, x + w * 0.47f, y + h * 0.43f,
|
||||
selected ? 1.1f : 0.75f, 0xFFD0D0D0u, 4);
|
||||
}
|
||||
}
|
||||
|
||||
void draw_selected_info(std::vector<GeGpuVertex> &v, const SavedataSlotEntry &slot,
|
||||
std::uint32_t white, std::uint32_t dim) {
|
||||
if (!slot.exists) {
|
||||
text(v, "NEW DATA", 180, 132, 1.15f, white, 20);
|
||||
return;
|
||||
}
|
||||
const std::string title = !slot.title.empty() ? first_line(slot.title, 34u)
|
||||
: "GRAND THEFT AUTO: VICE CITY STORIES";
|
||||
text(v, title, 180, 121, 1.0f, dim, 36);
|
||||
quad(v, 180, 136, 480, 137, white);
|
||||
// The host-side savedata directory does not currently persist the PSP clock
|
||||
// fields; show the stable slot id here instead of fabricating a date/time.
|
||||
text(v, slot.save_name, 180, 142, 0.85f, white, 30);
|
||||
text(v, slot_primary(slot), 175, 158, 1.05f, white, 35);
|
||||
text_wrapped(v, slot.detail, 175, 179, 0.82f, white, 41u, 4u);
|
||||
}
|
||||
|
||||
void draw_bottom_buttons(std::vector<GeGpuVertex> &v, const char *label,
|
||||
std::uint32_t dim) {
|
||||
text(v, label, 330, 249, 0.9f, dim, 28);
|
||||
}
|
||||
|
||||
void render_list(std::vector<GeGpuVertex> &v, std::vector<IconQuad> &icons) {
|
||||
constexpr std::uint32_t white = 0xFFF4F4F4u;
|
||||
constexpr std::uint32_t dim = 0xFFD0CCD4u;
|
||||
|
||||
// V9.3 clean-rebase visual change only: reproduce the user-supplied
|
||||
// purple/pink background with native vertex interpolation. This keeps the
|
||||
// exact proven V9 framebuffer/render path: no extra texture, target, or
|
||||
// high-resolution overlay is introduced. Colors are sampled from the four
|
||||
// corners of the supplied 1920x1080 gradient.
|
||||
gradient_quad(v, 0, 0, 480, 272,
|
||||
0xFFE23072u, 0xFFE02F72u,
|
||||
0xFFCDB3E1u, 0xFFCEABE3u);
|
||||
draw_banner(v, white);
|
||||
|
||||
if (g_ui.prompt == SavedataUtilityUiPrompt::NoData || g_ui.slots.empty()) {
|
||||
text(v, g_ui.message.empty() ? "THERE IS NO DATA" : g_ui.message,
|
||||
164, 132, 1.15f, white, 34);
|
||||
draw_bottom_buttons(v, "O BACK", dim);
|
||||
return;
|
||||
}
|
||||
|
||||
const std::size_t selected = std::min(g_ui.selected, g_ui.slots.size() - 1u);
|
||||
const SavedataSlotEntry &slot = g_ui.slots[selected];
|
||||
|
||||
if (g_ui.prompt == SavedataUtilityUiPrompt::List) {
|
||||
// Match the PSP utility's save-list composition: selected ICON0 is
|
||||
// 144x80 at (27,97); neighboring saves are 81x45 above/below it.
|
||||
for (std::size_t i = 0; i < g_ui.slots.size(); ++i) {
|
||||
float x, y, w, h;
|
||||
if (i == selected) {
|
||||
x = 27.0f; y = 97.0f; w = 144.0f; h = 80.0f;
|
||||
} else {
|
||||
x = 58.5f; w = 81.0f; h = 45.0f;
|
||||
if (i < selected)
|
||||
y = 97.0f - 13.0f - 45.0f * static_cast<float>(selected - i);
|
||||
else
|
||||
y = 97.0f + 48.0f + 45.0f * static_cast<float>(i - selected);
|
||||
}
|
||||
if (y < -60.0f || y > 271.0f) continue;
|
||||
draw_save_thumbnail(v, icons, x, y, w, h, i == selected, g_ui.slots[i].exists, i);
|
||||
}
|
||||
draw_selected_info(v, slot, white, dim);
|
||||
draw_bottom_buttons(v, "X ENTER O BACK", dim);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm/result states switch to one 144x80 icon, like PSP firmware.
|
||||
draw_save_thumbnail(v, icons, 27, 97, 144, 80, true, slot.exists, selected);
|
||||
text(v, slot_primary(slot), 8, 198, 0.85f, dim, 50);
|
||||
text(v, slot.save_name, 8, 214, 0.75f, dim, 30);
|
||||
|
||||
if (g_ui.prompt == SavedataUtilityUiPrompt::Confirm) {
|
||||
const char *message = g_ui.message.empty() ? confirm_message(g_ui.mode)
|
||||
: g_ui.message.c_str();
|
||||
text(v, message, 196, 126, 1.0f, white, 40);
|
||||
if (g_ui.confirm_yes)
|
||||
quad(v, 282, 145, 324, 162, 0xFF505050u);
|
||||
else
|
||||
quad(v, 348, 145, 388, 162, 0xFF505050u);
|
||||
text(v, "YES", 302, 151, 1.0f, g_ui.confirm_yes ? white : dim, 8);
|
||||
text(v, "NO", 366, 151, 1.0f, g_ui.confirm_yes ? dim : white, 8);
|
||||
draw_bottom_buttons(v, "X ENTER O BACK", dim);
|
||||
} else {
|
||||
text(v, g_ui.message, 206, 132, 1.05f,
|
||||
g_ui.operation_ok ? white : dim, 40);
|
||||
draw_bottom_buttons(v, "O BACK", dim);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void savedata_utility_ui_begin(std::uint32_t mode,
|
||||
const std::vector<SavedataSlotEntry> &slots,
|
||||
std::size_t selected) noexcept {
|
||||
g_ui.active = true;
|
||||
g_ui.mode = mode;
|
||||
g_ui.slots = slots;
|
||||
g_ui.icons.clear();
|
||||
g_ui.icons.reserve(g_ui.slots.size());
|
||||
for (const SavedataSlotEntry &slot : g_ui.slots)
|
||||
g_ui.icons.push_back(decode_png_icon(slot));
|
||||
g_logged_missing_target = false;
|
||||
g_logged_first_frame = false;
|
||||
g_ui.selected = slots.empty() ? 0u : std::min(selected, slots.size()-1u);
|
||||
g_ui.prompt = slots.empty() ? SavedataUtilityUiPrompt::NoData
|
||||
: SavedataUtilityUiPrompt::List;
|
||||
g_ui.message = slots.empty() ? "THERE IS NO SAVE DATA." : "";
|
||||
g_ui.operation_ok = true;
|
||||
g_ui.confirm_yes = false;
|
||||
}
|
||||
|
||||
void savedata_utility_ui_end() noexcept {
|
||||
g_ui = UiState{};
|
||||
}
|
||||
|
||||
bool savedata_utility_ui_active() noexcept { return g_ui.active; }
|
||||
|
||||
void savedata_utility_ui_set_selected(std::size_t selected) noexcept {
|
||||
if (!g_ui.slots.empty()) g_ui.selected = std::min(selected, g_ui.slots.size()-1u);
|
||||
}
|
||||
|
||||
void savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt prompt,
|
||||
const char *message,
|
||||
bool operation_ok) noexcept {
|
||||
g_ui.prompt = prompt;
|
||||
g_ui.message = message != nullptr ? message : "";
|
||||
g_ui.operation_ok = operation_ok;
|
||||
}
|
||||
|
||||
void savedata_utility_ui_set_confirm_choice(bool yes) noexcept {
|
||||
g_ui.confirm_yes = yes;
|
||||
}
|
||||
|
||||
void savedata_utility_ui_observe_draw(const GeGpuDrawDescriptor &draw,
|
||||
std::uint32_t vertex_weight) noexcept {
|
||||
if (draw.clear_mode || vertex_weight == 0u) return;
|
||||
const std::uint32_t address = draw.framebuffer_address & 0x001FFFF0u;
|
||||
if (address == 0u) return;
|
||||
if (address == g_last_observed_target) return;
|
||||
g_last_observed_target = address;
|
||||
// Keep one framebuffer-layout sample even before a utility opens. A PSP
|
||||
// title may stop issuing world/frontend draws while sceUtility owns the
|
||||
// screen, so waiting until InitStart would leave the HLE surface without a
|
||||
// render-target descriptor. Known targets are not recopied on every draw.
|
||||
if (target_for(address) != nullptr) return;
|
||||
for (TrackedTarget &target : g_targets) {
|
||||
if (target.valid) continue;
|
||||
target.valid = true;
|
||||
target.address = address;
|
||||
target.draw = draw;
|
||||
return;
|
||||
}
|
||||
// Small bounded cache: replace slot zero rather than allocate in hot GE code.
|
||||
g_targets[0] = TrackedTarget{address, draw, true};
|
||||
}
|
||||
|
||||
void savedata_utility_ui_render_frame(std::uint32_t selected_framebuffer) noexcept {
|
||||
if (!g_ui.active || !ge_gpu_backend_graphics_ready()) return;
|
||||
const std::uint32_t address = selected_framebuffer & 0x001FFFF0u;
|
||||
TrackedTarget *target = target_for(address);
|
||||
if (target == nullptr) {
|
||||
if (!g_logged_missing_target) {
|
||||
std::fprintf(stderr,
|
||||
"[savedata-ui] V9.3 active but framebuffer target 0x%08X was never observed; no UI frame can be submitted\n",
|
||||
address);
|
||||
g_logged_missing_target = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
static thread_local std::vector<GeGpuVertex> vertices;
|
||||
static thread_local std::vector<IconQuad> icon_quads;
|
||||
vertices.clear();
|
||||
icon_quads.clear();
|
||||
vertices.reserve(12000u);
|
||||
icon_quads.reserve(8u);
|
||||
render_list(vertices, icon_quads);
|
||||
if (vertices.empty()) return;
|
||||
|
||||
GeGpuDrawDescriptor draw = target->draw;
|
||||
configure_overlay_draw(draw, vertices.size());
|
||||
ge_gpu_backend_accumulate_color_triangles(draw, vertices);
|
||||
if (!g_logged_first_frame) {
|
||||
std::fprintf(stderr,
|
||||
"[savedata-ui] V9.3 first UI frame submitted target=0x%08X vertices=%zu\n",
|
||||
address, vertices.size());
|
||||
g_logged_first_frame = true;
|
||||
}
|
||||
|
||||
// ICON0.PNG is PSP savedata content supplied by the game/firmware request.
|
||||
// Decode it in-process and sample it through the same DX12 GE backend rather
|
||||
// than replacing it with host artwork. Each icon keeps a stable host-only
|
||||
// texture key; if the backend evicts it, texture_available() naturally
|
||||
// triggers a re-upload on a later utility frame.
|
||||
for (const IconQuad &quad_info : icon_quads) {
|
||||
if (quad_info.index >= g_ui.icons.size()) continue;
|
||||
const DecodedIcon &icon = g_ui.icons[quad_info.index];
|
||||
if (icon.rgba.empty() || icon.width == 0u || icon.height == 0u) continue;
|
||||
|
||||
GeGpuDrawDescriptor icon_draw = target->draw;
|
||||
configure_overlay_draw(icon_draw, 6u);
|
||||
icon_draw.texture_enabled = true;
|
||||
icon_draw.texture_address = 0u;
|
||||
icon_draw.texture_buffer_width = icon.width;
|
||||
icon_draw.texture_width = icon.width;
|
||||
icon_draw.texture_height = icon.height;
|
||||
icon_draw.texture_format = 3u; // host-decoded RGBA8
|
||||
icon_draw.texture_function = 3u; // REPLACE
|
||||
icon_draw.texture_use_alpha = true;
|
||||
icon_draw.texture_linear = true;
|
||||
icon_draw.texture_min_linear = true;
|
||||
icon_draw.texture_mag_linear = true;
|
||||
icon_draw.texture_clamp_u = true;
|
||||
icon_draw.texture_clamp_v = true;
|
||||
icon_draw.texture_cache_key_hint = icon.cache_key;
|
||||
icon_draw.texture_image_key_hint = icon.cache_key;
|
||||
icon_draw.texture_content_signature = icon.cache_key;
|
||||
|
||||
if (!ge_gpu_backend_texture_available(icon_draw) &&
|
||||
!ge_gpu_backend_upload_decoded_texture(icon_draw, icon.width, icon.height, icon.rgba))
|
||||
continue;
|
||||
|
||||
std::array<GeGpuVertex, 6> textured{};
|
||||
auto set = [&](GeGpuVertex &vert, float x, float y, float u, float vv) {
|
||||
vert.x = x; vert.y = y; vert.rgba = 0xFFFFFFFFu; vert.u = u; vert.v = vv;
|
||||
};
|
||||
const float x0 = quad_info.x, y0 = quad_info.y;
|
||||
const float x1 = quad_info.x + quad_info.w, y1 = quad_info.y + quad_info.h;
|
||||
const float u1 = static_cast<float>(icon.width), v1 = static_cast<float>(icon.height);
|
||||
set(textured[0], x0, y0, 0.0f, 0.0f);
|
||||
set(textured[1], x1, y0, u1, 0.0f);
|
||||
set(textured[2], x1, y1, u1, v1);
|
||||
set(textured[3], x0, y0, 0.0f, 0.0f);
|
||||
set(textured[4], x1, y1, u1, v1);
|
||||
set(textured[5], x0, y1, 0.0f, v1);
|
||||
ge_gpu_backend_accumulate_color_triangles(icon_draw, textured);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace vcs
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace vcs {
|
||||
|
||||
struct GeGpuDrawDescriptor;
|
||||
|
||||
// Data exposed by the PSP savedata HLE to the system-utility renderer. These
|
||||
// strings come from the game's SceUtilitySavedataParam / PARAM.SFO metadata;
|
||||
// the renderer owns no save-game policy and never opens a desktop window.
|
||||
struct SavedataSlotEntry {
|
||||
std::string save_name;
|
||||
bool exists{};
|
||||
std::string title;
|
||||
std::string savedata_title;
|
||||
std::string detail;
|
||||
// Host path to the PSP save icon persisted by sceUtilitySavedata. The UI
|
||||
// renderer decodes it in-process; it is never a replacement artwork.
|
||||
std::string icon0_path;
|
||||
};
|
||||
|
||||
enum class SavedataUtilityUiPrompt : std::uint8_t {
|
||||
List,
|
||||
Confirm,
|
||||
Result,
|
||||
NoData,
|
||||
};
|
||||
|
||||
// Starts/stops the PSP system-utility surface. LISTLOAD/LISTSAVE/LISTDELETE use
|
||||
// this instead of a host HWND; pixels are emitted into the same 480x272 GE
|
||||
// framebuffer the game is already presenting.
|
||||
void savedata_utility_ui_begin(std::uint32_t mode,
|
||||
const std::vector<SavedataSlotEntry> &slots,
|
||||
std::size_t selected) noexcept;
|
||||
void savedata_utility_ui_end() noexcept;
|
||||
[[nodiscard]] bool savedata_utility_ui_active() noexcept;
|
||||
|
||||
void savedata_utility_ui_set_selected(std::size_t selected) noexcept;
|
||||
void savedata_utility_ui_set_prompt(SavedataUtilityUiPrompt prompt,
|
||||
const char *message = nullptr,
|
||||
bool operation_ok = true) noexcept;
|
||||
void savedata_utility_ui_set_confirm_choice(bool yes) noexcept;
|
||||
|
||||
// Track the displayed GE target using ordinary game draws. This is only a
|
||||
// framebuffer-layout observation; no UI is injected until render_frame().
|
||||
void savedata_utility_ui_observe_draw(const GeGpuDrawDescriptor &draw,
|
||||
std::uint32_t vertex_weight) noexcept;
|
||||
|
||||
// Called once per vblank immediately before ge_gpu_backend_finish_color_frame.
|
||||
// The output is an in-frame PSP utility layer, never Win32/GDI/ImGui UI.
|
||||
void savedata_utility_ui_render_frame(std::uint32_t selected_framebuffer) noexcept;
|
||||
|
||||
} // namespace vcs
|
||||
@@ -453,6 +453,24 @@ bool parse_aspect_ratio(const std::string &value, std::uint32_t &x, std::uint32_
|
||||
return true;
|
||||
}
|
||||
|
||||
void apply_frontend_key(VcsConfiguration &config, const std::string &key,
|
||||
const std::string &value, std::size_t line) {
|
||||
if (key == "boottogamemenu" || key == "bootmenu") {
|
||||
bool ignored = false;
|
||||
if (!parse_bool(value, ignored))
|
||||
warning(config, line, "Frontend.BootToGameMenu expects true/false");
|
||||
// Retired in the load-only startup path. Kept parse-compatible so an
|
||||
// older INI cannot accidentally re-enable the removed boot frontend.
|
||||
return;
|
||||
}
|
||||
if (key == "mousemenu" || key == "menumouse") {
|
||||
if (!parse_bool(value, config.frontend.mouse_menu))
|
||||
warning(config, line, "Frontend.MouseMenu expects true/false");
|
||||
return;
|
||||
}
|
||||
warning(config, line, "unknown [Frontend] key '" + key + "'");
|
||||
}
|
||||
|
||||
void apply_controls_key(VcsConfiguration &config, const std::string &key,
|
||||
const std::string &value, std::size_t line) {
|
||||
if (key == "camerastick" || key == "mousecamera") {
|
||||
@@ -713,6 +731,8 @@ VcsConfiguration load_vcs_configuration(const std::filesystem::path &path) {
|
||||
apply_widescreen_key(config, key, value, line_number);
|
||||
else if (section == "controls")
|
||||
apply_controls_key(config, key, value, line_number);
|
||||
else if (section == "frontend")
|
||||
apply_frontend_key(config, key, value, line_number);
|
||||
// These sections belong to the optional Project2DFX module, which
|
||||
// deliberately owns its parser so it can be compiled independently of
|
||||
// the core display/input configuration. They are nevertheless valid
|
||||
|
||||
@@ -215,6 +215,12 @@ struct VolumetricCloudsConfiguration {
|
||||
// constant 0x3FE38E39 -- exactly 16/9 -- and everything is relative to it.
|
||||
inline constexpr float kGameNativeAspectRatio = 16.0f / 9.0f;
|
||||
|
||||
struct FrontendConfiguration {
|
||||
// Mouse ownership for the in-frame PSP savedata utility. Disabled by
|
||||
// default so the desktop pointer never interferes with gameplay.
|
||||
bool mouse_menu{false};
|
||||
};
|
||||
|
||||
struct ControlsConfiguration {
|
||||
// Mouse and right-stick camera. Needs the guest-side hook, which bypasses
|
||||
// the game's own camera conditions, so it is opt-in.
|
||||
@@ -242,6 +248,7 @@ struct ControlsConfiguration {
|
||||
};
|
||||
|
||||
struct VcsConfiguration {
|
||||
FrontendConfiguration frontend{};
|
||||
ControlsConfiguration controls{};
|
||||
DisplayConfiguration display{};
|
||||
RenderingConfiguration rendering{};
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
#include "ge_gpu_backend.hpp"
|
||||
#include "vcs_project2dfx.hpp"
|
||||
#include "vcs_draw_distance_patch.hpp"
|
||||
#include "savedata_dialog.hpp"
|
||||
#include "savedata_utility_ui.hpp"
|
||||
|
||||
#include "psprecomp/common.hpp"
|
||||
#include "psprecomp/deflate.hpp"
|
||||
@@ -796,6 +796,13 @@ std::filesystem::path identify_pmf_source(std::span<const std::uint8_t> header,
|
||||
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]) |
|
||||
@@ -2171,11 +2178,33 @@ enum class UtilityStatus : std::uint32_t {
|
||||
struct SavedataUtilityState {
|
||||
UtilityStatus status{UtilityStatus::None};
|
||||
std::uint32_t parameter_address{};
|
||||
std::uint32_t mode{};
|
||||
bool operation_complete{};
|
||||
bool slot_selection_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;
|
||||
|
||||
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;
|
||||
constexpr std::uint32_t kSavedataModeOffset = 0x30u;
|
||||
@@ -2186,6 +2215,13 @@ 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;
|
||||
@@ -2234,6 +2270,193 @@ std::filesystem::path savedata_directory(const psprecomp::Runtime &runtime, std:
|
||||
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/PPSSPP 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;
|
||||
@@ -2263,7 +2486,7 @@ std::vector<SavedataSlotEntry> savedata_slot_entries(psprecomp::Runtime &runtime
|
||||
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({std::move(name), exists});
|
||||
if (saving || exists) slots.push_back(make_savedata_slot_entry(runtime, parameter_address, std::move(name), exists, saving));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2279,7 +2502,7 @@ std::vector<SavedataSlotEntry> savedata_slot_entries(psprecomp::Runtime &runtime
|
||||
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({std::move(suffix), true});
|
||||
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;
|
||||
@@ -2293,34 +2516,42 @@ std::vector<SavedataSlotEntry> savedata_slot_entries(psprecomp::Runtime &runtime
|
||||
return slot.save_name == current;
|
||||
})) {
|
||||
const bool exists = std::filesystem::is_directory(root / (game + current));
|
||||
if (saving || exists) slots.push_back({current, exists});
|
||||
if (saving || exists) slots.push_back(make_savedata_slot_entry(runtime, parameter_address, current, exists, saving));
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
enum class SavedataSlotPreparation { Ready, Cancelled, NoSlots };
|
||||
bool savedata_mode_has_list_ui(std::uint32_t mode) noexcept {
|
||||
return mode == 4u || mode == 5u || mode == 6u;
|
||||
}
|
||||
|
||||
SavedataSlotPreparation prepare_savedata_list_selection(psprecomp::Runtime &runtime,
|
||||
std::uint32_t parameter_address,
|
||||
std::uint32_t mode) {
|
||||
if (mode != 4u && mode != 5u && mode != 6u) return SavedataSlotPreparation::Ready;
|
||||
const bool saving = mode == 5u;
|
||||
auto slots = savedata_slot_entries(runtime, parameter_address, saving);
|
||||
if (slots.empty()) return SavedataSlotPreparation::NoSlots;
|
||||
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(), parameter_address + kSavedataSaveNameOffset, 20u));
|
||||
const SavedataDialogChoice choice = choose_savedata_slot(slots, saving, current);
|
||||
if (!choice.confirmed) {
|
||||
// PSP exposes cancellation separately from base.result for the savedata
|
||||
// list utility. Leave base.result successful and flag abortStatus so the
|
||||
// guest can return from its fade/system-dialog state normally.
|
||||
runtime.memory().store32(parameter_address + kSavedataAbortStatusOffset, 1u);
|
||||
return SavedataSlotPreparation::Cancelled;
|
||||
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(parameter_address + kSavedataAbortStatusOffset, 0u);
|
||||
write_fixed_string(runtime.memory(), parameter_address + kSavedataSaveNameOffset,
|
||||
20u, choice.save_name);
|
||||
return SavedataSlotPreparation::Ready;
|
||||
|
||||
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.3 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,
|
||||
@@ -2389,6 +2620,14 @@ std::uint32_t save_savedata_file(psprecomp::Runtime &runtime, std::uint32_t para
|
||||
!write_savedata_auxiliary(runtime, parameter_address, kSavedataSnd0Offset, "SND0.AT3")) {
|
||||
return 0x80110385u;
|
||||
}
|
||||
// The PSP firmware normally persists sfoParam to PARAM.SFO and uses
|
||||
// savedataTitle/detail on its load screen. VCSNative's host savedata
|
||||
// path previously dropped that metadata entirely, leaving the slot UI
|
||||
// with only opaque names like S92F0. Preserve the exact guest-provided
|
||||
// display strings in a tiny host sidecar so the in-game overlay can show
|
||||
// the mission/save title on later loads.
|
||||
(void)write_savedata_metadata_file(path.parent_path(),
|
||||
savedata_metadata_from_guest(runtime, parameter_address));
|
||||
}
|
||||
return 0u;
|
||||
}
|
||||
@@ -2544,6 +2783,161 @@ std::uint32_t execute_savedata_operation(psprecomp::Runtime &runtime, std::uint3
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
runtime.memory().store32(savedata_utility.parameter_address + kSavedataAbortStatusOffset, 1u);
|
||||
runtime.memory().store32(savedata_utility.parameter_address + kUtilityCommonResultOffset, 0u);
|
||||
savedata_utility.operation_complete = true;
|
||||
savedata_utility.status = UtilityStatus::Quit;
|
||||
savedata_utility_ui_end();
|
||||
display_window_set_system_utility_mode(false);
|
||||
}
|
||||
|
||||
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.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.3 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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -5437,6 +5831,8 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
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{};
|
||||
deflate_fast_pending.clear();
|
||||
collision_chain_trace_stack.clear();
|
||||
@@ -7011,6 +7407,10 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
// 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 (frame_time_diag_enabled()) {
|
||||
@@ -7162,6 +7562,9 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
movie_output_buffers.count(normalize_ram_address(display_state.frame_buffer)) != 0u);
|
||||
const auto present_entry = frame_time_diag_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
|
||||
@@ -7288,8 +7691,39 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
ctx.set_gpr(2, 0x80110004u);
|
||||
return;
|
||||
}
|
||||
savedata_utility = SavedataUtilityState{UtilityStatus::Init, parameter, false, false};
|
||||
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.3 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)
|
||||
@@ -7307,30 +7741,21 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
}
|
||||
if (savedata_utility.status == UtilityStatus::Init) {
|
||||
savedata_utility.status = UtilityStatus::Visible;
|
||||
} else if (savedata_utility.status == UtilityStatus::Visible && !savedata_utility.operation_complete) {
|
||||
const std::uint32_t mode = rt.memory().load32(
|
||||
savedata_utility.parameter_address + kSavedataModeOffset);
|
||||
if (!savedata_utility.slot_selection_complete) {
|
||||
const SavedataSlotPreparation selection = prepare_savedata_list_selection(
|
||||
rt, savedata_utility.parameter_address, mode);
|
||||
savedata_utility.slot_selection_complete = true;
|
||||
if (selection == SavedataSlotPreparation::Cancelled) {
|
||||
rt.memory().store32(savedata_utility.parameter_address +
|
||||
kUtilityCommonResultOffset, 0u);
|
||||
savedata_utility.operation_complete = true;
|
||||
savedata_utility.status = UtilityStatus::Quit;
|
||||
set_success(ctx);
|
||||
return;
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
@@ -7340,9 +7765,11 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
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 dialog visible for Update().
|
||||
// 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{};
|
||||
}
|
||||
});
|
||||
@@ -7353,6 +7780,8 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
return;
|
||||
}
|
||||
savedata_utility.status = UtilityStatus::Finished;
|
||||
savedata_utility_ui_end();
|
||||
display_window_set_system_utility_mode(false);
|
||||
set_success(ctx);
|
||||
});
|
||||
|
||||
@@ -8636,6 +9065,9 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
}
|
||||
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;
|
||||
@@ -8832,8 +9264,9 @@ void install_profile(psprecomp::Runtime &runtime, std::uint32_t user_arena_start
|
||||
rt.memory().store32(ring + 40u, 0u);
|
||||
}
|
||||
}
|
||||
if (auto state = mpeg_contexts.find(mpeg_out); state != mpeg_contexts.end())
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "vcs_savedata_startup.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <system_error>
|
||||
|
||||
namespace vcs {
|
||||
namespace {
|
||||
|
||||
bool is_auxiliary_savedata_file(std::string name) {
|
||||
std::transform(name.begin(), name.end(), name.begin(),
|
||||
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
|
||||
return name == "ICON0.PNG" || name == "ICON1.PMF" || name == "PIC1.PNG" ||
|
||||
name == "SND0.AT3" || name == "PARAM.SFO" || name == "VCSNATIVE.META";
|
||||
}
|
||||
|
||||
bool regular_nonempty(const std::filesystem::path &path,
|
||||
std::filesystem::file_time_type &time) noexcept {
|
||||
std::error_code error;
|
||||
if (!std::filesystem::is_regular_file(path, error) || error) return false;
|
||||
const auto size = std::filesystem::file_size(path, error);
|
||||
if (error || size == 0u) return false;
|
||||
time = std::filesystem::last_write_time(path, error);
|
||||
return !error;
|
||||
}
|
||||
|
||||
bool candidate_time(const StartupSaveCandidate &candidate,
|
||||
std::string_view requested_file_name,
|
||||
std::filesystem::file_time_type &time) noexcept {
|
||||
try {
|
||||
if (!requested_file_name.empty())
|
||||
return regular_nonempty(candidate.directory / std::string(requested_file_name), time);
|
||||
|
||||
// Generic safety fallback for titles that leave fileName blank: use the
|
||||
// newest non-auxiliary regular file in the save directory.
|
||||
std::error_code error;
|
||||
if (!std::filesystem::is_directory(candidate.directory, error) || error) return false;
|
||||
bool found = false;
|
||||
for (std::filesystem::directory_iterator it(candidate.directory, error), end;
|
||||
it != end && !error; it.increment(error)) {
|
||||
if (!it->is_regular_file(error) || error) continue;
|
||||
if (is_auxiliary_savedata_file(it->path().filename().string())) continue;
|
||||
std::filesystem::file_time_type current{};
|
||||
if (!regular_nonempty(it->path(), current)) continue;
|
||||
if (!found || current > time) {
|
||||
time = current;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
StartupSaveChoice choose_latest_startup_save(
|
||||
const std::vector<StartupSaveCandidate> &candidates,
|
||||
std::string_view requested_file_name) noexcept {
|
||||
StartupSaveChoice result;
|
||||
std::filesystem::file_time_type newest{};
|
||||
bool have_newest = false;
|
||||
bool ambiguous = false;
|
||||
|
||||
for (const StartupSaveCandidate &candidate : candidates) {
|
||||
if (candidate.save_name.empty() || candidate.directory.empty()) continue;
|
||||
std::filesystem::file_time_type time{};
|
||||
if (!candidate_time(candidate, requested_file_name, time)) continue;
|
||||
++result.valid_count;
|
||||
if (!have_newest || time > newest) {
|
||||
newest = time;
|
||||
result.save_name = candidate.save_name;
|
||||
have_newest = true;
|
||||
ambiguous = false;
|
||||
} else if (time == newest && candidate.save_name != result.save_name) {
|
||||
ambiguous = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!have_newest) {
|
||||
result.kind = StartupSaveChoiceKind::NoValidSaves;
|
||||
result.save_name.clear();
|
||||
} else if (ambiguous) {
|
||||
result.kind = StartupSaveChoiceKind::AmbiguousLatest;
|
||||
result.save_name.clear();
|
||||
} else {
|
||||
result.kind = StartupSaveChoiceKind::Latest;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace vcs
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace vcs {
|
||||
|
||||
struct StartupSaveCandidate {
|
||||
std::string save_name;
|
||||
std::filesystem::path directory;
|
||||
};
|
||||
|
||||
enum class StartupSaveChoiceKind {
|
||||
NoValidSaves,
|
||||
Latest,
|
||||
AmbiguousLatest,
|
||||
};
|
||||
|
||||
struct StartupSaveChoice {
|
||||
StartupSaveChoiceKind kind{StartupSaveChoiceKind::NoValidSaves};
|
||||
std::string save_name;
|
||||
std::size_t valid_count{};
|
||||
};
|
||||
|
||||
// Select the newest *valid* save for startup autoload. The requested game data
|
||||
// file (for VCS this is supplied by SceUtilitySavedataParam::fileName) is used
|
||||
// as the authoritative timestamp and validity check, so ICON0/PARAM.SFO copies
|
||||
// cannot accidentally make an older slot look newer. If two valid slots have
|
||||
// exactly the same newest timestamp, do not guess: return AmbiguousLatest and
|
||||
// let the user choose in the Load Game list.
|
||||
[[nodiscard]] StartupSaveChoice choose_latest_startup_save(
|
||||
const std::vector<StartupSaveCandidate> &candidates,
|
||||
std::string_view requested_file_name) noexcept;
|
||||
|
||||
} // namespace vcs
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
@@ -22,11 +22,16 @@ require(host / "vcs_profile.cpp",
|
||||
"// limit_frame_rate() may advance virtual_time_us when the host misses the",
|
||||
"post-frame-limit audio timeline seal")
|
||||
|
||||
require(profile / "CMakeLists.txt", "host/savedata_dialog.cpp", "savedata dialog linked")
|
||||
require(profile / "CMakeLists.txt", "host/savedata_utility_ui.cpp", "PSP savedata utility renderer linked")
|
||||
require(host / "vcs_profile.cpp", "kSavedataSaveNameListOffset = 0x60u", "savedata saveNameList")
|
||||
require(host / "vcs_profile.cpp", "mode != 4u && mode != 5u && mode != 6u", "savedata LIST modes")
|
||||
require(host / "vcs_profile.cpp", "choose_savedata_slot", "savedata chooser invoked")
|
||||
require(host / "savedata_dialog.cpp", "VCSNativeSavedataSlotDialog", "Win32 savedata chooser")
|
||||
require(host / "vcs_profile.cpp", "savedata_mode_has_list_ui", "savedata LIST modes")
|
||||
require(host / "vcs_profile.cpp", "update_savedata_list_utility", "interactive savedata HLE state machine")
|
||||
require(host / "vcs_profile.cpp", "savedata_utility_ui_render_frame(display_state.frame_buffer);",
|
||||
"savedata utility rendered into GE framebuffer")
|
||||
require(host / "savedata_utility_ui.cpp", "ge_gpu_backend_accumulate_color_triangles",
|
||||
"in-frame savedata utility draw")
|
||||
if "host/savedata_dialog.cpp" in (profile / "CMakeLists.txt").read_text(encoding="utf-8", errors="replace"):
|
||||
errors.append("rejected Win32 savedata dialog must not be linked")
|
||||
|
||||
require(generated / "generated_units.hpp", '#include "vcs_draw_distance_patch.hpp"',
|
||||
"generated draw-distance state header")
|
||||
@@ -63,7 +68,7 @@ if errors:
|
||||
|
||||
print("CORRECTNESS 0.1 SOURCE AUDIT: PASS")
|
||||
print(" - audio release/timing guards present")
|
||||
print(" - savedata list selector linked")
|
||||
print(" - savedata LIST UI is firmware-style HLE rendered in the PSP framebuffer")
|
||||
print(" - draw-distance local AOT labels + regeneration hooks present")
|
||||
print(" - DX12 unsafe opaque blend fallback removed")
|
||||
print(" - draw distance remains opt-in for baseline FPS comparison")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cmake=(root/'CMakeLists.txt').read_text(encoding='utf-8')
|
||||
main=(root/'host/main.cpp').read_text(encoding='utf-8')
|
||||
profile=(root/'host/vcs_profile.cpp').read_text(encoding='utf-8')
|
||||
u127=(root/'generated/generated_unit_0127.cpp').read_text(encoding='utf-8')
|
||||
u172=(root/'generated/generated_unit_0172.cpp').read_text(encoding='utf-8')
|
||||
ini=(root/'config/VCSNative.ini').read_text(encoding='utf-8')
|
||||
display=(root/'host/display_window.cpp').read_text(encoding='utf-8')
|
||||
|
||||
def fail(msg):
|
||||
print('LOAD-ONLY STARTUP AUDIT: FAIL - '+msg)
|
||||
sys.exit(1)
|
||||
if 'host/native_frontend_boot.cpp' in cmake: fail('retired native frontend hook still linked')
|
||||
if 'native_frontend_boot_arm' in main: fail('main still arms native GAME frontend')
|
||||
if 'native_frontend_boot_' in u127 or 'native_frontend_boot_' in u172: fail('generated AOT still contains V8 autoload hooks')
|
||||
if 'startup_load_picker_consumed' not in profile or 'savedata_utility.startup_picker = true' not in profile:
|
||||
fail('first AUTOLOAD/LOAD is not promoted to load picker')
|
||||
if 'savedata_utility.mode = 4u' not in profile: fail('startup picker is not host-side LISTLOAD')
|
||||
if 'guest_mode == 0u' not in profile or 'guest_mode == 2u' not in profile: fail('AUTOLOAD/LOAD modes not covered')
|
||||
if 'if (!savedata_utility.startup_picker)' not in profile: fail('startup picker cancellation guard missing')
|
||||
if 'MouseMenu=false' not in ini: fail('mouse menu is not disabled by default')
|
||||
if 'if (mouse_menu_enabled()) enqueue_synthetic_pulse(state, kPspCross, 2);' not in display:
|
||||
fail('savedata mouse click is not gated by MouseMenu')
|
||||
print('LOAD-ONLY STARTUP AUDIT: PASS')
|
||||
print(' - native GAME frontend boot hook is unlinked')
|
||||
print(' - V8 generated autoload interception is removed')
|
||||
print(' - first AUTOLOAD and every explicit LOAD become the in-frame Load Game picker')
|
||||
print(' - startup picker cannot fall through to New Game via cancel')
|
||||
print(' - menu mouse remains disabled by default and gates savedata clicks')
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
profile = Path(__file__).resolve().parents[1]
|
||||
host = profile / 'host'
|
||||
errors = []
|
||||
|
||||
def text(path):
|
||||
return path.read_text(encoding='utf-8', errors='replace')
|
||||
|
||||
def require(path, needle, label):
|
||||
if needle not in text(path):
|
||||
errors.append(f'{label}: missing {needle!r}')
|
||||
|
||||
def forbid(path, needle, label):
|
||||
if needle in text(path):
|
||||
errors.append(f'{label}: forbidden {needle!r}')
|
||||
|
||||
cmake = profile / 'CMakeLists.txt'
|
||||
profile_cpp = host / 'vcs_profile.cpp'
|
||||
ui_cpp = host / 'savedata_utility_ui.cpp'
|
||||
display = host / 'display_window.cpp'
|
||||
ge = host / 'ge_renderer.cpp'
|
||||
|
||||
require(cmake, 'host/savedata_utility_ui.cpp', 'savedata utility HLE linked')
|
||||
forbid(cmake, 'host/savedata_dialog.cpp', 'retired Win32 chooser unlinked')
|
||||
require(profile_cpp, 'savedata_mode_has_list_ui', 'LIST mode classifier')
|
||||
require(profile_cpp, 'update_savedata_list_utility(rt);', 'interactive utility state machine')
|
||||
require(profile_cpp, 'execute_selected_savedata_slot', 'slot operation handoff')
|
||||
require(profile_cpp, 'SavedataUtilityUiPrompt::Confirm', 'confirm state')
|
||||
require(profile_cpp, 'SavedataUtilityUiPrompt::Result', 'result state')
|
||||
require(profile_cpp, 'savedata_utility_ui_render_frame(display_state.frame_buffer);',
|
||||
'HLE UI emitted at vblank')
|
||||
require(ge, 'savedata_utility_ui_observe_draw(gpu_draw, count);',
|
||||
'displayed GE target observed')
|
||||
require(ui_cpp, 'ge_gpu_backend_accumulate_color_triangles',
|
||||
'utility geometry enters GPU framebuffer')
|
||||
require(ui_cpp, 'x = 27.0f; y = 97.0f; w = 144.0f; h = 80.0f;',
|
||||
'PSP savedata selected-icon geometry')
|
||||
require(ui_cpp, 'quad(v, 0, 0, 480, 23', 'PSP utility banner geometry')
|
||||
require(ui_cpp, 'quad(v, 180, 136, 480, 137', 'PSP save-info separator geometry')
|
||||
require(profile_cpp, 'slot.icon0_path = icon0.string();', 'savedata ICON0 path preserved')
|
||||
require(ui_cpp, 'avcodec_find_decoder(AV_CODEC_ID_PNG)', 'ICON0 PNG decoded in-process')
|
||||
require(ui_cpp, 'ge_gpu_backend_upload_decoded_texture', 'decoded ICON0 uploaded to GE backend')
|
||||
require(display, 'display_window_set_system_utility_mode', 'utility input ownership')
|
||||
require(display, 'enqueue_synthetic_pulse(state, kPspCross, 2);', 'mouse left click -> Cross')
|
||||
require(display, 'enqueue_synthetic_pulse(state, kPspCircle, 2);', 'mouse right click -> Circle')
|
||||
|
||||
for path in (ui_cpp, host / 'savedata_dialog.cpp', host / 'savedata_dialog.hpp'):
|
||||
forbid(path, 'CreateWindowExW', 'no savedata host HWND')
|
||||
forbid(path, 'DialogBox', 'no savedata dialog API')
|
||||
forbid(path, 'choose_savedata_slot', 'old chooser removed')
|
||||
|
||||
if errors:
|
||||
print('NATIVE SAVEDATA UTILITY AUDIT: FAIL')
|
||||
for error in errors:
|
||||
print(' -', error)
|
||||
sys.exit(1)
|
||||
|
||||
print('NATIVE SAVEDATA UTILITY AUDIT: PASS')
|
||||
print(' - LISTLOAD/LISTSAVE/LISTDELETE remain asynchronous utility states')
|
||||
print(' - utility is drawn into the PSP GE framebuffer, not an HWND')
|
||||
print(' - PSP save-list/banner/info geometry and real ICON0.PNG decoding are present')
|
||||
print(' - metadata path and confirm/result states are wired')
|
||||
print(' - keyboard/gamepad plus utility mouse routing are wired')
|
||||
@@ -51,6 +51,8 @@ int main() {
|
||||
<< "LogToFile=true\n"
|
||||
<< "LogFile=vcs-config-test.log\n"
|
||||
<< "FlushEveryLine=false\n"
|
||||
<< "[Frontend]\n"
|
||||
<< "MouseMenu=false\n"
|
||||
<< "[Controls]\n"
|
||||
<< "CameraStick=true\n"
|
||||
<< "MouseSensitivity=17\n"
|
||||
@@ -131,6 +133,8 @@ int main() {
|
||||
"Diagnostics.LogFile was not parsed");
|
||||
require(!config.diagnostics.flush_every_line,
|
||||
"Diagnostics.FlushEveryLine was not parsed");
|
||||
require(!config.frontend.mouse_menu,
|
||||
"Frontend.MouseMenu was not parsed");
|
||||
require(config.controls.camera_stick, "camera stick was not parsed");
|
||||
require(config.controls.mouse_sensitivity == 17u,
|
||||
"mouse sensitivity was not parsed");
|
||||
@@ -244,6 +248,8 @@ int main() {
|
||||
"missing INI did not preserve PSP internal-resolution default");
|
||||
require(missing.controls.ped_camera_up_limit_degrees == 45u,
|
||||
"missing INI did not preserve the stock on-foot camera upper limit");
|
||||
require(!missing.frontend.mouse_menu,
|
||||
"missing INI did not preserve disabled pause-menu mouse default");
|
||||
const vcs::InternalResolutionDimensions native =
|
||||
vcs::resolve_internal_resolution(missing.rendering);
|
||||
require(native.width == 480u && native.height == 272u,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "vcs_savedata_startup.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
void require(bool condition, const char *message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
void write_file(const std::filesystem::path &path, const char *data) {
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out << data;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
const auto root = std::filesystem::temp_directory_path() / "vcs_savedata_startup_test";
|
||||
std::filesystem::remove_all(root);
|
||||
const auto a = root / "ULUS10160SLOT_A";
|
||||
const auto b = root / "ULUS10160SLOT_B";
|
||||
const auto invalid = root / "ULUS10160BROKEN";
|
||||
write_file(a / "DATA.BIN", "old");
|
||||
write_file(b / "DATA.BIN", "new");
|
||||
write_file(invalid / "ICON0.PNG", "art only");
|
||||
|
||||
const auto base = std::filesystem::file_time_type::clock::now();
|
||||
std::filesystem::last_write_time(a / "DATA.BIN", base - std::chrono::seconds(20));
|
||||
std::filesystem::last_write_time(b / "DATA.BIN", base - std::chrono::seconds(5));
|
||||
|
||||
std::vector<vcs::StartupSaveCandidate> candidates{
|
||||
{"SLOT_A", a}, {"SLOT_B", b}, {"BROKEN", invalid}
|
||||
};
|
||||
auto choice = vcs::choose_latest_startup_save(candidates, "DATA.BIN");
|
||||
require(choice.kind == vcs::StartupSaveChoiceKind::Latest,
|
||||
"newest valid save was not selected");
|
||||
require(choice.save_name == "SLOT_B", "wrong newest save selected");
|
||||
require(choice.valid_count == 2u, "invalid directory counted as a save");
|
||||
|
||||
// Many-save case: selection must still be timestamp-based rather than
|
||||
// slot-number-based. Add 40 more slots with older times and one truly
|
||||
// newest slot whose name sorts near the beginning.
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
const auto dir = root / ("ULUS10160MANY_" + std::to_string(i));
|
||||
write_file(dir / "DATA.BIN", "bulk");
|
||||
std::filesystem::last_write_time(
|
||||
dir / "DATA.BIN", base - std::chrono::seconds(100 + i));
|
||||
candidates.push_back({"MANY_" + std::to_string(i), dir});
|
||||
}
|
||||
const auto newest_dir = root / "ULUS10160AA_NEWEST";
|
||||
write_file(newest_dir / "DATA.BIN", "latest");
|
||||
std::filesystem::last_write_time(newest_dir / "DATA.BIN", base);
|
||||
candidates.push_back({"AA_NEWEST", newest_dir});
|
||||
choice = vcs::choose_latest_startup_save(candidates, "DATA.BIN");
|
||||
require(choice.kind == vcs::StartupSaveChoiceKind::Latest &&
|
||||
choice.save_name == "AA_NEWEST",
|
||||
"many-save selection used slot/name ordering instead of timestamp");
|
||||
|
||||
// Exact timestamp ties are intentionally ambiguous: do not guess based
|
||||
// on slot number/name when a copied save set has identical mtimes.
|
||||
std::filesystem::last_write_time(a / "DATA.BIN", base);
|
||||
choice = vcs::choose_latest_startup_save(candidates, "DATA.BIN");
|
||||
require(choice.kind == vcs::StartupSaveChoiceKind::AmbiguousLatest,
|
||||
"equal newest timestamps should open the picker");
|
||||
|
||||
for (const auto &candidate : candidates)
|
||||
std::filesystem::remove(candidate.directory / "DATA.BIN");
|
||||
choice = vcs::choose_latest_startup_save(candidates, "DATA.BIN");
|
||||
require(choice.kind == vcs::StartupSaveChoiceKind::NoValidSaves,
|
||||
"no valid data files should mean New Game fallback");
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "vcs_savedata_startup_tests: PASS\n";
|
||||
return 0;
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << "vcs_savedata_startup_tests: FAIL: " << error.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user