Put a picked mod together beside the mods folder and move it in once it is complete.

Add a Mod copied straight into the mods folder, so a copy that stopped halfway left a half mod there, switched on
at the game's next start. It now goes into a folder beside the mods folder and moves in with one rename; a failed or
interrupted copy removes what it made.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 22:09:07 -05:00
parent 3ca1541e0e
commit 2a7fda9506
3 changed files with 129 additions and 11 deletions

View File

@@ -368,7 +368,9 @@ that folder is there already, and the archive stays; a mod folder whose files si
that held the mod's own folder) gets them moved up; a `romfs`, `romfslite`, `exefs` or `cheats` folder put straight into
the mods folder becomes a mod of its own, *Unnamed mod*. An archive that would write outside its folder is refused.
Nothing is deleted. Add a Mod and Add a Mod Folder do the same for an archive or folder the player picks, which is
unpacked or copied into the mods folder under a free name; Open Mods Folder opens it.
unpacked or copied into the mods folder under a free name; Open Mods Folder opens it. The picked mod is put together
in a folder beside the mods folder (`.adding-mod`) and moves in with one rename once it is complete, so a copy that
stops halfway (a full disk, the launcher closed) leaves no half mod for the game to load (`mods.add`).
Each mod has an On/Off row; its description says what it changes (how many game files, code patches, cheats), which
other mod changes the same files and which of the two wins, and why the game leaves it off when it does: a code module

View File

@@ -21,12 +21,14 @@ Mods that change the game's code (code cheats, ExeFS patches) need the game buil
code_waiting() names the enabled ones whose files the last build did not take in (build-cache/mod-code.json), and
Play rebuilds the game first then. The build reads them from <game>/mods only.
"""
import errno
import json
import os
import re
import shutil
import subprocess
import sys
import time
import zipfile
from pathlib import Path
@@ -475,9 +477,52 @@ def prepare(game_dir, log=None):
return done
STAGING = '.adding-mod' # beside the mods folder: where add() puts a mod together before it moves in
STAGING_STALE = 24 * 3600 # seconds after which a staging folder is one a stopped launcher left
def _staging(folder):
"""A new, empty folder for add() to put a mod together in. It is beside the mods folder, not in it: the game
and the launcher take every folder of the mods folder for a mod, and beside it is the same disk, so the
finished mod moves in with one rename. The mods folder itself when nothing can be made beside it. What a
launcher that was stopped in the middle left there a day ago or longer is removed."""
try:
for old in folder.parent.glob(STAGING + '*'):
if old.is_dir() and time.time() - old.stat().st_mtime > STAGING_STALE:
shutil.rmtree(old, ignore_errors=True)
stage = _unique(folder.parent, STAGING)
stage.mkdir()
except OSError:
stage = _unique(folder, STAGING)
stage.mkdir()
return stage
def _move_in(made, folder, name):
"""Rename the finished mod folder made into the mods folder under name, or the first free name after it (looked
for now, not before the copy: a folder of that name may have come meanwhile). Returns where it is."""
for attempt in range(3):
target = _unique(folder, name)
try:
os.rename(made, target)
return target
except OSError as e:
if e.errno == errno.EXDEV: # the mods folder is on another disk than its parent (a mount, a junction)
shutil.move(str(made), str(target))
return target
if attempt == 2:
raise
time.sleep(0.3) # a scanner still holds a file just written
def add(game_dir, src, log=None):
"""Add an archive or a folder the player picked as a mod: unpacked or copied into the mods folder under its name
(a free one), in order for the game. Returns the new mod's folder."""
(a free one), in order for the game. Returns the new mod's folder.
The mod is put together in a folder beside the mods folder (_staging) and moves in only when it is complete. A
copy that stops halfway (a full disk, a file that cannot be read, the launcher closed) leaves nothing in the
mods folder: copied straight into it, the half that had arrived was a mod of its own, switched on, at the
game's next start."""
src = Path(src)
folder = ensure_mods_dir(game_dir)
if src.is_dir():
@@ -487,18 +532,24 @@ def add(game_dir, src, log=None):
except ValueError:
pass
name = src.name if src.name.lower() not in MOD_TOP else UNNAMED
target = _unique(folder, name)
if src.name.lower() in MOD_TOP: # the player picked the mod's romfs (or atmosphere) folder itself
target.mkdir()
shutil.copytree(src, target / src.name.lower() if src.name.lower() in LOOSE_PARTS else target / src.name)
else:
shutil.copytree(src, target)
elif src.suffix.lower() in ARCHIVES:
target = _unique(folder, src.stem.strip().rstrip('.') or UNNAMED)
unpack(src, target)
name = src.stem.strip().rstrip('.') or UNNAMED
else:
raise ModError('it is no archive (.zip, .7z or .rar) and no folder')
unwrap(target)
stage = _staging(folder)
try:
made = stage / 'mod'
if not src.is_dir():
unpack(src, made)
elif src.name.lower() in MOD_TOP: # the player picked the mod's romfs (or atmosphere) folder itself
made.mkdir()
shutil.copytree(src, made / src.name.lower() if src.name.lower() in LOOSE_PARTS else made / src.name)
else:
shutil.copytree(src, made)
unwrap(made)
target = _move_in(made, folder, name)
finally: # also on Ctrl+C and when the window closes: nothing half made stays
shutil.rmtree(stage, ignore_errors=True)
if log:
log(f'mods: added {src} as {target.name}')
return target

