The Internet-Optionen frame becomes Bildschirm with a Monitor dropdown (Hauptmonitor or a connected monitor, kept by device path in [display] Monitor=). The borderless window and the render size use that monitor, falling back to the main monitor when it is not connected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
287 lines
11 KiB
C++
287 lines
11 KiB
C++
// Borderless fullscreen.
|
|
//
|
|
// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode
|
|
// shows that as a 1024x768 popup centred on the desktop; fullscreen mode
|
|
// switches the display mode. With this mod the game always runs windowed, the
|
|
// popup covers the whole monitor (mods/monitor.h: the preferred one), and each frame is scaled up into the largest
|
|
// centred rectangle with the game's aspect ratio (black bars elsewhere).
|
|
//
|
|
// Mouse positions are mapped back from that rectangle to game pixels, both in
|
|
// GetMouseClientPos (polled by the game) and in mouse window messages.
|
|
//
|
|
// sacredK.ini:
|
|
// [borderless]
|
|
// Enabled=1 ; 0 = original behaviour
|
|
// IntegerScale=0 ; 1 = scale by whole multiples only (sharper, larger bars)
|
|
// RunInBackground=1 ; keep the game running when it loses focus
|
|
//
|
|
// Vanilla pauses when the window loses focus (WM_ACTIVATEAPP sets bit 0x20000 in
|
|
// 0x0182cdb8 and pauses sound) and stops presenting; a monitor-sized window then
|
|
// goes black. With RunInBackground the deactivation is never passed to the
|
|
// game. Because the game polls the keyboard with GetAsyncKeyState, which sees
|
|
// keys pressed in any application, key state is hidden from it while another
|
|
// window has the focus.
|
|
//
|
|
// Known gap: one code path (0x00610650, cursor nudging) moves the cursor with
|
|
// SetCursorPos by a game-pixel delta, which is not scaled to screen pixels.
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
|
|
#include <windows.h>
|
|
#include <windowsx.h>
|
|
|
|
#include "game/engine/input.h"
|
|
#include "game/render/dx_driver7.h"
|
|
#include "mods/hud.h"
|
|
#include "mods/input.h"
|
|
#include "mods/monitor.h"
|
|
#include "mods/widescreen.h"
|
|
#include "re/config.h"
|
|
#include "game/addresses.h"
|
|
#include "re/hook.h"
|
|
#include "re/log.h"
|
|
|
|
namespace {
|
|
|
|
// Where a gameWidth x gameHeight image appears in the window.
|
|
struct Layout {
|
|
int gameWidth = 0;
|
|
int gameHeight = 0;
|
|
RECT client = {}; // scaled game image, window client coordinates
|
|
RECT screen = {}; // same rectangle, screen coordinates
|
|
};
|
|
|
|
struct Viewport {
|
|
bool active = false;
|
|
HWND window = nullptr;
|
|
bool runInBackground = false;
|
|
Layout full; // the whole back buffer
|
|
};
|
|
|
|
Viewport g_viewport;
|
|
|
|
const Layout& CurrentLayout() {
|
|
return g_viewport.full;
|
|
}
|
|
|
|
bool Enabled() {
|
|
return re::ConfigInt("borderless", "Enabled", 1) != 0;
|
|
}
|
|
|
|
// Largest rectangle with the game's aspect ratio that fits the monitor.
|
|
RECT FitRect(int areaWidth, int areaHeight, int width, int height, bool integerScale) {
|
|
int scaledWidth;
|
|
int scaledHeight;
|
|
if (integerScale) {
|
|
int scale = std::max(1, std::min(areaWidth / width, areaHeight / height));
|
|
scaledWidth = width * scale;
|
|
scaledHeight = height * scale;
|
|
} else if (static_cast<long long>(areaWidth) * height > static_cast<long long>(areaHeight) * width) {
|
|
scaledHeight = areaHeight;
|
|
scaledWidth = MulDiv(width, areaHeight, height);
|
|
} else {
|
|
scaledWidth = areaWidth;
|
|
scaledHeight = MulDiv(height, areaWidth, width);
|
|
}
|
|
int left = (areaWidth - scaledWidth) / 2;
|
|
int top = (areaHeight - scaledHeight) / 2;
|
|
return {left, top, left + scaledWidth, top + scaledHeight};
|
|
}
|
|
|
|
void ApplyBorderless(game::DxDriver7* driver) {
|
|
HWND window = driver->window;
|
|
const RECT area = mods::monitor::GameMonitorRect();
|
|
const int areaWidth = area.right - area.left;
|
|
const int areaHeight = area.bottom - area.top;
|
|
|
|
SetWindowLongW(window, GWL_STYLE, WS_POPUP | WS_VISIBLE);
|
|
SetWindowPos(window, HWND_TOP, area.left, area.top, areaWidth, areaHeight, SWP_FRAMECHANGED | SWP_SHOWWINDOW);
|
|
|
|
const bool integerScale = re::ConfigInt("borderless", "IntegerScale", 0) != 0;
|
|
auto makeLayout = [&](int width, int height) {
|
|
Layout layout;
|
|
layout.gameWidth = width;
|
|
layout.gameHeight = height;
|
|
layout.client = FitRect(areaWidth, areaHeight, width, height, integerScale);
|
|
layout.screen = layout.client;
|
|
OffsetRect(&layout.screen, area.left, area.top);
|
|
return layout;
|
|
};
|
|
|
|
Viewport viewport;
|
|
viewport.window = window;
|
|
viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0;
|
|
viewport.full = makeLayout(driver->mode.width, driver->mode.height);
|
|
viewport.active = true;
|
|
g_viewport = viewport;
|
|
|
|
// Paint the bars: the window class background brush is black.
|
|
InvalidateRect(window, nullptr, TRUE);
|
|
|
|
const RECT& image = viewport.full.client;
|
|
re::Log("borderless: %dx%d game -> %ldx%ld monitor, image at (%ld,%ld) %ldx%ld", viewport.full.gameWidth,
|
|
viewport.full.gameHeight, static_cast<long>(areaWidth), static_cast<long>(areaHeight), image.left,
|
|
image.top, image.right - image.left, image.bottom - image.top);
|
|
}
|
|
|
|
// Window client coordinates -> game pixels.
|
|
POINT ToGame(POINT p) {
|
|
const Layout& layout = CurrentLayout();
|
|
const int width = layout.client.right - layout.client.left;
|
|
const int height = layout.client.bottom - layout.client.top;
|
|
POINT result;
|
|
result.x = std::clamp(MulDiv(p.x - layout.client.left, layout.gameWidth, width), 0, layout.gameWidth - 1);
|
|
result.y = std::clamp(MulDiv(p.y - layout.client.top, layout.gameHeight, height), 0, layout.gameHeight - 1);
|
|
result.x -= mods::hud::MenuFrameOffsetX(); // the main menu's centred frame
|
|
return result;
|
|
}
|
|
|
|
// Fills everything except the game image with black.
|
|
void PaintBars(HDC dc) {
|
|
RECT window;
|
|
GetClientRect(g_viewport.window, &window);
|
|
const RECT& image = CurrentLayout().client;
|
|
const int saved = SaveDC(dc);
|
|
ExcludeClipRect(dc, image.left, image.top, image.right, image.bottom);
|
|
FillRect(dc, &window, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
|
|
RestoreDC(dc, saved);
|
|
}
|
|
|
|
bool IsMouseMessageWithClientCoords(UINT message) {
|
|
// WM_MOUSEWHEEL and WM_MOUSEHWHEEL carry screen coordinates; leave them alone.
|
|
return message >= WM_MOUSEFIRST && message <= WM_MOUSELAST && message != WM_MOUSEWHEEL &&
|
|
message != WM_MOUSEHWHEEL;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace game {
|
|
|
|
RE_HOOK(addr::DxDriver7_SetDisplayMode, DxDriver7_SetDisplayMode);
|
|
RE_HOOK(addr::DxDriver7_Flip, DxDriver7_Flip);
|
|
RE_HOOK(addr::GetMouseClientPos, GetMouseClientPos);
|
|
RE_HOOK(addr::MainWindowProc, MainWindowProc);
|
|
|
|
int __fastcall DxDriver7_SetDisplayMode(DxDriver7* self, void* edx, void* deviceCaps, const DxDisplayMode* mode,
|
|
uint32_t unknown) {
|
|
if (!Enabled()) {
|
|
return DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, mode, unknown);
|
|
}
|
|
DxDisplayMode windowed = *mode;
|
|
windowed.fullscreen = 0;
|
|
mods::widescreen::ApplyRenderSize(windowed);
|
|
g_viewport.active = false;
|
|
const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown);
|
|
if (result) {
|
|
ApplyBorderless(self);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
int __fastcall DxDriver7_Flip(DxDriver7* self, void* edx) {
|
|
// The original computes the blit rectangles once, for a centred window of
|
|
// exactly the game's size; override them on every present.
|
|
if (g_viewport.active && self->window == g_viewport.window) {
|
|
const Layout& layout = CurrentLayout();
|
|
self->blitSource = {0, 0, layout.gameWidth, layout.gameHeight};
|
|
self->blitDest = layout.screen;
|
|
}
|
|
const int result = DxDriver7_Flip_original(self, edx);
|
|
static std::atomic<int> failures = 0;
|
|
if (result != 0 && failures++ < 5) {
|
|
re::Log("borderless: present failed (0x%08x), source %ldx%ld -> dest (%ld,%ld)-(%ld,%ld)",
|
|
static_cast<unsigned>(result), self->blitSource.right, self->blitSource.bottom, self->blitDest.left,
|
|
self->blitDest.top, self->blitDest.right, self->blitDest.bottom);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void __cdecl GetMouseClientPos(HWND window, POINT* position) {
|
|
GetMouseClientPos_original(window, position);
|
|
if (window && g_viewport.active && window == g_viewport.window) {
|
|
*position = ToGame(*position);
|
|
position->x -= mods::hud::CursorOffsetX();
|
|
}
|
|
}
|
|
|
|
LRESULT CALLBACK MainWindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) {
|
|
if (mods::input::HandleWindowMessage(window, message, wParam, lParam)) {
|
|
return 0;
|
|
}
|
|
if (!g_viewport.active || window != g_viewport.window) {
|
|
return MainWindowProc_original(window, message, wParam, lParam);
|
|
}
|
|
|
|
if (IsMouseMessageWithClientCoords(message)) {
|
|
POINT game = ToGame({GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)});
|
|
lParam = MAKELPARAM(game.x, game.y);
|
|
}
|
|
|
|
switch (message) {
|
|
case WM_ERASEBKGND:
|
|
PaintBars(reinterpret_cast<HDC>(wParam));
|
|
return 1;
|
|
|
|
case WM_ACTIVATEAPP:
|
|
if (!wParam && g_viewport.runInBackground) {
|
|
re::Log("borderless: focus lost, game keeps running");
|
|
return 0;
|
|
}
|
|
break;
|
|
}
|
|
return MainWindowProc_original(window, message, wParam, lParam);
|
|
}
|
|
|
|
} // namespace game
|
|
|
|
// The game polls keys with GetAsyncKeyState, which reports keys pressed in any
|
|
// application. While it runs in the background, hide them from it.
|
|
SHORT WINAPI GetAsyncKeyStateWhenFocused(int key);
|
|
RE_HOOK_API(GetAsyncKeyState, GetAsyncKeyStateWhenFocused);
|
|
|
|
SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) {
|
|
if (g_viewport.active && g_viewport.runInBackground && GetForegroundWindow() != g_viewport.window) {
|
|
return 0;
|
|
}
|
|
return GetAsyncKeyStateWhenFocused_original(key);
|
|
}
|
|
|
|
// The game's "bring the window to the front" (0x810af0, thiscall (window,
|
|
// timeout ms), ret 8; WinMain calls it three times): ShowWindow, then
|
|
// SetForegroundWindow until the window is in front and SetFocus until it has
|
|
// the focus. WinMain passes 0xffffffff, which never times out. Windows refuses
|
|
// the foreground to a process when the user clicked or typed elsewhere while
|
|
// it started, so the game hung at startup (2026-10-20, four times; main
|
|
// thread in this loop, log ending at WM_ACTIVATEAPP(1, 0)). Now it asks for a
|
|
// limited time, with the input of the window in front attached (which Windows
|
|
// allows), and carries on in the background otherwise; a click brings it up.
|
|
constexpr DWORD kForegroundWaitMs = 2000;
|
|
|
|
bool __fastcall BringWindowToFront(void* self, void* edx, HWND window, DWORD timeout);
|
|
RE_HOOK(0x00810af0, BringWindowToFront);
|
|
bool __fastcall BringWindowToFront(void*, void*, HWND window, DWORD timeout) {
|
|
ShowWindow(window, SW_SHOW);
|
|
const DWORD wait = std::min(timeout, kForegroundWaitMs);
|
|
const DWORD start = GetTickCount();
|
|
while (GetForegroundWindow() != window && GetTickCount() - start < wait) {
|
|
const DWORD front = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
|
|
const DWORD self = GetCurrentThreadId();
|
|
const bool attached = front && front != self && AttachThreadInput(front, self, TRUE);
|
|
BringWindowToTop(window);
|
|
SetForegroundWindow(window);
|
|
if (attached) {
|
|
AttachThreadInput(front, self, FALSE);
|
|
}
|
|
Sleep(10);
|
|
}
|
|
const bool inFront = GetForegroundWindow() == window;
|
|
if (inFront) {
|
|
SetFocus(window);
|
|
} else {
|
|
re::Log("borderless: the game window did not get to the front, starting in the background");
|
|
}
|
|
UpdateWindow(window);
|
|
return inFront;
|
|
}
|