diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 6d10a766d..697b0b299 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,19 @@ 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; } +// 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() { - 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 +108,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 +197,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, @@ -444,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) { @@ -907,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) @@ -1081,8 +1104,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 +1136,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..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 (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` sets it for one run. + 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/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..9f5eb16bc --- /dev/null +++ b/tests/test_output_resolution.py @@ -0,0 +1,84 @@ +"""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") + 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); + 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); + 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, menu order, 1080p override, handheld and render-target budgets") + + +if __name__ == "__main__": + main()