Skip to content

Commit 705716a

Browse files
committed
Support writable overlay mounts on squash mounts
Operating systems like DOS, Amiga, macOS, and others expect to change the filesystem, and this is frequently used by games to save settings, write save game files, (among other uses). Because squashfs is a read-only mount, this limits its use to games that never write any data. Figuring this out for DOS games can be trial and error, and worst case the user has to forfeit the tidy benefits of using squash. This PR lets emulators indicate that they need write access to the ROM area, in which case a writable overlayfs mount will be placed on top of the squashfs mount (if squash is used), with the writes going into the usual `/userdata/saves/<platform>/<game>` area. The overlay feature is a follow-on context after squash: - It handles squash's directory or single-file and linked output - It logs and cleans up the virtual area on context close - It preserves the writeable area if changes were made, but otherwise cleans it up if it's empty. Log staments: ``` DEBUG (overlayfs.py:43):_mount mounted '/var/run/overlays/biomenace-bare.pc' with components 'lowerdir=/var/run/squashfs/biomenace-bare.pc, upperdir=/userdata/saves/dos/biomenace-bare.pc/upper, workdir=/userdata/saves/dos/biomenace-bare.pc/work' DEBUG (overlayfs.py:85):mount_overlayfs cleaning up '/var/run/overlays/biomenace-bare.pc' DEBUG (overlayfs.py:90):mount_overlayfs keeping populated save directory '/userdata/saves/dos/biomenace-bare.pc' ``` I've tested all four types of changes: - Modifying an existing file in the squash base - Deleting an existing file in the squash base - Adding or changing a new file that doesn't exist in the squash base - Deleting a previously created file not in the squash base Using squashfs for PC games has a nice side-effect of keeping the underlying game in a known pristine state as any risky changes (patches, cracks, cheats and so on that a user might employ) can be wiped clean by deleting the save area. As of this PR I've only toggled it on for the DOSBox Staging generator, but could be easily be added to others if someone has working ROMs for those platforms (Amiga, MacOS, etc..)
1 parent 9267d26 commit 705716a

File tree

7 files changed

+139
-14
lines changed

7 files changed

+139
-14
lines changed

batocera-Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
- You can now choose to create a Win32 WINE bottle only via the option to run 32-bit Windows games.
7979
- DOSBox Staging's working directory is now set to the games' folder, allowing for local and relative (img)mount and conf file references.
8080
- DOSBox Staging will fallback to a C:\> prompt inside the games' folder if its missing dosbox.cfg/.conf/.bat files.
81+
- DOSBox Staging now stores DOS filesystem changes in /userdata/saves/dos/<game> for squashfs ROMs.
8182
- Systems like WINE and DOSBOX can now be prepared from PCManFM context menu. Right click on file items inside supported ones.
8283
to presetup them. This is mostly thought for startup files like dosbox.bat and autorun.cmd and for handling squashed archive files.
8384
- RPCS3 PS Move (light gun) mapping simplified. D-pad buttons are now PS Move face buttons. Check wiki for more info.

package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
from .utils.evmapy import evmapy
3636
from .utils.hotkeygen import set_hotkeygen_context
3737
from .utils.logger import setup_logging
38-
from .utils.squashfs import squashfs_rom
38+
from .utils.squashfs import mount_squashfs
39+
from .utils.overlayfs import mount_overlayfs
3940

4041
if TYPE_CHECKING:
4142
from collections.abc import Iterable
@@ -55,12 +56,23 @@
5556
_evmapy_instance = None
5657

5758
def main(args: argparse.Namespace, maxnbplayers: int) -> int:
59+
original_rom = args.rom
60+
5861
# squashfs roms if squashed
59-
if args.rom.suffix == ".squashfs":
60-
with squashfs_rom(args.rom) as rom:
61-
return start_rom(args, maxnbplayers, rom, args.rom)
62+
if original_rom.suffix == ".squashfs":
63+
with mount_squashfs(original_rom) as squash_rom:
64+
65+
# Do we need a writable overlay for the read-only squash?
66+
system = Emulator(args, original_rom)
67+
generator = get_generator(system.config.emulator)
68+
if generator.writesToRom():
69+
rom_saves_dir = SAVES / original_rom.parent.name / original_rom.stem
70+
with mount_overlayfs(squash_rom, rom_saves_dir) as overlay_rom:
71+
return start_rom(args, maxnbplayers, overlay_rom, original_rom)
72+
73+
return start_rom(args, maxnbplayers, squash_rom, original_rom)
6274
else:
63-
return start_rom(args, maxnbplayers, args.rom, args.rom)
75+
return start_rom(args, maxnbplayers, original_rom, original_rom)
6476

