From d01f0d70528ce8334a4e2d4f597cf4a582dc6d61 Mon Sep 17 00:00:00 2001 From: chrg6 Date: Tue, 10 Nov 2026 06:39:36 -0500 Subject: [PATCH] Give the Linux AppImage a window to install, update and play. Started without arguments on a desktop (a double-click), the AppImage showed nothing. It now opens a window (tools/appimage_gui.py, standard library and Pillow): the dump and the game folder through the desktop's own file choosers, Install or Update with a progress bar per step, Play, the last run's output, and an applications-menu entry for play.sh. The window runs AppRun --events, which does what the command line does and prints make_game.py's progress lines plus its own for the package check and DLSS. Its text is drawn with Pillow in the desktop's font and sized by the screen's DPI, in English or French by locale. The command line is unchanged. Pull request #291 by loteran. The window is taken as it is, with one change: the menu entry's Exec line quotes the game folder by the desktop entry rules, so a folder name with a quote, a dollar or a percent sign gives a working entry. The AppRun changes are applied by hand onto the script as it is now. The script passes bash -n and the window a syntax check; neither was run, on Linux or elsewhere. Co-Authored-By: Claude Fable 5.2 --- tools/appimage_gui.py | 533 +++++++++++++++++++++++++++++++++++++++++ tools/make_appimage.py | 29 ++- 2 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 tools/appimage_gui.py diff --git a/tools/appimage_gui.py b/tools/appimage_gui.py new file mode 100644 index 00000000..85c4d897 --- /dev/null +++ b/tools/appimage_gui.py @@ -0,0 +1,533 @@ +"""The Linux AppImage's window (tools/make_appimage.py): pick the dump and the game folder, install or update the game +with progress, and play. AppRun opens it when the AppImage starts without arguments on a desktop (double-click); the +command line keeps working as before. + +It runs AppRun itself with --events, which does what the command line does (the package check, zbic, make_game.py, +NVIDIA's DLSS, play.sh) and prints make_game.py's "@@" progress lines (tools/make_game.py, --events) plus its own for +the steps around it. The last dump and folder are remembered in ~/.config/nx2navi/appimage.json, the last run's output +in ~/.cache/nx2navi/build.log (Details opens it). + +The window is one Tk canvas. The Tk that comes with python-build-standalone has no Xft, so its only font is X11's +bitmap "fixed", unreadable on a high-DPI screen: text is drawn with Pillow in the desktop's own font (fc-match +sans-serif) as PNG images, everything is sized by the screen's DPI (Tk's winfo_fpixels, which follows Xft.dpi), and the +file choosers are the desktop's (kdialog or zenity, else Tk's). Without fc-match or Pillow, text falls back to Tk's. + + python3 tools/appimage_gui.py --apprun [--icon ] +""" +import argparse +import base64 +import io +import json +import locale +import os +import queue +import shutil +import subprocess +import threading +import tkinter as tk +from pathlib import Path + +CONFIG = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'nx2navi' / 'appimage.json' +CACHE = Path(os.environ.get('XDG_CACHE_HOME') or Path.home() / '.cache') / 'nx2navi' +APPS = Path(os.environ.get('XDG_DATA_HOME') or Path.home() / '.local' / 'share') / 'applications' +DEFAULT_OUT = Path.home() / 'Games' / 'Ocarina' + +# make_game.py's steps (STEPS, plus check) and AppRun's own around them, in the order they run +STEPS = ('deps', 'check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'dlss') + +TEXT = { + 'en': { + 'title': 'Ocarina of Time for Linux', + 'intro': 'Builds the game natively on this PC from your own dump (Switch 2, game 1.1.1). No game files are included.', + 'dump': 'Game dump', 'folder': 'Game folder', 'browse': 'Browse…', 'none': 'not chosen yet', + 'install': 'Install', 'update': 'Update', 'play': 'Play', 'cancel': 'Cancel', 'confirm': 'Stop the build?', + 'details': 'Details', 'shortcut': 'Add to the applications menu', + 'deps': 'Check the packages', 'check': 'Check the dump', 'exefs': 'Unpack the program files', + 'romfs': 'Build the RomFS image', 'lift': 'Turn the code into C', 'compile': 'Compile the game', + 'shaders': 'Prepare the shaders', 'prompts': 'Draw the button prompts', 'dlss': 'NVIDIA DLSS', + 'running': 'running', 'done': 'done', 'skip': 'kept', 'failed': 'failed', 'na': '—', + 'ready': 'Close other heavy programs while it builds: it uses a lot of memory.', + 'finished': 'Finished. Press Play.', 'stopped': 'Stopped:', 'cancelled': 'Cancelled. Run it again to go on.', + 'nodump': 'Choose your .nup dump first.', 'busy': 'A build is running: Cancel it first, or press close again.', + 'missing': 'Packages are missing. Install them (see Details), then press the button again.', + }, + 'fr': { + 'title': 'Ocarina of Time pour Linux', + 'intro': 'Construit le jeu en natif sur ce PC à partir de votre dump (Switch 2, jeu 1.1.1). Aucun fichier du jeu n\'est inclus.', + 'dump': 'Dump du jeu', 'folder': 'Dossier du jeu', 'browse': 'Parcourir…', 'none': 'pas encore choisi', + 'install': 'Installer', 'update': 'Mettre à jour', 'play': 'Jouer', 'cancel': 'Annuler', + 'confirm': 'Arrêter le build ?', 'details': 'Détails', 'shortcut': 'Ajouter au menu des applications', + 'deps': 'Vérifier les paquets', 'check': 'Vérifier le dump', 'exefs': 'Extraire les fichiers du programme', + 'romfs': 'Construire l\'image RomFS', 'lift': 'Convertir le code en C', 'compile': 'Compiler le jeu', + 'shaders': 'Préparer les shaders', 'prompts': 'Dessiner les icônes de boutons', 'dlss': 'NVIDIA DLSS', + 'running': 'en cours', 'done': 'fait', 'skip': 'conservé', 'failed': 'échec', 'na': '—', + 'ready': 'Fermez les gros programmes pendant le build : il utilise beaucoup de mémoire.', + 'finished': 'Terminé. Appuyez sur Jouer.', 'stopped': 'Arrêté :', + 'cancelled': 'Annulé. Relancez-le pour continuer.', 'nodump': 'Choisissez d\'abord votre dump .nup.', + 'busy': 'Un build est en cours : annulez-le d\'abord, ou fermez encore une fois.', + 'missing': 'Des paquets manquent. Installez-les (voir Détails), puis appuyez de nouveau sur le bouton.', + }, +} + +COLORS = {'bg': '#1f2329', 'panel': '#2a2f37', 'text': '#e8e6e3', 'dim': '#9aa0a8', 'accent': '#5fb3f0', + 'ok': '#7cc47f', 'warn': '#f0b35f', 'bad': '#f07a6a', 'button': '#3a414c', 'button_hi': '#4a5260', + 'primary': '#2f7fc1', 'primary_hi': '#3b90d6', 'track': '#3a3f47'} + + +def lang(): + code = (os.environ.get('LANGUAGE') or os.environ.get('LC_ALL') or + os.environ.get('LC_MESSAGES') or os.environ.get('LANG') or + (locale.getlocale()[0] or '')) + return 'fr' if code.lower().startswith('fr') else 'en' + + +T = TEXT[lang()] + + +def load_config(): + try: + return json.loads(CONFIG.read_text(encoding='utf-8')) + except (OSError, ValueError): + return {} + + +def save_config(cfg): + try: + CONFIG.parent.mkdir(parents=True, exist_ok=True) + CONFIG.write_text(json.dumps(cfg, indent=1), encoding='utf-8') + except OSError: + pass + + +def fc_font(pattern): + try: + out = subprocess.run(['fc-match', '-f', '%{file}', pattern], capture_output=True, text=True, timeout=5).stdout + return out if out and Path(out).is_file() else None + except (OSError, subprocess.SubprocessError): + return None + + +class Text: + """Text as PNG images in the desktop's font (Pillow), cached; None when that is not possible (Tk's text then).""" + def __init__(self, scale): + self.scale, self.cache = scale, {} + try: + from PIL import Image, ImageDraw, ImageFont + self.Image, self.ImageDraw, self.ImageFont = Image, ImageDraw, ImageFont + self.files = {False: fc_font('sans-serif'), True: fc_font('sans-serif:bold')} + self.ok = bool(self.files[False]) + except ImportError: + self.ok = False + self.fonts = {} + + def font(self, pt, bold): + key = (pt, bold) + if key not in self.fonts: + px = max(8, round(pt * self.scale * 96 / 72)) + self.fonts[key] = self.ImageFont.truetype(self.files[bold] or self.files[False], px) + return self.fonts[key] + + def wrap(self, text, pt, bold, width): + if not self.ok: + return [text] + f, lines = self.font(pt, bold), [] + for para in text.split('\n'): + line = '' + for word in para.split(' '): + trial = f'{line} {word}'.strip() + if line and f.getlength(trial) > width: + lines.append(line) + line = word + else: + line = trial + lines.append(line) + return lines + + def image(self, text, pt, color, bold=False): + key = (text, pt, color, bold) + if key in self.cache: + return self.cache[key] + f = self.font(pt, bold) + x0, y0, x1, y1 = f.getbbox(text or ' ') + ascent, descent = f.getmetrics() + img = self.Image.new('RGBA', (max(1, int(x1) + 2), ascent + descent), (0, 0, 0, 0)) + self.ImageDraw.Draw(img).text((0, 0), text, font=f, fill=color) + buf = io.BytesIO() + img.save(buf, 'PNG') + photo = tk.PhotoImage(data=base64.b64encode(buf.getvalue())) + self.cache[key] = photo + return photo + + +def pick_file(title, start): + """The desktop's file chooser (kdialog, zenity), else Tk's.""" + if shutil.which('kdialog'): + r = subprocess.run(['kdialog', '--title', title, '--getopenfilename', str(start), '*.nup|Switch 2 (*.nup)'], + capture_output=True, text=True) + return r.stdout.strip() or None + if shutil.which('zenity'): + r = subprocess.run(['zenity', '--file-selection', '--title', title, '--filename', f'{start}/', + '--file-filter=*.nup', '--file-filter=*'], capture_output=True, text=True) + return r.stdout.strip() or None + from tkinter import filedialog + return filedialog.askopenfilename(title=title, initialdir=str(start)) or None + + +def pick_dir(title, start): + if shutil.which('kdialog'): + r = subprocess.run(['kdialog', '--title', title, '--getexistingdirectory', str(start)], capture_output=True, + text=True) + return r.stdout.strip() or None + if shutil.which('zenity'): + r = subprocess.run(['zenity', '--file-selection', '--directory', '--title', title, '--filename', f'{start}/'], + capture_output=True, text=True) + return r.stdout.strip() or None + from tkinter import filedialog + return filedialog.askdirectory(title=title, initialdir=str(start)) or None + + +def exec_quote(path): + """A path as one quoted argument of a desktop entry's Exec line: inside the quotes a backslash goes before a + quote, a backtick, a dollar sign and a backslash, the line itself doubles each backslash again, and a percent sign + is doubled so that it is no field code.""" + out = str(path) + for ch in '\\"`$': + out = out.replace(ch, '\\' + ch) + return '"' + out.replace('\\', '\\\\').replace('%', '%%') + '"' + + +class App: + W, H = 760, 640 # logical pixels (96 dpi); everything is drawn at scale + + def __init__(self, root, apprun, icon): + self.root, self.apprun, self.icon = root, apprun, icon + self.s = max(1.0, root.winfo_fpixels('1i') / 96) + self.text = Text(self.s) + cfg = load_config() + self.nup = cfg.get('nup', '') + self.out = cfg.get('out', str(DEFAULT_OUT)) + self.shortcut = cfg.get('shortcut', True) + self.proc, self.events, self.log = None, queue.Queue(), [] + self.rows = {s: [0.0, ''] for s in STEPS} # fraction, state + self.status, self.status_color = T['ready'], 'dim' + self.confirm = self.close_armed = False + self.dirty = True + root.title(f"NX2navi – {T['title']}") + if icon and icon.is_file(): + try: + self.icon_img = tk.PhotoImage(file=str(icon)) + root.iconphoto(True, self.icon_img) + except tk.TclError: + pass + w, h = self.px(self.W), self.px(self.H) + root.geometry(f'{w}x{h}') + root.minsize(w, h) + root.configure(bg=COLORS['bg']) + self.c = tk.Canvas(root, width=w, height=h, bg=COLORS['bg'], highlightthickness=0) + self.c.pack(fill='both', expand=True) + self.c.bind('', lambda e: self.mark()) + self.buttons = {} + self.c.bind('', self.click) + self.c.bind('', self.hover) + self.hot = None + root.protocol('WM_DELETE_WINDOW', self.close) + root.after(50, self.tick) + + def px(self, v): + return int(round(v * self.s)) + + def mark(self): + self.dirty = True + + # ---- drawing + def label(self, x, y, text, pt=11, color='text', bold=False, anchor='nw'): + if self.text.ok: + return self.c.create_image(x, y, image=self.text.image(text, pt, COLORS[color], bold), anchor=anchor) + return self.c.create_text(x, y, text=text, fill=COLORS[color], anchor=anchor, + font=('TkDefaultFont', int(pt), 'bold' if bold else 'normal')) + + def rect(self, x0, y0, x1, y1, r, fill, outline=''): + r = min(r, (x1 - x0) / 2, (y1 - y0) / 2) + pts = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, x0, y1, + x0, y1 - r, x0, y0 + r, x0, y0] + return self.c.create_polygon(pts, smooth=True, fill=fill, outline=outline) + + def button(self, key, x1, y, text, primary=False, enabled=True): + """A button whose right edge is at x1; returns its left edge.""" + pt = 11 + width = (self.text.font(pt, True).getlength(text) if self.text.ok else len(text) * self.px(8)) + self.px(36) + h = self.px(40) + x0 = x1 - width + hot = self.hot == key and enabled + fill = (COLORS['primary_hi' if hot else 'primary'] if primary else COLORS['button_hi' if hot else 'button']) + if not enabled: + fill = COLORS['panel'] + self.rect(x0, y, x1, y + h, self.px(8), fill) + self.label((x0 + x1) / 2, y + h / 2, text, pt, 'text' if enabled else 'dim', bold=True, anchor='center') + self.buttons[key] = (x0, y, x1, y + h, enabled) + return x0 + + def draw(self): + c, s = self.c, self.s + c.delete('all') + self.buttons = {} + w = c.winfo_width() or self.px(self.W) + m = self.px(24) + y = self.px(20) + self.label(m, y, T['title'], 18, bold=True) + y += self.px(40) + for line in self.text.wrap(T['intro'], 10.5, False, w - 2 * m): + self.label(m, y, line, 10.5, 'dim') + y += self.px(20) + y += self.px(12) + + # the dump and the folder + for key, title, value in (('dump', T['dump'], self.nup), ('out', T['folder'], self.out)): + self.rect(m, y, w - m, y + self.px(56), self.px(10), COLORS['panel']) + self.label(m + self.px(14), y + self.px(8), title, 10, 'dim', bold=True) + shown = value or T['none'] + if self.text.ok: + f, room = self.text.font(10.5, False), w - 2 * m - self.px(170) + while f.getlength(shown) > room and len(shown) > 8: + shown = '…' + shown[len(shown) // 8 + 2:] + self.label(m + self.px(14), y + self.px(28), shown, 10.5, 'text' if value else 'dim') + self.button('pick_' + key, w - m - self.px(10), y + self.px(8), T['browse'], enabled=not self.proc) + y += self.px(68) + + # the steps + y += self.px(4) + label_w = self.px(270) + state_w = self.px(90) + for step in STEPS: + frac, state = self.rows[step] + self.label(m, y, T[step], 10.5) + bx0, bx1 = m + label_w, w - m - state_w + by = y + self.px(7) + self.rect(bx0, by, bx1, by + self.px(10), self.px(5), COLORS['track']) + if frac > 0: + color = COLORS['bad'] if state == 'failed' else COLORS['ok'] if state in ('done', 'skip') else COLORS['accent'] + self.rect(bx0, by, bx0 + max(self.px(10), (bx1 - bx0) * frac), by + self.px(10), self.px(5), color) + if state: + text = T['na'] if state == 'na' else T[state] + color = {'failed': 'bad', 'done': 'ok', 'skip': 'ok', 'running': 'accent'}.get(state, 'dim') + self.label(w - m, y, text, 10, color, anchor='ne') + y += self.px(30) + + # status, the shortcut box, the buttons + y += self.px(8) + for line in self.text.wrap(self.status, 10.5, False, w - 2 * m)[:4]: + self.label(m, y, line, 10.5, self.status_color) + y += self.px(20) + bottom = c.winfo_height() or self.px(self.H) + by = bottom - m - self.px(40) + box = self.px(20) + bx, bxy = m, by + self.px(10) + self.rect(bx, bxy, bx + box, bxy + box, self.px(4), COLORS['primary'] if self.shortcut else COLORS['track']) + if self.shortcut: + c.create_line(bx + box * .22, bxy + box * .52, bx + box * .43, bxy + box * .74, bx + box * .8, bxy + box * .28, + fill=COLORS['text'], width=max(2, self.px(2.5))) + self.label(bx + box + self.px(10), bxy + box / 2, T['shortcut'], 10, anchor='w') + self.buttons['shortcut'] = (bx, bxy, bx + box + self.px(260), bxy + box, True) + + built = (Path(self.out) / 'ocarina').is_file() + playable = (Path(self.out) / 'play.sh').is_file() + x = w - m + x = self.button('play', x, by, T['play'], primary=playable and not self.proc, enabled=playable and not self.proc) + main = T['confirm'] if self.confirm else T['cancel'] if self.proc else T['update'] if built else T['install'] + x = self.button('main', x - self.px(10), by, main, primary=not playable and not self.proc) + self.button('details', x - self.px(10), by, T['details'], enabled=CACHE.joinpath('build.log').is_file() or bool(self.log)) + self.dirty = False + + # ---- input + def hit(self, e): + for key, (x0, y0, x1, y1, enabled) in self.buttons.items(): + if enabled and x0 <= e.x <= x1 and y0 <= e.y <= y1: + return key + return None + + def hover(self, e): + key = self.hit(e) + if key != self.hot: + self.hot = key + self.c.config(cursor='hand2' if key else '') + self.mark() + + def click(self, e): + key = self.hit(e) + if key == 'pick_dump': + f = pick_file(T['dump'], Path(self.nup).parent if self.nup else Path.home()) + if f: + self.nup = f + elif key == 'pick_out': + d = pick_dir(T['folder'], Path(self.out).parent if self.out else Path.home()) + if d: + self.out = d + elif key == 'shortcut': + self.shortcut = not self.shortcut + elif key == 'details': + self.details() + elif key == 'play': + self.play() + elif key == 'main': + self.main_button() + if key != 'main': + self.confirm = False + if key: + save_config({'nup': self.nup, 'out': self.out, 'shortcut': self.shortcut}) + self.mark() + + # ---- the build + def main_button(self): + if self.proc: + if not self.confirm: + self.confirm = True + return + self.confirm, self.cancelled = False, True + self.kill() + return + if not self.nup or not Path(self.nup).is_file(): + self.status, self.status_color = T['nodump'], 'warn' + return + self.rows = {s: [0.0, ''] for s in STEPS} + self.log, self.cancelled, self.failed, self.missing = [], False, None, False + self.status, self.status_color = T['ready'], 'dim' + self.proc = subprocess.Popen([str(self.apprun), '--events', self.nup, self.out], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, errors='replace', bufsize=1, + start_new_session=True) + threading.Thread(target=self.read, args=(self.proc,), daemon=True).start() + + def kill(self): + try: + os.killpg(self.proc.pid, 15) + except (OSError, AttributeError): + pass + + def read(self, proc): + for line in proc.stdout: + self.events.put(line.rstrip('\n')) + self.events.put(('exit', proc.wait())) + + def tick(self): + try: + while True: + item = self.events.get_nowait() + if isinstance(item, tuple): + self.finished(item[1]) + else: + self.line(item) + except queue.Empty: + pass + if self.dirty: + self.draw() + self.root.after(100, self.tick) + + def line(self, text): + if not text.startswith('@@'): + self.log.append(text) + del self.log[:-5000] + if text.startswith('Missing:'): + self.missing = True + return + try: + ev = json.loads(text[2:]) + except ValueError: + return + kind, step = ev.get('ev'), ev.get('step') + row = self.rows.get(step) + if kind == 'step' and row is not None: + state = ev.get('state') + if state == 'start': + row[1] = 'running' + elif state == 'skip': + row[0], row[1] = 1.0, 'na' if step == 'dlss' else 'skip' + else: + row[0], row[1] = 1.0, 'done' + elif kind == 'progress' and row is not None: + frac = float(ev['fraction']) if 'fraction' in ev else ev.get('done', 0) / max(ev.get('total', 1), 1) + row[0] = max(0.0, min(1.0, frac)) + if ev.get('detail'): + self.status, self.status_color = ev['detail'], 'dim' + elif kind == 'error': + self.failed = ev.get('message') or '' + if row is not None: + row[1] = 'failed' + else: + return + self.mark() + + def finished(self, code): + self.proc = None + try: + CACHE.mkdir(parents=True, exist_ok=True) + CACHE.joinpath('build.log').write_text('\n'.join(self.log) + '\n', encoding='utf-8') + except OSError: + pass + if self.cancelled: + self.status, self.status_color = T['cancelled'], 'warn' + elif code == 0: + self.status, self.status_color = T['finished'], 'ok' + if self.shortcut: + self.add_shortcut() + elif self.missing: + self.status, self.status_color = T['missing'], 'bad' + else: + last = self.failed or next((l for l in reversed(self.log) if l.strip()), str(code)) + self.status, self.status_color = f"{T['stopped']} {last}", 'bad' + self.mark() + + def details(self): + log = CACHE / 'build.log' + if self.log: # the run in progress, as far as it went + try: + CACHE.mkdir(parents=True, exist_ok=True) + log.write_text('\n'.join(self.log) + '\n', encoding='utf-8') + except OSError: + pass + if log.is_file(): + subprocess.Popen(['xdg-open', str(log)], start_new_session=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + # ---- play and the menu entry + def play(self): + play = Path(self.out) / 'play.sh' + if play.is_file(): + subprocess.Popen([str(play)], cwd=str(play.parent), start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def add_shortcut(self): + out = Path(self.out) + try: + icon = out / 'nx2navi.png' + if self.icon and self.icon.is_file() and not icon.is_file(): + icon.write_bytes(self.icon.read_bytes()) + APPS.mkdir(parents=True, exist_ok=True) + (APPS / 'nx2navi-ocarina.desktop').write_text( + '[Desktop Entry]\nType=Application\nName=Ocarina of Time (NX2navi)\n' + f'Exec={exec_quote(out / "play.sh")}\nPath={out}\nIcon={icon}\nTerminal=false\nCategories=Game;\n', + encoding='utf-8') + except OSError: + pass + + def close(self): + if self.proc and not self.close_armed: + self.close_armed = True + self.status, self.status_color = T['busy'], 'warn' + self.mark() + return + if self.proc: + self.kill() + self.root.destroy() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--apprun', type=Path, required=True) + ap.add_argument('--icon', type=Path) + a = ap.parse_args() + root = tk.Tk(className='nx2navi') + App(root, a.apprun, a.icon) + root.mainloop() + + +if __name__ == '__main__': + main() diff --git a/tools/make_appimage.py b/tools/make_appimage.py index d64b3890..99696708 100644 --- a/tools/make_appimage.py +++ b/tools/make_appimage.py @@ -1,5 +1,7 @@ """Build NX2navi-Linux--x86_64.AppImage: the Linux build kit as one file, which builds Ocarina of Time on the -player's PC from their own dump with tools/make_game.py, as NX2navi-Setup.exe does on Windows. +player's PC from their own dump with tools/make_game.py, as NX2navi-Setup.exe does on Windows. Started without +arguments on a desktop it opens a window to install, update and play (tools/appimage_gui.py, which runs AppRun +with --events and reads its progress lines). python3 tools/make_appimage.py --python --appimagetool [--out dist] [--dlss libnvidia-ngx-dlss.so. | --no-dlss] @@ -45,6 +47,7 @@ usage() { cat < [game folder] build (default folder: ~/Games/Ocarina) $(basename "${APPIMAGE:-$0}") --check only check the dump's version $(basename "${APPIMAGE:-$0}") --make-game run tools/make_game.py directly @@ -54,8 +57,19 @@ everything the build writes comes from your dump, so keep it to yourself. EOF } +# No arguments on a desktop (a double-click): the window (tools/appimage_gui.py), which runs this file with --events. +if [ $# -eq 0 ] && [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + exec "$PY" -I "$KIT/tools/appimage_gui.py" --apprun "$HERE/AppRun" --icon "$HERE/nx2navi.png" +fi case "$1" in ""|-h|--help) usage; exit 0 ;; esac +# --events: the window's mode. make_game.py prints its "@@" progress lines (--events), and the steps around it print +# theirs (deps, dlss) in the same form. +EVENTS=() +ev() { [ ${#EVENTS[@]} -gt 0 ] && printf '@@{"ev":"%s","step":"%s"%s}\n' "$1" "$2" "${3:+,$3}"; return 0; } +if [ "$1" = --events ]; then EVENTS=(--events); shift; fi +ev step deps '"state":"start"' + missing=() for tool in clang clang++ ld.lld; do command -v "$tool" >/dev/null || missing+=("$tool"); done for h in SDL3/SDL.h vulkan/vulkan.h opus/opus.h vpx/vpx_decoder.h dav1d/dav1d.h zstd.h; do @@ -73,14 +87,17 @@ Install the compiler and libraries, then run this again: Debian, Ubuntu: sudo apt install clang lld libsdl3-dev libopus-dev libvpx-dev libdav1d-dev libzstd-dev libvulkan-dev EOF + ev error deps '"message":"missing packages"' exit 1 fi if ! PYTHONPATH="$PYDEPS" "$PY" -c 'import zbic' 2>/dev/null; then echo "Installing zbic (GPL-2.0, unpacks the game's program files) into $PYDEPS ..." "$PY" -m pip install --quiet --disable-pip-version-check --target "$PYDEPS" zbic || { - echo "error: pip could not install zbic (it needs the internet once)" >&2; exit 1; } + echo "error: pip could not install zbic (it needs the internet once)" >&2 + ev error deps '"message":"pip could not install zbic (it needs the internet once)"'; exit 1; } fi +ev step deps '"state":"done"' # One compile per 4 GB of RAM at most: with 16 GB, one per thread beside the RomFS image fills memory and swap. mem_gb=$(awk '/MemTotal/ {print int($2 / 1048576)}' /proc/meminfo) @@ -125,8 +142,12 @@ get_dlss() { } echo "Building into $OUT with $jobs compiles at a time (${mem_gb} GB of RAM); this takes 10 to 25 minutes." -run_make_game --nup "$NUP" --out "$OUT" --jobs "$jobs" || exit $? -get_dlss +run_make_game --nup "$NUP" --out "$OUT" --jobs "$jobs" "${EVENTS[@]+"${EVENTS[@]}"}" || exit $? +if [ -e /proc/driver/nvidia/version ] && [ -z "${NX2NAVI_NO_DLSS:-}" ]; then + ev step dlss '"state":"start"'; get_dlss; ev step dlss '"state":"done"' +else + ev step dlss '"state":"skip"' +fi # play.sh: the environment the game wants on this machine, then the game {