Keep saves safe in Import Saves and Quit, draw no traced game art, and recompile the game for a new processor.

Import Saves: the saves folder picked in the dialog imports what waits in it (it had made the folder's README the
save); the save backed up is the one the game would load, in runtime/fs_host.c save_recover's order, so an interrupted
commit's only copy is kept, and the rest of the commit journal moves into the backup instead of being deleted; the
imported save goes into place before the old one is deleted, which comes back if that fails; another NX2navi install's
finished commit counts as its save. Tests cover each.

Quit waits while the game writes a save, as Restart does: the game runs on under the menu, and closing the window
finishes only a commit that has begun. The VSync row says "in use: Immediate" when VSync On falls back to tearing, and
its default follows the device profile (cfg_default). Without the game's textures the menu's divider is a plain line
and diamond of its own, not a tracing of the game's ornament.

make_game.py: the compile key holds the processor's features (cpu_tag), so a game folder moved to a PC without AVX2
compiles again instead of crashing; the main thread gets 16 MiB of stack with GNU ld too; any error waits for the
RomFS image, so its .part file is not cut short. The launcher tests use a neutral title id; tools/menu_preview.cpp
has cfg_default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-08 03:57:02 -05:00
parent 8daedbd806
commit f57a327a86
11 changed files with 166 additions and 93 deletions

View File

