feat(enhancement): item assignable buttons, items on A, B, X and Y used with a shoulder hotkey #2151

Open
Ori-Jakob wants to merge 18 commits from Ori-Jakob/assignable-buttons into main
Ori-Jakob commented 2026-11-11 05:05:11 +00:00 (Migrated from github.com)

The remake has one item button, ZR, so using a second item in the middle of a fight means opening the quick-switch menu
first. This adds an option that puts an item on each of A, B, X and Y, used while a shoulder button is held, much like the
C buttons of the N64 game.

What it does

  • Controls tab: Item assignable buttons (Off by default) and Item button hotkey (L or R). With it off the game
    plays as before: its input untouched, and its own layouts loaded (see Layouts below).
  • In play, hold the hotkey and press A, B, X or Y to use that button's item as ZR would: hold to draw the bow, let go to
    shoot. The hotkey no longer opens the quick-switch menu; the other shoulder button still does.
  • To assign: highlight an item in the quick-switch menu or on the pause menu's Items page and press hotkey + A, B, X or Y.
    The same press again takes it off. An item is on one button at a time; child and adult Link have four each.
  • HUD: the four items in a small, half-transparent diamond beside the ZR box, with the hotkey's button over it in the
    style of the ZR box's hint (which now shows the other shoulder). While the hotkey is held the diamond grows over the ZR
    box's place, no longer faded, with each item's button under it; the ZR box and its hint fade out meanwhile.
  • An item on a face button has a light-blue box and that button's icon, in the quick-switch menu and on the Items page.
    The ZR item keeps its gold box, with the icon, when it is also on a face button. Both menus show an Assign guide:
    hotkey + the four face buttons.
  • While an item used this way is out (a drawn bow, a carried bomb), ZR uses it too, and the action guide shows both:
    "Shoot Arrow R2, L1 + △", "Throw RT, LB + B".
  • With the feature on, L and R do nothing in the pause menu (the hotkey assigns on the Items page), so its pages turn
    with ZL and ZR alone, as the game already allows, and the tab bar shows ZL and ZR.
  • Each save keeps its own items, in an item_buttons.ini in its slot folder (save/<user id>/slot_NN/), written only
    when the game saves, into the slot it saves to. Choosing items changes them in memory, so quitting without saving
    loses the change, as with the ZR item. When a save loads, items it does not own or cannot use at that age (or that
    the file names wrongly) come off their buttons, and a new game starts with none. The game's own save files are
    untouched, and the game never reads or lists the extra file.

