Skip to content

feat(prompts): a new face or new controls show while the game runs, no restart - #2366

Merged
hckrman101 merged 11 commits into
NotRexed:mainfrom
gab9281:live-prompts
Nov 11, 2026
Merged

hckrman101 merged 11 commits into
NotRexed:mainfrom
gab9281:live-prompts

Conversation

@gab9281

@gab9281 gab9281 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Since #1321, changing the Button prompts row or a binding on the Controls page only shows in the game's icons after a restart. This PR makes those changes show while the game runs, keeping #1321's rules.

What changes

When the face (the row, or Auto's controller) or a binding changes, host/prompts.c writes the new prompts where the game keeps the ones it loaded from the files #1321 serves:

  • Textures: the ocarina's notes, the Talk ring's letters, and the System page's controller picture with its leader lines. They're written in the memory pool the game makes over Common.bntx and change at once. The System page's picture is drawn once per opening, so it changes when the page is reopened.
  • Layouts: the Talk ring's A B X Y places and the ocarina hint's spacing, written where the game keeps Common.blarc.
  • Icons in texts (dialogue, control hints, menus, song lessons):
    • The game caches each glyph it has drawn under its character code, so a new cmap alone never shows.
    • The font now has its own code for each face, Switch button and pad input (nx2/prompt_assets.py, BIND_BASE U+F000..F54F, free in the game's font).
    • A text's button codes are rewritten to the codes of the current face and controls as the game lays the text out or draws it. This is done by two hooks at the head of the lifted text-pane functions (nx2/oot_patches.py).
  • Menu: the Button prompts row no longer asks for a restart when the game has all three parts loaded. Otherwise it asks, as before (for example under a mod that replaces those files).

#1321's rules are unchanged:

  • Mods come first.
  • The Switch face with the default controls shows the game's own images.
  • Without the prompt files, the game keeps its own icons.
  • Auto falls back to the Switch face when no controller is named.
  • The icons follow hos_binding, the bindings the Controls page sets.

Limit: a text that stays on screen keeps its old icons until the game shows it again: the next dialogue page, the hint shown again, a menu page reopened. The game keeps each text's drawn result until then. Textures change at once.

MKWVK_API_VERSION 17: MkwVkHost.prompts_live tells the menu which parts can change at once. Upstream's 15 (FPS breakdown) and 16 (control bridge, #926) came in on the rebase.

Screenshots

System page, Xbox, then PS5 after a live change and a reopen (ZL on RB reads "Target R1"). Windows build, under Proton:
system

Field: Xbox, then PS5 with A bound to R3 while the game runs. The Talk ring's letters moved at once, and the prompt reads "R3 Talk" (Windows):
field

Impa's lesson, Steam Deck, then a live change to PS5 during her dialogue. The next page has the PS5 icons (Linux):
impa dialogue

The same lesson: Impa's notes, and Link's replay with "Play a note" in PS5 (Linux):
impa lesson

The same on Windows:
impa windows

Tested

  • Linux, in game:
    • Talk ring and field prompt.
    • System page.
    • Bonooru's ocarina grid and hint. The notes on the staff changed PS5 → Xbox while shown.
    • His dialogue in each face.
    • Pause menu pages.
    • Impa's song lesson (dialogue, her notes, Link's replay) after live changes, on the final build.
  • Windows (llvm-mingw cross-build, run under GE-Proton): the same runs for the System page, the field (R3 Talk, the ring) and Impa's lesson. All three parts were found, and every change reached them.
  • Tests: python -I -B tests/run_all.py gives 336 passed, 3 failed, 49 skipped. All 3 fail the same way on upstream main:
    • test_wayland_libdecor_hint (newer clang, -Wunused-but-set-global in its own harness).
    • test_vcore_fs_open and test_vcore_video_acquire (env_on undeclared in their fixture).
    • New: test_prompts_live (putting the game's bytes back, guarded).
    • Extended: test_prompt_font_bind (285 text codes rewritten), test_menu_hooks (the text hooks).
  • Packages: no new files.
    • The AppImage copies nx2, host and gpu/vk whole.
    • The Windows parts take every tracked file under gpu, host, nx2, scripts and third_party.
    • test_appimage_prompt_fonts passes.
  • Review: a code review of the branch led to the last commit:
    • A part found after a change catches up.
    • Auto with no controller is the Switch face, as at the start.
    • Release/acquire on the pool's address.
    • The layouts are found without textures.
    • A font face not readable yet is looked at again.
    • !bind takes only a Switch button.
  • Left as is:
    • A change reads its files on the input thread. That happens only when the row or a binding changes; the font is 33 KB and each face's textures are read once.
    • After the first change, each text drawn is scanned for button codes. The texts are short.

🤖 Generated with Claude Code

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. Reading the whole thing, the design holds up: one font with every face's icons and a code of its own for each face, button and input (so the glyph cache never shows a stale icon), the game's bytes put back before each new set of runs, and the layouts and font guarded against bytes the game has changed since. MKWVK_API_VERSION goes to 17 for MkwVkHost.prompts_live, and the new field is appended after fps_timing with rx_backend.c's initializer in the same order, so the exe/back end ABI is fine. HOS_OPT_PROMPTS = 46 doesn't collide with main or the open PRs (10, 44). The hooks follow the existing _hook_head pattern and are only applied in the supported build's lift. The touched tests are updated rather than loosened (test_menu_hooks checks all five hooks once and none in another lift). The new test_prompts_live declares RUN_ALL_ARGS and covers put back, run order, out of range and guarded. Nothing generated from the game is committed: the font, textures and blarc are still made on the player's PC by make_game.py.

One thing I'd like before this goes in:

  • The textures' pool is written without a check that it's still there. Once hos_prompts_pool has found it, tex.l.base/lo/hi stay set for the rest of the run, and live_textures → put_runs writes (unguarded) into that range on every change. If the game ever finalizes that pool and the memory goes to something else, a change in the menu would write over unrelated guest memory. The layouts (guarded) and the font (64-byte head check) are both protected against this. The textures are the only part that isn't. A cheap fix: compare the three tex.want samples again before put_runs (and drop base if they differ), or clear tex.l.base in nvnMemoryPoolFinalize when it's that pool. Inline below.

Smaller things (not blocking):

  • The menu only skips the restart prompt when all three parts are found. A build made without textures.bin (no texture tools) never sets the TEXTURES bit, so the row keeps asking for a restart even though everything that was served does change at once. Treating a part that wasn't served as satisfied would fix that.
  • The first change to a face reads its textures.bin and does seeks/reads in Common.bntx/Common.blarc on the thread that is reading the game's npad. You already list this under "Left as is", and it only happens on a change, so fine for now. It'd be good to move it to a worker if anyone reports a hitch.
  • Overlap with open PRs: #2304 also changes build_layouts (the Recomp options button in Common.blarc, served without runs), and #2270 changes build() and the size table path in prompts.c/prompt_assets.py. Both are textual conflicts for whichever lands second. With #2304, find_layouts should still match, since it takes the head and length from the served prompts/Common.blarc.

CI: the appimage failure is the runner failing to resolve archive.ubuntu.com (apt), not this PR. test_vcore_video_acquire / test_vcore_fs_open fail the same way on main. The other Python parts were still running when I looked. The screenshots across Linux and Windows are very helpful, thanks.

Comment thread host/prompts.c
}
size_t nruns, written, put_back;
Run *runs = texture_runs(tex.bins[face], tex.nbins[face], bind, &nruns);
const size_t outside = put_runs(&tex.l, runs, nruns, &written, &put_back);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes into the pool range found once at startup, with no check that the pool still holds Common.bntx (guarded is 0 for tex.l). Re-checking tex.sample/tex.want here, or clearing tex.l.base when the pool is finalized, would keep a change from writing into memory the game has reused.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, clearing base from nvnMemoryPoolFinalize is the right hook. One gap is left: the check in live_textures and the writes in put_runs can still interleave with the finalize on the game's thread. See the new comments at live_textures and in put_runs's last loop.

Comment thread gpu/vk/vk_menu.cpp
/// host/prompts.c, nothing to restart for then; else, as the other restart rows, at the next start
void prompts_at_once(const Row& r) {
if (r.value == &opts.prompts && host && host->prompts_live &&
(host->prompts_live() & HOS_PROMPTS_LIVE_ALL) == HOS_PROMPTS_LIVE_ALL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: with a build that has no textures.bin, the TEXTURES bit never gets set, so this still asks for a restart even though the font and layouts change at once. Maybe have prompts_live count a part that wasn't served as live.

gab9281 and others added 5 commits November 11, 2026 12:16
…tton on each input

The prompts' font (prompts/External.bfotf) now holds the icons of every face: each face's pad icons and d-pad icons
at code points of its own, the game's own codes kept. A Switch button's icon on each face and pad input also gets a
code of its own (BIND_BASE..), so a text can name the icon of the controls in use while the game runs. One file for
all faces: the host binds the face and the controls into its cmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ile the game runs

A change of the Button prompts row, of Auto's controller or of the controls is written where the game keeps the
prompts it loaded from the files served here, by the start's rules (mods untouched, the Switch face on default
controls shows the game's own images):
- the textures (the ocarina's notes, the Talk ring's letters, the System page's controller and its lines) in the
  memory pool the game makes over Common.bntx;
- the layouts (the Talk ring's places, the ocarina hint's spacing) where the game keeps Common.blarc;
- the icons in texts: the font's cmap, and each text's button codes rewritten to the face's own as the game lays the
  text out or shows it (the game keeps a code's glyph drawn, so a new code draws the new icon).
A text on screen shows the new icons the next time it is shown; a page open, when it is opened again.

The live input takes "!bind <HOS_SW_*> <HOS_PAD_*>" and "!prompts <row>" for the tests in the game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the game has every part of the prompts from the files served here, a new face on the Button prompts row shows
at once and asks for no restart (MkwVkHost.prompts_live, MKWVK_API_VERSION 15); else it waits for the next start as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s other points

- Each part the game loaded (the textures' pool, the layouts, the font) keeps the face and controls it shows: one found
  after a change is written to the current ones at the next look, not left on the start's.
- Auto with no controller named is the Switch face, as at the start.
- The textures' pool address is published after its range (release/acquire).
- Without the textures, the layouts are looked for at once instead of waiting for a pool that never comes.
- A font face not readable yet is looked at again; only another font is skipped.
- "!bind" takes only a Switch button (a negative one reset every binding).
- The controls read in one place (current_binds), a text box marked in one (mark_box); the unused cmap offsets removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es it; a part never served counts as live

- nvnMemoryPoolFinalize tells prompts.c (hos_prompts_pool_gone): the textures' pool is not written any more, so a change
  never lands in memory the game has reused. A pool the game makes again is found again and catches up from the start's
  files.
- hos_prompts_live_parts counts a part none was served for (a build without textures.bin, a mod's size table) as live:
  a restart would not change it either, so the Button prompts row no longer asks for one then.
- test_prompts_live: the finalize reaches the prompts, and only the textures' own pool is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 thanks for the review. Both points are addressed in 10615af, and the branch is rebased on main:

  • Textures' pool: nvnMemoryPoolFinalize now tells prompts.c when a pool goes away (hos_prompts_pool_gone). If it is the textures' pool, nothing is written there any more, and live_textures checks this again right before put_runs. If the game makes the pool again, it is found again and catches up from the files served at the start. I didn't re-check the samples instead: they are picked only to avoid the start's runs, so after a change to another face they can hold our own writes. test_prompts_live now checks that the finalize reaches the prompts and that only the textures' own pool is dropped.
  • No textures.bin: prompts_live now counts a part that wasn't served as live, since a restart wouldn't change it either. It still returns 0 when no part was found at all.
  • fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270: it landed, and I resolved it in the rebase. A mod's size table is merged with ours as on main; the docs now say "the font", since this branch serves one.
  • The input-thread file reads stay as they are for now, as you said.

The build with these fixes passed the in-game live run on Linux before the rebase: every change reached the textures, layouts and font. The rebased branch is pushed but not yet built or tested; I'll post again once it is.

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. Compared the rebased patches file by file against 82fa562: apart from your new commit, the differences are main's (Camera speed, memory budget, #2270's size-table merge).

Fixed:

  • No textures.bin: hos_prompts_live_parts now counts a part with no served file (romfs_state == 3) as live, and still returns 0 when nothing was found. Looks right.
  • #2270: resolved cleanly. sizes() matches main byte for byte, and hos_prompts_romfs has main's mod handling. The only change is state moving out to romfs_state so the live check can read it.
  • MKWVK_API_VERSION is still 16 on main, so 17 here is still the right bump. HOS_OPT_PROMPTS = 46 doesn't collide with main's 45/48.
  • Re-creating the pool at the same address works. After hos_prompts_pool_gone clears base, hos_prompts_pool passes its early return again, matches the samples against the served file, and resets part_face[PART_TEXTURES] to the start's, so the next look catches up. Putting back tex.l.ranges writes the served file's bytes, which is what the new pool holds anyway.

Still open: the finalize can land in the middle of a write. hos_prompts_pool_gone runs on the game's thread (in nvnMemoryPoolFinalize), and live_textures/put_runs run on the npad thread with no lock between them. The fix is a check followed by a write, so:

  1. If the finalize comes after the check in live_textures, put_runs still writes the whole set. It does file reads per range, so the window is the length of the disk I/O, and the game can free and reuse the storage right after the finalize returns.
  2. It's slightly worse than before in one case. Live.base is volatile and put_runs re-reads it on every access. If it goes to 0 between the second loop and the third, the third loop's hos_guest_range(l->base + runs[r].off, ...) has no range check and no NULL check, so it would memcpy to guest address off (low pages of the guest window): a crash or a stray write. The first two loops are saved by the a >= l->lo check.

Both are narrow, but closing them is cheap: one lock (an SRWLOCK, as nvn_native.c uses) held by live_textures around the base check and put_runs (plus the part_face[PART_TEXTURES] update), and taken by hos_prompts_pool_gone/hos_prompts_pool around their stores. A finalize then waits only in the rare case a change is in flight. At minimum, have put_runs read base once into a local and range-check the third loop like the others. Inline below.

Earlier nits: the file reads on the npad thread stay as agreed. #2304 is still open and still overlaps build_layouts/prompt_assets.py, so whichever lands second resolves it.

CI: every job is still queued on this head, and you said the rebased branch isn't built or tested in game yet. Happy to approve once the race is closed and that run is in.

Comment thread host/prompts.c Outdated
if (!tex.bins[face]) return 0;
}
size_t nruns, written, put_back;
if (!__atomic_load_n(&tex.l.base, __ATOMIC_ACQUIRE)) return 0; /* (finalized since: hos_prompts_pool_gone) */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check and the put_runs below aren't atomic with respect to hos_prompts_pool_gone on the game's thread. A finalize right after this line still lets the whole set be written into storage the game can now free. A lock held from here through put_runs (and the part_face[PART_TEXTURES] update in hos_prompts_live), and taken in hos_prompts_pool_gone/hos_prompts_pool, would close it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b846ee7: tex_lock covers the check through the writes and the part_face update, and both pool hooks take it.

Comment thread host/prompts.c Outdated
outside++;
continue;
}
uint8_t *q = (uint8_t *)hos_guest_range(l->base + runs[r].off, runs[r].len);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l->base is re-read here (it's volatile), and unlike the two loops above this one has no range or NULL check. If the finalize clears base between the second loop and this one, it writes at guest address runs[r].off. Reading base once at the top of put_runs (and bailing if it's 0) avoids that even without the lock.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b846ee7: base is read once, with a bail-out at 0 and the range/NULL check in this loop too.

…ts for it

- One lock (tex_lock) over the textures' pool: hos_prompts_live holds it from the look at the pool through its writes and
  what it then shows; hos_prompts_pool and hos_prompts_pool_gone take it to set or clear it. A finalize that comes while a
  change is written waits for the writes to end, and the game frees the memory only after.
- put_runs reads the range's base once and writes nothing when it is 0; its last loop checks each run against the range,
  as the two before it.
- test_prompts_live: the finalize lets the pool go under the lock, and put_runs over a range let go writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 good catch. The race is closed in b846ee7:

  • One lock over the textures' pool (tex_lock, an SRWLOCK):
    • hos_prompts_live holds it from looking at tex.l.base, through live_textures/put_runs, to updating part_face[PART_TEXTURES].
    • hos_prompts_pool and hos_prompts_pool_gone take it to set or clear the pool.
    • A finalize that comes while a change is being written now waits for the writes to end, so the game frees the memory only after them. nvnMemoryPoolFinalize calls the hook after its own vmap_lock section, so no other lock is held there.
  • put_runs reads base once into a local and writes nothing when it is 0. Its last loop now checks each run against [lo, hi) and for NULL, like the two loops before it.
  • test_prompts_live checks two more things:
    • The finalize clears the pool while holding tex_lock, and releases it after.
    • put_runs over a range whose memory was let go writes nothing, not even at a run's offset from 0.

The Linux build and the in-game runs of the rebased branch with this commit are going now. I'll post the results here when they're in.

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, both points from last time are fixed in b846ee7.

  • Race with the finalize: tex_lock is now held from the tex.l.base check through live_textures/put_runs and the part_face[PART_TEXTURES] update, and hos_prompts_pool/hos_prompts_pool_gone take it too, re-checking under the lock. Both hooks run outside vmap_lock in nvn_native.c, so I don't see a lock-order problem.
  • put_runs: base is read once, nothing is written when it's 0, and the last loop now has the same [lo, hi)/NULL check as the other two. The new test cases cover both.

One small nit inline: the first change per face reads textures.bin from disk while the lock is held. Still open from before, also a nit: a build with no textures.bin still asks for a restart in the menu.

Approving the code. Before merging, CI should go green (still pending at this head) and your in-game runs of the rebased branch should be posted.

Comment thread host/prompts.c
char dir[MAX_PATH];
int start, ready = -1;
for (int p = 0; p < PARTS; p++) {
if (p == PART_TEXTURES) AcquireSRWLockExclusive(&tex_lock); /* (the pool kept, or let go, till its writes end) */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: when the textures are the first part to change, setup() and the read_all of textures.bin in live_textures both run with tex_lock held. A finalize on the game's thread then waits for that disk read. Loading tex.bins[face] before taking the lock would keep the time under the lock to the writes alone. It only happens once per face, so this isn't blocking.

@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 the branch is on the latest main (no newer commits upstream) at b846ee7, with the race fix from my last comment. Since main keeps moving, I'm pushing first and testing after:

  • Pushed: b846ee7. It builds on Linux (make_game.py --update).
  • Running now: a full code review of the branch, and the in-game live runs on that build (System page, field, Impa's lesson).

I'll post the review's findings, with what I changed for each, and the in-game results here as soon as they're in.

gab9281 and others added 2 commits November 11, 2026 12:39
…and its one font

test_mods_size_table compiles host/prompts.c whole beside a hos.h of its own: it now also declares and stubs what the live
prompts read (guest memory, the address regions, the clock, the Insert menu's rows), and the fixture's font is
prompts/External.bfotf, every face's in one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hecked, a mod's file, no half change

- Each part keeps the face and controls its own file was written for (part_served, once written): a pool made again goes
  back to those, and to the runs served (tex.served), the bytes put back at the next change.
- The layouts are looked at again before a change writes them: gone (another file's bytes there), not written.
- A part a mod gives its own file for counts as live (a restart does not change it), as one never served.
- While the menu asks for a restart (a part not found), a change waits: no part shows another face than the rest.
- The served font is found where the game keeps it with its 8-byte header too; another font of that size is not looked
  at again.
- find_layouts keeps a run whose bytes are a later run's where they overlap (write_patched's order), so the next change
  puts the game's bytes back there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 I ran a full code review of the branch at b846ee7 (the race fix). It found 10 issues. Here is each one and what I did. The fixes are in 1da8570, on the latest main.

Fixed:

# Finding Fix
1 When the game makes the textures' pool again, the put-back ranges still held the last change's runs, not the served file's. After that, a change could leave a start-face block that the new runs don't cover. The served runs are kept (tex.served). A pool made again restores them along with the served face and controls.
2 The layouts' region was found once and never checked again, so a change could write into memory the game had reused. live_layouts checks the file's head at lay.base before writing. If it no longer matches, it drops the region and writes nothing.
3 A part replaced by a mod's own file left romfs_state at 0, so the row kept asking for a restart. A mod's file now sets that part's state to "none", like a part never served.
4 While the menu asked for a restart (a part not found yet), a change still reached the parts that were found, mixing faces until the restart. Until every part is live, a change waits. Nothing changes before the restart, or before the last part is found, and then every part catches up together.
8 The glyph hook accepted a stream of font.n bytes (header included) but compared the head at offset 0, so that case could never match. It compares at +8 then. A same-size font that isn't ours is marked as seen, so it isn't checked again.
9 A part's start state was recorded before its file was written, and taken from whichever file was served first. Each part records the face and controls of its own file, and only once that file is written (part_served).
10 find_layouts dropped a run that a later, overlapping run had partly overwritten, so its bytes were never put back. A byte that a later run (by idx, write_patched's order) covers no longer counts against the run.

Rebase fallout, also fixed: #2270's test_mods_size_table compiles prompts.c whole, and it no longer compiled on this branch. Its hos.h and main.c now stub what the live code reads (guest memory, address regions, the clock, rx_option). Its font moved to prompts/External.bfotf, the single font this branch serves. All 10 of its cases pass.

Left as agreed: the npad-thread file reads (#5 and #6) and the per-draw text scan after the first change (#7). These are the performance points we already discussed; a worker thread can come later if anyone sees a hitch.

Proof: test_prompts_live, test_mods_size_table, test_prompt_font_bind, test_menu_hooks, the input tests and the NVN tests pass. Since main keeps moving, I pushed before the rest: the full suite and the in-game runs on this head come next, and I'll post them here.

The first change to a face reads its textures.bin before taking the textures' pool lock (texture_bin), so a finalize on
the game's thread waits only for the writes, not for the disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 thanks for the approval. On your two nits:

  • textures.bin read under tex_lock: fixed in d0dcc7f. hos_prompts_live reads the face's textures.bin (texture_bin) before taking the lock, so a finalize waits only for the writes. put_runs's put-back still reads Common.bntx under the lock, since those bytes go into the pool.
  • No textures.bin asking for a restart: this was fixed in 10615af, which is in the head you approved. Without the file, build_textures returns 0 (if (!t) return 0;), so hos_prompts_romfs sets the textures' state to 3. hos_prompts_live_parts then counts that part as live, as long as some other part was found. Since 1da8570, a part a mod replaces counts the same way.

Note that the approval was at b846ee7. Since then:

  • 1da8570 adds the fixes from my own review (table in the comment above).
  • d0dcc7f adds this nit.

Both are on the latest main. CI and the in-game runs of this head are next; I'll post them here.

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at the three commits since my last review (b846ee7 → d0dcc7f). Still good to go.

  • texture_bin now reads the face's textures.bin before tex_lock, so a finalize on the game's thread only waits for the writes. That was my last nit, thanks. It's only ever called from the input thread (under busy), so reading tex.bins outside the lock is fine.
  • Keeping the served runs and putting them back as tex.l.ranges when the pool is found again reads right, and the malloc failures fall back cleanly.
  • Not changing anything until every part is live (so no half change across faces), the layouts' header check before writing, and the mod's own file counting as "the same whatever the face" all look correct. run_as_served handling later runs written over earlier ones also makes sense.

One small thing, not blocking: live_layouts checks the header and then writes without anything stopping the game from letting that memory go in between, unlike the textures, which now have tex_lock. The window is tiny and the layouts don't go through a pool finalize, so I'm fine with it as is. It might be worth a comment.

CI is still pending on this head, so it's worth a green run before merging.

gab9281 and others added 2 commits November 11, 2026 12:50
…ough its writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 thanks. I added the comment in 3f124e4 and made it more precise in 8a7ea24 (comments only). It's at the layouts' header check in live_layouts and says:

  • unlike the textures' pool, nothing holds the region from the check to the writes;
  • the game frees its resources' regions on its own thread, with no hook here;
  • new runs are still written only where the bytes are the file's (guarded), but the last change's bytes put back are not checked that way.

The in-game runs of b846ee7 on Linux passed: System page, field and Impa's lesson, all shots taken, every change reaching the textures, layouts and font. The full suite and the in-game runs of this head are next. I'll post them with CI.

@hckrman101
hckrman101 merged commit 786b5b0 into NotRexed:main Nov 11, 2026
11 of 12 checks passed
@gab9281

gab9281 commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@hckrman101 the head changed after your approval: the in-game runs of d0dcc7f caught a regression of mine, fixed in 6cb2a3e.

The regression: in 1da8570, for review point #8, I made the glyph hook look for the font 8 bytes in when the stream's size counts the header. The game keeps the font decrypted at the stream's start in that case too, so on d0dcc7f the font was never found. With "a change waits until every part is live", no change showed at all: 0 changes applied in either run. 6cb2a3e looks at both places. A font that doesn't match (yet) is checked again at its next glyph, as before 1da8570, so the hook no longer marks a face as seen after a mismatch.

Results on 6cb2a3e (Linux, in game):

  • The font, the textures' pool and the layouts are all found (prompts: the font at 0x7fd39770c8: the icons in texts follow at once).
  • live.sh and live3.sh ran with every shot taken and every change applied (30 and 6, as on b846ee7).
  • System page reopened: the PS5 picture with "Target R1".
  • Impa's lesson after a live switch to PS5: her dialogue, her notes and Link's replay with "Play a note" all show PS5.

Tests: the full suite on d0dcc7f gave 348 passed, 0 failed, 49 skipped. 6cb2a3e changes only the glyph hook, and test_mods_size_table (which compiles prompts.c whole) passes on it.

CI on this head: every job passes except Python source checks (windows-2022, part 1/2). That job fails on test_native_astc_texture_count ('scanf' is deprecated, from #2288), which fails the same way on main (run 43074702922). Main has moved since. I can rebase once that's fixed, or before merging, as you prefer.

ProbBC added a commit to ProbBC/Hookshot that referenced this pull request Nov 11, 2026
…tions

Conflicts in host/prompts.c and nx2/prompt_assets.py, where both change
how Common.blarc is built and served:

- host/prompts.c: build_layouts now serves prompts/Common.blarc always
  (NotRexed#2366), so the Recomp Options button's own serving (the
  prompts/recomp_button marker, nruns || button) is dropped. The file's
  size and time are folded into the served file's key in layout_runs, so
  a build that changed the layout still makes it again.
- nx2/prompt_assets.py: size_table takes len(font) (NotRexed#2366) and still
  raises the layouts' entry by the bytes the button added; the marker is
  no longer written.
LNDF added a commit to LNDF/Hookshot that referenced this pull request Nov 11, 2026
Conflicts resolved:
- MKWVK_API_VERSION 18: upstream's version 17 is prompts_live (NotRexed#2366); the
  data folder's MkwVkHost.data_dir follows it as version 18.
- host/hos_update.c: "Skip this version" stays in the data folder; upstream's
  call order (NotRexed#2283, the check after the saves' place is known) is kept, with
  hos_data_init having chosen that place first. tests/test_linux_update_skip.py
  builds the start with hos_data.c instead of the removed saves_dir.
- host/prompts.c: upstream's live prompts and mod size tables (NotRexed#2366, NotRexed#2270),
  with the generated files (and the new rsizetable.mod.zs) in cache/prompts;
  made_dir makes cache/ too.
- builder/linux/play-diagnostics.sh: upstream's socket stderr (NotRexed#2279), with
  hookshot_startup.log in logs/.
- The program cache moved to vk_progcache.cpp (NotRexed#2323): its path is cache/.
- hookshot_driver_faults.txt (NotRexed#1823) goes to cache/ and is moved there.
- env_on/env_value_off (NotRexed#2042), env_override (vk_menu.cpp), the prepare
  watchdog (vk_main.cpp), audio and docs merged with the data folder paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarynclouatre added a commit to jarynclouatre/Hookshot that referenced this pull request Nov 11, 2026
…prompts changing at once

Lines for HDR contrast, VRAM budget, Frame generation by and Compact menu key; Frame generation and Frame
pacing after Render resolution, where the menu has them; Menu shortcut's Esc choice. Button prompts changes
while the game runs since NotRexed#2366, and Auto follows the controller. docs/development.md says a new row needs
its readme line.
hckrman101 added a commit that referenced this pull request Nov 11, 2026
…t compile sooner and stay put (#2324)

Rebuilding the game spends most of a warm build in the lift (translating
the game to C), and most of a cold build in the eight hot pieces. This
PR makes both shorter. The game's code comes out the same.

## What changes

1. **A player's update skips the lift when the tools did not change**
(`make_game.keep_translation` / `kept_before`, `build.py` `KEPT`).
- Before, the clean-up after a player's build deleted all of `sa/`,
including the 1.5 GB of lifted C. So every update lifted again (1-3
minutes), even when nothing the lift reads had changed.
- Now the clean-up deletes only the lifted `code_*.c` files and the
build outputs. It keeps:
     - the rest of the lift (~124 MB: headers, runtime tables, image);
- a list that build.py writes after the compile: each lifted file's
object key, its size, and a hash of its flags and headers
(`gen/lifted_objects.txt`). Its first line names the lift key it was
written for and a format number; a new lift deletes the list of the one
before.
   - The lift key in `.lift_key` gets a third line, `kept`.
- The next build with the same lift key, and a list naming that key,
skips the lift. make_game passes the list as `build.py --kept-lift`.
build.py then takes the lifted files' objects from the object cache by
the keys in that list. These are the same objects as before, so nothing
is compiled again.
- If an object is missing, or the flags or headers changed, build.py
stops before compiling anything (exit status 75, `KEPT_STALE`; not 3,
which is `abort()`'s on Windows). make_game then lifts again and
compiles as usual.
- If the clean-up cannot remove a lifted file (Windows: one still held
open), the build still succeeds: it lets `sa/` go as far as it can, and
the next update lifts again.
- The two builder apps' after-build clean-up (`Storage.Free(afterBuild)`
/ `core.free(after_build=True)`) also deleted `sa/`. It now leaves a
kept lift in place. "Free up space" still deletes it.
2. **The big chunks are split in one process per file**
(`chunks.split_big`, using the same worker pool as the edits). Before,
it read all 1.5 GB of chunks on one core.
3. **The runtime tables and standalone files are written beside the
edits.** These are `a64_rtgen`, `hos_image.c`, the import stubs and
`nvn2_exports.c`. They read the image, not the lifted C, so a forked
process now makes them while the C is being edited. Without fork
(Windows), or if that process fails, they are made after the edits, as
before.
4. **The shader list is made again only when its inputs changed**
(`make_game.shader_list_current`).
- Before, every build ran `shader_census.py` (about 4 s) and rewrote the
514 MB `hookshot_shaders.bin`.
- Now a note in the work folder records what the list was made from: the
script, the Python, the names, sizes and times of the files the census
reads, and the list file itself. If none of these changed, the census is
skipped.
5. **The Windows setup parts are zipped at the same time**
(`scripts/make_setup_parts.py`), one thread each, as the Linux
`make_parts.py` has done since #2079. The zips come out byte for byte
the same.
6. (Dropped: compiling the hot pieces without loop unrolling. It was in
this PR and is reverted by a later commit; see "Not in this PR".)
7. **The hot pieces of the last lift are kept** (`split_hot.py`).
- Before, split_hot dealt the hot functions out into eight pieces from
scratch at every lift. So a few bytes more in one hot function (a patch)
moved functions between most of the pieces, and every player compiled
all eight again. That is the longest third of a cold compile.
- Now the assignment is kept in `<work>/image/hot_pieces.txt`, next to
the chunk boundaries:
     - functions from the last lift stay in their piece;
- a new function goes to the piece of the function it shares the most
calls with;
- a fresh split is made when the kept split's longest piece would cost
more than 15% over a fresh split's.
- Each piece now declares only the functions and host calls it uses. A
new declaration therefore no longer changes every piece.
- The first split after this PR puts the same functions in each piece as
before.
- `hot_pieces.txt` is part of the lift key, as `chunks.txt` is: the
pieces' C follows it.
8. **On Windows, the lift's per-file passes run in several processes**
(`chunks.file_jobs`, `_each`). Before, they used forked workers only, so
without fork (Windows) they ran in one process. Their workers take a
file's path and read the file themselves, so without fork they now start
as new processes (spawn). The emit still runs in one process there.
`HOOKSHOT_LIFT_NO_FORK=1` takes this path on Linux, for testing.
9. Earlier in this PR (first pass):
   - the lift's edits run in one process per file;
   - the lift prints a timing line for each step;
   - the lift key covers only the modules the lift imports;
- a compile that failed before the link keeps the objects it already
made.

## Measured

Setup: Linux, Ryzen 9 7900X (24 threads), 30 GB RAM, other jobs running
on the PC.

- Timed runs used `oot-status --lock --timing`, which gives the run its
own core complex (6 cores / 12 threads).
- Old and new were alternated.
- The bundled clang 23 and Python were used.

**Player update, same tools** (`make_game.py --nup`, no
`HOOKSHOT_KEEP_BUILD_FILES`, objects cached, shader prepare skipped).
Times are from start to `@@done`:

| | update 1 | update 2 |
|---|---|---|
| before (lifts each time) | 100 s (lift 83 s) | 193 s (lift 175 s, PC
busier) |
| after (lift kept) | 9 s | 7 s |

Caveat: these four runs went through `make_game.py` under `oot-status
--timing`. A queue bug that night moved `make_game.py` / `build.py`
processes onto the busy build CPUs during timed runs, so the absolute
seconds of both rows are inflated by load. The gap does not come from
load, though: the "before" runs spent 83 / 175 s in a lift that the
"after" runs skip entirely. The lift-step, hot-piece and game-speed
numbers below ran lift.py, clang or the game directly, and the bug did
not affect them.

A second pair after the queue bug was fixed (`--timing --cpu-only`, one
round, the PC heavily loaded and the builds at idle priority, so both
absolute times are high again): make_game took 640 s before (lift 614 s:
discovery alone 162 s, against 26 s on a quiet PC) and 42 s after (lift
skipped; the rest is the finish stage's file copies).

- After a full build at the same commit, a kept update produced the same
`hookshot`, `hookshot_vk.so` and `image.bin` (sha1).
- Fallback test: I deleted one kept object (`code_010`) and ran
make_game again. It printed "the translation kept from the last build
does not fit this one (1 of 80 files: code_010.c): translating the game
again", lifted, compiled that one file, and produced the same `hookshot`
sha1 as the full build.
- The kept files take 124 MB in the work folder, on top of the objects.

**Lift steps** (lift.py + split_hot.py run alone, two rounds each). All
95 output files and `chunks.txt` were sha1-identical to the old lift:

| step | old (round 1 / 2) | new (round 1 / 2) |
|---|---|---|
| big chunks split | 6.0 / 5.6 s | 2.3 / 2.1 s |
| patches | 2.7 / 2.6 s | 3.5 / 3.1 s |
| output size + page-mark groups | 5.7 / 5.7 s | 6.0 / 6.2 s |
| runtime tables + standalone files | 4.3 / 4.0 s | 0.0 s (beside the
edits) |
| these four steps | 18.7 / 17.9 s | 11.8 / 11.4 s |

**Shader list:** the finish stage took 1 s when the list was kept,
against 4-7 s when it was made.

**Windows setup parts:** I could not run a real package here. On a 104
MB stand-in package (three parts), three rounds took 1.81 / 1.50 / 1.50
s before and 1.05 / 1.15 / 1.04 s after. The zips and
`hookshot-setup.txt` were identical.

**Windows path for the per-file passes:** measured on Linux with
`HOOKSHOT_LIFT_NO_FORK=1`, against the old one-process lift
(`HOOKSHOT_LIFT_JOBS=1`), two rounds. The three per-file passes (big
chunk split, patches, output size + page-mark groups) took 9.9 / 16.1 s,
against 40.6 / 35.6 s. The non-hot lifted files were byte-identical. The
hot pieces differ only because of item 7. The whole lift's time varied
more than that, because the one-process emit took 56-84 s from run to
run.

**Hot pieces kept:** this was measured without a game run, on the game's
real `code_hot.c` (849 functions). In 12 trials, each adding 1-60 lines
to one random hot function, the next split changed 5.42 of the 8 pieces
on average before (1-6 per trial) and exactly 1 after. A fresh split put
the same functions in each piece as the old code.

**Tried and dropped:**

- A faster discovery walk in the lifter (reading the image through a
32-bit view). The output was identical, but discovery ended at 21 vs 23
s in one round and 26 vs 25 s in the other: no clear gain.
- The coverage report in a side process during the emit. The output was
identical, but over four rounds the lift took 92.5 / 61.7 / 82.0 / 68.4
s against 71.6 / 72.4 / 88.4 / 69.8 s without it, a mean change of +0.6
s. The emit slowed by about what the report saved.
- Writing the lifter's small constant-count loops (NEON permutes and
lane loops) out as straight-line C in the emitter. The instruction test
gave the same results as before (Unicorn compare, 41,552 cases, 744 of
749 forms pass, and the same 5 forms fail without the change). But one
hot piece then compiled for over 16 minutes against about 3, so the idea
was dropped.

**Full build of the branch** (before merging main, still with the
dropped item 6): lift 94 s, 9 of 264 files compiled (the eight hot
pieces, 165-363 s each, and one more), then a kept update from it as
above.

**Merge with main:** main removed `oot_native_resolution.py` (#2042) and
added patches to `oot_patches.apply` (the text hooks of #2366, Camera
speed of #1945). They are in the per-file `patch_file` / `report` split
now; the ripple fix of #2409 goes through the frame-time table as
before. A full build of the merge booted from the test save into Kokiri
Forest and played 20 s of scripted running and sword swings (headless)
without an error. It lifted with no patch warnings: every patch found
once, the camera speed at its 4 functions, the three text hooks, both
step divides and both frame counters (the ripple one included). 3 files
compiled.

**Tests:** `tests/run_all.py` at the head (the merge and the review
fixes): 402 passed, 0 failed, 12 skipped. New or changed for the fixes:
a kept list of another lift or format is not used, a clean-up that
cannot remove a file is no failed build, the kept hot pieces change the
lift key, and no module of the lift imports by a name made at run time.
Before the merge: 364 passed, 1 failed (`test_wayland_libdecor_hint`, a
harness `-Werror` warning that also fails without this PR). New tests:
`test_kept_translation.py`, `test_shader_list_skip.py` and
`test_sticky_hot_pieces.py`. Updated tests that cut functions out of
build.py and make_game.py: `test_lift_cleanup_error.py` and
`test_compile_retry_jobs.py`.

## Not tested

- Windows, for all of it (CI runs the Windows test suite; two of its
tests failed on this branch at first, line endings and a missing `lz4`,
and were fixed):
- the kept lift on Windows, and the C# builder change (`Storage.cs`): no
.NET SDK here, only the Python builder's twin was run;
  - the Windows setup zips with a real package;
- the lift's per-file passes in spawned processes on Windows (item 8:
only the Linux spawn path was run), and the side process for the tables
there (it falls back to one process).
- While checking this I noticed that the play profile is out of date for
this lift: clang ignored the profile for 58 of the 134 functions in
`code_hot_1.c` (`-Wprofile-instr-out-of-date`). This happens without
this PR too.
- A kept lift across a real release-to-release update. It was tested
with two builds of the same tools, and with a deleted object for the
fallback.
- Only one round of the re-timed update (above); the first pair is in
the table.
- A full build of the last head, with the review fixes (unrolling back,
the kept list's header, the hot pieces in the lift key): the merge just
before them was built and run, the fixes only by the game-free tests.
With unrolling back, the hot pieces compile with main's flags again.

## Not in this PR (measured or written, for later)

- Compiling the hot pieces without loop unrolling (`-fno-unroll-loops`).
It cut the hot pieces' compile a lot (`code_hot_4.c` 261 → 216 s alone;
all eight 4547 → 2807 s of CPU in a loaded build), and Kokiri Forest ran
as fast (62.8/64.8 fps before, 65.1/65.2 after). But a review found that
it also stops clang from fully unrolling the lifter's ~25,000
fixed-count lane loops (`for (_i < 4)` and the like): on six lane-heavy
functions of `code_hot_5.c` the loops left in the code went from 65 to
263 and the stack accesses from 744 to 1165. The scenes measured barely
run those functions, so they could not show it. Reverted. A narrower set
(`-mllvm -unroll-runtime=false -mllvm -unroll-allow-partial=false`,
perhaps `-mllvm -unroll-max-upperbound=0`) gave code byte-identical to
today's on that sample; its compile-time gain with the play profile is
not measured yet.

- Smaller chunks of ordinary code: three 24 MB chunks compiled whole
took 145 s of CPU and 1.4-1.6 GB each; the same code cut in thirds (at
function starts, as `split_big` cuts) took 117 s (-19%) and 0.52-0.70 GB
each. More compilers would fit on a 16 GB PC, and less CPU on any PC. It
changes every ordinary chunk's object (one full compile for everyone)
and needs a game-speed check, since fewer callees share a file.
- Unpacking the romfs while the game is translated and compiled (about
as long as the unpack, ~1-2 minutes, off a first build): written and
tested game-free, not measured end to end.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
NotRexed pushed a commit that referenced this pull request Nov 12, 2026
…texts, hints and menus (#2435)

Players on the keyboard and mouse see a controller's buttons in every
prompt (the Switch's when no controller is connected), so they have to
work out which key each one means. This adds a **Keyboard** face to the
Button prompts system: the game's texts, hints and menus show the key or
mouse button each action is bound to.

Part of #1007 (keyboard button prompts, from #2028).

## What it does

- **Button prompts → Keyboard** (a seventh value). **Auto** picks it
when no controller is connected, through SDL or XInput; with one
connected, Auto picks that controller's face as before.
- Each Switch button shows the first key bound to it on the Controls
page, or the second when the first slot is clear. Mouse buttons show as
a mouse with that button lit, and keys named by words (Ctrl, Alt, Esc,
Home, PgUp, Num1...) as wider keys set in the game's own bold font.
Kenney's versions of those are too small to read at prompt size.
- The sticks' icons (Move, Change View and their direction variants)
show **WASD** (or W/A/S/D, AD, WS) and the mouse moving.
- It follows the live system from #2366: changing a key, or switching to
or from Keyboard, shows at once, like a pad rebind.
- Kept as the Switch's: the textures (ocarina notes, the letters on
black, the pause menu's controller picture) and the Talk ring's places.
The ocarina hint is spaced by the keys' icon widths.

## How

- `nx2/prompt_keys.py` (new, data only, standard library): which icon
each key and stick shows, and the code points.
- `nx2/prompt_draw.py`: the keyboard face's glyphs. They are Kenney's
Input Prompts **Keyboard & Mouse** font (CC0, the same 1.5 release as
the controller faces), in its own folder
`third_party/kenney_input_keyboard` with a `PROVENANCE.md` and the
release's `License.txt`; the AppImage copies it. The word keys and the
mice are drawn as non-crossing contours, so they fill the same under
either winding rule.
- `nx2/prompt_assets.py`: `faces_font` builds the keyboard face (the
Switch's pad icons, plus each key's icon at `KEY_BASE + key code`, past
every face's code points), and writes its `hint.bin` per key code.
- `host/prompts.c`:
- The bind arrays carry each button's first key after its pad input
(`BINDS`), so key changes go through the same live path.
  - `cmap4_bind` points a button at its key's icon on the keyboard face.
- `text_code` gives a text a code of its own for each (button, key) pair
as it first appears (`key_slot`), and points it at the key's icon where
the game keeps the font.
- Code points: the per-pair codes come from 320 codes at U+EEC0–U+EFFF,
and the sticks' codes are at U+F8C0–U+F8DF. I checked every font in the
romfs: none uses either range. The CJK fonts use most of the private use
area, including parts of U+F700–U+F7BF.
- `host/nn_hid.c`: `hos_xinput_pads()`, so Auto doesn't pick Keyboard
for an XInput-only controller.
- `gpu/vk/vk_menu.cpp`: the Keyboard value and its hint; `pad_name`
names pad inputs by place on the keyboard face.

## Testing

**In game, a build of this branch (81a5445, the same code before the
rebases onto #2448 and #1990)** on Windows 11 (native, RTX 4060),
keyboard and mouse, no controller. Driven with the control bridge
(`HOS_CONTROL`, headless, `--user` on a copy of the saves), into Hyrule
Field from a save, then the pause menu:

| Case | Result |
|---|---|
| Auto, no controller | ✅ `prompts: auto: controller "(none)":
keyboard`, `keyboard icons, 16 buttons on these keys`. The pause menu
shows Q/R on the tabs, the mouse for Rotate, E for Select, Shift for
Back; the HUD's item buttons show R and X. |
| Keyboard picked, a key rebound (A from E to F) | ✅ Select reads F. |
| Xbox picked (a pad face, unchanged by this PR) | ✅ LB/RB, Select on B
and Back on A (the pad layout in use); the ocarina hint spread for
Xbox's icons. |
| A change while the game runs (Keyboard ↔ Xbox) | ✅ with #2583: on
native Windows #2366's live path never found the font (it read it 8
bytes past its start), with or without the keyboard face. @polackangry
found it and fixed it in #2583, and with that on top of this branch,
Keyboard → Xbox → Keyboard while the game runs applies at once (`now the
xbox icons in texts` / `now the keyboard icons in texts`), the pause
menu reopened showing each face's icons. Without #2583 the row says
"(restart)" on native Windows, as for every face. |
| A pad plugged in and out, a key rebound with a hint on screen | ⚠️ Not
tested: no controller here, and both go through the live path. |

Also:
- `tests/test_prompt_font_bind.py` is extended. On the keyboard face it
checks: keys bound in the first or second slot, a key with no icon, the
sticks, and text codes for each (button, key) pair. The other faces are
unchanged. It passes, as do `prompts_live`, `appimage_prompt_fonts` and
`menu_hooks`.
- `tests/run_all.py`: the same results with and without the PR on this
machine (its other failures are missing .NET and MSVC-target C++
headers).

## Screenshots

From the build of this branch, 1280×720 (the bridge's shots: the game's
own picture).

Auto with no controller, the pause menu: Q/R tabs, the mouse for Rotate,
E for Select, Shift for Back.

![Auto, no controller: the pause menu with keyboard
prompts](https://raw.githubusercontent.com/Needicla/Hookshot/pr-2435-screenshots/screenshots/branch-auto-pause.png)

Keyboard with A rebound to F: Select reads F.

![Keyboard face, A on F: Select reads
F](https://raw.githubusercontent.com/Needicla/Hookshot/pr-2435-screenshots/screenshots/branch-keyboard-rebound-pause.png)

Xbox, for comparison (a pad face, as before this PR).

![Xbox face: LB/RB, B and
A](https://raw.githubusercontent.com/Needicla/Hookshot/pr-2435-screenshots/screenshots/branch-xbox-pause.png)

The field: the HUD's item buttons on R and X.

![Hyrule Field: the item buttons on R and
X](https://raw.githubusercontent.com/Needicla/Hookshot/pr-2435-screenshots/screenshots/branch-field-hud.png)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Needicla <226049938+Needicla@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
NotRexed pushed a commit that referenced this pull request Nov 12, 2026
… stream's start (#2583)

On native Windows the live button prompts (#2366) never find the font,
so a change of face or of controls waits for a restart (seen in #2435's
testing). The game keeps the font there from its stream's start with the
stream's size the file's, and `hos_prompts_glyph` looked 8 bytes in, for
a header that isn't there.

## What it does

`hos_prompts_glyph` (host/prompts.c) takes a stream of the served font's
size and looks for the font's head where it looked before (behind the
8-byte header when the size is the file's), then at the stream's start.
Whichever holds the head is `font.at`. A face whose bytes aren't
readable yet is looked at again, as before; one with neither head is
another font, as before.

## Why

A diagnostic build on native Windows 11 (RTX 5060 Ti, the v131072 game)
logged each face FreeType draws from, its stream's base and size:

```
prompts-diag: face 0x107e504c00 stream 0x107e4f9370 base 0x7fd37800e8 size 55088 (want 55088 or 55080)
prompts-diag: face 0x107e504c00 has the size but not the head (00030030 vs 4f54544f)
```

The size is the served file's (55088), so the code read from `base + 8`.
The bytes there, `00 03 00 30`, are the OpenType header's own
`entrySelector` (3) and `rangeShift` (48): the font starts at `base`,
with no header in front. #2435's description and comment guessed a size
mismatch; the size matches and the start is the difference.

## Testing

In game, native Windows 11, RTX 5060 Ti, keyboard and mouse, driven with
the control bridge (headless), a save in the Fire Temple:

| Case | Before | After |
|---|---|---|
| `prompts: the font at ...` | never logged | `prompts: the font at
0x7fd37800e8: the icons in texts follow at once` |
| Keyboard → Xbox while the game runs (#2435's branch) | held for a
restart | `now the xbox icons in texts`; the pause menu reopened shows
LB/RB, A, B |
| Xbox → Keyboard while the game runs | held for a restart | `now the
keyboard icons in texts`; Q/R, E, Shift again |

Tests: `test_prompts_live`, `test_prompt_font_bind`, `test_menu_hooks`
pass. The fix was tested on top of #2435's branch, the same function as
on main.


🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants