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

1380 lines
63 KiB
C++

#include "display_window.hpp"
#include "dx12_presenter.hpp"
#include "ge_gpu_backend.hpp"
#include "vcs_config.hpp"
#include "vcs_runtime_log.hpp"
#include "vcs_vehicle_input.hpp"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <mutex>
#include <stdexcept>
#include <iostream>
#include <string>
#include <vector>
#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <chrono>
#include <condition_variable>
#include <thread>
namespace vcs {
namespace {
constexpr std::uint32_t kPspSelect = 0x000001u;
constexpr std::uint32_t kPspStart = 0x000008u;
constexpr std::uint32_t kPspUp = 0x000010u;
constexpr std::uint32_t kPspRight = 0x000020u;
constexpr std::uint32_t kPspDown = 0x000040u;
constexpr std::uint32_t kPspLeft = 0x000080u;
constexpr std::uint32_t kPspLTrigger = 0x000100u;
constexpr std::uint32_t kPspRTrigger = 0x000200u;
constexpr std::uint32_t kPspTriangle = 0x001000u;
constexpr std::uint32_t kPspCircle = 0x002000u;
constexpr std::uint32_t kPspCross = 0x004000u;
constexpr std::uint32_t kPspSquare = 0x008000u;
constexpr UINT kMessagePresent = WM_APP + 1u;
struct KeyBinding {
int virtual_key;
std::uint32_t psp_button;
};
// GTA San Andreas' own PC defaults, mapped onto the PSP pad.
//
// VCS is a Vice City-era game on a console with four faces and two shoulders,
// so it cannot take San Andreas' full set; what it shares, it shares on the
// same key. Verified against the published SA control list rather than
// recalled: Sprint is Space and Jump is Left Shift (not the other way round),
// vehicles enter on F or Return, and weapons cycle on Q and E.
//
// The two collisions worth naming:
//
// * San Andreas fires and accelerates both on the left mouse button. VCS
// splits them, but ThirteenAG's modern control scheme already routes both
// through R -- R fires on foot and accelerates in a vehicle -- so one
// binding reproduces San Andreas in both contexts.
// * Crouch (C) has no counterpart. Vice City-era protagonists do not crouch,
// so it stays unbound rather than being given something to do.
//
// Movement is deliberately absent here: WASD drives the analog stick, because
// the digital D-pad makes the character walk in eight directions.
constexpr KeyBinding kKeyBindings[] = {
// On foot: sprint, jump, enter/exit, weapon cycling.
{VK_SPACE, kPspCross}, // Sprint on foot, handbrake in a vehicle
{VK_LSHIFT, kPspSquare}, // Jump on foot, brake/reverse in a vehicle
{VK_RSHIFT, kPspSquare},
{'F', kPspTriangle}, // Enter/exit vehicle
{VK_RETURN, kPspTriangle},
// Weapon select and radio both live on the D-pad in VCS, so Q/E and the
// wheel all land there -- the same one binding covers both contexts, which
// is how San Andreas' wheel behaves.
{'Q', kPspLeft}, // Previous weapon / radio station
{'E', kPspRight}, // Next weapon / radio station
{'H', kPspLTrigger}, // Horn
// The arrow keys stay on the D-pad: menus are navigated with them, and the
// PSP D-pad is what the game's own front end reads.
{VK_UP, kPspUp}, {VK_DOWN, kPspDown},
{VK_LEFT, kPspLeft}, {VK_RIGHT, kPspRight},
{VK_ESCAPE, kPspStart}, // Pause. Closing the window moved to Alt+F4.
{VK_TAB, kPspSelect},
};
// Mouse buttons follow San Andreas: fire left, aim right, look behind on the
// middle button.
//
// Measured out of the game rather than assumed. ThirteenAG's plugin replaces
// three pad accessors wholesale, so disassembling the stock versions at the
// addresses it patches says which field each one reads:
//
// CPad::GetWeapon (fire) loads 0x2C -> circle
// CPad::GetTarget (aim) loads 0x0E -> R1
// CPad::GetLookBehindForPed loads 0x0E and 0x0A -> R1, L1
//
// Worth stating because two earlier attempts here were guesses. The accessors
// in the plugin's own source describe its ModernControlScheme, not the shipped
// game, and reading them as documentation put fire on the wrong button twice.
constexpr KeyBinding kMouseBindings[] = {
{VK_LBUTTON, kPspCircle}, // Fire / punch
{VK_RBUTTON, kPspRTrigger}, // Target
{VK_MBUTTON, kPspLTrigger}, // Look behind
};
// WASD drives the analog stick rather than the D-pad, matching San Andreas.
constexpr int kMoveForward = 'W';
constexpr int kMoveBack = 'S';
constexpr int kMoveLeft = 'A';
constexpr int kMoveRight = 'D';
struct WindowState {
std::thread thread;
std::mutex mutex;
std::condition_variable ready_signal;
std::vector<std::uint32_t> pixels; // 0x00RRGGBB, top-down
std::uint32_t width{};
std::uint32_t height{};
std::uint64_t frame_index{};
std::atomic<HWND> window{nullptr};
std::atomic<bool> ready{false};
std::atomic<bool> focused{false};
std::atomic<bool> close_requested{false};
std::atomic<std::uint32_t> save_repro_commands{0u};
// Raw mouse motion accumulated by the window thread and drained by the
// guest's controller poll. Raw input rather than cursor position: the
// cursor stops at the screen edge, and a camera that stops turning when
// the pointer reaches the edge of a 3440-wide monitor is unusable.
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};
bool mouse_captured{false};
std::string status{"booting"};
DisplayConfiguration configuration{};
int client_width{480};
int client_height{272};
// Back buffer for WM_PAINT. Filling the window black and then stretching
// into it directly made the whole client flash black every frame, which is
// very visible once the client is desktop sized.
HDC back_buffer_dc{nullptr};
HBITMAP back_buffer_bitmap{nullptr};
HGDIOBJ back_buffer_previous{nullptr};
int back_buffer_width{};
int back_buffer_height{};
};
constexpr UINT_PTR kStatusTimer = 1u;
WindowState &window_state() {
static WindowState 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;
}
// XInput, loaded at run time. Linking it would make the executable refuse to
// start on a machine without the redistributable, for a feature that is
// optional by definition -- a missing gamepad is not an error.
struct XInputGamepad {
std::uint16_t buttons;
std::uint8_t left_trigger;
std::uint8_t right_trigger;
std::int16_t lx, ly, rx, ry;
};
struct XInputStatePacket {
std::uint32_t packet;
XInputGamepad gamepad;
};
using PfnXInputGetState = std::uint32_t(WINAPI *)(std::uint32_t, XInputStatePacket *);
constexpr std::uint16_t kPadDpadUp = 0x0001u;
constexpr std::uint16_t kPadDpadDown = 0x0002u;
constexpr std::uint16_t kPadDpadLeft = 0x0004u;
constexpr std::uint16_t kPadDpadRight = 0x0008u;
constexpr std::uint16_t kPadStart = 0x0010u;
constexpr std::uint16_t kPadBack = 0x0020u;
constexpr std::uint16_t kPadLeftShoulder = 0x0100u;
constexpr std::uint16_t kPadRightShoulder = 0x0200u;
constexpr std::uint16_t kPadA = 0x1000u;
constexpr std::uint16_t kPadB = 0x2000u;
constexpr std::uint16_t kPadX = 0x4000u;
constexpr std::uint16_t kPadY = 0x8000u;
[[nodiscard]] PfnXInputGetState xinput_get_state() noexcept {
// Newest first: 1_4 ships with Windows 8 and later, 9_1_0 is the version
// present on every machine since Vista, and 1_3 covers the old SDK
// redistributable.
static PfnXInputGetState resolved = [] () -> PfnXInputGetState {
for (const wchar_t *name : {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll"}) {
if (HMODULE module = LoadLibraryW(name)) {
if (auto function = reinterpret_cast<PfnXInputGetState>(
reinterpret_cast<void *>(GetProcAddress(module, "XInputGetState"))))
return function;
}
}
return nullptr;
}();
return resolved;
}
// Analog sticks arrive as signed 16-bit and leave as the PSP's 0..255 with 128
// at rest. The dead zone is applied before the rescale so the rest position is
// exactly 128 rather than a value that drifts by a unit or two.
[[nodiscard]] std::uint8_t stick_to_psp(std::int16_t value, bool invert) noexcept {
constexpr int kDeadZone = 7849; // XInput's own documented left-stick figure
int magnitude = std::abs(static_cast<int>(value));
if (magnitude <= kDeadZone) return 128u;
magnitude = (magnitude - kDeadZone) * 32767 / (32767 - kDeadZone);
int signed_value = value < 0 ? -magnitude : magnitude;
if (invert) signed_value = -signed_value;
return static_cast<std::uint8_t>(std::clamp(128 + signed_value * 127 / 32767, 0, 255));
}
int legacy_configured_scale() {
const char *text = std::getenv("PSPRECOMP_WINDOW_SCALE");
if (text == nullptr || *text == '\0') return 0;
char *end = nullptr;
const long value = std::strtol(text, &end, 10);
if (end == text || *end != '\0' || value < 1 || value > 16) return 0;
return static_cast<int>(value);
}
void resolve_client_size(WindowState &state) {
const int desktop_width = std::max(1, GetSystemMetrics(SM_CXSCREEN));
const int desktop_height = std::max(1, GetSystemMetrics(SM_CYSCREEN));
if (state.configuration.fullscreen ||
state.configuration.resolution_mode == DisplayResolutionMode::Desktop) {
state.client_width = desktop_width;
state.client_height = desktop_height;
} else if (state.configuration.resolution_mode == DisplayResolutionMode::Custom) {
state.client_width = static_cast<int>(state.configuration.custom_width);
state.client_height = static_cast<int>(state.configuration.custom_height);
} else {
state.client_width = 480;
state.client_height = 272;
}
// Backwards compatibility with the stage scripts that predate VCSNative.ini.
// An explicit legacy scale overrides the windowed client dimensions only.
if (!state.configuration.fullscreen) {
const int legacy_scale = legacy_configured_scale();
if (legacy_scale != 0) {
state.client_width = 480 * legacy_scale;
state.client_height = 272 * legacy_scale;
}
}
}
LRESULT CALLBACK window_procedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
WindowState &state = window_state();
switch (message) {
case kMessagePresent:
InvalidateRect(window, nullptr, FALSE);
return 0;
case WM_SETFOCUS:
state.focused.store(true, std::memory_order_relaxed);
return 0;
case WM_KILLFOCUS:
state.focused.store(false, std::memory_order_relaxed);
return 0;
case WM_KEYDOWN:
// Gameplay keys keep using the existing sampled-input path. Diagnostic
// F8/F10 edges are queued here on the UI thread, with auto-repeat
// ignored, so no GetAsyncKeyState call is added to guest timing.
if (save_repro_testing_enabled() &&
(static_cast<std::uint32_t>(lparam) & (1u << 30u)) == 0u) {
if (wparam == VK_F8)
state.save_repro_commands.fetch_or(0x1u, std::memory_order_release);
else if (wparam == VK_F10)
state.save_repro_commands.fetch_or(0x2u, std::memory_order_release);
}
// Do not infer pause menu ownership from Escape here: the guest may
// consume the press for an intro/transition.
return 0;
case WM_INPUT: {
// Raw mouse deltas. Sized from the message rather than assumed: the
// header is followed by a union whose size differs between builds.
UINT size = 0u;
GetRawInputData(reinterpret_cast<HRAWINPUT>(lparam), RID_INPUT, nullptr,
&size, sizeof(RAWINPUTHEADER));
if (size != 0u && size <= 256u) {
alignas(8) std::byte buffer[256];
if (GetRawInputData(reinterpret_cast<HRAWINPUT>(lparam), RID_INPUT, buffer,
&size, sizeof(RAWINPUTHEADER)) == size) {
const RAWINPUT *raw = reinterpret_cast<const RAWINPUT *>(buffer);
if (raw->header.dwType == RIM_TYPEMOUSE &&
(raw->data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) == 0) {
state.mouse_dx.fetch_add(raw->data.mouse.lLastX, std::memory_order_relaxed);
state.mouse_dy.fetch_add(raw->data.mouse.lLastY, std::memory_order_relaxed);
}
}
}
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:
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:
// 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) {
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;
case WM_ERASEBKGND:
return 1;
case WM_TIMER: {
if (wparam != kStatusTimer) break;
std::wstring title = L"VCSNative — GTA: Vice City Stories — ";
{
std::lock_guard<std::mutex> guard(state.mutex);
title.append(state.status.begin(), state.status.end());
}
title += L" — ";
title += std::to_wstring(state.client_width);
title += L"x";
title += std::to_wstring(state.client_height);
title += state.configuration.upscale_filter == DisplayUpscaleFilter::Bilinear
? L" bilinear" : L" nearest";
SetWindowTextW(window, title.c_str());
return 0;
}
case WM_PAINT: {
PAINTSTRUCT paint{};
HDC window_context = BeginPaint(window, &paint);
RECT client{};
GetClientRect(window, &client);
// Once DirectX 12 owns presentation there is no GDI back buffer to
// repaint. Begin/EndPaint still validates the update region; DWM keeps
// the last flip-model swapchain image visible between presents.
if (dx12_presenter_active()) {
EndPaint(window, &paint);
return 0;
}
const int client_w = std::max(1L, client.right - client.left);
const int client_h = std::max(1L, client.bottom - client.top);
std::lock_guard<std::mutex> guard(state.mutex);
if (state.back_buffer_dc == nullptr || state.back_buffer_width != client_w ||
state.back_buffer_height != client_h) {
if (state.back_buffer_dc != nullptr) {
SelectObject(state.back_buffer_dc, state.back_buffer_previous);
DeleteObject(state.back_buffer_bitmap);
DeleteDC(state.back_buffer_dc);
}
state.back_buffer_dc = CreateCompatibleDC(window_context);
state.back_buffer_bitmap =
CreateCompatibleBitmap(window_context, client_w, client_h);
state.back_buffer_previous =
SelectObject(state.back_buffer_dc, state.back_buffer_bitmap);
state.back_buffer_width = client_w;
state.back_buffer_height = client_h;
}
HDC context = state.back_buffer_dc != nullptr ? state.back_buffer_dc : window_context;
if (state.width != 0u && state.height != 0u && !state.pixels.empty()) {
BITMAPINFO info{};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = static_cast<LONG>(state.width);
// Negative height selects a top-down DIB, matching our row order.
info.bmiHeader.biHeight = -static_cast<LONG>(state.height);
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
FillRect(context, &client, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
const PresentationRectangle output = calculate_presentation_rectangle(
static_cast<std::uint32_t>(std::max(0L, client.right - client.left)),
static_cast<std::uint32_t>(std::max(0L, client.bottom - client.top)),
state.width, state.height,
state.aspect_lock.load(std::memory_order_relaxed)
? DisplayAspectMode::Preserve
: state.configuration.aspect_mode,
state.configuration.integer_scale);
// HALFTONE is expensive and buys nothing when the blit is 1:1,
// which is the normal case once the internal target matches the
// client size.
const bool scaling = output.width != static_cast<int>(state.width) ||
output.height != static_cast<int>(state.height);
if (scaling && state.configuration.upscale_filter == DisplayUpscaleFilter::Bilinear) {
SetStretchBltMode(context, HALFTONE);
SetBrushOrgEx(context, 0, 0, nullptr);
} else {
SetStretchBltMode(context, COLORONCOLOR);
}
StretchDIBits(context,
output.x, output.y, output.width, output.height,
0, 0, static_cast<int>(state.width), static_cast<int>(state.height),
state.pixels.data(), &info, DIB_RGB_COLORS, SRCCOPY);
} else {
FillRect(context, &client, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
const std::wstring message(state.status.begin(), state.status.end());
SetBkMode(context, TRANSPARENT);
SetTextColor(context, RGB(200, 200, 200));
DrawTextW(context, message.c_str(), -1, &client,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
if (context != window_context)
BitBlt(window_context, 0, 0, client_w, client_h, context, 0, 0, SRCCOPY);
EndPaint(window, &paint);
return 0;
}
case WM_CLOSE:
state.close_requested.store(true, std::memory_order_relaxed);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(window, message, wparam, lparam);
}
void window_thread_main() {
WindowState &state = window_state();
const HINSTANCE instance = GetModuleHandleW(nullptr);
WNDCLASSEXW window_class{};
window_class.cbSize = sizeof(window_class);
window_class.lpfnWndProc = window_procedure;
window_class.hInstance = instance;
// The project does not define UNICODE, so IDC_ARROW expands to the ANSI
// MAKEINTRESOURCE form; select the wide one explicitly.
window_class.hCursor = LoadCursorW(nullptr, MAKEINTRESOURCEW(32512));
window_class.lpszClassName = L"VCSNativeDisplay";
RegisterClassExW(&window_class);
resolve_client_size(state);
const DWORD style = state.configuration.fullscreen ? WS_POPUP : WS_OVERLAPPEDWINDOW;
RECT bounds{0, 0, state.client_width, state.client_height};
int window_x = 0;
int window_y = 0;
if (!state.configuration.fullscreen) {
AdjustWindowRect(&bounds, style, FALSE);
// Desktop resolution mode asks for a client as large as the monitor, and
// the frame AdjustWindowRect adds on top of it made the window larger
// than the screen. Placed at CW_USEDEFAULT it was also offset, so the
// right and bottom of the client sat outside the monitor: the radar was
// halved and the money/weapon icons were clipped by the screen edge, not
// by the renderer. Fit the whole window inside the work area instead and
// shrink the client by whatever the frame costs.
RECT work{0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
const int frame_width = (bounds.right - bounds.left) - state.client_width;
const int frame_height = (bounds.bottom - bounds.top) - state.client_height;
const int available_width =
static_cast<int>(std::max(1L, work.right - work.left)) - frame_width;
const int available_height =
static_cast<int>(std::max(1L, work.bottom - work.top)) - frame_height;
state.client_width = std::clamp(state.client_width, 1, std::max(1, available_width));
state.client_height = std::clamp(state.client_height, 1, std::max(1, available_height));
bounds = RECT{0, 0, state.client_width, state.client_height};
AdjustWindowRect(&bounds, style, FALSE);
window_x = static_cast<int>(work.left);
window_y = static_cast<int>(work.top);
}
const HWND window = CreateWindowExW(
0, window_class.lpszClassName, L"VCSNative — GTA: Vice City Stories",
style, window_x, window_y,
bounds.right - bounds.left, bounds.bottom - bounds.top,
nullptr, nullptr, instance, nullptr);
state.window.store(window, std::memory_order_release);
{
std::lock_guard<std::mutex> guard(state.mutex);
state.ready.store(true, std::memory_order_release);
}
state.ready_signal.notify_all();
if (window == nullptr) return;
ShowWindow(window, SW_SHOW);
UpdateWindow(window);
SetForegroundWindow(window);
state.focused.store(true, std::memory_order_relaxed);
SetTimer(window, kStatusTimer, 250u, nullptr);
// Raw mouse input for the camera. Registered on this window rather than
// with RIDEV_INPUTSINK, so the game stops turning when you tab away.
const RAWINPUTDEVICE mouse{0x01u, 0x02u, 0u, window};
RegisterRawInputDevices(&mouse, 1u, sizeof(mouse));
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
state.window.store(nullptr, std::memory_order_release);
}
void ensure_window_started() {
WindowState &state = window_state();
static std::once_flag once;
std::call_once(once, [&state] {
state.configuration = vcs_configuration().display;
// Detached: the UI thread outlives every emulation stop path, and a
// function-local static std::thread must never be destroyed joinable.
state.thread = std::thread(window_thread_main);
state.thread.detach();
std::unique_lock<std::mutex> guard(state.mutex);
state.ready_signal.wait(guard, [&state] { return state.ready.load(std::memory_order_acquire); });
});
}
} // namespace
bool display_window_enabled() {
static const bool enabled = [] {
const char *text = std::getenv("PSPRECOMP_WINDOW");
if (text != nullptr && *text != '\0') return std::string(text) != "0";
const VcsConfiguration &configuration = vcs_configuration();
return configuration.initialized && configuration.display.enabled;
}();
return enabled;
}
void display_window_start() {
if (!display_window_enabled()) return;
ensure_window_started();
if (vcs_configuration().rendering.backend != RenderingBackend::DirectX12) return;
WindowState &state = window_state();
HWND window = state.window.load(std::memory_order_acquire);
if (ge_gpu_backend_active()) {
// Native GE owns the D3D12 queue/swapchain. Avoid creating a second
// presenter/device for the same HWND.
ge_gpu_backend_set_native_window(window);
return;
}
// Native GE may have been intentionally disabled or may have failed its
// feature probe. In non-strict play mode the stable software GE can still
// be shown through the standalone D3D12 presenter.
std::string error;
if (window == nullptr || !dx12_presenter_initialize(window, error)) {
runtime_log_error("dx12 initialize", error.empty() ? "window unavailable" : error);
std::cerr << "[dx12] initialize failed at startup: "
<< (error.empty() ? "window unavailable" : error) << "\n";
return;
}
const Dx12PresenterStatus dx = dx12_presenter_status();
std::cout << "DirectX 12 presenter: " << dx.adapter_name
<< " / " << dx.frames_in_flight << " frames in flight"
<< (dx.tearing_supported ? " / tearing" : "") << "\n";
}
void display_window_set_aspect_lock(bool locked) noexcept {
if (!display_window_enabled()) return;
window_state().aspect_lock.store(locked, std::memory_order_relaxed);
}
void display_window_set_status(const char *status) {
if (!display_window_enabled() || status == nullptr) return;
WindowState &state = window_state();
if (!state.ready.load(std::memory_order_acquire)) return;
std::lock_guard<std::mutex> guard(state.mutex);
state.status = status;
}
void display_window_present(const psprecomp::GuestMemory &memory,
const FramebufferDescription &description) {
if (!display_window_enabled()) return;
ensure_window_started();
if (description.address == 0u || description.width == 0u || description.height == 0u ||
description.stride == 0u) {
return;
}
WindowState &state = window_state();
const HWND window = state.window.load(std::memory_order_acquire);
if (window == nullptr) return;
if (vcs_configuration().rendering.backend == RenderingBackend::DirectX12 &&
!vcs_configuration().rendering.dx12_ge_color) {
if (!dx12_presenter_active()) {
std::string error;
if (!dx12_presenter_initialize(window, error)) {
runtime_log_error("dx12 initialize", error);
std::cerr << "[dx12] initialize failed: " << error << "\n";
}
}
if (dx12_presenter_active()) {
try {
std::vector<std::byte> rgba = decode_framebuffer_rgba(memory, description);
std::string error;
if (!dx12_presenter_present_rgba(
rgba, description.width, description.height, state.configuration,
state.aspect_lock.load(std::memory_order_relaxed), error)) {
runtime_log_error("dx12 present", error);
std::cerr << "[dx12] present failed: " << error << "\n";
dx12_presenter_shutdown();
} else {
return;
}
} catch (const std::exception &error) {
runtime_log_error("dx12 framebuffer decode", error.what());
std::cerr << "[dx12] framebuffer decode failed: " << error.what() << "\n";
return;
}
}
}
std::vector<std::uint8_t> rgb;
try {
rgb = decode_framebuffer_rgb(memory, description);
} catch (const std::exception &) {
// A transient framebuffer pointer outside EDRAM must never take the
// host down; the previous frame simply stays on screen.
return;
}
const std::size_t pixel_count = static_cast<std::size_t>(description.width) * description.height;
{
std::lock_guard<std::mutex> guard(state.mutex);
state.width = description.width;
state.height = description.height;
state.pixels.resize(pixel_count);
for (std::size_t index = 0; index < pixel_count; ++index) {
const std::uint32_t red = rgb[index * 3u + 0u];
const std::uint32_t green = rgb[index * 3u + 1u];
const std::uint32_t blue = rgb[index * 3u + 2u];
state.pixels[index] = (red << 16u) | (green << 8u) | blue;
}
++state.frame_index;
}
PostMessageW(window, kMessagePresent, 0, 0);
}
void display_window_present_rgba(std::span<const std::byte> rgba,
std::uint32_t width,
std::uint32_t height) {
if (!display_window_enabled() || width == 0u || height == 0u) return;
const std::size_t pixel_count = static_cast<std::size_t>(width) * height;
if (rgba.size() < pixel_count * 4u) return;
ensure_window_started();
WindowState &state = window_state();
const HWND window = state.window.load(std::memory_order_acquire);
if (window == nullptr) return;
if (vcs_configuration().rendering.backend == RenderingBackend::DirectX12 &&
!vcs_configuration().rendering.dx12_ge_color) {
if (!dx12_presenter_active()) {
std::string error;
if (!dx12_presenter_initialize(window, error)) {
runtime_log_error("dx12 initialize", error);
std::cerr << "[dx12] initialize failed: " << error << "\n";
}
}
if (dx12_presenter_active()) {
std::string error;
if (!dx12_presenter_present_rgba(
rgba, width, height, state.configuration,
state.aspect_lock.load(std::memory_order_relaxed), error)) {
runtime_log_error("dx12 present", error);
std::cerr << "[dx12] present failed: " << error << "\n";
dx12_presenter_shutdown();
} else {
return;
}
}
}
{
std::lock_guard<std::mutex> guard(state.mutex);
state.width = width;
state.height = height;
state.pixels.resize(pixel_count);
// One 32-bit load and a red/blue swap per pixel. The byte-at-a-time
// version cost several milliseconds per frame once the internal target
// reached desktop resolution (a 3440x1440 frame is 4.95M pixels).
for (std::size_t index = 0u; index < pixel_count; ++index) {
std::uint32_t source = 0u;
std::memcpy(&source, rgba.data() + index * 4u, sizeof(source));
state.pixels[index] = (source & 0x0000FF00u) |
((source & 0x000000FFu) << 16u) |
((source >> 16u) & 0x000000FFu);
}
++state.frame_index;
}
PostMessageW(window, kMessagePresent, 0, 0);
}
DisplayWindowSurface display_window_surface() {
if (!display_window_enabled()) return {};
ensure_window_started();
WindowState &state = window_state();
HWND window = state.window.load(std::memory_order_acquire);
if (window == nullptr) return {};
RECT client{};
GetClientRect(window, &client);
return {window, GetModuleHandleW(nullptr),
static_cast<std::uint32_t>(std::max(0L, client.right - client.left)),
static_cast<std::uint32_t>(std::max(0L, client.bottom - client.top))};
}
std::uint32_t display_window_buttons() {
// Shares one reading with the analog path. Doing its own pass over
// kKeyBindings is what left the mouse out entirely: the mouse buttons and
// the wheel live in the reading below, so punching and tuning the radio
// never reached the guest while the keyboard worked fine.
return display_window_input().buttons;
}
void display_window_analog(std::uint8_t &x, std::uint8_t &y) {
const HostInputState input = display_window_input();
x = input.analog_x;
y = input.analog_y;
}
HostInputState display_window_input() {
// The buttons and the analog stick are fetched by separate callers within
// one controller poll, and the mouse deltas can only be drained once -- so
// the reading is cached for a few milliseconds and both callers get the
// same one. Shorter than a frame, so nothing here is ever perceptibly old.
static std::mutex cache_mutex;
static HostInputState cached{};
static std::chrono::steady_clock::time_point cached_at{};
const std::lock_guard<std::mutex> guard(cache_mutex);
const auto now = std::chrono::steady_clock::now();
if (cached_at.time_since_epoch().count() != 0 &&
now - cached_at < std::chrono::milliseconds(4))
return cached;
cached_at = now;
cached = HostInputState{};
HostInputState input{};
const auto publish = [&]() -> HostInputState & { cached = input; return cached; };
if (!display_window_enabled()) return publish();
WindowState &state = window_state();
if (!state.ready.load(std::memory_order_acquire)) return publish();
// The mouse keeps accumulating while the window is not focused, so the
// deltas are drained either way. Leaving them to pile up made the camera
// whip round on the frame focus came back.
const std::int32_t mouse_dx = state.mouse_dx.exchange(0, std::memory_order_relaxed);
const std::int32_t mouse_dy = state.mouse_dy.exchange(0, std::memory_order_relaxed);
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;
// 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
// throttle accessors, so their being read is the signal. See
// vcs_vehicle_input.hpp.
const bool driving = vcs_player_in_vehicle();
int move_x = 0;
int move_y = 0;
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 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
// that is where it goes.
if (key_down(VK_UP)) move_y -= 1;
if (key_down(VK_DOWN)) move_y += 1;
}
// 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 = !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));
input.analog_y = static_cast<std::uint8_t>(std::clamp(128 + move_y * reach, 0, 255));
// San Andreas puts weapon cycling and radio tuning on the same wheel, and
// VCS puts both on the D-pad -- so one binding reproduces both, without the
// host needing to know whether you are on foot or driving.
//
// Held across several polls rather than pulsed for one. A wheel notch is
// instantaneous, and a button that goes down and up inside a single poll is
// a press the game never sees: it compares this poll against the last one
// to find edges, and both of them can miss the middle.
static int wheel_hold = 0;
static std::uint32_t wheel_button = 0u;
if (wheel != 0) {
wheel_button = wheel > 0 ? kPspLeft : kPspRight;
wheel_hold = 4;
}
if (wheel_hold > 0) {
--wheel_hold;
input.buttons |= wheel_button;
}
const ControlsConfiguration &controls = vcs_configuration().controls;
const int sensitivity = static_cast<int>(controls.mouse_sensitivity);
// A curve rather than a multiply-and-clamp.
//
// The axis is a stick deflection, so it is a turn *rate* with a hard
// ceiling at 127, and `delta * 12` reaches that ceiling at eleven counts of
// mouse movement. An ordinary flick produces many times that, so the value
// sat pinned at the ceiling nearly all the time: every difference between
// a nudge and a sweep was discarded before the game saw it, which is what
// made aiming feel like it moved in steps.
//
// x/(x+k) keeps small movements proportional -- a slow drag still maps
// almost linearly -- while approaching the ceiling asymptotically instead
// of slamming into it, so a fast flick stays faster than a slow one all the
// way up. Sensitivity now scales the curve rather than the clamp.
// Full range, and the curve rises quickly to reach it.
//
// Capping the peak at 63 was tried, on the theory that the rate was double
// what the camera code expects -- the accessor being replaced does shift
// its result right by one. It is not the answer: the pad's right stick goes
// to the full 127 through a different path, turns at a speed the player
// likes, and stalls no more than the capped mouse did. All the cap achieved
// was a mouse that crawled.
//
// The stalling itself tracks something else. It is worst just after the
// game starts and clears on its own, and emulation speed was measured at
// 69-75% through boot, reaching 100% later -- see the intro-audio work.
// A camera integrating at seven tenths of the intended rate reads as stuck.
const auto camera_response = [sensitivity](std::int32_t delta) {
const double scaled = std::abs(delta) * (sensitivity / 12.0);
const double magnitude = 127.0 * scaled / (scaled + 12.0);
return static_cast<int>(std::lround(delta < 0 ? -magnitude : magnitude));
};
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{};
if (get_state(0u, &pad) == 0u) {
const std::uint16_t b = pad.gamepad.buttons;
// The pad follows San Andreas' console layout, which is also the
// scheme ThirteenAG's plugin assumes: cross accelerates and
// sprints, square brakes and jumps, triangle enters vehicles.
if (b & kPadA) input.buttons |= kPspCross;
if (b & kPadX) input.buttons |= kPspSquare;
if (b & kPadY) input.buttons |= kPspTriangle;
if (b & kPadB) input.buttons |= kPspCircle;
if (b & kPadLeftShoulder) input.buttons |= kPspLTrigger;
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;
if (b & kPadDpadRight) input.buttons |= kPspRight;
// On foot the triggers aim and look behind, which is where San
// Andreas puts them. In a vehicle they must not: R is the aim
// button, and holding aim suppresses the throttle, so a trigger
// that sent R while driving accelerated and immediately undid it.
// That is why the brake worked from LT and the accelerator did not
// from RT -- L is look-behind and blocks nothing.
if (!driving) {
if (pad.gamepad.left_trigger > 64u) input.buttons |= kPspLTrigger;
if (pad.gamepad.right_trigger > 64u) input.buttons |= kPspRTrigger;
}
// ...and they drive, the way every GTA on a modern pad does.
//
// Through the vehicle accessors rather than by moving them onto the
// pad's R and L: those two are aim and look-behind on foot, so a
// trigger wired straight to them would aim every time the player
// walked with a finger resting on it. Routed this way the trigger
// 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 (!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.
const std::uint8_t pad_y = stick_to_psp(pad.gamepad.ly, true);
if (pad_x != 128u || pad_y != 128u) {
input.analog_x = pad_x;
input.analog_y = pad_y;
}
// Not inverted, unlike the left stick: the camera axis reads
// positive as up, which is the direction the stick already gives.
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 (!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);
}
std::uint32_t display_window_take_save_repro_commands() noexcept {
if (!display_window_enabled()) return 0u;
return window_state().save_repro_commands.exchange(0u, std::memory_order_acq_rel);
}
// Keeps the last rendered frame on screen after the guest stops so the run can
// be inspected. PSPRECOMP_WINDOW_HOLD=0 closes immediately instead.
void display_window_shutdown() {
if (!display_window_enabled()) {
dx12_presenter_shutdown();
return;
}
WindowState &state = window_state();
const HWND window = state.window.load(std::memory_order_acquire);
if (window == nullptr) return;
const char *hold = std::getenv("PSPRECOMP_WINDOW_HOLD");
if (hold != nullptr && std::string(hold) == "0") {
dx12_presenter_shutdown();
PostMessageW(window, WM_CLOSE, 0, 0);
return;
}
SetWindowTextW(window, L"VCSNative — stopped (close this window)");
while (!state.close_requested.load(std::memory_order_relaxed) &&
state.window.load(std::memory_order_acquire) != nullptr) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
dx12_presenter_shutdown();
}
} // namespace vcs
#else
namespace vcs {
bool display_window_enabled() { return false; }
void display_window_start() {}
void display_window_set_status(const char *) {}
void display_window_set_aspect_lock(bool) noexcept {}
void display_window_present(const psprecomp::GuestMemory &, const FramebufferDescription &) {}
void display_window_present_rgba(std::span<const std::byte>, std::uint32_t, std::uint32_t) {}
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; }
std::uint32_t display_window_take_save_repro_commands() noexcept { return 0u; }
void display_window_shutdown() {}
} // namespace vcs
#endif