mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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:
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user