fix: settle a core file only by its size or slot

This commit is contained in:
Abdessamad Derraz committed 2026-10-10 09:15:11 +02:00
1 parent be45eda62d
commit f6da2cff3a
10 files changed
+321 -135

No files matched your search

+6 -6
View File
@@ -89,14 +89,14 @@ Full list with per-file details: **[https://abdess.github.io/retrobios/](https:/
| Batocera | 353/353 | 12,287/12,718 | MD5 hash |
| BizHawk | 118/118 | 800/801 | SHA1 hash |
| EmuDeck | 168/168 | 430/432 | MD5 hash |
| Lakka | 526/526 | 5,256/5,286 | file presence |
| Lakka | 526/526 | 5,258/5,288 | file presence |
| MiSTer FPGA | 81/81 | - | MD5 hash |
| ROCKNIX | 38/38 | 5,695/5,725 | MD5 hash |
| Recalbox | 351/351 | 2,721/2,729 | MD5 hash |
| RetroArch | 526/526 | 5,256/5,286 | file presence |
| RetroBat | 348/348 | 11,875/12,305 | MD5 hash |
| Recalbox | 351/351 | 2,726/2,734 | MD5 hash |
| RetroArch | 526/526 | 5,258/5,288 | file presence |
| RetroBat | 348/348 | 11,876/12,306 | MD5 hash |
| RetroDECK | 2,008/2,008 | 5,387/5,437 | MD5 hash |
| RetroPie * | 526/526 | 9,171/9,313 | file presence |
| RetroPie * | 526/526 | 9,175/9,317 | file presence |
| RomM | 381/381 | 746/752 | MD5 hash |
Each fraction is what the pack has over what is needed, counting required and optional files alike since both ship. The first column is the BIOS list the platform publishes. The second counts files its emulators load that this list never mentions, found by reading their source code, and it is routinely several times larger. A short fraction means files are still missing, and they are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/).
@@ -180,4 +180,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
The reasoning, and where it is weakest, is in the [FAQ](https://abdess.github.io/retrobios/wiki/faq/#is-this-legal).
*Auto-generated on 2026-10-10T05:32:25Z*
*Auto-generated on 2026-10-10T07:00:25Z*
+26 -26
View File
@@ -7860,6 +7860,32 @@
"nintendo-gamecube"
]
},
{
"dest": "SC-55/ROM1.BIN",
"sha1": "b91bb1d9dccffe831b7cfde7800a3fe32b2fbda6",
"sha256": "8a1eb33c7599b746c0c50283e4349a1bb1773b5c0ec0e9661219bf6c067d2042",
"size": 32768,
"repo_path": "bios/Roland/soundcanvas-roms/SC-55mk2-v1.01/rom1.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM2.BIN",
"sha1": "078cb5feea05e80bb9a1bb857a2163ee434fd053",
"sha256": "a4c9fd821059054c7e7681d61f49ce6f42ed2fe407a7ec1ba0dfdc9722582ce0",
"size": 524288,
"repo_path": "bios/Roland/sounddata/Nuked-SC55-Resources/ROMs/SC-55mk2-v1.01/rom2.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM_SM.BIN",
"sha1": "4d48578d811a762a8e7bfaf18989bcac70ae1ba4",
@@ -61556,32 +61582,6 @@
"nintendo-gamecube"
]
},
{
"dest": "SC-55/ROM1.BIN",
"sha1": "b91bb1d9dccffe831b7cfde7800a3fe32b2fbda6",
"sha256": "8a1eb33c7599b746c0c50283e4349a1bb1773b5c0ec0e9661219bf6c067d2042",
"size": 32768,
"repo_path": "bios/Roland/soundcanvas-roms/SC-55mk2-v1.01/rom1.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM2.BIN",
"sha1": "078cb5feea05e80bb9a1bb857a2163ee434fd053",
"sha256": "a4c9fd821059054c7e7681d61f49ce6f42ed2fe407a7ec1ba0dfdc9722582ce0",
"size": 524288,
"repo_path": "bios/Roland/sounddata/Nuked-SC55-Resources/ROMs/SC-55mk2-v1.01/rom2.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "fbalpha2012/hiscore.dat",
"sha1": "7381472bf046126257e51a0124e4553282f020e5",
+60 -4
View File
@@ -237,10 +237,10 @@
}
}
],
"total_files": 3220,
"total_size": 4192446979,
"pack_files": 3222,
"pack_size": 4192446979,
"total_files": 3225,
"total_size": 4193257987,
"pack_files": 3227,
"pack_size": 4193257987,
"total_omitted": 1,
"omitted_files": [
{
@@ -5338,6 +5338,16 @@
"sega-saturn"
]
},
{
"dest": "bk/BASIC10.ROM",
"sha1": "af7eae90c398ec12942a408fa5ac2722e331738c",
"sha256": "24be347356715352fcab480ed1559302d47d598255980a32150eba1431b462b8",
"size": 24576,
"repo_path": "bios/Elektronika/BK/BASIC10.ROM",
"cores": [
"bk"
]
},
{
"dest": "bk/TERAK.ROM",
"sha1": "273a9933b68a290c5aedcd6d69faa7b1d22c0344",
@@ -24115,6 +24125,32 @@
"gearcoleco"
]
},
{
"dest": "syscard2.pce",
"sha1": "88da02e2503f7c32810f5d93a34849d470742b6d",
"sha256": "0deb13845c7e44ea78a25bbbe324afd60a0ec29ea5a4cf5780349f1598d24cd3",
"size": 262144,
"repo_path": "bios/NEC/PC Engine/syscard2.pce",
"cores": [
"geargrafx"
],
"systems": [
"nec-pc-engine"
]
},
{
"dest": "syscard1.pce",
"sha1": "a39a66da7de6ba94ab84d04eef7afeec7d4ee66a",
"sha256": "afe9f27f91ac918348555b86298b4f984643eafa2773196f2c5441ea84f0c3bb",
"size": 262144,
"repo_path": "bios/NEC/PC Engine/syscard1.pce",
"cores": [
"geargrafx"
],
"systems": [
"nec-pc-engine"
]
},
{
"dest": "pac-n1.bin",
"sha1": "6973e2593e66fd21627fedccec98d4a364afaaff",
@@ -32985,6 +33021,26 @@
"virtualjaguar"
]
},
{
"dest": "jagboot.rom",
"sha1": "af643900fea84234e08b616a6d27f03f0a61db96",
"sha256": "f0bd29599b3dfca7f5620c011256c1dc45c9d82c428a318f965c2ba3ded2e9f9",
"size": 131072,
"repo_path": "bios/Atari/Jaguar/boot.rom",
"cores": [
"virtualjaguar"
]
},
{
"dest": "jagboot_m.rom",
"sha1": "af643900fea84234e08b616a6d27f03f0a61db96",
"sha256": "f0bd29599b3dfca7f5620c011256c1dc45c9d82c428a318f965c2ba3ded2e9f9",
"size": 131072,
"repo_path": "bios/Atari/Jaguar/boot.rom",
"cores": [
"virtualjaguar"
]
},
{
"dest": "pinmame/roms/gts1.zip",
"sha1": "d4b0c8277c187a92279e2d2b71fc91b72d581257",
+26 -26
View File
@@ -8099,6 +8099,32 @@
"nintendo-gamecube"
]
},
{
"dest": "SC-55/ROM1.BIN",
"sha1": "b91bb1d9dccffe831b7cfde7800a3fe32b2fbda6",
"sha256": "8a1eb33c7599b746c0c50283e4349a1bb1773b5c0ec0e9661219bf6c067d2042",
"size": 32768,
"repo_path": "bios/Roland/soundcanvas-roms/SC-55mk2-v1.01/rom1.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM2.BIN",
"sha1": "078cb5feea05e80bb9a1bb857a2163ee434fd053",
"sha256": "a4c9fd821059054c7e7681d61f49ce6f42ed2fe407a7ec1ba0dfdc9722582ce0",
"size": 524288,
"repo_path": "bios/Roland/sounddata/Nuked-SC55-Resources/ROMs/SC-55mk2-v1.01/rom2.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM_SM.BIN",
"sha1": "4d48578d811a762a8e7bfaf18989bcac70ae1ba4",
@@ -61795,32 +61821,6 @@
"nintendo-gamecube"
]
},
{
"dest": "SC-55/ROM1.BIN",
"sha1": "b91bb1d9dccffe831b7cfde7800a3fe32b2fbda6",
"sha256": "8a1eb33c7599b746c0c50283e4349a1bb1773b5c0ec0e9661219bf6c067d2042",
"size": 32768,
"repo_path": "bios/Roland/soundcanvas-roms/SC-55mk2-v1.01/rom1.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM2.BIN",
"sha1": "078cb5feea05e80bb9a1bb857a2163ee434fd053",
"sha256": "a4c9fd821059054c7e7681d61f49ce6f42ed2fe407a7ec1ba0dfdc9722582ce0",
"size": 524288,
"repo_path": "bios/Roland/sounddata/Nuked-SC55-Resources/ROMs/SC-55mk2-v1.01/rom2.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "fbalpha2012/hiscore.dat",
"sha1": "7381472bf046126257e51a0124e4553282f020e5",
+14 -4
View File
@@ -241,10 +241,10 @@
}
}
],
"total_files": 12397,
"total_size": 8504485005,
"pack_files": 12399,
"pack_size": 8504485005,
"total_files": 12398,
"total_size": 8504485261,
"pack_files": 12400,
"pack_size": 8504485261,
"total_omitted": 30,
"omitted_files": [
{
@@ -61418,6 +61418,16 @@
"genesis_plus_gx"
]
},
{
"dest": "c600.rom",
"sha1": "d4181c9f046aafc3fb326b381baac809d9e38d16",
"sha256": "de1e3e035878bab43d0af8fe38f5839c527e9548647036598ee6fe7ec74d2a7d",
"size": 256,
"repo_path": "bios/Apple/Apple II/AppleIIe_DiskII.rom",
"cores": [
"gsplus"
]
},
{
"dest": "gzdoom.pk3",
"sha1": "810ed9968c5b3bdc2c348c9bc76e9f80fbe31308",
+56 -30
View File
@@ -15,10 +15,10 @@
}
],
"standalone_copies": [],
"total_files": 9890,
"total_size": 8731814780,
"pack_files": 12533,
"pack_size": 8772347307,
"total_files": 9892,
"total_size": 8731913084,
"pack_files": 12535,
"pack_size": 8772445611,
"total_omitted": 8,
"omitted_files": [
{
@@ -43373,6 +43373,32 @@
"nintendo-gamecube"
]
},
{
"dest": "SC-55/ROM1.BIN",
"sha1": "b91bb1d9dccffe831b7cfde7800a3fe32b2fbda6",
"sha256": "8a1eb33c7599b746c0c50283e4349a1bb1773b5c0ec0e9661219bf6c067d2042",
"size": 32768,
"repo_path": "bios/Roland/soundcanvas-roms/SC-55mk2-v1.01/rom1.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM2.BIN",
"sha1": "078cb5feea05e80bb9a1bb857a2163ee434fd053",
"sha256": "a4c9fd821059054c7e7681d61f49ce6f42ed2fe407a7ec1ba0dfdc9722582ce0",
"size": 524288,
"repo_path": "bios/Roland/sounddata/Nuked-SC55-Resources/ROMs/SC-55mk2-v1.01/rom2.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM_SM.BIN",
"sha1": "4d48578d811a762a8e7bfaf18989bcac70ae1ba4",
@@ -99938,6 +99964,32 @@
"zesarux"
]
},
{
"dest": "cpc664.rom",
"sha1": "4a38f7f5bf7fd0a95cf882433c69e67251a46868",
"sha256": "347c9458851329512e90bfd6524f758ed6ac4bdcdeb58546db2f35338b5e8f12",
"size": 49152,
"repo_path": "bios/Other/zesarux/cpc664.rom",
"cores": [
"zesarux"
],
"systems": [
"amstrad-cpc"
]
},
{
"dest": "cpc6128.rom",
"sha1": "29dd0487dc5a96cd00966d074de8700ddd4ee18b",
"sha256": "1923b3779ea73519ff3c931c29917f72322b66bcfe37eba7ceeb9e49d095c8ea",
"size": 49152,
"repo_path": "bios/Other/zesarux/cpc6128.rom",
"cores": [
"zesarux"
],
"systems": [
"amstrad-cpc"
]
},
{
"dest": "pcw_boot.rom",
"sha1": "245a173cfc7976a4cae8d255f3d1422aeaf5f09a",
@@ -101233,32 +101285,6 @@
"nintendo-gamecube"
]
},
{
"dest": "SC-55/ROM1.BIN",
"sha1": "b91bb1d9dccffe831b7cfde7800a3fe32b2fbda6",
"sha256": "8a1eb33c7599b746c0c50283e4349a1bb1773b5c0ec0e9661219bf6c067d2042",
"size": 32768,
"repo_path": "bios/Roland/soundcanvas-roms/SC-55mk2-v1.01/rom1.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "SC-55/ROM2.BIN",
"sha1": "078cb5feea05e80bb9a1bb857a2163ee434fd053",
"sha256": "a4c9fd821059054c7e7681d61f49ce6f42ed2fe407a7ec1ba0dfdc9722582ce0",
"size": 524288,
"repo_path": "bios/Roland/sounddata/Nuked-SC55-Resources/ROMs/SC-55mk2-v1.01/rom2.bin",
"cores": [
"dosbox"
],
"systems": [
"dos"
]
},
{
"dest": "fbalpha2012/hiscore.dat",
"sha1": "7381472bf046126257e51a0124e4553282f020e5",
+41 -22
View File
@@ -1670,40 +1670,59 @@ def filter_systems_by_target(
return filtered
def expand_platform_declared_names(config: dict, db: dict) -> set[str]:
"""Build set of file names declared by a platform config.
def platform_declarations(
config: dict, db: dict, *, enrich: bool = True
) -> dict[str, list[tuple[int | None, str]]]:
"""Every file name a platform declares, with each declaration's size and destination.
Enriches the set with canonical names and aliases from the database
by resolving each platform file's MD5 through by_md5. This handles
cases where a platform declares a file under a different name than
the emulator profile (e.g. Batocera ROM1 vs gsplus ROM).
A name alone does not say which file a declaration is: RetroArch declares
galaksija/ROM1.BIN, a 4 KiB ROM, and DOSBox reads a 32 KiB SC-55 ROM1.BIN.
The size, from the platform entry or else from the file its hash names,
tells a declaration of the same file from a same-named one; None stands
for a size nobody knows. The destination, sanitized and case-folded, is
the slot the declaration fills.
With ``enrich``, a declaration also answers for the canonical name and
the aliases the database knows its MD5 under. This handles a platform
declaring a file under a different name than the emulator profile
(e.g. Batocera ROM1 vs gsplus ROM).
"""
declared: set[str] = set()
declared: dict[str, list[tuple[int | None, str]]] = {}
by_md5 = db.get("indexes", {}).get("by_md5", {})
files_db = db.get("files", {})
for system in config.get("systems", {}).values():
for fe in system.get("files", []):
name = fe.get("name", "")
dest = sanitize_pack_path(fe.get("destination") or name).lower()
# A zipped_file entry pins a member of the archive it names: its
# md5 and its size, when stated, are not the archive's.
by_hash: dict = {}
size = None
if not fe.get("zipped_file"):
md5 = (fe.get("md5") or "").lower()
if md5 and "," not in md5:
by_hash = files_db.get(by_md5.get(md5, ""), {})
size = fe.get("size")
if size is None:
size = files_db.get(
(fe.get("sha1") or "").lower(), by_hash
).get("size")
if name:
declared.add(name)
md5 = fe.get("md5", "")
if not md5:
declared.setdefault(name, []).append((size, dest))
if not enrich:
continue
# Skip multi-hash and zipped_file entries (inner ROM MD5, not file MD5)
if "," in md5 or fe.get("zipped_file"):
continue
sha1 = by_md5.get(md5.lower())
if not sha1:
continue
entry = files_db.get(sha1, {})
db_name = entry.get("name", "")
if db_name:
declared.add(db_name)
for alias in entry.get("aliases", []):
declared.add(alias)
known = by_hash
for other in (known.get("name", ""), *known.get("aliases", [])):
if other:
declared.setdefault(other, []).append((known.get("size"), dest))
return declared
def expand_platform_declared_names(config: dict, db: dict) -> set[str]:
"""The names a platform declares, enriched as `platform_declarations`."""
return set(platform_declarations(config, db))
import re
+2 -7
View File
@@ -7,7 +7,7 @@ this module builds so the report and the pack withdraw the same files."""
from __future__ import annotations
from common import PROFILE_IDENTITY_FIELDS
from common import expand_platform_declared_names
from common import expand_platform_declared_names, platform_declarations
from common import load_emulator_profiles
from common import resolution_is_hash_exact
from common import resolve_local_file
@@ -494,12 +494,7 @@ def _collect_emulator_extras(
# Use strict YAML names (no DB alias enrichment) so that files known
# under an alias still get packed at the emulator's expected path.
yaml_names: set[str] = set()
for system in config.get("systems", {}).values():
for fe in system.get("files", []):
name = fe.get("name", "")
if name:
yaml_names.add(name)
yaml_names = platform_declarations(config, db, enrich=False)
undeclared = find_undeclared_files(
config, emulators_dir, db, emu_profiles, target_cores=target_cores,
+31 -10
View File
@@ -29,6 +29,7 @@ import json
import os
import sys
import zipfile
from collections.abc import Mapping
import slots
@@ -43,7 +44,8 @@ from common import (
check_inside_zip,
compute_hashes,
expand_directory_entries,
expand_platform_declared_names,
name_match_size_ok,
platform_declarations,
filter_systems_by_target,
group_identical_platforms,
list_emulator_profiles,
@@ -396,7 +398,8 @@ def _candidate_verdict(
fname: str,
is_standalone: bool,
include_all: bool,
declared_names: set,
declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str],
dest: str = "",
) -> str:
"""Whether a profile entry can be a gap, and whether it is settled.
@@ -415,9 +418,27 @@ def _candidate_verdict(
# A platform declaring any name the core answers to has met the
# requirement: quasi88 reads n88sub.rom or disk.rom, and System.dat
# names disk.rom. Without the aliases the pack carried the ROM twice.
answers = {fname, file_entry.get("archive") or "", *file_entry.get("aliases", [])}
if answers & declared_names:
# The archive answers by its name. A loose file answers where the
# declaration fills the entry's own slot, whose content the slot
# arbitration decides, or elsewhere at a size the core accepts:
# Galaksija's 4 KiB ROM1.BIN does not stand for DOSBox's 32 KiB
# SC-55 one.
if file_entry.get("archive") in declared_names:
return "settled"
slot = sanitize_pack_path(dest).lower()
for name in (fname, *file_entry.get("aliases", [])):
if name not in declared_names:
continue
declarations = (
declared_names[name]
if isinstance(declared_names, Mapping)
else [(None, "")]
)
if any(
(slot and where == slot) or name_match_size_ok(file_entry, size)
for size, where in declarations
):
return "settled"
return "keep"
@@ -438,16 +459,16 @@ def find_undeclared_files(
target_cores: set[str] | None = None,
data_names: set[str] | None = None,
include_all: bool = False,
declared_names: set[str] | None = None,
declared_names: Mapping[str, list[tuple[int | None, str]]] | None = None,
) -> list[dict]:
"""Find files needed by cores but not declared in platform config.
declared_names overrides the default enriched set from
expand_platform_declared_names. Pass a strict set (YAML names only)
when building packs so alias-only names still get packed.
declared_names overrides the default enriched declarations from
platform_declarations. Pass the strict ones (YAML names only) when
building packs so alias-only names still get packed.
"""
if declared_names is None:
declared_names = expand_platform_declared_names(config, db)
declared_names = platform_declarations(config, db)
# Whether the builder drops a file whose local copy contradicts its
# declared hash, which decides if such a copy counts as held here.
@@ -523,7 +544,7 @@ def find_undeclared_files(
if not fname or seen_key in seen_files:
continue
verdict = _candidate_verdict(
f, fname, is_standalone, include_all, declared_names
f, fname, is_standalone, include_all, declared_names, dest
)
if verdict == "settled":
seen_files.add(seen_key)
+59
View File
@@ -92,5 +92,64 @@ class ADeclaredAliasSettlesTheRequirement(unittest.TestCase):
self.assertEqual([u["name"] for u in undeclared], [])
class ASameNamedFileOfAnotherSizeSettlesNothing(unittest.TestCase):
"""RetroArch declares Galaksija's ROM1.BIN, a 4 KiB ROM; DOSBox reads a
32 KiB SC-55 ROM1.BIN and checks its size. The gap pass settled DOSBox's
entry on the name alone: the report never listed SC-55/ROM1.BIN, and a
missing dump would have shown no gap."""
PROFILE = (
"emulator: DOSBox\ntype: libretro\nsystems: [dos]\ncores: [dosbox]\n"
"files:\n - name: ROM1.BIN\n path: SC-55/ROM1.BIN\n"
" size: 32768\n validation: [size]\n"
)
def undeclared(self, declared_size, destination="galaksija/ROM1.BIN", md5=None, db_size=None):
import os # noqa: PLC0415
import tempfile # noqa: PLC0415
from common import _emulator_profiles_cache # noqa: PLC0415
from verify import find_undeclared_files # noqa: PLC0415
with tempfile.TemporaryDirectory() as tmp:
Path(tmp, "emulators").mkdir()
Path(tmp, "emulators", "dosbox.yml").write_text(self.PROFILE)
_emulator_profiles_cache.clear()
self.addCleanup(_emulator_profiles_cache.clear)
config = {"platform": "P", "verification_mode": "existence", "cores": "all_libretro",
"systems": {"galaksija": {"files": [
{"name": "ROM1.BIN", "destination": destination,
"size": declared_size, "md5": md5}]}}}
db = {"files": {}, "indexes": {"by_name": {}, "by_md5": {}, "by_crc32": {}, "by_path_suffix": {}}}
if md5:
db["files"]["a" * 40] = {"name": "ROM1.BIN", "size": db_size}
db["indexes"]["by_md5"][md5] = "a" * 40
previous = os.getcwd()
os.chdir(tmp)
self.addCleanup(os.chdir, previous)
return [u["path"] for u in find_undeclared_files(config, "emulators", db, data_names=set())]
def test_a_declaration_at_a_size_the_core_rejects_leaves_the_gap_open(self):
self.assertEqual(self.undeclared(4096), ["SC-55/ROM1.BIN"])
def test_a_declaration_at_an_accepted_size_settles_it(self):
self.assertEqual(self.undeclared(32768), [])
def test_a_declaration_without_a_size_still_settles_it(self):
self.assertEqual(self.undeclared(None), [])
def test_the_size_of_the_file_its_hash_names_stands_in(self):
"""Batocera writes no size: its md5 names the file, and the file's size
decides. MT32_CONTROL.ROM at 64 KiB covered DOSBox's 128 KiB entries."""
self.assertEqual(
self.undeclared(None, md5="b" * 32, db_size=4096), ["SC-55/ROM1.BIN"]
)
def test_a_declaration_filling_the_entry_slot_settles_it(self):
"""Same destination, other size: which bytes fill the slot is the slot
arbitration's call, and the builder leaves a taken slot alone."""
self.assertEqual(self.undeclared(4096, destination="SC-55/ROM1.BIN"), [])
if __name__ == "__main__":
unittest.main()