Keep the game installed when an update stops in the compile.

An update whose lift ran again drops the compile step's record before the
compile runs, so a compile that then stopped left state.json without it,
build.built_version said '' and the launcher's next start showed the
first-run screen and asked for the dump. build.installed now also counts a
folder that holds the exe of the build before and game files a rebuild can
start from: the window opens on home, where Rebuild the Game reads "Not
Finished" and Play says which build it starts, and an update's rebuild and
the command line's go on with the game. Begin Installation into a folder
whose game files are already unpacked builds on from them instead of
reading the dump again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 15:11:06 -05:00
parent 7b35fe7504
commit 88495c296c
6 changed files with 175 additions and 6 deletions

View File

@@ -240,7 +240,11 @@ The stub swaps `python.next` in while no Python runs, then starts the new versio
objects of 0.5.0 and earlier); Rebuild the Game compiles it for this processor's (`--retune`), and runs the
compile step for that even when nothing else changed (`retune_due`).
6. **Swap In the New Build**: lld links `ocarina.exe.new`, which replaces `ocarina.exe` only once it is complete. A
failed rebuild leaves the old build playable; Rebuild the Game tries again.
failed rebuild leaves the old build playable; Rebuild the Game tries again. A rebuild that lifted again and then
stopped has no finished compile on record (`build.built_version` is empty), but the old exe and the unpacked game
files are there: the launcher still counts the game as installed (`build.installed`), opens on home, where
Rebuild the Game reads "Not Finished", and never asks for the dump again. Begin Installation into a folder whose
game files are already unpacked builds on from them as well (`jobs.InstallJob.args`).
7. **Prepare the Shaders** is skipped once shaders were prepared before (`shader-prepared.bin`): the game keeps using
them and translates them again with the new translator in the background (`NX2NAVI_SHADER_CARRY`). With
`shader_carry = 0`, or nothing prepared yet, the step prepares them all as at the install.

View File

@@ -151,3 +151,24 @@ def built_version(game_dir):
def can_rebuild(game_dir):
st = build_state(game_dir)
return st.get('version') == 2 and all(s in st.get('steps', {}) for s in ('exefs', 'romfs'))
def unpacked(game_dir):
"""True when the game files a rebuild reads are in the game folder (make_game.py BuiltSource names the same
ones): the unpacked ExeFS, the RomFS image and its index."""
gd = str(game_dir)
return all(os.path.isfile(os.path.join(gd, *parts)) for parts in
(('exefs', 'main'), ('romfs', 'romfs.bin'), ('romfs', 'index.json')))
def installed(game_dir):
"""True when the game folder holds a game the player can start: a finished build, or the exe of the last
finished build beside a build that stopped later. make_game.py drops a step's record when a step before it
runs again, so an update that lifted again and then stopped in the compile (an error, Cancel, a power cut)
leaves state.json without `compile` and built_version says '': the old exe is still there and playable, and
a rebuild continues from the unpacked game files without the dump (can_rebuild)."""
if not game_dir:
return False
if built_version(game_dir):
return True
return os.path.isfile(os.path.join(str(game_dir), 'ocarina.exe')) and can_rebuild(game_dir)

View File

