diff --git a/emulators/eden.yml b/emulators/eden.yml index 2f8b58c8e..80ba11f1f 100644 --- a/emulators/eden.yml +++ b/emulators/eden.yml @@ -63,6 +63,7 @@ files: standalone_directories: - "$XDG_DATA_HOME/eden" - "$HOME/.local/share/eden" + - "PORTABLE_DIR" data_root: windows: path: "%APPDATA%/eden" diff --git a/emulators/ryujinx.yml b/emulators/ryujinx.yml index 74a9b5b43..aa3d4df08 100644 --- a/emulators/ryujinx.yml +++ b/emulators/ryujinx.yml @@ -19,6 +19,7 @@ mode: standalone standalone_directories: - "$XDG_CONFIG_HOME/Ryujinx" - "$HOME/.config/Ryujinx" + - "DefaultPortableDir" data_root: windows: path: "%APPDATA%/Ryujinx" diff --git a/emulators/shadps4.yml b/emulators/shadps4.yml index 46db72954..81dc1b868 100644 --- a/emulators/shadps4.yml +++ b/emulators/shadps4.yml @@ -39,7 +39,12 @@ notes: | priority over the global sys_modules/ directory. System fonts are mounted from fonts/font/ (/preinst/common/font on real - PS4) and fonts/font2/ (/system/common/font2 on real PS4). These are SST + PS4) and fonts/font2/ (/system/common/font2 on real PS4) + (src/emulator.cpp:639-661). The font library opens each by its dumped name + or its PS4 name in fonts/, then fonts/font/, then fonts/font2/ + (src/core/libraries/font/font_internal.cpp:391-468), and falls back to the + Noto fonts it embeds (font_internal.cpp:119-127). The entries below put + each in fonts/font/, which both the mount and the library read. These are SST (Sony Standard Text) typeface files in OTF format. The emulator mounts the directories wholesale, and games load individual fonts by name. Without fonts, the emulator warns "No dumped system fonts, expect missing text @@ -239,3 +244,270 @@ files: description: "PS4 XML parser" note: "No HLE implementation. The module is stubbed when absent." source_ref: "src/core/libraries/sysmodule/sysmodule_internal.cpp:251" + + - name: "SST-Light.otf" + aliases: ["SST-EU-ROMAN-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:419, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Roman.otf" + aliases: ["SST-EU-ROMAN.OTF", "SST-EU-ROMAN-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:420, 422, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Medium.otf" + aliases: ["SST-EU-ROMAN-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:421, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Italic.otf" + aliases: ["SST-EU-ROMAN-I.OTF", "SST-ITALIC-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Italic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:423, 427, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Bold.otf" + aliases: ["SST-EU-ROMAN-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:424, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-BoldItalic.otf" + aliases: ["SST-EU-ROMAN-BI.OTF", "SST-ITALIC-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-BoldItalic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:425, 429, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-LightItalic.otf" + aliases: ["SST-ITALIC-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-LightItalic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:426, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-MediumItalic.otf" + aliases: ["SST-ITALIC-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-MediumItalic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:428, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTTypewriter-Roman.otf" + aliases: ["SST-TYPEWRITER-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTTypewriter-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:430, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTTypewriter-Bd.otf" + aliases: ["SST-TYPEWRITER-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTTypewriter-Bd.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:431, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTJpPro-Regular.otf" + aliases: ["SST-JPPRO-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTJpPro-Regular.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:432, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTJpPro-Bold.otf" + aliases: ["SST-JPPRO-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTJpPro-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:433, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "DFHEI5-SONY.ttf" + aliases: ["SST-CNGB-HEI-R.TTF"] + required: false + hle_fallback: true + path: "fonts/font/DFHEI5-SONY.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:434, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTAribStdB24-Regular.ttf" + aliases: ["SST-ARIB-STD-B24-R.TTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTAribStdB24-Regular.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:435, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Roman.otf" + aliases: ["SST-ARABIC-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:436, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Light.otf" + aliases: ["SST-ARABIC-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:437, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Medium.otf" + aliases: ["SST-ARABIC-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:438, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Bold.otf" + aliases: ["SST-ARABIC-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:439, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCEPS4Yoongd-Light.otf" + aliases: ["SCE-EXT-HANGUL-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCEPS4Yoongd-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:440, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCEPS4Yoongd-Medium.otf" + aliases: ["SCE-EXT-HANGUL-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCEPS4Yoongd-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:441, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCEPS4Yoongd-Bold.otf" + aliases: ["SCE-EXT-HANGUL-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCEPS4Yoongd-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:442, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "e046323ms.ttf" + aliases: ["SSTCC-SERIF-MONO.OTF"] + required: false + hle_fallback: true + path: "fonts/font/e046323ms.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:443, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "e046323ts.ttf" + aliases: ["SSTCC-SERIF.OTF"] + required: false + hle_fallback: true + path: "fonts/font/e046323ts.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:444, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "n023055ms.ttf" + aliases: ["SSTCC-SANSSERIF-MONO.OTF"] + required: false + hle_fallback: true + path: "fonts/font/n023055ms.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:445, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "n023055ts.ttf" + aliases: ["SSTCC-SANSSERIF.OTF"] + required: false + hle_fallback: true + path: "fonts/font/n023055ts.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:446, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "d013013ds.ttf" + aliases: ["SSTCC-CUSUAL.OTF"] + required: false + hle_fallback: true + path: "fonts/font/d013013ds.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:447, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "k006004ds.ttf" + aliases: ["SSTCC-CURSIVE.OTF"] + required: false + hle_fallback: true + path: "fonts/font/k006004ds.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:448, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "c041056ts.ttf" + aliases: ["SSTCC-SMALLCAPITAL.OTF"] + required: false + hle_fallback: true + path: "fonts/font/c041056ts.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:449, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCE-RDC-R-JPN.otf" + aliases: ["SCE-JP-CATTLEYA-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCE-RDC-R-JPN.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:450, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCE-RDC-B-JPN.otf" + aliases: ["SCE-JP-CATTLEYA-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCE-RDC-B-JPN.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:451, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Light.otf" + aliases: ["SST-THAI-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:452, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Roman.otf" + aliases: ["SST-THAI-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:453, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Medium.otf" + aliases: ["SST-THAI-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:454, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Bold.otf" + aliases: ["SST-THAI-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:455, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Light.otf" + aliases: ["SST-VIETNAMESE-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:456, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Roman.otf" + aliases: ["SST-VIETNAMESE-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:457, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Medium.otf" + aliases: ["SST-VIETNAMESE-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:458, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Bold.otf" + aliases: ["SST-VIETNAMESE-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:459, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" diff --git a/emulators/yuzu.yml b/emulators/yuzu.yml index 77ec2ef35..7aafef9c5 100644 --- a/emulators/yuzu.yml +++ b/emulators/yuzu.yml @@ -59,6 +59,7 @@ files: standalone_directories: - "$XDG_DATA_HOME/yuzu" - "$HOME/.local/share/yuzu" + - "PORTABLE_DIR" data_root: windows: path: "%APPDATA%/yuzu" diff --git a/install.ps1 b/install.ps1 index fcab5282b..b7958b45f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c" + $defaultInstallSha256 = "9e135ad9a52ec64148c079e2ce32f080c818b2af71c8420f93a76c09be41962e" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 29335e500..6f2f439ed 100644 --- a/install.py +++ b/install.py @@ -143,6 +143,7 @@ OMISSION_REASONS = { "no_save_system": "no system runs the core", "outside_bios_folder": "read outside the BIOS folder", "no_roms_directory": "no roms folder", + "no_configs_directory": "no configs folder", "no_emulators_directory": "no emulator folder", } # What a file is when it is not firmware, as the manifest's category field @@ -3528,14 +3529,20 @@ def _roms_relation(value: object, field: str) -> tuple[int, PurePosixPath]: return ups, PurePosixPath(*names) -def _roms_root(bios_path: Path, from_bios: str) -> Path | None: - """The roms root of a BIOS folder, on the host's own path type. +# The roots a list places files below by their place relative to the BIOS +# folder: the roms root (ROCKNIX's /storage/roms) and the frontend's +# configs folder (Batocera's /userdata/system/configs). +RELATIVE_ROOTS = ("roms", "configs") + + +def _relative_root(bios_path: Path, from_bios: str, root: str = "roms") -> Path | None: + """A relative root of a BIOS folder, on the host's own path type. The BIOS folder's ancestor at the depth the relation climbs, then the named folders below it. None when the joined and normalized path is anything else. """ - ups, names = _roms_relation(from_bios, "roms_directory.from_bios") + ups, names = _roms_relation(from_bios, f"{root}_directory.from_bios") if ups > len(bios_path.parents): return None ancestor = bios_path if not ups else bios_path.parents[ups - 1] @@ -3671,8 +3678,9 @@ class SavePlace(NamedTuple): # BIOS folder -> where the files read below its frontend's save directory go, # set once the save directory is known (save_directory_for). _SAVE_DIRS: dict[Path, SavePlace] = {} -# BIOS folder -> the platform's roms folder, set by place_roms_entries. -_ROMS_DIRS: dict[Path, Path] = {} +# (BIOS folder, root) -> the platform's roms or configs folder, set by +# place_relative_entries. +_RELATIVE_DIRS: dict[tuple[Path, str], Path] = {} # (BIOS folder, emulator key) -> the emulator folder, set by # place_emulator_entries. _EMULATOR_DIRS: dict[tuple[Path, str], Path] = {} @@ -3686,11 +3694,11 @@ def _entry_place(root: Path, entry: dict) -> tuple[Path, object]: core's own folder where the frontend sorts saves by core. Any other is relative to the BIOS folder. """ - if entry.get("root") == "roms": - roms = _ROMS_DIRS.get(root) - if roms is None: - raise ValueError(f"no roms folder known for {entry.get('dest')}") - return roms, entry.get("dest") + if entry.get("root") in RELATIVE_ROOTS: + folder = _RELATIVE_DIRS.get((root, entry["root"])) + if folder is None: + raise ValueError(f"no {entry['root']} folder known for {entry.get('dest')}") + return folder, entry.get("dest") if entry.get("root") == "emulators": key, _, rest = str(entry.get("dest")).partition("/") folder = _EMULATOR_DIRS.get((root, key)) @@ -3731,7 +3739,7 @@ def _entry_key(entry: dict) -> str: if root == "emulator": # A path in one emulator's own folder: two emulators read two files. return f"emulator\0{','.join(entry.get('cores') or [])}\0{dest}" - return f"{root}\0{dest}" if root in ("save", "roms", "emulators") else dest + return f"{root}\0{dest}" if root in ("save", "emulators", *RELATIVE_ROOTS) else dest def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: @@ -3739,7 +3747,8 @@ def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: the emulator's own folder, outside the BIOS tree), and core_folder one folder name of a save entry.""" root = entry.get("root") - if root not in (None, "save", "roms", "emulators") and not (omitted and root == "emulator"): + if root not in (None, "save", "emulators", *RELATIVE_ROOTS) and not ( + omitted and root == "emulator"): raise ValueError(f"invalid root for {dest}") if root == "emulators" and len(PurePosixPath(dest).parts) < 2: raise ValueError(f"an emulators entry names its folder first: {dest}") @@ -3752,24 +3761,27 @@ def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: raise ValueError(f"core_folder must be one folder name for {dest}") -def _check_roms_directory(data: dict) -> None: - """The manifest's roms_directory, which every installable root: roms entry needs.""" - layout = data.get("roms_directory") +def _check_relative_directories(data: dict) -> None: + """The manifest's roms_directory and configs_directory, which every + installable root: roms or root: configs entry needs.""" installable = [ entry for field in ("files", "case_variants") for entry in data.get(field) or [] if isinstance(entry, dict) ] - if layout is None: - if any(entry.get("root") == "roms" for entry in installable): - raise ValueError("entries below the roms folder and no roms_directory") - return - if ( - not isinstance(layout, dict) - or set(layout) != {"bios_folder", "from_bios"} - or len(_safe_relative_path(layout["bios_folder"], "roms_directory.bios_folder").parts) != 1 - ): - raise ValueError("invalid roms_directory") - _roms_relation(layout["from_bios"], "roms_directory.from_bios") + for root in RELATIVE_ROOTS: + field = f"{root}_directory" + layout = data.get(field) + if layout is None: + if any(entry.get("root") == root for entry in installable): + raise ValueError(f"entries below the {root} folder and no {field}") + continue + if ( + not isinstance(layout, dict) + or set(layout) != {"bios_folder", "from_bios"} + or len(_safe_relative_path(layout["bios_folder"], f"{field}.bios_folder").parts) != 1 + ): + raise ValueError(f"invalid {field}") + _roms_relation(layout["from_bios"], f"{field}.from_bios") def _check_emulators_directory(data: dict) -> None: @@ -3969,7 +3981,7 @@ def _check_entry( # Below a roms or emulator folder a colon has no use: refused outright. dest = str(_safe_relative_path( entry.get("dest"), f"{where}.dest", - colons=entry.get("root") not in ("roms", "emulators"), + colons=entry.get("root") not in ("emulators", *RELATIVE_ROOTS), )) key = _entry_key({**entry, "dest": dest}) if key in seen_destinations: @@ -4109,7 +4121,7 @@ def _validate_manifest(data: object, plat: str) -> dict: for index, entry in enumerate(data_dirs): _check_data_directory(entry, f"data_directories[{index}]") _check_save_directory(data) - _check_roms_directory(data) + _check_relative_directories(data) _check_emulators_directory(data) declared_total_files = data.get("total_files") @@ -5376,39 +5388,41 @@ def place_save_entries( return kept, unplaced -def place_roms_entries( +def place_relative_entries( files: list[dict], bios_path: Path, manifest: dict, display: str, say: bool = True, ) -> tuple[list[dict], list[dict]]: - """Find the roms folder for the entries a launcher reads below it. + """Find the roms and configs folders for the entries read below them. - The list gives the roms root relative to the BIOS folder, as the OS's - own source places them (ROCKNIX's /storage/roms holds bios/): a BIOS - folder of the platform's name puts the roms root there. Any other is a - folder this run cannot relate to the roms, and the entries are left out - with the reason. + The list gives each root relative to the BIOS folder, as the OS's own + source places them (ROCKNIX's /storage/roms holds bios/, Batocera's + configs folder is ../system/configs): a BIOS folder of the platform's + name puts the root there. Any other is a folder this run cannot relate + to the root, and the entries are left out with the reason. """ - below = [entry for entry in files if entry.get("root") == "roms"] - if not below: - return files, [] - layout = manifest.get("roms_directory") or {} - roms = ( - _roms_root(bios_path, layout["from_bios"]) - if layout and bios_path.name == layout.get("bios_folder") else None - ) - if roms is not None: - _ROMS_DIRS[bios_path] = roms - return files, [] - if say: - print(f" {_plural(len(below), 'file')} read from {display}'s roms folder left out: " - f"{bios_path} is not its {layout.get('bios_folder') or 'BIOS'} folder.") - kept = [entry for entry in files if entry.get("root") != "roms"] - unplaced = [ - {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, - "system": "", "systems": list(entry.get("systems") or []), "required": False, - "reason": "no_roms_directory", "cores": entry.get("cores"), "root": "roms"} - for entry in below - ] - return kept, unplaced + unplaced: list[dict] = [] + for root in RELATIVE_ROOTS: + below = [entry for entry in files if entry.get("root") == root] + if not below: + continue + layout = manifest.get(f"{root}_directory") or {} + folder = ( + _relative_root(bios_path, layout["from_bios"], root) + if layout and bios_path.name == layout.get("bios_folder") else None + ) + if folder is not None: + _RELATIVE_DIRS[(bios_path, root)] = folder + continue + if say: + print(f" {_plural(len(below), 'file')} read from {display}'s {root} folder left " + f"out: {bios_path} is not its {layout.get('bios_folder') or 'BIOS'} folder.") + files = [entry for entry in files if entry.get("root") != root] + unplaced += [ + {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, + "system": "", "systems": list(entry.get("systems") or []), "required": False, + "reason": f"no_{root}_directory", "cores": entry.get("cores"), "root": root} + for entry in below + ] + return files, unplaced def _config_keys(path: Path) -> set[str]: @@ -6559,6 +6573,20 @@ def _command_rank(option: tuple[str, str | None]) -> int: return _COMMAND_ORDER.index(flag) if flag in _COMMAND_ORDER else len(_COMMAND_ORDER) +def package_command(prefix: str) -> str: + """The command that ran the retrobios package, read from the environment + it runs in. uvx runs it from uv's cache (~/.cache/uv/archive-v0/ + with uv 0.12.9) and pipx run from pipx's (~/.cache/pipx/ with + pipx 1.4.3), and neither leaves a retrobios command behind. pip, pipx + install (pipx's venvs/) and uv tool install do.""" + parts = re.split(r"[\\/]", prefix) + if "archive-v0" in parts: + return "uvx retrobios" + if any(part in ("pipx", ".pipx") for part in parts) and "venvs" not in parts: + return "pipx run retrobios" + return "retrobios" + + class Invocation: """How this run was started, so that a hint gives the command back whole. @@ -6615,6 +6643,8 @@ class Invocation: return f"&([scriptblock]::Create((irm {BOOTSTRAP_URL}.ps1)))" if bootstrap == "sh": return f"curl -fsSL {BOOTSTRAP_URL} | {'sudo ' if elevated else ''}sh -s --" + if bootstrap == "pypi": + return f"{'sudo ' if elevated else ''}{package_command(sys.prefix)}" python = "python" if self.os_type == "windows" else "python3" return f"{'sudo ' if elevated else ''}{python} install.py" @@ -16098,7 +16128,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: display = display_name(plat_name, manifest) files, unplaced = place_save_entries( files, bios_path, manifest, args.save_dir, display, say=False) - files, beyond = place_roms_entries(files, bios_path, manifest, display, say=not listing) + files, beyond = place_relative_entries( + files, bios_path, manifest, display, say=not listing) files, elsewhere = place_emulator_entries( files, bios_path, manifest, display, say=not listing) unplaced = [*unplaced, *beyond, *elsewhere] diff --git a/install.sh b/install.sh index b5e0bc583..68aabf733 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c" +DEFAULT_INSTALL_SHA256="9e135ad9a52ec64148c079e2ce32f080c818b2af71c8420f93a76c09be41962e" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index bf070ce79..41f4cc61d 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1142840, - "sha256": "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c", + "size": 1144393, + "sha256": "9e135ad9a52ec64148c079e2ce32f080c818b2af71c8420f93a76c09be41962e", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1196465, - "sha256": "7ef6e947618ffe51b95823b28ce2a064c6eddf2b7fb77e2066f9c2d279176f03" + "size": 1198018, + "sha256": "4729710b43a017c62bd275ec7651d489d56e5646cae6e87c73717e13413c6b5d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1142840, - "sha256": "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c", + "size": 1144393, + "sha256": "9e135ad9a52ec64148c079e2ce32f080c818b2af71c8420f93a76c09be41962e", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1142840, - "sha256": "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c", + "size": 1144393, + "sha256": "9e135ad9a52ec64148c079e2ce32f080c818b2af71c8420f93a76c09be41962e", "executable": false }, { diff --git a/platforms/batocera.yml b/platforms/batocera.yml index a1702b50f..fc772a2fe 100644 --- a/platforms/batocera.yml +++ b/platforms/batocera.yml @@ -6633,3 +6633,8 @@ launchers: \ of citron_path / KEYS_DIR" fails_when_missing: false source_ref: "package/batocera/emulators/citron/002-adjust-paths.patch:9-10" +configs_root: + path: /userdata/system/configs + from_bios: ../system/configs + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/batoceraPaths.py:14,\ + \ 19-20, 25" diff --git a/platforms/knulli.yml b/platforms/knulli.yml index c8773da9f..d2aa23488 100644 --- a/platforms/knulli.yml +++ b/platforms/knulli.yml @@ -186,7 +186,6 @@ cores: - superbroswar - supermodel - supermodel-legacy -- suyu - swanstation - taradino - tgbdual @@ -4970,7 +4969,6 @@ standalone_cores: - stella - supermodel - supermodel-legacy -- suyu - taradino - theforceengine - thextech @@ -6144,14 +6142,6 @@ launchers: depends_on_option: not system.name == 'ti99' source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:431-432,\ \ 435" -- emulator: suyu - build: standalone - kind: passes_path - names: [] - search_dir: /userdata/saves/switch - passes_to: environment XDG_DATA_HOME - fails_when_missing: false - source_ref: "package/system/knulli-configgen/configgen/configgen/generators/suyu/suyuGenerator.py:41" - emulator: citron build: standalone kind: passes_path @@ -6161,3 +6151,8 @@ launchers: \ of citron_path / KEYS_DIR" fails_when_missing: false source_ref: "batocera/package/batocera/emulators/citron/002-adjust-paths.patch:9-10" +configs_root: + path: /userdata/system/configs + from_bios: ../system/configs + source_ref: "package/system/knulli-configgen/configgen/configgen/batoceraPaths.py:14,\ + \ 19-20, 25" diff --git a/platforms/retrobat.yml b/platforms/retrobat.yml index de95d06ba..c45f67c0e 100644 --- a/platforms/retrobat.yml +++ b/platforms/retrobat.yml @@ -4979,17 +4979,42 @@ launchers: kind: passes_path names: [] search_dir: "/user" - passes_to: PORTABLE_DIR, the user folder beside citron.exe + passes_to: PORTABLE_DIR, the portable folder beside citron.exe fails_when_missing: false source_ref: "emulatorLauncher/Generators/Citron.Generator.cs:25, 29-30" +- emulator: eden + build: standalone + kind: passes_path + names: [] + search_dir: "/user" + passes_to: PORTABLE_DIR, the portable folder beside eden.exe + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Eden.Generator.cs:28, 35-36" +- emulator: ryujinx + build: standalone + kind: passes_path + names: [] + search_dir: "/portable" + passes_to: DefaultPortableDir, the portable folder beside Ryujinx.exe + fails_when_missing: false + depends_on_option: unless saves/switch/ryujinx/portable exists, passed with -r + source_ref: "emulatorLauncher/Generators/Ryujinx.Generator.cs:31, 73-75" - emulator: shadps4 build: standalone kind: passes_path names: [] search_dir: "/user" - passes_to: PORTABLE_DIR, the user folder beside shadPS4.exe + passes_to: PORTABLE_DIR, the portable folder beside shadPS4.exe fails_when_missing: false source_ref: "emulatorLauncher/Generators/ShadPS4.Generator.cs:29, 158-160" +- emulator: yuzu + build: standalone + kind: passes_path + names: [] + search_dir: "/user" + passes_to: PORTABLE_DIR, the portable folder beside yuzu.exe + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Yuzu.Generator.cs:24, 33-34" - emulator: bizhawk build: standalone kind: names_searched diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 76d7512f5..9c3da6fe5 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -207,7 +207,8 @@ "no_save_system", "outside_bios_folder", "no_roms_directory", - "no_emulators_directory" + "no_emulators_directory", + "no_configs_directory" ], "description": "Why the file is left out, as a code. enumDescriptions gives the meaning of each code, in the order of enum.", "enumDescriptions": [ @@ -222,7 +223,8 @@ "The launcher gives each system its own save folder and no system of the platform runs the core.", "The platform runs the emulator from a folder outside the BIOS tree and links none of the BIOS tree where the emulator reads this file (root: emulator).", "The platform's launcher runs the file from its roms folder (root: roms) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps in it.", - "The platform's launcher runs the file from one of its emulator folders (root: emulators) and the run cannot place it there: the BIOS folder is not the one beside the platform's launcher config, or that config sets the folder." + "The platform's launcher runs the file from one of its emulator folders (root: emulators) and the run cannot place it there: the BIOS folder is not the one beside the platform's launcher config, or that config sets the folder.", + "The platform's launcher starts the emulator from a folder of its configs folder (root: configs) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps beside it." ] }, "category": { @@ -367,12 +369,13 @@ } }, "root": { - "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). emulators: dest is the emulator folder's key then the path in it (emulators_directory). Absent for a file of the BIOS tree.", + "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). emulators: dest is the emulator folder's key then the path in it (emulators_directory). configs: dest is relative to the frontend's configs folder (configs_directory). Absent for a file of the BIOS tree.", "enum": [ "save", "emulator", "roms", - "emulators" + "emulators", + "configs" ] }, "core_folder": { @@ -581,6 +584,27 @@ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" } } + }, + "configs_directory": { + "description": "Where the frontend keeps its emulators' configs, present when an entry carries root: configs. from_bios is that folder relative to the BIOS folder, as the platform's own path code builds it (Batocera's batoceraPaths CONFIGS, ../system/configs), with the rules of roms_directory.from_bios. It is followed only from a BIOS folder named bios_folder; any other leaves root: configs entries out (no_configs_directory).", + "type": "object", + "additionalProperties": false, + "required": [ + "bios_folder", + "from_bios" + ], + "properties": { + "bios_folder": { + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\]+$" + }, + "from_bios": { + "type": "string", + "pattern": "^(\\.|\\.\\.(/\\.\\.)?(/[^/\\\\:.][^/\\\\:]*)*)$", + "maxLength": 1024 + } + } } }, "$defs": { @@ -599,7 +623,7 @@ "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", - "description": "Path below base_destination, below the save directory when root is save, below the roms folder when root is roms, or in an emulator folder when root is emulators (its key first)." + "description": "Path below base_destination, below the save directory when root is save, below the roms folder when root is roms, below the configs folder when root is configs, or in an emulator folder when root is emulators (its key first)." }, "sha1": { "type": "string", @@ -1082,11 +1106,12 @@ } }, "root": { - "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). emulators: dest is the key of an emulator folder of the platform, then the path in it, located by emulators_directory (RetroBat's emulators/pico8). Absent for a file of the BIOS tree.", + "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). emulators: dest is the key of an emulator folder of the platform, then the path in it, located by emulators_directory (RetroBat's emulators/pico8). configs: dest is relative to the frontend's configs folder, which configs_directory locates (Batocera starts shadPS4 from configs/shadps4). Absent for a file of the BIOS tree.", "enum": [ "save", "roms", - "emulators" + "emulators", + "configs" ] }, "core_folder": { diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 5c0b0c1b1..abe4f8917 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -188,6 +188,30 @@ } } } + }, + "configs_root": { + "description": "Where the frontend keeps its emulators' configs, as its own path code builds it (Batocera's batoceraPaths CONFIGS): path as the OS spells it, from_bios relative to the BIOS folder. A launcher that starts an emulator from a folder below it (shadPS4's configs/shadps4) places that emulator's user folder there, and the install manifest writes such a file as root: configs.", + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "from_bios", + "source_ref" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "from_bios": { + "type": "string", + "minLength": 1 + }, + "source_ref": { + "type": "string", + "minLength": 1 + } + } } }, "$defs": { diff --git a/scripts/build_pypi.py b/scripts/build_pypi.py index 93dbf262f..61f1f0b1d 100644 --- a/scripts/build_pypi.py +++ b/scripts/build_pypi.py @@ -44,6 +44,7 @@ MODULE = "retrobios" # keeps the exit codes it gives a reader that left (141) and Ctrl+C (130). LAUNCHER = '''"""The RetroBIOS installer, install.py, run as the retrobios command.""" +import os import runpy from pathlib import Path @@ -51,6 +52,8 @@ __version__ = "{version}" def main() -> None: + # The hints give back the command that ran this, not python3 install.py. + os.environ["RETROBIOS_BOOTSTRAP"] = "pypi" runpy.run_path(str(Path(__file__).with_name("install.py")), run_name="__main__") ''' diff --git a/scripts/common.py b/scripts/common.py index 27fd2b30c..e96c5ef63 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -2882,7 +2882,8 @@ OUTSIDE_BIOS_FOLDER = "outside_bios_folder" # describes. Each is located by the registry entry _directory. ROMS_ROOT = "roms" EMULATORS_ROOT = "emulators" -OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT) +CONFIGS_ROOT = "configs" +OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT, CONFIGS_ROOT) def outer_layout(config: dict, root: str) -> dict | None: @@ -2915,6 +2916,15 @@ def outer_layout(config: dict, root: str) -> dict | None: return {"path": str(path), "install_prefix": "" if inside == "." else inside} if root == EMULATORS_ROOT: return config.get("emulators_directory") or None + if root == CONFIGS_ROOT: + # The frontend's configs folder, as its path code builds it + # (configs_root, Batocera's batoceraPaths CONFIGS). + found = config.get("configs_root") or {} + base = str(config.get("base_destination") or "") + if not (found.get("path") and found.get("from_bios") and base): + return None + return {"path": str(found["path"]), "from_bios": str(found["from_bios"]), + "bios_folder": base} return None diff --git a/scripts/launchers.py b/scripts/launchers.py index e197636a9..442195d7f 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -331,6 +331,8 @@ BIOS_TREE = "" ROMS_TREE = "roms" # The emulator folders of a platform whose registry describes them. EMULATORS_TREE = "emulators" +# The frontend's configs folder of a platform whose file records it. +CONFIGS_TREE = "configs" # The tree a platform's list installs into when it starts above the BIOS # folder: a plain dest, with no prefix. INSTALL_TREE = "install" @@ -494,6 +496,7 @@ def handed_directories(config: dict) -> list[HandedDirectory]: source_ref=str(record.get("source_ref", "")), roms_place=None if folder is not None else ( _roms_place(config, folder_in_roms(part, config)) + or _configs_place(folder_in_configs(part, config)) or _emulators_place(folder_in_emulators(part, config)) ), systems=systems, @@ -504,6 +507,11 @@ def handed_directories(config: dict) -> list[HandedDirectory]: return found +def _configs_place(path: str | None) -> Place | None: + """A folder below the configs root, as a place of the configs tree.""" + return None if path is None else (CONFIGS_TREE, path) + + def _emulators_place(path: str | None) -> Place | None: """A folder an emulator key names, as a place of the emulators tree.""" return None if path is None else (EMULATORS_TREE, path) @@ -523,6 +531,18 @@ def _roms_place(config: dict, path: str | None) -> Place | None: return (ROMS_TREE, path) +def folder_in_configs(directory: str, config: dict) -> str | None: + """A directory relative to the frontend's configs folder, None outside it. + + Only a platform whose file records its configs root has one + (``configs_root.path``): Batocera's /userdata/system/configs, where its + generators start shadPS4 from configs/shadps4. + """ + from common import CONFIGS_ROOT # noqa: PLC0415 + + return folder_in_root(directory, config, CONFIGS_ROOT) + + def folder_in_roms(directory: str, config: dict) -> str | None: """A directory relative to the platform's roms directory, None outside it. @@ -530,9 +550,16 @@ def folder_in_roms(directory: str, config: dict) -> str | None: (``roms_root.path``): ROCKNIX's /storage/roms, which holds its BIOS folder and the folders its launchers run programs from. """ - from common import ROMS_ROOT, outer_layout # noqa: PLC0415 + from common import ROMS_ROOT # noqa: PLC0415 - roms = str((outer_layout(config, ROMS_ROOT) or {}).get("path") or "").rstrip("/") + return folder_in_root(directory, config, ROMS_ROOT) + + +def folder_in_root(directory: str, config: dict, root: str) -> str | None: + """A directory relative to an outer root's path, None outside it.""" + from common import outer_layout # noqa: PLC0415 + + roms = str((outer_layout(config, root) or {}).get("path") or "").rstrip("/") directory = directory.strip().rstrip("/") if not roms or not directory.startswith(roms + "/"): return None diff --git a/scripts/scraper/batocera_launchers.py b/scripts/scraper/batocera_launchers.py index aac64eb9a..951c79612 100644 --- a/scripts/scraper/batocera_launchers.py +++ b/scripts/scraper/batocera_launchers.py @@ -97,6 +97,32 @@ def path_constants(source: Source) -> dict[str, str]: return constants +def configs_root(cg: Configgen) -> dict[str, str]: + """Where configgen keeps the emulators' configs, relative to the BIOS folder. + + batoceraPaths' CONFIGS (/userdata/system/configs), the folder a generator + starts an emulator from (shadPS4's configs/shadps4), with the lines that + build it and BIOS. + """ + import posixpath # noqa: PLC0415 + + src = cg.source(f"{cg.generators.rsplit('/', 1)[0]}/batoceraPaths.py") + if "CONFIGS" not in cg.paths: + src.fail("no CONFIGS path constant") + wanted: set[str] = {"CONFIGS", "BIOS"} + spans = [] + for node in reversed(src.tree.body): + name = _assigned_name(node) + if name in wanted: + spans.append((node.lineno, node.end_lineno or node.lineno)) + wanted |= {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)} # type: ignore[union-attr] + return { + "path": cg.paths["CONFIGS"], + "from_bios": posixpath.relpath(cg.paths["CONFIGS"], cg.bios), + "source_ref": src.ref(spans), + } + + def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None: if isinstance(node, ast.Name): return constants.get(node.id) diff --git a/scripts/scraper/batocera_scraper.py b/scripts/scraper/batocera_scraper.py index f1e3fcdcc..8ef10db16 100644 --- a/scripts/scraper/batocera_scraper.py +++ b/scripts/scraper/batocera_scraper.py @@ -14,6 +14,7 @@ import re import sys import urllib.error import urllib.request +from collections.abc import Callable, Iterable from pathlib import Path @@ -28,7 +29,7 @@ from .base_scraper import ( md5_field, requirement_entry, ) -from .batocera_launchers import extract_launchers +from .batocera_launchers import Configgen, configs_root, extract_launchers PLATFORM_NAME = "batocera" @@ -168,7 +169,8 @@ def es_systems_libretro_systems(text: str, dropped: set[str]) -> dict[str, list[ def es_systems_by_system( - es_systems_text: str, defaults_text: str, dropped: set[str] + es_systems_text: str, defaults_text: str, dropped: set[str], + built: Callable[[str], bool] | None = None, ) -> dict[str, list[str]]: """system -> the cores es_systems.yml offers it, in the file's order. @@ -195,6 +197,10 @@ def es_systems_by_system( default = defaults.get(system) if isinstance(default, dict): emulator, core = default.get("emulator"), default.get("core") + if (built is not None and emulator and emulator != "libretro" + and str(emulator) not in names and not built(str(emulator))): + # A default the build lacks (configgen_cores). + emulator = core = None if emulator and emulator != "libretro": names.append(str(emulator)) if core: @@ -472,7 +478,8 @@ def platform_systems( def configgen_cores( - defaults_text: str, es_systems_text: str, dropped: set[str] + defaults_text: str, es_systems_text: str, dropped: set[str], + built: Callable[[str], bool] | None = None, ) -> tuple[list[str], list[str], list[str]]: """Core names, standalone cores and dual-mode cores of a configgen build. @@ -481,9 +488,16 @@ def configgen_cores( dual-mode ones those es_systems.yml also offers under libretro (dolphin for GameCube). `dropped` names what es_systems.yml files under an emulator without being one. + + A default es_systems.yml does not offer names an emulator the build may + lack: KNULLI's defaults pick suyu for the Switch, and no package of its + tree, its batocera submodule or its buildroot builds suyu. `built` says + whether the build has a standalone emulator (packages_and_programs); a + default it answers no for is not run, nor its core. """ data = yaml_load_upstream(defaults_text) cores, standalone = es_systems_emulators(es_systems_text) + offered = set(cores) libretro = es_systems_libretro(es_systems_text) if not cores: raise RuntimeError("es_systems.yml names no emulator") @@ -492,6 +506,9 @@ def configgen_cores( continue emulator = cfg.get("emulator", "") core = cfg.get("core", "") + if (built is not None and emulator and emulator != "libretro" + and emulator not in offered and not built(emulator)): + continue if core: cores.add(core) if emulator and emulator != "libretro": @@ -502,6 +519,57 @@ def configgen_cores( return sorted(cores - dropped), sorted(standalone - dropped), sorted(dual) +BATOCERA_REPO = "batocera-linux/batocera.linux" + + +def package_names(paths: Iterable[str]) -> set[str]: + """The buildroot packages a tree defines: each folder holding a Config.in.""" + return { + path.rsplit("/", 2)[-2] + for path in paths + if path.endswith("/Config.in") and path.count("/") >= 1 + } + + +def repo_tree(repo: str, ref: str) -> list[str]: + """Every path of a GitHub repository at a revision.""" + url = f"https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1" + req = urllib.request.Request(url, headers=github_headers()) + with urllib.request.urlopen(req, timeout=60) as resp: + found = json.loads(resp.read().decode("utf-8")) + if found.get("truncated"): + raise RuntimeError(f"{url}: tree truncated") + return [entry["path"] for entry in found.get("tree", [])] + + +def submodule_revision(repo: str, path: str, ref: str) -> str: + """The commit a repository's submodule at `path` pins at a revision.""" + url = f"https://api.github.com/repos/{repo}/contents/{path}?ref={ref}" + req = urllib.request.Request(url, headers=github_headers()) + with urllib.request.urlopen(req, timeout=30) as resp: + found = json.loads(resp.read().decode("utf-8")) + if not isinstance(found, dict) or found.get("type") != "submodule" or not found.get("sha"): + raise RuntimeError(f"{url}: {path} is not a submodule") + return str(found["sha"]) + + +def packages_and_programs(packages: set[str], launchers: list[dict]) -> Callable[[str], bool]: + """Whether a build has a standalone emulator. + + It does when a package of that name exists, or when its launcher runs a + program the user puts in the BIOS folder (Batocera's lexaloffle runs + /userdata/bios/pico-8/pico8), which no package builds. + """ + supplied = { + name.strip() + for record in launchers + if record.get("passes_to") == "exec" + and str(record.get("search_dir") or "").startswith("/userdata/bios") + for name in str(record.get("emulator", "")).split(",") + } + return lambda emulator: emulator in packages or emulator in supplied + + class Scraper(BaseScraper): """Scraper for batocera-systems Python dict.""" @@ -509,12 +577,26 @@ class Scraper(BaseScraper): self.tag = fetch_stable_tag() super().__init__(url=SOURCE_URL.format(tag=self.tag)) - def _fetch_cores(self) -> tuple[list[str], list[str], list[str]]: - """Core names, standalone cores and dual-mode cores at the pinned tag.""" + def _fetch_cores( + self, launchers: list[dict] | None = None + ) -> tuple[list[str], list[str], list[str]]: + """Core names, standalone cores and dual-mode cores at the pinned tag. + + A default es_systems.yml does not offer counts when a package of + Batocera's tree or its buildroot builds it, or when its launcher + runs a program from the BIOS folder. + """ + built = None + if launchers is not None: + paths = repo_tree(BATOCERA_REPO, self.tag) + buildroot = submodule_revision(BATOCERA_REPO, "buildroot", self.tag) + paths += repo_tree("batocera-linux/buildroot", buildroot) + built = packages_and_programs(package_names(paths), launchers) return configgen_cores( self._fetch_text(CONFIGGEN_DEFAULTS_URL.format(tag=self.tag)), self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)), _not_cores(), + built, ) def _fetch_text(self, url: str) -> str: @@ -548,7 +630,10 @@ class Scraper(BaseScraper): SYSTEM_SLUG_MAP, ) - cores, standalone, dual = self._fetch_cores() + launchers = extract_launchers( + lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}") + ) + cores, standalone, dual = self._fetch_cores(launchers) libretro_systems = es_systems_libretro_systems( self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)), _not_cores() ) @@ -570,8 +655,9 @@ class Scraper(BaseScraper): result["dual_mode_cores"] = dual # The systems each libretro core runs for: its save folders. result["libretro_systems"] = libretro_systems - result["launchers"] = extract_launchers( - lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}") + result["launchers"] = launchers + result["configs_root"] = configs_root( + Configgen(lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}")) ) return result diff --git a/scripts/scraper/knulli_launchers.py b/scripts/scraper/knulli_launchers.py index b9916f0c8..e5a138afd 100644 --- a/scripts/scraper/knulli_launchers.py +++ b/scripts/scraper/knulli_launchers.py @@ -49,7 +49,6 @@ try: ) from .configgen_launchers import ( Launcher, - _calls, LauncherShapeError, Source, _assigned_name, @@ -76,7 +75,6 @@ except ImportError: ) from configgen_launchers import ( # type: ignore[no-redef] Launcher, - _calls, LauncherShapeError, Source, _assigned_name, @@ -1071,39 +1069,6 @@ EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( ) -# Generators whose command sets the emulator's XDG data folder, the parent -# of every user folder a yuzu-family emulator derives. -DATA_HOME_GENERATORS = ("suyu.suyuGenerator",) - - -def data_homes(cg: Configgen) -> list[Launcher]: - """Command(..., env={"XDG_DATA_HOME": }) in the generators above. - - The path need not lie in /userdata/bios: KNULLI's suyu keeps its data - in /userdata/saves/switch, which tells where its keys are not. - """ - launchers = [] - for module in DATA_HOME_GENERATORS: - src = cg.generator(module) - found = [ - (_path_value(item, cg.paths), key) - for call in _calls(src.tree, attr="Command") - for keyword in call.keywords - if keyword.arg == "env" and isinstance(keyword.value, ast.Dict) - for key, item in zip(keyword.value.keys, keyword.value.values) - if isinstance(key, ast.Constant) and key.value == "XDG_DATA_HOME" - ] - if not found or found[0][0] is None: - src.fail("the command environment sets no XDG_DATA_HOME path") - value, node = found[0] - launchers.append(Launcher( - emulator=cg.emulator(module), build="standalone", kind="passes_path", names=[], - search_dir=value, passes_to="environment XDG_DATA_HOME", - source_ref=src.ref([(node.lineno, node.end_lineno)]), - )) - return launchers - - # KNULLI builds Batocera's emulator packages from this submodule, at the # batocera.linux commit it pins: Citron's path patch is there. BATOCERA_SUBMODULE = "batocera" @@ -1123,7 +1088,6 @@ def extract_launchers( launchers += boot_disks(cg, read) for spec in GENERIC_SITES: launchers += bios_sites(cg, spec) - launchers += data_homes(cg) if batocera is not None: prefix = f"{BATOCERA_SUBMODULE}/" launchers += patched_paths( diff --git a/scripts/scraper/knulli_scraper.py b/scripts/scraper/knulli_scraper.py index abae8d987..763c25ec4 100644 --- a/scripts/scraper/knulli_scraper.py +++ b/scripts/scraper/knulli_scraper.py @@ -41,11 +41,17 @@ from .batocera_scraper import ( es_systems_by_system, es_systems_libretro_systems, extract_systems_dict, + package_names, + packages_and_programs, platform_systems, + repo_tree, requirements_from_systems, + submodule_revision, ) from .es_emulators import attach_system_cores +from .batocera_launchers import configs_root from .knulli_launchers import BATOCERA_SUBMODULE, extract_launchers +from .knulli_launchers import configgen as knulli_configgen PLATFORM_NAME = "knulli" @@ -123,11 +129,27 @@ class Scraper(BaseScraper): defaults = self._fetch_text(CONFIGGEN_DEFAULTS_TEMPLATE.format(ref=self.tag)) es_systems = self._fetch_text(ES_SYSTEMS_TEMPLATE.format(ref=self.tag)) dropped = _not_cores("batocera") | _not_cores(PLATFORM_NAME) - cores, standalone, dual = configgen_cores(defaults, es_systems, dropped) + revision = self.submodule_revision() + launchers = extract_launchers( + lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path), + lambda path: self._fetch_text(_BATOCERA_RAW.format(ref=revision) + path), + ) + # The packages KNULLI builds come from its own tree, its batocera + # submodule and its buildroot submodule: a default no package of + # them builds (suyu) is not run. + paths = repo_tree(REPO, self.tag) + paths += repo_tree("batocera-linux/batocera.linux", revision) + paths += repo_tree( + "knulli-cfw/buildroot", submodule_revision(REPO, "buildroot", self.tag) + ) + built = packages_and_programs(package_names(paths), launchers) + cores, standalone, dual = configgen_cores(defaults, es_systems, dropped, built) # Each system's own list (psx, es_systems.yml:640-653: libretro # pcsx_rearmed, swanstation, mednafen_psx, then duckstation and # duckstation-legacy). - attach_system_cores(systems, es_systems_by_system(es_systems, defaults, dropped)) + attach_system_cores( + systems, es_systems_by_system(es_systems, defaults, dropped, built) + ) result = { "platform": "KNULLI", "version": self.tag, @@ -146,10 +168,9 @@ class Scraper(BaseScraper): # configgen hands a libretro core /userdata/saves/, as # Batocera's does: the systems each core runs for are its save folders. result["libretro_systems"] = es_systems_libretro_systems(es_systems, dropped) - revision = self.submodule_revision() - result["launchers"] = extract_launchers( - lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path), - lambda path: self._fetch_text(_BATOCERA_RAW.format(ref=revision) + path), + result["launchers"] = launchers + result["configs_root"] = configs_root( + knulli_configgen(lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path)) ) return result diff --git a/scripts/scraper/retrobat_launchers.py b/scripts/scraper/retrobat_launchers.py index 8d624ceaa..037b350ba 100644 --- a/scripts/scraper/retrobat_launchers.py +++ b/scripts/scraper/retrobat_launchers.py @@ -32,6 +32,7 @@ SOURCE_FILES = ( f"{GENERATORS}/DosBoxPure.Generator.cs", f"{GENERATORS}/DosBoxStaging.Generator.cs", f"{GENERATORS}/Duckstation.Generator.cs", + f"{GENERATORS}/Eden.Generator.cs", f"{GENERATORS}/Eka2l1.Generator.cs", f"{GENERATORS}/Fbneo.Generator.cs", f"{GENERATORS}/Flycast.Generator.cs", @@ -57,6 +58,7 @@ SOURCE_FILES = ( f"{GENERATORS}/Project64.Generator.cs", f"{GENERATORS}/Raine.Generator.cs", f"{GENERATORS}/Rpcs3.Generator.cs", + f"{GENERATORS}/Ryujinx.Generator.cs", f"{GENERATORS}/SSF.Generator.cs", f"{GENERATORS}/ScummVm.Generator.cs", f"{GENERATORS}/ShadPS4.Generator.cs", @@ -68,6 +70,7 @@ SOURCE_FILES = ( f"{GENERATORS}/Xroar.Generator.cs", f"{GENERATORS}/Yabasanshiro.Generator.cs", f"{GENERATORS}/Ymir.Generator.cs", + f"{GENERATORS}/Yuzu.Generator.cs", f"{GENERATORS}/mGBA.Generator.cs", FEATURES, ) @@ -3082,44 +3085,63 @@ def _per_system(src: Source, record: Record) -> list[Record]: return out -# Generators that create a portable user folder beside the emulator's binary, -# and the emulator each one starts: the folder AppConfig.GetFullPath gives. +# Generators that start an emulator with a portable data folder beside its +# binary: (generator, emulator key, binary, the code's name for the folder). PORTABLE_USERS = ( - ("Citron.Generator.cs", "citron", "citron.exe"), - ("ShadPS4.Generator.cs", "shadps4", "shadPS4.exe"), + ("Citron.Generator.cs", "citron", "citron.exe", "PORTABLE_DIR"), + ("Eden.Generator.cs", "eden", "eden.exe", "PORTABLE_DIR"), + ("Ryujinx.Generator.cs", "ryujinx", "Ryujinx.exe", "DefaultPortableDir"), + ("ShadPS4.Generator.cs", "shadps4", "shadPS4.exe", "PORTABLE_DIR"), + ("Yuzu.Generator.cs", "yuzu", "yuzu.exe", "PORTABLE_DIR"), ) _EMULATOR_FOLDER = ( r"GetEmulatorPathAndExe\(emulator, out string path, out string exe, \"[^\"]+\"\)" - r"|string path = AppConfig\.GetFullPath\(emulator\);" + r"|string path = AppConfig\.GetFullPath\((?:emulator(?:\.Replace\(\"-\", \" \"\))?" + r"|\"\w+\")\);" +) +_PORTABLE_FOLDER = r'string (userFolder|setupPath) = Path\.Combine\(path, "(\w+)"\);' +# Ryujinx's generator hands --root-data-dir saves/switch/ryujinx/portable +# instead when that folder exists. +_ALTERNATE_ROOT = ( + r'string portablePath = Path\.Combine\(AppConfig\.GetFullPath\("saves"\), "switch", ' + r'"ryujinx", "portable"\);' ) def portable_users(sources: dict[str, Source]) -> list[Record]: - """The portable user folder a generator creates beside the binary. + """The portable data folder a generator gives an emulator beside its binary. - The emulator folder comes from AppConfig.GetFullPath(emulator), and the - generator creates /user before launching: the emulator then - keeps its data there (keys/, sys_modules/) in place of %APPDATA%. + The emulator folder comes from AppConfig.GetFullPath, and the generator + creates or configures /user (/portable for Ryujinx) + before launching: the emulator then keeps its data there (keys/, + sys_modules/, system/) in place of %APPDATA%. """ records = [] - for name, emulator, binary in PORTABLE_USERS: + for name, emulator, binary, handle in PORTABLE_USERS: src = sources[f"{GENERATORS}/{name}"] found = src.find(_EMULATOR_FOLDER, f"{emulator} folder") - user = src.find(r'string userFolder = Path\.Combine\(path, "(\w+)"\);', "user folder") - create = src.find( - r"Directory\.CreateDirectory\(userFolder\)", "user folder creation", user.end() - ) + folder = src.find(_PORTABLE_FOLDER, "portable folder") + spans = [src.lines(found.start(), found.end()), src.lines(folder.start(), folder.end())] + if folder.group(1) == "userFolder": + create = src.find( + r"Directory\.CreateDirectory\(userFolder\)", "user folder creation", folder.end() + ) + spans[-1] = src.lines(folder.start(), create.end()) + depends = None + alternate = re.search(_ALTERNATE_ROOT, src.text) + if alternate: + spans.append(src.lines(alternate.start(), alternate.end())) + depends = "unless saves/switch/ryujinx/portable exists, passed with -r" records.append(_record( emulator, "standalone", "passes_path", [], - source_ref((src.path, [ - src.lines(found.start(), found.end()), src.lines(user.start(), create.end()), - ])), - search_dir=f'/{user.group(1)}', - passes_to=f"PORTABLE_DIR, the user folder beside {binary}", + source_ref((src.path, spans)), + search_dir=f'/{folder.group(2)}', + passes_to=f"{handle}, the portable folder beside {binary}", fails_when_missing=False, + depends_on_option=depends, )) return records diff --git a/scripts/verify.py b/scripts/verify.py index 7a05a1be9..beaeb9b6b 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -980,6 +980,7 @@ _UNPLACED_REASONS = { "outside_bios_folder": "read in a folder the platform keeps outside the BIOS tree", "no_roms_directory": "read below a roms folder the platform's registry does not name", "no_emulators_directory": "read in an emulator folder the platform's registry does not describe", + "no_configs_directory": "read below a configs folder the platform's file does not record", "no_platform_slug": "no platform slug for its system", "below_platform_folder": "below the folder the platform lists", "no_save_directory": "read below a save directory the platform keeps in no fixed place", diff --git a/tests/test_install.py b/tests/test_install.py index 9ca17cabb..8024928b2 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1150,7 +1150,7 @@ class OuterRootsAreUntrusted(unittest.TestCase): def test_unsafe_from_bios_is_refused(self): for value in (*self.UNSAFE, "../../../x", "../x/..", "x", "./x", "/x", "..x", None): with self.subTest(from_bios=value), self.assertRaises(ValueError): - install._check_roms_directory({"roms_directory": {**self.ROMS, "from_bios": value}}) + install._check_relative_directories({"roms_directory": {**self.ROMS, "from_bios": value}}) def test_the_relations_the_rom_folders_files_use_are_accepted(self): bios = Path("/card/system/bios") @@ -1161,13 +1161,13 @@ class OuterRootsAreUntrusted(unittest.TestCase): } for value, root in expected.items(): with self.subTest(from_bios=value): - install._check_roms_directory({"roms_directory": {**self.ROMS, "from_bios": value}}) - self.assertEqual(install._roms_root(bios, value), root) + install._check_relative_directories({"roms_directory": {**self.ROMS, "from_bios": value}}) + self.assertEqual(install._relative_root(bios, value), root) self.assertEqual(install.MAX_ROMS_UPS, 2) def test_a_list_without_a_roms_root_leaves_the_files_out(self): files = [{"dest": "pico-8/pico8_64", "root": "roms", "cores": ["x"]}] - kept, left = install.place_roms_entries(files, Path("/card/roms/bios"), {}, "x", say=False) + kept, left = install.place_relative_entries(files, Path("/card/roms/bios"), {}, "x", say=False) self.assertEqual((kept, [o["reason"] for o in left]), ([], ["no_roms_directory"])) def test_unsafe_emulator_folders_are_refused(self): @@ -1192,7 +1192,7 @@ class OuterRootsAreUntrusted(unittest.TestCase): with unittest.mock.patch.object(install.os, "name", host): for value in ("../C:x", "..\\x", "../x:", "../Roms:x"): with self.subTest(host=host, from_bios=value), self.assertRaises(ValueError): - install._check_roms_directory( + install._check_relative_directories( {"roms_directory": {**self.ROMS, "from_bios": value}}) for value in ("C:x/{key}", "x:/{key}", "emu:x/{key}"): layout = {**self.EMULATORS, "searched": [value], "default": value} @@ -7721,5 +7721,32 @@ class TheListVersionIsSaidBeforeAndAfter(unittest.TestCase): self.assertNotIn("This list was built for", proc.stdout) +class APackageHintNamesThePackageCommand(unittest.TestCase): + """Run from the retrobios package, a hint said `python3 install.py`, + a file the user does not have.""" + + def test_the_environment_names_the_command(self): + for prefix, command in ( + ("/home/me/.cache/uv/archive-v0/UwL4i3xOlCEYSTnw", "uvx retrobios"), + ("C:\\Users\\me\\AppData\\Local\\uv\\cache\\archive-v0\\x1", "uvx retrobios"), + ("/home/me/.cache/pipx/539b8243c11ee7c", "pipx run retrobios"), + ("/home/me/.local/pipx/.cache/539b8243c11ee7c", "pipx run retrobios"), + ("/home/me/.local/share/pipx/venvs/retrobios", "retrobios"), + ("/home/me/.local/share/uv/tools/retrobios", "retrobios"), + ("/home/me/venv", "retrobios"), + ): + with self.subTest(prefix=prefix): + self.assertEqual(install.package_command(prefix), command) + + def test_the_launcher_is_the_package_command(self): + hint = install.Invocation() + with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "pypi"}), \ + unittest.mock.patch.object(install.sys, "prefix", "/home/me/.cache/uv/archive-v0/a"): + self.assertEqual(hint.launcher(), "uvx retrobios") + self.assertEqual(hint.launcher(elevated=True), "sudo uvx retrobios") + self.assertTrue(hint.command("retroarch", "DIR").startswith( + "uvx retrobios --platform retroarch --dest DIR")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_launcher_paths.py b/tests/test_launcher_paths.py index 2f0cb8f1e..e2b8a6866 100644 --- a/tests/test_launcher_paths.py +++ b/tests/test_launcher_paths.py @@ -21,7 +21,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "scripts")) from launchers import handed_directories, standalone_bindings # noqa: E402 -from scraper import knulli_launchers as kl # noqa: E402 from scraper import retrobat_launchers as rl # noqa: E402 from scraper.batocera_launchers import ( # noqa: E402 GENERATORS, @@ -128,35 +127,27 @@ class AFolderTheGeneratorEnters(unittest.TestCase): self.assertEqual(found, {"": ("outside", "")}) -class ADataFolderTheEnvironmentSets(unittest.TestCase): - SUYU = f"{kl.GENERATORS}/suyu/suyuGenerator.py" - TEXT = textwrap.dedent("""\ - from ...batoceraPaths import CACHE, CONFIGS, SAVES - - class SuyuGenerator: - def generate(self, system, rom): - commandArray = ["/usr/bin/suyu", "-f", "-g", rom] - return Command.Command(array=commandArray, env={ - "XDG_CONFIG_HOME": CONFIGS, - "XDG_DATA_HOME": SAVES / "switch", - }) - """) - - def test_the_data_folder_is_a_record_and_binds_outside(self): - files = {f"{kl.CONFIGGEN}/batoceraPaths.py": PATHS, - f"{kl.GENERATORS}/importer.py": IMPORTER_TEXT, self.SUYU: self.TEXT} - (record,) = records(kl.data_homes(kl.configgen(files.__getitem__))) - self.assertEqual( - (record["emulator"], record["search_dir"], record["passes_to"], record["source_ref"]), - ("suyu", "/userdata/saves/switch", "environment XDG_DATA_HOME", f"{self.SUYU}:8"), - ) - config = {"base_destination": "bios", "standalone_cores": ["suyu"], "launchers": [record]} +class AVariableADeclaredFolderIsSpelledFrom(unittest.TestCase): + def test_a_data_folder_outside_the_tree_binds_outside(self): + record = {"emulator": "emu", "build": "standalone", "kind": "passes_path", + "names": [], "search_dir": "/userdata/saves/switch", + "passes_to": "environment XDG_DATA_HOME", "source_ref": "x.py:1"} + config = {"base_destination": "bios", "standalone_cores": ["emu"], "launchers": [record]} found = standalone_bindings( - {"suyu"}, ["$XDG_DATA_HOME/suyu", "SuyuPath::KeysDir = keys"], - handed_directories(config), + {"emu"}, ["$XDG_DATA_HOME/emu"], handed_directories(config) ) self.assertEqual(found, {"": ("outside", "")}) + def test_a_data_folder_in_the_tree_binds_below_it(self): + record = {"emulator": "emu", "build": "standalone", "kind": "passes_path", + "names": [], "search_dir": "/userdata/bios/data", + "passes_to": "environment XDG_DATA_HOME", "source_ref": "x.py:1"} + config = {"base_destination": "bios", "standalone_cores": ["emu"], "launchers": [record]} + found = standalone_bindings( + {"emu"}, ["$XDG_DATA_HOME/emu"], handed_directories(config) + ) + self.assertEqual(found, {"": ("", "data/emu")}) + class APortableUserFolder(unittest.TestCase): CITRON = """ @@ -185,12 +176,28 @@ class APortableUserFolder(unittest.TestCase): } """ + RYUJINX = """ + public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution) + { + string path = AppConfig.GetFullPath("ryujinx"); + string portablePath = Path.Combine(AppConfig.GetFullPath("saves"), "switch", "ryujinx", "portable"); + string setupPath = Path.Combine(path, "portable"); + } + """ + def sources(self) -> dict[str, rl.Source]: + def source(ref: str, text: str) -> rl.Source: + return rl.Source(ref, textwrap.dedent(text).lstrip("\n")) + + def yuzu_like(exe: str) -> str: + return self.CITRON.replace("citron.exe", exe) + return { - f"{rl.GENERATORS}/Citron.Generator.cs": rl.Source( - "c", textwrap.dedent(self.CITRON).lstrip("\n")), - f"{rl.GENERATORS}/ShadPS4.Generator.cs": rl.Source( - "s", textwrap.dedent(self.SHADPS4).lstrip("\n")), + f"{rl.GENERATORS}/Citron.Generator.cs": source("c", self.CITRON), + f"{rl.GENERATORS}/Eden.Generator.cs": source("e", yuzu_like("eden.exe")), + f"{rl.GENERATORS}/Ryujinx.Generator.cs": source("r", self.RYUJINX), + f"{rl.GENERATORS}/ShadPS4.Generator.cs": source("s", self.SHADPS4), + f"{rl.GENERATORS}/Yuzu.Generator.cs": source("y", yuzu_like("yuzu.exe")), } def test_each_generator_gives_its_user_folder(self): @@ -198,7 +205,15 @@ class APortableUserFolder(unittest.TestCase): self.assertEqual( [(r["emulator"], r["search_dir"], r["source_ref"]) for r in found], [("citron", '/user', "c:3, 7-8"), - ("shadps4", '/user', "s:3, 8-10")], + ("eden", '/user', "e:3, 7-8"), + ("ryujinx", '/portable', "r:3-5"), + ("shadps4", '/user', "s:3, 8-10"), + ("yuzu", '/user', "y:3, 7-8")], + ) + ryujinx = found[2] + self.assertEqual(ryujinx["passes_to"], "DefaultPortableDir, the portable folder beside Ryujinx.exe") + self.assertEqual( + ryujinx["depends_on_option"], "unless saves/switch/ryujinx/portable exists, passed with -r" ) def test_the_user_folder_binds_below_the_emulator_folder(self): @@ -213,5 +228,94 @@ class APortableUserFolder(unittest.TestCase): self.assertEqual(found, {"": ("emulators", "citron/user")}) + +class ADefaultTheBuildLacksIsNotRun(unittest.TestCase): + """KNULLI's configgen-defaults.yml picks suyu for the Switch, which its + es_systems.yml does not offer and no package builds.""" + + ES = textwrap.dedent("""\ + switch: + emulators: + citron: + citron: { requireAnyOf: [BR2_PACKAGE_CITRON] } + voxatron: + emulators: {} + """) + DEFAULTS = textwrap.dedent("""\ + switch: + emulator: suyu + core: suyu + voxatron: + emulator: lexaloffle + core: vox_official + kodi: + emulator: kodi + core: kodi + """) + LAUNCHERS = [{"emulator": "lexaloffle", "kind": "fixed_name", "passes_to": "exec", + "search_dir": "/userdata/bios"}] + + def test_only_what_a_package_or_the_user_supplies_is_run(self): + from scraper import batocera_scraper as bs + + paths = ["package/batocera/emulators/citron/Config.in", "package/kodi/Config.in"] + built = bs.packages_and_programs(bs.package_names(paths), self.LAUNCHERS) + cores, standalone, _dual = bs.configgen_cores(self.DEFAULTS, self.ES, set(), built) + self.assertEqual(sorted(cores), ["citron", "kodi", "vox_official"]) + self.assertIn("lexaloffle", standalone) + self.assertNotIn("suyu", standalone) + by_system = bs.es_systems_by_system(self.ES, self.DEFAULTS, set(), built) + self.assertEqual(by_system["switch"], ["citron"]) + # Without the packages, every default counts, as before. + self.assertIn("suyu", bs.configgen_cores(self.DEFAULTS, self.ES, set())[0]) + + def test_the_real_knulli_list_names_no_suyu(self): + import yaml + + config = yaml.safe_load((REPO_ROOT / "platforms" / "knulli.yml").read_text()) + self.assertNotIn("suyu", config["cores"]) + self.assertNotIn("suyu", config.get("standalone_cores") or []) + + + +class AUserFolderInTheConfigsFolder(unittest.TestCase): + """Batocera starts shadPS4 from /userdata/system/configs/shadps4, a folder + of the configs root its batoceraPaths builds (../system/configs).""" + + def test_the_sys_modules_go_below_the_configs_root(self): + from common import load_emulator_profiles, load_platform_config, platform_layouts + from common import standalone_set + + profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["shadps4"] + for platform in ("batocera", "knulli"): + config = load_platform_config(platform, str(REPO_ROOT / "platforms")) + ((view, _standalone),) = platform_layouts("shadps4", profile, standalone_set(config)) + with self.subTest(platform=platform): + self.assertEqual( + {(f.get("root"), f["standalone_path"].rsplit("/", 1)[0]) for f in view["files"]}, + {("configs", "shadps4/user/sys_modules"), ("configs", "shadps4/user/fonts/font")}, + ) + + def test_the_installer_follows_from_bios_to_the_configs_folder(self): + import install + + manifest = {"configs_directory": {"bios_folder": "bios", "from_bios": "../system/configs"}} + install._check_relative_directories(manifest) + files = [{"dest": "shadps4/user/sys_modules/libSceNgs2.sprx", "root": "configs"}] + bios = Path("/userdata/bios") + kept, left = install.place_relative_entries(files, bios, manifest, "x", say=False) + self.assertEqual((len(kept), left), (1, [])) + self.assertEqual( + install._entry_place(bios, kept[0])[0], Path("/userdata/system/configs") + ) + kept, left = install.place_relative_entries( + files, Path("/elsewhere/firmware"), manifest, "x", say=False) + self.assertEqual((kept, [o["reason"] for o in left]), ([], ["no_configs_directory"])) + for value in ("../C:x", "../../../x", "../system/../x"): + with self.subTest(from_bios=value), self.assertRaises(ValueError): + install._check_relative_directories( + {"configs_directory": {"bios_folder": "bios", "from_bios": value}}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 2e23f5c75..cf0e2a55e 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -383,10 +383,10 @@ class AProgramRunFromItsFolder(unittest.TestCase): import install manifest = self.manifest("romsplat") - install._check_roms_directory(manifest) + install._check_relative_directories(manifest) files = list(manifest["files"]) card = self.root / "card" / "roms" / "bios" - kept, left = install.place_roms_entries(files, card, manifest, "romsplat", say=False) + kept, left = install.place_relative_entries(files, card, manifest, "romsplat", say=False) self.assertEqual((len(kept), left), (2, [])) self.assertEqual( install._entry_path(card, kept[0]), @@ -394,22 +394,22 @@ class AProgramRunFromItsFolder(unittest.TestCase): if install._entry_path(card, kept[0]).is_absolute() else card.parent / kept[0]["dest"], ) other = self.root / "elsewhere" - kept, left = install.place_roms_entries(files, other, manifest, "romsplat", say=False) + kept, left = install.place_relative_entries(files, other, manifest, "romsplat", say=False) self.assertEqual(kept, []) self.assertEqual({o["reason"] for o in left}, {"no_roms_directory"}) with self.assertRaises(ValueError): - install._check_roms_directory({"files": files}) + install._check_relative_directories({"files": files}) # A roms root beside the BIOS folder (Batocera's ../roms). beside = {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": "../roms"}} - install._check_roms_directory(beside) + install._check_relative_directories(beside) disk = self.root / "userdata" / "bios" - install.place_roms_entries(files, disk, beside, "romsplat", say=False) + install.place_relative_entries(files, disk, beside, "romsplat", say=False) self.assertEqual(install._entry_place(disk, kept_entry := files[0])[0], self.root / "userdata" / "roms") self.assertEqual(kept_entry["root"], "roms") for climbing in ("roms", "../roms/..", "/storage/roms"): with self.subTest(from_bios=climbing), self.assertRaises(ValueError): - install._check_roms_directory( + install._check_relative_directories( {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": climbing}} ) diff --git a/tests/test_pypi_package.py b/tests/test_pypi_package.py index 4e49f5f69..2785257c9 100644 --- a/tests/test_pypi_package.py +++ b/tests/test_pypi_package.py @@ -186,8 +186,10 @@ class TheInstalledCommandIsTheInstaller(Built): def _same_answers(self, python: str, name: str) -> None: command = self._installed(python, name) - # `retrobios` and `python -m retrobios` both run install.py. + # `retrobios` and `python -m retrobios` both run install.py, whose + # examples then name the package command. launchers = ([str(command)], [str(command.with_name("python")), "-m", "retrobios"]) + as_package = {**os.environ, "RETROBIOS_BOOTSTRAP": "pypi"} for launcher in launchers: for args in (["--help"], ["--version"], ["--no-such-option"]): with self.subTest(python=name, launcher=launcher[-1], args=args): @@ -195,7 +197,7 @@ class TheInstalledCommandIsTheInstaller(Built): text=True, check=False, cwd=self.scratch) script = subprocess.run([python, str(REPO_ROOT / "install.py"), *args], capture_output=True, text=True, check=False, - cwd=self.scratch) + cwd=self.scratch, env=as_package) self.assertEqual( (packaged.returncode, packaged.stdout, packaged.stderr), (script.returncode, script.stdout, script.stderr)) @@ -203,6 +205,20 @@ class TheInstalledCommandIsTheInstaller(Built): def test_this_python(self): self._same_answers(sys.executable, "venv-current") + def test_a_hint_gives_back_the_package_command(self): + """Nothing detected, the example said `python3 install.py`.""" + command = self._installed(sys.executable, "venv-hint") + home = self.scratch / "empty-home" + home.mkdir() + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home), + "RETROBIOS_INSTALL_OFFLINE": "1"} + env.pop("RETROBIOS_BOOTSTRAP", None) + run = subprocess.run([str(command), "--yes"], capture_output=True, text=True, + check=False, cwd=self.scratch, env=env) + out = run.stdout + run.stderr + self.assertIn("retrobios --platform retroarch --dest", out) + self.assertNotIn("install.py --platform", out) + def test_python_38(self): python = _python38() if python is None: diff --git a/wiki/installer.md b/wiki/installer.md index 0d642dd53..62b37bd35 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1886,7 +1886,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `EXTERNAL_STORAGE` | Android only, the shared storage root RetroArch builds its default directories from. Defaults to `/storage/emulated/0` | | `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only | | `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry | -| `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` | +| `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, and by the `retrobios` package (`pypi`), so that its hints give back the command that ran it (the one-liner, `uvx retrobios`, `pipx run retrobios` or `retrobios`) rather than `python3 install.py` | | `NO_COLOR` | Set to any value but an empty one: no colour, even at a terminal. The full screen then marks the row in focus with `>` as well as reverse video. What else turns colour off is under [The full-screen interface](#the-full-screen-interface) | | `TERM`, `TERMINFO`, `TERMINFO_DIRS` | Decide whether the full screen opens, as [The full-screen interface](#the-full-screen-interface) says. The terminal's description is read where ncurses reads it: `TERMINFO`, then `~/.terminfo`, then each folder of `TERMINFO_DIRS`, then the system's own | | `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file |