Refuse a game folder whose path leaves no room for the game's files.

Windows and the build tools stop at 260 characters for a file's whole path, and a built game needs up to 128 below
its folder. A deeper folder made the build stop in the middle with a file "not found"; Begin Installation and the
install job now refuse it first and name both lengths.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 22:24:26 -05:00
parent 0a85e33e0f
commit 8c32aedabb
5 changed files with 71 additions and 1 deletions

View File

@@ -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:

View File

@@ -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')})

View File

@@ -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."""

View File

@@ -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.'

View File

@@ -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):