Skip to content
Closed
Binary file added docs/images/menu-fps-scale-after.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/menu-fps-scale-before.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 44 additions & 8 deletions gpu/vk/vk_menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,7 @@ int measure_pending(int s) {
DeleteDC(dc);
return (int)sz.cy;
}
void redraw_menu(int s) {
void redraw_menu_at(int s) {
if (page == 5) {
gdi_draw(menu_bmp, kMenuWidth * s, (unsigned)menu_hit::kAskHeight * s, paint_menu, s);
return;
Expand All @@ -1682,6 +1682,30 @@ void redraw_menu(int s) {

std::atomic<int64_t> game_frames{0}, shown_frames{0};
int cur_scale = 1;
// (hookshot, v2.0.13) The menu at a scale it fits the window at. The scale is a whole step for each 720 lines (scale_of), and the
// Graphics tab is about 820 lines a step: at 1440p or 4K fullscreen the menu came out taller than the screen, and copy_over leaves
// out a bitmap that does not fit, so F1 and Insert showed nothing (a window on the same monitor, under 1440 lines, was scale 1 and
// fitted). Drawn too large, it is drawn again a step smaller, and that step is kept until the window's size changes.
int menu_fit = 1 << 20;
uint32_t menu_fit_w = 0, menu_fit_h = 0;
int menu_scale_for(uint32_t w, uint32_t h) {
if (w != menu_fit_w || h != menu_fit_h) menu_fit_w = w, menu_fit_h = h, menu_fit = 1 << 20;
return std::min(scale_of(h), menu_fit);
}
void redraw_menu(int s) {
redraw_menu_at(s);
for (;;) {
uint32_t mw, mh;
{
std::lock_guard lk(bmp_mu);
mw = menu_bmp.w, mh = menu_bmp.h;
}
if (s <= 1 || (mw <= dev.swap_extent.width && mh <= dev.swap_extent.height)) return;
s--;
menu_fit = cur_scale = s;
redraw_menu_at(s);
}
}

struct Upload {
VkBuffer buf = VK_NULL_HANDLE;
Expand All @@ -1693,7 +1717,13 @@ Upload ups[3][3]; // (the menu's, the counter's, the start-up screen's; a fram

void copy_over(VkCommandBuffer cmd, VkImage dst, Bitmap& b, Upload& u, int x, int y, uint32_t w, uint32_t h) {
std::lock_guard lk(bmp_mu);
if (!b.w || (uint32_t)x + b.w > w || (uint32_t)y + b.h > h) return;
if (!b.w) return;
// (the keyed ones whole or not at all; the menu, at its smallest scale still taller than a small window, cut to it rather than
// left out: v2.0.13)
if (b.keyed && (x < 0 || y < 0 || (uint32_t)x + b.w > w || (uint32_t)y + b.h > h)) return;
const int x0 = std::max(x, 0), y0 = std::max(y, 0);
const int cw = std::min((int64_t)x + b.w, (int64_t)w) - x0, ch = std::min((int64_t)y + b.h, (int64_t)h) - y0;
if (cw <= 0 || ch <= 0) return;
const uint64_t size = (uint64_t)b.w * b.h * 4;
if (u.size < size) {
if (!create_buffer(size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true, u.buf, u.alloc, &u.map)) return;
Expand All @@ -1708,9 +1738,11 @@ void copy_over(VkCommandBuffer cmd, VkImage dst, Bitmap& b, Upload& u, int x, in
return;
}
VkBufferImageCopy c{};
c.bufferOffset = ((uint64_t)(y0 - y) * b.w + (uint64_t)(x0 - x)) * 4;
c.bufferRowLength = b.w;
c.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
c.imageOffset = {x, y, 0};
c.imageExtent = {b.w, b.h, 1};
c.imageOffset = {x0, y0, 0};
c.imageExtent = {(uint32_t)cw, (uint32_t)ch, 1};
vkCmdCopyBufferToImage(cmd, u.buf, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &c);
}

Expand Down Expand Up @@ -2009,7 +2041,7 @@ void menu_tick() {
bool tab_left = press[10] || pad(HOS_PAD_LSHOULDER) || pad(HOS_PAD_LTRIGGER);
bool tab_right = press[11] || press[12] || pad(HOS_PAD_RSHOULDER) || pad(HOS_PAD_RTRIGGER);
if (press[12] && (GetAsyncKeyState(VK_SHIFT) & 0x8000)) tab_left = true, tab_right = false;
const int scale = scale_of(dev.swap_extent.height);
const int scale = menu_scale_for(dev.swap_extent.width, dev.swap_extent.height); // (v2.0.13: one the menu fits at)
bool changed = scale != cur_scale || menu_refresh;
menu_refresh = false;
cur_scale = scale;
Expand Down Expand Up @@ -2412,7 +2444,8 @@ void menu_tick() {
if (opts.framegen >= 1) snprintf(fps_sub, sizeof fps_sub, "game %.0f", game);
else fps_sub[0] = 0;
fps_bmp.keyed = true;
gdi_draw(fps_bmp, 120u * scale, 54u * scale, paint_fps, scale);
const int fps_scale = scale_of(dev.swap_extent.height);
gdi_draw(fps_bmp, 120u * fps_scale, 54u * fps_scale, paint_fps, fps_scale);
}
}
}
Expand Down Expand Up @@ -2443,7 +2476,8 @@ void menu_record(VkCommandBuffer cmd, VkImage dst, int slot, uint32_t w, uint32_
std::unique_lock lk(bmp_mu);
const uint32_t fw = fps_bmp.w;
lk.unlock();
if (fw) copy_over(cmd, dst, fps_bmp, ups[1][slot], (int)w - (int)fw - (int)(12 * cur_scale), (int)(10 * cur_scale), w, h);
const int fps_scale = scale_of(dev.swap_extent.height);
if (fw) copy_over(cmd, dst, fps_bmp, ups[1][slot], (int)w - (int)fw - (int)(12 * fps_scale), (int)(10 * fps_scale), w, h);
}
if (open_now.load(std::memory_order_relaxed)) {
std::unique_lock lk(bmp_mu);
Expand All @@ -2456,7 +2490,9 @@ void menu_record(VkCommandBuffer cmd, VkImage dst, int slot, uint32_t w, uint32_
VkDependencyInfo di{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
di.memoryBarrierCount = 1, di.pMemoryBarriers = &mb;
vkCmdPipelineBarrier2(cmd, &di);
if (mw) copy_over(cmd, dst, menu_bmp, ups[0][slot], (int)(w - mw) / 2, (int)(h - mh) / 2, w, h);
int mx0 = 0, my0 = 0;
menu_hit::origin((int)w, (int)h, (int)mw, (int)mh, &mx0, &my0); // (where the mouse finds it too)
if (mw) copy_over(cmd, dst, menu_bmp, ups[0][slot], mx0, my0, w, h);
}
}

Expand Down
6 changes: 4 additions & 2 deletions gpu/vk/vk_menu_hit.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ inline void to_image(int x, int y, int win_w, int win_h, int img_w, int img_h, i
}
/// The menu's top left in the window's image: centred, as menu_record copies it
inline void origin(int img_w, int img_h, int menu_w, int menu_h, int* x0, int* y0) {
*x0 = (img_w - menu_w) / 2;
*y0 = (img_h - menu_h) / 2;
// (centred; a menu taller or wider than the window from its top or left edge, so the title and the tabs show and the help
// below is what is cut: v2.0.13)
*x0 = img_w >= menu_w ? (img_w - menu_w) / 2 : 0;
*y0 = img_h >= menu_h ? (img_h - menu_h) / 2 : 0;
}
inline int rows_top(int s) { return (kTabY + kTabH + kRowGap) * s; }
constexpr int kTabs = 4; // Graphics, Gameplay, Controls, Debug
Expand Down
41 changes: 37 additions & 4 deletions host/nn_vi.c
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ HLE(_ZN2nn2vi15GetNativeWindowEPPvPNS0_5LayerE) {
HOS_NO_PACING=1: as fast as it can. */
static volatile int present_interval = 1;
/* (hookshot) HOS_FPS_MAX=<61-240>: the frame rate cap past the console's 60 (opt-in, a test): the display pacing's interval, the game's
own limiter (+0xfc) and the real-time step's rate (+0x2c8, game time = 60 / rate a frame) all go up to it, so the game is not sped up.
Unset or 60: 60, as on the console */
own limiter (+0xfc) and the vsync event go up to it, and the game's time step is the frames' own time (realtime_step), so the game is
not sped up. Unset or 60: 60, as on the console */
int hos_ini_option(const char *key, int otherwise); /* (nn_applet.c) */
static int fps_max(void) {
static int v = -1;
Expand Down Expand Up @@ -221,6 +221,9 @@ static float text_scale(const char *env, int menu) {
const int v = env && *env ? atoi(env) : menu;
return v == 1 ? 2.0f : v == 2 ? 4.0f : v >= 3 ? 1e6f : 1.0f;
}
volatile uint64_t hos_step_probe = 0; /* (f_80706be0's object, set by the lifted code when the frame-time hook is in this build; its
+0xd58 is the game clock in 1/60 s: nx2/oot_patches.py) */
volatile uint32_t hos_frame_seconds_bits = 0; /* (the frame's seconds as a float's bits for that hook, 0: the game's own step) */
volatile uint64_t hos_res_module = 0; /* (f_831f3200's object, whose +0xe5c is the 3D's scale: nx2/oot_patches.py) */
static void realtime_step(int64_t now) {
int rx_option(int id, int otherwise);
Expand Down Expand Up @@ -275,6 +278,18 @@ static void realtime_step(int64_t now) {
hos_log("vi: frame limiter %d, vrr %d %d, nominal %d, controller +0x58.. %d %d %d %d", *limit, *(uint8_t *)GP(mod + 0x231),
*(uint8_t *)GP(mod + 0x232), *(int *)GP(mod + 0x2c8), *(int *)GP(mod + 0x270 + 0x58), *(int *)GP(mod + 0x270 + 0x28),
*(int *)GP(mod + 0x270 + 0x2c), *(int *)GP(mod + 0x270 + 0x30));
if (log_on && !((n - 1) % 300) && hos_step_probe) { /* (game speed: the game clock's 1/60 s units a second of wall time, 60.0 at speed 1) */
static double clock_before;
static int64_t at_before;
double game_clock;
memcpy(&game_clock, GP(hos_step_probe + 0xd58), 8);
if (at_before && now > at_before) {
const double rate = (game_clock - clock_before) * 1e9 / (double)(now - at_before);
hos_log("vi: game clock %.1f: %.2f a second of wall time (speed %.3f) at %.1f fps", game_clock, rate, rate / 60.0,
300e9 / (double)(now - at_before));
}
clock_before = game_clock, at_before = now;
}
if (target && *limit > 0 && *limit < target) *limit = target;
if (fps_max() > 60 && *limit > 0 && *limit < fps_max()) *limit = fps_max();
}
Expand Down Expand Up @@ -302,11 +317,29 @@ static void realtime_step(int64_t now) {
if (count < (unsigned)window) count++;
double sum = 0;
for (unsigned i = 0; i < count; i++) sum += samples[i];
/* (hookshot) A cap above 60: the engine's own rate is 60 / <whole display intervals>, and its controller puts 60 back over a
higher +0x2c8 before the step is made from it, so every frame still stepped 1/60 s and the game ran fps / 60 times too fast.
The step is then the frames' own time at the two places the engine makes it (nx2/oot_patches.py, hos_frame_seconds_bits):
this same average, at most 1/15 s as below and at least 1/240 s, and the rate under it stays the engine's, 60 at most.
HOS_FPS_STEP=0, or a lift without the hook (hos_step_probe unset): the rate up to the cap, as before */
static int timed_step = -1;
if (timed_step < 0) timed_step = !(getenv("HOS_FPS_STEP") && !atoi(getenv("HOS_FPS_STEP")));
const int timed = fps_max() > 60 && timed_step && hos_step_probe;
if (timed) {
const double secs = sum / count;
const float f = (float)(secs > 4.0 / 60.0 ? 4.0 / 60.0 : secs < 1.0 / 240.0 ? 1.0 / 240.0 : secs);
uint32_t bits;
memcpy(&bits, &f, 4);
hos_frame_seconds_bits = bits;
} else {
hos_frame_seconds_bits = 0;
}
const int top = timed ? 60 : fps_max();
double ratio = sum / count * 60.0 + carry;
const double least = 60.0 / fps_max();
const double least = 60.0 / top;
ratio = ratio < least ? least : ratio > 4.0 ? 4.0 : ratio;
int fps = (int)(60.0 / ratio + 0.5);
fps = fps < 15 ? 15 : fps > fps_max() ? fps_max() : fps;
fps = fps < 15 ? 15 : fps > top ? top : fps;
int *slot = (int *)GP(mod + 0x2c8);
if (*slot < 15 || *slot > fps_max()) {
static int logged;
Expand Down
65 changes: 65 additions & 0 deletions nx2/oot_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,55 @@ def patch_dynres(s):
return s.replace('#include "a64.h"\n', '#include "a64.h"\n' + DECL, 1)


# The frame's real time in place of the nominal rate, for a Frame rate cap above 60 (host/nn_vi.c hos_frame_seconds_bits; 0, as
# at a cap of 60: the game's own divide, nothing changes). The engine has two places where its integer nominal rate (+0x2c8 of
# the graphics module) becomes a time step, both a float divide "base / nominal":
# - f_80706be0: the frame's step, base 60.0 (30.0 in the half-rate mode), added to the game clock (the double at
# [x19+0xd58], in 1/60 s) and handed on as the frame's seconds;
# - f_83b6ea90: the global step (60.0 / nominal, a float in .data) the rest of the engine reads.
# The engine's own rate is 60 / <whole display intervals>, never above 60, and its controller puts 60 back over a higher value
# written from outside before either divide runs: above 60 fps every frame still stepped 1/60 s and the game ran fps / 60 times
# too fast. With the host's measured frame time the step is base * seconds, whatever the rate. hos_step_probe: f_80706be0's
# x19, which tells the host the hook is in this build and gives it the game clock for its log (HOS_FPS_LOG=1).
STEP_SITES = (("void f_80706be0(Ctx *__restrict A64_NOESC c) {\n", 8, "\n hos_step_probe = c->x[19];"),
("void f_83b6ea90(Ctx *__restrict A64_NOESC c) {\n", 0, ""))
STEP_OLD = " _v=a64_fpx_div32_div32(c,_a,_b);\n a64_vset64(c->vreg[%d],_v);}"
STEP_HOOK = (" _v=a64_fpx_div32_div32(c,_a,_b);\n"
" { const uint32_t _hs = hos_frame_seconds_bits; if (_hs) { float _base, _secs; uint32_t _w; memcpy(&_base,&_a,4); "
"memcpy(&_secs,&_hs,4); _base *= _secs; memcpy(&_w,&_base,4); _v = _w; } } /* (base * the frame's seconds: nx2/oot_patches.py) */\n"
" a64_vset64(c->vreg[%d],_v);}")
STEP_MARK = "hos_frame_seconds_bits; if (_hs)" # (once per divide with the hook)
STEP_DECL = "extern volatile uint32_t hos_frame_seconds_bits;\nextern volatile uint64_t hos_step_probe;\n"


def patch_frame_seconds(s):
"""The host's frame time at the step divides of f_80706be0 and f_83b6ea90: (s, the sites patched here). A site whose
function is not in the file, is patched already, or whose code differs (another game version) is left as it is."""
done = 0
for head_text, reg, extra in STEP_SITES:
head = s.find(head_text)
if head < 0:
continue
end = s.find("\nvoid f_", head + len(head_text))
body = s[head:end if end >= 0 else len(s)]
old, new = STEP_OLD % reg, STEP_HOOK % reg + extra
if new in body or body.count(old) != 1 or body.count("a64_fpx_div32_div32") != 1:
continue
at = head + body.index(old)
s = s[:at] + new + s[at + len(old):]
done += 1
if done and STEP_DECL not in s:
s = s.replace('#include "a64.h"\n', '#include "a64.h"\n' + STEP_DECL, 1)
return s, done


def unpatch_frame_seconds(s):
"""s without the hook, as the lift wrote it: for a lift where only one of the two divides was found (apply)"""
for _, reg, extra in STEP_SITES:
s = s.replace(STEP_HOOK % reg + extra, STEP_OLD % reg)
return s if STEP_MARK in s else s.replace(STEP_DECL, "", 1) # (a hook of another shape stays, with its declarations)


def patch_text_speed(s):
"""The scale at the head of the letters' block of f_80b1e140; None when the file has no such function, s unchanged when the
function is there but its code differs (another game version)."""
Expand Down Expand Up @@ -197,6 +246,7 @@ def apply(gen, *, protect_hearts=False, required=False):
done["player flight rotation"] = 0
done["free camera"] = 0
done["mouse camera"] = 0
step_sites, step_files = 0, [] # (the divides with the hook, and the files that have it)
for path in sorted(glob.glob(os.path.join(gen, "code_*.c"))):
with open(path, encoding="utf-8", newline="") as f:
s = f.read()
Expand Down Expand Up @@ -240,11 +290,26 @@ def apply(gen, *, protect_hearts=False, required=False):
out = view
done["free camera"] += 1
print("oot_patches: free camera in", os.path.basename(path))
out, sites = patch_frame_seconds(out)
if STEP_MARK in out: # (made now, or by an earlier run over this file)
step_sites += out.count(STEP_MARK)
step_files.append(path)
if sites:
print("oot_patches: the frame's time at %d step divide(s) in %s" % (sites, os.path.basename(path)))
if out != s:
whole_write.write_whole(path, out, newline="")
for what, n in done.items():
if n != 1:
print("oot_patches: WARNING: the %s was found %d times" % (what, n))
if step_sites != len(STEP_SITES):
# (both or neither: with one divide timed and the other stepping 1/60 s the two clocks of the game would run apart. Without
# the hook the host keeps the rate up to the cap, as before: host/nn_vi.c, hos_step_probe)
for path in step_files:
with open(path, encoding="utf-8", newline="") as f:
s = f.read()
whole_write.write_whole(path, unpatch_frame_seconds(s), newline="")
print("oot_patches: WARNING: the frame's time was found at %d of %d step divides, none patched (a Frame rate cap above 60 "
"keeps the game's own step)" % (step_sites, len(STEP_SITES)))
apply_output_size(gen, required=required)


Expand Down
Loading
Loading