View File

@@ -5117,6 +5117,71 @@ class ModsTest(Tmp):
self.assertTrue((src / 'Picked.zip').is_file()) # the player's download stays where it was
with self.assertRaises(self.mods.ModError):
self.mods.add(self.game, src / 'Folder Mod' / 'Inner' / 'exefs' / 'E0F3CCFAF50065AE.ips')
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), []) # nothing stays beside the mods folder
def test_a_mod_that_does_not_arrive_whole_leaves_nothing(self):
"""A copy that stops halfway (a full disk, the launcher closed) or an archive that cannot be unpacked
leaves no folder in the mods folder, which the game would load as a mod, and none beside it."""
src = self.tmp / 'Downloads' / 'Big Mod'
(src / 'romfs').mkdir(parents=True)
for n in range(4):
(src / 'romfs' / f'{n}.txt').write_text('x')
self.file('Kept/romfs/k.txt')
real_copytree = shutil.copytree
for stop in (OSError(28, 'No space left on device'), KeyboardInterrupt()):
copied = []
def copy(a, b, stop=stop, copied=copied):
if len(copied) == 2:
raise stop
copied.append(b)
return shutil.copy2(a, b)
def copytree(a, b, *args, copy=copy, **kw): # shutil.copytree with a copy that stops at the third file
if args or kw: # shutil's own call for a folder inside, which hands the copy on
return real_copytree(a, b, *args, **kw)
return real_copytree(a, b, copy_function=copy)
with mock.patch.object(shutil, 'copytree', copytree), self.assertRaises(type(stop)):
self.mods.add(self.game, src)
self.assertEqual(len(copied), 2) # two files had arrived
self.assertEqual(self.tree(), ['Kept/romfs/k.txt'])
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), [])
(src.parent / 'Broken.zip').write_bytes(b'PK\x03\x04 not a zip')
with self.assertRaises(self.mods.ModError):
self.mods.add(self.game, src.parent / 'Broken.zip')
self.assertEqual(self.tree(), ['Kept/romfs/k.txt'])
self.assertEqual([m.name for m in self.mods.scan(self.game)], ['Kept'])
self.assertEqual(self.mods.add(self.game, src).name, 'Big Mod') # and the same mod goes in whole after
self.assertEqual(len(self.tree()), 5)
def test_a_mod_is_put_together_beside_the_mods_folder(self):
"""While a mod is copied the mods folder does not hold it; it moves in under a name that is free then; and
what a stopped launcher left beside the mods folder long ago is removed."""
src = self.tmp / 'Downloads' / 'Late'
(src / 'romfs').mkdir(parents=True)
(src / 'romfs' / 'a.txt').write_text('a')
left = self.game / self.mods.STAGING
(left / 'mod' / 'romfs').mkdir(parents=True)
old = time.time() - 2 * self.mods.STAGING_STALE
os.utime(left, (old, old))
fresh = self.game / (self.mods.STAGING + ' 2') # another add still at work
fresh.mkdir()
seen = []
real_copytree = shutil.copytree
def copytree(a, b, *args, **kw):
if not seen: # the first call; shutil calls it per folder
seen.append((Path(b), self.tree()))
(self.folder / 'Late' / 'romfs').mkdir(parents=True) # a folder of that name comes meanwhile
return real_copytree(a, b, *args, **kw)
with mock.patch.object(shutil, 'copytree', copytree):
target = self.mods.add(self.game, src)
self.assertEqual(seen[0][1], []) # nothing in the mods folder while copying
self.assertEqual(seen[0][0].parent.parent, self.game) # it was put together beside it
self.assertEqual(target.name, 'Late 2')
self.assertEqual(self.tree(), ['Late 2/romfs/a.txt']) # the folder that came holds no file
self.assertFalse(left.exists())
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), [fresh])
def test_code_waits_for_a_build_that_saw_it(self):
import make_game