Add keyboard and mouse button prompts, drawn from the art's keys.

The Button prompts row gains Keyboard and mouse (prompts = pc). tools/pad_prompts.py --style pc writes
romfs-patches-pc: every button glyph shows the first key its bindings give it (Ocarina's defaults, with the bind_
lines of the install's nx2navi.ini over them), the mouse buttons as a mouse, the d-pad and the left stick as their
keys laid out as on the keyboard (1 to 4, W A S D), and the camera as the mouse moving. The action prompt shows the
key's label, the ocarina's notes the light keys. A key the art has no picture of is written on a blank key. The pad
styles draw the same bytes as before.

The prompts description says so in each menu language, and the Chinese one no longer splits words across its lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-09 03:56:30 -05:00
parent ef08fed45f
commit 9e56d5a316
6 changed files with 253 additions and 91 deletions

View File

@@ -23,21 +23,25 @@ letters unless *Button prompts* says otherwise (below).
## Button prompts
`NX2NAVI_PROMPTS` (`prompts`, the *Button prompts* row on the Controls tab) picks the controller whose buttons the game's prompts show: `nintendo` (default, the game's own), `xbox` or `playstation`. It changes three things the game draws:
`NX2NAVI_PROMPTS` (`prompts`, the *Button prompts* row on the Controls tab) picks the buttons the game's prompts show: `nintendo` (default, the game's own), `xbox`, `playstation` or `pc` (*Keyboard and mouse*). It changes three things the game draws:
- the button font every prompt in text uses (dialogue, the button guides, the controls screen): A B X Y, L R ZL ZR, Plus and Minus, the d-pad and the sticks with their directions become that pad's buttons (LB RB LT RT, Menu and View; L1 R1 L2 R2, Options and Create; its d-pad and sticks). Power, Home and the console stay;
- the button font every prompt in text uses (dialogue, the button guides, the controls screen): A B X Y, L R ZL ZR, Plus and Minus, the d-pad and the sticks with their directions become that pad's buttons (LB RB LT RT, Menu and View; L1 R1 L2 R2, Options and Create; its d-pad and sticks), or the keys. Power, Home and the console stay;
- the letter in the action prompt (top right);
- the ocarina's note buttons.
A, B, X and Y show the button the *Button layout* presses: with `nintendo` (by position) Switch A is the right face button, so its prompts show B or circle, B shows A or cross, X shows Y or triangle and Y shows X or square; with `xbox`, `playstation` and `automatic` (by label) A shows A or cross, B shows B or circle, X shows X or square and Y shows Y or triangle. The game reads the font and the interface textures once at start, so a change shows after a restart (the row says so; the menu's Restart button restarts). A button remapped on this tab keeps its old prompt.
A, B, X and Y show the button the *Button layout* presses: with `nintendo` (by position) Switch A is the right face button, so its prompts show B or circle, B shows A or cross, X shows Y or triangle and Y shows X or square; with `xbox`, `playstation` and `automatic` (by label) A shows A or cross, B shows B or circle, X shows X or square and Y shows Y or triangle.
The prompts live in four overlay folders next to the exe, `romfs-patches-xbox`, `romfs-patches-xbox-label`, `romfs-patches-playstation` and `romfs-patches-playstation-label` (by position and by label), which `runtime/romfs.c` loads in place of the game's files. `tools/pad_prompts.py` draws them on the player's machine from the player's own RomFS and a folder of button art, which the repository does not hold:
`pc` shows the first key each Switch button has (the table above, or the `bind_` lines of `nx2navi.ini` when the tool runs): E, Ctrl, Space, the mouse with its left button for Y, Q, R, the mouse with its right button for ZL, F, Tab, M, V and C for the stick clicks, 1 to 4 for the d-pad, and W A S D for moving, the directions the prompt means set out as on the keyboard. The camera's prompts show the mouse moving. It follows no *Button layout*.
The game reads the font and the interface textures once at start, so a change shows after a restart (the row says so; the menu's Restart button restarts). A button or key remapped on this tab keeps its old prompt until the folders are made again.
The prompts live in overlay folders next to the exe, `romfs-patches-xbox`, `romfs-patches-xbox-label`, `romfs-patches-playstation` and `romfs-patches-playstation-label` (by position and by label) and `romfs-patches-pc`, which `runtime/romfs.c` loads in place of the game's files. `tools/pad_prompts.py` draws them on the player's machine from the player's own RomFS and a folder of button art, which the repository does not hold:
```
python tools\pad_prompts.py --art <art pack>\Source --game <install folder>
```
The art folder holds `XGamepad/Default`, `XGamepad/Light`, `P5Gamepad/Default` and `P5Gamepad/Light` with the file names of `STYLES` in the tool. Without the folders the row says *prompt files missing* and the Switch prompts stay; so they do when a RomFS mod replaces one of the same files (*off: a RomFS mod replaces their files*). The log has a `RomFS overlay: ... button prompts` line for the folder in use. The tool needs fontTools, Pillow and zstandard (`tools/requirements-py314.txt`).
`--style xbox|playstation|pc` makes one style; the keys come from `<install folder>\nx2navi.ini` (`--binds` names another). The art folder holds `XGamepad/Default`, `XGamepad/Light`, `P5Gamepad/Default`, `P5Gamepad/Light`, `Keyboard_Mouse/Dark` and `Keyboard_Mouse/White` with the file names of `STYLES` and `KEY_ART` in the tool; a key it has no picture of is written on `Keyboard_Mouse/Blanks`. Without the folders the row says *prompt files missing* and the Switch prompts stay; so they do when a RomFS mod replaces one of the same files (*off: a RomFS mod replaces their files*). The log has a `RomFS overlay: ... button prompts` line for the folder in use. The tool needs fontTools, Pillow and zstandard (`tools/requirements-py314.txt`).
The actions are the game's own, as its *Basic Controls* screen (Plus menu, System) names them:

View File

@@ -345,8 +345,8 @@
# pad_layout = nintendo
# The buttons the game's prompts show: nintendo (the game's own), xbox (LB RB LT RT, Menu, View) or playstation (L1 R1
# L2 R2, Options, Create), with A B X Y as the button pad_layout presses is labelled: with nintendo, A shows as B or
# circle and X as Y or triangle. Read at start; their files come from tools/pad_prompts.py (docs/controls.md, "Button
# prompts"). pc, the keys and mouse buttons, has no files for this game.
# circle and X as Y or triangle. pc: the key each button has (the bind_ lines below when the files are made), WASD
# and the mouse. Read at start; their files come from tools/pad_prompts.py (docs/controls.md, "Button prompts").
# prompts = nintendo
# Gamepad stick dead zone in percent of full deflection (0 to 50). Raise it for a stick that drifts: inside the circle
# the stick reads centred, and the rest of its travel is scaled to start there.

View File

@@ -898,7 +898,6 @@ static void overlay_init(void) {
"(tools/make_romfs_patches.py --prompts pc --aspect)"
: "the game resolution takes its own UI layout")
: prompts_off == ROMFS_PROMPTS_PATCHES ? "NX2NAVI_ROMFS_PATCHES is set"
: pick == PROMPTS_PC ? "their folder has no manifest.tsv (run the setup again)"
: "their folder has no manifest.tsv (tools/pad_prompts.py writes it)");
}
size_t prompts_end = noverlay;