@@ -230,10 +230,12 @@ Before a commit:
A change to the in-game menu's look (`runtime/ui/menu_skin.cpp`, `option_rows.cpp`, `perf_panel.cpp`) can be looked at
without game files: `python tools/menu_preview.py` builds the menu's own drawing code with the host's clang++ and
writes the Screen tab and the full performance overlay to `build/menu-preview/` (a stand-in font, and the window's
plain border in place of the game's ornamented frame). With `--romfs <extracted RomFS folder>` it draws the game's
interface textures as the game does; `python tools/ui_textures.py <folder>` lists the textures there and dumps those
matching `--match` to `build/ui-textures/`.
writes the Screen tab, the full performance overlay and the boot screen to `build/menu-preview/` (`menu.png`,
`overlay.png`, `boot.png`; a stand-in font, and the window's plain border in place of the game's ornamented frame).
With `--romfs <extracted RomFS folder>` it draws the game's interface textures as the game does, and
`tools/ui_textures.py <folder>` lists the textures there and dumps those matching `--match` to `build/ui-textures/`;
both decode the game's files, so they run with `.venv-py314\Scripts\python` (`compression.zstd` and numpy). What they
write is decoded game data: keep it under `build/`.
A change to the button prompts (`tools/make_romfs_patches.py --prompts`, `tools/button_icons.py`) passes
`.venv-py314\Scripts\python tests\romfs_prompts_test.py`, which runs the tool on a RomFS it makes itself (no game

View File

@@ -94,6 +94,9 @@ def find_save(folder):
return None, ()
for base in (p / 'profile' / 'save', p / 'save'): # another game folder, or its profile folder
if base.is_dir():
ready = _ready(base.parent / 'save-tx') # a finished commit there is newer than its account folder
if ready is not None and _has_file(ready):
return ready, ()
accounts = [d for d in base.iterdir() if d.is_dir() and HEX32.match(d.name)]
if accounts:
return max(accounts, key=lambda d: d.stat().st_mtime), ()
@@ -143,18 +146,25 @@ def _files(src, skip):
return out
def _newest(game, account):
"""The folder the game would load now: a finished commit still waiting in save-tx, else the account folder."""
tx = Path(game) / 'profile' / 'save-tx'
def _ready(tx):
"""The newest finished commit (ready-<n>) in a save-tx folder, or None."""
ready = []
if tx.is_dir():
for d in tx.iterdir():
if Path(tx).is_dir():
for d in Path(tx).iterdir():
m = re.fullmatch(r'ready-(\d+)', d.name)
if m and d.is_dir():
ready.append((int(m.group(1)), d))
if ready:
return max(ready)[1]
return account if account.is_dir() else None
return max(ready)[1] if ready else None
def _newest(game, account):
"""The folder the game would load now, in the order of runtime/fs_host.c save_recover: a finished commit still
waiting in save-tx, else the account folder, else an interrupted commit's new save, else the save it replaced."""
tx = Path(game) / 'profile' / 'save-tx'
for d in (_ready(tx), account, tx / 'new', tx / 'old'):
if d is not None and d.is_dir():
return d
return None
def game_running(game):
@@ -175,6 +185,8 @@ def import_save(game, folder=None, log=print):
"""Make the save in folder (None: the one waiting in <game>/saves) the game's save. Returns {'files', 'source',
'backup'}; backup is the folder the replaced save went to, or None when there was none."""
game = Path(game)
if folder is not None and Path(folder).resolve() == saves_dir(game).resolve():
folder = None # the saves folder itself, picked in the dialog: what waits in it, as the button takes it
from_saves = folder is None
src, skip = waiting(game) if from_saves else find_save(folder)
if src is None:
@@ -198,15 +210,34 @@ def import_save(game, folder=None, log=print):
for rel in files:
(stage / rel).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(src) / rel, stage / rel)
for d in list(tx.iterdir()): # the journal of the replaced save: the game would load a finished commit first
if d != stage:
# The journal of the replaced save goes out of save-tx (the game would load a finished commit first, or complete an
# interrupted one): what holds files moves into the backup, beside the save it was copied from, so a copy the
# backup's own choice missed (an interrupted commit's other half) is kept too.
for d in list(tx.iterdir()):
if d == stage:
continue
if _has_file(d) if d.is_dir() else d.stat().st_size:
if backup is None:
backup = _fresh(saves, f'backup {stamp}')
backup.mkdir()
(backup / 'save-tx').mkdir(exist_ok=True)
shutil.move(str(d), str(backup / 'save-tx' / d.name))
else:
shutil.rmtree(d, ignore_errors=True) if d.is_dir() else d.unlink(missing_ok=True)
account.parent.mkdir(parents=True, exist_ok=True)
# The imported save goes into place before the old one is deleted; a failed rename puts the old one back.
gone = None
if account.exists():
gone = tx / 'replaced'
gone = _fresh(tx, 'replaced')
os.replace(account, gone)
try:
os.replace(stage, account)
except OSError:
if gone is not None:
os.replace(gone, account)
raise
if gone is not None:
shutil.rmtree(gone, ignore_errors=True)
os.replace(stage, account)
if from_saves: # what was put into saves/ moves out whole, so the next import does not take any of it again
done = _fresh(saves, f'imported {stamp}')
done.mkdir()

View File

@@ -141,7 +141,7 @@ def states(app):
def import_saves():
app.release, app.message = None, ''
app.save_message = ('Save imported: 3 files from 0100F2C0115B6000. Your old save is in '
app.save_message = ('Save imported: 3 files from 0100000000010000. Your old save is in '
'saves\\backup 2026-11-08 17.30.12.')
app.show('home', next(i for i, r in enumerate(app.rows_home()) if r.label == 'Import Saves'))

View File

@@ -749,13 +749,17 @@ static const char *present_name(const char *mode) {
return nullptr;
}
// "in use: VSync" under the VSync row when it is off and the driver cannot present without waiting; NULL otherwise, and
// before the first swapchain.
// "in use: ..." under the VSync row when the swapchain does other than the row says: VSync when it is off and the driver
// cannot present without waiting, Immediate when it is on and the driver has neither mailbox nor relaxed FIFO
// (gfx_present.c pick_present_mode), so the game tears; NULL otherwise, and before the first swapchain.
static const char *vsync_note(void) {
const char *got = gfx_window_present_mode();
if (!*got || strcmp(cfg_value("NX2NAVI_VK_PRESENT"), "immediate") || !strcmp(got, "immediate")) return nullptr;
if (!*got) return nullptr;
bool off = !strcmp(cfg_value("NX2NAVI_VK_PRESENT"), "immediate"), tears = !strcmp(got, "immediate");
if (off == tears) return nullptr;
static char note[96];
snprintf(note, sizeof note, menu_text(UI_MENU_PRESENT_IN_USE), menu_text(UI_MENU_PRESENT_VSYNC));
snprintf(note, sizeof note, menu_text(UI_MENU_PRESENT_IN_USE),
menu_text(tears ? UI_MENU_PRESENT_IMMEDIATE : UI_MENU_PRESENT_VSYNC));
return note;
}
@@ -1004,8 +1008,9 @@ static const char *prompts_note(bool *restart) {
// then closes the game as Quit does and starts it again (gfx_window_request_restart). It waits while the game writes a
// save, and the game stays when nx2navi.ini cannot be written, as the changed settings would not come back. From the
// Cheats tab (restart_rebuild) Restart also lets the next start rebuild for cheats whose last rebuild failed
// (cheats_rebuild_allow). The Quit button's confirmation is the same box (g_confirm_quit): closing the window already
// finishes a save in progress (gfx_window_closed), so it does not wait.
// (cheats_rebuild_allow). The Quit button's confirmation is the same box (g_confirm_quit), and it waits too: the game
// runs on while the menu is open, and closing the window finishes only a Commit that has begun (gfx_window_closed,
// fs_save_flush), so a save between writing its files and committing them would be dropped.
static bool restart_rebuild, g_confirm_quit;
static void restart_confirm(float scale) {
g_modal = false;
@@ -1019,10 +1024,11 @@ static void restart_confirm(float scale) {
const ImVec2 box = ImGui::GetWindowPos(), box_size = ImGui::GetWindowSize();
skin_panel(ImGui::GetWindowDrawList(), box, ImVec2(box.x + box_size.x, box.y + box_size.y), scale);
ImGui::TextUnformatted(menu_text(g_confirm_quit ? UI_MENU_QUIT_CONFIRM : UI_MENU_RESTART_CONFIRM));
bool saving = !g_confirm_quit && fs_save_busy() != 0;
bool saving = fs_save_busy() != 0;
if (saving || (!g_confirm_quit && !cfg_last_save_ok()))
ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1), "%s",
menu_text(saving ? UI_MENU_RESTART_SAVING : UI_MENU_SAVE_FAILED));
menu_text(!saving ? UI_MENU_SAVE_FAILED : g_confirm_quit ? UI_MENU_QUIT_SAVING
: UI_MENU_RESTART_SAVING));
ImGui::Spacing();
const char *yes = menu_text(g_confirm_quit ? UI_MENU_QUIT : UI_MENU_RESTART_YES), *no = menu_text(UI_MENU_RESET_NO);
const ImGuiStyle &st = ImGui::GetStyle();

View File

@@ -2,8 +2,8 @@
// textures tinted and laid over each other in nine slices; until they are decoded, or without them, it is plain shapes
// whose colours and proportions are sampled from the game: the save menu's Load and Cancel buttons (brown fill, light
// edge, an inset line about a twentieth of the height in with a diamond and a curl at alternate corners), the options
// menu's cursor (a 3 px white frame with a soft white-blue glow) and its divider ornament (traced: two pointed lens
// ends, S-waves into spiral hooks and a diamond knot under the middle, in a 120 x 38 box with its centre at (60, 21)).
// menu's cursor (a 3 px white frame with a soft white-blue glow); without the textures the divider is a plain line with
// a diamond in its middle and a dot at each end, a shape of its own, not the game's ornament.
#include <float.h>
#include <math.h>
#include <stdlib.h>
@@ -63,34 +63,6 @@ void ornament_line(ImDrawList *dl, ImVec2 a, ImVec2 b, float inset, float s, ImU
diamond(dl, ImVec2(r, bt), d, col);
}
// The divider ornament's paths: F_M x y, F_L x y, F_C x1 y1 x2 y2 x y, ending with F_END.
enum { F_M = -1000, F_L = -1001, F_C = -1002, F_Z = -1003, F_END = -1004 };
static const float kFiligree0[] = {
F_M, 20, 17.4, F_C, 20.4, 17.3, 21.6, 16.8, 22.2, 16.9, F_C, 22.8, 17, 23.5, 17.7, 23.5, 18.2, F_C, 23.5, 18.7,
23.1, 19.4, 22.4, 19.8, F_C, 21.6, 20.2, 20.3, 20.4, 19, 20.4, F_C, 17.7, 20.4, 15.9, 20.2, 14.5, 19.9, F_C,
13.1, 19.6, 10.6, 19.4, 10.5, 18.6, F_C, 10.4, 17.9, 12.6, 16.3, 14, 15.4, F_C, 15.4, 14.5, 17.2, 13.6, 19, 13.2,
F_C, 20.8, 12.8, 23.2, 12.8, 25, 13.1, F_C, 26.8, 13.4, 28.1, 13.9, 29.5, 14.8, F_C, 30.9, 15.7, 32.2, 17.4,
33.5, 18.6, F_C, 34.8, 19.8, 36, 21.2, 37.5, 22, F_C, 39, 22.8, 40.8, 23.4, 42.5, 23.6, F_C, 44.2, 23.8, 46,
23.5, 47.5, 23.1, F_C, 49, 22.7, 50.4, 21.9, 51.2, 21.2, F_C, 52, 20.5, 52.5, 19.5, 52.4, 18.8, F_C, 52.3, 18.1,
51.5, 17.3, 50.6, 16.9, F_C, 49.7, 16.5, 48.2, 16.2, 47, 16.3, F_C, 45.8, 16.4, 44.2, 17.1, 43.6, 17.2, F_END};
static const float kFiligree1[] = {
F_M, 100, 17.4, F_C, 99.6, 17.3, 98.4, 16.8, 97.8, 16.9, F_C, 97.2, 17, 96.5, 17.7, 96.5, 18.2, F_C, 96.5, 18.7,
96.8, 19.4, 97.6, 19.8, F_C, 98.3, 20.2, 99.7, 20.4, 101, 20.4, F_C, 102.3, 20.4, 104.1, 20.2, 105.5, 19.9, F_C,
106.9, 19.6, 109.4, 19.4, 109.5, 18.6, F_C, 109.6, 17.9, 107.4, 16.3, 106, 15.4, F_C, 104.6, 14.5, 102.8, 13.6,
101, 13.2, F_C, 99.2, 12.8, 96.8, 12.8, 95, 13.1, F_C, 93.2, 13.4, 91.9, 13.9, 90.5, 14.8, F_C, 89.1, 15.7, 87.8,
17.4, 86.5, 18.6, F_C, 85.2, 19.8, 84, 21.2, 82.5, 22, F_C, 81, 22.8, 79.2, 23.4, 77.5, 23.6, F_C, 75.8, 23.8,
74, 23.5, 72.5, 23.1, F_C, 71, 22.7, 69.6, 21.9, 68.8, 21.2, F_C, 68, 20.5, 67.5, 19.5, 67.6, 18.8, F_C, 67.7,
18.1, 68.5, 17.3, 69.4, 16.9, F_C, 70.3, 16.5, 71.8, 16.2, 73, 16.3, F_C, 74.2, 16.4, 75.8, 17.1, 76.4, 17.2,
F_END};
static const float kFiligree2[] = {
F_M, 40.6, 17.6, F_C, 40.7, 17, 40.7, 14.9, 41.2, 14, F_C, 41.7, 13.1, 42.6, 12.4, 43.8, 12, F_C, 45, 11.6, 46.7,
11.4, 48.2, 11.5, F_C, 49.7, 11.6, 51.3, 11.8, 52.6, 12.3, F_C, 53.9, 12.8, 55, 13.6, 56, 14.6, F_C, 57, 15.6,
58.2, 17.6, 58.6, 18.2, F_L, 64.6, 26, F_L, 60, 31.6, F_END};
static const float kFiligree3[] = {
F_M, 79.4, 17.6, F_C, 79.3, 17, 79.3, 14.9, 78.8, 14, F_C, 78.3, 13.1, 77.4, 12.4, 76.2, 12, F_C, 75, 11.6, 73.3,
11.4, 71.8, 11.5, F_C, 70.3, 11.6, 68.7, 11.8, 67.4, 12.3, F_C, 66.1, 12.8, 65, 13.6, 64, 14.6, F_C, 63, 15.6,
61.8, 17.6, 61.4, 18.2, F_L, 55.4, 26, F_L, 60, 31.6, F_END};
const float *const kFiligree[] = {kFiligree0, kFiligree1, kFiligree2, kFiligree3};
// The settings menu's colours: a dark umber panel, warm browns for frames and what is selected, gold for the ornaments,
// accents and the cursor, parchment text. kGreen and kOcarina keep their names for the selected and active controls.
@@ -416,31 +388,12 @@ void skin_filigree(ImDrawList *dl, float x0, float x1, float y, float s, float a
dl->AddCircleFilled(ImVec2(x1 - 4 * s - dot_r, y), dot_r, dot);
dl->AddLine(ImVec2(x0 + 4 * s + 2 * dot_r + gap, y), ImVec2(cx - orn_w * 0.5f - gap, y), line, 1.0f * s);
dl->AddLine(ImVec2(cx + orn_w * 0.5f + gap, y), ImVec2(x1 - 4 * s - 2 * dot_r - gap, y), line, 1.0f * s);
auto at = [&](float px, float py) { return ImVec2(cx + (px - 60.0f) * k, y + (py - 21.0f) * k); };
for (const float *p : kFiligree) {
ImVec2 cur(0, 0);
bool open = false;
for (int i = 0; p[i] != F_END;) {
if (p[i] == F_M) {
if (open) dl->PathStroke(orn, 0, 2.2f * k);
cur = at(p[i + 1], p[i + 2]);
dl->PathLineTo(cur);
open = true;
i += 3;
} else if (p[i] == F_L) {
cur = at(p[i + 1], p[i + 2]);
dl->PathLineTo(cur);
i += 3;
} else if (p[i] == F_C) {
cur = at(p[i + 5], p[i + 6]);
dl->PathBezierCubicCurveTo(at(p[i + 1], p[i + 2]), at(p[i + 3], p[i + 4]), cur, 8);
i += 7;
} else { // F_Z: back to the path's start (no path here uses it)
i += 1;
}
}
if (open) dl->PathStroke(orn, 0, 2.2f * k);
}
// the middle: a diamond outline with a small filled one inside, and a short stroke on each side of it
float d = 9.0f * k * 1.08f, inner = 3.2f * k * 1.08f, t = 1.6f * k;
dl->AddQuad(ImVec2(cx, y - d), ImVec2(cx + d, y), ImVec2(cx, y + d), ImVec2(cx - d, y), orn, t);
dl->AddQuadFilled(ImVec2(cx, y - inner), ImVec2(cx + inner, y), ImVec2(cx, y + inner), ImVec2(cx - inner, y), orn);
dl->AddLine(ImVec2(cx - orn_w * 0.5f, y), ImVec2(cx - d - 3 * k, y), orn, t);
dl->AddLine(ImVec2(cx + d + 3 * k, y), ImVec2(cx + orn_w * 0.5f, y), orn, t);
}
void skin_panel(ImDrawList *dl, ImVec2 a, ImVec2 b, float s, float alpha) {

View File

@@ -322,11 +322,13 @@ bool opt_toggle(const char *key, UiTextId label, UiTextId desc, bool inverted) {
}
bool opt_toggle_value(const char *key, UiTextId label, UiTextId desc, const char *on_value, const char *off_value, const char *note) {
const CfgMeta *m = cfg_meta(key);
bool on = strcmp(cfg_value(key), off_value) != 0, def = m && strcmp(m->def, off_value) != 0;
// the default as the player's profile has it (cfg_default: the Steam Deck's fifo), which is also what "on" writes
// when it is an on value, so turning the switch off and on again puts the profile's own value back
const char *d = cfg_default(key);
bool on = strcmp(cfg_value(key), off_value) != 0, def = d && strcmp(d, off_value) != 0;
RowView v = {menu_text(on ? UI_MENU_ON : UI_MENU_OFF), on == def, false, note};
if (!row(key, label, desc, v)) return false; // any step flips it
apply(key, on ? off_value : on_value);
apply(key, on ? off_value : def && *d ? d : on_value);
return true;
}

View File

@@ -245,6 +245,7 @@ static const char *const text_en[UI_TEXT_COUNT] = {
[UI_MENU_QUIT_CONFIRM] = "Quit the game? Progress since your last save is lost.",
[UI_MENU_RESTART_YES] = "Restart",
[UI_MENU_RESTART_SAVING] = "The game is saving: Restart is possible in a moment.",
[UI_MENU_QUIT_SAVING] = "The game is saving: Quit is possible in a moment.",
[UI_MENU_GROUP_STICKS] = "Analog sticks",
[UI_MENU_LSTICK_DEADZONE] = "Left stick dead zone", [UI_MENU_RSTICK_DEADZONE] = "Right stick dead zone",
[UI_MENU_STICK_DEADZONE_DESC] = "How far a stick must move to register. Raise it if a stick drifts.",
@@ -715,6 +716,7 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
[UI_MENU_QUIT_CONFIRM] = "Выйти из игры? Прогресс после последнего сохранения будет потерян.",
[UI_MENU_RESTART_YES] = "Перезапустить",
[UI_MENU_RESTART_SAVING] = "Игра сохраняется: перезапуск будет доступен через мгновение.",
[UI_MENU_QUIT_SAVING] = "Игра сохраняется: выход будет доступен через мгновение.",
[UI_MENU_GROUP_STICKS] = "Стики",
[UI_MENU_LSTICK_DEADZONE] = "Мёртвая зона левого стика", [UI_MENU_RSTICK_DEADZONE] = "Мёртвая зона правого стика",
[UI_MENU_STICK_DEADZONE_DESC] = "Насколько нужно сдвинуть стик, чтобы он сработал. Увеличьте при дрейфе.",
@@ -1351,6 +1353,7 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
[UI_MENU_QUIT_CONFIRM] = "Sair do jogo? O progresso desde o último salvamento é perdido.",
[UI_MENU_RESTART_YES] = "Reiniciar",
[UI_MENU_RESTART_SAVING] = "O jogo está salvando: dá para reiniciar em um instante.",
[UI_MENU_QUIT_SAVING] = "O jogo está salvando: dá para sair em um instante.",
[UI_MENU_STICK_SENS] = "Velocidade da câmera (analógico)",
[UI_MENU_STICK_SENS_DESC] = "Velocidade da câmera no analógico direito.",
[UI_MENU_WALK_KEY] = "Tecla de andar",
@@ -1939,6 +1942,7 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
[UI_MENU_QUIT_CONFIRM] = "退出游戏?上次存档之后的进度将会丢失。",
[UI_MENU_RESTART_YES] = "重启",
[UI_MENU_RESTART_SAVING] = "游戏正在存档:稍后即可重启。",
[UI_MENU_QUIT_SAVING] = "游戏正在存档:稍后即可退出。",
[UI_MENU_STICK_SENS] = "摇杆镜头速度",
[UI_MENU_STICK_SENS_DESC] = "右摇杆的镜头速度。",
[UI_MENU_WALK_KEY] = "步行键",

View File

@@ -105,7 +105,7 @@ typedef enum {
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,
// "Restart now" under the prompts while they change after restart, and its confirmation (Cancel: UI_MENU_RESET_NO)
UI_MENU_RESTART, UI_MENU_RESTART_DESC, UI_MENU_RESTART_CONFIRM, UI_MENU_QUIT_CONFIRM, UI_MENU_RESTART_YES, UI_MENU_RESTART_SAVING,
UI_MENU_RESTART, UI_MENU_RESTART_DESC, UI_MENU_RESTART_CONFIRM, UI_MENU_QUIT_CONFIRM, UI_MENU_RESTART_YES, UI_MENU_RESTART_SAVING, UI_MENU_QUIT_SAVING,
UI_MENU_GROUP_STICKS, UI_MENU_LSTICK_DEADZONE, UI_MENU_RSTICK_DEADZONE, UI_MENU_STICK_DEADZONE_DESC,
UI_MENU_STICK_DEADZONE_NOW, UI_MENU_STICK_SENS, UI_MENU_STICK_SENS_DESC,
UI_MENU_MOUSE_MODE, UI_MENU_MOUSE_MODE_DESC, UI_MENU_MOUSE_STICK, UI_MENU_MOUSE_DIRECT,

View File

@@ -1046,8 +1046,8 @@ class SavesTest(unittest.TestCase):
def test_save_folders_found(self):
plain = self.files(self.tmp / 'backup', {'save.dat': b'1', 'option.dat': b'2'})
self.assertEqual(saves.find_save(plain)[0], plain)
emu = self.files(self.tmp / 'emu', {'0000000000000000/' + 'a' * 32 + '/0100F2C0115B6000/save.dat': b'1'})
self.assertEqual(saves.find_save(emu)[0].name, '0100F2C0115B6000')
emu = self.files(self.tmp / 'emu', {'0000000000000000/' + 'a' * 32 + '/0100000000010000/save.dat': b'1'})
self.assertEqual(saves.find_save(emu)[0].name, '0100000000010000')
other = self.files(self.tmp / 'other', {'profile/save/' + 'b' * 32 + '/save.dat': b'1', 'ocarina.exe': b''})
self.assertEqual(saves.find_save(other)[0].name, 'b' * 32)
self.assertEqual(saves.find_save(other / 'profile')[0].name, 'b' * 32)
@@ -1072,7 +1072,7 @@ class SavesTest(unittest.TestCase):
def test_import_from_the_saves_folder(self):
d = saves.ensure_saves_dir(self.game)
self.assertIsNone(saves.waiting(self.game)[0]) # README.txt alone is no save
self.files(d, {'emu/0000000000000000/' + 'c' * 32 + '/0100F2C0115B6000/save.dat': b'emu'})
self.files(d, {'emu/0000000000000000/' + 'c' * 32 + '/0100000000010000/save.dat': b'emu'})
self.assertIsNotNone(saves.waiting(self.game)[0])
r = saves.import_save(self.game, log=lambda m: None)
self.assertIsNone(r['backup']) # there was no save yet
@@ -1087,6 +1087,58 @@ class SavesTest(unittest.TestCase):
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'emu')
self.assertIsNone(saves.waiting(self.game)[0])
def test_picking_the_saves_folder_takes_what_waits_there(self):
acct = saves.account_dir(self.game)
self.files(acct, {'save.dat': b'mine'})
d = saves.ensure_saves_dir(self.game)
with self.assertRaises(saves.SaveError): # README.txt alone is no save
saves.import_save(self.game, d, log=lambda m: None)
self.assertEqual((acct / 'save.dat').read_bytes(), b'mine')
self.files(d, {'save.dat': b'put there'})
r = saves.import_save(self.game, d, log=lambda m: None)
self.assertEqual(sorted(p.name for p in acct.iterdir()), ['save.dat']) # not README.txt or a backup
self.assertEqual((acct / 'save.dat').read_bytes(), b'put there')
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'mine')
def test_an_interrupted_commit_is_backed_up(self):
# runtime/fs_host.c save_recover: with no account folder and no finished commit, save-tx/new is the save, and
# without it save-tx/old; the import keeps that only copy
for half in ('new', 'old'):
with self.subTest(half=half):
shutil.rmtree(self.game, ignore_errors=True)
self.game.mkdir()
acct = saves.account_dir(self.game)
tx = self.game / 'profile' / 'save-tx'
self.files(tx, {f'{half}/save.dat': b'only copy'})
src = self.files(self.tmp / f'mine-{half}', {'save.dat': b'imported'})
r = saves.import_save(self.game, src, log=lambda m: None)
self.assertEqual((acct / 'save.dat').read_bytes(), b'imported')
self.assertEqual((r['backup'] / 'save.dat').read_bytes(), b'only copy')
self.assertEqual((r['backup'] / 'save-tx' / half / 'save.dat').read_bytes(), b'only copy')
def test_a_failed_swap_keeps_the_old_save(self):
acct = saves.account_dir(self.game)
self.files(acct, {'save.dat': b'old'})
src = self.files(self.tmp / 'mine', {'save.dat': b'imported'})
real = saves.os.replace
def replace(a, b):
if Path(a).name == 'import': # the imported save going into place: antivirus holds a file
raise PermissionError(5, 'Access is denied')
return real(a, b)
saves.os.replace = replace
try:
with self.assertRaises(PermissionError):
saves.import_save(self.game, src, log=lambda m: None)
finally:
saves.os.replace = real
self.assertEqual((acct / 'save.dat').read_bytes(), b'old')
def test_another_installs_finished_commit_is_its_save(self):
other = self.files(self.tmp / 'other', {'profile/save/' + 'b' * 32 + '/save.dat': b'older',
'profile/save-tx/ready-2/save.dat': b'newest'})
self.assertEqual(saves.find_save(other)[0].name, 'ready-2')
def test_refusals(self):
acct = saves.account_dir(self.game)
self.files(acct, {'save.dat': b'1'})

View File

@@ -32,7 +32,8 @@ version (fast_clang: the same objects, about a fifth faster).
python tools/make_game.py --make-runtime-lib <kit>/lib --msys <ucrt64> # the release's prebuilt runtime
Nothing is written outside --out. The game folder then holds only what this PC built from the player's files: never
Nothing is written outside --out (but for --make-runtime-lib DIR, which writes DIR). The game folder then holds
only what this PC built from the player's files: never
share it. Needs Python 3.10+ with numpy, lz4, zstandard and zbic (pip), and MSYS2 UCRT64's clang and libraries
(README-BuildKit.md lists the commands). Delete <out>/build-cache afterwards to free about 5 GB; the next build then
compiles everything again.
@@ -938,7 +939,8 @@ def step_compile(out, cache_dir, msys, jobs, romfs_job=None):
rsp = cache_dir / 'link.rsp'
rsp.write_text('\n'.join('"%s"' % o.as_posix() for o in objs), encoding='utf-8')
# lld links several times faster than GNU ld; the stack matches tools/build_game.py's build
ld = ['-fuse-ld=lld', '-Wl,--stack,16777216'] if (msys / 'bin' / 'ld.lld.exe').is_file() else []
# 16 MiB for the main thread with either linker (GNU ld takes the same flag): the default 2 MiB is too little
ld = (['-fuse-ld=lld'] if (msys / 'bin' / 'ld.lld.exe').is_file() else []) + ['-Wl,--stack,16777216']
lib = ['-Wl,--whole-archive', str(prebuilt[0]), '-Wl,--no-whole-archive'] if prebuilt else []
stamp = [[o.name, o.stat().st_size, o.stat().st_mtime_ns] for o in objs]
if prebuilt:
@@ -1012,15 +1014,31 @@ def load_state(path):
return state
def cpu_tag():
"""This processor's features the lifted code's level follows (cpu_level), as Windows reports them; part of the
compile key, so a game folder moved to a PC without them (or a new processor) is compiled again rather than kept
with instructions the processor lacks. 'any' where it cannot be read."""
if os.name != 'nt':
return 'any'
try:
import ctypes
present = ctypes.windll.kernel32.IsProcessorFeaturePresent
# PF_SSE4_2_INSTRUCTIONS_AVAILABLE 38, PF_AVX2_INSTRUCTIONS_AVAILABLE 40, PF_AVX512F_INSTRUCTIONS_AVAILABLE 41
return ''.join('1' if present(f) else '0' for f in (38, 40, 41))
except (OSError, AttributeError):
return 'any'
def step_key(step, identity, out=None):
"""What a step's output depends on. exefs and romfs: the game files only; lift: the unpacked ExeFS and the
lifter; compile: the lift, the runtime sources and this script."""
lifter; compile: the lift, the runtime sources, this script and the processor (cpu_tag)."""
if step in ('exefs', 'romfs'):
return f'{step}-1|{identity}'
lift = f'lift|{exefs_digest(out)}|{lift_digest()}'
if step == 'lift':
return lift
compile_key = f'compile|{lift}|{tree_digest(KIT / "runtime", ("**/*",))}|{sha256_file(Path(__file__))}|{kit_version()}'
compile_key = (f'compile|{lift}|{tree_digest(KIT / "runtime", ("**/*",))}|{sha256_file(Path(__file__))}|{kit_version()}'
f'|cpu {cpu_tag()}')
if step == 'compile':
return compile_key
# shaders: the built game and the translator that prepared them
@@ -1130,8 +1148,9 @@ def main():
break
if romfs_job:
romfs_job.join()
except SystemExit:
# a failed lift or compile: the RomFS image still finishes and is kept, so the next run starts after it
except (SystemExit, Exception):
# a failed lift or compile, or an error of this script: the RomFS image still finishes and is kept, so the
# next run starts after it (the thread is a daemon, and the process ending would cut its .part file short)
if romfs_job and romfs_job.thread.is_alive():
say('waiting for the RomFS image before stopping')
romfs_job.thread.join()

View File

@@ -62,6 +62,10 @@ const char *cfg_value(const char *name) {
const CfgMeta *m = cfg_meta(name);
return v ? v : m ? m->def : "";
}
const char *cfg_default(const char *key) { // no device profile here: the row's own default
const CfgMeta *m = cfg_meta(key);
return m ? m->def : NULL;
}
int cfg_int(const char *name) { return atoi(cfg_value(name)); }
double cfg_double(const char *name) { return atof(cfg_value(name)); }
int cfg_bool(const char *name) { return strcmp(cfg_value(name), "0") != 0; }