How it works

  • host/oot_item_buttons.c: the controller filter, called in host/nn_hid.c after the frame step (its step, ib_step,
    takes one poll of the buttons and what the hooks publish, so tests/test_item_buttons_filter.py runs it without the
    game); the item slot's answers; assigning in both menus; the action guide; each save's ini file.
    host/oot_item_buttons_hud.c: the HUD's four boxes and hint.

  • The game is hooked in ten functions by nx2/oot_patches.py (IB_HOOKS), in the lift of the supported build
    (e0f3ccfa, the one make_game.py builds) only.

    Lifted function Game function Hook
    f_83323ca0 Main state's selector in play or not; a loaded save's items checked; a new game seen
    f_80f89630 Main state's requestTransition leaving play
    f_80594ab0 PlayerComponent::calcVirtualKeys a face button uses its item as ZR
    f_8019b6e0 PouchMgr::getAssignableItem the face item, for the player's own readers and the HUD's boxes
    f_81231c00 PouchMgr::setAssignableItem a menu setting the ZR item ends a face button's use
    f_833e98f0 ShortCutSlideList::decide closing the menu right after an assignment leaves ZR as it was
    f_802f1380 Screen::calc, after the screen's update assigning and light-blue boxes in both menus, the guides, the HUD's boxes, the title screen
    f_833ea110 ShortCutSlideListControl::onBindItem the face button's icon on a quick-switch entry
    f_83415d20 UIPlayerStatusScreen::initLayout binds the HUD's four boxes
    f_80aa2ad0 ScreenBase::~ScreenBase forgets a destroyed screen's panes
  • The ZR slot's getter answers the face button's item only at seven return addresses: Link's item selector, the bottle
    nodes and the shooter lookups (one of them a helper the aiming reticle also calls, so during a use the reticle counts
    the face item's ammo); and to ItemBtn::setItem while it gives one of the HUD's four boxes its item, so a bow box
    gets the bow's icon. Every other caller sees the real ZR item. The host never writes the slot, so the save keeps the
    game's ZR item.

  • Saves: host/nn_fs.c's OpenFile tells hos_oot_ib_save_file each file the game opens. A slot's progress.sav
    opened for reading is a load of that slot (its item_buttons.ini is read); opened for writing, a save (the items in
    memory go to that slot's file, which is removed when there are none). The slot is taken from that call, because a
    save often goes elsewhere than the slot loaded: a manual save to the one manual-save slot, an autosave to the oldest
    autosave's. On the first tick of play after a load (hook 1) the current age's items are checked with the game's own
    quick-switch test (PouchMgr::canListInShortCut over the pouch's ItemArray slots, plus the Items page's rule for
    Magic Beans at 0); the other age's wait, unanswered, until the game is at that age. The title screen coming up with
    no load before the next tick of play or save is a new game: no items. This runs with the option off too, so a save
    keeps the items it was loaded with; with none it is one remove of a file that is not there. The file is written as
    progress.sav is opened for writing, where the slot is known: a save that fails after the open leaves it newer than
    the save, which the check at the next load makes harmless.

  • Layouts: nx2/item_buttons_layout.py changes four layouts (PlayerStatus_00, ShortCut_01, MenuOverlay_00,
    PaItemMenuBtn_00) at build time, from the player's own RomFS, with the game's own parts (PaItemBtn_00,
    PaGuideKey_00). The archive keeps the game's size and every other layout keeps its place and bytes, so the prompts'
    runs still fit. host/prompts.c serves it in place of the game's only when its stamp is there and the option is on
    as the game starts (read from hookshot_options.ini, as the prompt face is); with the option off the game loads its
    own archive. Turned on while the game runs, the buttons work at once, and the HUD's boxes, the Assign guides and the
    Items page's icons come with the next start (the row's hint says so). A change of button prompts while the game runs
    (#2366) writes the Talk ring's and the ocarina hint's runs over whichever archive was served, and puts them back from
    it. Nothing from the game is in the repository. If the step cannot run (no zstandard), the buttons still work by the code (not run that way), without
    the HUD's boxes and hint, the Assign guides and the Items page's button icons; with the hotkey on R the ZR box's hint
    then keeps the game's R.

  • The four copies: the game's layout archives hold each layout four times (ZeldaNotes three). The changed layouts
    are larger, so three copies go into the room of four and the fourth entry is removed. Nothing reads a copy other than
    the one a name resolves to. engine::ui::Layout::load (f_8045a0e0) asks the archive for <name>.buil, and
    sead::SharcArchiveRes's lookup returns one fixed entry per name (every entry's collision index is 1).
    LayoutArchiveRes::parse (f_807978c0) relocates every entry once as the archive loads, and every instance of a
    layout points into the copy its lookup returned: the Items page's 24 slots come from one. The by-index read
    (getFileFastImpl_) is called only from parse and from a walk over shader archive entries. On the generated
    archive every name resolves, the three copies are equal and each on bytes of its own, and the 612 entries of the
    layouts it leaves keep their offsets and bytes. Why the archives have four copies is not known.

  • The comma and plus in the action guide: nx2/prompt_assets.py copies the two glyphs from the game's font into the
    served prompt font. When the font served lacks them (the game's own button icons, or a mod's font), the guide shows
    ZR, a gap, then the hotkey and the face button side by side.

  • A change of button prompts while the game runs (#2366) rewrites the button codes in each text to codes of the new face.
    The control guide finds ZR's entry, and the tab bar checks its glyphs, by the button a code shows
    (hos_prompts_game_code, ib_same_glyph), not byte for byte, so the combo still shows after a change.

  • The two options are ids 12 and 13 of the existing option() query, as 11, 16 and 17 are: vk_api.h is part of the
    shader cache's stamp, so it is unchanged and MKWVK_API_VERSION is not bumped.

Testing

  • python -I -B tests/run_all.py on Windows: 370 passed, 15 failed, 73 skipped. The same 15 fail on main (692922d9)
    on this machine (360 passed). Each commit was run the same way, and none adds a failure. New checks:
    test_item_buttons_{filter,hooks,hud,ini,layout,live_prompts,menu,saves,serve}.py and test_prompt_guide_marks.py;
    test_input_background.py, test_oot_menu_mouse.py, test_prompt_font_bind.py and test_vcore_fs_open.py were
    extended.
  • In the game: Windows 11, RTX 4090 (NVIDIA 621.01), game build e0f3ccfa.
    • Rebased on main 692922d9 (#1990's second binding and paddles, #2324's per-file patching): the ten hooks go in
      through patch_file with the others; in the game, the HUD's boxes, the bow from L1 + ○ with its guide, the Items
      page and the quick-switch menu as before.
    • Rebased on main with #2366: an adult save loaded and checked; the HUD at rest and with the hotkey
      held; the bow drawn from L1 + ○, its guide "Shoot Arrow R2, L1 + ○"; the Items page and the quick-switch menu with
      their icons and Assign guides; the pages turned with ZL and ZR. Then the prompts changed from PS5 to Xbox and back
      while the game ran: the HUD's icons and hint, the bow's guide ("Shoot Arrow RT, LB + B"), the Items page's tabs, icons
      and Assign guide all followed. On this PC that change needed a local fix to main's hos_prompts_glyph, which never
      finds the icon font here (the game keeps each font decrypted at its stream's start, with the file's length as the
      stream's size); the fix is not part of this PR.
    • The layouts with the option only: with it off at the start, the game's own archive (the log says so); the option
      then turned on over the control bridge (scripts/hookshot_ctl.py option set item_assignable_buttons 1): the bow drawn
      from L1 + ○ with its guide combo, the pause menu's ZL and ZR tabs and light-blue slots, and, as expected, no HUD boxes,
      Assign guides or slot icons. With it on at the start, the HUD's boxes as before.
    • Before the rebase: an adult save, DualSense and Xbox button prompts, the hotkey on L, and the feature off.
      Assigning and clearing in both menus; bombs, bow and hookshot used from the face buttons; ZR on an item drawn with
      the hotkey; the action guides; the pause menu's page turning; the assignments surviving a restart.
    • Each save's file, before the rebase: a hand-edited file loaded (an owned item, a child-only item on the adult, an item
      not owned, a duplicate, an unknown name: only the owned adult items kept); an assignment changing no file; a manual
      save writing only the slot it went to; a load from the pause menu reading that slot's file and checking it after the
      load; a child save (Bottom of the Well) checked, with the Deku Nut thrown, the Deku Stick drawn and the Slingshot
      aimed from the face buttons; a new game starting with none, its autosave removing the file of the slot it overwrote.
    • On earlier builds of this branch: the hotkey on R for the HUD, its hints and guides, with L opening the quick-switch
      menu. Using and assigning with R is covered by the filter's tests, not by a run in the game.
  • Not tested: Linux or the Steam Deck; a Linux build (the new host code uses the POSIX windows.h shim's MoveFileExA and SRW locks, as
    hos_main.c and nn_fs.c do);
    the mouse on the new guides in the game (covered by test_oot_menu_mouse.py). fps not measured. With the feature on,
    each frame runs the filter, Link's input hook, the HUD's four boxes and a check after each screen's update; with it
    off most of these return at once.

Known limits

  • The System page's Basic Controls picture still names the hotkey's shoulder "Quick Select Items". It is the game's own
    picture, left as it is.

Screenshots

HUD (PS5 prompts). At rest: the small, half-transparent diamond with L1 over it, and R1 over the ZR box. With L1 held: grown over the ZR box's place, each item with its button.

1-hud-rest-and-held

Action guides while an item used from a face button is out, PS5 and Xbox.

2-action-guides

Quick-switch menu (Xbox prompts, hotkey LB, the menu on RB): LB + A moves the bomb from B to A, the same press takes it off.

3-quick-switch-assign

Items page (PS5 prompts): the button icons on the light-blue slots, ZL and ZR on the tab bar (L2 and R2 here), the Assign guide; R2 turns to the System page, whose controller picture is the game's own (see Known limits).

4-items-page-and-tabs

Items page (Xbox prompts): LB + A puts the Deku Nut, the ZR item, on A (it stays gold) and takes A off the bow; the same press takes it off; LB + Y puts a bottle on Y and takes Y off the hammer.

5-items-page-assign

Controls tab: the two rows and their help.

6-controls-tab

Feature off (PS5 prompts): the Items page as the game has it...

7a-off-items-page

...the pages turning with R1 and L1, and the quick-switch menu.

7b-off-tabs-and-quick-switch

🤖 Generated with Claude Code

The remake has one item button, ZR, so using a second item in the middle of a fight means opening the quick-switch menu first. This adds an option that puts an item on each of A, B, X and Y, used while a shoulder button is held, much like the C buttons of the N64 game. ### What it does - Controls tab: **Item assignable buttons** (Off by default) and **Item button hotkey** (L or R). With it off the game plays as before: its input untouched, and its own layouts loaded (see Layouts below). - In play, hold the hotkey and press A, B, X or Y to use that button's item as ZR would: hold to draw the bow, let go to shoot. The hotkey no longer opens the quick-switch menu; the other shoulder button still does. - To assign: highlight an item in the quick-switch menu or on the pause menu's Items page and press hotkey + A, B, X or Y. The same press again takes it off. An item is on one button at a time; child and adult Link have four each. - HUD: the four items in a small, half-transparent diamond beside the ZR box, with the hotkey's button over it in the style of the ZR box's hint (which now shows the other shoulder). While the hotkey is held the diamond grows over the ZR box's place, no longer faded, with each item's button under it; the ZR box and its hint fade out meanwhile. - An item on a face button has a light-blue box and that button's icon, in the quick-switch menu and on the Items page. The ZR item keeps its gold box, with the icon, when it is also on a face button. Both menus show an **Assign** guide: hotkey + the four face buttons. - While an item used this way is out (a drawn bow, a carried bomb), ZR uses it too, and the action guide shows both: "Shoot Arrow R2, L1 + △", "Throw RT, LB + B". - With the feature on, L and R do nothing in the pause menu (the hotkey assigns on the Items page), so its pages turn with ZL and ZR alone, as the game already allows, and the tab bar shows ZL and ZR. - Each save keeps its own items, in an `item_buttons.ini` in its slot folder (`save/<user id>/slot_NN/`), written only when the game saves, into the slot it saves to. Choosing items changes them in memory, so quitting without saving loses the change, as with the ZR item. When a save loads, items it does not own or cannot use at that age (or that the file names wrongly) come off their buttons, and a new game starts with none. The game's own save files are untouched, and the game never reads or lists the extra file. ### How it works - `host/oot_item_buttons.c`: the controller filter, called in `host/nn_hid.c` after the frame step (its step, `ib_step`, takes one poll of the buttons and what the hooks publish, so `tests/test_item_buttons_filter.py` runs it without the game); the item slot's answers; assigning in both menus; the action guide; each save's ini file. `host/oot_item_buttons_hud.c`: the HUD's four boxes and hint. - The game is hooked in ten functions by `nx2/oot_patches.py` (`IB_HOOKS`), in the lift of the supported build (e0f3ccfa, the one `make_game.py` builds) only. | Lifted function | Game function | Hook | |---|---|---| | `f_83323ca0` | Main state's selector | in play or not; a loaded save's items checked; a new game seen | | `f_80f89630` | Main state's requestTransition | leaving play | | `f_80594ab0` | `PlayerComponent::calcVirtualKeys` | a face button uses its item as ZR | | `f_8019b6e0` | `PouchMgr::getAssignableItem` | the face item, for the player's own readers and the HUD's boxes | | `f_81231c00` | `PouchMgr::setAssignableItem` | a menu setting the ZR item ends a face button's use | | `f_833e98f0` | `ShortCutSlideList::decide` | closing the menu right after an assignment leaves ZR as it was | | `f_802f1380` | `Screen::calc`, after the screen's update | assigning and light-blue boxes in both menus, the guides, the HUD's boxes, the title screen | | `f_833ea110` | `ShortCutSlideListControl::onBindItem` | the face button's icon on a quick-switch entry | | `f_83415d20` | `UIPlayerStatusScreen::initLayout` | binds the HUD's four boxes | | `f_80aa2ad0` | `ScreenBase::~ScreenBase` | forgets a destroyed screen's panes | - The ZR slot's getter answers the face button's item only at seven return addresses: Link's item selector, the bottle nodes and the shooter lookups (one of them a helper the aiming reticle also calls, so during a use the reticle counts the face item's ammo); and to `ItemBtn::setItem` while it gives one of the HUD's four boxes its item, so a bow box gets the bow's icon. Every other caller sees the real ZR item. The host never writes the slot, so the save keeps the game's ZR item. - Saves: `host/nn_fs.c`'s `OpenFile` tells `hos_oot_ib_save_file` each file the game opens. A slot's `progress.sav` opened for reading is a load of that slot (its `item_buttons.ini` is read); opened for writing, a save (the items in memory go to that slot's file, which is removed when there are none). The slot is taken from that call, because a save often goes elsewhere than the slot loaded: a manual save to the one manual-save slot, an autosave to the oldest autosave's. On the first tick of play after a load (hook 1) the current age's items are checked with the game's own quick-switch test (`PouchMgr::canListInShortCut` over the pouch's ItemArray slots, plus the Items page's rule for Magic Beans at 0); the other age's wait, unanswered, until the game is at that age. The title screen coming up with no load before the next tick of play or save is a new game: no items. This runs with the option off too, so a save keeps the items it was loaded with; with none it is one `remove` of a file that is not there. The file is written as `progress.sav` is opened for writing, where the slot is known: a save that fails after the open leaves it newer than the save, which the check at the next load makes harmless. - Layouts: `nx2/item_buttons_layout.py` changes four layouts (`PlayerStatus_00`, `ShortCut_01`, `MenuOverlay_00`, `PaItemMenuBtn_00`) at build time, from the player's own RomFS, with the game's own parts (`PaItemBtn_00`, `PaGuideKey_00`). The archive keeps the game's size and every other layout keeps its place and bytes, so the prompts' runs still fit. `host/prompts.c` serves it in place of the game's only when its stamp is there and the option is on as the game starts (read from `hookshot_options.ini`, as the prompt face is); with the option off the game loads its own archive. Turned on while the game runs, the buttons work at once, and the HUD's boxes, the Assign guides and the Items page's icons come with the next start (the row's hint says so). A change of button prompts while the game runs (#2366) writes the Talk ring's and the ocarina hint's runs over whichever archive was served, and puts them back from it. Nothing from the game is in the repository. If the step cannot run (no `zstandard`), the buttons still work by the code (not run that way), without the HUD's boxes and hint, the Assign guides and the Items page's button icons; with the hotkey on R the ZR box's hint then keeps the game's R. - The four copies: the game's layout archives hold each layout four times (`ZeldaNotes` three). The changed layouts are larger, so three copies go into the room of four and the fourth entry is removed. Nothing reads a copy other than the one a name resolves to. `engine::ui::Layout::load` (`f_8045a0e0`) asks the archive for `<name>.buil`, and `sead::SharcArchiveRes`'s lookup returns one fixed entry per name (every entry's collision index is 1). `LayoutArchiveRes::parse` (`f_807978c0`) relocates every entry once as the archive loads, and every instance of a layout points into the copy its lookup returned: the Items page's 24 slots come from one. The by-index read (`getFileFastImpl_`) is called only from `parse` and from a walk over shader archive entries. On the generated archive every name resolves, the three copies are equal and each on bytes of its own, and the 612 entries of the layouts it leaves keep their offsets and bytes. Why the archives have four copies is not known. - The comma and plus in the action guide: `nx2/prompt_assets.py` copies the two glyphs from the game's font into the served prompt font. When the font served lacks them (the game's own button icons, or a mod's font), the guide shows ZR, a gap, then the hotkey and the face button side by side. - A change of button prompts while the game runs (#2366) rewrites the button codes in each text to codes of the new face. The control guide finds ZR's entry, and the tab bar checks its glyphs, by the button a code shows (`hos_prompts_game_code`, `ib_same_glyph`), not byte for byte, so the combo still shows after a change. - The two options are ids 12 and 13 of the existing `option()` query, as 11, 16 and 17 are: `vk_api.h` is part of the shader cache's stamp, so it is unchanged and `MKWVK_API_VERSION` is not bumped. ### Testing - `python -I -B tests/run_all.py` on Windows: 370 passed, 15 failed, 73 skipped. The same 15 fail on `main` (692922d9) on this machine (360 passed). Each commit was run the same way, and none adds a failure. New checks: `test_item_buttons_{filter,hooks,hud,ini,layout,live_prompts,menu,saves,serve}.py` and `test_prompt_guide_marks.py`; `test_input_background.py`, `test_oot_menu_mouse.py`, `test_prompt_font_bind.py` and `test_vcore_fs_open.py` were extended. - In the game: Windows 11, RTX 4090 (NVIDIA 621.01), game build e0f3ccfa. - Rebased on `main` 692922d9 (#1990's second binding and paddles, #2324's per-file patching): the ten hooks go in through `patch_file` with the others; in the game, the HUD's boxes, the bow from L1 + ○ with its guide, the Items page and the quick-switch menu as before. - Rebased on `main` with #2366: an adult save loaded and checked; the HUD at rest and with the hotkey held; the bow drawn from L1 + ○, its guide "Shoot Arrow R2, L1 + ○"; the Items page and the quick-switch menu with their icons and Assign guides; the pages turned with ZL and ZR. Then the prompts changed from PS5 to Xbox and back while the game ran: the HUD's icons and hint, the bow's guide ("Shoot Arrow RT, LB + B"), the Items page's tabs, icons and Assign guide all followed. On this PC that change needed a local fix to `main`'s `hos_prompts_glyph`, which never finds the icon font here (the game keeps each font decrypted at its stream's start, with the file's length as the stream's size); the fix is not part of this PR. - The layouts with the option only: with it off at the start, the game's own archive (the log says so); the option then turned on over the control bridge (`scripts/hookshot_ctl.py option set item_assignable_buttons 1`): the bow drawn from L1 + ○ with its guide combo, the pause menu's ZL and ZR tabs and light-blue slots, and, as expected, no HUD boxes, Assign guides or slot icons. With it on at the start, the HUD's boxes as before. - Before the rebase: an adult save, DualSense and Xbox button prompts, the hotkey on L, and the feature off. Assigning and clearing in both menus; bombs, bow and hookshot used from the face buttons; ZR on an item drawn with the hotkey; the action guides; the pause menu's page turning; the assignments surviving a restart. - Each save's file, before the rebase: a hand-edited file loaded (an owned item, a child-only item on the adult, an item not owned, a duplicate, an unknown name: only the owned adult items kept); an assignment changing no file; a manual save writing only the slot it went to; a load from the pause menu reading that slot's file and checking it after the load; a child save (Bottom of the Well) checked, with the Deku Nut thrown, the Deku Stick drawn and the Slingshot aimed from the face buttons; a new game starting with none, its autosave removing the file of the slot it overwrote. - On earlier builds of this branch: the hotkey on R for the HUD, its hints and guides, with L opening the quick-switch menu. Using and assigning with R is covered by the filter's tests, not by a run in the game. - Not tested: Linux or the Steam Deck; a Linux build (the new host code uses the POSIX `windows.h` shim's `MoveFileExA` and SRW locks, as `hos_main.c` and `nn_fs.c` do); the mouse on the new guides in the game (covered by `test_oot_menu_mouse.py`). fps not measured. With the feature on, each frame runs the filter, Link's input hook, the HUD's four boxes and a check after each screen's update; with it off most of these return at once. ### Known limits - The System page's Basic Controls picture still names the hotkey's shoulder "Quick Select Items". It is the game's own picture, left as it is. ### Screenshots HUD (PS5 prompts). At rest: the small, half-transparent diamond with L1 over it, and R1 over the ZR box. With L1 held: grown over the ZR box's place, each item with its button. <img width="1204" height="570" alt="1-hud-rest-and-held" src="https://github.com/user-attachments/assets/eae43737-d561-4e64-9167-bf36ee57c97a" /> Action guides while an item used from a face button is out, PS5 and Xbox. <img width="1040" height="380" alt="2-action-guides" src="https://github.com/user-attachments/assets/841ae20f-ea22-4f93-94ac-e87118228618" /> Quick-switch menu (Xbox prompts, hotkey LB, the menu on RB): LB + A moves the bomb from B to A, the same press takes it off. <img width="1244" height="604" alt="3-quick-switch-assign" src="https://github.com/user-attachments/assets/c41f24ee-6659-4bbc-af24-34c11934a8a5" /> Items page (PS5 prompts): the button icons on the light-blue slots, ZL and ZR on the tab bar (L2 and R2 here), the Assign guide; R2 turns to the System page, whose controller picture is the game's own (see Known limits). <img width="1914" height="720" alt="4-items-page-and-tabs" src="https://github.com/user-attachments/assets/ef99a01c-2ea5-49b8-b561-c9f86f63cd9a" /> Items page (Xbox prompts): LB + A puts the Deku Nut, the ZR item, on A (it stays gold) and takes A off the bow; the same press takes it off; LB + Y puts a bottle on Y and takes Y off the hammer. <img width="2012" height="617" alt="5-items-page-assign" src="https://github.com/user-attachments/assets/04314a7b-8209-4c9d-80f4-38219601e9fd" /> Controls tab: the two rows and their help. <img width="1540" height="432" alt="6-controls-tab" src="https://github.com/user-attachments/assets/f1b64363-44bf-40c7-81d2-9500f85e5bb7" /> Feature off (PS5 prompts): the Items page as the game has it... <img width="960" height="720" alt="7a-off-items-page" src="https://github.com/user-attachments/assets/a8d1e96e-9192-4fd2-b2fc-dfac342779d3" /> ...the pages turning with R1 and L1, and the quick-switch menu. <img width="810" height="742" alt="7b-off-tabs-and-quick-switch" src="https://github.com/user-attachments/assets/0d391ef9-e54b-48b3-b543-945b0a030007" /> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 20:06:57 +00:00
Joey-Magnus (Migrated from github.com) left a comment

This is a big, careful piece of work: the filter is a pure step function with its own tests, the hooks are in new files, the save-side file is handled conservatively, and the existing checks it touches (test_vcore_fs_open, test_input_background, test_prompt_font_bind, test_oot_menu_mouse) were extended rather than loosened. CI is green and it merges cleanly onto main. At 7.9k lines I focused on what runs for players who never turn the option on, and on the host's file and input paths: host/oot_item_buttons.c (the filter, the hooks' entry points, the save-slot file), the nn_hid.c/nn_fs.c/oot_menu.c/prompts.c changes, nx2/oot_patches.py's hooks, and the menu rows. I read oot_item_buttons_hud.c and the layout writer (item_buttons_buil.py/_rules.py/_layout.py) only in outline.

What I checked and found fine:

  • With the option off, hos_oot_ib_filter returns the buttons as they came (host/oot_item_buttons.c:251). hos_oot_ib_player returns unless a use is still ending, and the slot getter hook returns 0 unless an item is being answered, so the game's own ZR path is untouched.
  • Option ids 12 and 13 are unused on main, and answering them by number in t.option follows the precedent of 11, 16 and 17, so no vk_api.h change or API bump is needed.
  • ib_slot_of only accepts exactly save:/slot_NN/progress.sav. The ini reader bounds its lines and passes over unknown keys and names. The write goes through .tmp and MoveFileExA, and the game exe's UTF-8 manifest makes the narrow fopen/MoveFileExA calls safe for non-ASCII folders.
  • The new build-time packages are only zstandard, which the pipeline already has; the fontTools use stays inside nx2/prompt_assets.py as CONTRIBUTING requires. I didn't see anything from the game committed: the layout tests build synthetic .buil files.

should fix / question host/prompts.c:1126-1144 (build_layouts): the rebuilt item_buttons/Common.blarc is served whenever its stamp exists, and make_game.py builds it for everyone with zstandard, that is, every normal build. So every player runs the game with the four rewritten layouts, even with Item assignable buttons off. That includes the renumbered panes in ShortCut_01, and the archive with one of the four copies of each changed layout removed (nx2/item_buttons_layout.py's docstring: "three copies of it go into the room of the four … and the fourth entry goes"). The PR says "With it off the game plays as before", which is true for input, but the UI data every player loads changes, and that has been played only on one Windows/NVIDIA machine, mostly with the feature on. Two asks:

  • What does the game use the fourth copy of each layout for? Since entries are relocated in place, I'd expect the four copies to exist so that up to four instances can be loaded at once. If something ever loads a fourth PlayerStatus_00, ShortCut_01, MenuOverlay_00 or PaItemMenuBtn_00, what happens without it? If you've confirmed it's never reached, please say how in the description.
  • Consider serving item_buttons/ only when the option was on at start (the HUD's boxes and guides would then show after a restart, like the other restart rows), or at least add a HOS_ switch that serves the game's own archive. Then a layout problem found later can't reach players who never asked for the feature, and has a one-line workaround for those who did.

minor host/oot_item_buttons.c:638-648: the slot's item_buttons.ini is written when progress.sav is opened for writing, so a save that fails after the open still updates the ini. That's harmless today (the items are only checked against the save at load), but worth a comment, since the function's comment reads as if it follows the save.

minor Not tested on Linux or the Steam Deck. It compiles there (the appimage job is green), but since the layout change reaches every player (see above), one boot to the title and into play on Linux with the option off would be worth doing before merging.

This is a big, careful piece of work: the filter is a pure step function with its own tests, the hooks are in new files, the save-side file is handled conservatively, and the existing checks it touches (`test_vcore_fs_open`, `test_input_background`, `test_prompt_font_bind`, `test_oot_menu_mouse`) were extended rather than loosened. CI is green and it merges cleanly onto main. At 7.9k lines I focused on what runs for players who never turn the option on, and on the host's file and input paths: `host/oot_item_buttons.c` (the filter, the hooks' entry points, the save-slot file), the `nn_hid.c`/`nn_fs.c`/`oot_menu.c`/`prompts.c` changes, `nx2/oot_patches.py`'s hooks, and the menu rows. I read `oot_item_buttons_hud.c` and the layout writer (`item_buttons_buil.py`/`_rules.py`/`_layout.py`) only in outline. What I checked and found fine: - With the option off, `hos_oot_ib_filter` returns the buttons as they came (`host/oot_item_buttons.c:251`). `hos_oot_ib_player` returns unless a use is still ending, and the slot getter hook returns 0 unless an item is being answered, so the game's own ZR path is untouched. - Option ids 12 and 13 are unused on main, and answering them by number in `t.option` follows the precedent of 11, 16 and 17, so no `vk_api.h` change or API bump is needed. - `ib_slot_of` only accepts exactly `save:/slot_NN/progress.sav`. The ini reader bounds its lines and passes over unknown keys and names. The write goes through `.tmp` and `MoveFileExA`, and the game exe's UTF-8 manifest makes the narrow `fopen`/`MoveFileExA` calls safe for non-ASCII folders. - The new build-time packages are only `zstandard`, which the pipeline already has; the fontTools use stays inside `nx2/prompt_assets.py` as CONTRIBUTING requires. I didn't see anything from the game committed: the layout tests build synthetic `.buil` files. **should fix / question** `host/prompts.c:1126-1144` (`build_layouts`): the rebuilt `item_buttons/Common.blarc` is served whenever its stamp exists, and `make_game.py` builds it for everyone with `zstandard`, that is, every normal build. So every player runs the game with the four rewritten layouts, even with **Item assignable buttons** off. That includes the renumbered panes in `ShortCut_01`, and the archive with one of the four copies of each changed layout removed (`nx2/item_buttons_layout.py`'s docstring: "three copies of it go into the room of the four … and the fourth entry goes"). The PR says "With it off the game plays as before", which is true for input, but the UI data every player loads changes, and that has been played only on one Windows/NVIDIA machine, mostly with the feature on. Two asks: - What does the game use the fourth copy of each layout for? Since entries are relocated in place, I'd expect the four copies to exist so that up to four instances can be loaded at once. If something ever loads a fourth `PlayerStatus_00`, `ShortCut_01`, `MenuOverlay_00` or `PaItemMenuBtn_00`, what happens without it? If you've confirmed it's never reached, please say how in the description. - Consider serving `item_buttons/` only when the option was on at start (the HUD's boxes and guides would then show after a restart, like the other restart rows), or at least add a `HOS_` switch that serves the game's own archive. Then a layout problem found later can't reach players who never asked for the feature, and has a one-line workaround for those who did. **minor** `host/oot_item_buttons.c:638-648`: the slot's `item_buttons.ini` is written when `progress.sav` is *opened* for writing, so a save that fails after the open still updates the ini. That's harmless today (the items are only checked against the save at load), but worth a comment, since the function's comment reads as if it follows the save. **minor** Not tested on Linux or the Steam Deck. It compiles there (the appimage job is green), but since the layout change reaches every player (see above), one boot to the title and into play on Linux with the option off would be worth doing before merging.
Ori-Jakob commented 2026-11-11 20:49:11 +00:00 (Migrated from github.com)

Thanks for the careful read. Going through your points:

Every player loads the changed layouts. You're right: "with it off the game plays as before" was only true for input. host/prompts.c now serves item_buttons/Common.blarc only when Item assignable buttons is on as the game starts (read from hookshot_options.ini, as button_prompts is). With it off, the game loads its own archive, as on main. Turned on while the game runs, the buttons work at once, and the HUD's boxes, the Assign guides and the Items page's icons come with the next start; the row's hint says so. Turning it off and restarting is the workaround if a layout problem turns up.

I checked both cases in the game. Off at the start, the log says the game's own layouts were loaded. With the option then turned on over the control bridge, the bow works from L1 + ○ with its guide, the pause menu shows ZL and ZR, and the HUD has no boxes. On at the start, the boxes are there. tests/test_item_buttons_serve.py covers the option off, on and missing, and checks that the key read is the one the menu writes.

The fourth copy. As far as I can find, nothing reads a copy other than the one a name resolves to:

  • A layout is always found by name. engine::ui::Layout::load (0x710043a0e0, lifted f_8045a0e0) formats "%s.buil" and asks the archive for that path; parts go through the same call. sead::SharcArchiveRes::convertPathToEntryIDImpl_ is a binary search by hash, and every entry of the game's layout archives has collision index 1 in its name word, so a name resolves to one fixed entry. Following that lookup in the game's Common, PlayerStatus_00 resolves to its first copy, MenuOverlay_00 to its second and ShortCut_01 to its third.
  • Instances don't take a copy each. LayoutArchiveRes::parse (f_807978c0) walks every entry by index once, as the archive loads, to relocate it. Each instance keeps a pointer into the copy its lookup returned. The Items page shows 24 PaItemMenuBtn_00 slots from an archive with 4 copies of it.
  • The by-index read (getFileFastImpl_) is called only from parse and from a walk over shader archive (FSHA) entries.
  • The game ships a layout archive with three copies of each layout: ZeldaNotes. (Common, Title and Boot have four.)
  • In the generated archive, every name resolves with the same lookup, the three copies are equal and each on bytes of its own, and the 612 entries of the layouts it doesn't change keep their offsets and bytes.

Why the archives have four copies, I don't know. This is in the description now.

The ini written at the open. Agreed. It's written when progress.sav is opened for writing because that's where the slot is known. So a save that fails after the open leaves the ini newer than the save; the next load checks the items against that save, so nothing it doesn't own stays on a button. The function's comment says so now.

Also worth saying: with the option off, hos_oot_ib_save_file still runs at each save and load of a slot's progress.sav, so a save keeps the items it was loaded with. With no items, that's one remove of a file that isn't there, and a log line.

CI and Linux. A small correction: only the title check has run on this PR. The Windows builder, Linux AppImage, DLSS back end and game-free regression workflows are waiting for a maintainer's approval. Could you approve them? I don't have a Linux machine or a Steam Deck to boot it on. With the change above, players who leave the option off load the game's own archive on every platform.

Thanks for the careful read. Going through your points: **Every player loads the changed layouts.** You're right: "with it off the game plays as before" was only true for input. `host/prompts.c` now serves `item_buttons/Common.blarc` only when Item assignable buttons is on as the game starts (read from `hookshot_options.ini`, as `button_prompts` is). With it off, the game loads its own archive, as on main. Turned on while the game runs, the buttons work at once, and the HUD's boxes, the Assign guides and the Items page's icons come with the next start; the row's hint says so. Turning it off and restarting is the workaround if a layout problem turns up. I checked both cases in the game. Off at the start, the log says the game's own layouts were loaded. With the option then turned on over the control bridge, the bow works from L1 + ○ with its guide, the pause menu shows ZL and ZR, and the HUD has no boxes. On at the start, the boxes are there. `tests/test_item_buttons_serve.py` covers the option off, on and missing, and checks that the key read is the one the menu writes. **The fourth copy.** As far as I can find, nothing reads a copy other than the one a name resolves to: - A layout is always found by name. `engine::ui::Layout::load` (`0x710043a0e0`, lifted `f_8045a0e0`) formats `"%s.buil"` and asks the archive for that path; parts go through the same call. `sead::SharcArchiveRes::convertPathToEntryIDImpl_` is a binary search by hash, and every entry of the game's layout archives has collision index 1 in its name word, so a name resolves to one fixed entry. Following that lookup in the game's Common, `PlayerStatus_00` resolves to its first copy, `MenuOverlay_00` to its second and `ShortCut_01` to its third. - Instances don't take a copy each. `LayoutArchiveRes::parse` (`f_807978c0`) walks every entry by index once, as the archive loads, to relocate it. Each instance keeps a pointer into the copy its lookup returned. The Items page shows 24 `PaItemMenuBtn_00` slots from an archive with 4 copies of it. - The by-index read (`getFileFastImpl_`) is called only from `parse` and from a walk over shader archive (`FSHA`) entries. - The game ships a layout archive with three copies of each layout: `ZeldaNotes`. (`Common`, `Title` and `Boot` have four.) - In the generated archive, every name resolves with the same lookup, the three copies are equal and each on bytes of its own, and the 612 entries of the layouts it doesn't change keep their offsets and bytes. Why the archives have four copies, I don't know. This is in the description now. **The ini written at the open.** Agreed. It's written when `progress.sav` is opened for writing because that's where the slot is known. So a save that fails after the open leaves the ini newer than the save; the next load checks the items against that save, so nothing it doesn't own stays on a button. The function's comment says so now. Also worth saying: with the option off, `hos_oot_ib_save_file` still runs at each save and load of a slot's `progress.sav`, so a save keeps the items it was loaded with. With no items, that's one `remove` of a file that isn't there, and a log line. **CI and Linux.** A small correction: only the title check has run on this PR. The Windows builder, Linux AppImage, DLSS back end and game-free regression workflows are waiting for a maintainer's approval. Could you approve them? I don't have a Linux machine or a Steam Deck to boot it on. With the change above, players who leave the option off load the game's own archive on every platform.
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 21:25:43 +00:00
Joey-Magnus (Migrated from github.com) left a comment

CI has never run here (first-contributor workflows need maintainer approval), so none of the 7.9k lines has been built by CI yet. I said earlier that CI was green, and that was wrong.

What I found:

  • Of the 7.9k lines, about 3.4k are tests, 2.7k host C and 1.6k build-time Python. The tests use synthetic layouts, and no game data is committed.
  • The earlier concern is fixed: the rebuilt item_buttons/Common.blarc is only served when the option is on at start (host/prompts.c:1150).
  • The risky part is the ten hooks into lifted functions (IB_HOOKS in nx2/oot_patches.py). They use hard-coded return addresses and vtables for build e0f3ccfa, and several run with the option off: the per-frame screen-update hook, the slot getter, and the save hook in host/nn_fs.c:403, which writes or removes item_buttons.ini at every save. Each gates its changes on the option, and the ini write is atomic through a .tmp file.
  • One change still reaches every build: prompt_assets.py adds a comma and a plus glyph to the prompt font, falling back cleanly if that fails.
  • Nothing has been run on Linux or the Deck.

Whether to own ten build-specific hooks is the maintainers' call.

CI has never run here (first-contributor workflows need maintainer approval), so none of the 7.9k lines has been built by CI yet. I said earlier that CI was green, and that was wrong. What I found: - Of the 7.9k lines, about 3.4k are tests, 2.7k host C and 1.6k build-time Python. The tests use synthetic layouts, and no game data is committed. - The earlier concern is fixed: the rebuilt `item_buttons/Common.blarc` is only served when the option is on at start (`host/prompts.c:1150`). - The risky part is the ten hooks into lifted functions (`IB_HOOKS` in `nx2/oot_patches.py`). They use hard-coded return addresses and vtables for build e0f3ccfa, and several run with the option off: the per-frame screen-update hook, the slot getter, and the save hook in `host/nn_fs.c:403`, which writes or removes `item_buttons.ini` at every save. Each gates its changes on the option, and the ini write is atomic through a .tmp file. - One change still reaches every build: `prompt_assets.py` adds a comma and a plus glyph to the prompt font, falling back cleanly if that fails. - Nothing has been run on Linux or the Deck. Whether to own ten build-specific hooks is the maintainers' call.
hckrman101 commented 2026-11-11 22:17:22 +00:00 (Migrated from github.com)

This now conflicts with main in nx2/oot_patches.py, tests/test_prompt_font_bind.py. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.

This now conflicts with main in `nx2/oot_patches.py`, `tests/test_prompt_font_bind.py`. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.
Ori-Jakob commented 2026-11-11 22:55:04 +00:00 (Migrated from github.com)

This now conflicts with main in nx2/oot_patches.py, tests/test_prompt_font_bind.py. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.

Branch has been rebased. It is ready to look at :)

> This now conflicts with main in `nx2/oot_patches.py`, `tests/test_prompt_font_bind.py`. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date. Branch has been rebased. It is ready to look at :)
Owner

@Joey-Magnus reviewed on GitHub · 2026-11-12 14:32 UTC · original

The points from my earlier reviews are fixed on this head. item_buttons/Common.blarc is only served when the option is on at the start (b5af3839, ae241537). The ini written when the save opens is now documented (e3b82bb7). The question about the fourth layout copy is answered in your reply.

Main has moved 92 commits since your base, and the branch now conflicts in gpu/vk/vk_menu.cpp, vk_menu.h, host/nn_hid.c, host/prompts.c, nx2/oot_patches.py, nx2/prompt_assets.py and tests/test_prompt_font_bind.py. Other open PRs also change nn_hid.c, prompts.c and oot_patches.py, for example #2304 and #2755. I couldn't run the tests because of the conflicts, and CI has still never run here.

Before you spend time on another rebase, the maintainers should decide whether they want to own the ten hooks in IB_HOOKS. They hard-code return addresses and vtables for this build, and several run with the option off. After a rebase, please also add readme lines for the new Controls rows. #2105's check needs them. Linux and the Steam Deck are still untested.

> [@Joey-Magnus](https://github.com/Joey-Magnus) reviewed on GitHub · 2026-11-12 14:32 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2151#pullrequestreview-5876206707) The points from my earlier reviews are fixed on this head. `item_buttons/Common.blarc` is only served when the option is on at the start (`b5af3839`, `ae241537`). The ini written when the save opens is now documented (`e3b82bb7`). The question about the fourth layout copy is answered in your reply. Main has moved 92 commits since your base, and the branch now conflicts in `gpu/vk/vk_menu.cpp`, `vk_menu.h`, `host/nn_hid.c`, `host/prompts.c`, `nx2/oot_patches.py`, `nx2/prompt_assets.py` and `tests/test_prompt_font_bind.py`. Other open PRs also change `nn_hid.c`, `prompts.c` and `oot_patches.py`, for example #2304 and #2755. I couldn't run the tests because of the conflicts, and CI has still never run here. Before you spend time on another rebase, the maintainers should decide whether they want to own the ten hooks in `IB_HOOKS`. They hard-code return addresses and vtables for this build, and several run with the option off. After a rebase, please also add readme lines for the new Controls rows. #2105's check needs them. Linux and the Steam Deck are still untested. <!-- github-sync:r:5876206707 -->
Commenting is not possible because the repository is archived.
No description provided.