- Megamap / TAB map zoomed view: heroes' markers and the other symbols (integer 200h centre immediates) follow the view's centre. - Map reveal: the hero's reveal stamp scaled 10x (mods/mapreveal.cpp, docs/map-reveal.md); creatures on the zoomed map shown within 10x the sight range. - Load / save progress screen drawn on the rock background across the full width, the picture blended over it (mods/loadingscreen.cpp); the classic 1024x768 frame is gone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
396 lines
16 KiB
C++
396 lines
16 KiB
C++
// Widescreen: show more of the world horizontally on wide monitors while
|
|
// everything keeps its original on-screen size.
|
|
//
|
|
// The world view sizes itself from zoom * 1024.0 / 768.0 (float constants that
|
|
// the UI also reads) and projects 3D models with an ortho camera of
|
|
// +-267 x +-200 world units (times zoom). We redirect only the world view's
|
|
// reads of those constants to our own variables, so the UI keeps its
|
|
// 1024x768 layout, and set the variables from the render size.
|
|
// See docs/widescreen.md.
|
|
//
|
|
// sacredK.ini:
|
|
// [widescreen]
|
|
// Enabled=1
|
|
// Height=768 ; world pixels visible vertically (768 = original look);
|
|
// ; 0 = monitor height, i.e. native 1:1 (everything smaller)
|
|
// Width=0 ; 0 = Height * monitor aspect ratio
|
|
//
|
|
// Windows' Direct3D 7 cannot render to targets larger than 2048x2048, so the
|
|
// render size is clamped to that (only reachable with Height=0 / large Width).
|
|
//
|
|
// Experimental: the UI is still drawn at the top-left 1024x768, and creatures
|
|
// are only active within the original distance.
|
|
|
|
#include "mods/widescreen.h"
|
|
|
|
#include "mods/hud.h"
|
|
#include "mods/loadingscreen.h"
|
|
#include "mods/megamap.h"
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <bit>
|
|
#include <iterator>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
|
|
#include "game/addresses.h"
|
|
#include "game/ui/ui_manager.h"
|
|
#include "re/config.h"
|
|
#include "re/hook.h"
|
|
#include "re/log.h"
|
|
#include "re/patch.h"
|
|
|
|
namespace mods::widescreen {
|
|
namespace {
|
|
|
|
constexpr float kOriginalWidth = 1024.0f;
|
|
constexpr float kOriginalHeight = 768.0f;
|
|
constexpr double kOriginalOrthoX = 267.0;
|
|
constexpr double kOriginalOrthoY = 200.0;
|
|
|
|
// Read by the patched world view instructions; the initial values reproduce
|
|
// the original behaviour until a render size is applied.
|
|
float g_viewWidth = kOriginalWidth;
|
|
float g_viewHeight = kOriginalHeight;
|
|
double g_orthoLeft = -kOriginalOrthoX;
|
|
double g_orthoRight = kOriginalOrthoX;
|
|
double g_orthoBottom = -kOriginalOrthoY;
|
|
double g_orthoTop = kOriginalOrthoY;
|
|
|
|
bool g_pushesPatched = false;
|
|
|
|
// Render size once applied (0 = original 1024x768 behaviour).
|
|
int g_renderWidth = 0;
|
|
int g_renderHeight = 0;
|
|
|
|
// Every frame uses the full render width: the main menu thread (its windows
|
|
// drawn centred by mods/screen.cpp), the loading screen (centred on the menu
|
|
// background, mods/loadingscreen.cpp), the widened in-game view.
|
|
std::atomic<DWORD> g_menuThread = 0;
|
|
// Whether the most recent scene (on any thread) was the main menu's or the
|
|
// loading screen's. UI input is processed outside the menu thread.
|
|
std::atomic<bool> g_lastSceneMenu = false;
|
|
thread_local int g_loadingScreenDepth = 0;
|
|
|
|
// D3DVIEWPORT7 (d3d.h is not in current Windows SDKs).
|
|
struct Viewport7 {
|
|
DWORD x, y, width, height;
|
|
float minZ, maxZ;
|
|
};
|
|
|
|
bool Enabled() {
|
|
return re::ConfigInt("widescreen", "Enabled", 1) != 0;
|
|
}
|
|
|
|
// The world view keeps two fixed-size per-frame lists sized for 1024x768:
|
|
// the tile draw list (~1750 entries, unchecked) and the ground quad list
|
|
// (1749, capped: the ground just stops). Allocate the view larger and move
|
|
// both lists into the new space at its end by rewriting the field
|
|
// displacements of every instruction that addresses them.
|
|
constexpr uint32_t kListCapacity = 16384;
|
|
|
|
// Rewrites the displacement `oldBase + field` in `instruction` to
|
|
// `newBase + field`, for any field in [0, fieldRange).
|
|
bool MoveDisplacement(uint32_t instruction, uint32_t oldBase, uint32_t fieldRange, uint32_t newBase) {
|
|
const auto* code = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(instruction));
|
|
for (int offset = 1; offset <= 7; ++offset) {
|
|
uint32_t value;
|
|
memcpy(&value, code + offset, 4);
|
|
if (value >= oldBase && value < oldBase + fieldRange) {
|
|
return re::PatchInstructionDword(instruction, value, newBase + (value - oldBase));
|
|
}
|
|
}
|
|
re::Log("widescreen: no displacement 0x%x.. in instruction 0x%08x", oldBase, instruction);
|
|
return false;
|
|
}
|
|
|
|
void RelocateViewLists() {
|
|
using namespace game::addr;
|
|
const uint32_t tileList = kWorldViewSize;
|
|
const uint32_t groundQuads = tileList + kListCapacity * kTileListEntrySize;
|
|
const uint32_t groundIndices = groundQuads + kListCapacity * kGroundQuadEntrySize;
|
|
const uint32_t newSize = groundIndices + kListCapacity * 4;
|
|
if (!re::PatchPushImm32(kWorldViewAllocSizePush, kWorldViewSize, newSize)) {
|
|
re::Log("widescreen: world view allocation not patched, lists keep their 1024x768 capacity");
|
|
return;
|
|
}
|
|
int patched = 0;
|
|
int total = 0;
|
|
for (uint32_t instruction : kTileListUsers) {
|
|
patched += MoveDisplacement(instruction, kTileListOffset, kTileListEntrySize, tileList);
|
|
++total;
|
|
}
|
|
for (uint32_t instruction : kGroundQuadUsers) {
|
|
patched += MoveDisplacement(instruction, kGroundQuadOffset, kGroundQuadEntrySize, groundQuads);
|
|
++total;
|
|
}
|
|
for (uint32_t instruction : kGroundQuadIndexUsers) {
|
|
patched += MoveDisplacement(instruction, kGroundQuadIndexOffset, 4, groundIndices);
|
|
++total;
|
|
}
|
|
patched += re::PatchInstructionDword(kGroundQuadCapCompare, kGroundQuadCap, kListCapacity - 1);
|
|
++total;
|
|
re::Log("widescreen: view lists moved (%u entries each, view object 0x%x bytes), %d/%d instructions patched",
|
|
kListCapacity, newSize, patched, total);
|
|
}
|
|
|
|
// Screen size-dependent floats read by the patched instructions.
|
|
float g_viewHalfWidth = kOriginalWidth / 2;
|
|
float g_viewHalfHeight = kOriginalHeight / 2;
|
|
float g_groundClipBottom = kOriginalHeight + 50;
|
|
float g_clipBottom888 = kOriginalHeight + 120;
|
|
float g_viewInvWidth = 1.0f / kOriginalWidth;
|
|
float g_viewInvHeight = 1.0f / kOriginalHeight;
|
|
|
|
template <size_t N>
|
|
int Redirect(const uint32_t (&instructions)[N], uint32_t from, const void* to, int& total) {
|
|
int patched = 0;
|
|
for (uint32_t instruction : instructions) {
|
|
patched += re::RedirectOperand(instruction, from, to);
|
|
++total;
|
|
}
|
|
return patched;
|
|
}
|
|
|
|
void RedirectWorldView() {
|
|
if (!Enabled()) {
|
|
return;
|
|
}
|
|
using namespace game::addr;
|
|
int total = 0;
|
|
int patched = Redirect(kViewWidthUsers, kScreenWidthF, &g_viewWidth, total);
|
|
patched += Redirect(kViewHeightUsers, kScreenHeightF, &g_viewHeight, total);
|
|
patched += Redirect(kViewHalfWidthUsers, kScreenHalfWidthF, &g_viewHalfWidth, total);
|
|
patched += Redirect(kViewHalfHeightUsers, kScreenHalfHeightF, &g_viewHalfHeight, total);
|
|
patched += Redirect(kCreatureHalfWidthUsers, kScreenHalfWidthF, &g_viewHalfWidth, total);
|
|
patched += Redirect(kCreatureHalfHeightUsers, kScreenHalfHeightF, &g_viewHalfHeight, total);
|
|
patched += Redirect(kViewInvWidthUsers, kScreenInvWidthF, &g_viewInvWidth, total);
|
|
patched += Redirect(kViewInvHeightUsers, kScreenInvHeightF, &g_viewInvHeight, total);
|
|
patched += Redirect(kGroundClipBottomUsers, kGroundClipBottomF, &g_groundClipBottom, total);
|
|
patched += Redirect(kClipBottom888Users, kClipBottom888F, &g_clipBottom888, total);
|
|
for (const auto& site : kOrthoZoomedUsers) {
|
|
patched += re::RedirectOperand(site[0], kOrthoTopD, &g_orthoTop);
|
|
patched += re::RedirectOperand(site[1], kOrthoBottomD, &g_orthoBottom);
|
|
patched += re::RedirectOperand(site[2], kOrthoRightD, &g_orthoRight);
|
|
patched += re::RedirectOperand(site[3], kOrthoLeftD, &g_orthoLeft);
|
|
total += 4;
|
|
}
|
|
re::Log("widescreen: %d/%d world view operands redirected", patched, total);
|
|
RelocateViewLists();
|
|
}
|
|
RE_STARTUP(WidescreenRedirect, RedirectWorldView);
|
|
|
|
// The screen centre also appears as integer immediates (512 / 384).
|
|
void PatchCenterImmediates(int width, int height) {
|
|
int patched = 0;
|
|
for (const auto& pair : game::addr::kCenterImmediates) {
|
|
patched += re::PatchInstructionDword(pair[0], 0x200, static_cast<uint32_t>(width / 2));
|
|
patched += re::PatchInstructionDword(pair[1], 0x180, static_cast<uint32_t>(height / 2));
|
|
}
|
|
for (const auto& pair : game::addr::kNegativeCenterImmediates) {
|
|
patched += re::PatchInstructionDword(pair[0], static_cast<uint32_t>(-0x200), static_cast<uint32_t>(-(width / 2)));
|
|
patched += re::PatchInstructionDword(pair[1], static_cast<uint32_t>(-0x180), static_cast<uint32_t>(-(height / 2)));
|
|
}
|
|
re::Log("widescreen: %d/%d screen centre immediates patched", patched,
|
|
static_cast<int>(std::size(game::addr::kCenterImmediates) + std::size(game::addr::kNegativeCenterImmediates)) *
|
|
2);
|
|
}
|
|
|
|
// Creatures beyond the original screen (plus a margin) are not drawn.
|
|
void PatchCreatureClip(int width, int height) {
|
|
using namespace game::addr;
|
|
int patched = 0;
|
|
for (uint32_t site : kCreatureClipRight) {
|
|
patched += re::PatchInstructionDword(site, 1024 + kCreatureClipMargin, width + kCreatureClipMargin);
|
|
}
|
|
for (uint32_t site : kCreatureClipBottom) {
|
|
patched += re::PatchInstructionDword(site, 768 + kCreatureClipMargin, height + kCreatureClipMargin);
|
|
}
|
|
re::Log("widescreen: %d/%d creature clip immediates patched", patched,
|
|
static_cast<int>(std::size(kCreatureClipRight) + std::size(kCreatureClipBottom)));
|
|
}
|
|
|
|
uint32_t FloatBits(float value) {
|
|
return std::bit_cast<uint32_t>(value);
|
|
}
|
|
|
|
// The unzoomed projection is pushed as immediates; rewrite them once.
|
|
void PatchOrthoPushes(float right, float top) {
|
|
if (g_pushesPatched) {
|
|
return;
|
|
}
|
|
int patched = 0;
|
|
for (uint32_t site : game::addr::kOrthoPushSequences) {
|
|
patched += re::PatchPushImm32(site + 0, FloatBits(200.0f), FloatBits(top));
|
|
patched += re::PatchPushImm32(site + 5, FloatBits(-200.0f), FloatBits(-top));
|
|
patched += re::PatchPushImm32(site + 10, FloatBits(267.0f), FloatBits(right));
|
|
patched += re::PatchPushImm32(site + 15, FloatBits(-267.0f), FloatBits(-right));
|
|
}
|
|
g_pushesPatched = true;
|
|
re::Log("widescreen: %d/%d projection immediates patched", patched,
|
|
static_cast<int>(std::size(game::addr::kOrthoPushSequences)) * 4);
|
|
}
|
|
|
|
// 3D creatures (cGranny) are projected 1:1 in screen pixels around the centre.
|
|
void PatchGrannyOrthoPushes(int width, int height) {
|
|
const float right = width / 2.0f;
|
|
const float top = height / 2.0f;
|
|
int patched = 0;
|
|
for (uint32_t site : game::addr::kGrannyOrthoPushSequences) {
|
|
patched += re::PatchPushImm32(site + 0, FloatBits(384.0f), FloatBits(top));
|
|
patched += re::PatchPushImm32(site + 5, FloatBits(-384.0f), FloatBits(-top));
|
|
patched += re::PatchPushImm32(site + 10, FloatBits(512.0f), FloatBits(right));
|
|
patched += re::PatchPushImm32(site + 15, FloatBits(-512.0f), FloatBits(-right));
|
|
}
|
|
re::Log("widescreen: %d/%d creature projection immediates patched", patched,
|
|
static_cast<int>(std::size(game::addr::kGrannyOrthoPushSequences)) * 4);
|
|
}
|
|
|
|
// Labels are laid out within the screen; widen their area.
|
|
void PatchLabelArea(int width, int height) {
|
|
int patched = 0;
|
|
for (uint32_t site : game::addr::kLabelAreaPushes) {
|
|
patched += re::PatchPushImm32(site + 0, 0x300, static_cast<uint32_t>(height));
|
|
patched += re::PatchPushImm32(site + 5, 0x400, static_cast<uint32_t>(width));
|
|
}
|
|
re::Log("widescreen: %d/%d label area immediates patched", patched,
|
|
static_cast<int>(std::size(game::addr::kLabelAreaPushes)) * 2);
|
|
}
|
|
|
|
// Cinematic bars span the screen width.
|
|
void PatchLetterbox(int width) {
|
|
int patched = 0;
|
|
for (uint32_t site : game::addr::kLetterboxRightImmediates) {
|
|
patched += re::PatchInstructionDword(site, FloatBits(1024.0f), FloatBits(static_cast<float>(width)));
|
|
}
|
|
re::Log("widescreen: %d/%d letterbox immediates patched", patched,
|
|
static_cast<int>(std::size(game::addr::kLetterboxRightImmediates)));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) {
|
|
if (!Enabled()) {
|
|
return;
|
|
}
|
|
MONITORINFO monitor = {sizeof(monitor)};
|
|
GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTOPRIMARY), &monitor);
|
|
// Default: keep the original 768 pixels of world height (so everything
|
|
// keeps its on-screen size once scaled to the monitor) and extend the width
|
|
// to the monitor's aspect ratio. Height=0 renders at the monitor height 1:1.
|
|
const int monitorWidth = monitor.rcMonitor.right - monitor.rcMonitor.left;
|
|
const int monitorHeight = monitor.rcMonitor.bottom - monitor.rcMonitor.top;
|
|
int height = re::ConfigInt("widescreen", "Height", static_cast<int>(kOriginalHeight));
|
|
if (height <= 0) {
|
|
height = monitorHeight;
|
|
}
|
|
int width = re::ConfigInt("widescreen", "Width", 0);
|
|
if (width <= 0) {
|
|
width = MulDiv(height, monitorWidth, monitorHeight);
|
|
}
|
|
// Windows' Direct3D 7 runtime cannot create a device for a render target
|
|
// wider or taller than 2048 pixels (CreateDevice fails with
|
|
// DDERR_INVALIDOBJECT); anything larger needs a different renderer.
|
|
constexpr int kMaxD3D7Surface = 2048;
|
|
if (width > kMaxD3D7Surface || height > kMaxD3D7Surface) {
|
|
re::Log("widescreen: %dx%d exceeds the Direct3D 7 limit, clamping to %d", width, height, kMaxD3D7Surface);
|
|
width = std::min(width, kMaxD3D7Surface);
|
|
height = std::min(height, kMaxD3D7Surface);
|
|
}
|
|
// The game's own layout needs at least its original size.
|
|
if (width < kOriginalWidth || height < kOriginalHeight) {
|
|
re::Log("widescreen: %dx%d is smaller than 1024x768, keeping the original size", width, height);
|
|
return;
|
|
}
|
|
|
|
mode.width = static_cast<uint16_t>(width);
|
|
mode.height = static_cast<uint16_t>(height);
|
|
|
|
g_viewWidth = static_cast<float>(width);
|
|
g_viewHeight = static_cast<float>(height);
|
|
g_viewHalfWidth = width / 2.0f;
|
|
g_viewHalfHeight = height / 2.0f;
|
|
g_groundClipBottom = height + 50.0f;
|
|
g_clipBottom888 = height + 120.0f;
|
|
g_viewInvWidth = 1.0f / width;
|
|
g_viewInvHeight = 1.0f / height;
|
|
if (!g_pushesPatched) {
|
|
PatchCenterImmediates(width, height);
|
|
PatchCreatureClip(width, height);
|
|
PatchGrannyOrthoPushes(width, height);
|
|
PatchLabelArea(width, height);
|
|
PatchLetterbox(width);
|
|
mods::megamap::PatchLayout(width);
|
|
}
|
|
const double scaleX = width / kOriginalWidth;
|
|
const double scaleY = height / kOriginalHeight;
|
|
g_orthoRight = kOriginalOrthoX * scaleX;
|
|
g_orthoLeft = -g_orthoRight;
|
|
g_orthoTop = kOriginalOrthoY * scaleY;
|
|
g_orthoBottom = -g_orthoTop;
|
|
PatchOrthoPushes(static_cast<float>(g_orthoRight), static_cast<float>(g_orthoTop));
|
|
|
|
g_renderWidth = width;
|
|
g_renderHeight = height;
|
|
re::Log("widescreen: render size %dx%d, ortho x +-%.1f y +-%.1f", width, height, g_orthoRight, g_orthoTop);
|
|
}
|
|
|
|
int RenderWidth() {
|
|
return g_renderWidth;
|
|
}
|
|
|
|
bool MenuIsShowing() {
|
|
return g_lastSceneMenu;
|
|
}
|
|
|
|
bool IsLoadingScreen() {
|
|
return g_renderWidth != 0 && g_loadingScreenDepth > 0;
|
|
}
|
|
|
|
bool IsMenuThread() {
|
|
return GetCurrentThreadId() == g_menuThread.load();
|
|
}
|
|
|
|
} // namespace mods::widescreen
|
|
|
|
namespace game {
|
|
|
|
using namespace mods::widescreen;
|
|
|
|
RE_HOOK(addr::UiManager_ThreadLoop, UiManager_ThreadLoop);
|
|
RE_HOOK(addr::DxDriver7_LoadingScreen, DxDriver7_LoadingScreen);
|
|
RE_HOOK(addr::DxDriver7_BeginScene, DxDriver7_BeginScene);
|
|
|
|
void __fastcall UiManager_ThreadLoop(void* self, void* edx) {
|
|
g_menuThread = GetCurrentThreadId();
|
|
UiManager_ThreadLoop_original(self, edx);
|
|
g_menuThread = 0;
|
|
}
|
|
|
|
void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t progress, const char* text) {
|
|
++g_loadingScreenDepth;
|
|
mods::loadingscreen::Begin(self);
|
|
DxDriver7_LoadingScreen_original(self, edx, progress, text);
|
|
mods::loadingscreen::End();
|
|
--g_loadingScreenDepth;
|
|
}
|
|
|
|
// Every frame gets the full render size as its viewport.
|
|
int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) {
|
|
mods::hud::OnDevice(self->device);
|
|
if (self->sceneDepth == 0) {
|
|
g_lastSceneMenu = IsLoadingScreen() || IsMenuThread();
|
|
}
|
|
if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) {
|
|
Viewport7 viewport = {0, 0, static_cast<DWORD>(g_renderWidth), static_cast<DWORD>(g_renderHeight), 0.0f,
|
|
1.0f};
|
|
using SetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport);
|
|
auto setViewport = reinterpret_cast<SetViewportFn>((*static_cast<void***>(self->device))[13]);
|
|
setViewport(self->device, &viewport);
|
|
}
|
|
return DxDriver7_BeginScene_original(self, edx);
|
|
}
|
|
|
|
} // namespace game
|