Add Xbox and PlayStation button prompts, drawn from button art on the player's machine.

The Controls tab has a Button prompts row again, with Nintendo, Xbox and PlayStation. prompts = playstation is a new
value next to xbox; nintendo, xbox and pc read as before, and pc stays out of the row as nothing makes its files for
this game. The game reads its prompts at start, so the row says when a restart would change them.

tools/pad_prompts.py writes the overlay folders romfs-patches-xbox, -xbox-label, -playstation and -playstation-label
from the player's RomFS and a folder of button art that stays outside the repository. The button font's glyphs become
outlines traced from the art, the action prompt's letters and the ocarina's note buttons are painted again in their
own BC4 and ASTC formats, and the font's entry in the resource size table grows with it. The by-position folders show
Switch A as B or circle, the by-label ones as A or cross, following the pad layout. The settings probe checks the new
value and folder names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-08 23:18:26 -05:00
parent d8c26855ea
commit 612c02311d
11 changed files with 858 additions and 72 deletions

View File

@@ -18,7 +18,25 @@ amplitude before the strength (0.2 to 1.5, default 0.6; lower makes weak pulses
The log names the layout in effect (`input: gamepad layout ...`) and each gamepad's first press
(`input: gamepad "...": first press`); the menu shows the layout under *Automatic*. The game's prompts show the Switch
letters.
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:
- 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 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` 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.
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:
```
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`).
The actions are the game's own, as its *Basic Controls* screen (Plus menu, System) names them:

View File

@@ -291,11 +291,10 @@
# PlayStation pad) or auto (nintendo for a pad with Nintendo labels, xbox for the others). The bind_ lines below apply
# on top. Changes while the game runs take effect at once. 0.1.9's xbox_prompts is read as nintendo with prompts = xbox.
# pad_layout = nintendo
# The buttons the game's prompts show: nintendo (the game's own), xbox (LB RB LT RT, Menu, View and a d-pad cross,
# and A B X Y as labelled on the button pad_layout presses: with nintendo, A shows as B and X as Y). Read at start
# (docs/controls.md, "Xbox button prompts"). pc: the key or mouse button the keyboard defaults press each button with
# (E, Shift, Space, left click, Q, R, right click, F, Tab, M, 1 to 4, W A S D, Ctrl, C) and the mouse for the right
# stick, whatever pad_layout (docs/controls.md, "PC button prompts").
# 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.
# 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

@@ -151,7 +151,7 @@ static const CfgMeta metas[] = {
{"VOLUME", "100", "0..100", LIVE}, {"MENU_SOUND_VOLUME", "80", "0..100", LIVE}, {"AUDIO_CHANNELS", "stereo", "auto|stereo|5.1", RESTART},
{"MOTION", "1", "0|1", LIVE}, {"RUMBLE_STRENGTH", "130", "0..300", LIVE}, {"RUMBLE_CURVE", "0.6", "0.2..1.5", LIVE},
{"RUMBLE_TRIGGERS", "0", "0|1", LIVE}, {"PAD_LAYOUT", "nintendo", "auto|nintendo|xbox", LIVE},
{"PROMPTS", "nintendo", "nintendo|xbox|pc", RESTART},
{"PROMPTS", "nintendo", "nintendo|xbox|playstation|pc", RESTART},
{"STICK_DEADZONE", "8", "0..50", LIVE}, {"STICK_SENS", "1", "0.1..3", RESTART},
{"LSTICK_DEADZONE", "", "0..50", LIVE}, {"RSTICK_DEADZONE", "", "0..50", LIVE},
{"MOUSE_MODE", "direct", "stick|direct", LIVE},

View File

@@ -338,14 +338,26 @@ int probe_cfg(void) {
EQ(input_prompts_folder(PROMPTS_XBOX, "auto"), "romfs-patches-xbox-label") &&
EQ(input_prompts_folder(PROMPTS_XBOX, NULL), "romfs-patches-xbox-label"),
"prompts: the face buttons by position for the Nintendo layout, by label for xbox and auto");
// romfs-patches-xbox shows A as B (tests/romfs_prompts_test.py checks the folders' contents). It is right exactly
// where the layout puts A on the east button, which an Xbox pad labels B.
CHECK(input_prompts_pick("PlayStation", "nintendo") == PROMPTS_PLAYSTATION &&
input_prompts_pick("playstation", "xbox_prompts") == PROMPTS_PLAYSTATION &&
input_prompts_pick("ps", "nintendo") == PROMPTS_NINTENDO,
"prompts: playstation in any case; an unknown value is the game's own");
CHECK(EQ(input_prompts_folder(PROMPTS_PLAYSTATION, "nintendo"), "romfs-patches-playstation") &&
EQ(input_prompts_folder(PROMPTS_PLAYSTATION, "xbox_prompts"), "romfs-patches-playstation") &&
EQ(input_prompts_folder(PROMPTS_PLAYSTATION, "xbox"), "romfs-patches-playstation-label") &&
EQ(input_prompts_folder(PROMPTS_PLAYSTATION, "auto"), "romfs-patches-playstation-label") &&
EQ(input_prompts_folder(PROMPTS_PLAYSTATION, NULL), "romfs-patches-playstation-label"),
"prompts: the PlayStation face buttons by position for the Nintendo layout, by label for xbox and auto");
// romfs-patches-xbox shows A as B and romfs-patches-playstation A as circle (tools/pad_prompts.py FACE). That is
// right exactly where the layout puts A on the east button, which an Xbox pad labels B and a PlayStation pad circle.
input_map_reset();
static const char *const layouts[] = {"nintendo", "xbox", "auto", "xbox_prompts"};
for (int i = 0; i < 4; i++) {
int a_east = input_map_pad_buttons((uint8_t[PAD_COUNT]){[PAD_EAST] = 1}, input_pad_layout_pick(layouts[i], 0)) == 1ull << B_A;
CHECK(a_east == EQ(input_prompts_folder(PROMPTS_XBOX, layouts[i]), "romfs-patches-xbox"),
"prompts: the folder names A as B exactly when the layout puts A on the button labelled B");
CHECK(a_east == EQ(input_prompts_folder(PROMPTS_PLAYSTATION, layouts[i]), "romfs-patches-playstation"),
"prompts: the folder names A as circle exactly when the layout puts A on the circle button");
}
// The default layout is the Switch's own buttons by position, whatever the pad's labels (an Xbox pad's east button
// is labelled B and does Switch A): A right, B bottom, X top, Y left, L/R the bumpers, ZL/ZR the triggers.

View File

@@ -297,16 +297,21 @@ int input_pad_layout_pick(const char *setting, int east_labelled_a) {
int input_prompts_pick(const char *prompts, const char *pad_layout) {
if (prompts && *prompts)
return !strcasecmp(prompts, "xbox") ? PROMPTS_XBOX : !strcasecmp(prompts, "pc") ? PROMPTS_PC : PROMPTS_NINTENDO;
return !strcasecmp(prompts, "xbox") ? PROMPTS_XBOX
: !strcasecmp(prompts, "playstation") ? PROMPTS_PLAYSTATION
: !strcasecmp(prompts, "pc") ? PROMPTS_PC
: PROMPTS_NINTENDO;
return pad_layout && !strcasecmp(pad_layout, "xbox_prompts") ? PROMPTS_XBOX : PROMPTS_NINTENDO;
}
const char *input_prompts_folder(int prompts, const char *pad_layout) {
if (prompts == PROMPTS_PC) return "romfs-patches-pc";
if (prompts != PROMPTS_XBOX) return NULL;
if (prompts != PROMPTS_XBOX && prompts != PROMPTS_PLAYSTATION) return NULL;
// The folders are read at start, before a pad is open: auto counts as Xbox, as for a pad without Nintendo labels.
// A Nintendo-labelled pad under auto plays by label too (its A is on the button labelled A), so it matches as well.
return input_pad_layout_pick(pad_layout, 0) == PAD_LAYOUT_NINTENDO ? "romfs-patches-xbox" : "romfs-patches-xbox-label";
int by_position = input_pad_layout_pick(pad_layout, 0) == PAD_LAYOUT_NINTENDO;
if (prompts == PROMPTS_PLAYSTATION) return by_position ? "romfs-patches-playstation" : "romfs-patches-playstation-label";
return by_position ? "romfs-patches-xbox" : "romfs-patches-xbox-label";
}
uint64_t input_map_pad_buttons(const uint8_t down[PAD_COUNT], int layout) {

View File

@@ -87,20 +87,21 @@ void input_map_configure(void);
// pad's east button is labelled A (a Switch Pro Controller, a GuliKit) and Xbox otherwise.
int input_pad_layout_pick(const char *setting, int east_labelled_a);
// Button prompts (NX2NAVI_PROMPTS): the controller whose buttons the game's prompts show. Nintendo: the game's own. Xbox:
// LB RB LT RT, Menu, View and a d-pad cross for the other buttons, and the face buttons named by the label of the
// button the pad layout presses (runtime/romfs.c overlay_init, tools/make_romfs_patches.py --prompts). PC: the key or
// mouse button the keyboard defaults press each button with, the mouse for the right stick.
enum { PROMPTS_NINTENDO, PROMPTS_XBOX, PROMPTS_PC };
// The prompts for a NX2NAVI_PROMPTS value ("nintendo", "xbox" or "pc", any case). Unset or empty: Xbox while the pad
// layout setting is "xbox_prompts", else Nintendo.
// Button prompts (NX2NAVI_PROMPTS): the controller whose buttons the game's prompts show. Nintendo: the game's own. Xbox
// and PlayStation: that pad's buttons (LB RB LT RT, Menu and View; L1 R1 L2 R2, Options and Create), and the face buttons
// named by the label of the button the pad layout presses (runtime/romfs.c overlay_init, tools/pad_prompts.py). PC:
// the key or mouse button the keyboard defaults press each button with, the mouse for the right stick.
enum { PROMPTS_NINTENDO, PROMPTS_XBOX, PROMPTS_PC, PROMPTS_PLAYSTATION };
// The prompts for a NX2NAVI_PROMPTS value ("nintendo", "xbox", "playstation" or "pc", any case). Unset or empty: Xbox
// while the pad layout setting is "xbox_prompts", else Nintendo.
int input_prompts_pick(const char *prompts, const char *pad_layout);
// The folder next to romfs-index.bin that holds those prompts for a NX2NAVI_PAD_LAYOUT value, NULL for the game's own. The
// Nintendo layout plays an Xbox pad by position, so its folder names the face buttons the other way round
// (romfs-patches-xbox: the right button does A and is labelled B); xbox and auto put every Switch button on the button
// labelled with its letter (auto picks the layout per pad by its labels), so they share romfs-patches-xbox-label. The PC
// prompts follow no pad: romfs-patches-pc whatever the layout (romfs.c overlay_init serves romfs-patches-pc-<w>x<h>
// under that name at a game resolution of another shape).
// Nintendo layout plays a pad by position, so its folder names the face buttons the other way round
// (romfs-patches-xbox: the right button does A and is labelled B; romfs-patches-playstation: it is circle); xbox and
// auto put every Switch button on the button labelled with its letter, or on cross, circle, square and triangle in
// that order (auto picks the layout per pad by its labels), so they share romfs-patches-<style>-label. The PC prompts
// follow no pad: romfs-patches-pc whatever the layout (romfs.c overlay_init serves romfs-patches-pc-<w>x<h> under that
// name at a game resolution of another shape).
const char *input_prompts_folder(int prompts, const char *pad_layout);
// down[pad] != 0 for the gamepad inputs held. Returns the Switch button bits (0..15) the layout's pad table presses.
uint64_t input_map_pad_buttons(const uint8_t down[PAD_COUNT], int layout);

View File

@@ -780,7 +780,8 @@ static void camera_overlay(void) {
// did not load (ROMFS_PROMPTS_*). The menu compares them with the settings in effect (ui/menu.cpp prompts_note).
// romfs-patches-pc keeps that name, but at a game resolution of another shape than 16:9 its files come from pc_dir,
// romfs-patches-pc-<w>x<h> (overlay_init).
static struct { const char *name; int ready; } prompts_folders[3];
#define PROMPTS_FOLDERS 5 // Xbox and PlayStation by position and by label, then PC
static struct { const char *name; int ready; } prompts_folders[PROMPTS_FOLDERS];
static const char *prompts_wanted, *prompts_loaded;
static int prompts_off;
static char pc_dir[64];
@@ -788,7 +789,7 @@ static char pc_dir[64];
const char *romfs_prompts(void) { return prompts_loaded; }
int romfs_prompts_ready(const char *folder) {
for (int i = 0; i < 3; i++)
for (int i = 0; i < PROMPTS_FOLDERS; i++)
if (folder && prompts_folders[i].name && !strcmp(prompts_folders[i].name, folder)) return prompts_folders[i].ready;
return 0;
}
@@ -822,8 +823,9 @@ static void overlay_init(void) {
// A game resolution that is not 16:9 takes romfs-patches-<w>x<h> instead when it is there: the same overlay with
// the UI laid out for that shape (make_romfs_patches.py --aspect), when the game version has the camera aspect patch
// the layout goes with (res_wants_wide_overlay).
// With prompts = xbox, the Xbox prompts' folder for the pad layout in its place (input_prompts_folder): the same
// patches with Xbox button icons (make_romfs_patches.py --prompts). The game reads the archives once at boot, so
// With prompts = xbox or playstation, that pad's prompts folder for the pad layout in its place
// (input_prompts_folder): the game's button font and interface textures with that pad's buttons drawn in
// (tools/pad_prompts.py). The game reads the font and the archives once at boot, so
// the prompts stay until restart even though the layout itself changes live. Each folder replaces romfs-patches as
// a whole and the setup builds no folder of another shape with Xbox prompts, so a game resolution that is not 16:9
// keeps the Switch prompts. So does a RomFS mod (the NX2NAVI_ROMFS_MODS manifest or a player mod) that replaces one of
@@ -847,11 +849,13 @@ static void overlay_init(void) {
"(tools/make_romfs_patches.py --aspect %ux%u)", out[0], out[1], out[0], out[1]);
}
}
static const char *const layouts[2] = {"nintendo", "xbox"}; // one of each Xbox folder, then the PC one
for (int i = 0; i < 3; i++) {
const char *name = i < 2 ? input_prompts_folder(PROMPTS_XBOX, layouts[i]) : input_prompts_folder(PROMPTS_PC, 0);
static const char *const layouts[2] = {"nintendo", "xbox"}; // each pad's two folders, then the PC one
static const int pads[2] = {PROMPTS_XBOX, PROMPTS_PLAYSTATION};
for (int i = 0; i < PROMPTS_FOLDERS; i++) {
int pc = i == PROMPTS_FOLDERS - 1;
const char *name = pc ? input_prompts_folder(PROMPTS_PC, 0) : input_prompts_folder(pads[i / 2], layouts[i % 2]);
const char *dir = name;
if (i == 2) {
if (pc) {
if (wide) snprintf(pc_dir, sizeof pc_dir, "%s-%ux%u", name, out[0], out[1]);
else snprintf(pc_dir, sizeof pc_dir, "%s", name);
dir = pc_dir;
@@ -862,7 +866,7 @@ static void overlay_init(void) {
}
const char *layout = cfg_value("NX2NAVI_PAD_LAYOUT");
int pick = input_prompts_pick(cfg_get("NX2NAVI_PROMPTS"), layout), shaped = wide && pick == PROMPTS_PC;
const char *style = pick == PROMPTS_PC ? "keyboard and mouse" : "Xbox";
const char *style = pick == PROMPTS_PC ? "keyboard and mouse" : pick == PROMPTS_PLAYSTATION ? "PlayStation" : "Xbox";
prompts_wanted = input_prompts_folder(pick, layout);
prompts_off = !prompts_wanted ? ROMFS_PROMPTS_OK
: wide && !shaped ? ROMFS_PROMPTS_SHAPE
@@ -883,12 +887,14 @@ static void overlay_init(void) {
rt_log("RomFS overlay: %s failed its check (run the setup again); romfs-patches loads instead", path);
}
} else if (prompts_wanted) {
rt_log("RomFS overlay: prompts = %s, but %s; Switch prompts stay", pick == PROMPTS_PC ? "pc" : "xbox",
rt_log("RomFS overlay: prompts = %s, but %s; Switch prompts stay",
pick == PROMPTS_PC ? "pc" : pick == PROMPTS_PLAYSTATION ? "playstation" : "xbox",
prompts_off == ROMFS_PROMPTS_SHAPE ? (shaped ? "the game resolution has no romfs-patches-pc-<w>x<h> "
"(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"
: "their folder has no manifest.tsv (run the setup again)");
: 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;
char plain[2100]; // romfs-patches, when it has a manifest

View File

@@ -1066,6 +1066,12 @@ static void tab_controls(float scale) {
opt_begin("##controls", scale);
static const UiTextId layout[] = {UI_MENU_PAD_LAYOUT_AUTO, UI_MENU_PAD_LAYOUT_NINTENDO, UI_MENU_PAD_LAYOUT_XBOX};
opt_choice("NX2NAVI_PAD_LAYOUT", UI_MENU_PAD_LAYOUT, layout, 3, 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};
bool restart;
opt_choice("NX2NAVI_PROMPTS", UI_MENU_PROMPTS, prompts, 3, 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

@@ -224,16 +224,18 @@ static const char *const text_en[UI_TEXT_COUNT] = {
[UI_MENU_PAD_LAYOUT_XBOX] = "Xbox (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. Nintendo: the game's own. Xbox: LB, "
"RB, LT, RT, Menu, View and a d-pad cross, and A, B, X and Y as labelled on the button "
"the layout above presses (with Nintendo (A right) the right button is labelled B, so the "
"prompts for A show B). PC: the keys and mouse buttons of the keyboard's default layout "
"(E, Shift, Space, left click...) and the mouse for the camera. 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.",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox", [UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_MISSING] = "prompt files missing: run the setup again",
[UI_MENU_PROMPTS_FAILED] = "prompt files damaged: run the setup again",
[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 "
"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.",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation", [UI_MENU_PROMPTS_PC] = "PC",
[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",
[UI_MENU_PROMPTS_OFF_SHAPE] = "off at a game resolution not 16:9",
[UI_MENU_PROMPTS_OFF_MODS] = "off: a RomFS mod replaces their files",
@@ -694,16 +696,18 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
[UI_MENU_PAD_LAYOUT_XBOX] = "Xbox (A снизу)",
[UI_MENU_PAD_LAYOUT_IN_USE] = "сейчас: %s",
[UI_MENU_PROMPTS] = "Подсказки кнопок",
[UI_MENU_PROMPTS_DESC] = "Кнопки какого контроллера показывают подсказки игры. Nintendo: как в самой игре. Xbox: "
"LB, RB, LT, RT, Menu, View и крестовина, а A, B, X и Y подписаны так же, как кнопка, "
"которую нажимает раскладка выше (в Nintendo (A справа) правая кнопка подписана B, и "
"подсказки A показывают B). PC: клавиши и кнопки мыши стандартной раскладки клавиатуры "
"(E, Shift, пробел, левый клик...) и мышь для камеры. Игра читает подсказки при запуске, "
"так что они меняются после перезапуска. Подсказки записаны в файлах игры, поэтому у "
"кнопки, переназначенной на вкладке управления, на экране остаётся прежняя подсказка.",
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox", [UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_MISSING] = "нет файлов подсказок: запустите установку снова",
[UI_MENU_PROMPTS_FAILED] = "файлы подсказок повреждены: запустите установку снова",
[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_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation", [UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_PROMPTS_MISSING] = "нет файлов подсказок: их создаёт tools/pad_prompts.py",
[UI_MENU_PROMPTS_FAILED] = "файлы подсказок повреждены: создайте их снова tools/pad_prompts.py",
[UI_MENU_PROMPTS_RESTART] = "подсказки сменятся после перезапуска",
[UI_MENU_PROMPTS_OFF_SHAPE] = "выкл. при разрешении игры не 16:9",
[UI_MENU_PROMPTS_OFF_MODS] = "выкл.: мод RomFS заменяет их файлы",
@@ -1322,16 +1326,17 @@ 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. Nintendo: os do próprio jogo. "
"Xbox: LB, RB, LT, RT, Menu, Exibir e uma cruz direcional, e A, B, X e Y com a letra do "
"botão que o layout acima aperta (com Nintendo (A à direita) o botão da direita tem a "
"letra B, então os ícones de A mostram B). PC: as teclas e os botões do mouse do layout "
"padrão do teclado (E, Shift, Espaço, clique esquerdo...) e o mouse para a câmera. 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] = "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_NINTENDO] = "Nintendo",
[UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation",
[UI_MENU_PROMPTS_PC] = "PC",
[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.",
@@ -1339,8 +1344,8 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
[UI_MENU_MOUSE_DIRECT] = "Direta",
[UI_MENU_MENU_CURSOR] = "Mouse nos menus do jogo",
[UI_MENU_MENU_CURSOR_DESC] = "Nos menus do próprio jogo (título, inventário, lojas, escolhas de diálogo) o ponteiro aparece: o item sob ele fica selecionado, o clique esquerdo confirma e o direito volta. Só depois de usar o mouse ou uma tecla por último, então com um controle os menus ficam como antes (jogo 1.4.3).",
[UI_MENU_PROMPTS_MISSING] = "faltam os arquivos dos ícones: execute a instalação de novo",
[UI_MENU_PROMPTS_FAILED] = "arquivos dos ícones danificados: execute a instalação de novo",
[UI_MENU_PROMPTS_MISSING] = "faltam os arquivos dos ícones: tools/pad_prompts.py os cria",
[UI_MENU_PROMPTS_FAILED] = "arquivos dos ícones danificados: crie de novo com tools/pad_prompts.py",
[UI_MENU_PROMPTS_RESTART] = "os ícones mudam após reiniciar",
[UI_MENU_PROMPTS_OFF_SHAPE] = "desligados numa resolução de jogo que não é 16:9",
[UI_MENU_PROMPTS_OFF_MODS] = "desligados: um mod de RomFS substitui os arquivos deles",
@@ -1916,12 +1921,14 @@ 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:LB、RB、LT、RT、Menu、View 和十字键,以及上方布局所按下的按键上标注的 "
"A、B、X、Y(使用 Nintendo(A 在右)时右侧按键标为 B,因此 A 的提示显示 "
"B)。PC:键盘默认布局的按键和鼠标按键(E、Shift、空格、左键……),镜头用鼠标。"
"游戏在启动时读取按键提示,因此重启后才会改变。提示属于游戏文件,在“操作”中重新映射的按键在屏幕上仍显示原来的提示。",
[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_NINTENDO] = "Nintendo",
[UI_MENU_PROMPTS_XBOX] = "Xbox",
[UI_MENU_PROMPTS_PLAYSTATION] = "PlayStation",
[UI_MENU_PROMPTS_PC] = "PC",
[UI_MENU_MOUSE_MODE] = "鼠标视角",
[UI_MENU_MOUSE_MODE_DESC] = "直接:镜头按鼠标移动的距离转动,与电脑游戏相同,没有摇杆死区和响应曲线(游戏 1.4.3;其他版本使用摇杆模拟)。摇杆模拟:鼠标相当于右摇杆。",
@@ -1929,8 +1936,8 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
[UI_MENU_MOUSE_DIRECT] = "直接",
[UI_MENU_MENU_CURSOR] = "在游戏菜单中使用鼠标",
[UI_MENU_MENU_CURSOR_DESC] = "在游戏自身的菜单中(标题、背包、商店、对话选项)显示指针:指针下的项目被选中,左键确认,右键返回。仅在最后使用的是鼠标或键盘时生效,因此使用手柄时菜单保持不变(游戏 1.4.3)。",
[UI_MENU_PROMPTS_MISSING] = "缺少按键提示文件:请重新运行安装程序",
[UI_MENU_PROMPTS_FAILED] = "按键提示文件已损坏:请重新运行安装程序",
[UI_MENU_PROMPTS_MISSING] = "缺少按键提示文件:请用 tools/pad_prompts.py 生成",
[UI_MENU_PROMPTS_FAILED] = "按键提示文件已损坏:请用 tools/pad_prompts.py 重新生成",
[UI_MENU_PROMPTS_RESTART] = "按键提示重启后生效",
[UI_MENU_PROMPTS_OFF_SHAPE] = "游戏分辨率不是 16:9 时关闭",
[UI_MENU_PROMPTS_OFF_MODS] = "已关闭:RomFS 模组替换了它们的文件",

View File

@@ -100,7 +100,8 @@ typedef enum {
UI_MENU_RUMBLE_TRIGGERS, UI_MENU_RUMBLE_TRIGGERS_DESC,
UI_MENU_PAD_LAYOUT, UI_MENU_PAD_LAYOUT_DESC, UI_MENU_PAD_LAYOUT_AUTO, UI_MENU_PAD_LAYOUT_NINTENDO,
UI_MENU_PAD_LAYOUT_XBOX, UI_MENU_PAD_LAYOUT_IN_USE,
UI_MENU_PROMPTS, UI_MENU_PROMPTS_DESC, UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX, UI_MENU_PROMPTS_PC,
UI_MENU_PROMPTS, UI_MENU_PROMPTS_DESC, UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX, UI_MENU_PROMPTS_PLAYSTATION,
UI_MENU_PROMPTS_PC,
// the notes under the prompts: their files are missing or damaged, they change after restart, or why they are off
UI_MENU_PROMPTS_MISSING, UI_MENU_PROMPTS_FAILED, UI_MENU_PROMPTS_RESTART, UI_MENU_PROMPTS_OFF_SHAPE,
UI_MENU_PROMPTS_OFF_MODS, UI_MENU_PROMPTS_OFF_PATCHES,

731
tools/pad_prompts.py Normal file
View File

@@ -0,0 +1,731 @@
"""Xbox and PlayStation 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]
--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.
--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
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.
The two 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.
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
guides, the controls screen): the glyphs of the buttons (U+E040 A, B, X, Y, L, R, ZL, ZR, the d-pad, Plus, Minus,
and the sticks and d-pad with their directions) become outlines traced from the art (trace), at the size of the
game's own face buttons; Power, Home, the console and the arrows stay. A .bfotf is a CFF OpenType font behind an
8-byte header and an XOR (tools/button_icons.py bfotf_decode); fontTools writes it again.
- UI/Layout2Archive/Common.Product.100.Nin_Ounce_NVN2.bntx.zs, the shared interface textures: KeyTexFontA/B/X/Y_00,
the letter of the action prompt (DoCommand_00; 64x64 BC4 masks the layout tints), and OcarinaButtonA/B/X/Y/L/R_00,
the ocarina's note buttons (PaOcarinaSingleNote_00; 93x93 ASTC 4x4 sRGB, a white button with a dark letter). They are
painted again in place, in their own format, size and block-linear layout, so the archive keeps its size.
- the resource size table (System/Resource/ResourceSizeTable.Product.<n>...rsizetable.zs): the font grows, and the
game sizes the buffer it loads a file into from the table, so the font's entry is raised by as much
(button_icons.grow_restbl).
Needs fontTools, Pillow and zstandard (tools/requirements-py314.txt).
"""
import argparse
import hashlib
import io
import json
import struct
import sys
from pathlib import Path
import zstandard
from PIL import Image, ImageDraw
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont
sys.path.insert(0, str(Path(__file__).resolve().parent))
import button_icons # noqa: E402 (bfotf_decode, bfotf_encode, redraw_font, grow_restbl)
FONT = 'Font2/Scalable/External.Nin_Ounce_NVN2.bfotf.zs'
TEXTURES = 'UI/Layout2Archive/Common.Product.100.Nin_Ounce_NVN2.bntx.zs'
DICTIONARIES = 'Pack/ZsDic.pack.zs'
# The Switch button each code point of the button font shows (the stick and d-pad glyphs name their directions).
# U+E06A is the d-pad with three directions lit; the art has no such picture, so it shows the whole d-pad.
GLYPHS = {
0xE040: 'A', 0xE041: 'B', 0xE042: 'X', 0xE043: 'Y', 0xE044: 'L', 0xE045: 'R', 0xE046: 'ZL', 0xE047: 'ZR',
0xE049: 'DPAD', 0xE04B: 'PLUS', 0xE04C: 'MINUS',
0xE050: 'LSTICK_DOWN', 0xE051: 'RSTICK_DOWN', 0xE052: 'LSTICK_UP', 0xE053: 'RSTICK_UP',
0xE054: 'LSTICK_LEFTRIGHT', 0xE055: 'RSTICK_LEFTRIGHT', 0xE056: 'LSTICK_PRESS', 0xE057: 'RSTICK_PRESS',
0xE058: 'LSTICK_RIGHT', 0xE059: 'RSTICK_RIGHT', 0xE05A: 'LSTICK_LEFT', 0xE05B: 'RSTICK_LEFT',
0xE05C: 'LSTICK_UPDOWN', 0xE05D: 'RSTICK_UPDOWN', 0xE05E: 'LSTICK', 0xE05F: 'RSTICK',
0xE060: 'DPAD_UP', 0xE061: 'DPAD_DOWN', 0xE062: 'DPAD_LEFT', 0xE063: 'DPAD_RIGHT',
0xE064: 'DPAD_UPDOWN', 0xE065: 'DPAD_LEFTRIGHT', 0xE068: 'RSTICK_ROTATE', 0xE06A: 'DPAD',
}
# The face button that does each Switch face button, by its place on the pad.
FACE = {'position': {'A': 'east', 'B': 'south', 'X': 'north', 'Y': 'west'},
'label': {'A': 'south', 'B': 'east', 'X': 'west', 'Y': 'north'}}
def _sticks(folder, stick, press, rotate):
"""The stick pictures of one style: <stick>_<direction> for L and R."""
out = {}
for side, name in (('L', stick[0]), ('R', stick[1])):
base = f'{folder}{name}'
out.update({f'{side}STICK': f'{base}.png', f'{side}STICK_UP': f'{base}_UP.png',
f'{side}STICK_DOWN': f'{base}_Down.png', f'{side}STICK_LEFT': f'{base}_Left.png',
f'{side}STICK_RIGHT': f'{base}_Right.png', f'{side}STICK_UPDOWN': f'{base}_Y.png',
f'{side}STICK_LEFTRIGHT': f'{base}_X.png', f'{side}STICK_PRESS': f'{folder}{press[side == "R"]}'})
out['RSTICK_ROTATE'] = f'{folder}{rotate}'
return out
def _dpad(folder, name, up):
return {'DPAD': f'{folder}{name}.png', 'DPAD_UP': f'{folder}{name}_{up}.png', 'DPAD_DOWN': f'{folder}{name}_Down.png',
'DPAD_LEFT': f'{folder}{name}_Left.png', 'DPAD_RIGHT': f'{folder}{name}_Right.png',
'DPAD_UPDOWN': f'{folder}{name}_Y.png', 'DPAD_LEFTRIGHT': f'{folder}{name}_X.png'}
# Per style: glyph, the dark pictures the button font and the action prompt's letter are drawn from (by Switch button,
# the face buttons by their place on the pad); note, the light ones the ocarina's note buttons are drawn from. A
# value of OPTIONS is drawn here (options_art): the art shows PlayStation's Options button as its word over a small
# pill, which is unreadable at text size, so the prompt is the Create button's pill with three bars, as on the pad.
X, XL, P, PL = 'XGamepad/Default/T_X_', 'XGamepad/Light/T_X_', 'P5Gamepad/Default/T_P5_', 'P5Gamepad/Light/T_P5_'
OPTIONS = 'options'
STYLES = {
'xbox': {
'glyph': {'south': f'{X}A_White.png', 'east': f'{X}B_White.png', 'west': f'{X}X_White.png',
'north': f'{X}Y_White.png', 'L': f'{X}LB.png', 'R': f'{X}RB.png', 'ZL': f'{X}LT.png',
'ZR': f'{X}RT.png', 'PLUS': f'{X}X.png', 'MINUS': f'{X}Share.png', # Menu (three bars), View
**_dpad(X, 'Dpad', 'Up'), **_sticks(X, ('L', 'R'), ('Left_Stick_Click.png', 'Right_Stick_Click.png'),
'R_2D.png')},
'note': {'south': f'{XL}A_Color_Light.png', 'east': f'{XL}B_Color_Light.png', 'west': f'{XL}X_Color_Light.png',
'north': f'{XL}Y_Color_Light.png', 'L': f'{XL}LB_Light.png', 'R': f'{XL}RB_Light.png'},
},
'playstation': {
'glyph': {'south': f'{P}Cross.png', 'east': f'{P}Circle.png', 'west': f'{P}Square.png',
'north': f'{P}Triangle.png', 'L': f'{P}L1.png', 'R': f'{P}R1.png', 'ZL': f'{P}L2.png',
'ZR': f'{P}R2.png', 'PLUS': OPTIONS, 'MINUS': 'XGamepad/Default/T_X_Share-1.png', # a share pill
**_dpad(P, 'Dpad', 'UP'), **_sticks(P, ('L', 'R'), ('L3.png', 'R3.png'), 'R_2D.png')},
'note': {'south': f'{PL}Cross_Color_Light.png', 'east': f'{PL}Circle_Color_Light.png',
'west': f'{PL}Square_Color_Light.png', 'north': f'{PL}Triangle_Color_Light.png',
'L': f'{PL}L1_Light.png', 'R': f'{PL}R1_Light.png'},
},
}
# The textures drawn again: the action prompt's letter masks and the ocarina's note buttons, by Switch button.
MASKS = {'KeyTexFontA_00': 'A', 'KeyTexFontB_00': 'B', 'KeyTexFontX_00': 'X', 'KeyTexFontY_00': 'Y'}
NOTES = {'OcarinaButtonA_00': 'A', 'OcarinaButtonB_00': 'B', 'OcarinaButtonX_00': 'X', 'OcarinaButtonY_00': 'Y',
'OcarinaButtonL_00': 'L', 'OcarinaButtonR_00': 'R'}
# ---- 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):
self.root = Path(root)
index = self.root / 'index.json'
self.files = None
if index.is_file():
data = json.loads(index.read_text())
assert data.get('status') == 'pass', f'{index} is not a verified RomFS index'
self.files = {f['path']: f for f in data['files']}
self.dicts = None
def raw(self, path):
if self.files is None:
return (self.root / path).read_bytes()
f = self.files[path]
with (self.root / 'romfs.bin').open('rb') as image:
image.seek(f['offset'])
data = image.read(f['size'])
assert len(data) == f['size'], f'{path}: short read'
return data
def paths(self):
if self.files is not None:
return list(self.files)
return [p.relative_to(self.root).as_posix() for p in self.root.rglob('*') if p.is_file()]
def dictionary(self, dict_id):
if self.dicts is None:
self.dicts = {}
pack = zstandard.ZstdDecompressor().decompressobj().decompress(self.raw(DICTIONARIES))
for lo, hi in sarc_members(pack).values():
d = zstandard.ZstdCompressionDict(pack[lo:hi])
self.dicts[d.dict_id()] = d
return self.dicts[dict_id]
def load(self, path):
"""(contents, dictionary or None) of a RomFS file, a .zs file decompressed with the dictionary its frame names."""
raw = self.raw(path)
if not path.endswith('.zs'):
return raw, None
dict_id = zstandard.get_frame_parameters(raw).dict_id
d = self.dictionary(dict_id) if dict_id else None
return zstandard.ZstdDecompressor(dict_data=d).decompressobj().decompress(raw), d
def sarc_members(data):
"""name -> (absolute start, end) of every member of a SARC archive."""
assert data[:4] == b'SARC'
header, start = struct.unpack_from('<H', data, 4)[0], struct.unpack_from('<I', data, 12)[0]
count = struct.unpack_from('<H', data, header + 6)[0]
names, out = header + 12 + count * 16 + 8, {}
for i in range(count):
_, attribute, lo, hi = struct.unpack_from('<4I', data, header + 12 + 16 * i)
at = names + (attribute & 0xffffff) * 4
out[data[at:data.index(0, at)].decode()] = (start + lo, start + hi)
return out
def compress(contents, dictionary, level):
"""A zstd frame of contents with the dictionary the original used, so the game reads it the same way."""
return zstandard.ZstdCompressor(level=level, dict_data=dictionary, threads=-1).compress(contents)
# ---- art: a button as coverage, traced into outlines
def load_art(art, rel):
if rel == OPTIONS:
return options_art(art)
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 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."""
share = load_art(art, 'XGamepad/Default/T_X_Share-1.png')
x0, y0, x1, y1 = share.getchannel('A').point(lambda a: 255 if a > 127 else 0).getbbox()
k = 4
big = Image.new('RGBA', (share.width * k, share.height * k), (0, 0, 0, 0))
d = ImageDraw.Draw(big)
d.rounded_rectangle((x0 * k, y0 * k, x1 * k - 1, y1 * k - 1), radius=(y1 - y0) * k // 2, fill=(42, 42, 42, 255))
cx, cy, half, bar = (x0 + x1) * k / 2, (y0 + y1) * k / 2, (y1 - y0) * k * 0.32, (y1 - y0) * k * 0.05
for dy in (-0.22, 0, 0.22):
y = cy + dy * (y1 - y0) * k
d.rounded_rectangle((cx - half, y - bar, cx + half, y + bar), radius=bar, fill=(255, 255, 255, 255))
return big.convert('RGBa').resize(share.size, Image.LANCZOS).convert('RGBA')
LIT_AREA, LIT_RING = 200, 6 # pixels of the art
def ink(img, lit=False):
"""Coverage 0..1 per pixel (rows of floats) of a button drawn as a dark body with white symbols, for one-colour
glyphs: the body counts, and so does white outside it (a stick's direction arrows, which touch the transparent
background); white inside it (letters, symbols) is a hole.
lit: a d-pad with its pressed directions in white. The art draws them as a white arm in a hairline outline (Xbox)
or as a white arm the others' colour reversed (PlayStation); at text size the one vanishes and the other looks like
the rest, so each white part of LIT_AREA pixels or more becomes a hollow arm, a LIT_RING-pixel band along its
edge."""
a, lum, near, parts = _parts(img)
w, h = img.size
field = [[a[y][x] * (1 - lum[y][x]) + (a[y][x] * lum[y][x] if near[y][x] else 0) for x in range(w)] for y in range(h)]
for part in parts if lit else ():
if len(part) < LIT_AREA:
continue
inside = set(part)
dist = {p: 1 for p in part if any((p[0] + dx, p[1] + dy) not in inside
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))}
front = list(dist)
while front:
nxt = []
for x, y in front:
for q in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if q in inside and q not in dist:
dist[q] = dist[x, y] + 1
nxt.append(q)
front = nxt
for x, y in part:
field[y][x] = a[y][x] if dist[x, y] <= LIT_RING else 0.0
return field
def symbol(img):
"""Coverage of the white letter or symbol inside a dark button (the action prompt's letter), as an L image: the
white inside the body, the body's own grey left out."""
a, lum, near, _ = _parts(img)
out = Image.new('L', img.size)
for y in range(img.height):
for x in range(img.width):
white = min(1.0, max(0.0, (lum[y][x] - 0.25) / 0.6))
out.putpixel((x, y), 0 if near[y][x] else round(255 * a[y][x] * white))
return out
def _parts(img):
"""Alpha and luminance (0..1) per pixel, whether each pixel is white outside the body or its soft edge, and the
white parts (lists of (x, y))."""
w, h = img.size
px = img.load()
a = [[px[x, y][3] / 255 for x in range(w)] for y in range(h)]
lum = [[(0.299 * px[x, y][0] + 0.587 * px[x, y][1] + 0.114 * px[x, y][2]) / 255 for x in range(w)] for y in range(h)]
white = [[a[y][x] > 0.5 and lum[y][x] > 0.5 for x in range(w)] for y in range(h)]
# the background: transparent pixels reachable from the edge
bg = [[False] * w for _ in range(h)]
stack = [(x, y) for x in range(w) for y in (0, h - 1)] + [(x, y) for y in range(h) for x in (0, w - 1)]
while stack:
x, y = stack.pop()
if 0 <= x < w and 0 <= y < h and not bg[y][x] and a[y][x] <= 0.5:
bg[y][x] = True
stack += [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
# each white part: outside the body when a quarter of its edge touches the background. A stick's arrows touch it
# all round; a lit d-pad direction inside a thin dark outline touches it only where the outline thins at a rounded
# corner, and stays a hole.
outside = [[False] * w for _ in range(h)]
seen = [[False] * w for _ in range(h)]
parts = []
for sy in range(h):
for sx in range(w):
if not white[sy][sx] or seen[sy][sx]:
continue
part, stack, edge, touching = [], [(sx, sy)], 0, 0
seen[sy][sx] = True
while stack:
x, y = stack.pop()
part.append((x, y))
if not all(0 <= nx < w and 0 <= ny < h and white[ny][nx]
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))):
edge += 1
touching += any(bg[yy][xx] for yy in range(max(0, y - 1), min(h, y + 2))
for xx in range(max(0, x - 1), min(w, x + 2)))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and white[ny][nx] and not seen[ny][nx]:
seen[ny][nx] = True
stack.append((nx, ny))
parts.append(part)
if touching * 4 >= edge:
for x, y in part:
outside[y][x] = True
# the soft edge of an outside part (antialiased pixels next to it) counts with it
near = [[outside[y][x] or any(outside[yy][xx] for yy in range(max(0, y - 1), min(h, y + 2))
for xx in range(max(0, x - 1), min(w, x + 2))) for x in range(w)] for y in range(h)]
return a, lum, near, parts
def trace(field, level=0.5):
"""Closed outlines (lists of (x, y) in pixels, y down) where field crosses level, by marching squares with the
crossings placed linearly along the cell edges. Each outline has the covered side on its left once y points up
(as in a font): outer outlines run counter-clockwise and holes clockwise, as a CFF font wants them."""
h, w = len(field), len(field[0])
def v(x, y): # one pixel of zero around the field, so every outline closes
return field[y - 1][x - 1] if 1 <= x <= w and 1 <= y <= h else 0.0
def cross(p, q):
(x0, y0), (x1, y1) = p, q
a, b = v(x0, y0), v(x1, y1)
t = (level - a) / (b - a)
return (x0 + (x1 - x0) * t - 1, y0 + (y1 - y0) * t - 1) # back to field coordinates
starts = {} # edge -> (start point, end edge, end point) of the segment leaving the cell through it
for y in range(h + 1):
for x in range(w + 1):
corners = [(x, y), (x + 1, y), (x + 1, y + 1), (x, y + 1)] # clockwise with y down
inside = [v(*c) >= level for c in corners]
if all(inside) or not any(inside):
continue
# the crossings met going round the cell clockwise: (edge key, point, entering the covered part)
hits = []
for i in range(4):
p, q = corners[i], corners[(i + 1) % 4]
if inside[i] != inside[(i + 1) % 4]:
hits.append((tuple(sorted((p, q))), cross(p, q), inside[(i + 1) % 4]))
# each segment joins the crossing entering a covered part to the one leaving it, so that going round the
# cell clockwise (y down) the covered part lies between them
if len(hits) == 2:
enter, leave = (hits[0], hits[1]) if hits[0][2] else (hits[1], hits[0])
pairs = [(enter, leave)]
else: # a saddle: the middle decides whether the covered corners join
order = hits if hits[0][2] else hits[1:] + hits[:1] # enter, leave, enter, leave
e1, l1, e2, l2 = order
joined = (v(x, y) + v(x + 1, y) + v(x + 1, y + 1) + v(x, y + 1)) / 4 >= level
pairs = [(e2, l1), (e1, l2)] if joined else [(e1, l1), (e2, l2)]
for (sk, sp, _), (ek, ep, _) in pairs:
starts[sk] = (sp, ek, ep)
outlines = []
while starts:
key, (point, end, _) = next(iter(starts.items()))
loop = []
while key in starts:
point, end, _ = starts.pop(key)
loop.append(point)
key = end
if len(loop) >= 3:
outlines.append(loop)
return outlines
def simplify(loop, tolerance):
"""A closed outline with the points that stray less than tolerance from a straight line dropped (Douglas-Peucker on
the two halves between the first point and the one farthest from it)."""
def dp(points):
if len(points) < 3:
return points
(ax, ay), (bx, by) = points[0], points[-1]
dx, dy = bx - ax, by - ay
norm = (dx * dx + dy * dy) ** 0.5
far, at = -1.0, 0
for i in range(1, len(points) - 1):
px, py = points[i]
d = abs(dy * (px - ax) - dx * (py - ay)) / norm if norm else ((px - ax) ** 2 + (py - ay) ** 2) ** 0.5
if d > far:
far, at = d, i
if far <= tolerance:
return [points[0], points[-1]]
return dp(points[:at + 1])[:-1] + dp(points[at:])
x0, y0 = loop[0]
k = max(range(len(loop)), key=lambda i: (loop[i][0] - x0) ** 2 + (loop[i][1] - y0) ** 2)
out = dp(loop[:k + 1])[:-1] + dp(loop[k:] + loop[:1])[:-1]
return out if len(out) >= 3 else loop
def area(loop):
return sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(loop, loop[1:] + loop[:1])) / 2
def outlines_of(img, lit=False):
"""The traced, simplified outlines of a dark-style button picture, specks dropped (lit: as ink)."""
return [s for s in (simplify(o, 0.3) for o in trace(ink(img, lit))) if abs(area(s)) >= 1.5]
def bounds(outlines):
xs = [x for o in outlines for x, _ in o]
ys = [y for o in outlines for _, y in o]
return min(xs), min(ys), max(xs), max(ys)
# ---- 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."""
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)
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
def draw(pen, outlines=outlines, ox=ox):
for o in outlines:
pen.moveTo((ox + o[0][0] * scale, mid_y - (o[0][1] - mid_y_px) * scale))
for x, y in o[1:]:
pen.lineTo((ox + x * scale, mid_y - (y - mid_y_px) * scale))
pen.closePath()
drawn[cp] = (advance, draw)
return button_icons.redraw_font(otf, drawn)
# ---- BNTX textures: block-linear in the Switch 2 GOB layout (runtime/tex_deswizzle.c, nvn_bl_offset)
def bntx_textures(data):
"""name -> (format, width, height, block height (log2 GOBs), level offset, level size) of every one-level 2D
texture of a BNTX."""
assert data[:4] == b'BNTX'
count, table = struct.unpack_from('<IQ', data, 0x24)
out = {}
for k in range(count):
info = struct.unpack_from('<Q', data, table + 8 * k)[0]
assert data[info:info + 4] == b'BRTI'
at = struct.unpack_from('<Q', data, info + 0x60)[0]
name = data[at + 2:at + 2 + struct.unpack_from('<H', data, at)[0]].decode()
mips = struct.unpack_from('<H', data, info + 0x16)[0]
fmt, (w, h), th = struct.unpack_from('<I', data, info + 0x1c)[0], struct.unpack_from('<II', data, info + 0x24), \
struct.unpack_from('<I', data, info + 0x34)[0]
start = struct.unpack_from('<Q', data, struct.unpack_from('<Q', data, info + 0x70)[0])[0]
out[name] = dict(fmt=fmt, w=w, h=h, th=th, mips=mips, start=start, size=struct.unpack_from('<I', data, info + 0x50)[0])
return out
def level_tiles(th, rows):
"""The block height of a level rows block-rows high (runtime/nvn_tex.c adj_tile): halved while it is too tall."""
x = 8 << (th - 1) if th else 0
while th and x >= rows:
th -= 1
x >>= 1
return th
def block_linear(row_bytes, rows, th):
"""Offset of each 16-byte piece (row, piece) of a level, in the Switch 2 GOB order."""
th = level_tiles(th, rows)
unit, block, gobs = 8 << th, 512 << th, (row_bytes + 63) // 64
return lambda x, y: ((y // unit) * gobs * block + (x // 64) * block + ((y % unit) // 8) * 512 + (x % 64 // 32) * 256
+ (y % 8 // 4) * 128 + (x % 32 // 16) * 64 + (y % 4) * 16)
def read_level(data, t, row_bytes, rows):
at = block_linear(row_bytes, rows, t['th'])
out = bytearray(row_bytes * rows)
for y in range(rows):
for x in range(0, row_bytes, 16):
o = t['start'] + at(x, y)
out[y * row_bytes + x:y * row_bytes + x + 16] = data[o:o + 16]
return out
def write_level(data, t, lin, row_bytes, rows):
at = block_linear(row_bytes, rows, t['th'])
for y in range(rows):
for x in range(0, row_bytes, 16):
o = at(x, y)
assert o + 16 <= t['size'], 'past the end of the texture'
data[t['start'] + o:t['start'] + o + 16] = lin[y * row_bytes + x:y * row_bytes + x + 16]
BC4_UNORM, ASTC_4X4_SRGB = 0x1d01, 0x2d06
def bc4_decode(lin, w, h):
out = Image.new('L', (w, h))
wb = (w + 3) // 4
for by in range((h + 3) // 4):
for bx in range(wb):
b = lin[(by * wb + bx) * 8:(by * wb + bx) * 8 + 8]
pal = bc4_palette(b[0], b[1])
bits = int.from_bytes(b[2:8], 'little')
for i in range(16):
x, y = bx * 4 + i % 4, by * 4 + i // 4
if x < w and y < h:
out.putpixel((x, y), pal[bits >> (3 * i) & 7])
return out
def bc4_palette(a, b):
if a > b:
return [a, b] + [((6 - i) * a + (i + 1) * b) // 7 for i in range(6)]
return [a, b] + [((4 - i) * a + (i + 1) * b) // 5 for i in range(4)] + [0, 255]
def bc4_encode(img):
"""BC4 blocks of an L image: each block's lowest and highest value as the ends, every pixel the nearest of the
eight levels between them."""
w, h = img.size
px = img.load()
out = bytearray()
for by in range((h + 3) // 4):
for bx in range((w + 3) // 4):
vals = [px[min(w - 1, bx * 4 + i % 4), min(h - 1, by * 4 + i // 4)] for i in range(16)]
hi, lo = max(vals), min(vals)
if hi == lo:
out += bytes([hi, lo]) + bytes(6)
continue
pal = bc4_palette(hi, lo)
bits = 0
for i, v in enumerate(vals):
bits |= min(range(8), key=lambda k: abs(pal[k] - v)) << (3 * i)
out += bytes([hi, lo]) + bits.to_bytes(6, 'little')
return bytes(out)
# ASTC 4x4, one partition, endpoint mode 12 (RGBA, direct), 8-bit endpoints and a 4x4 grid of 2-bit weights: 11 bits
# of block mode (0x042: weights 0..3, grid 4x4), 2 of partition count, 4 of endpoint mode, then the eight endpoint
# bytes from bit 17 (r0 r1 g0 g1 b0 b1 a0 a1) and the 32 weight bits from bit 127 down. The 79 bits left after the
# mode and the weights hold eight values of 0..255 exactly, so the decoder reads the endpoints as plain bytes.
ASTC_WEIGHTS = (0, 21, 43, 64)
def astc_block(texels):
"""One 128-bit block of 16 RGBA texels: the two ends of the colours' main direction, every texel its nearest of
the four steps between them."""
n = len(texels)
mean = [sum(t[c] for t in texels) / n for c in range(4)]
cov = [[sum((t[i] - mean[i]) * (t[j] - mean[j]) for t in texels) for j in range(4)] for i in range(4)]
axis = [1.0, 1.0, 1.0, 1.0]
for _ in range(12):
nxt = [sum(cov[i][j] * axis[j] for j in range(4)) for i in range(4)]
norm = sum(v * v for v in nxt) ** 0.5
if norm < 1e-9:
break
axis = [v / norm for v in nxt]
proj = [sum((t[c] - mean[c]) * axis[c] for c in range(4)) for t in texels]
lo, hi = min(proj), max(proj)
e0 = [max(0, min(255, round(mean[c] + lo * axis[c]))) for c in range(4)]
e1 = [max(0, min(255, round(mean[c] + hi * axis[c]))) for c in range(4)]
if sum(e1[:3]) < sum(e0[:3]): # the decoder would read the ends the other way (blue contraction)
e0, e1 = e1, e0
span = [e1[c] - e0[c] for c in range(4)]
length = sum(v * v for v in span)
weights = []
for t in texels:
f = sum((t[c] - e0[c]) * span[c] for c in range(4)) / length if length else 0.0
weights.append(min(range(4), key=lambda k: abs(ASTC_WEIGHTS[k] / 64 - f)))
bits = 0x042 | (12 << 13)
for i, v in enumerate((e0[0], e1[0], e0[1], e1[1], e0[2], e1[2], e0[3], e1[3])):
bits |= v << (17 + 8 * i)
for i, wt in enumerate(weights):
for b in range(2):
if wt >> b & 1:
bits |= 1 << (127 - (2 * i + b))
return bits.to_bytes(16, 'little')
def astc_encode(img):
w, h = img.size
px = img.load()
out = bytearray()
for by in range((h + 3) // 4):
for bx in range((w + 3) // 4):
out += astc_block([px[min(w - 1, bx * 4 + i % 4), min(h - 1, by * 4 + i // 4)] for i in range(16)])
return bytes(out)
def fit(img, box, size):
"""img's opaque part scaled to fit box (x0, y0, x1, y1) and centred in it, on a transparent canvas of size;
resampled with premultiplied alpha, so the edges keep their colour."""
crop = img.crop(img.getchannel('A').point(lambda a: 255 if a > 8 else 0).getbbox())
bw, bh = box[2] - box[0], box[3] - box[1]
k = min(bw / crop.width, bh / crop.height)
scaled = crop.convert('RGBa').resize((max(1, round(crop.width * k)), max(1, round(crop.height * k))), Image.LANCZOS)
canvas = Image.new('RGBa', size, (0, 0, 0, 0))
canvas.paste(scaled, (round(box[0] + (bw - scaled.width) / 2), round(box[1] + (bh - scaled.height) / 2)))
return canvas.convert('RGBA')
def bleed(img):
"""The colour of the nearest covered pixel spread under transparent ones, so a compressed block that holds both
ends in the button's own colour rather than in black."""
w, h = img.size
px = img.load()
known = {(x, y) for y in range(h) for x in range(w) if px[x, y][3] > 8}
for _ in range(6):
grown = {}
for y in range(h):
for x in range(w):
if (x, y) in known:
continue
near = [px[x + dx, y + dy] for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
if 0 <= x + dx < w and 0 <= y + dy < h and (x + dx, y + dy) in known]
if near:
grown[x, y] = tuple(sum(p[c] for p in near) // len(near) for c in range(3)) + (px[x, y][3],)
for xy, p in grown.items():
px[xy] = p
known |= set(grown)
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."""
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])))
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)
else:
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']))
write_level(data, t, astc_encode(bleed(pic)), wb * 16, hb)
# Where the game's own note buttons are opaque in their 93x93 textures (1.1.1), which the new ones fill: the face
# buttons a disc, L and R a wider shape. Measured once with the runtime's ASTC decoder (tools/ui_textures.py), so this
# tool needs none.
NOTE_BOXES = {'disc': (13, 13, 80, 80), 'shoulder': (8, 15, 84, 76)}
# ---- the overlay folder
def write_overlay(out, outputs, originals):
"""outputs: RomFS path -> new file bytes; originals: RomFS path -> the original's bytes. Writes each file under out
and the manifest runtime/romfs.c overlay_load checks: path, size, SHA-256, the original's SHA-256."""
lines = []
for path, data in outputs.items():
target = out / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
lines.append('\t'.join([path, str(len(data)), hashlib.sha256(data).hexdigest(),
hashlib.sha256(originals[path]).hexdigest()]))
(out / 'manifest.tsv').write_text('# path\tsize\tsha256\toriginal_sha256 (tools/pad_prompts.py)\n'
+ ''.join(line + '\n' for line in lines))
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('--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('--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):
ap.error('--out writes one folder: give --style and --layout')
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)
assert restbl, 'no resource size table in the RomFS'
originals = {p: romfs.raw(p) for p in (FONT, TEXTURES, restbl)}
font_blob, font_dict = romfs.load(FONT)
otf, key = button_icons.bfotf_decode(font_blob)
table, table_dict = romfs.load(restbl)
textures, textures_dict = romfs.load(TEXTURES)
memo = {}
def cache(k, make):
if k not in memo:
memo[k] = make()
return memo[k]
def traced(rel, lit=False):
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):
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)
blob = button_icons.bfotf_encode(new_otf, key, font_blob)
data = bytearray(textures)
draw_textures(data, style, layout, 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):
outputs[restbl] = compress(button_icons.grow_restbl(table, {FONT: len(blob) - len(font_blob)}),
table_dict, 19)
write_overlay(out, outputs, originals)
report.append(dict(folder=str(out), style=style, layout=layout, font_bytes=len(blob),
font_grew=len(blob) - len(font_blob), files=sorted(outputs)))
print(f'{out}: {len(outputs)} files, the font grew by {len(blob) - len(font_blob)} bytes', flush=True)
print(json.dumps(dict(status='pass', folders=report)))
if __name__ == '__main__':
main()