Remove a cut RomFS image before measuring the free space.

A RomFS step that was cut short (Cancel, a full disk, a power cut) leaves
romfs.bin.part, up to 26 GB. The next run writes the image anew, but
make_game.py's step_romfs and the launcher's Begin Installation measured
the free space first, with that file counted as used: on a nearly full
disk the retry was refused although it needs no more room than the first
try. Both remove the file before they measure, and the first-run screen
counts its size as free.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 15:15:35 -05:00
parent e69a84fe59
commit 6e38458f77
3 changed files with 138 additions and 19 deletions

View File

@@ -140,6 +140,25 @@ def note_exit_in_log(game_dir, text):
pass
def partial_image_gb(game_dir):
"""The size of what an install that was cut short left of the RomFS image (romfs/romfs.bin.part, up to 26 GB).
The next install writes the image anew (tools/make_game.py step_romfs), so that space is the install's to use:
the first-run screen counts it as free."""
try:
return (Path(game_dir) / 'romfs' / 'romfs.bin.part').stat().st_size / 1e9
except OSError:
return 0.0
def drop_partial_image(game_dir):
"""Removes that file (partial_image_gb), so that it does not count against the free space an install asks
for."""
try:
(Path(game_dir) / 'romfs' / 'romfs.bin.part').unlink(missing_ok=True)
except OSError:
pass
def drive_of(path):
return Path(path).anchor.rstrip('\\/') or str(Path(path).parent)
@@ -731,7 +750,7 @@ class App:
'found in Downloads, Desktop or Documents.',)
gd = self.game_dir()
need = 30
free = free_gb(gd)
free = free_gb(gd) + partial_image_gb(gd) # Begin Installation removes the partial image (begin_install)
folder_desc = (f'The game is built into this folder. {free:.0f} GB free on {drive_of(gd)}; the game needs '
f'about {need} GB. Select this row to choose another folder.',)
ready = bool(dump)
@@ -828,6 +847,7 @@ class App:
self.choose_dump()
return
gd = self.game_dir()
drop_partial_image(gd) # before the free space is measured
if free_gb(gd) < 31 and not (Path(gd) / 'romfs' / 'romfs.bin').is_file():
self.message = f'{drive_of(gd)} has {free_gb(gd):.0f} GB free; the game needs about 30 GB.'
self.render()

View File

