feat: install for the games on the card

--for-games reads the card's rom folders through install/games/ and keeps
what the games need: per system what its default core reads, one region
where the content proves it (crc32 and size, a disc serial, a header the
core reads), an arcade set's BIOS parents. A game not recognised gets
every BIOS of its system and is named. --required-only keeps only what
starts. The plan and the line menu open on it when the card holds games,
everything one press away. A cache keeps what reading a card found.
This commit is contained in:
Abdessamad Derraz committed 2026-11-03 18:51:21 +01:00
1 parent b69b33178b
commit 2e9175a8e9
28 files changed
+4030 -36

No files matched your search

+1 -1
View File
@@ -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 = "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15"
$defaultInstallSha256 = "8cf9ac83b620610cb469e65158e9e7f4efd0509242db86820403457c69ab3628"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+1803 -23
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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="5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15"
DEFAULT_INSTALL_SHA256="8cf9ac83b620610cb469e65158e9e7f4efd0509242db86820403457c69ab3628"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 996397,
"sha256": "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15",
"size": 1077524,
"sha256": "8cf9ac83b620610cb469e65158e9e7f4efd0509242db86820403457c69ab3628",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1049574,
"sha256": "30bbe8a0f78cc7b6bb37b9290c69e2244e0b9217339d9ea695f45c0d7815a263"
"size": 1130701,
"sha256": "4381cfaa7fe2363671f334f1567e31e763df78d11fbadab3de135d7937878715"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 996397,
"sha256": "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15",
"size": 1077524,
"sha256": "8cf9ac83b620610cb469e65158e9e7f4efd0509242db86820403457c69ab3628",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 996397,
"sha256": "5b8bd7b486d236ee1d00439b2bdd4f6fefcf20112abe3b0f1037e38426d80c15",
"size": 1077524,
"sha256": "8cf9ac83b620610cb469e65158e9e7f4efd0509242db86820403457c69ab3628",
"executable": false
},
{
+111
View File
@@ -21,6 +21,9 @@
"file",
"system",
"checking",
"games_reading",
"games",
"games_unavailable",
"check",
"declared",
"data",
@@ -71,6 +74,15 @@
{
"$ref": "#/$defs/checking"
},
{
"$ref": "#/$defs/games_reading"
},
{
"$ref": "#/$defs/games"
},
{
"$ref": "#/$defs/games_unavailable"
},
{
"$ref": "#/$defs/check"
},
@@ -437,6 +449,105 @@
}
}
},
"games_reading": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"files_done",
"files_total"
],
"properties": {
"event": {
"const": "games_reading"
},
"files_done": {
"type": "integer",
"minimum": 0
},
"files_total": {
"type": "integer",
"minimum": 0
}
}
},
"games": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"folder",
"games",
"recognised",
"found",
"fallback",
"not_games",
"required_only"
],
"properties": {
"event": {
"const": "games"
},
"platform": {
"type": "string"
},
"folder": {
"type": "string"
},
"games": {
"type": "integer",
"minimum": 0
},
"recognised": {
"type": "integer",
"minimum": 0
},
"found": {
"type": "array",
"items": {
"type": "object"
}
},
"fallback": {
"type": "array",
"items": {
"type": "object"
}
},
"not_games": {
"type": "integer",
"minimum": 0
},
"required_only": {
"type": "boolean"
}
}
},
"games_unavailable": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"reason",
"message"
],
"properties": {
"event": {
"const": "games_unavailable"
},
"platform": {
"type": "string"
},
"reason": {
"type": "string"
},
"message": {
"type": "string"
}
}
},
"check": {
"type": "object",
"additionalProperties": false,
+4 -3
View File
@@ -43,11 +43,12 @@ KEY_TYPES: dict[str, dict] = {
"files", "files_done", "files_total", "bytes", "bytes_done", "bytes_total",
"bytes_local", "bytes_network", "size", "present", "missing", "wrong_hash",
"bytes_needed", "data_missing", "not_installable", "installed", "up_to_date",
"pending", "code",
"pending", "code", "games", "recognised", "not_games",
), _COUNT),
**dict.fromkeys(("truncated", "ok", "interrupted"), _FLAG),
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only"), _FLAG),
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept"), _RECORDS),
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found",
"fallback"), _RECORDS),
}
+6
View File
@@ -25,3 +25,9 @@ os.environ.setdefault(
(Path(__file__).resolve().parent.parent / "tmp" / "tests").mkdir(
parents=True, exist_ok=True
)
# The installer's cache (the games index, what reading a card found) is the
# suite's own: a run never reads nor fills the one of the person running it.
os.environ.setdefault(
"RETROBIOS_CACHE",
str(Path(__file__).resolve().parent.parent / "tmp" / "tests" / "cache"),
)
+82
View File
@@ -900,3 +900,85 @@
Any key closes this help
=== plan-games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m
The ^[[0;33m1 wrong copy^[[0m is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
^[[0;7;35m > Install^[[0m
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
=== games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Games on this card^[[0m
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
^[[0;1;35mA/Enter^[[0m Back
+26
View File
@@ -284,3 +284,29 @@
BIOS folder:
^[[0;1m/home/user/.config/retroarch/system^[[0m
Any key closes this help
=== plan-games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
BIOS folder:
^[[0;1m~/.config/retroarch/system^[[0m
^[[0;7;35m > Install^[[0m
For the games on this card: yes 412
Only what starts: no
Show the games
^[[0;2m 1-4 of 8^[[0m
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
=== games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Games on this card^[[0m
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
^[[0;2m 1-5 of 9^[[0m
^[[0;1;35mA^[[0m Back ^[[0;2m^v^[[0m Scroll
+32
View File
@@ -350,3 +350,35 @@
Any key closes this help
=== plan-games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
BIOS folder:
^[[0;1m~/.config/retroarch/system^[[0m
For the 412 games on this card.
^[[0;7;35m > Install^[[0m
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
=== games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Games on this card^[[0m
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
Every BIOS of their system kept, so
that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
^[[0;1;35mA^[[0m Back
+50
View File
@@ -548,3 +548,53 @@
Any key closes this help
=== plan-games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m
The ^[[0;33m1 wrong copy^[[0m is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
^[[0;7;35m > Install^[[0m
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
=== games
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Games on this card^[[0m
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
^[[0;1;35mA/Enter^[[0m Back
+40
View File
@@ -0,0 +1,40 @@
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A/Enter Back
+12
View File
@@ -0,0 +1,12 @@
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
1-5 of 9
A Back ^v Scroll
+15
View File
@@ -0,0 +1,15 @@
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
Every BIOS of their system kept, so
that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A Back
+24
View File
@@ -0,0 +1,24 @@
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A/Enter Back
+40
View File
@@ -0,0 +1,40 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
A/Enter Install B/Esc Back Y/o Options ? Help
+12
View File
@@ -0,0 +1,12 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
A Install B Back Y Options ? Help
+15
View File
@@ -0,0 +1,15 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
A Install B Back Y Options ? Help
+24
View File
@@ -0,0 +1,24 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
A/Enter Install B/Esc Back Y/o Options ? Help
+79
View File
@@ -1054,3 +1054,82 @@
Any key closes this help
=== plan-games 40x12
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
A Install B Back Y Options
=== plan-games 40x15
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
A Install B Back Y Options
=== plan-games 80x24
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
A Install B Back Y Options
=== games 80x24
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A Back
+79
View File
@@ -1054,3 +1054,82 @@
Any key closes this help
=== plan-games 40x12
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
A Install B Back Y Options
=== plan-games 40x15
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
A Install B Back Y Options
=== plan-games 80x24
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
A Install B Back Y Options
=== games 80x24
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A Back
+79
View File
@@ -1054,3 +1054,82 @@
Any key closes this help
=== plan-games 40x12
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
A Install B Back Y Options
=== plan-games 40x15
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
A Install B Back Y Options
=== plan-games 80x24
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
A Install B Back Y Options
=== games 80x24
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A Back
+108
View File
@@ -1132,3 +1132,111 @@
Any key closes this help
=== plan-games 40x12
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
Enter Install Esc Back o Options
=== plan-games 40x15
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
Enter Install Esc Back o Options
=== plan-games 80x24
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
Enter Install Esc Back o Options ? Help
=== games 40x12
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
1-5 of 9
Enter Back ^v Scroll
=== games 40x15
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
Every BIOS of their system kept, so
that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
Enter Back
=== games 80x24
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
Enter Back
+79
View File
@@ -1128,3 +1128,82 @@
Any key closes this help
=== plan-games 40x12
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
A Install R2 Back L2 Options
=== plan-games 40x15
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
A Install R2 Back L2 Options
=== plan-games 80x24
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
A Install R2 Back L2 Options
=== games 80x24
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
A Back
+108
View File
@@ -1186,3 +1186,111 @@
Any key closes this help
=== plan-games 40x12
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
1-4 of 8
Start Install B Back
=== plan-games 40x15
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder:
~/.config/retroarch/system
For the 412 games on this card.
> Install
For the games on this card: yes 412
Only what starts: no
Show the games
Choose systems
Copy from your old card or a folder
Options
Quit
Start Install B Back
=== plan-games 80x24
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.config/retroarch/system
For the 412 games on this card.
One region where the games prove it: nintendo-gba Europe.
2 games get every BIOS of their system: Show the games.
1 in place, 3 missing, 1 wrong copy
The 1 wrong copy is replaced, the old one kept in a backup folder.
Free space: 50.0 GB
> Install
For the games on this card: yes 412 games
Only what starts: no
Show the games
Choose systems
Copy the BIOS files from your old card or a folder
Options
Quit
Start Install B Back
=== games 40x12
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
1-5 of 9
Start Back ^v Scroll
=== games 40x15
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms,
398 recognised by their content.
sony-playstation (PS): 400 games, 390
recognised, every region
nintendo-gba (GBA): 12 games, 8
recognised, Europe only
Every BIOS of their system kept, so
that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
Start Back
=== games 80x24
RetroBIOS: Games on this card
412 games in /media/user/SDCARD/Roms, 398 recognised by their content.
sony-playstation (PS): 400 games, 390 recognised, every region
nintendo-gba (GBA): 12 games, 8 recognised, Europe only
Every BIOS of their system kept, so that they start:
GBA/demo.gba: not recognised
GBA/hack.gba: not recognised
Start Back
+947
View File
@@ -0,0 +1,947 @@
"""install.py --for-games: what the games on a card need.
The games index (install/games/) is a fixture written per test, read from
the folder beside --manifest as a run offline reads it, so a test pins the
rules and not the state of the data. Two tests read the repository's own
index, published by scripts/generate_games_index.py.
"""
from __future__ import annotations
import contextlib
import functools
import gzip
import hashlib
import http.server
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
import zipfile
import zlib
from pathlib import Path
from unittest import mock
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
import install # noqa: E402
TMP_ROOT = REPO_ROOT / "tmp" / "tests"
TMP_ROOT.mkdir(parents=True, exist_ok=True)
def _crc(data: bytes) -> str:
return f"{zlib.crc32(data) & 0xFFFFFFFF:08x}"
MD_EU = b"md europe game " * 64
MD_JP = b"md japan game " * 64
MD_ZIPPED = b"md zipped usa game " * 64
GC_HEADER = 0x458
# Every file the fixture list carries: dest -> (size, regions, cores, systems).
FILES = {
"scph5500.bin": (512, ["japan"], None, ["sony-playstation"]),
"scph5501.bin": (512, ["north-america"], None, ["sony-playstation"]),
"scph5502.bin": (512, ["europe"], None, ["sony-playstation"]),
"bios_CD_E.bin": (128, ["europe"], None, ["sega-megacd"]),
"bios_CD_U.bin": (128, ["north-america"], None, ["sega-megacd"]),
"bios_CD_J.bin": (128, ["japan"], None, ["sega-megacd"]),
"cd_extra.bin": (16, None, ["picodrive"], None),
"md_eu.bin": (64, ["europe"], None, ["sega-megadrive"]),
"md_us.bin": (64, ["north-america"], None, ["sega-megadrive"]),
"md_jp.bin": (64, ["japan"], None, ["sega-megadrive"]),
"md_tmss.bin": (32, None, None, ["sega-megadrive"]),
"dolphin/GC/USA/IPL.bin": (256, ["north-america"], ["dolphin"], None),
"dolphin/GC/EUR/IPL.bin": (256, ["europe"], ["dolphin"], None),
"dolphin/GC/JAP/IPL.bin": (256, ["japan"], ["dolphin"], None),
"neogeo.zip": (300, None, None, ["snk-neogeo"]),
"fbneo/neogeo.zip": (300, None, ["fbneo"], None),
"fbneo/pgm.zip": (200, None, ["fbneo"], None),
"fbneo/samples/big.zip": (9000, None, ["fbneo"], None),
"gba_bios.bin": (100, None, None, ["nintendo-gba"]),
}
# The fallback sets: key -> (files, optional).
SETS = {
"sony-playstation|beetle_psx": (["scph5500.bin", "scph5501.bin", "scph5502.bin"], []),
"sega-megacd|picodrive": (["bios_CD_E.bin", "bios_CD_U.bin", "bios_CD_J.bin",
"cd_extra.bin"], ["cd_extra.bin"]),
"sega-megadrive|fixcore": (["md_eu.bin", "md_us.bin", "md_jp.bin", "md_tmss.bin"],
["md_tmss.bin"]),
"nintendo-gamecube|dolphin": (["dolphin/GC/USA/IPL.bin", "dolphin/GC/EUR/IPL.bin",
"dolphin/GC/JAP/IPL.bin"], []),
"arcade|fbneo": (["fbneo/samples/big.zip"], ["fbneo/samples/big.zip"]),
"nintendo-gba|mgba": (["gba_bios.bin"], ["gba_bios.bin"]),
"nintendo-gba|gpsp": (["gba_bios.bin"], []),
}
REGION_READER_MCD = {
"applies_to": {"extensions": [".cue", ".bin", ".iso"], "media": "cd"},
"decode": {"kind": "integer", "otherwise": ["north-america"],
"values": [{"regions": ["europe"], "value": 100},
{"regions": ["japan"], "value": 161}]},
"location": {"encoding": "u8", "length": 1, "offset": 523, "sector": 0},
"magic": {"bytes": "SEGADISCSYSTEM", "offset": 0, "sector": 0},
}
SYSTEMS = {
"sony-playstation": {
"identify": {"sources": [{"source": "redump", "dat": "PS"}], "regions": [["europe"]],
"games": [], "serials": {"SLES-00001": [0]}},
"headers": {"readers": {"psx-serial-prefix": {
"location": {"iso_file": "SYSTEM.CNF", "key": "BOOT", "take": "basename"},
"decode": {"kind": "prefix-nocase", "values": [
{"value": "sles", "regions": ["europe"]}], "otherwise": "unknown"}}},
"cores": {"beetle_psx": {"order": ["psx-serial-prefix"],
"otherwise": ["north-america"]}}},
"region_rules": {"beetle_psx": {"keep_all_regions": [
{"case": "psx-override-bios", "condition": "option", "keeps": "region-free-files"}]}},
},
"sega-megacd": {
"identify": {"sources": [{"source": "redump", "dat": "MCD"}], "regions": [],
"games": [], "serials": {}},
"headers": {"readers": {"mcd-security-code": REGION_READER_MCD},
"cores": {"picodrive": {"order": ["mcd-security-code"],
"otherwise": ["north-america"]}}},
"region_rules": {"picodrive": {"default_region": "north-america"}},
"extras": [{"system": "sega-megacd", "profile": "picodrive",
"trigger": {"every_game": True}, "files": ["cd_extra.bin"],
"required": True}],
},
"sega-megadrive": {
"identify": {"sources": [{"source": "no-intro", "dat": "MD"}],
"regions": [["europe"], ["japan"], ["north-america"]],
"games": sorted([[_crc(MD_EU), len(MD_EU), 0, "Game (Europe)"],
[_crc(MD_JP), len(MD_JP), 1, "Game (Japan)"],
[_crc(MD_ZIPPED), len(MD_ZIPPED), 2, "Zipped (USA)"]]),
"serials": {}},
"headers": {"readers": {"md-country": {
"location": {"offset": 0x1F0, "length": 3, "encoding": "ascii"},
"decode": {"kind": "letters", "otherwise": "unknown", "values": [
{"value": "J", "regions": ["japan"]}, {"value": "E", "regions": ["europe"]},
{"value": "U", "regions": ["north-america"]}]}}},
"cores": {"fixcore": {"order": ["md-country"], "otherwise": "unknown"}}},
"region_rules": {"fixcore": {}},
},
"nintendo-gamecube": {
"identify": {"sources": [{"source": "redump", "dat": "GC"}],
"regions": [["europe", "north-america"], ["japan"]],
"games": [], "serials": {"GALE01": [0], "GJPN01": [1]}},
"headers": {"readers": {
"gc-game-id": {"location": {"container": "gamecube-disc", "disc_offset": 0,
"encoding": "ascii", "length": 6},
"decode": {"kind": "serial"}},
"gc-region": {"location": {"container": "gamecube-disc", "disc_offset": GC_HEADER,
"encoding": "u32be", "length": 4},
"decode": {"kind": "integer", "otherwise": "unknown", "values": [
{"value": 0, "regions": ["japan"]},
{"value": 1, "regions": ["north-america"]},
{"value": 2, "regions": ["europe"]}]}}},
"cores": {"dolphin": {"order": ["gc-region"], "otherwise": "unknown"}}},
"region_rules": {"dolphin": {"keep_all_regions": [
{"case": "gamecube-unknown-region", "condition": "disc"}]}},
},
"nintendo-gba": {},
}
ARCADE = {
"bios": {"neogeo": ["mslug", "mslug2"], "pgm": ["orlegend"], "nobios": ["lost"]},
"devices": {}, "chd": [], "missing_parents": {},
"no_bios": ["pacman"], "bios_sets": ["neogeo", "pgm", "nobios"],
}
SETUP = {
"format_version": 1, "setup": "retroarch", "name": "Fixture", "platform": "retroarch",
"sources": {}, "roms_root": {"path": "/mnt/SDCARD/Roms", "from_bios": "../Roms"},
"case_sensitive": False,
"media": [{"name": "Imgs"}],
"folders": [
{"folder": "PS", "systems": ["sony-playstation"], "basis": "id",
"profile": "beetle_psx", "extensions": [".cue", ".bin", ".chd", ".m3u", ".iso"],
"fallback": ["sony-playstation|beetle_psx"], "source_ref": "x"},
{"folder": "SEGACD", "systems": ["sega-megacd"], "basis": "id",
"profile": "picodrive", "extensions": [".cue", ".iso", ".bin", ".chd", ".m3u"],
"fallback": ["sega-megacd|picodrive"], "source_ref": "x"},
{"folder": "MD", "systems": ["sega-megadrive"], "basis": "id", "profile": "fixcore",
"extensions": [".md", ".bin", ".zip", ".7z"],
"fallback": ["sega-megadrive|fixcore"], "source_ref": "x"},
{"folder": "GC", "systems": ["nintendo-gamecube"], "basis": "id",
"profile": "dolphin", "extensions": [".iso", ".rvz"],
"fallback": ["nintendo-gamecube|dolphin"], "source_ref": "x"},
{"folder": "FBNEO", "systems": ["arcade"], "basis": "id", "profile": "fbneo",
"arcade": "fbneo", "extensions": [".zip", ".7z"], "fallback": ["arcade|fbneo"],
"source_ref": "x"},
{"folder": "GBA", "systems": ["nintendo-gba"], "basis": "id", "profile": "mgba",
"extensions": [".gba"], "fallback": ["nintendo-gba|mgba"],
"board_defaults": {"rpi": {"core": "gpsp", "profile": "gpsp",
"fallback": ["nintendo-gba|gpsp"]}},
"source_ref": "x"},
{"folder": "PORTS", "systems": [], "basis": "unresolved", "source_ref": "x"},
],
}
def _dumps(doc: object) -> bytes:
return (json.dumps(doc, sort_keys=True, separators=(",", ":")) + "\n").encode()
def write_index(folder: Path, systems: dict | None = None, arcade: dict | None = None,
setup: dict | None = None, extra_sets: dict | None = None) -> None:
"""install/games/ as the generator lays it out, every digest right."""
rows = sorted({(dest, FILES[dest][0], "bios") for files, _o in SETS.values()
for dest in files})
position = {row[0]: i for i, row in enumerate(rows)}
sets = {**SETS, **(extra_sets or {})}
fallback = {"format_version": 1, "platform": "retroarch", "files": [list(r) for r in rows],
"sets": {key: {"files": [position[d] for d in files],
"optional": [position[d] for d in optional],
"bytes": 0, "required_bytes": 0}
for key, (files, optional) in sets.items()}}
docs = {"setups": {"retroarch": setup or SETUP}, "fallback": {"retroarch": fallback},
"systems": {key: {"format_version": 1, "system": key, **value}
for key, value in (systems or SYSTEMS).items()},
"arcade": {"fbneo": {"profile": "fbneo", **(arcade or ARCADE)}}}
index: dict = {"format_version": 1, "data_version": "0" * 16, "licences": [],
"folders": {"ps": ["sony-playstation"], "psx": ["sony-playstation"],
"md": ["sega-megadrive"], "megadrive": ["sega-megadrive"]}}
for kind, items in docs.items():
index[kind] = {}
for key, doc in items.items():
data = _dumps(doc)
rel = f"{kind}/{key}.json"
(folder / kind).mkdir(parents=True, exist_ok=True)
(folder / rel).write_bytes(data)
index[kind][key] = {"file": rel, "size": len(data),
"gzip_size": len(gzip.compress(data)),
"sha256": hashlib.sha256(data).hexdigest()}
(folder / "index.json").write_bytes(_dumps(index))
def manifest() -> dict:
files = []
for dest, (size, regions, cores, systems) in FILES.items():
payload = dest.encode().ljust(size, b".")[:size]
entry = {"dest": dest, "size": size, "sha1": hashlib.sha1(payload).hexdigest(),
"sha256": hashlib.sha256(payload).hexdigest(),
"repo_path": f"bios/{dest.replace('/', '_')}", "cores": cores}
if systems:
entry["systems"] = systems
if regions:
entry.update(regions=regions)
files.append(entry)
return {"manifest_version": 2, "platform": "retroarch", "files": files,
"omitted_files": []}
def mcd_iso(code: int, magic: bool = True) -> bytes:
data = bytearray(2048 * 18)
if magic:
data[0:14] = b"SEGADISCSYSTEM"
data[523] = code
return bytes(data)
def gc_iso(game_id: bytes, region: int) -> bytes:
data = bytearray(0x500)
data[0:6] = game_id
data[GC_HEADER:GC_HEADER + 4] = region.to_bytes(4, "big")
return bytes(data)
def md_rom(content: bytes, country: bytes) -> bytes:
data = bytearray(content.ljust(0x200, b"\0"))
data[0x1F0:0x1F3] = country
return bytes(data)
def ps_iso(boot: str) -> bytes:
"""A 2048-byte ISO 9660 image whose root holds SYSTEM.CNF."""
sectors = [bytearray(2048) for _ in range(20)]
volume = sectors[16]
volume[0] = 1
volume[1:6] = b"CD001"
root = bytearray(34)
root[0] = 34
root[2:6] = (17).to_bytes(4, "little")
root[10:14] = (2048).to_bytes(4, "little")
volume[156:190] = root
name = b"SYSTEM.CNF;1"
record = bytearray(33 + len(name))
record[0] = len(record)
record[2:6] = (18).to_bytes(4, "little")
text = f"BOOT = cdrom:\\{boot};1\r\n".encode()
record[10:14] = len(text).to_bytes(4, "little")
record[32] = len(name)
record[33:] = name
sectors[17][0:len(record)] = record
sectors[18][0:len(text)] = text
return b"".join(bytes(s) for s in sectors)
class Card:
"""A card in tmp/: BIOS/ and Roms/ at its root, the games index beside
a saved file list."""
def __init__(self) -> None:
self.root = Path(tempfile.mkdtemp(dir=TMP_ROOT))
self.bios = self.root / "card" / "BIOS"
self.roms = self.root / "card" / "Roms"
self.bios.mkdir(parents=True)
self.roms.mkdir()
self.lists = self.root / "lists"
(self.lists / "games").mkdir(parents=True)
self.manifest_path = self.lists / "retroarch.json"
self.manifest_path.write_text(json.dumps(manifest()))
write_index(self.lists / "games")
self.cache = self.root / "cache"
def game(self, folder: str, name: str, data: bytes = b"game") -> Path:
path = self.roms / folder / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def zipped(self, folder: str, name: str, member: str, data: bytes) -> Path:
path = self.roms / folder / name
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(member, data)
return path
def close(self) -> None:
shutil.rmtree(self.root, ignore_errors=True)
class GamesTestCase(unittest.TestCase):
def setUp(self) -> None:
self.card = Card()
self.addCleanup(self.card.close)
patches = [mock.patch.dict(os.environ, {"RETROBIOS_CACHE": str(self.card.cache),
install.OFFLINE_VARIABLE: "1"}),
mock.patch.object(install.LOCAL, "manifest", self.card.manifest_path),
mock.patch.object(install, "GAMES", install.GamesData())]
for patcher in patches:
patcher.start()
self.addCleanup(patcher.stop)
def plan(self, folder: Path | None = None) -> install.GamesPlan:
plan = install.plan_games("retroarch", self.card.bios, manifest(), folder,
os_type="linux")
self.assertIsNotNone(plan)
return plan
def kept(self, plan: install.GamesPlan, prefix: str = "", required: bool = False) -> list:
return sorted(dest for dest in plan.wanted(required) if dest.startswith(prefix))
class Identification(GamesTestCase):
def test_a_cartridge_is_known_by_crc32_and_size(self):
self.card.game("MD", "anything.md", MD_EU)
plan = self.plan()
game = plan.games[0]
self.assertTrue(game.recognised)
self.assertEqual(game.title, "Game (Europe)")
self.assertEqual(game.dat_regions, frozenset({"europe"}))
def test_a_zip_member_is_known_from_the_central_directory(self):
self.card.zipped("MD", "z.zip", "rom.md", MD_ZIPPED)
with mock.patch.object(install, "_crc32_file") as hashed:
plan = self.plan()
hashed.assert_not_called()
self.assertTrue(plan.games[0].recognised)
self.assertEqual(plan.games[0].dat_regions, frozenset({"north-america"}))
def test_a_disc_serial_from_system_cnf(self):
self.card.game("PS", "game.iso", ps_iso("SLES_000.01"))
game = self.plan().games[0]
self.assertTrue(game.recognised)
self.assertEqual(game.title, "SLES_000.01")
def test_a_chd_is_not_read(self):
self.card.game("PS", "game.chd", b"MComprHD")
game = self.plan().games[0]
self.assertFalse(game.read)
self.assertFalse(game.recognised)
def test_files_a_cue_names_are_part_of_its_game(self):
self.card.game("SEGACD", "g.cue", b'FILE "g.bin" BINARY\n TRACK 01 MODE1/2048\n')
self.card.game("SEGACD", "g.bin", mcd_iso(100))
plan = self.plan()
self.assertEqual([game.shown for game in plan.games], ["SEGACD/g.cue"])
self.assertEqual(install.core_regions(plan.games[0], "sega-megacd",
install.SystemData(SYSTEMS["sega-megacd"]),
"picodrive"), frozenset({"europe"}))
def test_hidden_media_and_other_files_are_not_games(self):
self.card.game("MD", ".hidden.md", MD_EU)
self.card.game("MD", "Imgs/cover.md", MD_EU)
self.card.game("MD", "notes.txt", b"x")
self.card.game("MD", "game.md", MD_EU)
plan = self.plan()
self.assertEqual([game.shown for game in plan.games], ["MD/game.md"])
self.assertEqual(plan.not_games, 1)
def test_a_folder_the_setup_does_not_list_holds_no_game(self):
self.card.game("UNKNOWN", "game.md", MD_EU)
self.card.game("PORTS", "game.sh", b"x")
self.assertEqual(self.plan().games, [])
def test_a_raw_cd_image_with_its_mode(self):
user = mcd_iso(161)
raw = bytearray()
for index in range(len(user) // 2048):
sector = bytearray(2352)
sector[0:12] = b"\x00" + b"\xff" * 10 + b"\x00"
sector[15] = 1
sector[16:16 + 2048] = user[index * 2048:(index + 1) * 2048]
raw += sector
self.card.game("SEGACD", "g.bin", bytes(raw))
plan = self.plan()
self.assertEqual(self.kept(plan, "bios_CD"), ["bios_CD_J.bin"])
class RegionMatrix(GamesTestCase):
"""What each kind of evidence lets the run remove (research section 2)."""
def test_a_header_read_keeps_the_region_it_proves(self):
self.card.game("SEGACD", "sonic.iso", mcd_iso(100))
plan = self.plan()
self.assertEqual(self.kept(plan, "bios_CD"), ["bios_CD_E.bin"])
self.assertEqual(plan.systems["sega-megacd"]["regions"], ["europe"])
self.assertEqual(plan.fallback, [])
def test_a_value_the_code_reads_as_its_default_is_a_proof(self):
self.card.game("SEGACD", "us.iso", mcd_iso(7))
self.assertEqual(self.kept(self.plan(), "bios_CD"), ["bios_CD_U.bin"])
def test_two_games_keep_both_regions(self):
self.card.game("SEGACD", "a.iso", mcd_iso(100))
self.card.game("SEGACD", "b.iso", mcd_iso(161))
self.assertEqual(self.kept(self.plan(), "bios_CD"), ["bios_CD_E.bin", "bios_CD_J.bin"])
def test_a_hash_match_keeps_the_catalog_region(self):
self.card.game("MD", "eu.md", MD_EU)
self.assertEqual(self.kept(self.plan(), "md_"), ["md_eu.bin", "md_tmss.bin"])
def test_a_filename_tag_alone_keeps_every_region(self):
self.card.game("SEGACD", "Sonic CD (Europe).iso", b"no header here" * 200)
plan = self.plan()
self.assertEqual(self.kept(plan, "bios_CD"),
["bios_CD_E.bin", "bios_CD_J.bin", "bios_CD_U.bin"])
self.assertEqual([(g.shown, g.reason) for g in plan.fallback],
[("SEGACD/Sonic CD (Europe).iso", "not_recognised")])
def test_folder_only_keeps_every_region_and_names_the_game(self):
self.card.game("MD", "unknown.md", b"not in any catalog")
plan = self.plan()
self.assertEqual(self.kept(plan, "md_"),
["md_eu.bin", "md_jp.bin", "md_tmss.bin", "md_us.bin"])
self.assertEqual(plan.fallback[0].reason, "not_recognised")
lines = install.games_lines(plan, None)
self.assertIn("1 game gets every BIOS of its system: MD/unknown.md (not recognised)",
"\n".join(lines))
def test_one_unproven_game_keeps_every_region_for_all(self):
self.card.game("SEGACD", "a.iso", mcd_iso(100))
self.card.game("SEGACD", "b.chd", b"MComprHD")
plan = self.plan()
self.assertEqual(len(self.kept(plan, "bios_CD")), 3)
self.assertEqual([(g.shown, g.reason) for g in plan.fallback],
[("SEGACD/b.chd", "not_read")])
self.assertIsNone(plan.systems["sega-megacd"]["regions"])
def test_a_catalog_and_a_header_that_disagree_keep_every_region(self):
self.card.game("MD", "jp.md", md_rom(MD_JP, b"E "))
# The catalog matches the whole file: write the header into it first.
path = self.card.roms / "MD" / "jp.md"
data = path.read_bytes()
rows = [[_crc(data), len(data), 1, "Game (Japan)"]]
systems = json.loads(json.dumps(SYSTEMS))
systems["sega-megadrive"]["identify"]["games"] = rows
write_index(self.card.lists / "games", systems=systems)
plan = self.plan()
self.assertTrue(plan.games[0].recognised)
self.assertEqual(plan.games[0].readings["md-country"], frozenset({"europe"}))
self.assertEqual(len(self.kept(plan, "md_")), 4)
self.assertEqual(plan.fallback[0].reason, "region_unknown")
def test_a_multi_region_serial_keeps_its_regions(self):
self.card.game("GC", "game.iso", gc_iso(b"GALE01", 1))
plan = self.plan()
self.assertEqual(self.kept(plan, "dolphin"),
["dolphin/GC/EUR/IPL.bin", "dolphin/GC/USA/IPL.bin"])
def test_an_unknown_gamecube_region_keeps_every_ipl(self):
self.card.game("GC", "odd.iso", gc_iso(b"XXXX01", 9))
self.assertEqual(len(self.kept(self.plan(), "dolphin")), 3)
def test_a_compressed_gamecube_disc_is_not_read(self):
self.card.game("GC", "game.rvz", b"RVZ\x01")
plan = self.plan()
self.assertEqual(len(self.kept(plan, "dolphin")), 3)
self.assertEqual(plan.fallback[0].reason, "not_read")
def test_a_core_with_a_keep_all_case_never_narrows(self):
self.card.game("PS", "game.iso", ps_iso("SLES_000.01"))
plan = self.plan()
self.assertTrue(plan.games[0].recognised)
self.assertEqual(self.kept(plan, "scph"),
["scph5500.bin", "scph5501.bin", "scph5502.bin"])
# Nothing the game could have proven changes that: it is not named.
self.assertEqual(plan.fallback, [])
def test_a_game_region_no_file_matches_keeps_the_whole_set(self):
dests = ["a.bin", "b.bin"]
entries = {"a.bin": {"regions": ["japan"]}, "b.bin": {"regions": ["north-america"]}}
self.assertEqual(install.keep_regions(dests, entries, frozenset({"europe"}), set()),
dests)
def test_a_country_matches_its_region_and_region_free_files_stay(self):
dests = ["eu.bin", "us.bin", "free.bin", "world.bin"]
entries = {"eu.bin": {"regions": ["europe"]}, "us.bin": {"regions": ["north-america"]},
"free.bin": {"regions": ["japan"]}, "world.bin": {"regions": ["world"]}}
self.assertEqual(install.keep_regions(dests, entries, frozenset({"germany"}),
{"free.bin"}),
["eu.bin", "free.bin", "world.bin"])
def test_routes_send_a_region_to_another_regions_file(self):
rule = {"routes": {"south-korea": "japan"}}
self.assertEqual(install.needed_regions(rule, [frozenset({"south-korea"})]),
frozenset({"japan"}))
class KeepEveryRegionCases(unittest.TestCase):
"""Every case of games/regions.yml keeps every region where it applies."""
@classmethod
def setUpClass(cls) -> None:
with open(REPO_ROOT / "games" / "regions.yml", encoding="utf-8") as handle:
cls.rules = yaml.safe_load(handle)
def facts(self, name: str) -> install.GameFacts:
folder = install.GameFolder(Path("x"), {})
return install.GameFacts(Path(name), name, folder)
def test_there_are_fourteen_cases_and_each_is_decided(self):
cases = self.rules["cases"]
self.assertEqual(len(cases), 14)
disc_cases = {"first-disc-region", "multi-area-disc", "gamecube-unknown-region",
"saturn-kr-ipl"}
game = [self.facts("game.iso")]
for name in cases:
with self.subTest(case=name):
conditions = {case.get("condition")
for system in self.rules["systems"].values()
for rule in system.values()
for case in rule.get("keep_all_regions") or []
if case.get("case") == name}
self.assertTrue(conditions, f"{name} is named by no rule")
for condition in conditions:
rule = {"keep_all_regions": [{"case": name, "condition": condition}]}
keeps = install.region_rule_keeps_all(rule, game)
if condition in ("always", "option", "user-state"):
self.assertTrue(keeps, (name, condition))
else:
self.assertIn(name, disc_cases)
def test_first_disc_region_keeps_every_region_for_a_playlist(self):
rule = {"keep_all_regions": [{"case": "first-disc-region", "condition": "disc"}]}
self.assertTrue(install.region_rule_keeps_all(rule, [self.facts("g.m3u")]))
self.assertFalse(install.region_rule_keeps_all(rule, [self.facts("g.cue")]))
def test_an_asia_ntsc_or_korean_saturn_disc_keeps_every_region(self):
rule = {"keep_all_regions": [{"case": "saturn-kr-ipl", "condition": "disc"}]}
self.assertIsNone(install.needed_regions(rule, [frozenset({"asia-ntsc"})]))
self.assertIsNone(install.needed_regions(rule, [frozenset({"south-korea"})]))
self.assertEqual(install.needed_regions(rule, [frozenset({"japan"})]),
frozenset({"japan"}))
def test_a_multi_area_disc_keeps_every_area_it_lists(self):
rule = {"keep_all_regions": [{"case": "multi-area-disc", "condition": "disc"}]}
self.assertFalse(install.region_rule_keeps_all(rule, [self.facts("g.iso")]))
self.assertEqual(install.needed_regions(rule, [frozenset({"japan", "europe"})]),
frozenset({"japan", "europe"}))
def test_an_unknown_gamecube_region_is_no_proof(self):
system = install.SystemData({"headers": {"cores": {"dolphin": {
"order": ["gc-region"], "otherwise": "unknown"}}}})
facts = self.facts("g.iso")
facts.system = "nintendo-gamecube"
facts.readings["gc-region"] = "unknown"
self.assertIsNone(install.core_regions(facts, "nintendo-gamecube", system, "dolphin"))
def test_no_rule_read_and_a_case_not_known_keep_every_region(self):
game = [self.facts("g.iso")]
self.assertTrue(install.region_rule_keeps_all(None, game))
self.assertTrue(install.region_rule_keeps_all(
{"keep_all_regions": [{"case": "new-case", "condition": "disc"}]}, game))
def test_every_rule_of_the_repository_is_read_without_error(self):
game = [self.facts("g.iso")]
for system, cores in self.rules["systems"].items():
for core, rule in cores.items():
with self.subTest(system=system, core=core):
install.region_rule_keeps_all(rule, game)
class Arcade(GamesTestCase):
def test_a_set_gets_its_bios_parent_from_the_whole_list(self):
self.card.game("FBNEO", "mslug.zip", b"PK")
plan = self.plan()
self.assertEqual(self.kept(plan), ["fbneo/neogeo.zip", "neogeo.zip"])
self.assertEqual(self.kept(plan, required=True), ["fbneo/neogeo.zip", "neogeo.zip"])
def test_a_set_with_no_bios_needs_nothing(self):
self.card.game("FBNEO", "pacman.zip", b"PK")
plan = self.plan()
self.assertEqual(self.kept(plan), [])
self.assertTrue(plan.games[0].recognised)
def test_a_bios_set_on_the_card_is_not_a_game(self):
self.card.game("FBNEO", "neogeo.zip", b"PK")
plan = self.plan()
self.assertEqual(plan.games, [])
self.assertEqual(plan.support, 1)
def test_an_unknown_set_gets_every_bios_set_of_the_core(self):
self.card.game("FBNEO", "zzzz.zip", b"PK")
plan = self.plan()
self.assertEqual(self.kept(plan),
["fbneo/neogeo.zip", "fbneo/pgm.zip", "fbneo/samples/big.zip"])
self.assertEqual([(g.shown, g.reason) for g in plan.fallback],
[("FBNEO/zzzz.zip", "unknown_set")])
def test_a_parent_the_list_does_not_hold_falls_back(self):
self.card.game("FBNEO", "lost.zip", b"PK")
plan = self.plan()
self.assertEqual(plan.fallback[0].reason, "no_parent")
self.assertIn("fbneo/neogeo.zip", plan.keep)
class RequiredOnlyAndExtras(GamesTestCase):
def test_required_only_leaves_out_what_the_set_marks_optional(self):
self.card.game("MD", "eu.md", MD_EU)
plan = self.plan()
self.assertEqual(self.kept(plan, "md_"), ["md_eu.bin", "md_tmss.bin"])
self.assertEqual(self.kept(plan, "md_", required=True), ["md_eu.bin"])
def test_a_required_extra_the_game_triggers_stays(self):
self.card.game("SEGACD", "sonic.iso", mcd_iso(100))
plan = self.plan()
self.assertIn("cd_extra.bin", plan.required)
def test_apply_keeps_the_entries_and_the_omissions_of_the_games(self):
self.card.game("MD", "eu.md", MD_EU)
plan = self.plan()
omitted = [{"dest": "md_missing.bin", "systems": ["sega-megadrive"], "required": False},
{"dest": "other.bin", "systems": ["nintendo-gba"], "required": True}]
files, kept_omitted = plan.apply(manifest()["files"], omitted, False)
self.assertEqual(sorted(e["dest"] for e in files), ["md_eu.bin", "md_tmss.bin"])
self.assertEqual([o["dest"] for o in kept_omitted], ["md_missing.bin"])
_files, kept_omitted = plan.apply(manifest()["files"], omitted, True)
self.assertEqual(kept_omitted, [])
def test_a_board_default_core_adds_its_set(self):
self.card.game("GBA", "game.gba", b"gba game")
plan = self.plan()
# mgba marks the BIOS optional, gpsp (a board's default) requires it.
self.assertEqual(self.kept(plan, "gba", required=True), ["gba_bios.bin"])
class Folders(GamesTestCase):
def test_a_games_folder_named_on_a_computer_is_read_by_folder_names(self):
games = self.card.root / "pc games"
(games / "megadrive").mkdir(parents=True)
(games / "megadrive" / "eu.md").write_bytes(MD_EU)
setup = dict(SETUP, folders=[])
write_index(self.card.lists / "games", setup=setup)
plan = self.plan(games)
self.assertFalse(plan.card)
self.assertEqual(plan.where(), "in this folder")
self.assertEqual(plan.unknown_core, ["sega-megadrive"])
# The core is not known: every set of the system is read.
self.assertIn("md_eu.bin", plan.keep)
def test_a_folder_of_one_system_named_alone(self):
folder = self.card.roms / "MD"
folder.mkdir()
(folder / "eu.md").write_bytes(MD_EU)
plan = self.plan(folder)
self.assertEqual(len(plan.games), 1)
def test_no_rom_folder_beside_the_bios_folder(self):
shutil.rmtree(self.card.roms)
self.assertIsNone(install.plan_games("retroarch", self.card.bios, manifest(),
os_type="linux"))
def test_the_probe_counts_folders_holding_a_game(self):
self.card.game("MD", "eu.md", MD_EU)
self.card.game("PS", "a.chd", b"x")
(self.card.roms / "GBA").mkdir()
self.assertEqual(install.games_probe("retroarch", self.card.bios, network=False,
os_type="linux"), 2)
def test_the_probe_says_nothing_without_a_rom_folder(self):
shutil.rmtree(self.card.roms)
self.assertIsNone(install.games_probe("retroarch", self.card.bios, network=False,
os_type="linux"))
class Cache(GamesTestCase):
def test_a_second_run_hashes_nothing_that_did_not_change(self):
game = self.card.game("MD", "eu.md", MD_EU)
counted = []
real = install._crc32_file
def counting(path):
counted.append(path)
return real(path)
with mock.patch.object(install, "_crc32_file", side_effect=counting):
self.plan()
self.assertEqual(len(counted), 1)
install.GAMES.reset()
plan = self.plan()
self.assertEqual(len(counted), 1)
self.assertTrue(plan.games[0].recognised)
game.write_bytes(MD_JP)
plan = self.plan()
self.assertEqual(len(counted), 2)
self.assertEqual(plan.games[0].dat_regions, frozenset({"japan"}))
def test_the_index_is_cached_and_read_offline_without_the_list_beside_it(self):
self.card.game("MD", "eu.md", MD_EU)
index = (self.card.lists / "games" / "index.json").read_bytes()
cached = self.card.cache / "games"
cached.mkdir(parents=True)
(cached / "index.json").write_bytes(index)
for path in (self.card.lists / "games").rglob("*.json"):
if path.name != "index.json":
data = path.read_bytes()
(cached / f"{hashlib.sha256(data).hexdigest()}.json").write_bytes(data)
shutil.rmtree(self.card.lists / "games")
install.GAMES.reset()
self.assertTrue(self.plan().games[0].recognised)
class Unavailable(GamesTestCase):
def test_offline_with_no_index_is_said(self):
shutil.rmtree(self.card.lists / "games")
with self.assertRaises(install.GamesUnavailable) as caught:
install.plan_games("retroarch", self.card.bios, manifest(), os_type="linux")
self.assertEqual(caught.exception.reason, "offline")
def test_a_document_that_is_not_the_one_listed_is_refused(self):
(self.card.lists / "games" / "setups" / "retroarch.json").write_text("{}")
with self.assertRaises(install.GamesUnavailable) as caught:
install.plan_games("retroarch", self.card.bios, manifest(), os_type="linux")
self.assertEqual(caught.exception.reason, "offline")
def test_an_index_that_names_a_file_the_list_lacks_is_stale(self):
self.card.game("MD", "x.md", b"unknown")
listed = manifest()
listed["files"] = [e for e in listed["files"] if e["dest"] != "md_us.bin"]
with self.assertRaises(install.GamesUnavailable) as caught:
install.plan_games("retroarch", self.card.bios, listed, os_type="linux")
self.assertEqual(caught.exception.reason, "stale")
def run_cli(card: Card, *options: str, offline: bool = True,
base: str | None = None) -> subprocess.CompletedProcess:
env = {**os.environ, "RETROBIOS_CACHE": str(card.cache)}
env.pop(install.OFFLINE_VARIABLE, None)
if base:
env["RETROBIOS_BASE_URL"] = base
command = [sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(card.bios), *options]
if offline:
command += ["--offline", "--manifest", str(card.manifest_path)]
return subprocess.run(command, env=env, capture_output=True, text=True, timeout=120,
check=False, stdin=subprocess.DEVNULL)
class CommandLine(unittest.TestCase):
def setUp(self) -> None:
self.card = Card()
self.addCleanup(self.card.close)
self.card.game("SEGACD", "sonic.iso", mcd_iso(100))
self.card.game("MD", "odd.md", b"not in the catalog")
def events(self, proc: subprocess.CompletedProcess) -> list[dict]:
return [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")]
def test_check_narrows_to_what_the_games_need(self):
proc = run_cli(self.card, "--for-games", "--check", "--events")
events = self.events(proc)
games = next(e for e in events if e["event"] == "games")
self.assertEqual(games["games"], 2)
self.assertEqual(games["fallback"], [{"system": "sega-megadrive", "folder": "MD",
"name": "MD/odd.md",
"reason": "not_recognised"}])
self.assertEqual(sorted(set(games)), sorted(["event", *install.EVENT_KEYS["games"]]))
dests = sorted(e["dest"] for e in events if e["event"] == "file")
self.assertEqual(dests, ["bios_CD_E.bin", "cd_extra.bin", "md_eu.bin", "md_jp.bin",
"md_tmss.bin", "md_us.bin"])
self.assertIn("1 game gets every BIOS of its system", proc.stderr)
def test_required_only_leaves_out_the_optional_files(self):
proc = run_cli(self.card, "--for-games", "--required-only", "--check", "--events")
dests = sorted(e["dest"] for e in self.events(proc) if e["event"] == "file")
self.assertEqual(dests, ["bios_CD_E.bin", "cd_extra.bin", "md_eu.bin", "md_jp.bin",
"md_us.bin"])
def test_required_only_alone_is_refused(self):
proc = run_cli(self.card, "--required-only", "--check")
self.assertEqual(proc.returncode, 2)
self.assertIn("--required-only applies to --for-games", proc.stderr)
def test_a_listing_refuses_it(self):
proc = run_cli(self.card, "--for-games", "--list-systems")
self.assertEqual(proc.returncode, 2)
def test_a_games_folder_that_does_not_exist_stops_the_run(self):
proc = run_cli(self.card, "--for-games", str(self.card.root / "nowhere"), "--check")
self.assertEqual(proc.returncode, 1)
self.assertIn("does not exist", proc.stderr)
def test_offline_without_the_index_keeps_every_file_and_says_so(self):
shutil.rmtree(self.card.lists / "games")
proc = run_cli(self.card, "--for-games", "--check", "--events")
events = self.events(proc)
unavailable = next(e for e in events if e["event"] == "games_unavailable")
self.assertEqual(unavailable["reason"], "offline")
self.assertEqual(len([e for e in events if e["event"] == "file"]), len(FILES))
self.assertIn("Every file of the list is kept", proc.stderr)
def test_a_system_choice_narrows_what_the_games_keep(self):
proc = run_cli(self.card, "--for-games", "--system", "sega-megacd", "--check",
"--events")
dests = sorted(e["dest"] for e in self.events(proc) if e["event"] == "file")
# cd_extra.bin names no system: a system choice leaves it, as without
# --for-games.
self.assertEqual(dests, ["bios_CD_E.bin"])
def test_the_install_writes_only_what_the_games_need(self):
self.card.game("MD", "eu.md", MD_EU)
root = self.card.root / "repo"
(root / "install").mkdir(parents=True)
shutil.copytree(self.card.lists / "games", root / "install" / "games")
listed = manifest()
(root / "bios").mkdir()
for entry in listed["files"]:
payload = entry["dest"].encode().ljust(entry["size"], b".")[:entry["size"]]
(root / entry["repo_path"]).write_bytes(payload)
(root / "install" / "retroarch.json").write_text(json.dumps(listed))
handler = functools.partial(_Quiet, directory=str(root))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
base = f"http://127.0.0.1:{server.server_address[1]}"
proc = run_cli(self.card, "--for-games", "--required-only", "--yes", offline=False,
base=base)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
written = sorted(p.relative_to(self.card.bios).as_posix()
for p in self.card.bios.rglob("*") if p.is_file())
self.assertEqual(written, ["bios_CD_E.bin", "cd_extra.bin", "md_eu.bin", "md_jp.bin",
"md_us.bin"])
self.assertIn("1 game gets every BIOS of its system", proc.stdout)
# Cached for the next run, offline.
self.assertTrue((self.card.cache / "games" / "index.json").is_file())
self.assertTrue((self.card.cache / install.GAMES_SCAN_FILE).is_file())
class _Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A002 - stdlib signature
return
class LineMenu(GamesTestCase):
def menu(self, answers: list[str], given: object = None) -> tuple[object, str]:
self.card.game("SEGACD", "sonic.iso", mcd_iso(100))
plan = self.plan()
listed = manifest()
replies = iter(answers)
out = io.StringIO()
with mock.patch.object(install, "_ask", side_effect=lambda _p: next(replies)), \
contextlib.redirect_stdout(out):
choice = install._prompt_custom_selection(
"retroarch", listed["files"], [], manifest=listed, played=plan, given=given,
command=lambda choice: install._INVOCATION.command("retroarch", "<dest>"))
return choice, out.getvalue()
def test_the_games_are_the_first_choice(self):
choice, text = self.menu([""])
self.assertIsInstance(choice, install.GamesChoice)
self.assertFalse(choice.required)
self.assertIn("for the 1 game on this card", text)
self.assertIn("e) everything, whatever the games", text)
self.assertIn(f"r) {install.GAMES_REQUIRED_WORDS}", text)
def test_e_installs_everything_and_h_goes_back(self):
choice, text = self.menu(["e", ""])
self.assertIsNone(choice)
self.assertIn("h) only for the games on this card", text)
choice, _text = self.menu(["e", "h", ""])
self.assertIsInstance(choice, install.GamesChoice)
def test_r_keeps_only_what_starts(self):
choice, text = self.menu(["r", ""])
self.assertTrue(choice.required)
self.assertIn("r) every file the games read", text)
self.assertEqual(choice.flags(), [("--for-games", None), ("--required-only", None)])
def test_the_choice_is_given_back_as_a_command(self):
self.card.game("SEGACD", "sonic.iso", mcd_iso(100))
plan = self.plan()
self.assertEqual(install.chosen_flags(install.GamesChoice(plan)),
[("--for-games", None)])
named = install.plan_games("retroarch", self.card.bios, manifest(), self.card.roms,
os_type="linux")
self.assertEqual(install.GamesChoice(named).flags(),
[("--for-games", str(self.card.roms))])
class Published(unittest.TestCase):
"""The repository's own index, as a run reads it."""
def test_a_card_reads_with_the_published_index(self):
if not (REPO_ROOT / "install" / "games" / "index.json").exists():
self.skipTest("install/games/ not generated")
root = Path(tempfile.mkdtemp(dir=TMP_ROOT))
self.addCleanup(shutil.rmtree, root, True)
bios = root / "userdata" / "bios"
bios.mkdir(parents=True)
(root / "userdata" / "roms" / "fbneo").mkdir(parents=True)
(root / "userdata" / "roms" / "fbneo" / "mslug.zip").write_bytes(b"PK")
(root / "userdata" / "roms" / "fbneo" / "pacman.zip").write_bytes(b"PK")
with mock.patch.object(install.LOCAL, "manifest", REPO_ROOT / "install" / "batocera.json"), \
mock.patch.object(install, "GAMES", install.GamesData()), \
mock.patch.dict(os.environ, {"RETROBIOS_CACHE": str(root / "cache"),
install.OFFLINE_VARIABLE: "1"}):
listed = json.loads((REPO_ROOT / "install" / "batocera.json").read_text())
plan = install.plan_games("batocera", bios, listed, os_type="linux")
self.assertEqual(len(plan.games), 2)
self.assertEqual(plan.fallback, [])
self.assertIn("neogeo.zip", plan.required)
self.assertTrue(all(dest.lower().endswith("neogeo.zip") for dest in plan.required))
def test_every_published_setup_resolves_a_root_layout(self):
index = json.loads((REPO_ROOT / "install" / "games" / "index.json").read_text())
for key in index["setups"]:
doc = json.loads((REPO_ROOT / "install" / "games" / "setups"
/ f"{key}.json").read_text())
with self.subTest(setup=key):
self.assertIn("from_bios", doc["roms_root"])
if __name__ == "__main__":
unittest.main()
+215
View File
@@ -371,6 +371,36 @@ def fresh_state() -> install.TuiState:
return state
GAMES = {"event": "games", "platform": "retroarch", "folder": "/media/user/SDCARD/Roms",
"games": 412, "recognised": 398,
"found": [{"system": "sony-playstation", "folder": "PS", "games": 400,
"recognised": 390, "regions": []},
{"system": "nintendo-gba", "folder": "GBA", "games": 12,
"recognised": 8, "regions": ["europe"]}],
"fallback": [{"system": "nintendo-gba", "folder": "GBA", "name": "GBA/demo.gba",
"reason": "not_recognised"},
{"system": "nintendo-gba", "folder": "GBA", "name": "GBA/hack.gba",
"reason": "not_recognised"}],
"not_games": 3, "required_only": False}
def games_state() -> install.TuiState:
"""The plan of a card whose games the check read (--for-games)."""
state = fresh_state()
state.stack = ["setup"]
with mock.patch.object(install.Path, "exists", return_value=True):
install.tui_update(state, Key("enter"))
install.tui_receive(state, ("games_here", "retroarch", SYSTEM_DIR, 2))
effects = install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
assert effects == [("run", "check")], effects
for event in [GAMES, *FILES, *SYSTEMS, CHECK]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True, "cores": ["pcsx_rearmed"]}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
return state
def checked_state(facts: dict | None = None) -> install.TuiState:
"""The plan screen of a RetroArch folder with files missing."""
state = fresh_state()
@@ -989,6 +1019,10 @@ def golden_states() -> dict:
helped = checked_state()
helped.help = True
states["help"] = helped
states["plan-games"] = games_state()
listed = games_state()
listed.stack.append("games")
states["games"] = listed
return states
@@ -14490,5 +14524,186 @@ class AuditTwentyFourAndSeventeen(unittest.TestCase):
self.assertFalse(os.path.ismount("/media/x"))
class GamesOnTheCard(unittest.TestCase):
"""For the games on this card: the first choice where the card holds
games, the whole list one press away (--for-games)."""
def test_a_card_with_games_opens_on_them(self):
state = games_state()
self.assertTrue(state.for_games)
self.assertEqual(state.games_choice, "probe")
argv = install.tui_argv(state, "check", "py", "i.py")
self.assertEqual(argv[4:], ["--for-games", "--check", "--events"])
rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows[:4]],
["install", "games", "startonly", "gameslist"])
self.assertEqual(rows[1].label, "For the games on this card: yes")
self.assertEqual(rows[1].right, "412 games")
text = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("For the 412 games on this card.", text)
self.assertIn("2 games get every BIOS of their system: Show the games.", text)
def test_a_card_without_games_keeps_the_whole_list(self):
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
install.tui_update(state, Key("enter"))
install.tui_receive(state, ("games_here", "retroarch", SYSTEM_DIR, 0))
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
self.assertFalse(state.for_games)
self.assertNotIn("--for-games", install.tui_argv(state, "check", "py", "i.py"))
self.assertNotIn("games", [row.action for row in install.tui_rows(checked_state())])
def test_a_look_at_another_folder_is_ignored(self):
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
install.tui_update(state, Key("enter"))
install.tui_receive(state, ("games_here", "retroarch", HOME / "other", 5))
self.assertIsNone(state.for_games)
def test_everything_is_one_press_away(self):
state = games_state()
state.focus["plan"] = 1
self.assertEqual(press(state, "enter"), [("run", "check")])
self.assertFalse(state.for_games)
self.assertEqual(state.games_choice, "user")
self.assertNotIn("--for-games", install.tui_argv(state, "check", "py", "i.py"))
self.assertEqual(state.refocus, "games")
def test_only_what_starts_adds_its_flag(self):
state = games_state()
state.focus["plan"] = 2
self.assertEqual(press(state, "enter"), [("run", "check")])
self.assertIn("--required-only", install.tui_argv(state, "check", "py", "i.py"))
self.assertEqual(install.tui_command(state).split()[-2:],
["--for-games", "--required-only"])
def test_show_the_games_names_those_that_fell_back(self):
state = games_state()
state.focus["plan"] = 3
press(state, "enter")
self.assertEqual(state.screen, "games")
items = install._pager_items(state)
self.assertTrue(any(line.endswith("(GBA): 12 games, 8 recognised, Europe only")
for line in items), items)
self.assertIn("GBA/demo.gba: not recognised", items)
press(state, "enter")
self.assertEqual(state.screen, "plan")
def test_the_result_names_them_too(self):
state = games_state()
state.stack.append("result")
state.code = 0
state.download = {"event": "download", "files": 4, "bytes": 1064964}
state.summary = {"event": "summary", "mode": "install", "installed": 4,
"up_to_date": 1, "pending": 0, "failed": [], "skipped": [],
"left_out": [], "kept": [], "interrupted": False}
lines = install.tui_outcome(state)[1]
self.assertIn("Not recognised, every BIOS of their system kept: GBA/demo.gba, "
"GBA/hack.gba.", lines)
def test_the_command_line_decides_and_names_a_folder(self):
args = _args()
args.for_games, args.required_only = Path("games"), True
with mock.patch.object(install, "_mounted_cards", return_value=[]):
state, _effects = install.tui_start(args, "linux", detect=False)
self.assertEqual(state.games_choice, "cli")
flags = install.tui_flags(state)
self.assertIn(("--for-games", str(Path("games").absolute())), flags)
self.assertIn(("--required-only", None), flags)
state.platform, state.dest = "retroarch", SYSTEM_DIR
install.tui_receive(state, ("games_here", "retroarch", SYSTEM_DIR, 0))
self.assertTrue(state.for_games)
def test_another_folder_is_looked_at_from_what_was_read(self):
state = games_state()
state.dest = HOME / "card" / "BIOS"
with mock.patch.object(install, "games_probe", return_value=0) as probe:
self.assertEqual(install._start_check(state), [("run", "check")])
probe.assert_called_once_with("retroarch", HOME / "card" / "BIOS", network=False,
os_type="linux")
self.assertFalse(state.for_games)
self.assertEqual(state.games_probed, ("retroarch", HOME / "card" / "BIOS"))
def test_the_fetch_looks_at_the_card_before_the_list_arrives(self):
inbox: queue.Queue = queue.Queue()
def fetched(plat, held, log):
held.put(("manifest", plat, MANIFEST))
with mock.patch.object(install, "_tui_fetch", side_effect=fetched), \
mock.patch.object(install, "games_probe", return_value=3) as probe:
install._tui_fetch_games("retroarch", inbox, io.StringIO(), SYSTEM_DIR, True)
probe.assert_called_once_with("retroarch", SYSTEM_DIR, attempts=1)
self.assertEqual(inbox.get()[0], "games_here")
self.assertEqual(inbox.get()[0], "manifest")
def test_reading_the_games_is_counted_while_the_check_runs(self):
state = fresh_state()
state.platform, state.dest, state.stack = "retroarch", SYSTEM_DIR, ["checking"]
state.manifest, state.run, state.running = MANIFEST, "check", True
install.tui_receive(state, ("event", state.run_id, {
"event": "games_reading", "files_done": 40, "files_total": 412}))
self.assertIn("Reading the games: 40 of 412",
"\n".join(install.tui_view(state, 80, 24)))
def test_what_the_games_need_not_known_is_said(self):
state = games_state()
state.games = None
state.games_unavailable = {"event": "games_unavailable", "platform": "retroarch",
"reason": "offline", "message": "the games index is "
"not on this machine"}
self.assertIn("What the games need is not known: the games index is not on this "
"machine. Every file is kept.", install.games_plan_words(state))
def test_every_size_draws_the_rows(self):
state = games_state()
for cols, rows in SIZES:
with self.subTest(size=f"{cols}x{rows}"):
state.cols, state.rows = cols, rows
lines = install.tui_view(state, cols, rows)
self.assertTrue(any("For the games" in line for line in lines), lines)
@unittest.skipUnless(pyte and pty, "the end-to-end screens need pyte and a pty")
class GamesEndToEnd(unittest.TestCase):
"""The real --tui on a card with games, against a loopback repository."""
def test_the_plan_opens_on_the_games_and_installs_only_them(self):
import test_install_games as games # noqa: PLC0415
card = games.Card()
self.addCleanup(card.close)
card.game("SEGACD", "sonic.iso", games.mcd_iso(100))
root = card.root / "repo"
(root / "install").mkdir(parents=True)
shutil.copytree(card.lists / "games", root / "install" / "games")
listed = games.manifest()
(root / "bios").mkdir()
for entry in listed["files"]:
payload = entry["dest"].encode().ljust(entry["size"], b".")[:entry["size"]]
(root / entry["repo_path"]).write_bytes(payload)
(root / "install" / "retroarch.json").write_text(json.dumps(listed))
handler = functools.partial(_QuietHandler, directory=str(root))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
env = {**os.environ, "TERM": "xterm", "HOME": str(card.root), "RETROBIOS_OS": "linux",
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{server.server_address[1]}",
"RETROBIOS_CACHE": str(card.cache)}
for name in ("DISPLAY", "WAYLAND_DISPLAY", "SSH_CONNECTION", "SSH_TTY", "NO_COLOR",
install.TUI_PAD_ENV, install.OFFLINE_VARIABLE, *install._STEAM_MARKERS):
env.pop(name, None)
term = Pty(["--platform", "retroarch", "--dest", str(card.bios)], env)
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
screen = term.wait("For the games on this card: yes", timeout=40)
self.assertIn("For the 1 game on this card.", screen)
term.send("\r")
term.wait("A/Enter Exit", timeout=40)
term.send("\r")
self.assertEqual(term.close(), 0)
written = sorted(p.name for p in card.bios.rglob("*") if p.is_file())
self.assertEqual(written, ["bios_CD_E.bin", "cd_extra.bin"])
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -839,6 +839,31 @@ python install.py --platform recalbox --region us,eu,jp
python install.py --platform emuelec --no-game-data
```
### For the games on this card { #for-the-games-on-this-card }
When the card holds games in its rom folders, the plan opens on **For the
games on this card: yes**, and Install takes only what those games need. One
press on the row installs everything instead. Line mode asks the same: Enter
installs for the games, `e` everything, `r` only what starts. On the command
line it is `--for-games`, and `--for-games FOLDER` reads a games folder on a
computer, one subfolder per system.
The rule is that every game must start. Per system, the run keeps what the
system's default core reads. One region is kept only where the game's own
content proves it: a cartridge's crc32 found in No-Intro, a disc serial found
in Redump, or a header the core reads. A file name such as `(Europe)` proves
nothing. Where a core forces a region by an option, falls back to another
region, or uses its own disc list, every region stays. An arcade set gets the
BIOS sets of its parent chain (`neogeo.zip` for `mslug.zip`).
A game that is not recognised gets every BIOS of its system, and the run names
it. So does an arcade set the core does not know. **Only what starts**
(`--required-only`) leaves out the files the cores mark optional.
The games are read without hashing whole discs, and a cache keeps what was
read, so a second run over the same card reads only new files. Offline, with
no games index on this machine, the run keeps every file and says so.
### Firmware without game data
Some platform lists name the files a game reads beside the firmware its
@@ -932,6 +957,8 @@ what the options name, everything by default.
| <span id="option-core"></span>`--core NAMES` | Keep the platform's own list and only the extra files these cores load. `--list-cores` prints the names |
| <span id="option-emulator"></span>`--emulator NAMES` | Install what these emulators read on the detected platform, and nothing else: their own files and the platform's files of the systems they run. Each emulator page on the site gives this command. `--list-emulators` prints the names, buildbot names such as `mednafen_psx` included |
| <span id="option-region"></span>`--region LIST` | Preferred regions, best first (`us,eu,jp`): keep one BIOS per region where a system has several. Accepts the names and aliases of `generate_pack.py --region` |
| <span id="option-for-games"></span>`--for-games FOLDER` | Install only what the games on the card need. See [For the games on this card](#for-the-games-on-this-card) |
| <span id="option-required-only"></span>`--required-only` | With `--for-games`, only the files the games cannot start without |
| <span id="option-no-game-data"></span>`--no-game-data` | Leave out game data (a game's own files, not firmware) and install the firmware only. What it leaves out is named. See [Firmware without game data](#firmware-without-game-data) |
| <span id="option-declared-only"></span>`--declared-only` | Keep only the files the platform's own list names (`cores: null` in the manifest), not the extra files cores load. With `--check`, the answer is about the list the platform itself checks. Refused with `--core`, the listings it does not change, `--standalone-copies` and `--tui` |
| <span id="option-list-systems"></span>`--list-systems` | Print a platform's systems with the files and size each holds, the files other systems share too counted apart |
@@ -1739,6 +1766,9 @@ other schemas under `api/v1/schemas/`.
| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` |
| `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it |
| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place |
| `games_reading` | `files_done`, `files_total` | With `--for-games`, while the games of the card are read (a cartridge hashed, a disc's header read), at most every half second and once at the end |
| `games` | `platform`, `folder`, `games`, `recognised`, `found`, `fallback`, `not_games`, `required_only` | With `--for-games`, once the games are read, before `choices`. `found` lists per system its `folder`, `games`, `recognised` and the `regions` kept (empty for every region). `fallback` names each game that got every BIOS of its system, with a `reason` (`not_recognised`, `not_read`, `region_unknown`, `unknown_set`, `no_parent`) |
| `games_unavailable` | `platform`, `reason`, `message` | With `--for-games`, what the games need could not be read (`offline`, `network`, `invalid`, `stale`, `no_sets`, `no_folder`): the run keeps every file of the list, except for a `FOLDER` that does not exist, where it stops |
| `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply |
| `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` |
| `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place |