View File

@@ -1089,12 +1089,12 @@ static void tab_controls(float scale) {
static const UiTextId layout[] = {UI_MENU_PAD_LAYOUT_AUTO, UI_MENU_PAD_LAYOUT_NINTENDO, UI_MENU_PAD_LAYOUT_XBOX,
UI_MENU_PAD_LAYOUT_PLAYSTATION};
opt_choice("NX2NAVI_PAD_LAYOUT", UI_MENU_PAD_LAYOUT, layout, 4, UI_MENU_PAD_LAYOUT_DESC, pad_layout_note());
// The first three values of the setting (cfg.c): pc stays readable in nx2navi.ini, but nothing makes its files for
// this game, so the row leaves it out. The game reads its prompts at start: the row's note says when a restart
// would bring others, and the bar's Restart button does it.
static const UiTextId prompts[] = {UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX, UI_MENU_PROMPTS_PLAYSTATION};
// The setting's values in their cfg.c order (nintendo, xbox, playstation, pc). The game reads its prompts at start:
// the row's note says when a restart would bring others, and the bar's Restart button does it.
static const UiTextId prompts[] = {UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX, UI_MENU_PROMPTS_PLAYSTATION,
UI_MENU_PROMPTS_PC};
bool restart;
opt_choice("NX2NAVI_PROMPTS", UI_MENU_PROMPTS, prompts, 3, UI_MENU_PROMPTS_DESC, prompts_note(&restart));
opt_choice("NX2NAVI_PROMPTS", UI_MENU_PROMPTS, prompts, 4, UI_MENU_PROMPTS_DESC, prompts_note(&restart));
opt_toggle("NX2NAVI_MOTION", UI_MENU_MOTION, UI_MENU_MOTION_DESC);
opt_slider("NX2NAVI_RUMBLE_STRENGTH", UI_MENU_RUMBLE_STRENGTH, 0, 300, UI_MENU_RUMBLE_STRENGTH_DESC, 10, "%.0f %%");
opt_slider("NX2NAVI_RUMBLE_CURVE", UI_MENU_RUMBLE_CURVE, 0.2f, 1.5f, UI_MENU_RUMBLE_CURVE_DESC, 0.05f, "%.2f");

View File

@@ -251,16 +251,18 @@ static const char *const text_en[UI_TEXT_COUNT] = {
[UI_MENU_PAD_LAYOUT_XBOX] = "Xbox (A bottom)", [UI_MENU_PAD_LAYOUT_PLAYSTATION] = "PlayStation (A bottom)",
[UI_MENU_PAD_LAYOUT_IN_USE] = "in use: %s",
[UI_MENU_PROMPTS] = "Button prompts",
[UI_MENU_PROMPTS_DESC] = "Which controller's buttons the game's prompts show, in text, in the action prompt and on "
"the ocarina's notes. Nintendo: the game's own. Xbox and PlayStation: that pad's buttons "
"(LB, RB, LT, RT, Menu, View; L1, R1, L2, R2, Options, Create), and A, B, X and Y as the "
[UI_MENU_PROMPTS_DESC] = "Which buttons the game's prompts show, in text, in the action prompt and on the "
"ocarina's notes. Nintendo: the game's own. Xbox and PlayStation: that pad's buttons (LB, "
"RB, LT, RT, Menu, View; L1, R1, L2, R2, Options, Create), and A, B, X and Y as the "
"button the layout above presses (with Nintendo (A right) the right button does A, so the "
"prompts for A show B, or circle). The prompt files are made from button art with "
"tools/pad_prompts.py. The game reads its prompts at start, so they change after a "
"restart. They are part of the game's files, so a button you remap in Controls keeps its "
"old prompt on screen.",
"prompts for A show B, or circle). Keyboard and mouse: the key each button has (E, Ctrl, "
"Space, left click...), WASD for moving and the mouse for the camera. The prompt files "
"are made from button art with tools/pad_prompts.py, the keys as nx2navi.ini binds them "
"when it runs. The game reads its prompts at start, so they change after a restart. They "
"are part of the game's files, so a button you remap in Controls keeps its old prompt on "
"screen until the files are made again.",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation", [UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation", [UI_MENU_PROMPTS_PC] = "Keyboard and mouse",
[UI_MENU_PROMPTS_MISSING] = "prompt files missing: tools/pad_prompts.py makes them",
[UI_MENU_PROMPTS_FAILED] = "prompt files damaged: make them again with tools/pad_prompts.py",
[UI_MENU_PROMPTS_RESTART] = "prompts change after restart",
@@ -748,16 +750,18 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
[UI_MENU_PAD_LAYOUT_XBOX] = "Xbox (A снизу)", [UI_MENU_PAD_LAYOUT_PLAYSTATION] = "PlayStation (A снизу)",
[UI_MENU_PAD_LAYOUT_IN_USE] = "сейчас: %s",
[UI_MENU_PROMPTS] = "Подсказки кнопок",
[UI_MENU_PROMPTS_DESC] = "Кнопки какого контроллера показывают подсказки игры: в тексте, в подсказке действия и на "
"нотах окарины. Nintendo: как в самой игре. Xbox и PlayStation: кнопки этого контроллера "
"(LB, RB, LT, RT, Menu, View; L1, R1, L2, R2, Options, Create), а A, B, X и Y показаны "
"той кнопкой, которую нажимает раскладка выше (в Nintendo (A справа) A делает правая "
"кнопка, и подсказки A показывают B или круг). Файлы подсказок создаются из картинок "
"кнопок программой tools/pad_prompts.py. Игра читает подсказки при запуске, так что они "
"меняются после перезапуска. Подсказки записаны в файлах игры, поэтому у кнопки, "
"переназначенной на вкладке управления, на экране остаётся прежняя подсказка.",
[UI_MENU_PROMPTS_DESC] = "Какие кнопки показывают подсказки игры: в тексте, в подсказке действия и на нотах "
"окарины. Nintendo: как в самой игре. Xbox и PlayStation: кнопки этого контроллера (LB, "
"RB, LT, RT, Menu, View; L1, R1, L2, R2, Options, Create), а A, B, X и Y показаны той "
"кнопкой, которую нажимает раскладка выше (в Nintendo (A справа) A делает правая кнопка, "
"и подсказки A показывают B или круг). Клавиатура и мышь: клавиша каждой кнопки (E, Ctrl, "
"пробел, левый клик...), WASD для движения и мышь для камеры. Файлы подсказок создаются "
"из картинок кнопок программой tools/pad_prompts.py, клавиши берутся из nx2navi.ini на "
"момент её запуска. Игра читает подсказки при запуске, так что они меняются после "
"перезапуска. Подсказки записаны в файлах игры, поэтому у кнопки, переназначенной на "
"вкладке управления, на экране остаётся прежняя подсказка, пока файлы не созданы заново.",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation", [UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation", [UI_MENU_PROMPTS_PC] = "Клавиатура и мышь",
[UI_MENU_PROMPTS_MISSING] = "нет файлов подсказок: их создаёт tools/pad_prompts.py",
[UI_MENU_PROMPTS_FAILED] = "файлы подсказок повреждены: создайте их снова tools/pad_prompts.py",
[UI_MENU_PROMPTS_RESTART] = "подсказки сменятся после перезапуска",
@@ -1382,18 +1386,21 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
[UI_MENU_HDR_UI] = "Brilho do HUD e do menu",
[UI_MENU_HDR_UI_DESC] = "Brilho da interface e deste menu, em nits.",
[UI_MENU_PROMPTS] = "Ícones de botões",
[UI_MENU_PROMPTS_DESC] = "Os botões de qual controle os ícones do jogo mostram: no texto, no ícone de ação e nas "
"notas da ocarina. Nintendo: os do próprio jogo. Xbox e PlayStation: os botões desse "
"controle (LB, RB, LT, RT, Menu, Exibir; L1, R1, L2, R2, Options, Create), e A, B, X e Y "
"aparecem como o botão que o layout acima aperta (com Nintendo (A à direita) o botão da "
"direita faz A, então os ícones de A mostram B, ou círculo). Os arquivos dos ícones são "
"feitos a partir de imagens de botões com tools/pad_prompts.py. O jogo lê os ícones ao "
"iniciar, então eles mudam depois de reiniciar. Eles fazem parte dos arquivos do jogo, "
"então um botão remapeado em Controles continua com o ícone antigo na tela.",
[UI_MENU_PROMPTS_DESC] = "Quais botões os ícones do jogo mostram: no texto, no ícone de ação e nas notas da "
"ocarina. Nintendo: os do próprio jogo. Xbox e PlayStation: os botões desse controle (LB, "
"RB, LT, RT, Menu, Exibir; L1, R1, L2, R2, Options, Create), e A, B, X e Y aparecem como "
"o botão que o layout acima aperta (com Nintendo (A à direita) o botão da direita faz A, "
"então os ícones de A mostram B, ou círculo). Teclado e mouse: a tecla de cada botão (E, "
"Ctrl, Espaço, clique esquerdo...), WASD para andar e o mouse para a câmera. Os arquivos "
"dos ícones são feitos a partir de imagens de botões com tools/pad_prompts.py, com as "
"teclas como o nx2navi.ini as define quando ele roda. O jogo lê os ícones ao iniciar, "
"então eles mudam depois de reiniciar. Eles fazem parte dos arquivos do jogo, então um "
"botão remapeado em Controles continua com o ícone antigo na tela até os arquivos serem "
"feitos de novo.",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo",
[UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation",
[UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_PC] = "Teclado e mouse",
[UI_MENU_MOUSE_MODE] = "Câmera com o mouse",
[UI_MENU_MOUSE_MODE_DESC] = "Direta: a câmera gira o quanto o mouse se move, como num jogo de PC, sem zona morta nem curva do analógico (jogo 1.4.3; as outras versões usam a emulação do analógico). Emulação do analógico: o mouse age como o analógico direito.",
[UI_MENU_MOUSE_STICK] = "Emulação do analógico",
@@ -2004,15 +2011,17 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
[UI_MENU_HDR_UI] = "HUD 和菜单亮度",
[UI_MENU_HDR_UI_DESC] = "界面和本菜单的亮度(尼特)。",
[UI_MENU_PROMPTS] = "按键提示",
[UI_MENU_PROMPTS_DESC] = "游戏的按键提示显示哪种手柄的按键:包括文字、动作提示和陶笛的音符。Nintendo:游戏原有的提示。Xbox 和 Pla"
"yStation:该手柄的按键(LB、RB、LT、RT、Menu、View;L1、R1、L2、R2、Options、Cr"
"eate),A、B、X、Y 显示为上方布局所按下的按键(使用 Nintendo(A 在右)时由右侧按键执行 A,因此 A"
" 的提示显示 B 或圆圈)。提示文件由 tools/pad_prompts.py 根据按键图片生成。游戏在启动时读取按键"
"提示,因此重启后才会改变。提示属于游戏文件,在“操作”中重新映射的按键在屏幕上仍显示原来的提示。",
[UI_MENU_PROMPTS_DESC] = "游戏的按键提示显示哪种按键:包括文字、动作提示和陶笛的音符。Nintendo:游戏原有的提示。"
"Xbox 和 PlayStation:该手柄的按键(LB、RB、LT、RT、Menu、View;L1、R1、L2、R2、Options、Create),"
"A、B、X、Y 显示为上方布局所按下的按键(使用 Nintendo(A 在右)时由右侧按键执行 A,因此 A 的提示显示 B 或圆圈)。"
"键盘和鼠标:每个按键对应的键(E、Ctrl、空格、左键……),WASD 移动,鼠标控制镜头。"
"提示文件由 tools/pad_prompts.py 根据按键图片生成,键位以生成时 nx2navi.ini 中的设置为准。"
"游戏在启动时读取按键提示,因此重启后才会改变。"
"提示属于游戏文件,在“操作”中重新映射的按键在重新生成文件之前仍显示原来的提示。",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo",
[UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation",
[UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_PC] = "键盘和鼠标",
[UI_MENU_MOUSE_MODE] = "鼠标视角",
[UI_MENU_MOUSE_MODE_DESC] = "直接:镜头按鼠标移动的距离转动,与电脑游戏相同,没有摇杆死区和响应曲线(游戏 1.4.3;其他版本使用摇杆模拟)。摇杆模拟:鼠标相当于右摇杆。",
[UI_MENU_MOUSE_STICK] = "摇杆模拟",

View File

@@ -1,23 +1,29 @@
"""Xbox and PlayStation button prompts for Ocarina of Time (Switch 2), drawn from button art on the player's machine.
"""Xbox, PlayStation and keyboard-and-mouse button prompts for Ocarina of Time (Switch 2), drawn from button art on the
player's machine.
python tools/pad_prompts.py --art FOLDER --game FOLDER [--style xbox|playstation] [--layout position|label]
python tools/pad_prompts.py --art FOLDER --game FOLDER [--style xbox|playstation|pc] [--layout position|label]
[--binds nx2navi.ini]
--art is a folder of button art with the file names of STYLES below: the Source folder of a button prompt art pack,
which holds XGamepad/Default, XGamepad/Light, P5Gamepad/Default and P5Gamepad/Light (128x128 PNGs: a dark button with
a white letter or symbol, or a light button with a coloured one). The art is read where it lies and never enters the
repository.
--art is a folder of button art with the file names of STYLES and KEY_ART below: the Source folder of a button prompt
art pack, which holds XGamepad/Default, XGamepad/Light, P5Gamepad/Default, P5Gamepad/Light, Keyboard_Mouse/Dark and
Keyboard_Mouse/White (128x128 PNGs: a dark button or key with a white label, or a light one with a dark or coloured
label). The art is read where it lies and never enters the repository.
--game is the folder of the port's exe and romfs-index.bin. The RomFS is read from its romfs folder (index.json and
romfs.bin, as the setup writes them); --romfs names another, which may also be a folder of extracted files. Each
romfs.bin, as the setup writes them); --romfs names another, which may also be a folder of extracted files. Each pad
style and layout becomes the overlay folder <game>/romfs-patches-<style> (layout position) or
<game>/romfs-patches-<style>-label (layout label), with a manifest.tsv: runtime/romfs.c overlay_init loads it in place
of the game's files when nx2navi.ini has prompts = xbox or playstation (runtime/input_map.c input_prompts_folder).
Without --style or --layout, every style or layout. --out writes a single style and layout elsewhere.
<game>/romfs-patches-<style>-label (layout label), and the keyboard and mouse <game>/romfs-patches-pc, each with a
manifest.tsv: runtime/romfs.c overlay_init loads it in place of the game's files when nx2navi.ini has prompts = xbox,
playstation or pc (runtime/input_map.c input_prompts_folder). Without --style or --layout, every style or layout.
--out writes a single style and layout elsewhere.
The two layouts follow pad_layout (runtime/input_map.c). position, for pad_layout = nintendo: a pad is played by
The two pad layouts follow pad_layout (runtime/input_map.c). position, for pad_layout = nintendo: a pad is played by
position, so Switch A is the right face button, labelled B on an Xbox pad and circle on a PlayStation pad; the prompts
for A show that button (A as B or circle, B as A or cross, X as Y or triangle, Y as X or square). label, for
pad_layout = xbox and auto: Switch A is the bottom face button, so A shows A or cross, B shows B or circle, X shows X
or square and Y shows Y or triangle.
pad_layout = xbox, playstation and auto: Switch A is the bottom face button, so A shows A or cross, B shows B or
circle, X shows X or square and Y shows Y or triangle.
pc shows the first key each button has (PC_KEYS, Ocarina's defaults, with the bind_ lines of --binds over them; by
default <game>/nx2navi.ini), the mouse buttons as a mouse, and for the right stick the mouse moving. A key bound
later shows after the tool runs again.
What changes, the rest of each file staying as it is:
- Font2/Scalable/External.Nin_Ounce_NVN2.bfotf.zs, the button font every prompt in text uses (dialogue, the button
@@ -44,7 +50,7 @@ import sys
from pathlib import Path
import zstandard
from PIL import Image, ImageDraw
from PIL import Image, ImageDraw, ImageFont
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont
@@ -123,6 +129,92 @@ NOTES = {'OcarinaButtonA_00': 'A', 'OcarinaButtonB_00': 'B', 'OcarinaButtonX_00'
'OcarinaButtonL_00': 'L', 'OcarinaButtonR_00': 'R'}
def pad_pictures(style, layout):
"""(glyph, note) for a pad style: Switch button -> picture, the face buttons through FACE."""
s = STYLES[style]
glyph = {b: s['glyph'][FACE[layout].get(b, b)] for b in set(GLYPHS.values())}
note = {b: s['note'][FACE[layout].get(b, b)] for b in NOTES.values()}
return glyph, note
# ---- keyboard and mouse (prompts = pc): every button shows the first key its bindings give it. The defaults are
# runtime/input_map.c DEFAULT_KEYS; --binds takes the bind_ lines of a nx2navi.ini over them. The right stick is the
# camera, which the mouse turns, so its prompts show the mouse moving (its arrow keys, a second source, do not show).
PC_KEYS = {'A': 'e', 'B': 'lctrl', 'X': 'space', 'Y': 'mouse_l', 'L': 'q', 'R': 'r', 'ZL': 'mouse_r', 'ZR': 'f',
'PLUS': 'tab', 'MINUS': 'm', 'LEFT': '1', 'UP': '2', 'RIGHT': '3', 'DOWN': '4', 'LCLICK': 'v', 'RCLICK': 'c',
'LSTICK_UP': 'w', 'LSTICK_LEFT': 'a', 'LSTICK_DOWN': 's', 'LSTICK_RIGHT': 'd'}
# The art's name of each key (nx2navi.ini names, runtime/input_map.c key_names); letters, digits and F keys are their
# own. The art spells Ctrl as Crtl.
KEY_ART = {'space': 'Space', 'tab': 'Tab', 'lshift': 'Shift', 'rshift': 'Shift', 'lctrl': 'Crtl', 'rctrl': 'Crtl',
'lalt': 'Alt', 'up': 'Up', 'down': 'Down', 'left': 'Left', 'right': 'Right', 'enter': 'Enter',
'backspace': 'BackSpace', 'capslock': 'CapsLock', 'insert': 'Ins', 'delete': 'Del', 'home': 'Home',
'end': 'End', 'pageup': 'PageUp', 'pagedown': 'PageDown', 'minus': 'Minus', 'num_sub': 'Minus',
'num_add': 'Plus', 'num_mul': 'Asterisk', 'num_div': 'Slash', 'lbracket': 'Brackets_L',
'rbracket': 'Brackets_R', 'semicolon': 'Semicolon', 'apostrophe': 'Quotation', 'slash': 'Slash',
'grave': 'Tilde', 'mouse_l': 'Mouse_Left', 'mouse_r': 'Mouse_Right', 'mouse_m': 'Mouse_Middle'}
# What a blank key shows for a key the art has no picture of (label_art); any other shows its name.
KEY_LABELS = {'comma': ',', 'period': '.', 'equals': '=', 'num_decimal': '.', 'backslash': '\\', 'mouse_x1': 'M4',
'mouse_x2': 'M5'}
KM = 'Keyboard_Mouse/'
def pc_keys(ini_text=None):
"""PC_KEYS with the first key of each bind_<button> line of a nx2navi.ini text over it. A line without a key
entry, or with key:default first, keeps the default (the prompt then shows a key the line took away)."""
keys = dict(PC_KEYS)
for line in (ini_text or '').splitlines():
line = line.split('#')[0].split(';')[0].strip()
if not line.lower().startswith('bind_') or '=' not in line:
continue
button, value = (s.strip() for s in line.split('=', 1))
entries = [e.strip()[4:].strip().lower() for e in value.split(',') if e.strip().lower().startswith('key:')]
if entries and entries[0] != 'default' and button[5:].upper() in keys:
keys[button[5:].upper()] = entries[0]
return keys
def key_picture(art, key, shade):
"""The art of a key or mouse button in shade (Dark: a dark key with a white label; White: the other way round), or
a blank key with its name drawn on it when the art has none. The art names its dark 4 key T_3_Key_Dark-1."""
stem = KEY_ART.get(key)
if not stem:
digit = key[3:] if key.startswith('num') and key[3:].isdigit() else key
stem = digit.upper() if len(digit) == 1 or (digit[0] == 'f' and digit[1:].isdigit()) else None
for name in ([f'T_{stem}_Key_{shade}.png'] + ([f'T_3_Key_{shade}-1.png'] if stem == '4' else [])) if stem else []:
if (art / KM / shade / name).is_file():
return f'{KM}{shade}/{name}'
return ('label', key, shade)
def pc_pictures(art, keys):
"""(glyph, note) for the keyboard and mouse: Switch button -> picture, by the key each button has. A d-pad or a
stick with several directions shows their keys in a grid, as they sit on the keyboard."""
def k(button, shade='Dark'):
return key_picture(art, keys[button], shade)
def grid(*cells):
return ('grid', tuple(cells))
def mouse(axes):
return f'{KM}Dark/T_Mouse_{axes}_Key_Dark.png'
glyph = {b: k(b) for b in ('A', 'B', 'X', 'Y', 'L', 'R', 'ZL', 'ZR', 'PLUS', 'MINUS')}
glyph.update({
'LSTICK_PRESS': k('LCLICK'), 'RSTICK_PRESS': k('RCLICK'),
'DPAD': grid((k('UP'), 1, 0), (k('LEFT'), 0, 1), (k('DOWN'), 1, 1), (k('RIGHT'), 2, 1)),
'DPAD_UP': k('UP'), 'DPAD_DOWN': k('DOWN'), 'DPAD_LEFT': k('LEFT'), 'DPAD_RIGHT': k('RIGHT'),
'DPAD_UPDOWN': grid((k('UP'), 0, 0), (k('DOWN'), 0, 1)), 'DPAD_LEFTRIGHT': grid((k('LEFT'), 0, 0), (k('RIGHT'), 1, 0)),
'LSTICK': grid((k('LSTICK_UP'), 1, 0), (k('LSTICK_LEFT'), 0, 1), (k('LSTICK_DOWN'), 1, 1),
(k('LSTICK_RIGHT'), 2, 1)),
'LSTICK_UP': k('LSTICK_UP'), 'LSTICK_DOWN': k('LSTICK_DOWN'), 'LSTICK_LEFT': k('LSTICK_LEFT'),
'LSTICK_RIGHT': k('LSTICK_RIGHT'), 'LSTICK_UPDOWN': grid((k('LSTICK_UP'), 0, 0), (k('LSTICK_DOWN'), 0, 1)),
'LSTICK_LEFTRIGHT': grid((k('LSTICK_LEFT'), 0, 0), (k('LSTICK_RIGHT'), 1, 0)),
'RSTICK': mouse('XY'), 'RSTICK_ROTATE': mouse('XY'), 'RSTICK_UP': mouse('Y'), 'RSTICK_DOWN': mouse('Y'),
'RSTICK_UPDOWN': mouse('Y'), 'RSTICK_LEFT': mouse('X'), 'RSTICK_RIGHT': mouse('X'), 'RSTICK_LEFTRIGHT': mouse('X'),
})
note = {b: k(b, 'White') for b in NOTES.values()}
return glyph, note
# ---- the RomFS: index.json and romfs.bin (each file's offset and size), or a folder of extracted files
class Romfs:
def __init__(self, root):
@@ -189,14 +281,53 @@ def compress(contents, dictionary, level):
# ---- art: a button as coverage, traced into outlines
def load_art(art, rel):
"""A picture as RGBA: a file under art, or one drawn from several (OPTIONS, ('grid', cells), ('label', key, shade))."""
if rel == OPTIONS:
return options_art(art)
if isinstance(rel, tuple) and rel[0] == 'grid':
return grid_art(art, rel[1])
if isinstance(rel, tuple) and rel[0] == 'label':
return label_art(art, *rel[1:])
path = art / rel
if not path.is_file():
sys.exit(f'{path} missing: --art is the art pack\'s Source folder')
return Image.open(path).convert('RGBA')
def opaque(img):
return img.crop(img.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox())
def grid_art(art, cells):
"""Keys side by side: cells are (picture, column, row), row 0 on top, each centred in a cell as large as the
largest key, with a small gap between cells."""
pics = [(opaque(load_art(art, rel)), c, r) for rel, c, r in cells]
cw, ch, gap, pad = max(p.width for p, _, _ in pics), max(p.height for p, _, _ in pics), 6, 4
cols, rows = max(c for _, c, _ in pics) + 1, max(r for _, _, r in pics) + 1
canvas = Image.new('RGBA', (cols * cw + (cols - 1) * gap + 2 * pad, rows * ch + (rows - 1) * gap + 2 * pad))
for p, c, r in pics:
canvas.alpha_composite(p, (pad + c * (cw + gap) + (cw - p.width) // 2, pad + r * (ch + gap) + (ch - p.height) // 2))
return canvas
def label_art(art, key, shade):
"""A key the art has no picture of: its blank key with the key's name written on it."""
blank = load_art(art, f'{KM}Blanks/T_Blank_{"Black" if shade == "Dark" else "White"}_Normal.png')
name = KEY_LABELS.get(key, key.upper())
d = ImageDraw.Draw(blank)
x0, y0, x1, y1 = blank.getchannel('A').point(lambda a: 255 if a > 127 else 0).getbbox()
size = 48
while size > 10:
font = ImageFont.load_default(size)
l, t, r, b = d.textbbox((0, 0), name, font=font)
if r - l <= (x1 - x0) * 0.7 and b - t <= (y1 - y0) * 0.5:
break
size -= 2
d.text(((x0 + x1 - (r - l)) / 2 - l, (y0 + y1 - (b - t)) / 2 - t), name, font=font,
fill=(255, 255, 255, 255) if shade == 'Dark' else (40, 40, 40, 255))
return blank
def options_art(art):
"""PlayStation's Options button as the art draws its Create button's neighbour on the pad: the dark pill of the
share picture with three white bars, drawn four times larger and scaled down for smooth edges."""
@@ -402,35 +533,37 @@ def bounds(outlines):
# ---- the button font
def draw_font(otf, style, layout, art, traced):
"""The button font with the glyphs of GLYPHS drawn from the style's art. Every picture is scaled as the face button
of the art is to the game's A (both fill their square the same way), centred in its advance and placed at the
height of the art's own square, so the arrows of a stick reach out as they do in the art. A wider picture widens
its glyph."""
def draw_font(otf, glyph, traced, lit_dpad):
"""The button font with the glyphs of GLYPHS drawn from glyph (Switch button -> picture). Every picture is scaled
as A's picture is to the game's A (both fill their square the same way), centred in its advance and placed at the
height of the art's own square, so the arrows of a stick reach out as they do in the art. A grid of keys is made
no taller than the game's A and centred on it. A wider picture widens its glyph. lit_dpad: the d-pad directions
are lit pad pictures (ink lit)."""
font = TTFont(io.BytesIO(otf))
glyphs, cmap, hmtx = font.getGlyphSet(), font.getBestCmap(), font['hmtx']
box = BoundsPen(glyphs)
glyphs[cmap[0xE040]].draw(box)
gx0, gy0, gx1, gy1 = box.bounds # the game's A: a round button
names = STYLES[style]['glyph']
ref = traced(names['south'])
rx0, ry0, rx1, ry1 = bounds(ref)
rx0, ry0, rx1, ry1 = bounds(traced(glyph['A']))
scale = (gy1 - gy0) / (ry1 - ry0)
mid_y_px, mid_y = (ry0 + ry1) / 2, (gy0 + gy1) / 2
side = gx0 # the game's A has this much room on either side
drawn = {}
for cp, button in GLYPHS.items():
key = FACE[layout][button] if button in FACE[layout] else button
outlines = traced(names[key], button.startswith('DPAD_'))
x0, _, x1, _ = bounds(outlines)
advance = max(hmtx[cmap[cp]][0], round((x1 - x0) * scale + 2 * side))
ox = advance / 2 - (x0 + x1) / 2 * scale
pic = glyph[button]
outlines = traced(pic, lit_dpad and button.startswith('DPAD_'))
x0, y0, x1, y1 = bounds(outlines)
s, cy = scale, mid_y_px
if isinstance(pic, tuple) and pic[0] == 'grid':
s, cy = min(scale, (gy1 - gy0) / (y1 - y0)), (y0 + y1) / 2
advance = max(hmtx[cmap[cp]][0], round((x1 - x0) * s + 2 * side))
ox = advance / 2 - (x0 + x1) / 2 * s
def draw(pen, outlines=outlines, ox=ox):
def draw(pen, outlines=outlines, ox=ox, s=s, cy=cy):
for o in outlines:
pen.moveTo((ox + o[0][0] * scale, mid_y - (o[0][1] - mid_y_px) * scale))
pen.moveTo((ox + o[0][0] * s, mid_y - (o[0][1] - cy) * s))
for x, y in o[1:]:
pen.lineTo((ox + x * scale, mid_y - (y - mid_y_px) * scale))
pen.lineTo((ox + x * s, mid_y - (y - cy) * s))
pen.closePath()
drawn[cp] = (advance, draw)
return button_icons.redraw_font(otf, drawn)
@@ -623,25 +756,35 @@ def bleed(img):
return img
def draw_textures(data, style, layout, art, cache):
"""Paints the action prompt's letters and the ocarina's note buttons of the style into the BNTX data (a bytearray)
in place. A new letter takes the height and centre of the game's own letter in that mask (a wider symbol may use a
little more width); a note button fills NOTE_BOXES."""
def mask_of(img, rel):
"""The action prompt's mask of a dark picture: the white letter or symbol inside it, or for a mouse, whose white is
the pressed button rather than a label, the mouse itself with that button cut out."""
if '/T_Mouse_' not in str(rel):
return symbol(img)
field = ink(img)
out = Image.new('L', img.size)
out.putdata([round(255 * min(1.0, v)) for row in field for v in row])
return out
def draw_textures(data, glyph, note, art, cache):
"""Paints the action prompt's letters (from glyph) and the ocarina's note buttons (from note; both Switch button ->
picture) into the BNTX data (a bytearray) in place. A new letter takes the height and centre of the game's own
letter in that mask (a wider one may use a little more width); a note button fills NOTE_BOXES."""
textures = bntx_textures(data)
names = STYLES[style]
for name, button in {**MASKS, **NOTES}.items():
assert name in textures, f'no {name} in {TEXTURES}'
t = textures[name]
assert t['mips'] == 1, f'{name}: {t["mips"]} levels'
wb, hb = (t['w'] + 3) // 4, (t['h'] + 3) // 4
key = FACE[layout].get(button, button)
if name in MASKS:
assert t['fmt'] == BC4_UNORM, f'{name}: format {t["fmt"]:#x}'
old = bc4_decode(read_level(data, t, wb * 8, hb), t['w'], t['h'])
box = old.point(lambda v: 255 if v > 64 else 0).getbbox()
# the old letter's height; a wider new symbol may use a little more width
cx, cy, half = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2, (box[3] - box[1]) / 2
sym = cache(('symbol', names['glyph'][key]), lambda: symbol(load_art(art, names['glyph'][key])))
rel = glyph[button]
sym = cache(('mask', rel), lambda: mask_of(load_art(art, rel), rel))
rgba = Image.merge('RGBA', (sym.point(lambda v: 255),) * 3 + (sym,))
mask = fit(rgba, (cx - half * 1.15, cy - half, cx + half * 1.15, cy + half), (t['w'], t['h']))
write_level(data, t, bc4_encode(mask.getchannel('A')), wb * 8, hb)
@@ -649,7 +792,7 @@ def draw_textures(data, style, layout, art, cache):
assert t['fmt'] == ASTC_4X4_SRGB and (t['w'], t['h']) == (93, 93), f'{name}: format {t["fmt"]:#x}, ' \
f'{t["w"]}x{t["h"]}'
box = NOTE_BOXES['shoulder' if button in 'LR' else 'disc']
pic = fit(load_art(art, names['note'][key]), box, (t['w'], t['h']))
pic = fit(load_art(art, note[button]), box, (t['w'], t['h']))
write_level(data, t, astc_encode(bleed(pic)), wb * 16, hb)
@@ -676,18 +819,23 @@ def write_overlay(out, outputs, originals):
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--art', type=Path, required=True, help='the art pack\'s Source folder (XGamepad, P5Gamepad)')
ap.add_argument('--art', type=Path, required=True,
help='the art pack\'s Source folder (XGamepad, P5Gamepad, Keyboard_Mouse)')
ap.add_argument('--game', type=Path, help='the folder of the exe and romfs-index.bin; the overlays go there')
ap.add_argument('--romfs', type=Path, help='default <game>/romfs')
ap.add_argument('--style', choices=sorted(STYLES), help='default: both')
ap.add_argument('--layout', choices=sorted(FACE), help='default: both')
ap.add_argument('--style', choices=sorted(STYLES) + ['pc'], help='default: every one')
ap.add_argument('--layout', choices=sorted(FACE), help='default: both (pc has one)')
ap.add_argument('--binds', type=Path, help='pc: a nx2navi.ini whose bind_ lines the keys follow '
'(default <game>/nx2navi.ini when there is one)')
ap.add_argument('--out', type=Path, help='the overlay folder, for one --style and --layout')
ap.add_argument('--level', type=int, default=9, help='zstd level of the written files')
args = ap.parse_args()
if not args.game and not (args.out and args.romfs):
ap.error('--game, or --out with --romfs')
if args.out and not (args.style and args.layout):
if args.out and not (args.style and (args.layout or args.style == 'pc')):
ap.error('--out writes one folder: give --style and --layout')
binds = args.binds or (args.game / 'nx2navi.ini' if args.game and (args.game / 'nx2navi.ini').is_file() else None)
keys = pc_keys(binds.read_text(encoding='utf-8', errors='replace') if binds else None)
romfs = Romfs(args.romfs or args.game / 'romfs')
restbl = max((p for p in romfs.paths() if p.startswith('System/Resource/ResourceSizeTable.') and p.endswith('.zs')),
key=lambda p: int(p.split('.')[2]) if p.split('.')[2].isdigit() else 0, default=None)
@@ -708,13 +856,15 @@ def main():
return cache(('outlines', rel, lit), lambda: outlines_of(load_art(args.art, rel), lit))
report = []
for style in [args.style] if args.style else sorted(STYLES):
for layout in [args.layout] if args.layout else sorted(FACE):
for style in [args.style] if args.style else sorted(STYLES) + ['pc']:
# the keyboard follows no pad layout: one folder, romfs-patches-pc
for layout in [None] if style == 'pc' else [args.layout] if args.layout else sorted(FACE):
out = args.out or args.game / f'romfs-patches-{style}{"-label" if layout == "label" else ""}'
new_otf = draw_font(otf, style, layout, args.art, traced)
glyph, note = pc_pictures(args.art, keys) if style == 'pc' else pad_pictures(style, layout)
new_otf = draw_font(otf, glyph, traced, style != 'pc')
blob = button_icons.bfotf_encode(new_otf, key, font_blob)
data = bytearray(textures)
draw_textures(data, style, layout, args.art, cache)
draw_textures(data, glyph, note, args.art, cache)
assert len(data) == len(textures)
outputs = {FONT: compress(blob, font_dict, 19), TEXTURES: compress(bytes(data), textures_dict, args.level)}
if len(blob) > len(font_blob):