#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 #include #include #include #include #include #include #include #include #include #include #if defined(_WIN32) #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #ifndef NOMINMAX #define NOMINMAX #endif #include #include #include #include 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 pixels; // 0x00RRGGBB, top-down std::uint32_t width{}; std::uint32_t height{}; std::uint64_t frame_index{}; std::atomic window{nullptr}; std::atomic ready{false}; std::atomic focused{false}; std::atomic close_requested{false}; // 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 mouse_dx{0}; std::atomic mouse_dy{0}; std::atomic 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 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 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 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 native_boot_armed{false}; std::atomic native_boot_active{false}; std::atomic native_boot_game_tab_queued{false}; std::atomic native_boot_game_tab_ready{false}; std::atomic native_boot_user_committed{false}; std::atomic 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 native_boot_release_ready{false}; std::atomic native_boot_release_neutral_polls{0u}; std::mutex synthetic_mutex; std::deque synthetic_buttons; std::atomic 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 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 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 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 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(x) / static_cast(w); const double ny = static_cast(y) / static_cast(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(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(x) / static_cast(w); const double ny = static_cast(y) / static_cast(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 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( reinterpret_cast(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(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::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(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(state.configuration.custom_width); state.client_height = static_cast(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: // Keyboard state is sampled with GetAsyncKeyState. Do not infer pause // menu ownership from Escape here: the guest may consume the press for // an intro/transition. Cursor mode is driven only by VCS' real native // frontend-active byte through display_window_set_guest_frontend_active. return 0; case WM_INPUT: { // Raw mouse deltas. Sized from the message rather than assumed: the // header is followed by a union whose size differs between builds. UINT size = 0u; GetRawInputData(reinterpret_cast(lparam), RID_INPUT, nullptr, &size, sizeof(RAWINPUTHEADER)); if (size != 0u && size <= 256u) { alignas(8) std::byte buffer[256]; if (GetRawInputData(reinterpret_cast(lparam), RID_INPUT, buffer, &size, sizeof(RAWINPUTHEADER)) == size) { const RAWINPUT *raw = reinterpret_cast(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(static_cast(LOWORD(lparam))); const int y = static_cast(static_cast(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 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 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(state.width); // Negative height selects a top-down DIB, matching our row order. info.bmiHeader.biHeight = -static_cast(state.height); info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; info.bmiHeader.biCompression = BI_RGB; FillRect(context, &client, static_cast(GetStockObject(BLACK_BRUSH))); const PresentationRectangle output = calculate_presentation_rectangle( static_cast(std::max(0L, client.right - client.left)), static_cast(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(state.width) || output.height != static_cast(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(state.width), static_cast(state.height), state.pixels.data(), &info, DIB_RGB_COLORS, SRCCOPY); } else { FillRect(context, &client, static_cast(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(std::max(1L, work.right - work.left)) - frame_width; const int available_height = static_cast(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(work.left); window_y = static_cast(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 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 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 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 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 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(description.width) * description.height; { std::lock_guard 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 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(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 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::max(0L, client.right - client.left)), static_cast(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 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::clamp(128 + move_x * reach, 0, 255)); input.analog_y = static_cast(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(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(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 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 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 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); } // 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, 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; } void display_window_shutdown() {} } // namespace vcs #endif