@@ -98,7 +98,7 @@ def main(argv=None):
if a.cmd == 'continue-update':
gd = s.get('game_dir')
return follow(jobs.ContinueUpdateJob(ctx, gd if gd and build.built_version(gd) else '', a.old,
return follow(jobs.ContinueUpdateJob(ctx, gd if build.installed(gd) else '', a.old,
s.get('dump')))
if a.cmd == 'tools':

View File

@@ -301,9 +301,18 @@ class InstallJob(Job):
'scan', 'ready'])
self.dump, self.game_dir = str(dump), str(game_dir)
def args(self):
"""make_game.py's arguments. A game folder whose game files an earlier run already unpacked and checked
(build.can_rebuild, build.unpacked) is built on from those, for this processor's level as a first build is:
the dump is not read again, so an install that stopped after the RomFS image goes on although the dump
was moved or deleted since, and the 26 GB image is not packed a second time."""
if build.can_rebuild(self.game_dir) and build.unpacked(self.game_dir):
return build.rebuild_args(self.ctx.layout, self.game_dir, retune=True)
return build.install_args(self.ctx.layout, self.dump, self.game_dir)
def run(self):
self.ensure_tools('tools')
self.make_game(build.install_args(self.ctx.layout, self.dump, self.game_dir),
self.make_game(self.args(),
{s: s for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan')})
for sid in ('shaders', 'prompts', 'scan'):
if self.step(sid) and self.step(sid).state == PENDING:

View File

@@ -653,9 +653,11 @@ class App:
elif a.continue_update is not None and not a.rolled_back:
# after a rollback the stub passes the failed version's --continue-update on to this one: the game was
# never rebuilt for the new version, so there is nothing to finish, and home tells why the version is back
self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if gd and build.built_version(gd) else '',
self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if build.installed(gd) else '',
a.continue_update, self.settings.get('dump')), 'home')
elif gd and build.built_version(gd):
elif build.installed(gd):
# also a game whose last update stopped in the compile (build.installed): home, where Play starts the
# build from before the update and Rebuild the Game finishes it, not the first-run screen and its dump
self.show('home')
self.after_update_notice()
if self.install_staged('a staged update waited for the launcher to start'):
@@ -870,11 +872,17 @@ class App:
upd = Row('value', 'Updates', f'Version {mine}', 'normal', big=True, icon='refresh',
on=lambda: self.check_now(force=True),
desc=(f'You have NX2navi {mine}. Select this row to look for a new version.',))
rebuild_value = f'Built with {versions.display(built)}' if built else 'Not built'
# an update that stopped in the compile left no finished build on record, but the exe of the build before it
unfinished = not built and build.installed(gd)
rebuild_value = (f'Built with {versions.display(built)}' if built else
'Not Finished' if unfinished else 'Not built')
rebuild_style = 'normal' if versions.display(built) == mine else 'salmon'
play_desc = ('Start Ocarina of Time. The launcher steps aside while you play and comes back when you quit.',)
if built and versions.display(built) != mine:
play_desc += (f'The game was built with {versions.display(built)}. Rebuild the Game brings it to {mine}.',)
elif unfinished:
play_desc += (f'The last rebuild of the game did not finish, so Play starts the build from before it. '
f'Rebuild the Game finishes it and brings the game to {mine}; your dump is not needed.',)
if staged and self.settings.get('auto_install'):
play_desc += (f'NX2navi {staged["version"]} is downloaded and installs after you quit the game.',)
if self.message:

View File

@@ -1215,6 +1215,29 @@ def _nest(flat):
return tree
def made_up_game(out, steps=('exefs', 'romfs', 'lift', 'compile', 'shaders'), exe=True, kit='0.6.1'):
"""A game folder as tools/make_game.py leaves it, made up without game files: an ExeFS whose main is a header
with the game's build id, a RomFS image of zeros, the exe unless exe is False, and a state.json in which the
game-file steps are current and every other step named has the key of an earlier kit."""
import make_game
out = Path(out)
for d in ('exefs', 'romfs', 'build-cache'):
(out / d).mkdir(parents=True, exist_ok=True)
main = bytearray(0x100)
main[:4], main[0x40:0x50] = b'NSO0', bytes.fromhex(make_game.MAIN_BUILD_ID)
for n in make_game.EXEFS_FILES:
(out / 'exefs' / n).write_bytes(bytes(main) if n == 'main' else b'x' * 64)
(out / 'romfs' / 'romfs.bin').write_bytes(bytes(16))
(out / 'romfs' / 'index.json').write_text('{}')
if exe:
(out / 'ocarina.exe').write_bytes(b'MZ')
ident = 'nup X 1 1'
keys = {s: f'{s}-1|{ident}' if s in ('exefs', 'romfs') else f'old-{s}' for s in steps}
(out / 'build-cache' / 'state.json').write_text(json.dumps(
{'version': make_game.STATE_VERSION, 'input': ident, 'kit': kit, 'steps': keys}))
return out
# Stands in for windres.exe: records its arguments, working directory and temporary folder, opens the .rc and the
# files it names from the working directory as windres does, and leaves two temporary files behind as windres does.
FAKE_WINDRES = r'''
@@ -2290,6 +2313,42 @@ class MakeGameTest(Tmp):
self.assertIn('windres failed on runtime/nx2navi.rc: windres: preprocessing failed.', err.getvalue())
self.assertEqual(sorted(p.name for p in obj.iterdir()), ['nx2navi_rc.o'])
def main_rebuild(self, lift, compile_, *more):
"""make_game.main() --rebuild in this process on self.out, with the given functions in place of the lift and
the compile. Returns (the SystemExit code or 0, what it printed)."""
buf = io.StringIO()
argv = ['make_game.py', '--rebuild', '--out', str(self.out), '--msys', str(self.msys), *more]
with mock.patch.object(self.mg, 'step_lift', lift), mock.patch.object(self.mg, 'step_compile', compile_), \
mock.patch.object(self.mg, 'keep_awake', lambda: None), mock.patch.object(sys, 'argv', argv), \
contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
self.mg.main()
return 0, buf.getvalue()
except SystemExit as e:
return e.code, buf.getvalue()
def test_an_update_that_stops_in_the_compile_keeps_the_game(self):
"""An update whose lift ran again drops the compile step's record before the compile runs. When the compile
then stops, the folder still holds the exe of the build before and the unpacked game files: the launcher
counts it as installed (build.installed), not as a folder to install into with the dump."""
made_up_game(self.out)
self.assertEqual(build.built_version(self.out), '0.6.1')
def stop(*a, **k):
self.mg.fail('3 files did not compile')
code, _ = self.main_rebuild(lambda out, cache: None, stop)
self.assertEqual(code, 1)
st = json.loads((self.out / 'build-cache' / 'state.json').read_text())
self.assertEqual(sorted(st['steps']), ['exefs', 'lift', 'romfs'])
self.assertEqual(build.built_version(self.out), '') # no finished build on record
self.assertTrue(build.can_rebuild(self.out))
self.assertTrue(build.installed(self.out))
(self.out / 'ocarina.exe').unlink() # a first install that stopped there has nothing to play
self.assertFalse(build.installed(self.out))
self.assertFalse(build.installed(''))
self.assertFalse(build.installed(None))
self.assertFalse(build.installed(self.tmp / 'no such folder'))
# ---------------------------------------------------------------- jobs
@@ -2357,6 +2416,20 @@ class JobTest(Tmp):
self.assertIn('1.1.1', j.error)
self.assertEqual(j.step('check').state, jobs.FAILED)
def test_an_install_goes_on_from_the_unpacked_game_files(self):
"""Begin Installation into a folder whose game files an earlier run unpacked builds on from them (--rebuild
for this processor's level), so a moved or deleted dump is not asked for; without them it reads the dump."""
ctx = self.make_ctx('ok')
game = self.tmp / 'game'
first = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()
self.assertEqual(first[:2], ['--nup', str(self.tmp / 'g.nup')])
made_up_game(game, steps=('exefs', 'romfs'), exe=False)
again = jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()
self.assertEqual(again[:2], ['--rebuild', '--retune'])
self.assertNotIn('--nup', again)
(game / 'romfs' / 'romfs.bin').unlink() # the image is gone: only the dump brings it back
self.assertEqual(jobs.InstallJob(ctx, self.tmp / 'g.nup', game).args()[0], '--nup')
# ---------------------------------------------------------------- the icon
@@ -3122,6 +3195,60 @@ class WhilePlayingTest(Tmp):
a.root.deiconify.assert_called_once()
class UnfinishedUpdateTest(Tmp):
"""A game whose last update stopped in the compile (state.json without the compile step, the exe of the build
before still there): the window opens on home and says how to finish, and an update goes on with the game."""
def app(self, game):
try:
from launcher.ui.app import App
except ImportError as e:
self.skipTest(f'no window toolkit: {e}')
a = mock.MagicMock()
a.settings = {'game_dir': str(game), 'auto_check': False}
a.args = mock.Mock(rolled_back='', uninstall=False, continue_update=None)
a.ctx = Context(self.layout)
self.addCleanup(a.ctx._file.close)
a.layout = self.layout
a.install_staged.return_value = False
a.bg.ready.return_value = None
a.bg.staging.return_value = False
a.release = a.check = None
a.check_error = a.message = ''
a.checked_at = 0
return App, a
def test_the_window_opens_on_home(self):
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))
App, a = self.app(game)
App.start(a)
a.show.assert_called_once_with('home')
a.find_dumps.assert_not_called()
(game / 'ocarina.exe').unlink() # nothing to play: an install that never finished asks for the dump
App, a = self.app(game)
App.start(a)
a.show.assert_called_once_with('first_run')
def test_an_update_goes_on_with_the_game(self):
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))
App, a = self.app(game)
a.args.continue_update = '0.6.1'
App.start(a)
job = a.run_job.call_args[0][0]
self.assertIsInstance(job, jobs.ContinueUpdateJob)
self.assertEqual(job.game_dir, str(game)) # not '', which skips the rebuild with "No game yet"
def test_home_says_how_to_finish(self):
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))
App, a = self.app(game)
rows = {r.label: r for r in App.rows_home(a) if r.label}
self.assertEqual(rows['Rebuild the Game'].value, 'Not Finished')
self.assertTrue(any('Rebuild the Game finishes it' in d and 'dump is not needed' in d for d in rows['Play'].desc))
made_up_game(game) # a finished build of an earlier version
rows = {r.label: r for r in App.rows_home(a) if r.label}
self.assertEqual(rows['Rebuild the Game'].value, 'Built with 0.6.1')
class WindowTest(Tmp):
def test_selftest(self):
r = subprocess.run([sys.executable, '-m', 'launcher', '--root', str(self.tmp / 'w'), '--selftest'],