feat(prompts): a new face or new controls show while the game runs, no restart #2366
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2366
Loading…
Reference in a new issue
No description provided.
Delete branch "live-prompts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.cwrites the new prompts where the game keeps the ones it loaded from the files #1321 serves:Common.bntxand change at once. The System page's picture is drawn once per opening, so it changes when the page is reopened.Common.blarc.nx2/prompt_assets.py,BIND_BASEU+F000..F54F, free in the game's font).nx2/oot_patches.py).#1321's rules are unchanged:
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_VERSION17:MkwVkHost.prompts_livetells 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:

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):

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

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

The same on Windows:

Tested
python -I -B tests/run_all.pygives 336 passed, 3 failed, 49 skipped. All 3 fail the same way on upstream main:test_wayland_libdecor_hint(newer clang,-Wunused-but-set-globalin its own harness).test_vcore_fs_openandtest_vcore_video_acquire(env_onundeclared in their fixture).test_prompts_live(putting the game's bytes back, guarded).test_prompt_font_bind(285 text codes rewritten),test_menu_hooks(the text hooks).nx2,hostandgpu/vkwhole.gpu,host,nx2,scriptsandthird_party.test_appimage_prompt_fontspasses.!bindtakes only a Switch button.🤖 Generated with Claude Code
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_VERSIONgoes to 17 forMkwVkHost.prompts_live, and the new field is appended afterfps_timingwithrx_backend.c's initializer in the same order, so the exe/back end ABI is fine.HOS_OPT_PROMPTS = 46doesn't collide with main or the open PRs (10, 44). The hooks follow the existing_hook_headpattern and are only applied in the supported build's lift. The touched tests are updated rather than loosened (test_menu_hookschecks all five hooks once and none in another lift). The newtest_prompts_livedeclaresRUN_ALL_ARGSand 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 bymake_game.py.One thing I'd like before this goes in:
hos_prompts_poolhas found it,tex.l.base/lo/histay set for the rest of the run, andlive_textures→put_runswrites (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 threetex.wantsamples again beforeput_runs(and dropbaseif they differ), or cleartex.l.baseinnvnMemoryPoolFinalizewhen it's that pool. Inline below.Smaller things (not blocking):
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.textures.binand does seeks/reads inCommon.bntx/Common.blarcon 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.build_layouts(the Recomp options button inCommon.blarc, served without runs), and #2270 changesbuild()and the size table path inprompts.c/prompt_assets.py. Both are textual conflicts for whichever lands second. With #2304,find_layoutsshould still match, since it takes the head and length from the servedprompts/Common.blarc.CI: the
appimagefailure is the runner failing to resolve archive.ubuntu.com (apt), not this PR.test_vcore_video_acquire/test_vcore_fs_openfail 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.@ -1385,6 +1385,14 @@ void save() {}}/// (hookshot) The Button prompts row: a new face shows at once where the game has every part of the prompts fromNit: 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 haveprompts_livecount a part that wasn't served as live.@ -472,0 +846,4 @@}/* the font with the face's icons on these controls (prompts/External.bfotf, cmap4_bind: hos_binding's), decrypted behindits header (key: the XOR key); bound: how many buttons. NULL none */This writes into the pool range found once at startup, with no check that the pool still holds Common.bntx (
guardedis 0 fortex.l). Re-checkingtex.sample/tex.wanthere, or clearingtex.l.basewhen the pool is finalized, would keep a change from writing into memory the game has reused.@hckrman101 thanks for the review. Both points are addressed in
10615af4, and the branch is rebased on main:nvnMemoryPoolFinalizenow tellsprompts.cwhen a pool goes away (hos_prompts_pool_gone). If it is the textures' pool, nothing is written there any more, andlive_textureschecks this again right beforeput_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_livenow checks that the finalize reaches the prompts and that only the textures' own pool is dropped.textures.bin:prompts_livenow 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.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.
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:
textures.bin:hos_prompts_live_partsnow counts a part with no served file (romfs_state == 3) as live, and still returns 0 when nothing was found. Looks right.sizes()matches main byte for byte, andhos_prompts_romfshas main'smodhandling. The only change isstatemoving out toromfs_stateso the live check can read it.MKWVK_API_VERSIONis still 16 on main, so 17 here is still the right bump.HOS_OPT_PROMPTS = 46doesn't collide with main's 45/48.hos_prompts_pool_goneclearsbase,hos_prompts_poolpasses its early return again, matches the samples against the served file, and resetspart_face[PART_TEXTURES]to the start's, so the next look catches up. Putting backtex.l.rangeswrites 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_goneruns on the game's thread (innvnMemoryPoolFinalize), andlive_textures/put_runsrun on the npad thread with no lock between them. The fix is a check followed by a write, so:live_textures,put_runsstill 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.Live.baseisvolatileandput_runsre-reads it on every access. If it goes to 0 between the second loop and the third, the third loop'shos_guest_range(l->base + runs[r].off, ...)has no range check and no NULL check, so it wouldmemcpyto guest addressoff(low pages of the guest window): a crash or a stray write. The first two loops are saved by thea >= l->locheck.Both are narrow, but closing them is cheap: one lock (an SRWLOCK, as
nvn_native.cuses) held bylive_texturesaround the base check andput_runs(plus thepart_face[PART_TEXTURES]update), and taken byhos_prompts_pool_gone/hos_prompts_poolaround their stores. A finalize then waits only in the rare case a change is in flight. At minimum, haveput_runsreadbaseonce 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.
@ -465,3 +509,4 @@ok ? "" : ": could not be written");if (ok) part_served(PART_TEXTURES, face, bind);free(runs);free(t);This check and the
put_runsbelow aren't atomic with respect tohos_prompts_pool_goneon 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 throughput_runs(and thepart_face[PART_TEXTURES]update inhos_prompts_live), and taken inhos_prompts_pool_gone/hos_prompts_pool, would close it.l->baseis re-read here (it'svolatile), and unlike the two loops above this one has no range or NULL check. If the finalize clearsbasebetween the second loop and this one, it writes at guest addressruns[r].off. Readingbaseonce at the top ofput_runs(and bailing if it's 0) avoids that even without the lock.@ -472,0 +846,4 @@}/* the font with the face's icons on these controls (prompts/External.bfotf, cmap4_bind: hos_binding's), decrypted behindits header (key: the XOR key); bound: how many buttons. NULL none */Thanks, clearing
basefromnvnMemoryPoolFinalizeis the right hook. One gap is left: the check inlive_texturesand the writes input_runscan still interleave with the finalize on the game's thread. See the new comments atlive_texturesand input_runs's last loop.@hckrman101 good catch. The race is closed in
b846ee78:tex_lock, an SRWLOCK):hos_prompts_liveholds it from looking attex.l.base, throughlive_textures/put_runs, to updatingpart_face[PART_TEXTURES].hos_prompts_poolandhos_prompts_pool_gonetake it to set or clear the pool.nvnMemoryPoolFinalizecalls the hook after its ownvmap_locksection, so no other lock is held there.put_runsreadsbaseonce 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_livechecks two more things:tex_lock, and releases it after.put_runsover 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.
Thanks, both points from last time are fixed in
b846ee7.tex_lockis now held from thetex.l.basecheck throughlive_textures/put_runsand thepart_face[PART_TEXTURES]update, andhos_prompts_pool/hos_prompts_pool_gonetake it too, re-checking under the lock. Both hooks run outsidevmap_lockinnvn_native.c, so I don't see a lock-order problem.put_runs:baseis 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.binfrom disk while the lock is held. Still open from before, also a nit: a build with notextures.binstill 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.
Nit: when the textures are the first part to change,
setup()and theread_alloftextures.bininlive_texturesboth run withtex_lockheld. A finalize on the game's thread then waits for that disk read. Loadingtex.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.@ -465,3 +509,4 @@ok ? "" : ": could not be written");if (ok) part_served(PART_TEXTURES, face, bind);free(runs);free(t);Fixed in
b846ee7:tex_lockcovers the check through the writes and thepart_faceupdate, and both pool hooks take it.@ -465,3 +509,4 @@ok ? "" : ": could not be written");if (ok) part_served(PART_TEXTURES, face, bind);free(runs);free(t);Fixed in
b846ee7:baseis read once, with a bail-out at 0 and the range/NULL check in this loop too.@hckrman101 the branch is on the latest main (no newer commits upstream) at
b846ee78, with the race fix from my last comment. Since main keeps moving, I'm pushing first and testing after:b846ee78. It builds on Linux (make_game.py --update).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.
@hckrman101 I ran a full code review of the branch at
b846ee78(the race fix). It found 10 issues. Here is each one and what I did. The fixes are in1da85707, on the latest main.Fixed:
tex.served). A pool made again restores them along with the served face and controls.live_layoutschecks the file's head atlay.basebefore writing. If it no longer matches, it drops the region and writes nothing.romfs_stateat 0, so the row kept asking for a restart.font.nbytes (header included) but compared the head at offset 0, so that case could never match.+8then. A same-size font that isn't ours is marked as seen, so it isn't checked again.part_served).find_layoutsdropped a run that a later, overlapping run had partly overwritten, so its bytes were never put back.idx,write_patched's order) covers no longer counts against the run.Rebase fallout, also fixed: #2270's
test_mods_size_tablecompilesprompts.cwhole, and it no longer compiled on this branch. Itshos.handmain.cnow stub what the live code reads (guest memory, address regions, the clock,rx_option). Its font moved toprompts/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.@hckrman101 thanks for the approval. On your two nits:
textures.binread undertex_lock: fixed ind0dcc7f1.hos_prompts_livereads the face'stextures.bin(texture_bin) before taking the lock, so a finalize waits only for the writes.put_runs's put-back still readsCommon.bntxunder the lock, since those bytes go into the pool.textures.binasking for a restart: this was fixed in10615af4, which is in the head you approved. Without the file,build_texturesreturns 0 (if (!t) return 0;), sohos_prompts_romfssets the textures' state to 3.hos_prompts_live_partsthen counts that part as live, as long as some other part was found. Since1da85707, a part a mod replaces counts the same way.Note that the approval was at
b846ee78. Since then:1da85707adds the fixes from my own review (table in the comment above).d0dcc7f1adds this nit.Both are on the latest main. CI and the in-game runs of this head are next; I'll post them here.
Looked at the three commits since my last review (
b846ee7→d0dcc7f). Still good to go.texture_binnow reads the face's textures.bin beforetex_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 (underbusy), so readingtex.binsoutside the lock is fine.tex.l.rangeswhen the pool is found again reads right, and the malloc failures fall back cleanly.run_as_servedhandling later runs written over earlier ones also makes sense.One small thing, not blocking:
live_layoutschecks the header and then writes without anything stopping the game from letting that memory go in between, unlike the textures, which now havetex_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.
@hckrman101 thanks. I added the comment in
3f124e4band made it more precise in8a7ea243(comments only). It's at the layouts' header check inlive_layoutsand says:The in-game runs of
b846ee78on 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 the head changed after your approval: the in-game runs of
d0dcc7f1caught a regression of mine, fixed in 6cb2a3e9.The regression: in
1da85707, 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 ond0dcc7f1the font was never found. With "a change waits until every part is live", no change showed at all: 0 changes applied in either run. 6cb2a3e9 looks at both places. A font that doesn't match (yet) is checked again at its next glyph, as before1da85707, so the hook no longer marks a face as seen after a mismatch.Results on 6cb2a3e9 (Linux, in game):
prompts: the font at 0x7fd39770c8: the icons in texts follow at once).live.shandlive3.shran with every shot taken and every change applied (30 and 6, as onb846ee78).Tests: the full suite on
d0dcc7f1gave 348 passed, 0 failed, 49 skipped. 6cb2a3e9 changes only the glyph hook, andtest_mods_size_table(which compilesprompts.cwhole) passes on it.CI on this head: every job passes except
Python source checks (windows-2022, part 1/2). That job fails ontest_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.github-sync referenced this pull request2026-11-12 06:22:54 +00:00