lodscreen and save screen

lodscreen and save screen
This commit is contained in:
Jessica_Natalia
2026-08-15 17:08:36 -03:00
parent 4a6b83aede
commit e48abf87ca
26 changed files with 4228 additions and 659 deletions
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -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%
+1 -1
View File
@@ -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
+6
View File
@@ -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
+443 -26
View File
@@ -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() {}
+18
View File
@@ -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();
+4 -1
View File
@@ -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)) {
+1
View File
@@ -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.
+6 -549
View File
@@ -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 -26
View File
@@ -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.
+582
View File
@@ -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 &target;
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
+57
View File
@@ -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
+20
View File
@@ -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
+7
View File
@@ -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{};
+483 -50
View File
@@ -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

+10 -5
View File
@@ -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')
+6
View File
@@ -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;
}
}