diff --git a/.github/workflows/source-regressions.yml b/.github/workflows/source-regressions.yml index a61e450d..2a96ce71 100644 --- a/.github/workflows/source-regressions.yml +++ b/.github/workflows/source-regressions.yml @@ -9,6 +9,9 @@ on: - nx2/unpack.py - nx2/test_nup_paths.py - tests/test_make_game_cache.py + - gpu/vk/vk_present_rect.h + - gpu/vk/tests/present_rect_test.cpp + - gpu/vk/vk_replay.cpp - tests/test_child_unicode.py - gpu/vk/vk_replay.cpp - gpu/vk/vk_state.h @@ -67,6 +70,9 @@ on: - nx2/unpack.py - nx2/test_nup_paths.py - tests/test_make_game_cache.py + - gpu/vk/vk_present_rect.h + - gpu/vk/tests/present_rect_test.cpp + - gpu/vk/vk_replay.cpp - tests/test_child_unicode.py - gpu/vk/vk_replay.cpp - gpu/vk/vk_state.h @@ -148,6 +154,34 @@ jobs: run: python -I -B nx2/test_nup_paths.py - name: Check source-cache coherency run: python -I -B tests/test_make_game_cache.py + - name: Check Vulkan presentation rectangle + if: runner.os == 'Windows' + shell: cmd + run: | + @echo off + set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" + if not exist "%VSWHERE%" ( + echo Visual Studio locator not found. + exit /b 1 + ) + set "VSROOT=" + for /f "usebackq delims=" %%i in (`call "%VSWHERE%" -latest -products * -version "[17.0,18.0)" -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSROOT=%%i" + if not defined VSROOT ( + echo Visual Studio 2022 C++ tools not found. + exit /b 1 + ) + if not exist "%VSROOT%\Common7\Tools\VsDevCmd.bat" ( + echo Visual Studio developer command prompt not found. + exit /b 1 + ) + call "%VSROOT%\Common7\Tools\VsDevCmd.bat" -arch=x64 -host_arch=x64 + if errorlevel 1 exit /b 1 + where cl + if errorlevel 1 exit /b 1 + cl /nologo /std:c++17 /W4 /WX /EHsc /Fo"%TEMP%\present_rect_test.obj" /Fe:"%TEMP%\present_rect_test.exe" gpu\vk\tests\present_rect_test.cpp + if errorlevel 1 exit /b 1 + "%TEMP%\present_rect_test.exe" + if errorlevel 1 exit /b 1 - name: Check isolated child Unicode output run: python -I -B tests/test_child_unicode.py - name: Check a failed step's error line diff --git a/gpu/vk/tests/present_rect_test.cpp b/gpu/vk/tests/present_rect_test.cpp new file mode 100644 index 00000000..0963001b --- /dev/null +++ b/gpu/vk/tests/present_rect_test.cpp @@ -0,0 +1,40 @@ +// Game-free regression for the production Vulkan presentation rectangle helper. +#include + +#include "../vk_present_rect.h" + +namespace { +int failures = 0; + +void expect(const char* name, mkwvk::PresentRect got, mkwvk::PresentRect want) { + if (got.x == want.x && got.y == want.y && got.w == want.w && got.h == want.h) return; + std::fprintf(stderr, "%s: got {%u,%u,%u,%u}, wanted {%u,%u,%u,%u}\n", name, got.x, got.y, got.w, got.h, + want.x, want.y, want.w, want.h); + failures++; +} + +} // namespace + +int main() { + using mkwvk::PresentRect; + const PresentRect fit_16_10 = mkwvk::present_rect(1920, 1080, nullptr, 1600, 1000); + expect("16:9 into 16:10", fit_16_10, {0, 50, 1600, 900}); + expect("16:9 same aspect", mkwvk::present_rect(1920, 1080, nullptr, 1920, 1080), {0, 0, 1920, 1080}); + expect("explicit ultrawide camera mode retains fill", + mkwvk::present_rect(1920, 1080, nullptr, 3440, 1440, true), {0, 0, 3440, 1440}); + expect("ultrawide source aspect fits naturally", + mkwvk::present_rect(3440, 1440, nullptr, 3440, 1440), {0, 0, 3440, 1440}); + expect("ultrawide source remains wider than 16:9", + mkwvk::present_rect(3440, 1440, nullptr, 1600, 1000), {0, 165, 1600, 669}); + expect("portrait target", mkwvk::present_rect(1920, 1080, nullptr, 1000, 1600), {0, 519, 1000, 562}); + expect("odd resized destination", mkwvk::present_rect(1920, 1080, nullptr, 1601, 1001), {0, 50, 1601, 900}); + + const int crop_16_9[4] = {160, 90, 2240, 1260}; + expect("cropped 16:9 content", mkwvk::present_rect(2560, 1440, crop_16_9, 1600, 1000), {0, 50, 1600, 900}); + const int crop_odd[4] = {7, 11, 1900, 1069}; + expect("odd crop and resized target", mkwvk::present_rect(1920, 1080, crop_odd, 1600, 1000), {0, 50, 1600, 900}); + const int crop_at_edge[4] = {90, 70, 20, 20}; + expect("small crop clamps to source edge", mkwvk::present_rect(100, 80, crop_at_edge, 160, 100), {30, 0, 100, 100}); + expect("zero source falls back to destination", mkwvk::present_rect(0, 0, nullptr, 160, 100), {0, 0, 160, 100}); + return failures ? 1 : 0; +} diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index faf638ad..7dcda419 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -15,7 +15,7 @@ extern "C" { #endif -#define MKWVK_API_VERSION 3 +#define MKWVK_API_VERSION 4 /* (hookshot) the controls' names (host/nn_hid.c, host/pad_sdl.c, the pad_ and key_ lines of hookshot_options.ini): the Switch's buttons in nn::hid's bit order, and a gamepad's inputs by position, SDL's order (the bottom face @@ -76,6 +76,8 @@ typedef struct MkwVkHost { do (Windows key codes, 0 none; slots 0 and 1). bind with button -1: all back to their defaults */ int (*binding)(int set, int button, int slot); void (*bind)(int set, int button, int slot, int code); + /// The guest camera aspect explicitly applied at startup (0: its default aspect) + float (*aspect_ratio)(void); } MkwVkHost; /* what the back end gives the executable: the rx_* functions (gpu/render/rx_nvn.h, rx_core) */ diff --git a/gpu/vk/vk_present_rect.h b/gpu/vk/vk_present_rect.h new file mode 100644 index 00000000..e833e7c8 --- /dev/null +++ b/gpu/vk/vk_present_rect.h @@ -0,0 +1,43 @@ +// The content rectangle for a window present, independent of Vulkan. +#pragma once + +#include + +namespace mkwvk { + +struct PresentRect { + uint32_t x, y, w, h; +}; + +struct PresentContentSize { + uint32_t w, h; +}; + +constexpr PresentContentSize present_content_size(uint32_t sw, uint32_t sh, const int crop[4]) noexcept { + if (!sw || !sh) return {0, 0}; + if (!crop || crop[2] <= 0 || crop[3] <= 0) return {sw, sh}; + if (crop[0] < 0 || crop[1] < 0 || (uint32_t)crop[0] >= sw || (uint32_t)crop[1] >= sh) return {0, 0}; + const uint32_t available_w = sw - (uint32_t)crop[0]; + const uint32_t available_h = sh - (uint32_t)crop[1]; + const uint32_t crop_w = (uint32_t)crop[2]; + const uint32_t crop_h = (uint32_t)crop[3]; + return {crop_w < available_w ? crop_w : available_w, crop_h < available_h ? crop_h : available_h}; +} + +constexpr PresentRect present_rect(uint32_t sw, uint32_t sh, const int crop[4], uint32_t dw, uint32_t dh, + bool preserve_stretch = false) noexcept { + if (!dw || !dh) return {0, 0, dw, dh}; + if (preserve_stretch) return {0, 0, dw, dh}; + const PresentContentSize content = present_content_size(sw, sh, crop); + if (!content.w || !content.h) return {0, 0, dw, dh}; + + // Fit the source content into the destination while preserving its aspect. + if ((uint64_t)dw * content.h > (uint64_t)dh * content.w) { + const uint32_t w = (uint32_t)((uint64_t)dh * content.w / content.h); + return {(dw - w) / 2, 0, w ? w : 1, dh}; + } + const uint32_t h = (uint32_t)((uint64_t)dw * content.h / content.w); + return {0, (dh - h) / 2, dw, h ? h : 1}; +} + +} // namespace mkwvk diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index bf9cdece..7f7b16ae 100644 --- a/gpu/vk/vk_replay.cpp +++ b/gpu/vk/vk_replay.cpp @@ -33,6 +33,7 @@ #include "vk_state.h" #include "vk_texture.h" #include "vk_xfer.h" +#include "vk_present_rect.h" #include "vk_internal.h" @@ -1928,9 +1929,9 @@ void image_barrier(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkIma vkCmdPipelineBarrier2(cmd, &d); } -/// The crop of src (x, y, w, h; w 0: all of it) scaled into dst (dw x dh) -void blit_crop(VkCommandBuffer cmd, VkImage src, uint32_t sw, uint32_t sh, const int crop[4], VkImage dst, - VkImageLayout dst_layout, uint32_t dw, uint32_t dh) { +/// The crop of src (x, y, w, h; w 0: all of it) scaled into a rectangle of dst. +void blit_crop_to_rect(VkCommandBuffer cmd, VkImage src, uint32_t sw, uint32_t sh, const int crop[4], VkImage dst, + VkImageLayout dst_layout, PresentRect rect) { int x0 = 0, y0 = 0, x1 = (int)sw, y1 = (int)sh; if (crop && crop[2] > 0 && crop[3] > 0) x0 = crop[0], y0 = crop[1], x1 = crop[0] + crop[2], y1 = crop[1] + crop[3]; VkImageBlit b{}; @@ -1938,10 +1939,17 @@ void blit_crop(VkCommandBuffer cmd, VkImage src, uint32_t sw, uint32_t sh, const b.srcOffsets[0] = {x0, y0, 0}; b.srcOffsets[1] = {std::min(x1, (int)sw), std::min(y1, (int)sh), 1}; b.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - b.dstOffsets[1] = {(int)dw, (int)dh, 1}; + b.dstOffsets[0] = {(int)rect.x, (int)rect.y, 0}; + b.dstOffsets[1] = {(int)(rect.x + rect.w), (int)(rect.y + rect.h), 1}; vkCmdBlitImage(cmd, src, VK_IMAGE_LAYOUT_GENERAL, dst, dst_layout, 1, &b, VK_FILTER_LINEAR); } +/// The crop of src scaled to the whole destination (captures keep their existing dimensions). +void blit_crop(VkCommandBuffer cmd, VkImage src, uint32_t sw, uint32_t sh, const int crop[4], VkImage dst, + VkImageLayout dst_layout, uint32_t dw, uint32_t dh) { + blit_crop_to_rect(cmd, src, sw, sh, crop, dst, dst_layout, {0, 0, dw, dh}); +} + // ---- PNG (stored deflate blocks: no compression library needed) ---- uint32_t crc_table[256]; uint32_t crc32(const uint8_t* p, size_t n, uint32_t c = 0xFFFFFFFFu) { @@ -2631,12 +2639,20 @@ void present_image(VkImage src, uint32_t sw, uint32_t sh, const int crop[4], VkI VkImage dst = dev.swap_images[index]; full_barrier(f.cmd); // (after the game's work for the frame: earlier submits on this queue) image_barrier(f.cmd, dst, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); - if (const VkImage scaled = src && view ? framegen_scale(f.cmd, view, src, sw, sh, crop, dev.swap_extent.width, dev.swap_extent.height) : VK_NULL_HANDLE) { - blit_crop(f.cmd, scaled, dev.swap_extent.width, dev.swap_extent.height, nullptr, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, - dev.swap_extent.width, dev.swap_extent.height); + const bool preserve_stretch = host && host->aspect_ratio && host->aspect_ratio() > 1.0f; + const PresentRect rect = present_rect(sw, sh, crop, dev.swap_extent.width, dev.swap_extent.height, preserve_stretch); + const bool has_bars = rect.x || rect.y || rect.w != dev.swap_extent.width || rect.h != dev.swap_extent.height; + if (has_bars) { + const VkClearColorValue black{0.0f, 0.0f, 0.0f, 1.0f}; + const VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdClearColorImage(f.cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &range); + // The following blit overlaps the clear in the content rectangle. + image_barrier(f.cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + } + if (const VkImage scaled = src && view ? framegen_scale(f.cmd, view, src, sw, sh, crop, rect.w, rect.h) : VK_NULL_HANDLE) { + blit_crop_to_rect(f.cmd, scaled, rect.w, rect.h, nullptr, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, rect); } else if (src) { - blit_crop(f.cmd, src, sw, sh, crop, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, dev.swap_extent.width, - dev.swap_extent.height); + blit_crop_to_rect(f.cmd, src, sw, sh, crop, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, rect); } else { const VkClearColorValue black{}; const VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; diff --git a/host/rx_backend.c b/host/rx_backend.c index bdba068f..60ae779e 100644 --- a/host/rx_backend.c +++ b/host/rx_backend.c @@ -50,6 +50,7 @@ int hos_gpu_pools(uint64_t (*out)[2], int max); uint32_t hos_gpu_maps_generation(void); uint32_t nvn_vmap_generation(void); uint64_t hos_reserved_cpus(void); /* hos_thread.c */ +extern float hos_aspect_ratio; /* hos_main.c: the guest camera aspect applied at startup, or 0 */ uint32_t hos_pad_raw(void); /* nn_hid.c: the gamepads and the controls, for the Insert menu */ int hos_binding(int set, int button, int slot); void hos_bind(int set, int button, int slot, int code); @@ -132,13 +133,14 @@ static uint64_t host_guest_alloc(uint64_t size) { void hos_watch_guest_writes(const uint8_t *data, uint64_t size); /* nvn_native.c */ static void host_watch_guest_writes(uint64_t guest, uint64_t size) { hos_watch_guest_writes(hos_guest_base() + guest, size); } +static float host_aspect_ratio(void) { return hos_aspect_ratio; } static const MkwVkHost vk_host = { MKWVK_API_VERSION, host_log, hos_guest_base, hos_guest_size, hos_gpu_ptr, nvn_gpu_guest, nvn_gpu_read, nvn_gpu_write, host_collect, host_free_pages, host_mark_written, hos_ticks, hos_now_ns, nvn_tex_layout, nvn_tex_level_offset, nvn_fmt_bpb, nvn_tex_level_tiles, nvn_tex_linear_stride, nvn_depth_minus_w, hos_gpu_pools, hos_gpu_maps_generation, nvn_vmap_generation, hos_reserved_cpus, host_collect_in, host_guest_alloc, - host_watch_guest_writes, hos_pad_raw, hos_binding, hos_bind, + host_watch_guest_writes, hos_pad_raw, hos_binding, hos_bind, host_aspect_ratio, }; static const MkwVkRenderer *vk; /* the Vulkan back end, when it was chosen and started */