From f1dec99690af7ca598bcc66dc8b50fde3a096ef9 Mon Sep 17 00:00:00 2001 From: Arthur Freitas Date: Mon, 9 Nov 2026 11:18:32 -0300 Subject: [PATCH 1/2] feat: add a 1920x1080 docked output mode Add 1920x1080 as a docked output size, with output_res=3 and HOS_OUTPUT_RES=3. Keep the saved identifiers for all existing modes and show the corresponding internal resolution in the Graphics menu. Add a game-free regression covering saved modes, the override, handheld fallback, and render-target budgets. On Windows, 119 checks passed and 12 were skipped. The four failures also reproduce on unmodified main. Smoke-tested the same runtime/menu patch on v2.0.7 on Windows with an RTX 4060 Laptop GPU (driver 617.42). The startup run reported 1920x1080 NVN window textures, a 1920x1080 scene at fixed 100%, and frame presents. No gameplay performance comparison was run. Refs #1134 Assisted-by: OpenAI Codex Co-Authored-By: OpenAI Codex --- gpu/vk/vk_menu.cpp | 25 +++++++---- gpu/vk/vk_menu.h | 2 +- host/nn_applet.c | 17 ++++--- readme.md | 4 +- tests/run_all.py | 1 + tests/test_output_resolution.py | 80 +++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 tests/test_output_resolution.py diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 6d10a766d..2f699ef61 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -74,9 +75,11 @@ const char* tx_percent(int v) { struct Out { uint32_t w, h; }; +constexpr Out kOutputs[] = {{2048, 1152}, {2560, 1440}, {3840, 2160}, {1920, 1080}}; +constexpr int kLastOutput = std::size(kOutputs) - 1; +int output_choice(int v) { return v >= 0 && v <= kLastOutput ? v : 0; } Out shown_output() { - static const Out t[] = {{2048, 1152}, {2560, 1440}, {3840, 2160}}; - return opts.handheld ? Out{1536, 864} : t[std::min(std::max(opts.output_res, 0), 2)]; + return opts.handheld ? Out{1536, 864} : kOutputs[output_choice(opts.output_res)]; } constexpr int kResPercent[] = {0, 100, 75, 67, 50}; // The size the game draws the 3D at for a fixed scale of its output, a twelfth of it: every twelfth asked came out exact at the three @@ -97,8 +100,11 @@ const char* tx_resolution(int v) { return b; } const char* tx_output_res(int v) { - static const char* t[] = {"2048x1152 (game, restart)", "2560x1440 (restart)", "3840x2160 (restart)"}; - return t[v]; + static char b[48]; + v = output_choice(v); + const Out o = kOutputs[v]; + snprintf(b, sizeof b, "%ux%u (%srestart)", o.w, o.h, v ? "" : "game, "); + return b; } const char* tx_speed(int v) { return v ? "Real time" : "Per frame"; } const char* tx_text_speed(int v) { @@ -183,7 +189,7 @@ Row rows[] = { {TAB_GRAPHICS, "Resolution", &opts.resolution, 0, 4, 1, tx_resolution, "How sharp the 3D is drawn. Auto lets the game lower it when the GPU is busy. A fixed percentage stays steady: lower is " "faster."}, - {TAB_GRAPHICS, "Output resolution", &opts.output_res, 0, 2, 1, tx_output_res, + {TAB_GRAPHICS, "Output resolution", &opts.output_res, 0, kLastOutput, 1, tx_output_res, "The size the game draws its whole frame at, HUD included, before it is scaled to your window. The 3D is drawn at the " "Resolution percentage of it. Docked only. Applies after a restart."}, {TAB_GRAPHICS, "Upscaling", &opts.upscale, 0, 2, 1, tx_upscale, @@ -1081,8 +1087,11 @@ void menu_init() { char* eq = strchr(line, '='); if (!eq) continue; *eq = 0; - for (Row& r : rows) - if (r.value && !strcmp(line, ini_key(r))) *r.value = std::min(std::max(atoi(eq + 1), r.lo), r.hi); + for (Row& r : rows) { + if (!r.value || strcmp(line, ini_key(r))) continue; + const int v = atoi(eq + 1); + *r.value = r.value == &opts.output_res ? output_choice(v) : std::min(std::max(v, r.lo), r.hi); + } has_resolution |= !strcmp(line, "resolution"); v2 |= !strcmp(line, "options_version"); if (!strcmp(line, "dynamic_resolution")) old_dynres = atoi(eq + 1); @@ -1110,7 +1119,7 @@ void menu_init() { if (r.value == &opts.upscale) *r.value = std::min(std::max(atoi(e), r.lo), r.hi); if (const char* e = getenv("HOS_REALTIME_STEP")) opts.realtime = atoi(e) != 0; if (const char* e = getenv("HOS_DYNRES")) opts.resolution = atoi(e) != 0 ? 0 : 1; - if (const char* e = getenv("HOS_OUTPUT_RES")) opts.output_res = std::min(std::max(atoi(e), 0), 2); + if (const char* e = getenv("HOS_OUTPUT_RES")) opts.output_res = output_choice(atoi(e)); } bool resolution_auto() { return opts.resolution == 0; } diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 75aa3d38f..52e749854 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -10,7 +10,7 @@ struct Options { int framegen = 0; // made frames: 0 off, or 2, 3, 4 times the game's frames int pace = 0; // frames shown on an even line (vk_framegen.cpp) int resolution = 0; // the 3D's: 0 Auto (the game's dynamic resolution), else 100%, 75%, 67%, 50% of its full scale (nx2/oot_patches.py) - int output_res = 0; // the game's whole frame, docked: 0 its 2048x1152, 1 2560x1440, 2 3840x2160; read once, at the start (host/nn_applet.c) + int output_res = 0; // the game's whole frame, docked: 0 its 2048x1152, 1 2560x1440, 2 3840x2160, 3 1920x1080 (host/nn_applet.c) int upscale = 0; // to the window: 0 off (the plain copy to the window), 1 Catmull-Rom, 2 Lanczos int sharpness = 0; // 0 to 100 int realtime = 1; // game time follows the clock (host/nn_vi.c); 0: per frame (slower frames, slower game) diff --git a/host/nn_applet.c b/host/nn_applet.c index 894b54df8..5d0dc9f85 100644 --- a/host/nn_applet.c +++ b/host/nn_applet.c @@ -106,19 +106,26 @@ static int handheld(void) { return v; } /* (hookshot) The Insert menu's Output resolution (output_res=, HOS_OUTPUT_RES over it), docked only: 0 the game's 2048 x 1152, - 1 2560 x 1440, 2 3840 x 2160. The game's frame is its display size (2560 x 1440 as shipped; nx2/oot_patches.py + 1 2560 x 1440, 2 3840 x 2160, 3 1920 x 1080. The game's frame is its display size (2560 x 1440 as shipped; nx2/oot_patches.py apply_output_size asks hos_output_size) times its graphics scale (GetCurrentGraphicsScale: 0 x1, 1 x0.8, 2 x0.667). Here it - saw scale 1 (x0 left at the call before's answer), so 2048 x 1152; 1 and 2 ask for x1 of a 2560 x 1440 or 3840 x 2160 display. */ + saw scale 1 (x0 left at the call before's answer), so 2048 x 1152; each explicit size uses x1. */ static int output_mode(void) { static int v = -1; if (v < 0) { + static const char *const names[] = {"2048x1152 (the game's)", "2560x1440", "3840x2160", "1920x1080"}; v = getenv("HOS_OUTPUT_RES") ? atoi(getenv("HOS_OUTPUT_RES")) : ini_option("output_res", 0); - if (v < 0 || v > 2 || handheld()) v = 0; - hos_log("oe: output resolution %s", v == 2 ? "3840x2160" : v == 1 ? "2560x1440" : "2048x1152 (the game's)"); + if ((unsigned)v >= sizeof names / sizeof *names || handheld()) v = 0; + hos_log("oe: output resolution %s", names[v]); } return v; } -uint32_t hos_output_size(int height) { return output_mode() == 2 ? (height ? 2160 : 3840) : (height ? 1440 : 2560); } +uint32_t hos_output_size(int height) { + switch (output_mode()) { + case 2: return height ? 2160 : 3840; + case 3: return height ? 1080 : 1920; + default: return height ? 1440 : 2560; + } +} /* (hookshot) The render targets' GPU pool (nx2/oot_patches.py), 0x13100000 bytes as shipped, for 2560 x 1440: a 3840x2160 3D at the 4K output ran out of it. Scaled with the output's pixels; HOS_RT_POOL_MB= over it */ uint64_t hos_rt_pool_size(uint64_t game) { diff --git a/readme.md b/readme.md index 636384c2c..eadceacaf 100644 --- a/readme.md +++ b/readme.md @@ -194,7 +194,7 @@ and the right one closes the menu. Your choices are saved in `hookshot_options.i | | Frame generation | Off, 2x to 8x | Off | | | Frame pacing | Off, On | Off | | | Resolution | Auto (the game's own dynamic resolution), 100%, 75%, 67%, 50% (each with its size) | Auto | -| | Output resolution | 2048x1152, 2560x1440, 3840x2160 (after a restart) | 2048x1152 | +| | Output resolution | 2048x1152, 2560x1440, 3840x2160, 1920x1080 (after a restart) | 2048x1152 | | | Upscaling | Off, Catmull-Rom, Lanczos | Off | | | Sharpness | 0 to 100%, in steps of 10 | 0% | | | Handheld profile | Off, On (after a restart) | Off | @@ -223,7 +223,7 @@ and the right one closes the menu. Your choices are saved in `hookshot_options.i show artifacts. **Frame pacing** shows frames at an even interval: less stutter, up to one frame of delay. - **Output resolution** is the size of the game's whole frame, HUD included, before it is scaled to your window. Resolution is a share of it, and its row shows the size for the output you chose. Docked only. At 3840x2160 the game's - render targets get a bigger GPU pool, so a full-size 3D fits. `HOS_OUTPUT_RES=0/1/2` sets it for one run. + render targets get a bigger GPU pool, so a full-size 3D fits. `HOS_OUTPUT_RES=0/1/2/3` sets it for one run, in the order above. - **Upscaling** and **Sharpness** also work when Frame generation is Off. - **Handheld profile** runs the game as if the console were undocked: lower resolution, lighter on the GPU. - **Robust buffers:** a read past a buffer's end is dropped instead of stopping the GPU. Auto is on, on every card; Off diff --git a/tests/run_all.py b/tests/run_all.py index 431618edd..2a7bcbe5d 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -39,6 +39,7 @@ "tests/test_taau_env_lookup.py": ["--cxx", "g++"], "tests/test_target_overlap.py": ["--cxx", "g++"], "tests/test_menu_hit.py": ["--cxx", "g++"], + "tests/test_output_resolution.py": ["--cxx", "{cxx}"], "tests/test_nvn_buffer_map.py": ["--cc", "gcc"], "tests/test_audio_voice_biquad.py": ["--cc", "gcc"], "tests/test_cpp_preflight.py": ["--cxx", "g++"], diff --git a/tests/test_output_resolution.py b/tests/test_output_resolution.py new file mode 100644 index 000000000..537adf145 --- /dev/null +++ b/tests/test_output_resolution.py @@ -0,0 +1,80 @@ +"""Exercise the host and menu's docked output sizes without a game dump.""" +import argparse +import os +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cxx", default="g++") + args = parser.parse_args() + + host = (ROOT / "host/nn_applet.c").read_text(encoding="utf-8") + menu = (ROOT / "gpu/vk/vk_menu.cpp").read_text(encoding="utf-8") + assert '"Output resolution", &opts.output_res, 0, kLastOutput, 1, tx_output_res' in menu + fixture = r''' +#include +#include +#include +#include +#include +#include +#include + +using DWORD = unsigned long; +constexpr int MAX_PATH = 260; +DWORD hos_game_path_a(char *path, int size) { return snprintf(path, size, "./hookshot.exe"); } +void hos_log(const char *, ...) {} +struct { int handheld, output_res; } opts; +''' + host[host.index("static int ini_option("):host.index("HLE(_ZN2nn2oe16GetOperationModeEv)")] + menu[ + menu.index("struct Out {"):menu.index("const char* tx_speed(")] + r''' +int main(int argc, char **argv) { + assert(argc == 5); + const int mode = atoi(argv[1]); + const unsigned w = atoi(argv[2]), h = atoi(argv[3]); + assert(output_mode() == mode); + assert(hos_output_size(0) == w && hos_output_size(1) == h); + assert(hos_rt_pool_size(305ull << 20) == ((mode == 2 ? 687ull : 305ull) << 20)); + assert(kLastOutput == 3); + assert(output_choice(-1) == 0 && output_choice(4) == 0); + opts.output_res = mode; + opts.handheld = handheld(); + assert(strcmp(tx_output_res(mode), argv[4]) == 0); + unsigned scene_w, scene_h; + game_size(1.0f, scene_w, scene_h); + assert(scene_w == (opts.handheld ? 1536 : mode ? w : 2048)); + assert(scene_h == (opts.handheld ? 864 : mode ? h : 1152)); + if (mode == 3) assert(strcmp(tx_resolution(2), "75% (1440x810)") == 0); +} +''' + cases = ( + (0, 0, None, 0, 2560, 1440, "2048x1152 (game, restart)"), + (1, 0, None, 1, 2560, 1440, "2560x1440 (restart)"), + (2, 0, None, 2, 3840, 2160, "3840x2160 (restart)"), + (3, 0, None, 3, 1920, 1080, "1920x1080 (restart)"), + (1, 0, "3", 3, 1920, 1080, "1920x1080 (restart)"), + (3, 1, "3", 0, 2560, 1440, "2048x1152 (game, restart)"), + (4, 0, None, 0, 2560, 1440, "2048x1152 (game, restart)"), + ) + with tempfile.TemporaryDirectory(prefix="output-resolution-") as tmp: + work = Path(tmp) + source, exe = work / "fixture.cpp", work / "fixture.exe" + source.write_text(fixture, encoding="utf-8") + subprocess.run([args.cxx, "-std=c++17", str(source), "-o", str(exe)], check=True) + for saved, handheld, override, mode, w, h, label in cases: + (work / "hookshot_options.ini").write_text(f"output_res={saved}\nhandheld_profile={handheld}\n", encoding="ascii") + env = os.environ.copy() + for key in ("HOS_OUTPUT_RES", "HOS_HANDHELD", "HOS_RT_POOL_MB"): + env.pop(key, None) + if override is not None: + env["HOS_OUTPUT_RES"] = override + subprocess.run([str(exe), str(mode), str(w), str(h), label], cwd=work, env=env, check=True) + print("PASS: output sizes, 1080p override, handheld and render-target budgets") + + +if __name__ == "__main__": + main() From dc7a077c6ebcf67c85771c3f29019ac4490cfcc1 Mon Sep 17 00:00:00 2001 From: Arthur Freitas Date: Mon, 9 Nov 2026 13:36:11 -0300 Subject: [PATCH 2/2] vk: order docked outputs by resolution Step through the sizes from 1080p to 4K, with the saved IDs intact. Describe native HUD and text scaling in the README and test navigation in both directions, including the endpoints. Assisted-by: OpenAI Codex Co-Authored-By: OpenAI Codex --- gpu/vk/vk_menu.cpp | 19 ++++++++++++++++++- readme.md | 8 +++++--- tests/test_output_resolution.py | 8 ++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 2f699ef61..697b0b299 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -78,6 +78,14 @@ struct Out { constexpr Out kOutputs[] = {{2048, 1152}, {2560, 1440}, {3840, 2160}, {1920, 1080}}; constexpr int kLastOutput = std::size(kOutputs) - 1; int output_choice(int v) { return v >= 0 && v <= kLastOutput ? v : 0; } +// Display the sizes from smallest to largest while keeping the IDs used by saved settings. +constexpr int kOutputOrder[] = {3, 0, 1, 2}; +static_assert(std::size(kOutputOrder) == std::size(kOutputs)); +int step_output_choice(int id, int dir) { + const int* pos = std::find(std::begin(kOutputOrder), std::end(kOutputOrder), output_choice(id)); + const int next = std::clamp((int)(pos - std::begin(kOutputOrder)) + dir, 0, kLastOutput); + return kOutputOrder[next]; +} Out shown_output() { return opts.handheld ? Out{1536, 864} : kOutputs[output_choice(opts.output_res)]; } @@ -450,6 +458,10 @@ void save() { /// A row's new value v, as the arrows set it (dir: 1 up, -1 down, for the rows that skip or wrap), saved unless !keep /// (a slider's drag: saved once it is let go) void set_row(Row& r, int v, int dir, bool keep = true) { + if (r.value == &opts.output_res) { + v = step_output_choice(*r.value, dir); + if (v == *r.value) return; + } if (r.value == &opts.framegen && v == 1) v = dir > 0 ? 2 : 0; // (off, 2x ... 8x) if (r.value == &opts.mouse_sens) v = std::min(std::max(v, r.lo), r.hi); // (a scale: it stops at its ends) if (r.value == &opts.pad_layout) { @@ -913,7 +925,12 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { GetTextExtentPoint32A(dc, t.c_str(), (int)t.size(), &z); return (int)z.cx; }; - auto framed = [&](const std::string& t) { return arrows ? "< " + t + " >" : t; }; + const bool output_row = r.value == &opts.output_res; + const bool left_arrow = arrows && (!output_row || step_output_choice(*r.value, -1) != *r.value); + const bool right_arrow = arrows && (!output_row || step_output_choice(*r.value, 1) != *r.value); + auto framed = [&](const std::string& t) { + return arrows ? (left_arrow ? "< " : " ") + t + (right_arrow ? " >" : " ") : t; + }; std::string v = framed(label); if (width(v) > room && arrows) { // (slides: the longest piece from marquee_pos that fits) size_t start = label.size(); // (the last start that still shows the name's end) diff --git a/readme.md b/readme.md index eadceacaf..f932d391b 100644 --- a/readme.md +++ b/readme.md @@ -194,7 +194,7 @@ and the right one closes the menu. Your choices are saved in `hookshot_options.i | | Frame generation | Off, 2x to 8x | Off | | | Frame pacing | Off, On | Off | | | Resolution | Auto (the game's own dynamic resolution), 100%, 75%, 67%, 50% (each with its size) | Auto | -| | Output resolution | 2048x1152, 2560x1440, 3840x2160, 1920x1080 (after a restart) | 2048x1152 | +| | Output resolution | 1920x1080, 2048x1152, 2560x1440, 3840x2160 (after a restart) | 2048x1152 | | | Upscaling | Off, Catmull-Rom, Lanczos | Off | | | Sharpness | 0 to 100%, in steps of 10 | 0% | | | Handheld profile | Off, On (after a restart) | Off | @@ -222,8 +222,10 @@ and the right one closes the menu. Your choices are saved in `hookshot_options.i - **Frame generation** makes extra frames between the game's own. It adds a little input delay, and fast motion can show artifacts. **Frame pacing** shows frames at an even interval: less stutter, up to one frame of delay. - **Output resolution** is the size of the game's whole frame, HUD included, before it is scaled to your window. - Resolution is a share of it, and its row shows the size for the output you chose. Docked only. At 3840x2160 the game's - render targets get a bigger GPU pool, so a full-size 3D fits. `HOS_OUTPUT_RES=0/1/2/3` sets it for one run, in the order above. + On a 1920x1080 display, the 1920x1080 option keeps HUD and text sharp when the game fills the screen by mapping them + pixel for pixel. The separate **Resolution** setting scales the 3D scene as a share of the output size. Docked only. + At 3840x2160 the game's render targets get a bigger GPU pool so full-size 3D fits. For one run, set `HOS_OUTPUT_RES` + to `3` for 1920x1080, `0` for 2048x1152, `1` for 2560x1440, or `2` for 3840x2160. - **Upscaling** and **Sharpness** also work when Frame generation is Off. - **Handheld profile** runs the game as if the console were undocked: lower resolution, lighter on the GPU. - **Robust buffers:** a read past a buffer's end is dropped instead of stopping the GPU. Auto is on, on every card; Off diff --git a/tests/test_output_resolution.py b/tests/test_output_resolution.py index 537adf145..9f5eb16bc 100644 --- a/tests/test_output_resolution.py +++ b/tests/test_output_resolution.py @@ -15,7 +15,6 @@ def main(): host = (ROOT / "host/nn_applet.c").read_text(encoding="utf-8") menu = (ROOT / "gpu/vk/vk_menu.cpp").read_text(encoding="utf-8") - assert '"Output resolution", &opts.output_res, 0, kLastOutput, 1, tx_output_res' in menu fixture = r''' #include #include @@ -41,6 +40,11 @@ def main(): assert(hos_rt_pool_size(305ull << 20) == ((mode == 2 ? 687ull : 305ull) << 20)); assert(kLastOutput == 3); assert(output_choice(-1) == 0 && output_choice(4) == 0); + assert(step_output_choice(3, -1) == 3); + assert(step_output_choice(3, 1) == 0 && step_output_choice(0, -1) == 3); + assert(step_output_choice(0, 1) == 1 && step_output_choice(1, -1) == 0); + assert(step_output_choice(1, 1) == 2 && step_output_choice(2, -1) == 1); + assert(step_output_choice(2, 1) == 2); opts.output_res = mode; opts.handheld = handheld(); assert(strcmp(tx_output_res(mode), argv[4]) == 0); @@ -73,7 +77,7 @@ def main(): if override is not None: env["HOS_OUTPUT_RES"] = override subprocess.run([str(exe), str(mode), str(w), str(h), label], cwd=work, env=env, check=True) - print("PASS: output sizes, 1080p override, handheld and render-target budgets") + print("PASS: output sizes, menu order, 1080p override, handheld and render-target budgets") if __name__ == "__main__":