diff --git a/docs/launcher.md b/docs/launcher.md index de2d3077..6e6998cf 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -135,6 +135,12 @@ game/ the built game, unless another folder was chosen (its l A path the Windows ANSI code page cannot hold breaks the MSYS2 tools, so the toolchain then goes to `C:\NX2navi\toolchain` instead (`launcher/paths.py`). +Windows and the build tools stop at 260 characters for a file's whole path, and the files of a built game need up to +128 below the game folder (the prompt folders' resource table is the longest). Begin Installation and the install +job therefore refuse a game folder whose own path is longer than 130 characters, with both lengths +(`paths.long_path_problem`). Longer paths are not supported: the launcher does not use Windows' long path form, and +the compiler, the linker and `tar` would not take it. + ## Installing `jobs.InstallJob`, one row per step: diff --git a/launcher/jobs.py b/launcher/jobs.py index 74678075..f8dfef5e 100644 --- a/launcher/jobs.py +++ b/launcher/jobs.py @@ -9,7 +9,7 @@ import threading import time import traceback -from . import build, feed, gamefiles, locks, net, toolchain, updater, versions, winsys +from . import build, feed, gamefiles, locks, net, paths, toolchain, updater, versions, winsys RECHECK = 3600 # seconds between update checks while the launcher stays open, hidden during play included @@ -456,6 +456,9 @@ class InstallJob(Job): return build.install_args(self.ctx.layout, self.dump, self.game_dir) def run(self): + too_long = paths.long_path_problem(self.game_dir) # the window asks first (begin_install); the command line here + if too_long: + raise build.BuildError(too_long) self.ensure_tools('tools') self.make_game(self.args(), {s: s for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts', 'scan')}) diff --git a/launcher/paths.py b/launcher/paths.py index fd98c330..5d104233 100644 --- a/launcher/paths.py +++ b/launcher/paths.py @@ -38,6 +38,29 @@ def ansi_safe(path): return False +# Windows: a file's whole path may be 259 characters for the programs that do not ask for more, and the build's tools +# (the MSYS2 compiler, linker and resource compiler, tar) are among them whatever the system's long path setting. +# LONGEST_IN_GAME is the room the game folder's own files need below it: the longest path a built game folder held +# was 106 characters (romfs-patches-playstation-label\System\Resource\ResourceSizeTable...rsizetable.zs), and a file +# is written under a temporary name up to 22 characters longer before it gets its own. +WINDOWS_PATH_MAX = 259 +LONGEST_IN_GAME = 128 + + +def long_path_problem(game_dir): + """'' when the files of a game built into game_dir fit Windows' path length, else the sentence that says how long + the folder's path is and how long it may be. Always '' elsewhere. Without it a build into a deep folder stops + somewhere in the middle with "file not found" for a file that is there.""" + if os.name != 'nt' or not game_dir: + return '' + n, most = len(os.path.abspath(str(game_dir))), WINDOWS_PATH_MAX - 1 - LONGEST_IN_GAME + if n <= most: + return '' + return (f'the path of the game folder is {n} characters long, and the game\'s files need {LONGEST_IN_GAME} more: ' + f'Windows and the build tools stop at {WINDOWS_PATH_MAX + 1} for a file\'s whole path. Choose a folder ' + f'whose path has at most {most} characters, such as D:\\Games\\NX2navi') + + def fixed_drive(drive): """True when drive (X:\\) is a fixed disk (GetDriveTypeW DRIVE_FIXED), not a network, removable or optical drive. True where that cannot be asked.""" diff --git a/launcher/ui/app.py b/launcher/ui/app.py index 7d8439e1..977d4278 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -39,6 +39,7 @@ from tkinter import filedialog from .. import NAME, build, feed, gameini, gamefiles, jobs, locks, mods, saves, steam, versions, winsys from ..context import Context +from ..paths import long_path_problem from . import art, picture, sound W, H = 1000, 640 @@ -966,6 +967,11 @@ class App: self.choose_dump() return gd = self.game_dir() + too_long = long_path_problem(gd) # told here, not as a missing file halfway through the build + if too_long: + self.message = App.friendly(too_long) + self.render() + return drop_partial_image(gd) # before the free space is measured if free_gb(gd) < 34 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 34 GB.' diff --git a/tests/launcher_test.py b/tests/launcher_test.py index d93fb8e6..1a2d8a1c 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -5130,6 +5130,38 @@ class BeginInstallTest(Tmp): self.assertIn('31 GB free', rows['Install Folder'].desc[0]) self.assertIn('(31 GB free)', rows['Begin Installation'].desc[0]) + def test_a_game_folder_whose_path_is_too_long_is_refused_first(self): + """Windows and the build tools stop at 260 characters for a file's whole path, and the game folder's own + files need 128 below it: a deeper folder is refused at Begin Installation and by the install job with the + lengths, where the build stopped in the middle with a file "not found". No limit elsewhere.""" + from launcher import paths + most = paths.WINDOWS_PATH_MAX - 1 - paths.LONGEST_IN_GAME + fits = 'C:\\' + 'g' * (most - 3) + deep = fits + 'g' + with mock.patch.object(paths.os, 'name', 'nt'), \ + mock.patch.object(paths.os.path, 'abspath', lambda p: p): # as Windows gives these paths back + self.assertEqual(paths.long_path_problem(fits), '') + self.assertEqual(paths.long_path_problem(''), '') + said = paths.long_path_problem(deep) + self.assertIn(f'is {most + 1} characters long', said) + self.assertIn(f'at most {most} characters', said) + with mock.patch.object(paths.os, 'name', 'posix'): + self.assertEqual(paths.long_path_problem('/' + 'g' * 4000), '') + if os.name != 'nt': + return + App, a = mock_app(self, deep) + a.settings['dump'] = str(self.tmp / 'game.nup') + a.game_dir.return_value = deep + App.begin_install(a) + a.run_job.assert_not_called() + self.assertTrue(a.message.startswith('The path of the game folder is')) + ctx = JobTest.make_ctx(self, 'ok') + self.addCleanup(ctx._file.close) + j = jobs.InstallJob(ctx, self.tmp / 'g.nup', deep).run_inline() + self.assertEqual(j.result, 'failed') + self.assertIn('characters long', j.error) + self.assertEqual(j.step('tools').state, jobs.PENDING) # before the build tools are fetched + class UpdateTextTest(Tmp): def test_the_updates_row_does_not_promise_a_few_minutes(self):