DS2Revival/mods/widescreen/remix.cpp
DS2Revival a1e2f8f9b5
Some checks failed
build / build (push) Failing after 1m35s
CI: build on the project's own runner at git.dsii.uk
2026-10-27 22:02:02 -04:00

309 lines
16 KiB
C++

// RTX Remix support ([Display] Renderer=remix/auto, display.cpp RemixProfile). docs/graphics/rtx-remix.md
//
// Sky ([Display] RemixSky=on, default with the Remix profile): DS2 draws no sky. Remix builds the sky that lights
// the scene (misses of indirect rays) only from draw calls it classifies as sky, and clears that sky to the
// game's last colour clear when such a draw arrives (dxvk-remix RtxContext::clearRenderTarget / sky rasterise).
// With no sky draw the sky stays black, so everything not hit by the single fallback light is black.
// Right after each colour clear of the back buffer, one small untextured triangle in the clear colour is drawn with
// a viewport depth range of [1, 1] and the game's own transforms; Remix classifies draws with viewport MinZ >=
// rtx.skyMinZThreshold (1.0 by default) as sky, and its sky clear uses the colour of the clear just before. (Drawn
// at the end of the frame instead, the sky stayed black: measured.) The game clears to its mood's fog colour, so
// the sky - and the ambient light - follows the mood. Remix rasterises sky draws into its sky only.
#include "../common/patch.h"
#include "../common/vtable_hook.h"
#include <d3d9.h>
#include <cstdio>
#include <cstring>
namespace {
bool g_sky = false;
bool g_skipOffscreen = false;
bool g_offscreen = false; // render target 0 is not the back buffer (render thread only)
unsigned g_skipped = 0;
// Off-screen passes ([Display] RemixSkipOffscreen=on, default with the Remix profile): DS2 starts each world frame
// by drawing character silhouettes (stage 0 = texture factor colour, alpha-tested) into a 512x512 render-target
// texture from the light's point of view - its projected shadows. dxvk-remix takes the first perspective draw of a
// frame as the main camera (CameraManager::processCameraData, camera.update once per frame), so the path-traced
// view was the shadow projector's. Remix computes shadows itself, so these draws are dropped while a render
// target other than the back buffer is bound (measured with ds2dev "drawstates": the first 4-5 draws of a frame).
using SetRenderTarget_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, DWORD, IDirect3DSurface9*);
using DrawPrimitive_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, D3DPRIMITIVETYPE, UINT, UINT);
using DrawIndexedPrimitive_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, D3DPRIMITIVETYPE, INT, UINT, UINT, UINT, UINT);
using DrawPrimitiveUP_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, D3DPRIMITIVETYPE, UINT, const void*, UINT);
using DrawIndexedPrimitiveUP_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, D3DPRIMITIVETYPE, UINT, UINT, UINT, const void*,
D3DFORMAT, const void*, UINT);
HRESULT STDMETHODCALLTYPE HookSetRenderTarget(IDirect3DDevice9* dev, DWORD index, IDirect3DSurface9* s);
HRESULT STDMETHODCALLTYPE HookDP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, UINT start, UINT count);
HRESULT STDMETHODCALLTYPE HookDIP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, INT base, UINT minIndex, UINT verts, UINT start,
UINT count);
HRESULT STDMETHODCALLTYPE HookDPUP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, UINT count, const void* data, UINT stride);
HRESULT STDMETHODCALLTYPE HookDIPUP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, UINT minIndex, UINT verts, UINT count,
const void* idx, D3DFORMAT fmt, const void* data, UINT stride);
ds2::VtableHook g_setRtHook(37, reinterpret_cast<void*>(HookSetRenderTarget));
ds2::VtableHook g_dpHook(81, reinterpret_cast<void*>(HookDP));
ds2::VtableHook g_dipHook(82, reinterpret_cast<void*>(HookDIP));
ds2::VtableHook g_dpupHook(83, reinterpret_cast<void*>(HookDPUP));
ds2::VtableHook g_dipupHook(84, reinterpret_cast<void*>(HookDIPUP));
// Cursor ([Display] RemixCursor=on, default with the Remix profile): DS2 shows its mouse cursor with the D3D9
// hardware cursor (sub_5199e0: SetCursorProperties(hotspot, 32x32 surface) + ShowCursor(TRUE)). Under Remix the
// bridge forwards both calls to its 64-bit server, where dxvk's D3D9Cursor builds an HCURSOR and calls Win32
// SetCursor - in the server process, which does not own the game window, so no cursor is shown (measured on
// the RTX test PC: GetCursorInfo over the game window = not showing, hCursor 0; without Remix: showing). Here the same
// cursor is built in the game process from the surface (read back on the client; the Windows arrow if that
// fails) and set on WM_SETCURSOR (display.cpp window procedure) and whenever the game shows or hides it.
bool g_cursorFix = false;
bool g_cursorShown = false;
HCURSOR g_cursor = nullptr;
bool g_cursorOwned = false; // g_cursor was created here (DestroyCursor), not loaded
unsigned g_cursorLogs = 0;
using SetCursorProperties_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, UINT, UINT, IDirect3DSurface9*);
using ShowCursor_t = BOOL(STDMETHODCALLTYPE*)(IDirect3DDevice9*, BOOL);
HRESULT STDMETHODCALLTYPE HookSetCursorProperties(IDirect3DDevice9* dev, UINT x, UINT y, IDirect3DSurface9* s);
BOOL STDMETHODCALLTYPE HookShowCursor(IDirect3DDevice9* dev, BOOL show);
ds2::VtableHook g_setCursorPropsHook(10, reinterpret_cast<void*>(HookSetCursorProperties));
ds2::VtableHook g_showCursorHook(12, reinterpret_cast<void*>(HookShowCursor));
// HCURSOR from a 32-bit cursor surface (as dxvk's D3D9Cursor does); null if the bits cannot be read or are empty.
HCURSOR CursorFromSurface(IDirect3DSurface9* s, UINT hotX, UINT hotY) {
D3DSURFACE_DESC d{};
if (!s || FAILED(s->GetDesc(&d)) || d.Width == 0 || d.Width > 128 || d.Height == 0 || d.Height > 128 ||
(d.Format != D3DFMT_A8R8G8B8 && d.Format != D3DFMT_X8R8G8B8))
return nullptr;
D3DLOCKED_RECT lr{};
if (FAILED(s->LockRect(&lr, nullptr, D3DLOCK_READONLY)) || !lr.pBits) return nullptr;
// A 32x32 cursor is tiny on a 4K TV (the menus are scaled ~3.6x): scale by screen height / 1080, nearest.
int scale = GetSystemMetrics(SM_CYSCREEN) / 1080;
if (scale < 1) scale = 1;
if (scale > 4) scale = 4;
const UINT w = d.Width * scale, h = d.Height * scale;
DWORD* bits = new DWORD[w * h];
DWORD any = 0;
for (UINT row = 0; row < h; ++row) {
const DWORD* src = reinterpret_cast<const DWORD*>(static_cast<const BYTE*>(lr.pBits) + (row / scale) * lr.Pitch);
for (UINT col = 0; col < w; ++col) {
DWORD p = src[col / scale];
if (d.Format == D3DFMT_X8R8G8B8) p |= 0xFF000000;
bits[row * w + col] = p;
any |= p;
}
}
s->UnlockRect();
HCURSOR c = nullptr;
if (any & 0xFF000000) { // some visible pixel: the bridge returned real data
ICONINFO info{};
info.fIcon = FALSE;
info.xHotspot = hotX * scale;
info.yHotspot = hotY * scale;
BYTE* mask = new BYTE[((w + 15) / 16) * 2 * h];
std::memset(mask, 0xFF, ((w + 15) / 16) * 2 * h); // the colour bitmap's alpha decides
info.hbmMask = CreateBitmap(w, h, 1, 1, mask);
info.hbmColor = CreateBitmap(w, h, 1, 32, bits);
c = CreateIconIndirect(&info);
DeleteObject(info.hbmMask);
DeleteObject(info.hbmColor);
delete[] mask;
}
delete[] bits;
return c;
}
void ApplyCursor() {
SetCursor(g_cursorShown ? g_cursor : nullptr);
}
HRESULT STDMETHODCALLTYPE HookSetCursorProperties(IDirect3DDevice9* dev, UINT x, UINT y, IDirect3DSurface9* s) {
HRESULT hr = g_setCursorPropsHook.Original<SetCursorProperties_t>(dev)(dev, x, y, s);
HCURSOR c = CursorFromSurface(s, x, y);
bool fromGame = c != nullptr;
if (!c) c = LoadCursorA(nullptr, MAKEINTRESOURCEA(32512)); // IDC_ARROW
if (g_cursorOwned && g_cursor) DestroyCursor(g_cursor);
g_cursor = c;
g_cursorOwned = fromGame;
if (g_cursorLogs++ < 3) {
D3DSURFACE_DESC d{};
if (s) s->GetDesc(&d);
ds2::log().Detail("remix: cursor %ux%u fmt %d hotspot %u,%u -> %s", d.Width, d.Height, static_cast<int>(d.Format), x, y,
fromGame ? "the game's cursor" : "Windows arrow (surface not readable)");
}
ApplyCursor();
return hr;
}
BOOL STDMETHODCALLTYPE HookShowCursor(IDirect3DDevice9* dev, BOOL show) {
BOOL prev = g_showCursorHook.Original<ShowCursor_t>(dev)(dev, show);
g_cursorShown = show != FALSE;
ApplyCursor();
return prev;
}
bool IsBackBuffer(IDirect3DDevice9* dev, IDirect3DSurface9* s) {
IDirect3DSurface9* bb = nullptr;
bool same = SUCCEEDED(dev->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)) && bb == s;
if (bb) bb->Release();
return same;
}
HRESULT STDMETHODCALLTYPE HookSetRenderTarget(IDirect3DDevice9* dev, DWORD index, IDirect3DSurface9* s) {
HRESULT hr = g_setRtHook.Original<SetRenderTarget_t>(dev)(dev, index, s);
if (index == 0 && SUCCEEDED(hr)) g_offscreen = s && !IsBackBuffer(dev, s);
return hr;
}
bool Skip() {
if (!g_offscreen) return false;
if (++g_skipped == 1) ds2::log().Detail("remix: dropping draws into off-screen render targets (RemixSkipOffscreen)");
return true;
}
HRESULT STDMETHODCALLTYPE HookDP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, UINT start, UINT count) {
return Skip() ? D3D_OK : g_dpHook.Original<DrawPrimitive_t>(dev)(dev, t, start, count);
}
HRESULT STDMETHODCALLTYPE HookDIP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, INT base, UINT minIndex, UINT verts, UINT start,
UINT count) {
return Skip() ? D3D_OK : g_dipHook.Original<DrawIndexedPrimitive_t>(dev)(dev, t, base, minIndex, verts, start, count);
}
HRESULT STDMETHODCALLTYPE HookDPUP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, UINT count, const void* data, UINT stride) {
return Skip() ? D3D_OK : g_dpupHook.Original<DrawPrimitiveUP_t>(dev)(dev, t, count, data, stride);
}
HRESULT STDMETHODCALLTYPE HookDIPUP(IDirect3DDevice9* dev, D3DPRIMITIVETYPE t, UINT minIndex, UINT verts, UINT count,
const void* idx, D3DFORMAT fmt, const void* data, UINT stride) {
return Skip() ? D3D_OK
: g_dipupHook.Original<DrawIndexedPrimitiveUP_t>(dev)(dev, t, minIndex, verts, count, idx, fmt, data, stride);
}
using Clear_t = HRESULT(STDMETHODCALLTYPE*)(IDirect3DDevice9*, DWORD, const D3DRECT*, DWORD, D3DCOLOR, float, DWORD);
HRESULT STDMETHODCALLTYPE HookClear(IDirect3DDevice9* dev, DWORD count, const D3DRECT* rects, DWORD flags, D3DCOLOR color,
float z, DWORD stencil);
ds2::VtableHook g_clearHook(43, reinterpret_cast<void*>(HookClear)); // IDirect3DDevice9::Clear
bool g_logged = false;
bool OnBackBuffer(IDirect3DDevice9* dev) {
IDirect3DSurface9 *rt = nullptr, *bb = nullptr;
bool same = false;
if (SUCCEEDED(dev->GetRenderTarget(0, &rt)) && SUCCEEDED(dev->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &bb)))
same = rt == bb;
if (rt) rt->Release();
if (bb) bb->Release();
return same;
}
void DrawSkyTrigger(IDirect3DDevice9* dev, D3DCOLOR color) {
IDirect3DStateBlock9* sb = nullptr;
if (FAILED(dev->CreateStateBlock(D3DSBT_ALL, &sb)) || !sb) return;
D3DVIEWPORT9 vp{};
dev->GetViewport(&vp);
D3DVIEWPORT9 sky = vp;
sky.MinZ = sky.MaxZ = 1.0f;
dev->SetViewport(&sky);
// The game's own transforms stay (the triangle is classified as sky, so its camera is the sky camera).
dev->SetVertexShader(nullptr);
dev->SetPixelShader(nullptr);
dev->SetTexture(0, nullptr);
dev->SetFVF(D3DFVF_XYZ | D3DFVF_DIFFUSE);
dev->SetRenderState(D3DRS_LIGHTING, FALSE);
dev->SetRenderState(D3DRS_ZENABLE, D3DZB_FALSE);
dev->SetRenderState(D3DRS_ZWRITEENABLE, FALSE);
dev->SetRenderState(D3DRS_ALPHABLENDENABLE, FALSE);
dev->SetRenderState(D3DRS_ALPHATESTENABLE, FALSE);
dev->SetRenderState(D3DRS_FOGENABLE, FALSE);
dev->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
dev->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG1);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
struct V {
float x, y, z;
D3DCOLOR c;
} tri[3] = {{-1.0f, -1.0f, 100.0f, color | 0xFF000000}, {0.0f, 1.0f, 100.0f, color | 0xFF000000},
{1.0f, -1.0f, 100.0f, color | 0xFF000000}};
HRESULT hr = dev->DrawPrimitiveUP(D3DPT_TRIANGLELIST, 1, tri, sizeof(V));
sb->Apply();
sb->Release();
if (!g_logged) {
ds2::log().Detail("remix: sky from the clear colour %08lX (draw 0x%08lX)", static_cast<unsigned long>(color),
static_cast<unsigned long>(hr));
g_logged = true;
}
}
// Menu light ([Display] RemixMenuLight=r,g,b; "off" disables): DS2 sets no Direct3D lights, so Remix lights every
// scene with its fallback sun (LightManager: fallbackLightMode NoLightsPresent). The front end's 3D background is a
// dark-red, fogged camp whose mood is baked into vertex colours; under the sun it was ~6x brighter than in raster
// (docs/graphics/rtx-remix.md). On the front-end device only (back buffer <= 1024x768: DS2 800x600, Broken World
// 1024x768) a dim directional light is set with every colour clear, so Remix uses it instead of the sun there; the
// world device keeps the sun. Raster output is unchanged: DS2 draws with D3DRS_LIGHTING off.
bool g_menuLight = false;
D3DLIGHT9 g_light{};
IDirect3DDevice9* g_frontEnd = nullptr;
bool g_menuLightLogged = false;
void SetMenuLight(IDirect3DDevice9* dev) {
dev->SetLight(0, &g_light); // re-set every clear: marks Remix's light state dirty each frame
dev->LightEnable(0, TRUE);
if (!g_menuLightLogged) {
g_menuLightLogged = true;
ds2::log().Detail("remix: front-end light %.2f %.2f %.2f (RemixMenuLight)", g_light.Diffuse.r, g_light.Diffuse.g,
g_light.Diffuse.b);
}
}
HRESULT STDMETHODCALLTYPE HookClear(IDirect3DDevice9* dev, DWORD count, const D3DRECT* rects, DWORD flags, D3DCOLOR color,
float z, DWORD stencil) {
HRESULT hr = g_clearHook.Original<Clear_t>(dev)(dev, count, rects, flags, color, z, stencil);
if (g_menuLight && dev == g_frontEnd && SUCCEEDED(hr) && (flags & D3DCLEAR_TARGET)) SetMenuLight(dev);
if (g_sky && SUCCEEDED(hr) && (flags & D3DCLEAR_TARGET) && OnBackBuffer(dev)) DrawSkyTrigger(dev, color);
return hr;
}
} // namespace
namespace widescreen::remix {
// Called with the Remix profile active; true if a device hook is needed.
bool Wanted(const ds2::Ini& ini) {
g_sky = ini.Bool("Graphics", "RemixSky", true, "Display");
g_skipOffscreen = ini.Bool("Graphics", "RemixSkipOffscreen", true, "Display");
g_cursorFix = ini.Bool("Graphics", "RemixCursor", true, "Display");
char ml[64];
ini.String("Graphics", "RemixMenuLight", "0.10,0.022,0.013", ml, sizeof(ml), "Display");
float r = 0, g = 0, b = 0;
g_menuLight = _stricmp(ml, "off") != 0 && sscanf_s(ml, "%f,%f,%f", &r, &g, &b) == 3;
if (g_menuLight) {
g_light.Type = D3DLIGHT_DIRECTIONAL;
g_light.Diffuse = {r, g, b, 1.0f};
g_light.Direction = {0.3f, -1.0f, 0.5f}; // from above, slightly from the front
}
ds2::log().Write("remix: RemixSky=%d RemixSkipOffscreen=%d RemixCursor=%d RemixMenuLight=%s", g_sky ? 1 : 0,
g_skipOffscreen ? 1 : 0, g_cursorFix ? 1 : 0, g_menuLight ? ml : "off");
return g_sky || g_skipOffscreen || g_cursorFix || g_menuLight;
}
// WM_SETCURSOR over the client area (display.cpp window procedure): true if the cursor was set here.
bool OnSetCursor() {
if (!g_cursorFix || !g_setCursorPropsHook.Installed()) return false;
ApplyCursor();
return true;
}
void DeviceCreated(IDirect3DDevice9* dev, UINT width, UINT height) {
if (!dev) return;
g_offscreen = false;
g_frontEnd = (width <= 1024 && height <= 768) ? dev : nullptr;
if (g_sky || g_menuLight) g_clearHook.Install(dev);
if (g_cursorFix) {
g_setCursorPropsHook.Install(dev);
g_showCursorHook.Install(dev);
}
if (g_skipOffscreen) {
g_setRtHook.Install(dev);
g_dpHook.Install(dev);
g_dipHook.Install(dev);
g_dpupHook.Install(dev);
g_dipupHook.Install(dev);
}
}
} // namespace widescreen::remix