Skip to content

feat(mods): a Mods tab, merged resource size tables, ExeFS patches and Atmosphere cheats - #2478

Draft
hckrman101 wants to merge 4 commits into
NotRexed:mainfrom
hckrman101:feat/mods-cheats
Draft

hckrman101 wants to merge 4 commits into
NotRexed:mainfrom
hckrman101:feat/mods-cheats

Conversation

@hckrman101

@hckrman101 hckrman101 commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Branch: feat/mods-cheats, head f654ef2 (5d9a877, then a fix, a merge of main 940eb9e, and the menu header)


Mods that each make files bigger, such as HD texture packs, can't be combined today: the game reads one resource size table, so only one mod's grown entries count. ExeFS patches (IPS, pchtxt) and Atmosphère cheat files, which many Switch mods ship, aren't loaded at all. This PR merges the size tables, adds a Mods tab to the Insert menu, applies ExeFS patches when the game is built, and runs cheat files.

What changes

  • Merged resource size table (host/mods.c, host/restbl.c, host/zs.c). At start, when mods serve files, Hookshot builds the size table the game reads:
    • It starts from the game's table, or from the button prompts' copy when they made one, so the prompts' entries stay in it.
    • Every enabled mod's own table is merged in; the larger entry wins.
    • Every file a mod serves gets its entry raised to fit its size. A file that grew is raised in proportion to the game's own allowance; a file the game doesn't have gets an estimate from Ocarina of Time's own table.
    • The tables are read with the game's zstd dictionaries when they need them. The result is written to modcache/rsizetable.zs and logged.
  • How this relates to fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270. fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270 merges the button prompts' entries into the one size table that wins (the last mod's), so a mod with its own table keeps the controller icons. This PR merges the tables of every enabled mod, and also grows entries for mod files that need it, so mods that each grow files load together. fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270's path stays as the fallback when no merged table can be made (the last mod's table with the prompts' entries merged in). This PR reuses fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270's zstd decoder build. fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270's test passes unchanged in behaviour; only its fixture's file list grew.
  • Mods tab (gpu/vk/vk_menu.cpp; MkwVkHost version 18). The tab is shown only when there are mods.
    • It lists the mods in their order, each On or Off. Left and Right switch one; Enter picks one up, Up and Down move it, and Enter puts it down. A mod lower in the list wins a file two mods have.
    • The order and switches are kept in hookshot_mods.txt and apply at the next start; closing the menu asks to restart ("the mods").
    • Without that file, the order is the folders' names, as today.
    • The help box says what each mod has: RomFS files replaced or added, files taken by a mod lower in the list, ExeFS patches built in or waiting for a build.
  • ExeFS patches (nx2/exefs_patches.py, nx2/image.py, nx2/make_game.py). mods/<name>/exefs/*.ips and *.pchtxt files (IPS, IPS32, IPSwitch) are applied to main's image before the lift relocates and translates it, as rtld would see the module. The code is then compiled from the patched instructions, and image.bin holds the patched data.
    • A file counts when its build id matches (the IPS file's name, or the pchtxt's @nsobid).
    • A write into the padding after .text (a code cave) becomes code the lift translates.
    • Writes outside main, into the dynamic linker's tables, or into a lazily bound pointer make the whole file unsupported.
    • The patches are part of the lift's key; with no patches the lift and its key are as before.
    • The build writes exefs_patches.txt into the game folder. The Mods tab uses it to say when a mod's patches wait for the next build (a change takes a rebuild, since the code is compiled ahead of time).
  • Atmosphère cheats on the Mods tab (host/cheats.c). These are cheat-code files, mods/<name>/cheats/E0F3CCFAF50065AE.txt in Atmosphère/dmnt format. They are separate from the Debug tab's built-in cheats (Add opt-in Ocarina of Time developer debug tools #785 tools, oot_cheat_items.h), which this PR doesn't touch.
    • They run in a cheat VM after dmnt's (opcodes 0-A and C0-C3) at each presented frame, on guest memory the game was given (the image, the heap, address regions).
    • Each cheat is listed under its mod and switched at once; the switch is kept in hookshot_mods.txt. {Master} codes are always on.
    • A cheat that writes the game's code is listed as "Not supported" (the code is compiled); make it an ExeFS patch instead.
  • Menu header (gpu/vk/vk_menu.cpp). With six tabs, the build's text at the banner's left had no room: it shrank and then was hidden (at Large it was drawn at its size or not at all). The banner now starts with a small header that is always shown: Hookshot's icon (the start-up screen's assets/hookshot.png) and the build, a little smaller than before (13 points, from 15). The tabs are a little smaller too (21 points, from 23). They stay centred while that leaves the header room; otherwise they start after it, with the Q/E keys at their ends as before. Where it is still too tight (Small at 1280x720 with a long source build's version), the header drops "Hookshot " first (the icon says it), then the git suffix after the release (v2.1.0-rc.3...), then a size step. Only after that do the tabs shrink. With no mods (five tabs) the same rules apply.
  • Nothing changes without mods. No mods folder means no tab, nothing served or written, and no lift-key change. The cheat hook returns at once.

Credit

  • The list's order and switches, the size table's merge, the IPS/IPS32/pchtxt parsing and the cheat VM are ported from valentderah/totk-pc-recomp: runtime/mods.c, romfs.c, restbl.c, cheats.c and tools/exefs_patches.py. That project is MIT, Copyright (c) 2026 Valiantsin Dzerakh; its notice is kept in host/restbl.h and nx2/exefs_patches.py, and host/PROVENANCE.md names the files.
  • The cheat file loading and VM there started with conongnang.
  • The size estimates are Ocarina of Time's own, measured against the 1.1.1 table.

Verified

On an RTX 3090 (Linux), headless runs with copies of saves. The test mods were made locally from the user's own files and never shared.

  • Mods that grow the same file load together.

    • Mod A grows Title.bntx (+64 KiB) and the HUD's Common.bntx (+64 KiB), with its own table.
    • Mod B grows the same Title.bntx (+256 KiB), with its own table.
    • Log: resource size table: 2 tables of mods merged (3 entries), 2 entries raised for mod files ... served from modcache. The title texture is served from B (lower in the list) and the HUD from A.
    • The HUD shows A's deliberately overwritten texture data in game, so A's grown file loaded even though the last mod's table alone doesn't fit it.
  • ExeFS patches.

    • Kabosune's First Person IPS32 (MIT): hooks at two sites plus code in the cave after .text. In game, R3 switches to first person (screenshots: before / after).
    • A harmless pchtxt of my own (mov x19, x0 re-encoded as add x19, x0, #0 in the camera view update): its bytes are in image.bin.
    • The build log says 1075 bytes of main patched by 2 files and main's code reaches 0x3c46f8c now (a code cave); the Mods tab shows both mods' patches as built in.
  • Mods tab. In game, with screenshots: the list, a mod moved up (hookshot_mods.txt written; "Restart needed: the mods"), and a cheat switched on (log cheats: ... 1 on).

  • Game-free checks, 399 of 399 passing on Linux:

    • New: test_mods_sizes (three mods over a synthetic RomFS and tables: the merged entries, the served files, the counts, hookshot_mods.txt across two starts, ExeFS status, no mods = nothing written).
    • New: test_exefs_patches (IPS/IPS32/pchtxt, build ids, the mods' order, code/data/linker tables on a synthetic main, caves, the list).
    • New: test_cheat_vm (stores, buttons, pointer chains, loops, register conditions, out-of-guest reads, code writes refused).
    • New: test_mods_menu (the tab's lines and keys against a fake host, hidden without mods or with an older host).
    • Updated: fix(mods): merge a mod's resource size table with the button prompts' instead of switching the icons off #2270's test_mods_size_table, test_fs_links, test_present_trace, test_input_background.
  • Menu header, in the game (RTX 3090, Linux, headless; the game's own frames). Small, Medium and Large at 1280x720, 1600x900, 1920x1080 and 2560x1440, with six tabs and the version v2.1.0-rc.3-135-g32bb5f4-dirty; plus five tabs (no mods) at Small 1280x720 and Medium 1920x1080. Before: at Medium and Large the version was hidden, and at Large the tab keys reached the bar's ends. After: the icon and the version show at every size. Small and Medium keep the whole "Hookshot v2.1.0-rc.3-135-g32bb5f4-dirty"; Large, at every resolution, shows "v2.1.0-rc.3..." beside the icon. Nothing overlaps, and the Q/E keys stay inside the bar. The full suite (tests/run_all.py) passes after the merge and the header: 412 passed, 0 failed, 12 skipped. tests/test_menu_size.py checks the header's order of shortening and the shortened version.

Not tested

  • In-game effect of a data cheat. The VM ran in game with a cheat on, but the camera-FOV address I tried showed no visible change (the game likely recomputes it each frame). The VM itself is covered by test_cheat_vm.
  • The rebased build in game. The in-game runs above were on the same change before the rebase onto 2ed98c4. After the rebase, the game-free checks pass; the full rebuild (compile check) was still queued when this was written: see ~/.cache/hookshot-work/port-mods/build2.log.
  • A "before" run of main with the same mods (expected: mod A's grown HUD file doesn't fit the last mod's table).
  • Windows (the host code goes through the same Win32 calls the posix layer stands in for; not built for Windows here). AMD/Intel GPUs. The builder UI (no change there; a rebuild with new ExeFS patches is the usual Build/Update).
  • Atmosphère's atmosphere/exefs_patches/<name>/ layout and opcode C4/0xF extensions are not supported.
  • This PR and the amiibo PR both take MkwVkHost version 18; whichever lands second moves to 19.

Screenshots (game output only): pr-shots/a-third-person.png, a-first-person.png, mods-tab.png, mods-tab-moving.png, mods-tab-moved.png, mods-tab-cheat-on.png (in ~/.cache/hookshot-work/port-mods/pr-shots/).

Menu header screenshots (the game's own frames)

Before, 6 tabs, Large, 1920x1080 (the version is hidden):

before

After, Medium and Large at 1920x1080, Small at 1280x720, and Medium with no mods:

after medium
after large
after small 720p
after no mods

hckrman101 and others added 4 commits November 11, 2026 15:13
…d Atmosphere cheats

Mods that each grow files (HD texture packs) could not be combined: only one
resource size table was used. Hookshot now merges every enabled mod's table
into the game's at start (the button prompts' entries kept) and raises the
entry of every file a mod serves to fit its size (host/mods.c, host/restbl.c,
host/zs.c). NotRexed#2270's merge of the prompts' entries into the winning mod's table
stays as the fallback when no merged table can be made.

The Insert menu gets a Mods tab (shown only when there are mods): each mod on
or off and its place in the list, kept in hookshot_mods.txt and applied at the
next start (the restart question names them), and the cheats of the enabled
mods, switched at once.

ExeFS patches (IPS, IPS32, IPSwitch .pchtxt) are applied to main's image when
the game is built (nx2/exefs_patches.py, nx2/image.py), so the code is
translated from the patched instructions; a write into the padding after the
code (a code cave) makes it code. exefs_patches.txt in the game folder lists
what a build applied; the Mods tab says when a patch waits for a build.

Atmosphere cheat-code files (separate from the Debug tab's built-in cheats)
run in a cheat VM after dmnt's (host/cheats.c), every presented frame, on
guest memory the game was given; cheats that write the game's code are
listed as not supported.

The list, order and switches, the size table's merge, the patch formats and
the cheat VM follow valentderah/totk-pc-recomp (MIT), whose notice is kept in
host/restbl.h and nx2/exefs_patches.py.

Co-authored-by: Valiantsin Dzerakh <valentderah@gmail.com>
Co-authored-by: conongnang <173552257+conongnang@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The control bridge's bridge_fill names each MenuTab and checks the count: with the Mods tab
added before Debug, its table needs "mods" there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s shown, and the tabs after it

With the Mods tab the banner's tabs came close to the build's text at its left, which shrank and then went away
(at Large it was drawn at its size or not at all; at Small and Medium not under 10 points). The banner now starts with
a small header that is always there: Hookshot's icon (assets/hookshot.png, the start-up screen's) and the build at 13
points, down from 15. The tabs are a little smaller (21 points, from 23) and stay in the middle while that leaves the
header room, else start after it, with the Q and E keys at their ends as before.

When the header and every tab still do not fit (Small at 1280x720 with all six tabs and a long source build's
version), the header gives up "Hookshot " first (the icon says it), then the git suffix past the release
("v2.1.0-rc.3..."), then a size step, and only then do the tabs get smaller. tests/test_menu_size.py checks the order
and the shortened version.

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

Copy link
Copy Markdown
Contributor

I'm prototyping the optional indexed RomFS image from #583 (tracked in #1007; no PR yet). At f654ef20, the storage boundaries to preserve are game_file, find_sizes, and the base RESTBL reads in merge_sizes: they currently inspect the loose RomFS folder directly.

I'll handle image-backed base reads/enumeration in that package and revalidate against your real mod loader. Mod ordering, switches and RESTBL merging remain in this PR. Whichever lands second will need that integration; the existing folder path should keep working. No loading-speed gain is established yet.

hckrman101 pushed a commit that referenced this pull request Nov 12, 2026
)

**What this is for:** contributors rebuilding from source should not
spend another minute drawing the same button prompts. **What is
measured:** on an Intel Core i9-13900K running Arch Linux, the finish
stage at main `c544828f` took 67.707 and 66.898 seconds; two unchanged
runs took 0.603 and 0.651 seconds, and the final head took 0.616
seconds, clearing the run-time files as well. Other work was running
throughout. **Not measured:** Windows, a packaged Builder update, a
complete compile/lift, or gameplay performance.

Refs #2636.

## Problem

`nx2/make_game.py` removes the game folder's `prompts` directory and
runs `prompt_assets.py` on every build when fontTools is available. Most
source edits do not change anything those assets depend on.

This avoids drawing unchanged assets; it does not make the drawing
itself faster. #2636's A1 plans to measure the finish stage, but its
A1–A10 items do not already propose this prompt cache. It complements
#2674 and #2675, which address unnecessary retranslation rather than
prompt generation.

## What changed

- **`nx2/make_game.py`:** source-checkout builds request incremental
prompt generation. Packaged builders still regenerate every time, using
the finish stage's existing launcher distinction. `RX_RELEASE` cannot
distinguish them: make_game sets it for source builds too. Missing
fontTools still removes the replacement prompts, keeping the existing
fallback to the game's icons.
- **`nx2/prompt_assets.py`:** fingerprint the inputs before generating,
draw into a fresh sibling directory, and record the successful outputs
only if a second fingerprint still matches. Reuse requires both matching
inputs and every recorded output's size and SHA-256. **A failed
generation still removes the prompts, as before**, so the game shows its
own icons. An interrupted builder can leave the previous complete set
without a stamp; the next build regenerates it. The drawing, encoding
and asset formats are unchanged.
- **`tests/test_prompt_skip.py`:** game-free fixtures run the actual
finish-stage prompt call and production cache/publication functions,
with small authored files and a stand-in for expensive drawing.
- **`docs/development.md`:** explain reuse, its limits, and
`HOOKSHOT_FORCE_PROMPTS=1`. The force switch follows `env_on`: unset,
empty, `0`, `false`, `off` and `no` are off, ignoring case and
surrounding spaces.

### What prevents a wrong skip

The input record covers:

- The four prompt source modules, including controller, keyboard and
binding tables.
- The game's icon font and optional bold label font, compression
dictionary, common texture/layout archives, and matching resource-size
table names and contents.
- The repository's controller and keyboard fonts, and added, changed or
removed PNG overrides.
- Python/platform; fontTools, Pillow, ASTC encoder, texture decoder,
NumPy, zstandard and archspec availability and versions; Pillow's native
library versions and the selected ASTC backend.
- Generation parameters and relevant library switches, including
`SOURCE_DATE_EPOCH`, `FREETYPE_PROPERTIES` and the encoder's custom CPU
definitions.

Languages and saved bindings are not build inputs here: the generator
uses its source tables, and the runtime applies the selected controls.

The output record includes files **and directories**: the keyboard
directory itself is a runtime marker. Legitimately absent optional
outputs stay absent; a missing, truncated or same-size-corrupted
recorded file forces regeneration. The runtime's own compressed copies
and control-specific keys are excluded before hashing, not mistaken for
generator output. **The files the game derives at run time are still
cleared by every build**, including an unchanged hit: their own keys do
not cover changes to `host/prompts.c`. Only the known runtime filenames
are removed; if one cannot be cleared, the build regenerates instead of
keeping on doubt.

An unreadable input cannot authorize reuse. The log gives the reason,
even if a forced regeneration subsequently fails. A source edited during
generation leaves no stamp, addressing the stale-input certification
problem discussed in [#2674's
review](#2674 (comment)).

## How it was tested

2026-11-12; Arch Linux, kernel `7.2.15-1-omarchy`, x86-64, Intel Core
i9-13900K, 32 logical CPUs, Python 3.12.16. Base `c544828ff`; final head
`2ec89de8`. No game or GPU program was launched for these checks.

### Finish-stage measurements

These execute the real finish-stage statements through completion,
including binary/image copies, the unchanged shader-list check,
pipeline-list work and prompt generation, in a disposable copy of an
existing built game folder. Compilation, translation and shader
preparation are not included. They are **not whole rebuild times**.

All optional prompt packages were present: fontTools 4.68.0, Pillow
13.0.0, astc-encoder-py 0.1.12, texture2ddecoder 1.0.6, archspec 0.2.7,
NumPy 2.5.4 and zstandard 0.25.0.

| Run | Source | Finish time | 1-minute load before → after | Top other
work |
|---|---|---:|---:|---|
| Before 1 | `c544828f` | 67.707 s | 7.13 → 7.40 | Existing game 316 →
315% CPU; file indexing 66 → 79%; relay check also active |
| Before 2 | `c544828f` | 66.898 s | 7.06 → 8.24 | Existing game 315 →
318%; terminal process about 41% |
| First generation with cache | `63d5c965` | 66.400 s | 8.24 → 8.19 |
Existing game 318 → 320%; terminal process about 42% |
| Unchanged 1 | `63d5c965` | 0.603 s | 8.19 → 8.19 | Existing game 320%;
terminal process about 42% |
| Unchanged 2 | `63d5c965` | 0.651 s | 8.19 → 8.19 | Existing game 320%;
terminal process about 42% |
| Changed table source | `681208dd` | 63.147 s | 6.99 → 6.95 | Existing
game 320 → 321%; terminal process about 42% |
| Regenerate, final head | `2ec89de8` | 65.007 s | 5.77 → 5.86 |
Existing game 317 → 316%; terminal process about 43% |
| Unchanged, final head, with run-time files present | `2ec89de8` |
0.616 s | 5.79 → 5.79 | Existing game 316%; terminal process about 43% |

Other work was running; none was a quiet measurement. CPU percentages
are lifetime averages from snapshots, not interval measurements. The
first generation is included to show that this is a skip, not a faster
drawing algorithm.

The unchanged runs print:

```text
prompts: unchanged, kept
```

Adding a harmless comment to the button-table source forces a fresh
generation:

```text
prompts: regenerated (table changed)
```

The comment was removed afterwards. A timestamp-only touch does not
regenerate: the key checks content. An additional run on the final head
verifies an unchanged hit with all six runtime-derived files present:
all six are removed, while the 16 generated originals and the stamp
retain their sizes and SHA-256 values. The focused fixture also compares
the retained originals byte for byte. The final timing rows above
include that cleanup.

**Asset parity:** all 16 regenerated files, 274,704,680 bytes, compare
byte for byte with a full generation by main `c544828f`. Only the new
stamp is excluded. Both sides used the same `SOURCE_DATE_EPOCH` because
fontTools otherwise writes the current time into the OpenType header.
This comparison setting is not introduced into normal builds. A
development cache hit retains the earlier font timestamp; packaged
builds retain their old full-generation behavior.

### Game-free checks

- **23 focused tests pass** on the final change. They cover unchanged
reuse, every input kind, package versions/presence, switch/parameter
changes, missing/truncated/same-size-corrupted output, empty
directories, clearing runtime derivatives while retaining byte-identical
originals, regeneration when runtime cleanup fails, interruption,
mid-generation edits, unreadable input, force-switch parsing, incomplete
output, publication failure, and the packaged/no-fontTools paths. A
non-zero generator result removes prompts for both source and packaged
builds; interruption before that result leaves the old set unstamped.
- The same unchanged-build test against main's finish stage fails
behaviorally: `2 != 1: unchanged finish must not draw the prompts
twice`.
- Removing each of **18 guards/key components** in memory makes its
corresponding behavior test fail on the final change. Three later
corrections also fail against the earlier version of this patch:
returned failure retained old prompts in both build paths, runtime files
survived a hit, and an uncleared runtime file did not force
regeneration. The unreadable-input diagnostic has a separate
before-fails/after-passes proof.
- The whole suite one commit earlier, at `f20ea58b` (before a failed
generation was made to remove the prompts again and the run-time files
to be cleared), `python -I -B tests/run_all.py --jobs 8`:

  ```text
  472 passed, 0 failed, 20 skipped in 133 s (linux, 8 at once)
  ```

Load was 7.37 → 19.48. The first full run, at 32 checks at once, was
**471 passed, 1 failed, 20 skipped**: a clang++ child compiling
`texture_pass.cpp` for `nx2/test_depth_sample_types.py` died with
SIGKILL. That fixture and its input trees are unchanged from main. No
kernel OOM line was found, so the cause is not established. The complete
run with eight workers passes, including that check. The failed run is
retained, not counted as a pass. On the final head `2ec89de8` the
focused checks, the baseline failure and all guard mutations were rerun,
and the whole suite once more with eight workers: **471 passed, 1
failed, 20 skipped in 136 s** (load 11.14 → 22.62). The one failure is
`tests/test_release_scripts.py`,
`test_nightly_two_runs_at_once_end_at_the_newer_commit`, the
intermittent race reported in #2626; this change touches none of its
files.
- No core dumps were recorded. Nothing generated from the game is in
this change.

## Not tested

- Windows filesystem behavior or a Windows build.
- The packaged Windows Builder or Linux AppImage build/update end to
end. Their always-regenerate selection is covered by a game-free test
only.
- A full compile/lift or a new gameplay run, other CPUs, or
gameplay/frame-rate benefits.

No screenshots: the regenerated asset bytes match, and no visual change
is intended.

## Risks

The input list must grow when the generator gains new file dependencies
or outputs outside the prompt directory. A regression flags new local
module imports, but it does not replace reviewing new file reads.
Generation temporarily needs room for both old and new assets.
Publication uses completed directories and rollback on a rename error;
interruption cannot create a stamped half-set, though a process killed
between the two directory renames may require regeneration on the next
build.

## Overlaps

The open-PR scan found 47 PRs touching one of these files. The actual
conflict hunks were read:

- **#2151:** adjacent item-button generation conflicts with the
finish-stage prompt call/error branch. Preserve both steps and account
for added prompt dependencies.
- **#2261:** substantive overlap. It adds `--wheel-icons`, another input
folder and output outside `prompts`. Keep that work outside this skip or
extend the input/output proof and staging; accepting both hunks
mechanically is not enough.
- #1872, #1958, #2304, #2317, #2430, #2465, #2474, #2478, #2608, #2673,
#2675 and #2686 share code files but merged cleanly in the scan. That is
not proof that any newly added generator dependency is covered.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <noreply@openai.com>

Co-authored-by: GPT-6 Astra <noreply@openai.com>
hckrman101 pushed a commit that referenced this pull request Nov 12, 2026
**What this is for:** keep controller-button prompts working when a
RomFS mod includes resource size tables for more than one game version,
following [hckrman101's review of
#2270](#2270 (review)).
**What is measured:** on Linux, GCC and Clang both pass 12 file-routing
cases and the existing merge checks, including seven malformed-table
refusals; both new open-order cases fail against main `fce53453`. **Not
measured:** a new game run, Windows file enumeration, or any change to a
normal single-table seed's gameplay.

## Problem

The reviewer noted that:

> `build()` picks the mod table with `hos_mods_find`, which returns the
first match in hash order, while `hos_mods_romfs` passes the path
actually opened.

`host/prompts.c::sizes()` merges once and remembers the source file. If
the font is requested first, the first matching mod table can belong to
another game version: the real table is subsequently served unmerged
alongside the larger font. In the opposite order, the real table is
merged, but the font's check asks about the other table and disables the
replacement font.

The other review nit was about CI visibility, not another implementation
change. At the 2026-11-11 17:13 CST check, #2270's check list still
contained only two successful Conventional Commit title checks. That is
not evidence that its GitHub regression suite ran. The current
`.github/workflows/source-regressions.yml` already runs all discovered
checks for pull requests to main; this PR needs those checks approved
and green before merge.

## What changed

- `host/prompts.c`: discover the resource-table name in the game's own
RomFS, using the same first-by-name rule as `nx2/prompt_assets.py`,
before checking a mod's table for the font. This also works when the
font is opened first.
- `host/mods.c`: replace the prefix/suffix hash-order search with an
exact, case/slash-normalized lookup of that name. It uses the same
winning indexed entry as the normal RomFS open path.
- `tests/test_mods_size_table.py`: add a second, wholly made-up table
with different entries, exercise both table-first and font-first
requests, and check both the returned files and merged bytes. `--source
<host-directory>` runs the same fixtures against unmodified main.
- `readme.md`: explain which version's table is used.

The merge algorithm, maximum-per-entry rule, malformed-table fallback,
last-sorted-mod precedence and a mod's own font priority are unchanged.
No game file or data made from the game is added.

## How to try it

No switch or rebuild of a mod is needed. A mod containing two
differently versioned size-table files should keep its matching table's
entries and the controller font, irrespective of which file is requested
first. The game-free reproduction is:

```sh
python -I -B tests/test_mods_size_table.py --cc gcc
python -I -B tests/test_mods_size_table.py --cc clang
# In this checkout, against another checkout's unmodified host sources:
python -I -B tests/test_mods_size_table.py --cc gcc --source /path/to/main/host
```

## How it was tested

2026-11-11; Arch Linux, kernel `7.2.15-1-omarchy`, Core i9-13900K; GCC
16.2.1 and Clang 23.1.3; deps Python 3.12 with `-I -B`. Base `fce53453`,
branch `fd017d3f`. The machine was heavily loaded (load average about
287 during the focused checks). No GPU was used.
Final branch: `bd4b1ec3`, one commit on current main `ef81aee7`. The
rebase from `fce53453` was clean and its range-diff is `=`. Both
compiler proofs, including the two failing open orders without the fix,
were rerun after rebase; the relevant base host-source blobs are
identical. The full-suite totals below are the recorded `fce53453`-based
run, not a second full suite on the newer base.
The focused runs after the rebase had load snapshots of 71.63 to 75.04.

Both compilers print:

```text
ok: merge_restbl: the larger entry of each file, entries of one side kept, sorted; 7 unmergeable tables refused
ok: 12 cases served as they should: no mod, a mod with a table, two table versions in both open orders, one that grows a file without, two mods, a table that cannot be merged, no table of ours, a mod's own font, compressed and size-less frames
```

Against the base, **both compilers fail both new cases**, rather than a
source-text or compilation check:

| Request order | Main's returned font | Main's returned actual table |
Branch |
|---|---|---|---|
| Table, then font | The game's font; replacement disabled | Merged with
the correct table | Correct merged table and controller font |
| Font, then table | Controller font | The unmerged mod table; the other
version seeded the merge | Correct merged table and controller font |

The normal single-table cases still compare all merged bytes to the
independent expected table. No new game run was made: the ordinary
seed's merge and resource sizes are unchanged, and the changed
source-selection case is fully made up in the fixture. This does not
claim a new gameplay result.

The whole `tests/run_all.py` suite ran once, in the idle scheduling
class:

```text
449 passed, 1 failed, 20 skipped in 288 s (linux, 32 at once)
```

Load was 111.79 at its start and 92.91 at its end. The only failure is
the unrelated main regression `test_env_on.py`: `HOS_FPS_LOG
(host/oot_high_fps.c:15)` still uses `getenv(...) != NULL` after #2470.
That source and the guard test are unchanged here.
`test_updater_asset_digest.py` **passed** via this machine's fallback;
it is not counted as a failure. `coredumpctl` reported `No coredumps
found.`
The same guard was then run against the untouched `fce53453` checkout
with GCC and Clang; both fail on that exact `HOS_FPS_LOG` line. This is
the only remaining suite failure for B, not a mod-table regression.

Before pushing: one commit on current main, the commit identity and
trailers as the project asks, and no game file or build output among the
four changed files. Rerun on the pushed head by me: the test with gcc
and with clang passes, and against main's `host/` sources it fails with
both.

## Not tested

- Windows runtime enumeration and a Windows game build.
- A real mod containing multiple versions' tables, long play, or another
game update's layout.
- A game RomFS with ambiguous multiple applicable tables. Selection
intentionally matches the prompt-assets builder; this does not invent a
new version-selection policy.
- GitHub CI for this not-yet-opened follow-up; the local results above
do not stand in for it.

## Risks

The font's initial preparation now enumerates the game's resource
directory once per preparation rather than taking the first matching mod
entry. It relies on `hos_romfs_dir` being the game's RomFS.
Unsupported/ambiguous base layouts need a separate version-selection
decision; ordinary supported single-table layouts and the merge's bounds
are unchanged.

## Overlaps

The final standalone overlap scan found **46 open PRs** touching these
files; the actual conflict hunks were read:

- [#2151](#2151): its
punctuation helper is inserted beside `game_table`; retain both helpers.
- [#2317](#2317): the adjacent
font-output comment changes to a cache path, plus the fixture's
`string.h` include. Keep its path changes and this exact-table
selection.
- [#2478](#2478), draft:
substantive overlap. Its all-mod-table merger changes the old lookup's
fallback semantics, the README and fixture sources. The exact-key
selection must be carried into that resolver, including its
merged-table/fallback behavior; do not resolve this by blindly keeping
one side.
- [#2561](#2561): the README's
large player/developer sections are replaced by documentation links.
Move this table-selection explanation into its new mod documentation
instead of retaining the old README section wholesale.
- #2304, #2430, #2435 and #2583 touch the same code files but merged
cleanly in the scan.

No separate claim issue: this is the review follow-up to #2270.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <noreply@openai.com>

Co-authored-by: GPT-6 Astra <noreply@openai.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