From 0169fe1e3174823a05bb7bfb4abd53050bd8b6bf Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:35:12 -0700 Subject: [PATCH] gitadora: show LEFT / RIGHT screens on sub screen in landscape mode (#900) ## Link to GitHub Issue or related Pull Request, if one exists Continues #899 ## Description of change #899 added the ability to display the gitadora subscreen in lanscape mode with black bars on the side. With the new option the black bars can instead display the LEFT / RIGHT screens, completing the two-monitor full screen use case for arena model. ## Testing --- src/spice2x/games/gitadora/gitadora.cpp | 8 +- src/spice2x/games/gitadora/gitadora.h | 18 ++- .../graphics/backends/d3d9/d3d9_device.cpp | 2 +- .../graphics/backends/d3d9/d3d9_gfdm.cpp | 122 +++++++++++++++++- .../hooks/graphics/backends/d3d9/d3d9_gfdm.h | 4 +- .../graphics/backends/d3d9/d3d9_swapchain.cpp | 2 +- src/spice2x/hooks/graphics/graphics.cpp | 4 + src/spice2x/hooks/graphics/graphics.h | 3 + src/spice2x/launcher/launcher.cpp | 13 +- src/spice2x/launcher/options.cpp | 32 +++-- src/spice2x/launcher/options.h | 2 +- 11 files changed, 178 insertions(+), 32 deletions(-) diff --git a/src/spice2x/games/gitadora/gitadora.cpp b/src/spice2x/games/gitadora/gitadora.cpp index 460b1b62..d99c7709 100644 --- a/src/spice2x/games/gitadora/gitadora.cpp +++ b/src/spice2x/games/gitadora/gitadora.cpp @@ -39,7 +39,7 @@ namespace games::gitadora { std::optional PICK_ALGO = socd::SocdAlgorithm::PreferRecent; std::optional ARENA_WINDOW_COUNT = std::nullopt; bool ARENA_TWO_HEAD_EXCLUSIVE = false; - bool ARENA_SUBSCREEN_LANDSCAPE = false; + ArenaSubscreenLandscape ARENA_SUBSCREEN_LANDSCAPE = ArenaSubscreenLandscape::Off; std::optional ASIO_DRIVER = std::nullopt; bool ALLOW_REALTEK_AUDIO = false; bool NATIVE_TOUCH = false; @@ -48,7 +48,7 @@ namespace games::gitadora { if (GRAPHICS_FS_CUSTOM_RESOLUTION_SUB.has_value()) { return GRAPHICS_FS_CUSTOM_RESOLUTION_SUB.value(); } - if (ARENA_SUBSCREEN_LANDSCAPE) { + if (arena_subscreen_landscape()) { return { ARENA_SUBSCREEN_LANDSCAPE_WIDTH, ARENA_SUBSCREEN_LANDSCAPE_HEIGHT }; } return { ARENA_SUBSCREEN_WIDTH, ARENA_SUBSCREEN_HEIGHT }; @@ -372,11 +372,11 @@ namespace games::gitadora { "arena model: unsupported window count: {}", count); } - if (ARENA_SUBSCREEN_LANDSCAPE && !ARENA_TWO_HEAD_EXCLUSIVE) { + if (arena_subscreen_landscape() && !ARENA_TWO_HEAD_EXCLUSIVE) { log_warning( "gitadora", "arena model: landscape subscreen needs full screen two-window mode, ignoring"); - ARENA_SUBSCREEN_LANDSCAPE = false; + ARENA_SUBSCREEN_LANDSCAPE = ArenaSubscreenLandscape::Off; } if (ARENA_TWO_HEAD_EXCLUSIVE) { const auto [host_width, host_height] = arena_subscreen_host_size(); diff --git a/src/spice2x/games/gitadora/gitadora.h b/src/spice2x/games/gitadora/gitadora.h index 7e6edaf4..0dd2218b 100644 --- a/src/spice2x/games/gitadora/gitadora.h +++ b/src/spice2x/games/gitadora/gitadora.h @@ -12,6 +12,14 @@ namespace games::gitadora { + // what a landscape monitor driving the SMALL head does with the space beside the + // portrait subscreen image + enum class ArenaSubscreenLandscape { + Off, // SMALL head stays portrait + Small, // subscreen centered, the space either side stays black + All, // that space shows the LEFT and RIGHT heads instead + }; + // settings extern bool TWOCHANNEL; extern bool DISABLE_FRAME_LIMITER; @@ -22,11 +30,19 @@ namespace games::gitadora { extern std::optional PICK_ALGO; extern std::optional ARENA_WINDOW_COUNT; extern bool ARENA_TWO_HEAD_EXCLUSIVE; - extern bool ARENA_SUBSCREEN_LANDSCAPE; + extern ArenaSubscreenLandscape ARENA_SUBSCREEN_LANDSCAPE; extern std::optional ASIO_DRIVER; extern bool ALLOW_REALTEK_AUDIO; extern bool NATIVE_TOUCH; + static inline bool arena_subscreen_landscape() { + return ARENA_SUBSCREEN_LANDSCAPE != ArenaSubscreenLandscape::Off; + } + + static inline bool arena_subscreen_shows_sides() { + return ARENA_SUBSCREEN_LANDSCAPE == ArenaSubscreenLandscape::All; + } + // arena SMALL subscreen (touch panel) resolution static constexpr int ARENA_SUBSCREEN_WIDTH = 800; static constexpr int ARENA_SUBSCREEN_HEIGHT = 1280; diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp index e7bba5f2..d03b9c8b 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_device.cpp @@ -670,7 +670,7 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Present( // an adapter group device presents every head at once, so the SMALL head has to be // composed here as well as in its own swap chain if (gfdm_small_head.scaled()) { - gfdm_small_head.compose(pReal); + gfdm_small_head.compose(this); } CHECK_RESULT(pReal->Present(pSourceRect, pDestRect, hDestWindowOverride, pDirtyRegion)); diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp index 280c4c7e..89e47117 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "games/gitadora/gitadora.h" @@ -739,15 +740,118 @@ void GfdmSmallHead::release() { } } -HRESULT GfdmSmallHead::compose(IDirect3DDevice9 *device) { +// largest centered rect inside bounds that keeps the source's aspect ratio +static RECT gfdm_fit_centered(const RECT &bounds, LONG source_width, LONG source_height) { + const LONG bounds_width = bounds.right - bounds.left; + const LONG bounds_height = bounds.bottom - bounds.top; + if (bounds_width <= 0 || bounds_height <= 0 || source_width <= 0 || source_height <= 0) { + return RECT {}; + } + + LONG width = MulDiv(bounds_height, source_width, source_height); + LONG height = bounds_height; + if (width > bounds_width) { + width = bounds_width; + height = MulDiv(bounds_width, source_height, source_width); + } + + const LONG left = bounds.left + (bounds_width - width) / 2; + const LONG top = bounds.top + (bounds_height - height) / 2; + return RECT { left, top, left + width, top + height }; +} + +static void gfdm_compose_side_heads( + WrappedIDirect3DDevice9 *device, + IDirect3DSurface9 *head, + LONG host_width, + LONG host_height, + const RECT &content) +{ + if (!games::gitadora::arena_subscreen_shows_sides()) { + return; + } + + const RECT bars[2] = { + { 0, 0, content.left, host_height }, + { content.right, 0, host_width, host_height }, + }; + + for (UINT swapchain = 1; swapchain < GFDM_LOGICAL_HEAD_COUNT; swapchain++) { + if (!device->is_gfdm_logical_side_swapchain(swapchain)) { + continue; + } + + // nothing has been drawn yet if the game never asked for this head's back buffer + const auto *chain = device->fake_sub_swapchain[ + device->gfdm_hidden_side_swapchain_slot(swapchain)]; + if (chain == nullptr || chain->render_targets.empty()) { + continue; + } + + // the head's own window is the only thing that says which side it is, so an + // unnamed one is left out rather than guessed at and possibly mirrored + const char *name = graphics_gitadora_window_name( + device->gfdm_logical_group_parameters[swapchain].hDeviceWindow); + const bool named_left = name != nullptr && strcmp(name, "LEFT") == 0; + const bool named_right = name != nullptr && strcmp(name, "RIGHT") == 0; + if (!named_left && !named_right) { + static std::once_flag warned; + std::call_once(warned, [] { + log_warning( + "graphics::d3d9", + "two-head exclusive: a side head has no LEFT or RIGHT window, " + "leaving its bar black"); + }); + continue; + } + + const RECT &bar = bars[named_left ? 0 : 1]; + if (bar.right <= bar.left) { + continue; + } + + IDirect3DSurface9 *source = chain->render_targets[0]; + D3DSURFACE_DESC source_desc {}; + if (FAILED(source->GetDesc(&source_desc))) { + continue; + } + + const RECT target = gfdm_fit_centered( + bar, + static_cast(source_desc.Width), + static_cast(source_desc.Height)); + const HRESULT result = device->pReal->StretchRect( + source, + nullptr, + head, + &target, + D3DTEXF_LINEAR); + if (FAILED(result)) { + static std::once_flag warned; + std::call_once(warned, [result] { + log_warning( + "graphics::d3d9", + "two-head exclusive: could not draw a side head into the bars, hr={}", + FMT_HRESULT(result)); + }); + } + } +} + +HRESULT GfdmSmallHead::compose(WrappedIDirect3DDevice9 *device) { + if (device == nullptr || device->pReal == nullptr) { + return D3DERR_INVALIDCALL; + } + + IDirect3DDevice9 *real = device->pReal; IDirect3DSurface9 *proxy = nullptr; - HRESULT result = backbuffer(device, &proxy); + HRESULT result = backbuffer(real, &proxy); if (FAILED(result)) { return result; } IDirect3DSurface9 *head = nullptr; - result = device->GetBackBuffer( + result = real->GetBackBuffer( GFDM_NATIVE_SMALL_SWAPCHAIN, 0, D3DBACKBUFFER_TYPE_MONO, @@ -760,14 +864,18 @@ HRESULT GfdmSmallHead::compose(IDirect3DDevice9 *device) { D3DSURFACE_DESC desc {}; result = head->GetDesc(&desc); if (SUCCEEDED(result)) { + const auto host_width = static_cast(desc.Width); + const auto host_height = static_cast(desc.Height); const RECT content = games::gitadora::arena_subscreen_content_rect( - static_cast(desc.Width), - static_cast(desc.Height)); + host_width, + host_height); // discard swap effect leaves the whole head undefined every frame, so the bars // have to be repainted along with the image - device->ColorFill(head, nullptr, D3DCOLOR_XRGB(0, 0, 0)); - result = device->StretchRect(proxy, nullptr, head, &content, D3DTEXF_LINEAR); + real->ColorFill(head, nullptr, D3DCOLOR_XRGB(0, 0, 0)); + result = real->StretchRect(proxy, nullptr, head, &content, D3DTEXF_LINEAR); + + gfdm_compose_side_heads(device, head, host_width, host_height, content); } head->Release(); diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.h b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.h index 87a61fef..4cabb315 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.h +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_gfdm.h @@ -13,6 +13,8 @@ inline constexpr UINT GFDM_SMALL_HEIGHT = games::gitadora::ARENA_SUBSCREEN_HEIGH inline constexpr UINT GFDM_LOGICAL_HEAD_COUNT = 4; inline constexpr UINT GFDM_NATIVE_SMALL_SWAPCHAIN = 1; +struct WrappedIDirect3DDevice9; + // Owns the portrait surface the game draws into while the SMALL head is scanned out at a // different resolution, and composes it onto the real head. Allocates nothing and reports // nothing until resolve() finds a head that is not the panel size. @@ -27,7 +29,7 @@ struct GfdmSmallHead { bool scaled() const { return active; } HRESULT backbuffer(IDirect3DDevice9 *device, IDirect3DSurface9 **out); - HRESULT compose(IDirect3DDevice9 *device); + HRESULT compose(WrappedIDirect3DDevice9 *device); void release(); // the game only ever sees the portrait size it asked for diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp index e035972a..dacb126e 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_swapchain.cpp @@ -96,7 +96,7 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DSwapChain9::Present(const RECT *pSourc } if (scales_small_head(this)) { - pDev->gfdm_small_head.compose(pDev->pReal); + pDev->gfdm_small_head.compose(pDev); } HRESULT result = pReal->Present( diff --git a/src/spice2x/hooks/graphics/graphics.cpp b/src/spice2x/hooks/graphics/graphics.cpp index 7bb967bc..e3fb0160 100644 --- a/src/spice2x/hooks/graphics/graphics.cpp +++ b/src/spice2x/hooks/graphics/graphics.cpp @@ -233,6 +233,10 @@ bool graphics_gitadora_has_dedicated_subscreen() { return GFDM_SUBSCREEN_WINDOW != nullptr; } +const char *graphics_gitadora_window_name(HWND hWnd) { + return gitadora_window_name_for_hwnd(hWnd); +} + bool graphics_gitadora_prepare_two_head_device_window( HWND hWnd, HMONITOR target_monitor, UINT desired_width, UINT desired_height) { if (hWnd == nullptr || !IsWindow(hWnd)) { diff --git a/src/spice2x/hooks/graphics/graphics.h b/src/spice2x/hooks/graphics/graphics.h index fe839551..e7baa9ec 100644 --- a/src/spice2x/hooks/graphics/graphics.h +++ b/src/spice2x/hooks/graphics/graphics.h @@ -122,6 +122,9 @@ extern bool D3D9_DEVICE_HOOK_DISABLE; void graphics_init(); void graphics_hook_window(HWND hWnd, D3DPRESENT_PARAMETERS *pPresentationParameters); bool graphics_gitadora_has_dedicated_subscreen(); + +// "GITADORA", "LEFT", "RIGHT", "SMALL", or nullptr for anything else +const char *graphics_gitadora_window_name(HWND hWnd); // The native GITADORA two-head D3D9 group uses the game's named SMALL // device window for the native physical SMALL head. The game requests // D3DCREATE_NOWINDOWCHANGES, so this host must be made borderless and sized diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index f130fc5a..d1e0cf71 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -671,6 +671,16 @@ int main_implementation(int argc, char *argv[]) { } } + // gitadora arena sub layout + if (options[launcher::Options::GitaDoraArenaSubLayout].is_active()) { + const auto text = options[launcher::Options::GitaDoraArenaSubLayout].value_text(); + if (text == "landscape") { + games::gitadora::ARENA_SUBSCREEN_LANDSCAPE = games::gitadora::ArenaSubscreenLandscape::Small; + } else if (text == "combine") { + games::gitadora::ARENA_SUBSCREEN_LANDSCAPE = games::gitadora::ArenaSubscreenLandscape::All; + } + } + if (options[launcher::Options::GitaDoraWailHold].is_active()) { socd::TILT_HOLD_MS = options[launcher::Options::GitaDoraWailHold].value_uint32(); } @@ -693,9 +703,6 @@ int main_implementation(int argc, char *argv[]) { if (options[launcher::Options::GitaDoraArenaRealtekAccess].value_bool()) { games::gitadora::ALLOW_REALTEK_AUDIO = true; } - if (options[launcher::Options::GitaDoraSubscreenLandscape].value_bool()) { - games::gitadora::ARENA_SUBSCREEN_LANDSCAPE = true; - } if (options[launcher::Options::LoadNostalgiaModule].value_bool()) { attach_nostalgia = true; } diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index b10715c7..6451edd4 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -1330,6 +1330,25 @@ static const std::vector OPTION_DEFINITIONS = { }, .quick_setting_category = "Game", }, + { + // GitaDoraArenaSubLayout + .title = "GitaDora Arena Subscreen Layout (EXPERIMENTAL)", + .name = "gdasublayout", + .desc = "For Arena Model full screen two-window mode: select the image layout on the second monitor.\n\n" + "portrait (default): default subscreen @ 800x1280\n\n" + "landscape: pillarboxed subscreen @ 1920x1080\n\n" + "combine: LEFT + SMALL + RIGHT screens side by side @ 1920x1080\n\n" + "For landscape and combine, -forceressub can be used to override the resolution.", + .type = OptionType::Enum, + .game_name = "GitaDora", + .category = "Game Options", + .elements = { + {"portrait", ""}, + {"landscape", ""}, + {"combine", ""} + }, + .quick_setting_category = "Game", + }, { // GitaDoraArenaAsioDriver .title = "GitaDora Arena ASIO driver", @@ -3425,19 +3444,6 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Bool, .category = "General Overlay", }, - { - // GitaDoraSubscreenLandscape - .title = "GitaDora Arena Landscape Subscreen (EXPERIMENTAL)", - .name = "gdsublandscape", - .desc = "For Arena Model full screen two-window mode: drive the SMALL touch subscreen " - "with a landscape monitor instead of a portrait one.\n\n" - "The portrait image is centered and black bars fill the space on either side.\n\n" - "Launches at 1920x1080; use -forceressub if you need a different resolution.", - .type = OptionType::Bool, - .game_name = "GitaDora", - .category = "Game Options", - .quick_setting_category = "Game", - }, }; const std::vector &launcher::get_categories(Options::OptionsCategory category) { diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index 6a7627da..89dde71a 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -122,6 +122,7 @@ namespace launcher { GitaDoraSubOverlaySize, GitaDoraArenaSingleWindow, GitaDoraArenaWindowLayout, + GitaDoraArenaSubLayout, GitaDoraArenaAsioDriver, GitaDoraArenaRealtekAccess, LoadJubeatModule, @@ -324,7 +325,6 @@ namespace launcher { OBSWebSocketDebug, ScreenshotIncludeOverlay, ScreenshotSubscreens, - GitaDoraSubscreenLandscape }; enum class OptionsCategory {