6577
def start_rom(args: argparse.Namespace, maxnbplayers: int, rom: Path, original_rom: Path) -> int:
6678
global _active_player_controllers, _evmapy_instance

package/batocera/core/batocera-configgen/configgen/configgen/generators/Generator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def getMouseMode(self, config: SystemConfig, rom: Path) -> bool:
3838
def executionDirectory(self, config: SystemConfig, rom: Path) -> Path | None:
3939
return None
4040

41+
# Some systems expect to write into the ROM area, for example: DOS, Amiga, and Wine
42+
def writesToRom(self) -> bool:
43+
return False
44+
4145
# mame or libretro have internal bezels, don't display the one of mangohud
4246
def supportsInternalBezels(self) -> bool:
4347
return False

package/batocera/core/batocera-configgen/configgen/configgen/generators/dosboxstaging/dosboxstagingGenerator.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,24 @@ class DosBoxStagingGenerator(Generator):
2525
# Returns a populated Command object
2626
def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution):
2727

28+
# Handle the single-file ROM case
29+
game_dir = rom if rom.is_dir() else rom.parent
30+
2831
# DOSBox Staging common resource data and conf file
2932
common_resource_dir = CONFIGS / 'dosbox'
3033
common_resource_conf = _find_iname(common_resource_dir, "dosbox-staging.conf")
3134

32-
dosbox_cfg = _find_iname(rom, "dosbox.cfg")
33-
dosbox_conf = _find_iname(rom, "dosbox.conf")
34-
dosbox_bat = _find_iname(rom, "dosbox.bat")
35+
dosbox_cfg = _find_iname(game_dir, "dosbox.cfg")
36+
dosbox_conf = _find_iname(game_dir, "dosbox.conf")
37+
dosbox_bat = _find_iname(game_dir, "dosbox.bat")
3538

3639
is_configured = dosbox_cfg or dosbox_conf or dosbox_bat
3740

3841
commandArray = [
3942
'/usr/bin/dosbox-staging',
4043
"--fullscreen",
41-
"--working-dir", str(rom),
42-
"-c", f"set WORKDIR={rom}",
44+
"--working-dir", str(game_dir),
45+
"-c", f"set WORKDIR={game_dir}",
4346
]
4447

