mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
NX2navi-Setup.exe installs the launcher into %LOCALAPPDATA%\Programs\NX2navi without administrator rights, with shortcuts and an Apps entry. The launcher finds the player's .nup, downloads the build tools itself (the MSYS2 UCRT64 packages toolchain.lock.json pins, checked by sha256 and unpacked without pacman, and zbic from PyPI) and runs tools/make_game.py, whose steps it shows with their progress. Its window follows the game's options menu: colours sampled from it, the menu's filigree, the white glowing cursor, the Triforce in the title band and as the programs' icon, and all text in the game's Cetra Display, decoded from the player's own dump. Mouse, keyboard and an XInput pad drive it. Updates come from GitHub releases (NX2navi-update.json): the new version is downloaded, checked and staged beside the running one, the stub restarts into it and falls back to the previous version when it fails to start, and the game is rebuilt without the dump. make_game.py now keys its exefs and romfs steps on the game files alone, has --rebuild and --events, compiles only the files whose source, headers, flags or compiler changed, and replaces ocarina.exe only once the new one links. tools/make_launcher.py builds the release on Windows or Linux. Tested: tests/launcher_test.py (19 tests), the real toolchain download and install, and under Wine 11 the setup, the stub, the window on the bundled Windows Python, an update from a local feed by clicking Updates, the rollback of a version that fails to start, and the downloaded clang compiling and linking a program against the game's libraries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Merged onto the current port: make_game.py keeps the launcher's step keys, cached link and in-place swap, and its own runtime manifest (windres), AMD's FidelityFX library and the shader preparation, which becomes a step of its own ("shaders") that the launcher shows in its install, rebuild and update jobs. make_launcher.py ships the shader tools and, with --ffx, AMD's library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
7.2 KiB
Python
174 lines
7.2 KiB
Python
"""--selftest [--shots DIR]: draw every screen with made-up states, optionally save a PNG of each, and close.
|
|
Nothing runs: the jobs are built and their steps set by hand."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from .. import feed, jobs
|
|
|
|
|
|
def grab_gdi(x, y, w, h, path):
|
|
"""Windows: copy the screen rectangle with GDI and write it as PNG (art.png_bytes); no extra packages."""
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
import numpy as np
|
|
from . import art
|
|
user32, gdi32 = ctypes.windll.user32, ctypes.windll.gdi32
|
|
user32.GetDC.restype = wintypes.HDC
|
|
gdi32.CreateCompatibleDC.restype = wintypes.HDC
|
|
gdi32.CreateCompatibleBitmap.restype = wintypes.HBITMAP
|
|
gdi32.SelectObject.restype = wintypes.HGDIOBJ
|
|
screen = user32.GetDC(None)
|
|
mem = gdi32.CreateCompatibleDC(screen)
|
|
bmp = gdi32.CreateCompatibleBitmap(screen, w, h)
|
|
old = gdi32.SelectObject(mem, bmp)
|
|
gdi32.BitBlt(mem, 0, 0, w, h, screen, x, y, 0x00CC0020) # SRCCOPY
|
|
|
|
class BITMAPINFOHEADER(ctypes.Structure):
|
|
_fields_ = [('biSize', wintypes.DWORD), ('biWidth', wintypes.LONG), ('biHeight', wintypes.LONG),
|
|
('biPlanes', wintypes.WORD), ('biBitCount', wintypes.WORD), ('biCompression', wintypes.DWORD),
|
|
('biSizeImage', wintypes.DWORD), ('biXPelsPerMeter', wintypes.LONG),
|
|
('biYPelsPerMeter', wintypes.LONG), ('biClrUsed', wintypes.DWORD),
|
|
('biClrImportant', wintypes.DWORD)]
|
|
bi = BITMAPINFOHEADER(ctypes.sizeof(BITMAPINFOHEADER), w, -h, 1, 32, 0, 0, 0, 0, 0, 0)
|
|
buf = (ctypes.c_ubyte * (w * h * 4))()
|
|
gdi32.GetDIBits(mem, bmp, 0, h, buf, ctypes.byref(bi), 0)
|
|
gdi32.SelectObject(mem, old)
|
|
gdi32.DeleteObject(bmp)
|
|
gdi32.DeleteDC(mem)
|
|
user32.ReleaseDC(None, screen)
|
|
a = np.frombuffer(buf, dtype=np.uint8).reshape(h, w, 4)[..., [2, 1, 0, 3]].copy()
|
|
a[..., 3] = 255
|
|
Path(path).write_bytes(art.png_bytes(a))
|
|
return True
|
|
|
|
|
|
def grab(app, path):
|
|
"""A PNG of the window: GDI on Windows, else PIL's ImageGrab, else ImageMagick's import on X11."""
|
|
app.root.update()
|
|
x, y = app.root.winfo_rootx(), app.root.winfo_rooty()
|
|
w, h = app.root.winfo_width(), app.root.winfo_height()
|
|
if os.name == 'nt':
|
|
try:
|
|
return grab_gdi(x, y, w, h, path)
|
|
except Exception as e:
|
|
app.ctx.log(f'selftest: screen capture failed: {e!r}')
|
|
try:
|
|
from PIL import ImageGrab
|
|
ImageGrab.grab(bbox=(x, y, x + w, y + h), xdisplay=os.environ.get('DISPLAY') if os.name != 'nt' else None
|
|
).save(path)
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
subprocess.run(['import', '-window', str(app.root.winfo_id()), str(path)], check=True, capture_output=True)
|
|
return True
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return False
|
|
|
|
|
|
def fake_release(version='0.3.11'):
|
|
m = {'format': 1, 'version': version, 'app': {'name': f'NX2navi-app-{version}.zip', 'size': 1, 'sha256': '0'}}
|
|
notes = ('- Each program module comes from the .nup package that holds it whole.\n'
|
|
'- The PC stays awake while the game builds.\n- This launcher: one-click install, updates and play.')
|
|
return feed.Release(m, notes)
|
|
|
|
|
|
def states(app):
|
|
ctx = app.ctx
|
|
s = app.settings
|
|
s['dump'] = s.get('dump') or str(Path.home() / 'Downloads' / 'Ocarina of Time.nup')
|
|
s['game_dir'] = s.get('game_dir') or ('D:\\Games\\NX2navi' if os.name == 'nt' else '/games/NX2navi')
|
|
app.dumps = [Path(s['dump'])]
|
|
|
|
def first_run():
|
|
app.show('first_run', len(app.rows_first_run()) - 1)
|
|
|
|
def installing():
|
|
j = jobs.InstallJob(ctx, s['dump'], s['game_dir'])
|
|
for sid, val in (('tools', 'Done · 0:48'), ('check', 'Version 1.1.1'), ('exefs', 'Done · 0:04'),
|
|
('romfs', 'Done · 0:31')):
|
|
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
|
|
j.begin('lift')
|
|
j.progress('lift', 0.34, '412 of 1,204 chunks')
|
|
j.started = time.time() - 192
|
|
for ln in ('check: Ocarina of Time 1.1.1 (build e0f3ccfa)', 'romfs: 26.0 of 26.0 GB, 31 s', 'lift:',
|
|
' emitting with 16 workers', ' emitted 412/1204 chunks, 18s'):
|
|
j.lines.append(ln)
|
|
app.job, app.job_return, app.job_back = j, 'home', 'first_run'
|
|
app.show('job')
|
|
|
|
def home():
|
|
app.release, app.checked_at, app.check, app.check_error, app.message = None, time.time() - 5, None, '', ''
|
|
app.show('home', 0)
|
|
|
|
def update_ready():
|
|
app.release = fake_release()
|
|
app.show('home', 1)
|
|
|
|
def updating():
|
|
j = jobs.UpdateDownloadJob(ctx, fake_release())
|
|
for sid, val in (('download', 'Done · 0:06'), ('verify', 'Checksum OK')):
|
|
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
|
|
j.skip('tools_update', 'Unchanged')
|
|
j.step('lift').state, j.step('lift').value, j.step('lift').fraction = jobs.DONE, 'Done · 0:41', 1.0
|
|
j.begin('compile_changed')
|
|
j.progress('compile_changed', 0.61, '238 of 391 changed files')
|
|
j.started = time.time() - 104
|
|
app.job, app.job_return = j, 'home'
|
|
app.show('job')
|
|
|
|
def settings():
|
|
app.show('settings', 0)
|
|
|
|
def failed():
|
|
j = jobs.InstallJob(ctx, s['dump'], s['game_dir'])
|
|
for sid, val in (('tools', 'Done · 0:48'), ('check', 'Version 1.1.1'), ('exefs', 'Done · 0:04'),
|
|
('romfs', 'Done · 0:31'), ('lift', 'Done · 0:48')):
|
|
j.step(sid).state, j.step(sid).value, j.step(sid).fraction = jobs.DONE, val, 1.0
|
|
j.step('compile').state, j.step('compile').value = jobs.FAILED, 'Stopped'
|
|
j.result, j.error = 'failed', '3 files did not compile'
|
|
j.started, j.finished = time.time() - 362, time.time()
|
|
for ln in ('compile:', ' compiled 1200 of 1212, 236 s', 'error: 3 files did not compile'):
|
|
j.lines.append(ln)
|
|
app.job, app.job_return, app.job_back = j, 'home', 'first_run'
|
|
app.show('job')
|
|
|
|
def uninstall():
|
|
app.show('uninstall', 0)
|
|
|
|
return [('1-first-run', first_run), ('2-installing', installing), ('3-home', home),
|
|
('4-update-ready', update_ready), ('5-updating', updating), ('6-settings', settings),
|
|
('7-build-failed', failed), ('8-uninstall', uninstall)]
|
|
|
|
|
|
def run(app, shots):
|
|
out = Path(shots) if shots else None
|
|
if out:
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
seq = states(app)
|
|
ok = True
|
|
|
|
def step(i=0):
|
|
nonlocal ok
|
|
if i >= len(seq):
|
|
app.ctx.log(f'selftest: {len(seq)} screens drawn')
|
|
print(f'selftest: {len(seq)} screens drawn' + (f', shots in {out}' if out else ''), flush=True)
|
|
app.root.after(100, app.root.destroy)
|
|
return
|
|
name, fn = seq[i]
|
|
try:
|
|
fn()
|
|
app.root.update()
|
|
except Exception as e:
|
|
ok = False
|
|
print(f'selftest: {name}: {e!r}', file=sys.stderr, flush=True)
|
|
raise
|
|
if out:
|
|
app.root.after(350, lambda: (grab(app, out / f'{name}.png'), step(i + 1)))
|
|
else:
|
|
app.root.after(50, lambda: step(i + 1))
|
|
step()
|