feat(vk): draw the menu, FPS counter and start-up screen with Dear ImGui #1459

Merged
hckrman101 merged 23 commits from imgui-menu into main 2026-11-10 07:03:09 +00:00
hckrman101 commented 2026-11-10 03:43:04 +00:00 (Migrated from github.com)

The Insert menu, the FPS counter and the start-up screen were text drawn by GDI (and a small GDI on Linux, on stb_truetype) into bitmaps, then copied onto the window's image. This PR draws them with Dear ImGui instead, in the style of the game's own options screen.

What changes for players

  • A new look, close to the game's own menus: a tan banner with the tabs, warm cells, a gold selected value, a help box beside the rows, and button prompts at the bottom.
  • The text uses the game's own menu font, read from the player's own game files (the romfs the game runs from). Without it, the included Libertinus font is used.
  • New Menu size row (Gameplay tab): Auto, Small, Medium, Large. Sizes follow the window height at any fraction. Large keeps a 5% TV-safe margin. Auto is Large in fullscreen at 1080 lines and up while a controller is in use, else Medium. It is decided when the menu opens, so it never changes size under the cursor.
  • The prompts show the keys or the controller's buttons, whichever was used last.
  • The start-up bar's percentage sits in the middle of the bar, dark on the gold part and light on the empty part.
  • Every text sits on its own background at a contrast of 4.5:1 or more (WCAG AA), even over a white game frame.
  • Nothing else changes: the same pages, rows, presets, ini, restart question, keys, gamepad and mouse.

How it works

  • gpu/vk/vk_imgui.cpp: the ImGui context and its Vulkan backend (dynamic rendering, our own function loader). Font and logo textures are uploaded with copies on the present's own command buffer. The backend's own upload path (which calls vkQueueWaitIdle) is not used. Fonts are loaded once. The menu's text sizes are baked ahead while the start-up screen shows, so the first open has no hitch.
  • gpu/vk/vk_ui.h: the look (palette, cells, banner, help box, prompts).
  • gpu/vk/vk_menu.cpp: every page is drawn anew from the menu's state at each present while something shows. menu_record records nothing at all while nothing shows, so a closed menu (and no FPS counter) costs nothing.
  • gpu/vk/vk_menu_hit.h: the drawing records each control's rectangle, and the mouse is tested against that. The picture and the hit test cannot drift apart at any size.
  • The swapchain images get COLOR_ATTACHMENT use and image views (for the render pass). On an sRGB-only surface, the swapchain is made mutable (VK_KHR_swapchain_mutable_format) and the overlays draw through UNORM views. Without that extension, the colours are made linear. A new swapchain format makes the pipeline again.
  • The host gives the back end the romfs folder (MkwVkHost.romfs_dir, API version 11).
  • Removed: the GDI path (gdi_draw, the bitmap uploads and copies), host/posix/gdi_posix.c and its part of windows.h, third_party/stb, vk_startup_paint.h. The Windows DLL no longer imports GDI32.

The game's font

