mirror of
https://github.com/jessicanataliagta/PSPRecomp
synced 2026-09-26 08:41:08 -04:00
3bdd8699c2
fixed save games 2
1380 lines
63 KiB
C++
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
|