diff --git a/emulators/lrps2-416291a.yml b/emulators/lrps2-416291a.yml new file mode 100644 index 000000000..5b4a7919f --- /dev/null +++ b/emulators/lrps2-416291a.yml @@ -0,0 +1,152 @@ +# LRPS2 at 416291a with Batocera's BIOS directory patch +# Source: https://github.com/libretro/parallel-ps2 +# Upstream: https://github.com/PCSX2/pcsx2 + +emulator: "LRPS2 (416291a, Batocera)" +type: libretro +core_classification: community_fork +bios_mode: agnostic +source: "https://github.com/libretro/parallel-ps2" +upstream: "https://github.com/PCSX2/pcsx2" +profiled_date: "2026-11-10" +source_commit: "416291ad7dc3caf5df4501c9249cbbe30cbef811" +pin_frozen: "Batocera, the only platform that ships it, builds this revision with package/batocera/emulators/retroarch/libretro/libretro-ps2/001.bios-dir.patch (Batocera package/batocera/emulators/retroarch/libretro/libretro-ps2/libretro-ps2.mk:7 at batocera-43.1). Later commits are not part of that build." +pin_build: + - platform: batocera + repo: "https://github.com/batocera-linux/batocera.linux" + revision: "batocera-43.1" + path: "package/batocera/emulators/retroarch/libretro/libretro-ps2/libretro-ps2.mk" + variable: LIBRETRO_PS2_VERSION + value: "416291ad7dc3caf5df4501c9249cbbe30cbef811" +core_version: null +version_basis: + kind: none +display_name: "Sony - PlayStation 2 (LRPS2 416291a for Batocera)" +cores: [pcsx2] +built_by: [batocera] +systems: [sony-playstation-2] + +bios_directory: "ps2/" +resources_directory: "pcsx2/resources/" + +notes: | + Batocera installs the build as pcsx2_libretro.so (Batocera + package/batocera/emulators/retroarch/libretro/libretro-ps2/libretro-ps2.mk:35-36) + and launches it as the libretro core pcsx2 of the ps2 system (Batocera + package/batocera/emulationstation/batocera-es-system/es_systems.yml:2293-2294). + The library version is the short commit hash git reports at build time + (cmake/BuildParameters.cmake:4-9, libretro/main.cpp:1423-1427). + + Its patch moves the BIOS scan from pcsx2/bios to ps2 below the system + directory (libretro/main.cpp:1806) and the BIOS folder default to + /userdata/bios/ps2, the same folder on Batocera (pcsx2/Pcsx2Config.cpp:1042). + The data root stays system/pcsx2, so resources are read from + pcsx2/resources and memory cards are written to pcsx2/memcards + (libretro/main.cpp:1910-1912). + + BIOS detection is filename-agnostic: ps2/ is scanned for any file between + 4 and 8 MB with a RESET and a ROMVER romdir entry, no hash is checked, and + the list fills the pcsx2_bios core option whose default is the first BIOS + found (libretro/main.cpp:1800-1838). retro_load_game refuses to start when + no BIOS was found (libretro/main.cpp:1927-1931). The companion files take + their path from the selected BIOS. + + The DEV9 hard disk image is user storage the core creates, not a file to + obtain. + +files: + - name: ".bin" + found_by: content + path: "ps2/.bin" + required: true + description: "PS2 BIOS binary" + min_size: 4194304 + max_size: 8388608 + validation: [size] + config_key: "pcsx2_bios" + source_ref: "source libretro/main.cpp:1800-1838, source libretro/main.cpp:360-365, source libretro/main.cpp:1927-1931, source pcsx2/ps2/BiosTools.cpp:31-32, 60-187, 231-323, source pcsx2/Pcsx2Config.cpp:994-1000, 1042, 1051-1061" + note: > + Any file of 4 to 8 MB in ps2/ whose romdir holds RESET and ROMVER. The + pcsx2_bios core option picks one, and the first valid image found is used + when the configured one is missing. + + - name: ".rom1" + path: "ps2/.rom1" + required: false + max_size: 4194304 + description: "DVD player ROM" + source_ref: "source pcsx2/ps2/BiosTools.cpp:189-213, 314, source pcsx2/MemoryTypes.h:24" + note: "Tries .rom1, then the BIOS path with its extension replaced by rom1. Skipped when neither exists." + + - name: ".rom2" + path: "ps2/.rom2" + required: false + max_size: 524288 + description: "Chinese ROM extension" + source_ref: "source pcsx2/ps2/BiosTools.cpp:189-213, 315, source pcsx2/MemoryTypes.h:25" + note: "Same naming as rom1. Present only on Chinese consoles." + + - name: ".nvm" + path: "ps2/.nvm" + required: false + hle_fallback: true + size: 1024 + description: "NVRAM / EEPROM data" + source_ref: "source pcsx2/CDVD/CDVD.cpp:59-60, source pcsx2/CDVD/CDVD.cpp:160-184" + note: > + Read at the BIOS path with the extension replaced by nvm. Region and + language defaults are filled in when the file is missing, short, or has + no language or region parameters. + + - name: ".mec" + path: "ps2/.mec" + required: false + hle_fallback: true + size: 4 + description: "Mechacon version" + source_ref: "source pcsx2/CDVD/CDVD.cpp:62, source pcsx2/CDVD/CDVD.cpp:186-196" + note: "Read at the BIOS path with the extension replaced by mec. Written as 0x00020603 when missing." + + - name: "GameIndex.yaml" + path: "pcsx2/resources/GameIndex.yaml" + required: false + installed_on: [batocera] + description: "Per-game fixes and settings database" + source_ref: "source pcsx2/GameDatabase.cpp:48, source pcsx2/GameDatabase.cpp:880-885, source libretro/main.cpp:2324-2361" + note: > + Read from pcsx2/resources at the first game lookup. Without it no game + fix applies. Batocera installs the copy of the built tree in + bios/pcsx2/resources (Batocera + package/batocera/emulators/retroarch/libretro/libretro-ps2/libretro-ps2.mk:37-39). + + - name: "cheats_ws.zip" + path: "pcsx2/resources/cheats_ws.zip" + required: false + description: "widescreen patches archive" + source_ref: "source pcsx2/VMManager.cpp:333-348, source libretro/main.cpp:2315-2322" + note: "Read when widescreen patches are enabled and pcsx2/cheats_ws holds no patch for the game." + + - name: "cheats_ni.zip" + path: "pcsx2/resources/cheats_ni.zip" + required: false + description: "no-interlacing patches archive" + source_ref: "source pcsx2/VMManager.cpp:366-383, source libretro/main.cpp:2315-2322" + note: "Read when no-interlacing patches are enabled and pcsx2/cheats_ni holds no patch for the game." + + - name: "eeprom.dat" + load_from: working_directory + required: false + hle_fallback: true + size: 64 + description: "DEV9 EEPROM" + source_ref: "source pcsx2/DEV9/DEV9.cpp:107-116, source pcsx2/DEV9/DEV9.cpp:152-167" + note: "Opened from the working directory when DEV9 starts and mapped over 64 bytes. A compiled-in image stands in when the file is missing." + + - name: "flash.dat" + load_from: working_directory + required: false + hle_fallback: true + size: 8650752 + description: "DEV9 flash memory with ECC" + source_ref: "source pcsx2/DEV9/flash.cpp:21-28, source pcsx2/DEV9/flash.cpp:74-101" + note: "Read from the working directory when DEV9 starts. The flash is blank (0xFF) when the file is missing." diff --git a/emulators/lrps2-9485a53.yml b/emulators/lrps2-9485a53.yml new file mode 100644 index 000000000..7ffef6c18 --- /dev/null +++ b/emulators/lrps2-9485a53.yml @@ -0,0 +1,151 @@ +# LRPS2 at 9485a53 with Recalbox's path patch +# Source: https://github.com/libretro/parallel-ps2 +# Upstream: https://github.com/PCSX2/pcsx2 + +emulator: "LRPS2 (9485a53, Recalbox)" +type: libretro +core_classification: community_fork +bios_mode: agnostic +source: "https://github.com/libretro/parallel-ps2" +upstream: "https://github.com/PCSX2/pcsx2" +profiled_date: "2026-11-10" +source_commit: "9485a53fa5aa2bff17e04518116107f81a8c82e3" +pin_frozen: "Recalbox, the only platform that ships it, builds this revision with package/libretro-ps2/0001-recalbox-paths.patch (Recalbox package/libretro-ps2/libretro-ps2.mk:9 at tag 10.1.1). Later commits are not part of that build." +pin_build: + - platform: recalbox + repo: "https://gitlab.com/recalbox/recalbox" + revision: "10.1.1" + path: "package/libretro-ps2/libretro-ps2.mk" + variable: LIBRETRO_PS2_VERSION + value: "9485a53fa5aa2bff17e04518116107f81a8c82e3" +core_version: null +version_basis: + kind: none +display_name: "Sony - PlayStation 2 (LRPS2 9485a53 for Recalbox)" +cores: [ps2] +built_by: [recalbox] +systems: [sony-playstation-2] + +bios_directory: "ps2/" +resources_directory: "ps2/resources/" + +notes: | + Recalbox installs the build as ps2_libretro.so (Recalbox + package/libretro-ps2/libretro-ps2.mk:31-33) and lists it as the libretro + core ps2 of the ps2 system (Recalbox + package/recalbox-romfs2/systems/ps2/system.ini:65-69). + The library version is the short commit hash git reports at build time + (cmake/BuildParameters.cmake:4-9, libretro/main.cpp:1424-1428). + + Its patch moves the BIOS scan from pcsx2/bios to ps2 below the system + directory (libretro/main.cpp:1807), the data root from system/pcsx2 to + system/ps2, so resources are read from ps2/resources + (libretro/main.cpp:1911-1913), the BIOS folder default to + /recalbox/share/bios/ps2, the same folder on Recalbox, and memory cards to + /recalbox/share/saves/ps2 (pcsx2/Pcsx2Config.cpp:1042-1043, 1061-1062). + + BIOS detection is filename-agnostic: ps2/ is scanned for any file between + 4 and 8 MB with a RESET and a ROMVER romdir entry, no hash is checked, and + the list fills the pcsx2_bios core option whose default is the first BIOS + found (libretro/main.cpp:1801-1839). retro_load_game refuses to start when + no BIOS was found (libretro/main.cpp:1928-1932). The companion files take + their path from the selected BIOS. + + The DEV9 hard disk image is user storage the core creates, not a file to + obtain. + +files: + - name: ".bin" + found_by: content + path: "ps2/.bin" + required: true + description: "PS2 BIOS binary" + min_size: 4194304 + max_size: 8388608 + validation: [size] + config_key: "pcsx2_bios" + source_ref: "source libretro/main.cpp:1801-1839, source libretro/main.cpp:360-365, source libretro/main.cpp:1928-1932, source pcsx2/ps2/BiosTools.cpp:31-32, 60-187, 231-323, source pcsx2/Pcsx2Config.cpp:994-1000, 1042, 1051-1061" + note: > + Any file of 4 to 8 MB in ps2/ whose romdir holds RESET and ROMVER. The + pcsx2_bios core option picks one, and the first valid image found is used + when the configured one is missing. + + - name: ".rom1" + path: "ps2/.rom1" + required: false + max_size: 4194304 + description: "DVD player ROM" + source_ref: "source pcsx2/ps2/BiosTools.cpp:189-213, 314, source pcsx2/MemoryTypes.h:24" + note: "Tries .rom1, then the BIOS path with its extension replaced by rom1. Skipped when neither exists." + + - name: ".rom2" + path: "ps2/.rom2" + required: false + max_size: 524288 + description: "Chinese ROM extension" + source_ref: "source pcsx2/ps2/BiosTools.cpp:189-213, 315, source pcsx2/MemoryTypes.h:25" + note: "Same naming as rom1. Present only on Chinese consoles." + + - name: ".nvm" + path: "ps2/.nvm" + required: false + hle_fallback: true + size: 1024 + description: "NVRAM / EEPROM data" + source_ref: "source pcsx2/CDVD/CDVD.cpp:59-60, source pcsx2/CDVD/CDVD.cpp:160-184" + note: > + Read at the BIOS path with the extension replaced by nvm. Region and + language defaults are filled in when the file is missing, short, or has + no language or region parameters. + + - name: ".mec" + path: "ps2/.mec" + required: false + hle_fallback: true + size: 4 + description: "Mechacon version" + source_ref: "source pcsx2/CDVD/CDVD.cpp:62, source pcsx2/CDVD/CDVD.cpp:186-196" + note: "Read at the BIOS path with the extension replaced by mec. Written as 0x00020603 when missing." + + - name: "GameIndex.yaml" + path: "ps2/resources/GameIndex.yaml" + required: false + installed_on: [recalbox] + description: "Per-game fixes and settings database" + source_ref: "source pcsx2/GameDatabase.cpp:48, source pcsx2/GameDatabase.cpp:880-885, source libretro/main.cpp:2265-2302" + note: > + Read from ps2/resources at the first game lookup. Without it no game fix + applies. Recalbox installs the copy of the built tree in + bios/ps2/resources (Recalbox package/libretro-ps2/libretro-ps2.mk:34-37). + + - name: "cheats_ws.zip" + path: "ps2/resources/cheats_ws.zip" + required: false + description: "widescreen patches archive" + source_ref: "source pcsx2/VMManager.cpp:333-348, source libretro/main.cpp:2256-2263" + note: "Read when widescreen patches are enabled and ps2/cheats_ws holds no patch for the game." + + - name: "cheats_ni.zip" + path: "ps2/resources/cheats_ni.zip" + required: false + description: "no-interlacing patches archive" + source_ref: "source pcsx2/VMManager.cpp:366-383, source libretro/main.cpp:2256-2263" + note: "Read when no-interlacing patches are enabled and ps2/cheats_ni holds no patch for the game." + + - name: "eeprom.dat" + load_from: working_directory + required: false + hle_fallback: true + size: 64 + description: "DEV9 EEPROM" + source_ref: "source pcsx2/DEV9/DEV9.cpp:107-116, source pcsx2/DEV9/DEV9.cpp:152-167" + note: "Opened from the working directory when DEV9 starts and mapped over 64 bytes. A compiled-in image stands in when the file is missing." + + - name: "flash.dat" + load_from: working_directory + required: false + hle_fallback: true + size: 8650752 + description: "DEV9 flash memory with ECC" + source_ref: "source pcsx2/DEV9/flash.cpp:21-28, source pcsx2/DEV9/flash.cpp:74-101" + note: "Read from the working directory when DEV9 starts. The flash is blank (0xFF) when the file is missing." diff --git a/emulators/lrps2.yml b/emulators/lrps2.yml index b9675af82..870f6f956 100644 --- a/emulators/lrps2.yml +++ b/emulators/lrps2.yml @@ -140,6 +140,15 @@ files: flash is blank (0xFF) when neither exists. Written back only after the emulated system programs it. + - name: "eeprom.dat" + load_from: working_directory + required: false + hle_fallback: true + size: 64 + description: "DEV9 EEPROM" + source_ref: "source pcsx2/DEV9/DEV9.cpp:111-123, source pcsx2/DEV9/DEV9.cpp:159-176" + note: "Opened from the working directory when DEV9 starts and mapped over 64 bytes. A compiled-in image stands in when the file is missing." + - name: "mg_keystore.bin" path: "pcsx2/bios/mg_keystore.bin" required: false diff --git a/emulators/pcsx2-libretro-408e3b2.yml b/emulators/pcsx2-libretro-408e3b2.yml new file mode 100644 index 000000000..b90a4468f --- /dev/null +++ b/emulators/pcsx2-libretro-408e3b2.yml @@ -0,0 +1,118 @@ +# libretro PCSX2 at 408e3b2 with Recalbox's BIOS path patch +# Source: https://gitlab.com/recalbox/packages/libretro/libretro-pcsx2 +# Upstream: https://github.com/PCSX2/pcsx2 + +emulator: "PCSX2 libretro (408e3b2, Recalbox)" +type: libretro +core_classification: community_fork +bios_mode: agnostic +source: "https://gitlab.com/recalbox/packages/libretro/libretro-pcsx2" +upstream: "https://github.com/PCSX2/pcsx2" +profiled_date: "2026-11-10" +source_commit: "408e3b253847b25536ad77e4ab0c3aff6b8b896f" +pin_frozen: "Recalbox, the only platform that ships it, builds this revision with package/libretro-pcsx2/0003-bios-path.patch (Recalbox package/libretro-pcsx2/libretro-pcsx2.mk:8 at tag 10.1.1). Later commits are not part of that build." +pin_build: + - platform: recalbox + repo: "https://gitlab.com/recalbox/recalbox" + revision: "10.1.1" + path: "package/libretro-pcsx2/libretro-pcsx2.mk" + variable: LIBRETRO_PCSX2_VERSION + value: "408e3b253847b25536ad77e4ab0c3aff6b8b896f" +core_version: "1.7.0" +version_basis: + kind: file + path: "pcsx2/SysForwardDefs.h" + pattern: 'PCSX2_VersionHi\s*=\s*(\d+);\s*\n.*PCSX2_VersionMid\s*=\s*(\d+);\s*\n.*PCSX2_VersionLo\s*=\s*(\d+);' + format: "{1}.{2}.{3}" +display_name: "Sony - PlayStation 2 (PCSX2 libretro 408e3b2 for Recalbox)" +cores: [pcsx2] +built_by: [recalbox] +systems: [sony-playstation-2] + +bios_directory: "ps2/" + +notes: | + The wxWidgets-era libretro port of PCSX2 1.7.0. Recalbox builds it for + x86_64 only (Recalbox package/libretro-pcsx2/Config.in:3, Recalbox + configs/recalbox-x86_64_defconfig:352), installs it as pcsx2_libretro.so + (Recalbox package/libretro-pcsx2/libretro-pcsx2.mk:30-31) and lists it as + the libretro core pcsx2 of the ps2 system (Recalbox + package/recalbox-romfs2/systems/ps2/system.ini:54-58). + The library version is git describe of the build tree, empty when the tree + has no .git (cmake/Pcsx2Utils.cmake:38-52, 70-72, libretro/main.cpp:447-448). + + Its patch moves the BIOS scan from pcsx2/bios to ps2 below the system + directory (libretro/main.cpp:264), /recalbox/share/bios/ps2 on Recalbox. + + BIOS detection is filename-agnostic: every file in ps2/ whose romdir holds + a RESET and a ROMVER entry is listed, no size or hash is checked, and the + list fills the pcsx2_bios core option whose default is the first BIOS found + (libretro/main.cpp:262-303). Without one, retro_init returns before it + creates the emulator and no game starts (libretro/main.cpp:282-287, + 325-328). The companion files take their path from the selected BIOS. + + GameIndex.yaml and the widescreen, no-interlacing and 60 fps patch archives + are compiled in (pcsx2/CMakeLists.txt:338-367, + pcsx2/gui/AppGameDatabase.cpp:20-24, pcsx2/Patch.cpp:30-32). DEV9 is the + null device (pcsx2/DEV9/DEV9.cpp:59-64), so no hard disk, EEPROM or flash + file is read. Memory cards and pnach patches under system/pcsx2 are user + files. + +files: + - name: ".bin" + found_by: content + path: "ps2/.bin" + required: true + description: "PS2 BIOS binary" + min_size: 4194304 + max_size: 8388608 + config_key: "pcsx2_bios" + source_ref: "source libretro/main.cpp:262-303, source libretro/main.cpp:317-328, source libretro/main.cpp:653-654, source pcsx2/ps2/BiosTools.cpp:74-167, 261-319, 340-356, source pcsx2/MemoryTypes.h:21" + note: > + Any file in ps2/ whose romdir holds RESET and ROMVER, picked by the + pcsx2_bios core option. No size is checked: RESET is looked for in the + first 8 MB (pcsx2/ps2/BiosTools.cpp:79-84) and the first 4 MB are + mapped. + + - name: ".rom1" + path: "ps2/.rom1" + required: false + description: "DVD player ROM" + source_ref: "source pcsx2/ps2/BiosTools.cpp:191-229, 294, source pcsx2/MemoryTypes.h:22" + note: "Tries .rom1, then the BIOS path with its extension replaced by rom1. Skipped when neither exists. The first 256 KB are read." + + - name: ".rom2" + path: "ps2/.rom2" + required: false + description: "Chinese ROM extension" + source_ref: "source pcsx2/ps2/BiosTools.cpp:191-229, 295, source pcsx2/MemoryTypes.h:23" + note: "Same naming as rom1. Present only on Chinese consoles. The first 512 KB are read." + + - name: ".erom" + path: "ps2/.erom" + required: false + description: "DVD player extension ROM" + source_ref: "source pcsx2/ps2/BiosTools.cpp:191-229, 296, source pcsx2/MemoryTypes.h:24" + note: "Same naming as rom1. The first 0x1C0000 bytes are read." + + - name: ".nvm" + path: "ps2/.nvm" + required: false + hle_fallback: true + size: 1024 + description: "NVRAM / EEPROM data" + source_ref: "source pcsx2/CDVD/CDVD.cpp:142-187, source libretro/main.cpp:634-642, source libretro/language_injector.cpp:231-262" + note: > + Read at the BIOS path with the extension replaced by nvm. A file shorter + than 1024 bytes is replaced by a blank one with a dummy i.LINK ID. + Without it the fast boot option is ignored. When it exists and the BIOS + is in the language table, the system language option is written into it. + + - name: ".mec" + path: "ps2/.mec" + required: false + hle_fallback: true + size: 4 + description: "Mechacon version" + source_ref: "source pcsx2/CDVD/CDVD.cpp:96-126" + note: "Read at the BIOS path with the extension replaced by mec. Written as bytes 03 06 02 00 when missing." diff --git a/scripts/common.py b/scripts/common.py index 09d2900e2..7fc2bdf04 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -3308,6 +3308,11 @@ def _platform_profiles( } index = upstream_profile_index(profiles) platform = str(config.get("_key") or "") + dual_names = {str(c) for c in config.get("dual_mode_cores") or []} + + def build_modes(p: dict) -> set[str]: + kind = str(p.get("type", "")) + return {mode for mode in ("standalone", "libretro") if mode in kind} def designated(name: str) -> set[str]: # A build the platform compiles itself (built_by) is what its @@ -3319,10 +3324,21 @@ def _platform_profiles( p for p in index.get(name, ()) if platform and platform in (profiles[p].get("built_by") or []) } - if own: - return own preferred = preferred_profile(index, name) - return {preferred} if preferred is not None else set() + if not own: + return {preferred} if preferred is not None else set() + # The own builds stand for the modes they are built in only. + # Batocera builds LRPS2 as pcsx2_libretro.so and launches + # standalone PCSX2 under the same name. + own_modes = set().union(*(build_modes(profiles[p]) for p in own)) + if ( + name in dual_names + and preferred is not None + and preferred not in own + and not build_modes(profiles[preferred]) & own_modes + ): + own.add(preferred) + return own result = {profile for c in core_set for profile in designated(c)} # A name the platform launches both ways designates its libretro diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 8c1018563..fcda2e74d 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -4091,8 +4091,20 @@ def _data_root_target(system: str, text: str) -> str | None: return None +def _one_content(held: list[str], entries: dict[str, dict], emulator: str) -> list[str]: + """The one dest a copy reads when every held dest carries the same bytes. + + Unchanged when the contents differ: the name then designates nothing. + """ + if len({entries[dest].get("sha1") for dest in held}) != 1: + return held + own = [dest for dest in held if emulator in (entries[dest].get("cores") or ())] + return [min(own or held, key=lambda dest: (dest.count("/"), len(dest), dest))] + + def _profile_copies( profiles: dict[str, dict], dests: list[str], declared: list[dict], + entries: dict[str, dict] | None = None, ) -> list[dict]: """The standalone copies the emulator profiles document: each file with a standalone_path, into the data_root of each system that names a place. @@ -4105,6 +4117,10 @@ def _profile_copies( file found by content (found_by) sits wherever the folder the user set for the scan points: Amiberry reads its ROMs from rom_path, which amiberry.conf moves, so its name under data_root proves nothing. + ``entries`` maps each dest to its manifest entry: one file carried at + two paths with the same bytes is one source, the copy whose cores name + the emulator, else the shortest path. DuckStation's ps2_scph50006.bin + sits at the BIOS root and under pcsx2/bios/ for the libretro PS2 cores. """ from fnmatch import fnmatch # noqa: PLC0415 @@ -4143,6 +4159,8 @@ def _profile_copies( or entry.get("type") == "directory" or entry.get("found_by") == "content"): continue held = by_tail.get(entry.get("path") or entry["name"], []) + if len(held) > 1 and entries is not None: + held = _one_content(held, entries, profile_name) if len(held) != 1: continue source = held[0] @@ -4171,8 +4189,10 @@ def _standalone_copies( whose registry entry declares none gets none.""" if not declared: return [] - dests = [entry["dest"] for entry in manifest_files if entry.get("root") != "save"] - return _placed_copies(declared, dests) + _profile_copies(profiles, dests, declared) + kept = [entry for entry in manifest_files if entry.get("root") != "save"] + dests = [entry["dest"] for entry in kept] + entries = {entry["dest"]: entry for entry in kept} + return _placed_copies(declared, dests) + _profile_copies(profiles, dests, declared, entries) def _stable_hashes(local_path: str, db: dict) -> dict: diff --git a/scripts/packextras.py b/scripts/packextras.py index 056eaedd0..559014fd9 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -513,6 +513,7 @@ def _agnostic_scan_extras( extras_prefix: str, standalone_set: set[str] | None = None, taken: dict[str, dict] | None = None, + all_profiles: dict | None = None, ) -> list[dict]: """Every interchangeable candidate a filename-agnostic core accepts. @@ -521,6 +522,8 @@ def _agnostic_scan_extras( The directory is the delicate part: anchoring it on a single filename walked into another emulator's tree, so the profile's own files have to agree before the scan follows an ambiguous name. + ``all_profiles`` are the profiles a placeholder's directory is proven + from, ``profiles`` when not given. """ extras: list[dict] = [] files_db = db.get("files", {}) @@ -617,7 +620,21 @@ def _agnostic_scan_extras( resolved_dirs.get(id(image), "") if image else resolved_dirs.get(id(f), "") ) - if not path_prefix: + # A placeholder (.bin) resolves to nothing and anchors no + # directory. The folders where the system's other profiles + # resolve hashed files of that shape do, those folders only: + # LRPS2 and PCEE2 are made of placeholders, and on Batocera, + # where no other libretro PS2 core anchors pcsx2/bios/, the + # pack held nothing there. A companion (.nvm) looks + # where its image is proven: RetroBat's pcsx2/bios/ had + # scph39001.bin and its .nvm only at the BIOS root. + exact_dirs: frozenset[str] = frozenset() + anchor = image if image else f + if not path_prefix and "<" in str(anchor.get("name", "")): + exact_dirs = placeholder_directories( + anchor, emu_name, profile, db, all_profiles or profiles + ) + if not path_prefix and not exact_dirs: continue # A name matching one file identifies it. A name matching several # identifies nothing on its own, so the profile's other files have @@ -629,7 +646,11 @@ def _agnostic_scan_extras( # Scan DB for all files under this prefix matching size for sha1, entry in files_db.items(): path = entry.get("path", "") - if not path.startswith(path_prefix): + if exact_dirs: + if ("/" not in path or path.rsplit("/", 1)[0] not in exact_dirs + or "/.variants/" in path): + continue + elif not path.startswith(path_prefix): continue if not companion_ext and not size_fits(f, entry.get("size", 0)): continue @@ -676,9 +697,16 @@ def _agnostic_scan_extras( "agnostic_scan": True, } extra["claim_order"] = (4, 0, emu_name) - if not _take(extra, full_dest, seen_dests, taken): - continue - extras.append(extra) + if _take(extra, full_dest, seen_dests, taken): + extras.append(extra) + else: + # The image another core's scan placed is this core's + # too, and so are the companions it reads beside it: + # Recalbox's libretro PCSX2 opens .erom next to + # the images LRPS2 placed, which LRPS2 never reads. + held = taken.get(full_dest) if taken is not None else None + if held is None or held.get("sha1") != sha1: + continue extras.extend(_companion_extras( _companions(path, scan_name, _companion_extensions(profile, fname), files_db), folder, extras_prefix, seen_dests, profile, emu_name, f, taken, @@ -686,6 +714,31 @@ def _agnostic_scan_extras( return extras +def placeholder_directories( + seed: dict, emu_name: str, profile: dict, db: dict, all_profiles: dict, +) -> frozenset[str]: + """The folders that hold the dumps a placeholder entry accepts. + + Each folder where another profile of the same system resolves a hashed + file of the seed's shape by content: PCSX2 proves bios/Sony/PlayStation 2/. + Only that folder, not the ones below it (its DVD firmware). + """ + systems = set(profile.get("systems", [])) + directories: set[str] = set() + for other_name, other in all_profiles.items(): + if other_name == emu_name or not systems & set(other.get("systems", [])): + continue + for f in other.get("files", []): + if not isinstance(f, dict) or not any(f.get(h) for h in ("sha1", "md5", "sha256", "crc32")): + continue + if f.get("size") and not size_fits(seed, f["size"]): + continue + local, status = resolve_local_file({**f, "source_profile": other_name}, db) + if local and "/" in local and resolution_is_hash_exact(status): + directories.add(local.rsplit("/", 1)[0]) + return frozenset(directories) + + def _placeholder_scan( files: list[dict], emu_name: str, profile: dict, db: dict, standalone: bool, all_profiles: dict | None = None, @@ -699,7 +752,6 @@ def _placeholder_scan( (its DVD firmware). Nothing proven, nothing scanned. """ files_db = db.get("files", {}) - systems = set(profile.get("systems", [])) extras: list[dict] = [] for seed in files: name = str(seed.get("name", "")) @@ -707,18 +759,10 @@ def _placeholder_scan( continue if not (seed.get("size") or seed.get("min_size") or seed.get("max_size")): continue - directories: set[str] = set() - for other_name, other in (all_profiles or load_emulator_profiles("emulators")).items(): - if other_name == emu_name or not systems & set(other.get("systems", [])): - continue - for f in other.get("files", []): - if not any(f.get(h) for h in ("sha1", "md5", "sha256", "crc32")): - continue - if f.get("size") and not size_fits(seed, f["size"]): - continue - local, status = resolve_local_file({**f, "source_profile": other_name}, db) - if local and "/" in local and resolution_is_hash_exact(status): - directories.add(local.rsplit("/", 1)[0]) + directories = placeholder_directories( + seed, emu_name, profile, db, + all_profiles or load_emulator_profiles("emulators"), + ) layout = (seed.get("standalone_path") if standalone else None) or seed.get("path") or "" folder = layout.rsplit("/", 1)[0] if "/" in layout else "" for sha1, entry in sorted(files_db.items(), key=lambda kv: kv[1].get("path", "")): diff --git a/tests/test_agnostic_scope.py b/tests/test_agnostic_scope.py index 3a1d3d27a..bb54e0332 100644 --- a/tests/test_agnostic_scope.py +++ b/tests/test_agnostic_scope.py @@ -55,6 +55,96 @@ class AgnosticScan(unittest.TestCase): ["pcsx2/bios/scph1.bin", "pcsx2/bios/scph2.bin"]) + def test_a_placeholder_scans_the_folder_another_profile_proves(self): + """LRPS2 and PCEE2 name their BIOS .bin, which resolves nowhere. + + On RetroArch yaps2 anchored pcsx2/bios/ for them; on Batocera no + other libretro PS2 core runs, and the pack held nothing there. The + folder where another profile of the system resolves a hashed dump of + that shape is the evidence, that folder only. + """ + from packextras import _agnostic_scan_extras + + with tempfile.TemporaryDirectory() as tmp: + previous = os.getcwd() + os.chdir(tmp) + self.addCleanup(os.chdir, previous) + files, by_name = {}, {} + for path, data in ( + ("bios/PS2/scph1.bin", b"a" * 64), + ("bios/PS2/scph2.bin", b"b" * 64), + ("bios/PS2/scph2.bin.rom1", b"r" * 8), + ("bios/PS2/scph1.nvm", b"n" * 8), + ("bios/PS2/.variants/scph3.bin", b"e" * 64), + ("bios/PS2/DVD/dvd.bin", b"f" * 64), + ): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_bytes(data) + sha1 = hashlib.sha1(data).hexdigest() + name = path.rsplit("/", 1)[1] + files[sha1] = {"path": path, "name": name, "size": len(data), "sha1": sha1} + by_name.setdefault(name, []).append(sha1) + db = {"files": files, "indexes": {"by_name": by_name, "by_md5": {}, "by_crc32": {}, + "by_path_suffix": {}}} + placeholders = {"emulator": "L", "type": "libretro", "bios_mode": "agnostic", + "bios_directory": "pcsx2/bios/", "systems": ["ps2"], "files": [ + {"name": ".bin", "found_by": "content", + "path": "pcsx2/bios/.bin", "min_size": 32, "max_size": 64}, + {"name": ".rom1", "path": "pcsx2/bios/.rom1"}, + {"name": ".nvm", "path": "pcsx2/bios/.nvm", "size": 8}, + ]} + pinned = {"emulator": "S", "type": "standalone", "systems": ["ps2"], "files": [ + {"name": "scph1.bin", "size": 64, + "sha1": hashlib.sha1(b"a" * 64).hexdigest()}, + ]} + extras = _agnostic_scan_extras( + {"l": placeholders, "s": pinned}, {"l"}, db, by_name, set(), "") + self.assertEqual(sorted(e["destination"] for e in extras), [ + "pcsx2/bios/scph1.bin", "pcsx2/bios/scph1.nvm", "pcsx2/bios/scph2.bin", + "pcsx2/bios/scph2.bin.rom1", + ]) + self.assertTrue(all(e["source_profile"] == "l" for e in extras)) + # An image the platform already ships there still gets its + # companion: RetroBat lists pcsx2/bios/scph39001.bin itself. + shipped = {"pcsx2/bios/scph1.bin"} + extras = _agnostic_scan_extras( + {"l": placeholders, "s": pinned}, {"l"}, db, by_name, shipped, "") + self.assertIn("pcsx2/bios/scph1.nvm", {e["destination"] for e in extras}) + # Nothing proves the folder: nothing is scanned. + self.assertEqual( + _agnostic_scan_extras({"l": placeholders}, {"l"}, db, by_name, set(), ""), []) + + +class OneContentTwoPaths(unittest.TestCase): + """A standalone copy reads one file the manifest carries at two paths. + + The libretro PS2 cores put DuckStation's ps2_scph50006.bin under + pcsx2/bios/ beside the BIOS root copy, and the copy into DuckStation's + folder was dropped as ambiguous on Batocera, Recalbox and RetroBat. + """ + + DUCK = {"data_root": {"linux": {"path": "~/.local/share/duckstation", "source_ref": "x"}}, + "files": [{"name": "ps2_scph50006.bin", "standalone_path": "bios/ps2_scph50006.bin"}]} + + def _copies(self, entries): + from generate_pack import _profile_copies + + return _profile_copies({"duckstation": self.DUCK}, list(entries), [], entries) + + def test_same_bytes_take_the_copy_that_serves_the_emulator(self): + (copy,) = self._copies({ + "pcsx2/bios/ps2_scph50006.bin": {"sha1": "a", "cores": ["lrps2"]}, + "ps2_scph50006.bin": {"sha1": "a", "cores": ["duckstation", "pcsx2"]}, + }) + self.assertEqual(copy["file"], "ps2_scph50006.bin") + + def test_other_bytes_under_one_name_designate_nothing(self): + self.assertEqual(self._copies({ + "pcsx2/bios/ps2_scph50006.bin": {"sha1": "a", "cores": ["lrps2"]}, + "ps2_scph50006.bin": {"sha1": "b", "cores": ["duckstation"]}, + }), []) + + class BundledHashIsProof(unittest.TestCase): """A bundled file with a declared hash is served by those bytes or flagged. diff --git a/tests/test_batocera_cores.py b/tests/test_batocera_cores.py index eeeff5203..271bd276c 100644 --- a/tests/test_batocera_cores.py +++ b/tests/test_batocera_cores.py @@ -160,5 +160,56 @@ class APlatformsOwnBuildWinsItsName(unittest.TestCase): self.assertEqual(resolve_platform_cores(retropie, profiles), {"uae4all"}) +class APatchedLibretroBuildTakesItsNameOnly(unittest.TestCase): + """Batocera and Recalbox compile LRPS2 with a patch that reads the BIOS + from ps2/ instead of pcsx2/bios/. Their own build replaces the buildbot + LRPS2 under the name they ship, and Batocera's standalone PCSX2, under + the same name, stays.""" + + def test_own_libretro_build_keeps_the_standalone_of_a_dual_name(self): + from common import resolve_platform_cores + + profiles = { + "pcsx2": {"type": "standalone", "source": "s", "cores": ["pcsx2"]}, + "lrps2": {"type": "libretro", "source": "l", "cores": ["lrps2", "pcsx2"]}, + "lrps2-own": {"type": "libretro", "source": "l", "cores": ["pcsx2"], + "built_by": ["x"]}, + } + config = {"_key": "x", "cores": ["pcsx2"], "standalone_cores": ["pcsx2"], + "dual_mode_cores": ["pcsx2"]} + self.assertEqual(resolve_platform_cores(config, profiles), {"pcsx2", "lrps2-own"}) + del config["dual_mode_cores"] + self.assertEqual(resolve_platform_cores(config, profiles), {"lrps2-own"}) + + def test_the_registry_resolves_each_platform_build(self): + from common import load_emulator_profiles, load_platform_config, resolve_platform_cores + + profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) + platforms = str(REPO_ROOT / "platforms") + + def ps2_cores(platform: str) -> set[str]: + config = load_platform_config(platform, platforms) + return { + c for c in resolve_platform_cores(config, profiles) + if c.startswith(("lrps2", "pcsx2", "pcee2")) + } + + self.assertEqual(ps2_cores("batocera"), {"lrps2-416291a", "pcsx2", "pcee2"}) + # Recalbox ships two libretro PS2 builds on x86_64: LRPS2 as ps2 and + # the wxWidgets-era libretro PCSX2 as pcsx2, beside standalone PCSX2. + self.assertEqual( + ps2_cores("recalbox"), {"lrps2-9485a53", "pcsx2-libretro-408e3b2", "pcsx2"} + ) + own_builds = {"lrps2-416291a", "lrps2-9485a53", "pcsx2-libretro-408e3b2"} + for platform in ("retroarch", "retrobat", "emudeck", "retrodeck"): + with self.subTest(platform=platform): + cores = ps2_cores(platform) + self.assertIn("lrps2", cores) + self.assertFalse(own_builds & cores) + self.assertNotIn("pcsx2-libretro-408e3b2", ps2_cores("batocera")) + for name in sorted(own_builds): + self.assertEqual(profiles[name]["files"][0]["path"], "ps2/.bin") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_bios_companions.py b/tests/test_bios_companions.py index de9d9c45b..d0b8ba4c0 100644 --- a/tests/test_bios_companions.py +++ b/tests/test_bios_companions.py @@ -108,6 +108,56 @@ class Companions(unittest.TestCase): self.assertIn("SCPH-70004.rom1", names) self.assertFalse({"SCPH-70004.ROM1", "cheats_ws.zip", "GameIndex.yaml"} & names) + def test_a_shared_image_brings_each_cores_companions(self): + """Recalbox runs LRPS2 and the old libretro PCSX2 on one ps2/ folder. + LRPS2's scan placed SCPH-70004.BIN and its rom1, and the second core's + scan stopped at the taken image, so the .erom only it reads stayed out.""" + import os + import tempfile + + from packextras import _agnostic_scan_extras + + def profile(name: str, ext: str) -> dict: + return {"emulator": name, "bios_mode": "agnostic", "files": [ + {"name": "ps2-0230a-20080220.bin", "found_by": "content", + "path": "ps2/ps2-0230a-20080220.bin", "min_size": 8, "max_size": 16}, + {"name": f"ps2-0230a-20080220.{ext}", "path": f"ps2/ps2-0230a-20080220.{ext}"}, + ]} + + stored = { + "ps2-0230a-20080220.bin": b"I" * 8, + "ps2-0230a-20080220.rom1": b"R", + "ps2-0230a-20080220.erom": b"E", + } + with tempfile.TemporaryDirectory() as tmp: + cwd = os.getcwd() + os.chdir(tmp) + try: + files, by_name = {}, {} + for name, payload in stored.items(): + path = f"bios/PS2/{name}" + os.makedirs("bios/PS2", exist_ok=True) + Path(path).write_bytes(payload) + sha1 = f"{len(files):040d}" + files[sha1] = {"path": path, "name": name, "size": len(payload), + "sha1": sha1} + by_name[name] = [sha1] + db = {"files": files, "indexes": { + "by_name": by_name, "by_md5": {}, "by_crc32": {}, "by_path_suffix": {}, + }} + profiles = {"a": profile("A", "rom1"), "b": profile("B", "erom")} + extras = _agnostic_scan_extras( + profiles, {"a", "b"}, db, by_name, set(), "", taken={}, + ) + finally: + os.chdir(cwd) + placed = [(e["destination"], e["source_profile"]) for e in extras] + self.assertEqual(sorted(placed), [ + ("ps2/ps2-0230a-20080220.bin", "a"), + ("ps2/ps2-0230a-20080220.erom", "b"), + ("ps2/ps2-0230a-20080220.rom1", "a"), + ]) + def test_pcsx2_profiles_name_no_free_standing_companion(self): for name in ("pcsx2", "pcsx2-legacy"): profile = yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text()) diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index 1154990e3..446d020bf 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -195,7 +195,10 @@ resolved. Three strategies exist: - **Explicit list**: `cores: [beetle_psx, dolphin, ...]` - match by profile key name or by any name in a profile's `cores:`. When a profile's `built_by` names this - platform, that profile answers for the name (ROCKNIX's `yabadabadoo`). Used by + platform, that profile answers for the name (ROCKNIX's `yabadabadoo`). On a + `dual_mode_cores` name it answers for its own build mode only: Batocera's + patched LRPS2 (`lrps2-416291a`) takes the libretro `pcsx2` and the standalone + PCSX2 stays. Used by Batocera, Recalbox, RetroBat, RomM, KNULLI, AmberELEC, EmuELEC, MinUI, NextUI, MyMinUI, ROCKNIX, RetroPie, EmuDeck, RetroDECK. A list that names `retroarch` (the last three) also takes every libretro profile, as `all_libretro` does,