The game's menu font is a commercial font licensed to Nintendo. Hookshot never ships it. It is read from the player's own romfs (the folder the game runs from), decompressed (zstd, with the game's own dictionary) and decoded in memory only. It is never written to disk, a cache or a release. If it is not found, Libertinus is used. RX_VK_UI_GAME_FONT=0 forces Libertinus.

Third-party parts added

Part Version Licence Used for
Dear ImGui (core + Vulkan backend) 1.92.9b MIT drawing the overlays
zstd (decoder only) 1.5.7 BSD reading the game's font from the player's files
Libertinus Sans Regular, Sans Bold, Serif Semibold 7.051 SIL OFL 1.1 the fallback font, compiled in

Each folder has its licence and a PROVENANCE.md with the source and the archive's sha256. The Linux AppImage ships these licences in its licenses folder. Removed: stb_truetype (only the GDI path used it).

Testing

  • tests/run_all.py: 206 passed, 0 failed (5 skipped), Linux. New tests:
    • test_ui_record.py: the real ui::record with stubs (barriers, load and store, order);
    • test_game_font.py: the font reader on synthetic files (good, truncated, bad name offset, inexact name, wrong size, wrong tag);
    • test_appimage_licenses.py: the AppImage's licence copies.
  • Linux build, and the Windows DLL cross-build (llvm-mingw). The DLL imports only KERNEL32 and USER32 now (no GDI32).
  • Game runs (Linux, headless) from a copy of a save, through every page with RX_VK_MENU_SCRIPT: the options tabs, Controls, Cheats, Teleport, Inventory, an "After restart" change and the restart question.
    • 1920x1080 and 2560x1440 with keyboard prompts, Menu size Auto (Medium).
    • 3840x2160 fullscreen with controller prompts: Auto picks Large, with the TV-safe margin.
  • The start-up screen: both steps, and the bar at 0%, 50% and 100% at 1080p and 4K.
  • An sRGB swapchain (forced in a test build): with UNORM views the overlays match the UNORM swapchain; without the extension, the linear colours are close.
  • Frame generation 2x and 4x: the menu and the FPS counter (with the game's own rate) show over the generated frames; the log reports 0 late frames.
  • CPU time of menu_record per present, 1080p:
    • nothing shown: 0.2 to 0.3 us (no ImGui frame, no render pass);
    • FPS counter only, frame generation 4x: 15 to 21 us;
    • menu open: 50 to 65 us; the first open 0.18 ms (it was 2.6 ms before the glyphs were baked ahead).
  • The mouse hit test is covered by tests/test_menu_hit.cpp. The headless runs have no pointer, so clicks were not tried in a game run.

Screenshots

Taken in the game (headless, a fresh save). The menu is drawn over the game, dimmed.

1080p, keyboard prompts

Graphics Gameplay
Graphics tab Gameplay tab
Controls Cheats
Controls page Cheats page
Teleport Inventory
Teleport page Inventory page
Restart question 1440p, Sharpness
Restart question Sharpness row at 1440p

4K TV size (Menu size Auto picks Large in fullscreen with a controller), controller prompts

4K, Large, Graphics
Controls Restart question
4K Controls 4K restart question
Cheats, top Cheats, scrolled to Inventory
4K Cheats top 4K Cheats scrolled

Narrow window (1024x1024): the help sits below the rows

1024x1024 Cheats

Start-up screen (the percentage is centred on the bar and stays readable across the fill edge)

1080p, 0% 1080p, 50% 4K, 50%
start-up 0% start-up 50% start-up 50% at 4K

Frame generation 4x: the menu and the FPS counter over generated frames

Menu FPS counter
menu at 4x FPS counter at 4x

The images are on the fork's pr-assets branch, which is not part of this PR.

The Insert menu, the FPS counter and the start-up screen were text drawn by GDI (and a small GDI on Linux, on stb_truetype) into bitmaps, then copied onto the window's image. This PR draws them with Dear ImGui instead, in the style of the game's own options screen. ### What changes for players - A new look, close to the game's own menus: a tan banner with the tabs, warm cells, a gold selected value, a help box beside the rows, and button prompts at the bottom. - The text uses the game's own menu font, read from the player's own game files (the romfs the game runs from). Without it, the included Libertinus font is used. - New **Menu size** row (Gameplay tab): Auto, Small, Medium, Large. Sizes follow the window height at any fraction. Large keeps a 5% TV-safe margin. Auto is Large in fullscreen at 1080 lines and up while a controller is in use, else Medium. It is decided when the menu opens, so it never changes size under the cursor. - The prompts show the keys or the controller's buttons, whichever was used last. - The start-up bar's percentage sits in the middle of the bar, dark on the gold part and light on the empty part. - Every text sits on its own background at a contrast of 4.5:1 or more (WCAG AA), even over a white game frame. - Nothing else changes: the same pages, rows, presets, ini, restart question, keys, gamepad and mouse. ### How it works - `gpu/vk/vk_imgui.cpp`: the ImGui context and its Vulkan backend (dynamic rendering, our own function loader). Font and logo textures are uploaded with copies on the present's own command buffer. The backend's own upload path (which calls `vkQueueWaitIdle`) is not used. Fonts are loaded once. The menu's text sizes are baked ahead while the start-up screen shows, so the first open has no hitch. - `gpu/vk/vk_ui.h`: the look (palette, cells, banner, help box, prompts). - `gpu/vk/vk_menu.cpp`: every page is drawn anew from the menu's state at each present while something shows. `menu_record` records nothing at all while nothing shows, so a closed menu (and no FPS counter) costs nothing. - `gpu/vk/vk_menu_hit.h`: the drawing records each control's rectangle, and the mouse is tested against that. The picture and the hit test cannot drift apart at any size. - The swapchain images get `COLOR_ATTACHMENT` use and image views (for the render pass). On an sRGB-only surface, the swapchain is made mutable (`VK_KHR_swapchain_mutable_format`) and the overlays draw through UNORM views. Without that extension, the colours are made linear. A new swapchain format makes the pipeline again. - The host gives the back end the romfs folder (`MkwVkHost.romfs_dir`, API version 11). - Removed: the GDI path (`gdi_draw`, the bitmap uploads and copies), `host/posix/gdi_posix.c` and its part of `windows.h`, `third_party/stb`, `vk_startup_paint.h`. The Windows DLL no longer imports GDI32. ### The game's font The game's menu font is a commercial font licensed to Nintendo. Hookshot never ships it. It is read from the player's own romfs (the folder the game runs from), decompressed (zstd, with the game's own dictionary) and decoded in memory only. It is never written to disk, a cache or a release. If it is not found, Libertinus is used. `RX_VK_UI_GAME_FONT=0` forces Libertinus. ### Third-party parts added | Part | Version | Licence | Used for | |---|---|---|---| | Dear ImGui (core + Vulkan backend) | 1.92.9b | MIT | drawing the overlays | | zstd (decoder only) | 1.5.7 | BSD | reading the game's font from the player's files | | Libertinus Sans Regular, Sans Bold, Serif Semibold | 7.051 | SIL OFL 1.1 | the fallback font, compiled in | Each folder has its licence and a `PROVENANCE.md` with the source and the archive's sha256. The Linux AppImage ships these licences in its `licenses` folder. Removed: stb_truetype (only the GDI path used it). ### Testing - `tests/run_all.py`: 206 passed, 0 failed (5 skipped), Linux. New tests: - `test_ui_record.py`: the real `ui::record` with stubs (barriers, load and store, order); - `test_game_font.py`: the font reader on synthetic files (good, truncated, bad name offset, inexact name, wrong size, wrong tag); - `test_appimage_licenses.py`: the AppImage's licence copies. - Linux build, and the Windows DLL cross-build (llvm-mingw). The DLL imports only KERNEL32 and USER32 now (no GDI32). - Game runs (Linux, headless) from a copy of a save, through every page with `RX_VK_MENU_SCRIPT`: the options tabs, Controls, Cheats, Teleport, Inventory, an "After restart" change and the restart question. - 1920x1080 and 2560x1440 with keyboard prompts, Menu size Auto (Medium). - 3840x2160 fullscreen with controller prompts: Auto picks Large, with the TV-safe margin. - The start-up screen: both steps, and the bar at 0%, 50% and 100% at 1080p and 4K. - An sRGB swapchain (forced in a test build): with UNORM views the overlays match the UNORM swapchain; without the extension, the linear colours are close. - Frame generation 2x and 4x: the menu and the FPS counter (with the game's own rate) show over the generated frames; the log reports 0 late frames. - CPU time of `menu_record` per present, 1080p: - nothing shown: 0.2 to 0.3 us (no ImGui frame, no render pass); - FPS counter only, frame generation 4x: 15 to 21 us; - menu open: 50 to 65 us; the first open 0.18 ms (it was 2.6 ms before the glyphs were baked ahead). - The mouse hit test is covered by `tests/test_menu_hit.cpp`. The headless runs have no pointer, so clicks were not tried in a game run. ### Screenshots Taken in the game (headless, a fresh save). The menu is drawn over the game, dimmed. **1080p, keyboard prompts** | Graphics | Gameplay | |---|---| | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-01-graphics.png" alt="Graphics tab" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-03-gameplay.png" alt="Gameplay tab" width="100%"> | | **Controls** | **Cheats** | | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-05-controls-page.png" alt="Controls page" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-06-cheats.png" alt="Cheats page" width="100%"> | | **Teleport** | **Inventory** | | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-07-teleport.png" alt="Teleport page" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-08-inventory.png" alt="Inventory page" width="100%"> | | **Restart question** | **1440p, Sharpness** | | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-10-ask.png" alt="Restart question" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1440p-02-sharpness.png" alt="Sharpness row at 1440p" width="100%"> | **4K TV size (Menu size Auto picks Large in fullscreen with a controller), controller prompts** <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/4k-tv-large-pad-02-sharpness.png" alt="4K, Large, Graphics" width="100%"> | Controls | Restart question | |---|---| | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/4k-tv-large-pad-05-controls-page.png" alt="4K Controls" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/4k-tv-large-pad-10-ask.png" alt="4K restart question" width="100%"> | | Cheats, top | Cheats, scrolled to Inventory | |---|---| | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/4k-tv-large-pad-cheats-top.png" alt="4K Cheats top" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/4k-tv-large-pad-cheats-scrolled.png" alt="4K Cheats scrolled" width="100%"> | **Narrow window (1024x1024)**: the help sits below the rows <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1024x1024-cheats-narrow.png" alt="1024x1024 Cheats" width="100%"> **Start-up screen** (the percentage is centred on the bar and stays readable across the fill edge) | 1080p, 0% | 1080p, 50% | 4K, 50% | |---|---|---| | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-startup-0pct.png" alt="start-up 0%" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-startup-50pct.png" alt="start-up 50%" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/4k-startup-50pct.png" alt="start-up 50% at 4K" width="100%"> | **Frame generation 4x**: the menu and the FPS counter over generated frames | Menu | FPS counter | |---|---| | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-framegen-4x-menu.png" alt="menu at 4x" width="100%"> | <img src="https://github.com/hckrman101/Hookshot/raw/pr-assets/imgui-menu/1080p-framegen-4x-fps.png" alt="FPS counter at 4x" width="100%"> | The images are on the fork's `pr-assets` branch, which is not part of this PR.
hckrman101 (Migrated from github.com) reviewed 2026-11-10 04:19:36 +00:00
hckrman101 (Migrated from github.com) left a comment

Self-review notes.

Checked and fine:

  • Barriers: ui::record takes the window image from TRANSFER_DST (after ALL_TRANSFER / TRANSFER_WRITE, which covers the blit, the clear and frame generation's copy) to COLOR_ATTACHMENT, runs one LOAD/STORE pass, and goes back to TRANSFER_DST for image_barrier's move to PRESENT_SRC. Texture copies are recorded before the pass, with UNDEFINED or SHADER_READ_ONLY to TRANSFER_DST and back. Nothing is recorded for an index with no view. test_ui_record.py runs the real record() and layout() and checks all of these masks.
  • Locking: every ImGui call is reached from menu_tick or menu_record, and both run inside present_image under present_image_mu. startup_screen (on the start-up thread) only swaps the StartupView under startup_mu, and draw_startup copies it under the same lock. Context creation and font loading happen in the first ui::begin, so they are under the lock too.
  • Lifetimes: textures and staging buffers that are let go are destroyed 4 presents later. present_image keeps 3 frames in flight, and the backend gets ImageCount = 4 for its vertex buffers. ui::idle keeps reaping while nothing shows. When the format changes, the pipeline is rebuilt after create_swapchain has waited for the device, so the old pipeline is no longer in use. The views are dropped before the old swapchain is destroyed.
  • sRGB: when VK_KHR_swapchain_mutable_format is present, the swapchain gets UNORM views (with a format list and the mutable flag only in that case). Without it, the vertex colours are made linear and the logo is sampled as sRGB. Glyphs are white with coverage in alpha, so they look the same either way.
  • Game-font reader: unzstd caps the output at 256 MB and needs a known content size. sarc_file checks the header size, the BOM, each node against the buffer, the name offset and length (with its NUL), and s0 <= s1, data + s1 <= size. bfotf_font checks the size word against the real size and the sfnt tag. The font goes to the atlas via IM_ALLOC and is never written to disk. With RX_VK_UI_GAME_FONT=0, or when any of the three faces is missing, all three fall back to Libertinus, so the faces are never mixed.
  • Earlier fixes are in: licences in the AppImage (the block runs after the deps' cp -a and has a test), Auto decided when the menu opens (auto_pad), whole UTF-8 characters (utf8_whole, char_starts in fit), the format change and sRGB, --romfs through MkwVkHost.romfs_dir (API 11), texture lifetime, the barrier and font tests, the glyph pre-bake (the dry draw restores hits, marquee_max, list_lines, ease_step and scroll_at), and the Windows test fixes.
  • Menu logic against main: the menu_tick hunks only change the hit map (hits.at/hits.drag), list_lines in place of the fixed 20 (with a re-reveal when it changes), pad_last/auto_pad, and the deleted redraw_menu calls. Keys, pages, presets, ini and the restart flow are untouched. The Menu size row is saved by the generic row loop.
  • The merge: main's GDI-only changes are carried over: the Controls tab stays lit on the Motion page, and the Motion page's prompts show Back with no Tabs. The menu_tick lines from main (Esc back to Controls, tabs from Motion, the Motion row opening TAB_MOTION) are intact. scroll_at has 5 slots: tabs 0-2, Motion at 3, Controls page at kControlsSlot = 4. tab_selected has 4. The deadzone row is drawn and measured like any other row.
  • Hit map: at most 65 boxes in a frame (the Controls page with all 25 rows in view), so kMax = 96 is enough. Boxes are clipped to the visible list area.
  • Cost while hidden: menu_record does ui::idle() (a counter and an empty reap) and returns, with no ImGui frame and no pass. The menu_tick additions are a few branches.
  • Portability: the font is read with fopen from the same ANSI path the game itself opens romfs with. zstd is built with ZSTD_DISABLE_ASM, no threading and no legacy code. ImGui is built with IMGUI_DISABLE_WIN32_FUNCTIONS and IMGUI_DISABLE_DEFAULT_SHELL_FUNCTIONS, so there is no imm32/shell32. third_party/stb has no remaining users.
  • Tests: run_all.py on the PR head, Linux, with the CPM headers: 206 passed, 1 failed, 5 skipped. The failure is test_program_cache_cleanup.py, which fails the same way on main. The old GDI barrier test now lives in test_ui_record.py, with stricter masks. test_menu_hit.cpp now tests the recorded map instead of fixed geometry, and test_menu_hit.py checks that each Part is recorded where it is drawn. Only one check got looser: test_device_lost_exit.py no longer needs hos_log_flush to be last in the table, which is needed now that host_romfs_dir follows it.
  • Leftovers: no TODOs. RX_VK_MENU_DUMP is gone along with the bitmaps. RX_VK_UI_GAME_FONT and RX_VK_UI_PROMPTS are both in the readme. The readme's menu text, the Menu size row, the font note and the licence list match the code.

Worth a look before merge:

  1. The Cheats page does not fit at Large. Its 11 rows are placed at a fixed pitch with no scrolling. At Large the window is 554 points tall (720 / 1.3). The rows start at 92 points and end at 612, while the prompt strip starts at 487. The result is that Teleport sits over "Choose", Switch age is cut in half, and Inventory is entirely below the screen. You can still select Inventory with the D-pad, but its highlight can't be seen and the mouse can't reach it. This is exactly the TV case: Auto picks Large in fullscreen at 1080 lines and up with a controller. The 4K run shows it (the Cheats shot was left out of the description). Fix: use scroll_rows here as the other pages do, or shrink the pitch to fit. Related: on a narrow window the Cheats help box sits at 62% of the height, on top of the last rows.
  2. If creating a texture fails (create_buffer/create_image out of memory), it stays WantCreate with TexID invalid, but that frame's draw list still uses it. In this build (NDEBUG) ImGui_ImplVulkan_RenderDrawData then binds a null descriptor set. This only happens under memory pressure, but skipping RenderDrawData while any texture is not OK would be safer.
  3. The pre-bake only runs while something is drawn. After a resize or F11 with the FPS counter off, the first open at the new size bakes its glyphs right then, so the one-time first-open hitch (2.6 ms measured at 1080p) comes back. Minor.
  4. Nit: nx2/build.py still passes -lgdi32 to the DLL link. lld drops the unused import, so the DLL is fine as described, but the flag could go.
Self-review notes. Checked and fine: - Barriers: `ui::record` takes the window image from TRANSFER_DST (after ALL_TRANSFER / TRANSFER_WRITE, which covers the blit, the clear and frame generation's copy) to COLOR_ATTACHMENT, runs one LOAD/STORE pass, and goes back to TRANSFER_DST for `image_barrier`'s move to PRESENT_SRC. Texture copies are recorded before the pass, with UNDEFINED or SHADER_READ_ONLY to TRANSFER_DST and back. Nothing is recorded for an index with no view. `test_ui_record.py` runs the real `record()` and `layout()` and checks all of these masks. - Locking: every ImGui call is reached from `menu_tick` or `menu_record`, and both run inside `present_image` under `present_image_mu`. `startup_screen` (on the start-up thread) only swaps the `StartupView` under `startup_mu`, and `draw_startup` copies it under the same lock. Context creation and font loading happen in the first `ui::begin`, so they are under the lock too. - Lifetimes: textures and staging buffers that are let go are destroyed 4 presents later. `present_image` keeps 3 frames in flight, and the backend gets `ImageCount = 4` for its vertex buffers. `ui::idle` keeps reaping while nothing shows. When the format changes, the pipeline is rebuilt after `create_swapchain` has waited for the device, so the old pipeline is no longer in use. The views are dropped before the old swapchain is destroyed. - sRGB: when `VK_KHR_swapchain_mutable_format` is present, the swapchain gets UNORM views (with a format list and the mutable flag only in that case). Without it, the vertex colours are made linear and the logo is sampled as sRGB. Glyphs are white with coverage in alpha, so they look the same either way. - Game-font reader: `unzstd` caps the output at 256 MB and needs a known content size. `sarc_file` checks the header size, the BOM, each node against the buffer, the name offset and length (with its NUL), and `s0 <= s1`, `data + s1 <= size`. `bfotf_font` checks the size word against the real size and the sfnt tag. The font goes to the atlas via `IM_ALLOC` and is never written to disk. With `RX_VK_UI_GAME_FONT=0`, or when any of the three faces is missing, all three fall back to Libertinus, so the faces are never mixed. - Earlier fixes are in: licences in the AppImage (the block runs after the deps' `cp -a` and has a test), Auto decided when the menu opens (`auto_pad`), whole UTF-8 characters (`utf8_whole`, `char_starts` in `fit`), the format change and sRGB, `--romfs` through `MkwVkHost.romfs_dir` (API 11), texture lifetime, the barrier and font tests, the glyph pre-bake (the dry draw restores `hits`, `marquee_max`, `list_lines`, `ease_step` and `scroll_at`), and the Windows test fixes. - Menu logic against main: the `menu_tick` hunks only change the hit map (`hits.at`/`hits.drag`), `list_lines` in place of the fixed 20 (with a re-reveal when it changes), `pad_last`/`auto_pad`, and the deleted `redraw_menu` calls. Keys, pages, presets, ini and the restart flow are untouched. The Menu size row is saved by the generic row loop. - The merge: main's GDI-only changes are carried over: the Controls tab stays lit on the Motion page, and the Motion page's prompts show Back with no Tabs. The `menu_tick` lines from main (Esc back to Controls, tabs from Motion, the Motion row opening `TAB_MOTION`) are intact. `scroll_at` has 5 slots: tabs 0-2, Motion at 3, Controls page at `kControlsSlot` = 4. `tab_selected` has 4. The deadzone row is drawn and measured like any other row. - Hit map: at most 65 boxes in a frame (the Controls page with all 25 rows in view), so `kMax = 96` is enough. Boxes are clipped to the visible list area. - Cost while hidden: `menu_record` does `ui::idle()` (a counter and an empty reap) and returns, with no ImGui frame and no pass. The `menu_tick` additions are a few branches. - Portability: the font is read with `fopen` from the same ANSI path the game itself opens romfs with. zstd is built with `ZSTD_DISABLE_ASM`, no threading and no legacy code. ImGui is built with `IMGUI_DISABLE_WIN32_FUNCTIONS` and `IMGUI_DISABLE_DEFAULT_SHELL_FUNCTIONS`, so there is no imm32/shell32. `third_party/stb` has no remaining users. - Tests: `run_all.py` on the PR head, Linux, with the CPM headers: 206 passed, 1 failed, 5 skipped. The failure is `test_program_cache_cleanup.py`, which fails the same way on main. The old GDI barrier test now lives in `test_ui_record.py`, with stricter masks. `test_menu_hit.cpp` now tests the recorded map instead of fixed geometry, and `test_menu_hit.py` checks that each Part is recorded where it is drawn. Only one check got looser: `test_device_lost_exit.py` no longer needs `hos_log_flush` to be last in the table, which is needed now that `host_romfs_dir` follows it. - Leftovers: no TODOs. `RX_VK_MENU_DUMP` is gone along with the bitmaps. `RX_VK_UI_GAME_FONT` and `RX_VK_UI_PROMPTS` are both in the readme. The readme's menu text, the Menu size row, the font note and the licence list match the code. Worth a look before merge: 1. The Cheats page does not fit at Large. Its 11 rows are placed at a fixed pitch with no scrolling. At Large the window is 554 points tall (720 / 1.3). The rows start at 92 points and end at 612, while the prompt strip starts at 487. The result is that Teleport sits over "Choose", Switch age is cut in half, and Inventory is entirely below the screen. You can still select Inventory with the D-pad, but its highlight can't be seen and the mouse can't reach it. This is exactly the TV case: Auto picks Large in fullscreen at 1080 lines and up with a controller. The 4K run shows it (the Cheats shot was left out of the description). Fix: use `scroll_rows` here as the other pages do, or shrink the pitch to fit. Related: on a narrow window the Cheats help box sits at 62% of the height, on top of the last rows. 2. If creating a texture fails (`create_buffer`/`create_image` out of memory), it stays WantCreate with `TexID` invalid, but that frame's draw list still uses it. In this build (NDEBUG) `ImGui_ImplVulkan_RenderDrawData` then binds a null descriptor set. This only happens under memory pressure, but skipping `RenderDrawData` while any texture is not OK would be safer. 3. The pre-bake only runs while something is drawn. After a resize or F11 with the FPS counter off, the first open at the new size bakes its glyphs right then, so the one-time first-open hitch (2.6 ms measured at 1080p) comes back. Minor. 4. Nit: `nx2/build.py` still passes `-lgdi32` to the DLL link. lld drops the unused import, so the DLL is fine as described, but the flag could go.
@ -0,0 +70,4 @@
const size_t name = names + (size_t)(u32(node + 4) & 0xFFFFFF) * 4;
if (name > a.size() || a.size() - name < len + 1 || memcmp(a.data() + name, want, len + 1)) continue; // (with its 0)
const size_t s0 = u32(node + 8), s1 = u32(node + 12);
if (data + s1 > a.size() || s1 < s0) return false;
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

Checked the bounds on a 64-bit size_t: every node is checked before it is read, the name is checked for offset and length including its NUL, and the data range is s0 <= s1 and data + s1 <= size. The u32 values can't overflow the sum. unzstd caps output at 256 MB, and bfotf_font checks the size word and the sfnt tag. test_game_font.cpp covers the truncated and bad-offset cases.

Checked the bounds on a 64-bit size_t: every node is checked before it is read, the name is checked for offset and length including its NUL, and the data range is `s0 <= s1` and `data + s1 <= size`. The u32 values can't overflow the sum. `unzstd` caps output at 256 MB, and `bfotf_font` checks the size word and the sfnt tag. `test_game_font.cpp` covers the truncated and bad-offset cases.
@ -0,0 +222,4 @@
VkBuffer buf = VK_NULL_HANDLE;
VmaAllocation buf_alloc = nullptr;
void* map = nullptr;
if (!size || !create_buffer(size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true, buf, buf_alloc, &map)) return;
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

If this fails (or create_image/AddTexture below), the texture stays WantCreate with TexID invalid, but the draw list built this frame still refers to it. The build has NDEBUG, so ImGui's assert is gone and the backend binds a null descriptor set. It only happens out of memory. Still, it would be safer for record to skip RenderDrawData for the frame when any texture in dd->Textures is still not OK after the update loop.

If this fails (or `create_image`/`AddTexture` below), the texture stays WantCreate with `TexID` invalid, but the draw list built this frame still refers to it. The build has NDEBUG, so ImGui's assert is gone and the backend binds a null descriptor set. It only happens out of memory. Still, it would be safer for `record` to skip `RenderDrawData` for the frame when any texture in `dd->Textures` is still not OK after the update loop.
@ -0,0 +355,4 @@
pi.PipelineRenderingCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO;
pi.PipelineRenderingCreateInfo.colorAttachmentCount = 1;
pi.PipelineRenderingCreateInfo.pColorAttachmentFormats = &pipeline_format;
ImGui_ImplVulkan_CreateMainPipeline(&pi);
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

Checked: CreateMainPipeline destroys the old pipeline right away. That is safe because a format change only comes from create_swapchain, which has already waited for the device, and this begin is the first ImGui recording since then.

Checked: `CreateMainPipeline` destroys the old pipeline right away. That is safe because a format change only comes from `create_swapchain`, which has already waited for the device, and this `begin` is the first ImGui recording since then.
@ -0,0 +412,4 @@
if (!told) told = true, logf("vk: ui: a texture could not be made (out of memory?): the overlays wait for it");
return;
}
layout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_PIPELINE_STAGE_2_ALL_TRANSFER_BIT,
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

Checked: srcStage ALL_TRANSFER / TRANSFER_WRITE covers whatever wrote the image last in present_image (the clear, blit_crop_to_rect, frame generation's copy). The barrier at the end goes back to TRANSFER_DST for image_barrier's ALL_COMMANDS move to PRESENT_SRC. test_ui_record.py asserts both.

Checked: srcStage ALL_TRANSFER / TRANSFER_WRITE covers whatever wrote the image last in `present_image` (the clear, `blit_crop_to_rect`, frame generation's copy). The barrier at the end goes back to TRANSFER_DST for `image_barrier`'s ALL_COMMANDS move to PRESENT_SRC. `test_ui_record.py` asserts both.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

Checked: the mutable flag and the format list are only used when the extension is on, the format really has a different UNORM twin, and COLOR_ATTACHMENT was granted. In every other case the swapchain is created exactly as before, and the views use the swapchain's own format.

Checked: the mutable flag and the format list are only used when the extension is on, the format really has a different UNORM twin, and COLOR_ATTACHMENT was granted. In every other case the swapchain is created exactly as before, and the views use the swapchain's own format.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

This is the bug from the review body. The 11 Cheats rows use a fixed 48-point pitch from hh + 24 * s with no scroll. At Large the window is 554 points tall, the prompt strip starts at about 487, and the last row would end at about 612. Teleport overlaps the prompts, Switch age is cut off, and Inventory is off screen (seen in the 4K run). The options and Controls pages already use scroll_rows, and this page should too, or the pitch should shrink to fit between top and the prompt bar.

This is the bug from the review body. The 11 Cheats rows use a fixed 48-point pitch from `hh + 24 * s` with no scroll. At Large the window is 554 points tall, the prompt strip starts at about 487, and the last row would end at about 612. Teleport overlaps the prompts, Switch age is cut off, and Inventory is off screen (seen in the 4K run). The options and Controls pages already use `scroll_rows`, and this page should too, or the pitch should shrink to fit between `top` and the prompt bar.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

Merge check: scroll_at has TAB_MOTION + 2 slots. The options page scrolls by cur_tab (0-3, Motion at 3) and the Controls page by this slot (4), so the Motion page added on main gets its own slot. prebake saves and restores an array of the same size.

Merge check: `scroll_at` has TAB_MOTION + 2 slots. The options page scrolls by `cur_tab` (0-3, Motion at 3) and the Controls page by this slot (4), so the Motion page added on main gets its own slot. `prebake` saves and restores an array of the same size.
@ -2377,3 +2502,3 @@
}
}
} else if (press[7] && &r == &rows[kCheatsRow]) page = 2, changed = true;
} else if (r.cheat >= 0) cheat_press(r.cheat, press, changed); // (a debug tool: on the Debug tab itself)
hckrman101 (Migrated from github.com) commented 2026-11-10 04:19:37 +00:00

Pre-bake runs only from menu_record, so only while something shows. Once the start-up screen is gone and the FPS counter is off, a resize or F11 means the first open at the new unit bakes on the spot (the old 2.6 ms first-open hitch, once). Minor. One option is to also call prebake when the size changes, even with nothing drawn.

Pre-bake runs only from `menu_record`, so only while something shows. Once the start-up screen is gone and the FPS counter is off, a resize or F11 means the first open at the new unit bakes on the spot (the old 2.6 ms first-open hitch, once). Minor. One option is to also call `prebake` when the size changes, even with nothing drawn.
hckrman101 commented 2026-11-10 04:41:31 +00:00 (Migrated from github.com)

Self-review items addressed:

  1. Cheats at Large: fixed in 28af753. The Cheats rows now scroll and keep the selected row in view, the hit map is clipped to the visible rows, and on narrow windows the help sits below the rows. Checked at 4K Large, 1080p, 1280x1024 and 1024x1024 (4K and narrow screenshots added to the description).
  2. Failed texture creation: fixed in d5b14d9. While any texture failed to create, no barriers or pass are recorded and the texture is retried next frame (test_ui_record covers the failure and the recovery).
  3. Hitch after a resize: fixed in e01dbe0. After a window or Menu size change while nothing shows, one empty frame bakes the glyphs and measures the page, so the first open has no hitch.
  4. gdi32: fixed in a394ccf. It is gone from the DLL link; the DLL imports only KERNEL32 and USER32.

run_all: 207 passed, 0 failed. Linux build and Windows DLL cross-build OK.

Self-review items addressed: 1. **Cheats at Large:** fixed in 28af753. The Cheats rows now scroll and keep the selected row in view, the hit map is clipped to the visible rows, and on narrow windows the help sits below the rows. Checked at 4K Large, 1080p, 1280x1024 and 1024x1024 (4K and narrow screenshots added to the description). 2. **Failed texture creation:** fixed in d5b14d9. While any texture failed to create, no barriers or pass are recorded and the texture is retried next frame (`test_ui_record` covers the failure and the recovery). 3. **Hitch after a resize:** fixed in e01dbe0. After a window or Menu size change while nothing shows, one empty frame bakes the glyphs and measures the page, so the first open has no hitch. 4. **gdi32:** fixed in a394ccf. It is gone from the DLL link; the DLL imports only KERNEL32 and USER32. `run_all`: 207 passed, 0 failed. Linux build and Windows DLL cross-build OK.
hckrman101 commented 2026-11-10 06:54:18 +00:00 (Migrated from github.com)

Changes since the last update, from playing the build and from main moving:

Fixes

  • A and B on a controller (02b0903): the menu confirmed with the bottom face button and went back with the right one, whatever the layout. With the Nintendo layout (the default) the game's A is the right button, so A went back instead of opening Debug tools, Motion or Controls. Main's menu has the same bug. The menu now confirms with whatever button A is bound to and goes back with B's. The prompt bar says what A does on each row (Open, Open list, Apply, Toggle, Switch). test_menu_keys.py covers every layout and every page.
  • Help box over the prompts at Large (bd1616f): the help box could run down over the prompt strip and cut it off. The strip is now laid out first and drawn on top; the help box and the rows end above it, the help moves up or shrinks if it must, and prompts shrink to fit a narrow window.
  • "After restart" cut long names (bd1616f): the tag no longer sits in the name cell. The help box says "Takes effect after a restart" for the selected row, and other rows get a small gold diamond. While there: menu_restart.h had room for 8 restart rows but the menu has 10, so Audio output and Button prompts read start[-1] and Button prompts showed "After restart" with nothing changed. Main has the same bug. kMax is now 16, and a test checks the count.
  • Controls grid cells (bd1616f): the two cells of a binding row now draw like other value cells (same padding, labels centred, a gap between them).

Changes to the menu

  • Teleport is a link (a4c3a8d): it was a picker (Left/Right changed the map) plus a list behind Enter. It now only opens the map list, like Inventory.
  • Debug tools on the Debug tab (e256616): no separate page. The tools show right below the Debug tools switch while it is on, in the same list. Teleport and Inventory still open their pages, and back returns to the Debug tab on the same row.
  • Developer section (e256616): at the bottom of the Debug tab, always shown. Descriptor reuse and Sampler cache (#1330) move there from Graphics, with the same ini keys and restart behaviour.
  • Ultrawide (70e2209): on windows wider than 16:9 the pages draw in a centred 16:9 column, with the game dimmed across the whole window. Nothing moves at 16:9 or narrower.

Merges with main

  • Main's Motion page, Debug tab, Layout preset and Button prompts rows, HDR output, Frame rate cap and RX_VK_SHOT_MENU are drawn with ImGui. In HDR the overlays are converted to HDR10 at the paper white.
  • #1389 (UTF-8 menu text on Linux) is covered by ImGui; test_menu_utf8.py checks that the menu's own text cuts keep whole characters. #1405 (fonts without Latin letters) moved into the game-font reader, which skips a font with no 'A'/'a' and falls back to Libertinus; test_game_font.py covers it.
  • #1453's upload-buffer growth applied to the GDI bitmaps this PR removes, so its test and doc page go with them.
  • MKWVK_API_VERSION is 12 (main took 11 for prompt_face; romfs_dir follows it).

Testing: tests/run_all.py: all pass except tests/test_linux_update.py, which fails the same way on main right now (#1525's absolute-symlink refusal vs test_a_part_keeps_toolchain_links). Linux build with DLSS and the Windows DLL cross-build are clean. Headless runs at 1080p, 1440p and 4K Large: every page, A/B into and out of each sub-page, and Game's own / DLSS / DLAA / FSR 3.1 in the Upscaler row. The ultrawide and Large-layout screenshots are being refreshed and will be added to the description.

Changes since the last update, from playing the build and from main moving: **Fixes** - **A and B on a controller** (02b0903): the menu confirmed with the bottom face button and went back with the right one, whatever the layout. With the Nintendo layout (the default) the game's A is the right button, so A went back instead of opening Debug tools, Motion or Controls. Main's menu has the same bug. The menu now confirms with whatever button A is bound to and goes back with B's. The prompt bar says what A does on each row (Open, Open list, Apply, Toggle, Switch). `test_menu_keys.py` covers every layout and every page. - **Help box over the prompts at Large** (bd1616f): the help box could run down over the prompt strip and cut it off. The strip is now laid out first and drawn on top; the help box and the rows end above it, the help moves up or shrinks if it must, and prompts shrink to fit a narrow window. - **"After restart" cut long names** (bd1616f): the tag no longer sits in the name cell. The help box says "Takes effect after a restart" for the selected row, and other rows get a small gold diamond. While there: `menu_restart.h` had room for 8 restart rows but the menu has 10, so Audio output and Button prompts read `start[-1]` and Button prompts showed "After restart" with nothing changed. Main has the same bug. `kMax` is now 16, and a test checks the count. - **Controls grid cells** (bd1616f): the two cells of a binding row now draw like other value cells (same padding, labels centred, a gap between them). **Changes to the menu** - **Teleport is a link** (a4c3a8d): it was a picker (Left/Right changed the map) plus a list behind Enter. It now only opens the map list, like Inventory. - **Debug tools on the Debug tab** (e256616): no separate page. The tools show right below the Debug tools switch while it is on, in the same list. Teleport and Inventory still open their pages, and back returns to the Debug tab on the same row. - **Developer section** (e256616): at the bottom of the Debug tab, always shown. Descriptor reuse and Sampler cache (#1330) move there from Graphics, with the same ini keys and restart behaviour. - **Ultrawide** (70e2209): on windows wider than 16:9 the pages draw in a centred 16:9 column, with the game dimmed across the whole window. Nothing moves at 16:9 or narrower. **Merges with main** - Main's Motion page, Debug tab, Layout preset and Button prompts rows, HDR output, Frame rate cap and `RX_VK_SHOT_MENU` are drawn with ImGui. In HDR the overlays are converted to HDR10 at the paper white. - #1389 (UTF-8 menu text on Linux) is covered by ImGui; `test_menu_utf8.py` checks that the menu's own text cuts keep whole characters. #1405 (fonts without Latin letters) moved into the game-font reader, which skips a font with no 'A'/'a' and falls back to Libertinus; `test_game_font.py` covers it. - #1453's upload-buffer growth applied to the GDI bitmaps this PR removes, so its test and doc page go with them. - `MKWVK_API_VERSION` is 12 (main took 11 for `prompt_face`; `romfs_dir` follows it). **Testing:** `tests/run_all.py`: all pass except `tests/test_linux_update.py`, which fails the same way on main right now (#1525's absolute-symlink refusal vs `test_a_part_keeps_toolchain_links`). Linux build with DLSS and the Windows DLL cross-build are clean. Headless runs at 1080p, 1440p and 4K Large: every page, A/B into and out of each sub-page, and Game's own / DLSS / DLAA / FSR 3.1 in the Upscaler row. The ultrawide and Large-layout screenshots are being refreshed and will be added to the description.
Joey-Magnus commented 2026-11-10 13:14:08 +00:00 (Migrated from github.com)

Tested by hand on Linux (KDE Wayland, RTX 4070 Ti), windowed, build fb70b34, complete dump, DLSS options:

  • F1 opens the new menu. It has the tan banner with the version and tabs (Graphics / Gameplay / Controls / Debug, Q/E hints), gold selected row, help box on the right ("DLSS model ... the one tested most here"), and the Choose / Change / Tabs / Close prompts along the bottom. The log confirms the game's own menu font is loaded from the player's files.
  • The existing options came through: 3840x2160 output, NVIDIA DLSS, Transformer (K), Quality (67%), Catmull-Rom. The HDR rows and the Frame rate cap row show as expected.
  • The FPS badge in the top-right reads 50 while the menu is open.
  • Gameplay at the Hyrule Castle road holds 60 fps with no device lost or crashes.

Nothing broken found. I only tried the keyboard: the menu didn't open from scripted pad input (HOS_INPUT_LIVE), which is expected since it reads the real devices.

Tested by hand on Linux (KDE Wayland, RTX 4070 Ti), windowed, build fb70b34, complete dump, DLSS options: - F1 opens the new menu. It has the tan banner with the version and tabs (Graphics / Gameplay / Controls / Debug, Q/E hints), gold selected row, help box on the right ("DLSS model ... the one tested most here"), and the Choose / Change / Tabs / Close prompts along the bottom. The log confirms the game's own menu font is loaded from the player's files. - The existing options came through: 3840x2160 output, NVIDIA DLSS, Transformer (K), Quality (67%), Catmull-Rom. The HDR rows and the Frame rate cap row show as expected. - The FPS badge in the top-right reads 50 while the menu is open. - Gameplay at the Hyrule Castle road holds 60 fps with no device lost or crashes. Nothing broken found. I only tried the keyboard: the menu didn't open from scripted pad input (HOS_INPUT_LIVE), which is expected since it reads the real devices.
Commenting is not possible because the repository is archived.
No description provided.