@@ -2313,6 +2313,44 @@ 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 test_a_cut_romfs_image_does_not_count_as_used_space(self):
"""The RomFS step removes the romfs.bin.part of a run that was cut short before it measures the free space:
on a disk with just enough room for the image, the retry passes the check as the first run did."""
import collections
mb = 1 << 20
class Src:
romfs = [(f'f{i}.bin', i) for i in range(3)]
def romfs_size(self, ref):
return mb
def romfs_chunks(self, ref):
yield bytes([ref]) * mb
budget = 3 * mb + (1 << 30) + mb # the image, the margin the step asks for, and 1 MB to spare
usage = collections.namedtuple('usage', 'total used free')
def disk_usage(path): # a disk of that size that holds only the game folder
held = sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
return usage(budget, held, budget - held)
romfs = self.out / 'romfs'
romfs.mkdir(parents=True)
(romfs / 'romfs.bin.part').write_bytes(bytes(2 * mb)) # what the cut run left
with mock.patch.object(self.mg.shutil, 'disk_usage', disk_usage), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
self.mg.step_romfs(Src(), self.out, 2)
self.assertEqual((romfs / 'romfs.bin').stat().st_size, 3 * mb)
self.assertFalse((romfs / 'romfs.bin.part').exists())
# a disk that is too small even so still stops the step, and says by how much
(romfs / 'romfs.bin').unlink()
budget -= 2 * mb
err = io.StringIO()
with mock.patch.object(self.mg.shutil, 'disk_usage', disk_usage), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err), \
self.assertRaises(SystemExit):
self.mg.step_romfs(Src(), self.out, 2)
self.assertIn('GB free for the RomFS image', err.getvalue())
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)."""
@@ -3195,6 +3233,76 @@ class WhilePlayingTest(Tmp):
a.root.deiconify.assert_called_once()
class MemorySettings(dict):
"""The launcher's settings (launcher/settings.py) without their file."""
def save(self):
pass
def mock_app(test, game):
"""launcher.ui.app.App and a stand-in for its instance, for calling one of its methods without a window: the
settings name the game folder, the context and layout are real ones in the test's folder, nothing is staged,
no check has run and no message is set. Skips the test without the window toolkit."""
try:
from launcher.ui.app import App
except ImportError as e:
test.skipTest(f'no window toolkit: {e}')
a = mock.MagicMock()
a.settings = MemorySettings({'game_dir': str(game), 'auto_check': False})
a.args = mock.Mock(rolled_back='', uninstall=False, continue_update=None)
a.ctx = Context(test.layout)
test.addCleanup(a.ctx._file.close)
a.layout = test.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
class BeginInstallTest(Tmp):
def test_a_cut_romfs_image_does_not_block_the_install(self):
"""Begin Installation removes the romfs.bin.part an install that was cut short left before it measures the
free space, so the 31 GB it asks for are not short by the part's size."""
import collections
game = self.tmp / 'game'
part = game / 'romfs' / 'romfs.bin.part'
part.parent.mkdir(parents=True)
part.write_bytes(b'cut short')
usage = collections.namedtuple('usage', 'total used free')
def disk_usage(path): # 40 GB free without the part, 20 GB with it
return usage(10 ** 12, 0, 20 * 10 ** 9 if part.exists() else 40 * 10 ** 9)
App, a = mock_app(self, game)
a.settings['dump'] = str(self.tmp / 'game.nup')
a.settings['desktop_shortcut'] = False
a.game_dir.return_value = str(game)
with mock.patch('launcher.ui.app.shutil.disk_usage', disk_usage):
App.begin_install(a)
self.assertFalse(part.exists())
job = a.run_job.call_args[0][0]
self.assertIsInstance(job, jobs.InstallJob)
self.assertEqual(job.game_dir, str(game))
def test_the_first_run_screen_counts_a_cut_romfs_image_as_free(self):
from launcher.ui import app
game = self.tmp / 'game'
(game / 'romfs').mkdir(parents=True)
self.assertEqual(app.partial_image_gb(game), 0.0)
(game / 'romfs' / 'romfs.bin.part').write_bytes(bytes(2500000))
self.assertEqual(app.partial_image_gb(game), 0.0025)
App, a = mock_app(self, game)
a.settings['dump'] = str(self.tmp / 'game.nup')
a.game_dir.return_value = str(game)
with mock.patch.object(app, 'free_gb', lambda p: 5.0), mock.patch.object(app, 'partial_image_gb', lambda p: 26.0):
rows = {r.label: r for r in App.rows_first_run(a) if r.label}
self.assertIn('31 GB free', rows['Install Folder'].desc[0])
self.assertIn('(31 GB free)', rows['Begin Installation'].desc[0])
class StartScriptTest(Tmp):
"""launcher/start.pyw when the version cannot be imported: with a version to go back to it ends with code 86 at
once and shows no message box, so the stub's six-second wait sees the code and rolls back."""
@@ -3268,23 +3376,7 @@ class UnfinishedUpdateTest(Tmp):
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
return mock_app(self, game)
def test_the_window_opens_on_home(self):
game = made_up_game(self.tmp / 'game', steps=('exefs', 'romfs', 'lift'))

View File

@@ -387,12 +387,19 @@ def step_romfs(src, out, jobs):
dst.mkdir(parents=True, exist_ok=True)
files = sorted(((rel, ref, src.romfs_size(ref)) for rel, ref in src.romfs), key=lambda e: e[0].encode())
total = sum(size for _, _, size in files)
# What a run that was cut short (Cancel, a full disk, a power cut) left of the image goes first: this run
# writes the image anew, and counted as used space its up to 26 GB made a retry on a nearly full disk fail the
# check below although the retry needs no more room than the first try.
part = dst / 'romfs.bin.part'
try:
part.unlink(missing_ok=True)
except OSError as e:
fail(f'cannot remove {part}, left by an earlier run ({e.strerror or e}): close whatever has it open')
free = shutil.disk_usage(dst).free
if free < total + (1 << 30):
fail(f'{dst} needs {total / 1e9:.1f} GB free for the RomFS image, it has {free / 1e9:.1f} GB')
say(f' {len(files)} files, {total / 1e9:.1f} GB')
entries, h, pos, done, t0 = [], hashlib.sha256(), 0, 0, time.time()
part = dst / 'romfs.bin.part'
emit('progress', step='romfs', done=0, total=total, unit='bytes')
def load(e): # small files are read whole on a worker; big ones are streamed by the writer