4548
if common_resource_dir.is_dir():
@@ -85,3 +88,6 @@ def getHotkeysContext(self) -> HotkeysContext:
8588
"name": "dosboxstaging",
8689
"keys": { "exit": ["KEY_LEFTCTRL", "KEY_F9"] }
8790
}
91+
92+
def writesToRom(self) -> bool:
93+
return system.config.get_bool('dosbox_staging_writes_to_rom')
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import shutil
5+
import subprocess
6+
from contextlib import contextmanager
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Final
9+
10+
from ..batoceraPaths import mkdir_if_not_exists
11+
from ..exceptions import BatoceraException
12+
13+
if TYPE_CHECKING:
14+
from collections.abc import Iterator
15+
16+
_logger = logging.getLogger(__name__)
17+
18+
_OVERLAY_BASE_DIR: Final = Path("/var/run/overlays/")
19+
20+
21+
def _unmount_and_remove(mount_point: Path):
22+
if mount_point.is_mount():
23+
result = subprocess.run(["umount", str(mount_point)], capture_output=True, text=True)
24+
if result.returncode != 0:
25+
_logger.error("failed unmounting '%s' (rc=%d) because %s",
26+
mount_point, result.returncode, result.stderr.strip())
27+
28+
# Skip the follow-on removal if the umount failed because it might still
29+
# be connected to the ROM's save area (system crashing or bad state?).
30+
return
31+
32+
shutil.rmtree(mount_point, ignore_errors=True)
33+
34+
35+
def _mount(lower: Path, upper: Path, work: Path, mount_point: Path) -> bool:
36+
components = f"lowerdir={lower},upperdir={upper},workdir={work}"
37+
38+
result = subprocess.run(["mount", "-t", "overlay", "overlay",
39+
"-o", components, str(mount_point)],
40+
capture_output=True, text=True)
41+
42+
if result.returncode == 0:
43+
_logger.debug("mounted '%s' with components '%s'",
44+
mount_point, components)
45+
else:
46+
_logger.error("failed mounting '%s' with components '%s' (rc=%d) because %s",
47+
mount_point, components, result.returncode, result.stderr.strip())
48+
49+
return result.returncode == 0
50+
51+
52+
@contextmanager
53+
def mount_overlayfs(lower_dir: Path, writes_dir: Path, /) -> Iterator[Path]:
54+
"""
55+
Create an overlay mount for a read-only lower directory saving writes to the
56+
writes_dir. Returns the merged overlay mount point.
57+
"""
58+
59+
# If we were passed a single file, then overlay its parent directory
60+
lower_file = None
61+
if lower_dir.is_file():
62+
_logger.debug("overlaying single-file or linked rom '%s'", lower_dir)
63+
lower_dir = lower_dir.parent
64+
lower_file = lower_dir.name
65+
66+
# Where overlayfs keeps persistent writes
67+
upper_dir = writes_dir / "upper"
68+
mkdir_if_not_exists(upper_dir)
69+
70+
# Where overlayfs manages in-flight writes
71+
work_dir = writes_dir / "work"
72+
mkdir_if_not_exists(work_dir)
73+
74+
# Where overlayfs exposes the combined filesystem
75+
mount_point = _OVERLAY_BASE_DIR / lower_dir.name
76+
_unmount_and_remove(mount_point)
77+
mkdir_if_not_exists(mount_point)
78+
79+
if not _mount(lower_dir, upper_dir, work_dir, mount_point):
80+
raise BatoceraException(f"Unable to setup writable overlay for '{lower_dir}'")
81+
try:
82+
yield mount_point / lower_file if lower_file else mount_point
83+
84+
finally:
85+
_logger.debug("cleaning up '%s'", mount_point)
86+
_unmount_and_remove(mount_point)
87+
88+
has_writes = upper_dir.is_dir() and any(upper_dir.iterdir())
89+
90+
_logger.debug("%s save directory '%s'",
91+
"keeping populated" if has_writes else "removing empty",
92+
writes_dir)
93+
94+
if not has_writes:
95+
shutil.rmtree(writes_dir, ignore_errors=True)

package/batocera/core/batocera-configgen/configgen/configgen/utils/squashfs.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919

2020
@contextmanager
21-
def squashfs_rom(rom: Path, /) -> Iterator[Path]:
22-
_logger.debug("squashfs_rom(%s)", rom)
21+
def mount_squashfs(rom: Path, /) -> Iterator[Path]:
22+
_logger.debug("mount_squashfs(%s)", rom)
2323
mount_point = _SQUASHFS_DIR / rom.stem
2424

2525
mkdir_if_not_exists(_SQUASHFS_DIR)
@@ -63,12 +63,12 @@ def squashfs_rom(rom: Path, /) -> Iterator[Path]:
6363
_logger.debug("squashfs: linked rom %s", rom_linked)
6464
yield rom_linked
6565
finally:
66-
_logger.debug("squashfs_rom: cleaning up %s", mount_point)
66+
_logger.debug("mount_squashfs: cleaning up %s", mount_point)
6767

6868
# unmount
6969
return_code = subprocess.call(["umount", mount_point])
7070
if return_code != 0:
71-
_logger.debug("squashfs_rom: unmounting %s failed", mount_point)
71+
_logger.debug("mount_squashfs: unmounting %s failed", mount_point)
7272
raise BatoceraException(f"Unable to unmount the file {mount_point}")
7373

7474
# cleaning the empty directory

package/batocera/emulators/dosbox-staging/dosbox_staging.emulator.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ systems:
1717
- name: dos
1818
exclude_extensions:
1919
- zip
20+
custom_features:
21+
dosbox_staging_writes_to_rom:
22+
prompt: Enable writable overlay for squashfs ROMs
23+
description: Writes are saved per-game in saves/dos/game-rom-name
24+
choices:
25+
Enabled: True
26+
Disabled: False

0 commit comments

Comments
 (0)