// Headless tests for the game library (#838). No window, no GL, no real network: // dat parsing, CRC identification, libretro-thumbnails naming, the cache layout, a scan of a // temp folder, and (POSIX) the whole art pipeline against a loopback HTTP server. // // vj_library_tests run every test // vj_library_tests --scan DIR [--db DIR] dev tool: scan DIR, identify against DIR/*.dat, print COUNTS only #include #include #include #include #include #include #include #include #include #include #include #include #include #include "config.h" #include "disc_hash.h" #include "http.h" #include "library.h" #include "library_service.h" #include "m3u.h" #include "paths.h" #ifndef _WIN32 #include #include #include #include #include #include #endif namespace fs = std::filesystem; using namespace vj; static int g_fail = 0, g_checks = 0; #define CHECK(cond) \ do { \ g_checks++; \ if (!(cond)) { \ g_fail++; \ std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ } \ } while (0) #define CHECK_EQ(a, b) \ do { \ g_checks++; \ std::string _a = (a), _b = (b); \ if (_a != _b) { \ g_fail++; \ std::fprintf(stderr, "FAIL %s:%d: %s\n got: %s\n want: %s\n", __FILE__, __LINE__, #a, _a.c_str(), _b.c_str()); \ } \ } while (0) static std::string hex8(uint32_t v) { char b[16]; std::snprintf(b, sizeof b, "%08X", v); return b; } // ---- fixtures --------------------------------------------------------------------------------------- static std::string g_tmp; // our own temp root, removed at exit static std::string mkTmp() { std::error_code ec; fs::path p = fs::temp_directory_path(ec); if (ec) p = "."; p /= "vj-library-test-" + std::to_string((unsigned long long)SDL_GetPerformanceCounter()); fs::create_directories(p, ec); return p.string() + "/"; } static void writeBytes(const std::string& path, const std::vector& b) { std::error_code ec; fs::create_directories(fs::path(path).parent_path(), ec); std::ofstream f(path, std::ios::binary); f.write((const char*)b.data(), (std::streamsize)b.size()); } static void writeText(const std::string& path, const std::string& s) { writeBytes(path, std::vector(s.begin(), s.end())); } // Deterministic pseudo-random bytes. static std::vector noise(size_t n, uint32_t seed) { std::vector v(n); uint32_t x = seed * 2654435761u + 12345u; for (size_t i = 0; i < n; i++) { x = x * 1664525u + 1013904223u; v[i] = (unsigned char)(x >> 24); } return v; } static void putBE32(std::vector& v, size_t off, uint32_t x) { v[off] = (unsigned char)(x >> 24); v[off + 1] = (unsigned char)(x >> 16); v[off + 2] = (unsigned char)(x >> 8); v[off + 3] = (unsigned char)x; } static uint32_t crcOf(const std::vector& v, size_t from = 0) { return lib::crc32Update(0, v.data() + from, v.size() - from); } // ---- dat text ----------------------------------------------------------------------------------------- static std::string datText(const std::string& version, const std::string& games) { return "clrmamepro (\n\tname \"Test\"\n\tversion \"" + version + "\"\n)\n\n" + games; } static std::string datGame(const std::string& name, const std::string& romName, uint32_t crc, const std::string& extra = "") { return "game (\n\tname \"" + name + "\"\n" + extra + "\trom ( name \"" + romName + "\" size 1048576 crc " + hex8(crc) + " md5 00 sha1 00 )\n)\n"; } // ---- tests --------------------------------------------------------------------------------------------------- static void testCrc() { CHECK_EQ(hex8(lib::crc32Update(0, "123456789", 9)), "CBF43926"); uint32_t a = lib::crc32Update(0, "1234", 4); CHECK_EQ(hex8(lib::crc32Update(a, "56789", 5)), "CBF43926"); // incremental } static void testDatParsing() { // Rows in the shape of the real libretro-database files, including a quote-heavy name and a serial. std::string text = "clrmamepro (\n\tname \"Atari - Jaguar\"\n\tversion \"2026.10.07\"\n\textensions \"abs|cof|j64|jag|rom\"\n)\n\n" "game (\n\tname \"1943 (World)\"\n\trom ( name \"1943 (World) (Aftermarket) (Unl).j64\" size 4194304 crc ED88E869 md5 658E sha1 710D )\n)\n" "game (\n\tname \"1943 (World)\"\n\trom ( name \"1943 (World) (Aftermarket) (Unl).rom\" size 4186112 crc 95FD1E88 md5 CB10 sha1 F263 )\n)\n" "game (\n\tname \"Alice's Mom's Rescue! (Europe) (En,Fr,De,Es,It)\"\n\tregion \"Europe\"\n" "\trom ( name \"Alice's Mom's Rescue! (Europe) (En,Fr,De,Es,It) (Unl) (Track 1).bin\" size 29995056 crc 63E41DF2 md5 1E06 sha1 B6F5 )\n)\n" "game (\n\tname \"Baldies (USA) (Rev 1)\"\n\tregion \"USA\"\n\tserial \"J9055E\"\n" "\trom ( name \"Baldies (USA) (Rev 1) (Track 01).bin\" size 11207280 crc 2B34A071 md5 FB17 sha1 E414 serial \"J9055E\" )\n)\n"; lib::Dat d; CHECK(d.parse(text)); CHECK(d.size() == 4); CHECK_EQ(d.header("version"), "2026.10.07"); CHECK_EQ(d.header("name"), "Atari - Jaguar"); const lib::DatGame* g = d.byCrc(0x95FD1E88); // the second rom of the first game name CHECK(g && g->name == "1943 (World)"); g = d.byCrc(0x63E41DF2); CHECK(g && g->name == "Alice's Mom's Rescue! (Europe) (En,Fr,De,Es,It)" && g->region == "Europe"); g = d.byCrc(0x2B34A071); CHECK(g && g->serial == "J9055E"); CHECK(d.byCrc(0xDEADBEEF) == nullptr); CHECK(d.byCrc(0) == nullptr); // file-name matching: case, punctuation and a trailing "(Track N)" do not matter CHECK(d.byName("baldies (usa) (rev 1)") != nullptr); CHECK(d.byName("Baldies (USA) (Rev 1) (Track 01)") != nullptr); CHECK(d.byName("Baldies_USA_Rev_1") != nullptr); CHECK(d.byName("Baldies (USA)") == nullptr); // a different pressing is not a match CHECK(d.byName("") == nullptr); lib::Dat bad; CHECK(!bad.parse("this is not a dat")); CHECK(!bad.parse("game ( name \"x\" ")); // unterminated lib::Dat empty; CHECK(!empty.parse("")); } static void testHeaderDetection() { const size_t MiB = 1048576; std::vector v; v.assign(MiB + 512, 0); // copier dump: N MiB + 512, marker at file $600 putBE32(v, 512 + 0x400, 0x04040404); CHECK(lib::cartHeaderSize(v.data(), v.size()) == 512); v.assign(MiB, 0); // exact megabyte: never stripped, even with the marker at $600 putBE32(v, 512 + 0x400, 0x04040404); CHECK(lib::cartHeaderSize(v.data(), v.size()) == 0); v.assign(512 + 0x2000, 0); // sub-1MiB homebrew with a copier header putBE32(v, 512 + 0x400, 0x04040404); CHECK(lib::cartHeaderSize(v.data(), v.size()) == 512); v.assign(512 + 0x2000, 0); // sub-1MiB native cart: marker already at $400, left alone putBE32(v, 0x400, 0x04040404); putBE32(v, 512 + 0x400, 0x04040404); CHECK(lib::cartHeaderSize(v.data(), v.size()) == 0); v.assign(0x300, 0); // too small to say CHECK(lib::cartHeaderSize(v.data(), v.size()) == 0); } static void testThumbnailNames() { // libretro replaces & * / : ` " < > ? \ | with '_' CHECK_EQ(lib::sanitizeThumbName("Ecco: The Tides of Time"), "Ecco_ The Tides of Time"); CHECK_EQ(lib::sanitizeThumbName("Demolition Man & Commander Blood - Lost Video Footage for Jaguar 64bit (USA)"), "Demolition Man _ Commander Blood - Lost Video Footage for Jaguar 64bit (USA)"); CHECK_EQ(lib::sanitizeThumbName("a&b*c/d:e`f\"gi?j\\k|l"), "a_b_c_d_e_f_g_h_i_j_k_l"); // names that exist upstream (Named_Boxarts/) are left alone, apostrophe and parentheses included CHECK_EQ(lib::sanitizeThumbName("Iron Soldier (World)"), "Iron Soldier (World)"); CHECK_EQ(lib::sanitizeThumbName("Arena Football '95 (World)"), "Arena Football '95 (World)"); CHECK_EQ(lib::sanitizeThumbName("Breakout 2000 (USA, Europe)"), "Breakout 2000 (USA, Europe)"); CHECK_EQ(lib::urlEncode("Atari - Jaguar"), "Atari%20-%20Jaguar"); CHECK_EQ(lib::thumbnailUrl("Named_Boxarts", "Iron Soldier (World)"), "https://thumbnails.libretro.com/Atari%20-%20Jaguar/Named_Boxarts/Iron%20Soldier%20%28World%29.png"); CHECK_EQ(lib::thumbnailUrl("Named_Snaps", "Arena Football '95 (World)"), "https://thumbnails.libretro.com/Atari%20-%20Jaguar/Named_Snaps/Arena%20Football%20%2795%20%28World%29.png"); CHECK_EQ(lib::thumbnailUrl("Named_Boxarts", "A: B", "http://127.0.0.1:1/"), "http://127.0.0.1:1/Atari%20-%20Jaguar/Named_Boxarts/A_%20B.png"); // cache layout: thumbnails///.png, miss marker beside it CHECK_EQ(lib::thumbnailCachePath("/data/", "Named_Boxarts", "Ecco: The Tides"), "/data/thumbnails/Atari - Jaguar/Named_Boxarts/Ecco_ The Tides.png"); CHECK_EQ(lib::thumbnailMissPath("/data/", "Named_Titles", "Iron Soldier (World)"), "/data/thumbnails/Atari - Jaguar/Named_Titles/Iron Soldier (World).png.miss"); CHECK(std::strcmp(lib::kThumbTypes[0], "Named_Boxarts") == 0); // boxart is tried first // the site mirror serves the same layout under "Atari - Jaguar CD" CHECK_EQ(lib::thumbnailUrl("Named_Boxarts", "Myst (USA)", lib::kMirrorBase, lib::kThumbSystemCd), "https://jaguar.provenance-emu.com/thumbnails/Atari%20-%20Jaguar%20CD/Named_Boxarts/Myst%20%28USA%29.png"); CHECK_EQ(lib::thumbnailCachePath("/data/", "Named_Titles", "Myst (USA)", lib::kThumbSystemCd), "/data/thumbnails/Atari - Jaguar CD/Named_Titles/Myst (USA).png"); // the mirror has its own cache folder CHECK_EQ(lib::thumbnailMissPath("/data/", "Named_Titles", "Myst (USA)", lib::kThumbSystemCd), "/data/thumbnails/Atari - Jaguar CD/Named_Titles/Myst (USA).png.miss"); // sources: libretro-thumbnails always first; the mirror only for discs and only when set std::vector s = lib::artSources(lib::Section::Cart, "http://up/", lib::kMirrorBase); CHECK(s.size() == 1 && s[0].base == "http://up/" && std::strcmp(s[0].system, "Atari - Jaguar") == 0); CHECK(s[0].missRetrySeconds == lib::kMissRetrySeconds); s = lib::artSources(lib::Section::Disc, "http://up/", "http://mirror/thumbs"); // trailing slash added CHECK(s.size() == 2); if (s.size() == 2) { CHECK(s[0].base == "http://up/" && std::strcmp(s[0].system, "Atari - Jaguar") == 0); CHECK(s[1].base == "http://mirror/thumbs/" && std::strcmp(s[1].system, "Atari - Jaguar CD") == 0); CHECK(std::string(s[0].system) != std::string(s[1].system)); // cache and misses are kept per source CHECK(s[1].sessionMisses && !s[0].sessionMisses); // the mirror's misses last one session (#997) } CHECK(lib::artSources(lib::Section::Disc, "http://up/", "").size() == 1); // mirror off CHECK(lib::artSources(lib::Section::Bios, "http://up/", lib::kMirrorBase).size() == 1); } static void testBiosClassification() { // ---- by checksum: the images the core itself knows (biosdb.c, CD_BIOS_CRC_*, Memory Track) ---- struct { uint32_t crc; lib::BiosRole role; const char* title; } known[] = { {0xFB731AAAu, lib::BiosRole::CartBoot, "Series K boot ROM"}, {0xAE25BDF5u, lib::BiosRole::CartBoot, "Model M boot ROM"}, {0xE60277BBu, lib::BiosRole::CartBoot, "Stubulator '93 dev boot ROM"}, {0x8D15DBC6u, lib::BiosRole::CartBoot, "Stubulator '94 dev boot ROM"}, {0x687068D5u, lib::BiosRole::CdBios, "Jaguar CD BIOS (retail)"}, {0x55A0669Cu, lib::BiosRole::CdBios, "Jaguar CD BIOS (developer)"}, {0xFDF37F47u, lib::BiosRole::MemoryTrack, "Memory Track cartridge"}, }; for (const auto& k : known) { // the file name must not matter: a recognized image is recognized under any name, at any size lib::BiosInfo b = lib::classifyBios("/x/anything_at_all.j64", 131072, k.crc); CHECK(b.role == k.role && b.recognized && b.title == k.title); } CHECK(lib::classifyBios("/x/renamed.j64", 1 << 20, 0xFB731AAAu).recognized); // size cap is for names only // ---- by name: unknown checksum, BIOS-ish name ---- CHECK(lib::classifyBios("/s/jagboot.rom", 131072, 0x12345678).role == lib::BiosRole::CartBoot); CHECK(lib::classifyBios("/s/jagboot_m.rom", 131072, 0x12345678).role == lib::BiosRole::CartBoot); CHECK(lib::classifyBios("/s/boot0.rom", 131072, 1).role == lib::BiosRole::CartBoot); CHECK(lib::classifyBios("/s/jagcd_bios.bin", 262144, 1).role == lib::BiosRole::CdBios); CHECK(lib::classifyBios("/s/Jaguar CD BIOS.rom", 262144, 1).role == lib::BiosRole::CdBios); lib::BiosInfo tagged = lib::classifyBios("/s/[BIOS] Atari Jaguar CD (World).j64", 262144, 1); CHECK(tagged.role == lib::BiosRole::CdBios && !tagged.recognized && tagged.title == "Atari Jaguar CD (World)"); CHECK(lib::classifyBios("/s/[BIOS] Atari Jaguar Stubulator '94 (World).j64", 131072, 1).role == lib::BiosRole::CartBoot); CHECK(lib::classifyBios("/s/[BIOS] Atari Jaguar (World).j64", 131072, 1).role == lib::BiosRole::CartBoot); CHECK(lib::classifyBios("/s/My Jaguar Boot BIOS v2.bin", 131072, 1).role == lib::BiosRole::CartBoot); // unknown but BIOS-named CHECK(lib::classifyBios("/s/something bios.bin", 100000, 1).role == lib::BiosRole::Other); CHECK(lib::classifyBios("/s/Memory Track (World).j64", 131072, 1).role == lib::BiosRole::MemoryTrack); CHECK(lib::classifyBios("/s/memtrack.bin", 131072, 1).role == lib::BiosRole::MemoryTrack); // games are not BIOS, whatever the extension; and a BIOS-looking name on a game-sized file is a game CHECK(lib::classifyBios("/g/Iron Soldier (World).j64", 2 << 20, 0x99999999).role == lib::BiosRole::None); CHECK(lib::classifyBios("/g/Bios Fear.j64", 2 << 20, 0x99999999).role == lib::BiosRole::None); CHECK(lib::classifyBios("/g/boot.rom", 4 << 20, 0x99999999).role == lib::BiosRole::None); CHECK(lib::classifyBios("/g/Tempest 2000 (World).j64", 2 << 20, 1).role == lib::BiosRole::None); CHECK(lib::classifyBios("/g/Rebooted.j64", 100000, 1).role == lib::BiosRole::None); // ---- will the core read it from where it is? (name + folder + size, from libretro.c; never the CRC) ---- const uint64_t K128 = 0x20000, K256 = 0x40000; lib::BiosInfo cart = lib::classifyBios("/sys/jagboot.rom", K128, 1); CHECK(lib::biosUse(cart, "/sys/jagboot.rom", K128, "/sys/").core); CHECK(lib::biosUse(cart, "/sys/jaguar/jagboot.rom", K128, "/sys").core); CHECK(lib::biosUse(cart, "C:\\sys\\Atari - Jaguar\\jagboot.rom", K128, "C:\\sys\\").core); CHECK(lib::biosUse(cart, "/sys/jagboot.rom", K128, "/sys").text.find("Custom") != std::string::npos); CHECK(!lib::biosUse(cart, "/sys/jaguarcd/jagboot.rom", K128, "/sys").core); // not a cart-boot sub-folder CHECK(!lib::biosUse(cart, "/downloads/jagboot.rom", K128, "/sys").core); // wrong folder CHECK(!lib::biosUse(cart, "/sys/deep/er/jagboot.rom", K128, "/sys").core); // the core does not recurse CHECK(!lib::biosUse(cart, "/sys/jagboot.rom", K128, "").core); // no system folder known CHECK(!lib::biosUse(cart, "/sys/jagboot.rom", 0x30000, "/sys").core); // right place, wrong size: skipped CHECK(lib::biosUse(cart, "/sys/jagboot.rom", 0x30000, "/sys").text.find("131072") != std::string::npos); // jagboot_m.rom: the system folder itself only, 128 KiB, and not by the Custom search lib::BiosInfo mm = lib::classifyBios("/sys/jagboot_m.rom", K128, 1); CHECK(lib::biosUse(mm, "/sys/jagboot_m.rom", K128, "/sys").core); CHECK(lib::biosUse(mm, "/sys/jagboot_m.rom", K128, "/sys").text.find("Model M") != std::string::npos); CHECK(!lib::biosUse(mm, "/sys/jaguar/jagboot_m.rom", K128, "/sys").core); CHECK(!lib::biosUse(mm, "/sys/Atari - Jaguar/jagboot_m.rom", K128, "/sys").core); CHECK(!lib::biosUse(mm, "/sys/jagboot_m.rom", K256, "/sys").core); // a Series K dump under a name the core does not search for is not read; named jagboot.rom it is only the Custom path lib::BiosInfo k = lib::classifyBios("/sys/series_k.bin", K128, 0xFB731AAAu); CHECK(k.recognized && !lib::biosUse(k, "/sys/series_k.bin", K128, "/sys").core); lib::BiosInfo kj = lib::classifyBios("/sys/jagboot.rom", K128, 0xFB731AAAu); lib::BiosUse kju = lib::biosUse(kj, "/sys/jagboot.rom", K128, "/sys"); CHECK(kju.core && kju.text.find("Custom") != std::string::npos && kju.text.find("built-in Series K") != std::string::npos); // CD BIOS: its own sub-folders, exactly 256 KiB lib::BiosInfo cdb = lib::classifyBios("/sys/jaguarcd/jagcd.bin", K256, 0x687068D5u); CHECK(lib::biosUse(cdb, "/sys/jaguarcd/jagcd.bin", K256, "/sys").core); CHECK(lib::biosUse(cdb, "/sys/Atari - Jaguar CD/jagcd.bin", K256, "/sys").core); CHECK(lib::biosUse(cdb, "/sys/jagcd.bin", K256, "/sys").text.find("CD Boot Mode") != std::string::npos); CHECK(!lib::biosUse(cdb, "/sys/jagcd.bin", 0x30000, "/sys").core); // 192 KiB: rejected by the core CHECK(lib::biosUse(cdb, "/sys/jagcd.bin", 0x30000, "/sys").text.find("262144") != std::string::npos); CHECK(!lib::biosUse(cdb, "/sys/jagcd2.bin", K256, "/sys").core); // the core never opens that name CHECK(!lib::biosUse(cdb, "/downloads/jagcd.bin", K256, "/sys").core); lib::BiosInfo mt = lib::classifyBios("/sys/memtrack.bin", K128, 1); CHECK(!lib::biosUse(mt, "/sys/memtrack.bin", K128, "/sys").core); // ---- scanning and identify: sections ---- const size_t K = 1024; std::string dir = g_tmp + "bios/lib/", sys = g_tmp + "bios/system/"; std::vector mt1 = noise(1024 * 1024, 11); writeBytes(dir + "Game One (World).j64", mt1); writeBytes(dir + "bioses/jagboot.rom", noise(128 * K, 12)); writeBytes(dir + "bioses/[BIOS] Atari Jaguar CD (World).j64", noise(256 * K, 13)); writeBytes(dir + "bioses/Memory Track.j64", noise(128 * K, 14)); std::vector weird = noise(128 * K, 15); writeBytes(dir + "bioses/blob.bin", weird); // only the dat says what this is writeBytes(dir + "Disc (USA) (Track 2).bin", noise(2352 * 30, 16)); // a loose Redump track, no cue sheet writeBytes(dir + "Disc (USA) (Track 02).BIN", noise(2352 * 30, 17)); writeText(dir + "Real Disc (USA).cue", "FILE \"Real Disc (USA) (Track 1).bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); writeBytes(dir + "Real Disc (USA) (Track 1).bin", noise(2352 * 30, 18)); // the core's system folder: BIOS files count, everything else there is not library content writeBytes(sys + "jagcd.bin", noise(256 * K, 19)); writeBytes(sys + "jaguar/jagboot.rom", noise(128 * K, 20)); writeBytes(sys + "stray game.j64", noise(1048576, 21)); writeBytes(sys + "big.rom", noise(1048576, 22)); writeText(sys + "notes.txt", "x"); lib::ScanOptions opt; opt.folders = {dir}; opt.systemFolder = sys; std::vector files = lib::scanFolders(opt, lib::ScanCache(), nullptr, nullptr); lib::Dat carts; CHECK(carts.parse(datText("1", datGame("[BIOS] Atari Jaguar Weird Boot (World)", "weird.bin", crcOf(weird)) + datGame("Game One (World)", "g.j64", crcOf(mt1))))); std::map by; for (const lib::ScanFile& f : files) by[fs::path(f.path).filename().string()] = lib::identify(f, &carts, nullptr); CHECK(by.count("Game One (World).j64") && by["Game One (World).j64"].section == lib::Section::Cart); CHECK(by["Game One (World).j64"].title == "Game One (World)"); CHECK(by.count("Real Disc (USA).cue") && by["Real Disc (USA).cue"].section == lib::Section::Disc); CHECK(by["jagboot.rom"].section == lib::Section::Bios && by["jagboot.rom"].bios.role == lib::BiosRole::CartBoot); CHECK(by["[BIOS] Atari Jaguar CD (World).j64"].section == lib::Section::Bios); CHECK(by["[BIOS] Atari Jaguar CD (World).j64"].bios.role == lib::BiosRole::CdBios); CHECK(by["Memory Track.j64"].section == lib::Section::Bios && by["Memory Track.j64"].bios.role == lib::BiosRole::MemoryTrack); CHECK(by["jagcd.bin"].section == lib::Section::Bios && by["jagcd.bin"].bios.role == lib::BiosRole::CdBios); // the dat tags it "[BIOS]": it leaves the game grid even though its file name says nothing CHECK(by["blob.bin"].section == lib::Section::Bios && by["blob.bin"].bios.role == lib::BiosRole::CartBoot); CHECK(by["blob.bin"].title == "Atari Jaguar Weird Boot (World)"); // disc tracks and non-BIOS files from the system folder never show CHECK(!by.count("Real Disc (USA) (Track 1).bin")); CHECK(!by.count("Disc (USA) (Track 2).bin") && !by.count("Disc (USA) (Track 02).BIN")); CHECK(!by.count("stray game.j64") && !by.count("big.rom") && !by.count("notes.txt")); int bios = 0, games = 0; for (const auto& kv : by) (kv.second.section == lib::Section::Bios ? bios : games)++; CHECK(bios == 5); // jagboot.rom (two copies share a key), CD BIOS, Memory Track, jagcd.bin, blob.bin CHECK(games == 2); } static void testScanAndIdentify() { const size_t MiB = 1048576; std::string dir = g_tmp + "scan/"; // carts std::vector payload = noise(MiB, 1); putBE32(payload, 0x400, 0x04040404); std::vector headered(512, 0); headered.insert(headered.end(), payload.begin(), payload.end()); // N MiB + 512 copier dump std::vector other = noise(MiB, 2); std::vector small = noise(200 * 1024, 3); std::vector nested = noise(2 * MiB, 4); writeBytes(dir + "good.j64", payload); writeBytes(dir + "headered.j64", headered); writeBytes(dir + "Unlisted Game (World).rom", other); writeBytes(dir + "small.bin", small); writeBytes(dir + "deep/er/nested.JAG", nested); // not Jaguar content, or not content the core can read writeText(dir + "readme.txt", "hello"); writeBytes(dir + "archive.zip", noise(1000, 5)); writeBytes(dir + "huge-track.bin", std::vector(9 * MiB, 0)); // a disc track, not a cart // a CUE set: Track 1 (audio) and Track 2, plus a CUE whose bin is gone, plus a CHD std::vector t1 = noise(2352 * 40, 6), t2 = noise(2352 * 20, 7); writeBytes(dir + "Test Disc (USA) (Track 1).bin", t1); writeBytes(dir + "Test Disc (USA) (Track 2).bin", t2); writeText(dir + "Test Disc (USA).cue", "FILE \"test disc (usa) (track 1).BIN\" BINARY\r\n TRACK 01 AUDIO\r\n INDEX 01 00:00:00\r\n" "FILE \"Test Disc (USA) (Track 2).bin\" BINARY\r\n TRACK 02 MODE1/2352\r\n INDEX 01 00:00:00\r\n"); writeText(dir + "Ghost (USA).cue", "FILE \"Ghost (USA) (Track 1).bin\" BINARY\n TRACK 01 AUDIO\n INDEX 01 00:00:00\n"); writeBytes(dir + "Some Game (USA).chd", noise(4096, 8)); lib::ScanOptions opt; opt.folders = {dir, dir}; // the same folder twice must not duplicate anything size_t hashedFirst = 0; std::vector files = lib::scanFolders(opt, lib::ScanCache(), nullptr, [&](size_t d, size_t t) { hashedFirst = t; (void)d; }); int carts = 0, discs = 0; bool sawTrack = false, sawHuge = false, sawZip = false, sawTxt = false; for (const lib::ScanFile& f : files) { (f.kind == lib::Kind::Cart ? carts : discs)++; if (f.path.find("(Track") != std::string::npos && f.path.find(".bin") != std::string::npos) sawTrack = true; if (f.path.find("huge-track") != std::string::npos) sawHuge = true; if (f.path.find(".zip") != std::string::npos) sawZip = true; if (f.path.find(".txt") != std::string::npos) sawTxt = true; } CHECK(carts == 5); // good, headered, Unlisted, small.bin, nested.JAG CHECK(discs == 3); // Test Disc cue, Ghost cue, chd CHECK(!sawTrack && !sawHuge && !sawZip && !sawTxt); CHECK(hashedFirst == 8); // identification uint32_t payloadCrc = crcOf(payload), t1Crc = crcOf(t1); lib::Dat carts_dat, disc_dat; CHECK(carts_dat.parse(datText("1", datGame("Good Game (World)", "Good Game (World).j64", payloadCrc, "\tregion \"World\"\n") + datGame("Nested Game (World)", "n.jag", crcOf(nested))))); CHECK(disc_dat.parse(datText("1", datGame("Test Disc (USA)", "Test Disc (USA) (Track 1).bin", t1Crc) + datGame("Some Game (USA)", "Some Game (USA) (Track 1).bin", 0x11111111) + datGame("Ghost (USA)", "Ghost (USA) (Track 1).bin", 0x22222222)))); std::map by; for (const lib::ScanFile& f : files) { lib::Entry e = lib::identify(f, &carts_dat, &disc_dat); by[fs::path(f.path).filename().string()] = e; } // whole-file CRC CHECK(by["good.j64"].match == lib::Match::Crc && by["good.j64"].title == "Good Game (World)" && by["good.j64"].region == "World"); // the copier-headered dump matches on its payload CRC CHECK(by["headered.j64"].file.crcStripped == payloadCrc); CHECK(by["headered.j64"].match == lib::Match::Crc && by["headered.j64"].datName == "Good Game (World)"); CHECK(by["nested.JAG"].match == lib::Match::Crc && by["nested.JAG"].datName == "Nested Game (World)"); // unknown: titled from the file name, never given art CHECK(by["Unlisted Game (World).rom"].match == lib::Match::Unknown && by["Unlisted Game (World).rom"].datName.empty() && by["Unlisted Game (World).rom"].title == "Unlisted Game (World)"); // disc: the CRC of the cue's first FILE (found case-insensitively) is the Redump track CRC CHECK(by["Test Disc (USA).cue"].file.crcWhole == t1Crc); CHECK(by["Test Disc (USA).cue"].match == lib::Match::Crc && by["Test Disc (USA).cue"].title == "Test Disc (USA)"); // no checksum available: file name against the dat, flagged as a name match only CHECK(by["Ghost (USA).cue"].match == lib::Match::Name && by["Ghost (USA).cue"].datName == "Ghost (USA)"); CHECK(by["Some Game (USA).chd"].match == lib::Match::Name); // a disc never matches the cartridge dat, nor the reverse lib::Entry crossed = lib::identify(by["good.j64"].file, nullptr, &disc_dat); CHECK(crossed.match == lib::Match::Unknown); // no dat at all: still a usable list lib::Entry nodat = lib::identify(by["good.j64"].file, nullptr, nullptr); CHECK(nodat.match == lib::Match::Unknown && nodat.title == "good"); // the on-disk cache round-trips, and a rescan then hashes nothing lib::ScanCache cache = lib::parseCache(lib::serializeCache(files)); CHECK(cache.size() == files.size()); size_t hashedAgain = 99; std::vector again = lib::scanFolders(opt, cache, nullptr, [&](size_t, size_t t) { hashedAgain = t; }); CHECK(again.size() == files.size()); CHECK(hashedAgain == 99); // never called: nothing needed hashing for (size_t i = 0; i < again.size() && i < files.size(); i++) CHECK(again[i].crcWhole == files[i].crcWhole); // a changed file is re-hashed (and only that one) std::this_thread::sleep_for(std::chrono::milliseconds(1100)); // mtime has 1 s resolution in the cache writeBytes(dir + "good.j64", noise(MiB, 99)); size_t hashedThree = 0; again = lib::scanFolders(opt, cache, nullptr, [&](size_t, size_t t) { hashedThree = t; }); CHECK(hashedThree == 1); // cancel std::atomic cancel(true); std::vector none = lib::scanFolders(opt, lib::ScanCache(), &cancel, nullptr); CHECK(none.size() < files.size()); // a corrupt cache is ignored CHECK(lib::parseCache("garbage\nnot\ttabs").empty()); } // A library folder may hold BIOS files in any layout: nested and with upper-case names. They belong // under BIOS & system (never with the games), wherever they sit under the folder, and the app can put them where // the core reads them. static void testBiosInLibraryFolders() { const size_t K = 1024; std::string root = g_tmp + "biosroot/", lib1 = root + "lib/", sys = root + "system/"; std::error_code ec; // images the core knows only by checksum: injected, since a real BIOS cannot be committed std::vector bootImg = noise(128 * K, 41), cdImg = noise(256 * K, 42), devImg = noise(256 * K, 43), mImg = noise(128 * K, 51), kImg = noise(128 * K, 52); lib::addKnownBiosForTest(crcOf(bootImg), lib::BiosRole::CartBoot, "Test boot ROM"); lib::addKnownBiosForTest(crcOf(cdImg), lib::BiosRole::CdBios, "Test CD BIOS (retail)"); lib::addKnownBiosForTest(crcOf(devImg), lib::BiosRole::CdBios, "Test CD BIOS (developer)"); lib::addKnownBiosForTest(crcOf(mImg), lib::BiosRole::CartBoot, "Test Model M boot ROM"); writeBytes(lib1 + "Atari - Jaguar/BIOS/Boot ROMs/Rev B/dump-4711.bin", bootImg); // nested, checksum only writeBytes(lib1 + "a/b/c/d/e/f/deep.rom", cdImg); // 6 folders down, checksum only writeBytes(lib1 + "Sets/JAGCD.BIN", noise(256 * K, 44)); // upper-case names writeBytes(lib1 + "Sets/JAGBOOT.ROM", noise(128 * K, 45)); writeBytes(lib1 + "Sets/Jaguar CD BIOS.ROM", noise(256 * K, 46)); writeBytes(lib1 + "Sets/some data.bin", noise(128 * K, 47)); // unknown, not BIOS-named: a game writeBytes(lib1 + "Game (World).j64", noise(1024 * K, 48)); writeBytes(lib1 + "Zipped/[BIOS] Atari Jaguar CD (World).zip", noise(100 * K, 49)); // archives are never read lib::ScanOptions opt; opt.folders = {lib1, lib1 + "Sets"}; // a folder inside another chosen one: nothing doubles opt.systemFolder = sys; std::vector files = lib::scanFolders(opt, lib::ScanCache(), nullptr, nullptr); std::map by; int total = 0; for (const lib::ScanFile& f : files) { std::string name = fs::path(f.path).filename().string(); by[name] = lib::identify(f, nullptr, nullptr); total++; } auto isBios = [&](const char* n, lib::BiosRole role) { auto it = by.find(n); return it != by.end() && it->second.section == lib::Section::Bios && it->second.bios.role == role; }; CHECK(isBios("dump-4711.bin", lib::BiosRole::CartBoot)); CHECK(by.count("dump-4711.bin") && by["dump-4711.bin"].bios.recognized); CHECK(isBios("deep.rom", lib::BiosRole::CdBios)); CHECK(isBios("JAGCD.BIN", lib::BiosRole::CdBios)); CHECK(isBios("JAGBOOT.ROM", lib::BiosRole::CartBoot)); CHECK(isBios("Jaguar CD BIOS.ROM", lib::BiosRole::CdBios)); CHECK(by.count("Game (World).j64") && by["Game (World).j64"].section == lib::Section::Cart); CHECK(by.count("some data.bin") && by["some data.bin"].section == lib::Section::Cart); // not mistaken for a BIOS CHECK(!by.count("[BIOS] Atari Jaguar CD (World).zip")); CHECK(total == 7); // 5 BIOS + 2 games; nothing listed twice for (const auto& kv : by) if (kv.second.section == lib::Section::Cart) CHECK(kv.first == "Game (World).j64" || kv.first == "some data.bin"); // ---- what the app copies into the system folder, and what the core then does with it ---- const uint64_t S128 = 128 * K, S256 = 256 * K; struct { std::vector* img; const char* name; const char* want; } plans[] = { {&bootImg, "dump-4711.bin", "jagboot.rom"}, {&mImg, "m.bin", "jagboot_m.rom"}, {&cdImg, "r.bin", "[BIOS] Atari Jaguar CD (World).bin"}, {&devImg, "d.bin", "[BIOS] Atari Jaguar Developer CD (World).bin"}, }; for (const auto& pl : plans) { lib::BiosInfo bi = lib::classifyBios("/lib/" + std::string(pl.name), pl.img->size(), crcOf(*pl.img)); std::string name = lib::biosInstallName(bi, pl.img->size()); CHECK_EQ(name, pl.want); // installed under that name, the core reads it (name, folder and size are all it looks at) lib::BiosInfo inst = lib::classifyBios(sys + name, pl.img->size(), crcOf(*pl.img)); CHECK(lib::biosUse(inst, sys + name, pl.img->size(), sys).core); } lib::BiosInfo unk = lib::classifyBios("/lib/My CD BIOS.bin", S256, 1); CHECK_EQ(lib::biosInstallName(unk, S256), "jagcd.bin"); // unrecognized: the generic name CHECK_EQ(lib::biosInstallName(unk, 0x30000), ""); // a size the core rejects: copying would not help lib::BiosInfo mt = lib::classifyBios("/lib/Memory Track.j64", S128, 1); CHECK_EQ(lib::biosInstallName(mt, S128), ""); // data, not read lib::BiosInfo realK; realK.role = lib::BiosRole::CartBoot; realK.recognized = true; realK.title = "Series K boot ROM"; CHECK_EQ(lib::biosInstallName(realK, S128), ""); // built into the core, never read from a file // the service does the copy: into the system folder, once, never over a file that is there LibraryService svc; fs::create_directories(fs::u8path(sys), ec); svc.systemDir = sys; lib::Entry src = by["dump-4711.bin"]; CHECK(svc.installBios(src)); std::vector got; { std::ifstream f(sys + "jagboot.rom", std::ios::binary); got.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); } CHECK(got == bootImg); CHECK(!svc.installBios(src)); // second time: already there CHECK(svc.status().find("already") != std::string::npos); CHECK(!svc.installBios(by["Game (World).j64"])); // not a BIOS } // ---- tile zoom (pinch, Ctrl/Cmd+wheel, Ctrl/Cmd +/-/0 share this math) ------------------------------------------ static void testTileZoom() { const float eps = 1e-4f; auto near = [&](float a, float b) { return std::fabs(a - b) < eps; }; CHECK(lib::kTileScaleMin < lib::kTileScaleDefault && lib::kTileScaleDefault < lib::kTileScaleMax); CHECK(near(lib::kTileScaleDefault, 1.0f)); // the shipped default stays 1.0x // clamp CHECK(near(lib::clampTileScale(1.0f), 1.0f)); CHECK(near(lib::clampTileScale(-3.0f), lib::kTileScaleMin)); CHECK(near(lib::clampTileScale(0.0f), lib::kTileScaleMin)); CHECK(near(lib::clampTileScale(99.0f), lib::kTileScaleMax)); CHECK(near(lib::clampTileScale(std::nanf("")), lib::kTileScaleDefault)); CHECK(near(lib::clampTileScale(INFINITY), lib::kTileScaleDefault)); // steps are multiplicative and reversible CHECK(near(lib::zoomTileScale(1.0f, 1.0f), lib::kTileScaleStep)); CHECK(near(lib::zoomTileScale(lib::zoomTileScale(1.0f, 3.0f), -3.0f), 1.0f)); CHECK(near(lib::zoomTileScale(1.0f, 0.5f), std::sqrt(lib::kTileScaleStep))); // a fractional trackpad delta CHECK(near(lib::zoomTileScale(1.0f, 0.0f), 1.0f)); // ... and stop at the ends, however far the wheel goes CHECK(near(lib::zoomTileScale(1.0f, 1000.0f), lib::kTileScaleMax)); CHECK(near(lib::zoomTileScale(1.0f, -1000.0f), lib::kTileScaleMin)); CHECK(near(lib::zoomTileScale(lib::kTileScaleMax, 1.0f), lib::kTileScaleMax)); CHECK(near(lib::zoomTileScale(lib::kTileScaleMin, -1.0f), lib::kTileScaleMin)); CHECK(near(lib::zoomTileScale(1.0f, std::nanf("")), 1.0f)); // a pinch factor is applied as is (>1 zooms in), junk is ignored, a glitch cannot jump CHECK(near(lib::pinchTileScale(1.0f, 1.25f), 1.25f)); CHECK(near(lib::pinchTileScale(1.0f, 0.8f), 0.8f)); CHECK(near(lib::pinchTileScale(1.0f, 1.0f), 1.0f)); CHECK(near(lib::pinchTileScale(1.0f, 0.0f), 1.0f)); CHECK(near(lib::pinchTileScale(1.0f, -2.0f), 1.0f)); CHECK(near(lib::pinchTileScale(1.0f, std::nanf("")), 1.0f)); CHECK(near(lib::pinchTileScale(1.0f, 50.0f), 2.0f)); CHECK(near(lib::pinchTileScale(2.0f, 1.5f), lib::kTileScaleMax)); // the config value is clamped when read and written back after a zoom std::string root = g_tmp + "zoom-root/"; Paths paths; paths.root = root; Config cfg; cfg.setPath(root + "config.ini"); cfg.setFloat("library.size", 9.0f); // hand-edited nonsense { LibraryService svc; svc.start(paths, cfg, false); CHECK(near(svc.cellScale, lib::kTileScaleMax)); svc.cellScale = lib::pinchTileScale(svc.cellScale, 0.5f); svc.zoomChanged(); svc.stop(); // the pending zoom is written on the way out } CHECK(near(cfg.getFloat("library.size", 0.0f), lib::kTileScaleMax * 0.5f)); { Config again; again.setPath(root + "config.ini"); CHECK(again.load(root + "config.ini")); LibraryService svc; svc.start(paths, again, false); CHECK(near(svc.cellScale, lib::kTileScaleMax * 0.5f)); // survives a restart svc.stop(); } } // ---- loopback HTTP server (POSIX) ------------------------------------------------------------------------------ #ifndef _WIN32 struct Reply { int status = 200; std::string location; std::vector body; }; class TestServer { public: std::function handler; int port = 0; bool start() { fd_ = ::socket(AF_INET, SOCK_STREAM, 0); if (fd_ < 0) return false; int one = 1; ::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); sockaddr_in a; std::memset(&a, 0, sizeof a); a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); a.sin_port = 0; // the OS picks a free port if (::bind(fd_, (sockaddr*)&a, sizeof a) != 0 || ::listen(fd_, 16) != 0) return false; socklen_t len = sizeof a; ::getsockname(fd_, (sockaddr*)&a, &len); port = ntohs(a.sin_port); running_ = true; th_ = std::thread([this] { loop(); }); return true; } void stop() { running_ = false; if (th_.joinable()) th_.join(); if (fd_ >= 0) ::close(fd_); fd_ = -1; } ~TestServer() { stop(); } int hits(const std::string& path) { std::lock_guard g(m_); return hitCount_[path]; } int totalHits() { std::lock_guard g(m_); int n = 0; for (auto& kv : hitCount_) n += kv.second; return n; } std::string url(const std::string& path = "") const { return "http://127.0.0.1:" + std::to_string(port) + "/" + path; } private: void loop() { while (running_) { pollfd p = {fd_, POLLIN, 0}; if (::poll(&p, 1, 50) <= 0) continue; int c = ::accept(fd_, nullptr, nullptr); if (c < 0) continue; handle(c); ::close(c); } } void handle(int c) { std::string req; char buf[2048]; while (req.find("\r\n\r\n") == std::string::npos && req.size() < 16384) { pollfd p = {c, POLLIN, 0}; if (::poll(&p, 1, 2000) <= 0) return; ssize_t n = ::recv(c, buf, sizeof buf, 0); if (n <= 0) return; req.append(buf, (size_t)n); } size_t sp1 = req.find(' '), sp2 = req.find(' ', sp1 + 1); std::string path = req.substr(sp1 + 1, sp2 - sp1 - 1); { std::lock_guard g(m_); hitCount_[path]++; } Reply r = handler ? handler(path) : Reply{404, "", {}}; std::string head = "HTTP/1.1 " + std::to_string(r.status) + " X\r\nContent-Length: " + std::to_string(r.body.size()) + "\r\nConnection: close\r\n"; if (!r.location.empty()) head += "Location: " + r.location + "\r\n"; head += "\r\n"; send(c, head.data(), head.size()); send(c, (const char*)r.body.data(), r.body.size()); } static void send(int c, const char* d, size_t n) { while (n > 0) { ssize_t w = ::send(c, d, n, 0); if (w <= 0) return; d += w; n -= (size_t)w; } } int fd_ = -1; std::atomic running_{false}; std::thread th_; std::mutex m_; std::map hitCount_; }; static std::vector makePng(int w, int h, unsigned char shade) { SDL_Surface* s = SDL_CreateSurface(w, h, SDL_PIXELFORMAT_RGBA32); SDL_FillSurfaceRect(s, nullptr, SDL_MapSurfaceRGBA(s, shade, 100, 200, 255)); std::string path = g_tmp + "mk-" + std::to_string(w) + "x" + std::to_string(h) + ".png"; SDL_SavePNG(s, path.c_str()); SDL_DestroySurface(s); std::vector out; readFile(path, out); return out; } static void testHttp() { CHECK(http::available()); TestServer srv; CHECK(srv.start()); std::vector big(3 * 1024 * 1024, 'x'); srv.handler = [&](const std::string& p) { Reply r; if (p == "/ok") r.body = {'h', 'e', 'l', 'l', 'o'}; else if (p == "/redir") { r.status = 302; r.location = "/ok"; } else if (p == "/big") r.body = big; else r.status = 404; return r; }; http::Response r; CHECK(http::get(srv.url("ok"), r, 1 << 20, nullptr)); CHECK(r.status == 200 && r.body.size() == 5 && std::memcmp(r.body.data(), "hello", 5) == 0); CHECK(http::get(srv.url("missing"), r, 1 << 20, nullptr)); CHECK(r.status == 404); // a 404 is a response, not a transport error CHECK(http::get(srv.url("redir"), r, 1 << 20, nullptr)); CHECK(r.status == 200 && r.body.size() == 5); // redirects are followed CHECK(!http::get(srv.url("big"), r, 1 << 20, nullptr)); CHECK(r.error.find("large") != std::string::npos); // the size cap stops it CHECK(http::get(srv.url("big"), r, 8u << 20, nullptr) && r.body.size() == big.size()); std::atomic cancel(true); CHECK(!http::get(srv.url("ok"), r, 1 << 20, &cancel) || r.status == 200); // cancel may race a tiny reply; must not hang CHECK(!http::get("file:///etc/hosts", r, 1 << 20, nullptr)); // only http(s) CHECK(!http::get("ftp://127.0.0.1/x", r, 1 << 20, nullptr)); int deadPort = srv.port; srv.stop(); auto t0 = std::chrono::steady_clock::now(); CHECK(!http::get("http://127.0.0.1:" + std::to_string(deadPort) + "/ok", r, 1 << 20, nullptr)); // refused CHECK(!r.error.empty()); CHECK(std::chrono::steady_clock::now() - t0 < std::chrono::seconds(10)); } // The whole art pipeline: scan -> identify -> download -> cache -> decode -> texture, then offline. static void testServicePipeline() { std::string root = g_tmp + "svc-root/"; std::string games = g_tmp + "svc-games/"; std::error_code ec; fs::create_directories(root + "library/db", ec); std::vector romA = noise(1048576, 21), romB = noise(1048576, 22), romC = noise(1048576, 23); putBE32(romA, 0x400, 0x04040404); writeBytes(games + "a.j64", romA); writeBytes(games + "b.j64", romB); writeBytes(games + "c.j64", romC); std::string name = "Pipeline: Test & Co (World)"; // special characters exercise sanitising end to end std::string datA = datText("2099.01.01", datGame(name, "a.j64", crcOf(romA)) + datGame("No Art Game (World)", "b.j64", crcOf(romB)) + datGame("Offline Game (World)", "c.j64", crcOf(romC))); writeText(root + "library/db/Atari - Jaguar.dat", datA); std::vector png = makePng(600, 800, 90); std::string sanitized = lib::sanitizeThumbName(name); std::string artPath = "/" + lib::urlEncode("Atari - Jaguar") + "/Named_Boxarts/" + lib::urlEncode(sanitized) + ".png"; // what "Update database" downloads: newer dats (a rename of game A proves they were re-read) std::string updC = datText("2099.06.01", datGame("Renamed By Update (World)", "a.j64", crcOf(romA)) + datGame("Filler One", "x", 1) + datGame("Filler Two", "x", 2) + datGame("Filler Three", "x", 3) + datGame("Filler Four", "x", 4) + datGame("Filler Five", "x", 5)); std::string updD = datText("2099.06.01", datGame("Disc One", "x", 1) + datGame("Disc Two", "x", 2) + datGame("Disc Three", "x", 3) + datGame("Disc Four", "x", 4) + datGame("Disc Five", "x", 5)); std::vector updCarts(updC.begin(), updC.end()), updDiscs(updD.begin(), updD.end()); TestServer srv; CHECK(srv.start()); srv.handler = [&](const std::string& p) { Reply r; if (p == artPath) r.body = png; else if (p == "/no-intro/" + lib::urlEncode("Atari - Jaguar.dat")) r.body = updCarts; else if (p == "/redump/" + lib::urlEncode("Atari - Jaguar CD.dat")) r.body = updDiscs; else r.status = 404; return r; }; Paths paths; paths.root = root; Config cfg; cfg.setPath(root + "config.ini"); cfg.set("library.dir.0", games.substr(0, games.size() - 1)); struct Fake { int created = 0, destroyed = 0, lastW = 0, lastH = 0; } fake; auto bind = [&](LibraryService& s) { s.createTexture = [&](const void*, int w, int h) { fake.created++; fake.lastW = w; fake.lastH = h; return (unsigned)fake.created; }; s.destroyTexture = [&](unsigned) { fake.destroyed++; }; }; auto waitFor = [&](LibraryService& s, const std::function& cond) { for (int i = 0; i < 400; i++) { // up to ~10 s s.pump(); if (cond()) return true; SDL_Delay(25); } return false; }; auto find = [&](LibraryService& s, const std::string& title) -> const lib::Entry* { for (const lib::Entry& e : s.entries()) if (e.title == title) return &e; return nullptr; }; { LibraryService svc; bind(svc); svc.artBaseUrl = srv.url(); svc.artMirrorUrl.clear(); svc.dbBaseUrl = srv.url(); svc.start(paths, cfg, true); CHECK(svc.show); // a configured library opens at start when no game is loaded CHECK(waitFor(svc, [&] { return !svc.scanning() && svc.entries().size() == 3; })); const lib::Entry* a = find(svc, name); const lib::Entry* b = find(svc, "No Art Game (World)"); CHECK(a && a->match == lib::Match::Crc); CHECK(b && b->match == lib::Match::Crc); CHECK(svc.identified() == 3); // art for A: downloaded, cached, decoded to <= 256 px keeping the 3:4 aspect, uploaded as a texture ArtView v; CHECK(waitFor(svc, [&] { v = svc.art(*a); return v.state == ArtView::Ready; })); CHECK(v.tex != 0 && v.w == 192 && v.h == 256); CHECK(fake.created == 1); CHECK(fs::exists(fs::u8path(lib::thumbnailCachePath(root, "Named_Boxarts", name)))); CHECK(srv.hits(artPath) == 1); // art for B: the server says 404 for all three kinds -> Missing, remembered, not asked again ArtView vb; CHECK(waitFor(svc, [&] { vb = svc.art(*b); return vb.state == ArtView::Missing; })); CHECK(fs::exists(fs::u8path(lib::thumbnailMissPath(root, "Named_Boxarts", "No Art Game (World)")))); int hitsBefore = srv.totalHits(); for (int i = 0; i < 20; i++) { svc.pump(); svc.art(*b); SDL_Delay(10); } CHECK(srv.totalHits() == hitsBefore); // the library hint's count: B only (A has art, C was never drawn), and no discs in this library CHECK(svc.missingArt(lib::Section::Cart) == 1); CHECK(svc.missingArt(lib::Section::Disc) == 0); // an unidentified entry has no thumbnail name: it ends up Missing without ever touching the network lib::Entry unknown = *a; unknown.datName.clear(); unknown.file.crcWhole = 0x0BADF00Du; // its own slot (the tile key is the checksum, not the title) int hitsUnknown = srv.totalHits(); ArtView vu; CHECK(waitFor(svc, [&] { vu = svc.art(unknown); return vu.state == ArtView::Missing; })); CHECK(srv.totalHits() == hitsUnknown); // launching posts the same "open this path" event the file dialog does SDL_FlushEvents(SDL_EVENT_USER, SDL_EVENT_USER); svc.launch(*a); SDL_Event ev; SDL_PumpEvents(); bool got = SDL_PollEvent(&ev) && ev.type == SDL_EVENT_USER && ev.user.code == 0x56414A47 && ev.user.data1 && std::string((const char*)ev.user.data1) == a->file.path; if (got) SDL_free(ev.user.data1); CHECK(got); CHECK(!svc.show); // "close on launch" // ... a plain launch carries no marker; a fresh one (the TV screens: the resume prompt needs a mouse) marks the event // so App::handleEvent opens the game without it (#991). SDL_PumpEvents(); SDL_FlushEvents(SDL_EVENT_USER, SDL_EVENT_USER); svc.launch(*a); svc.launch(*a, /*fresh=*/true); SDL_PumpEvents(); std::vector opens; // the "open this path" events, in order { SDL_Event got[8]; const int n = SDL_PeepEvents(got, 8, SDL_GETEVENT, SDL_EVENT_USER, SDL_EVENT_USER); for (int i = 0; i < n; i++) if (got[i].user.code == 0x56414A47 && got[i].user.data1) opens.push_back(got[i]); } const bool gotPlain = opens.size() == 2, gotFresh = opens.size() == 2; SDL_Event plain = opens.size() == 2 ? opens[0] : SDL_Event(), fresh = opens.size() == 2 ? opens[1] : SDL_Event(); CHECK(gotPlain && gotFresh); if (gotPlain) { CHECK(plain.user.data2 == nullptr); SDL_free(plain.user.data1); } if (gotFresh) { CHECK(fresh.user.data2 != nullptr && std::string((const char*)fresh.user.data1) == a->file.path); SDL_free(fresh.user.data1); } // database update: both dats downloaded, validated, written, re-identified svc.updateDatabase(); CHECK(waitFor(svc, [&] { return svc.status().find("updated") != std::string::npos; })); CHECK(waitFor(svc, [&] { return find(svc, "Renamed By Update (World)") != nullptr; })); CHECK(svc.cartDatInfo().find("2099.06.01") != std::string::npos); svc.stop(); } CHECK(fake.destroyed >= 1); // Offline, a new session: the cached cover still shows, the miss marker still holds, and a // server that is gone is never contacted for them. srv.stop(); { LibraryService svc; bind(svc); svc.artBaseUrl = "http://127.0.0.1:" + std::to_string(srv.port) + "/"; // closed port svc.artMirrorUrl.clear(); svc.dbBaseUrl = svc.artBaseUrl; svc.start(paths, cfg, false); CHECK(!svc.show); CHECK(waitFor(svc, [&] { return !svc.scanning() && svc.entries().size() == 3; })); const lib::Entry* a = find(svc, "Renamed By Update (World)"); // the updated dat won (newer version) CHECK(a != nullptr); if (a) { // The renamed title has no cached art under its new name: Missing once the offline fetch fails. ArtView v; CHECK(waitFor(svc, [&] { v = svc.art(*a); return v.state == ArtView::Missing; })); } svc.stop(); } { // Same root, the original dat restored: the cover downloaded earlier shows with the network off. writeText(root + "library/db/Atari - Jaguar.dat", datText("2099.12.31", datGame(name, "a.j64", crcOf(romA)) + datGame("x", "x", 7) + datGame("y", "y", 8))); LibraryService svc; bind(svc); svc.artBaseUrl = "http://127.0.0.1:" + std::to_string(srv.port) + "/"; svc.artMirrorUrl.clear(); svc.dbBaseUrl = svc.artBaseUrl; svc.start(paths, cfg, false); CHECK(waitFor(svc, [&] { return !svc.scanning() && find(svc, name) != nullptr; })); const lib::Entry* a = find(svc, name); ArtView v; CHECK(a && waitFor(svc, [&] { v = svc.art(*a); return v.state == ArtView::Ready; })); CHECK(v.w == 192 && v.h == 256); // downloads switched off: a title with nothing cached goes Missing without any request svc.setDownloadArt(false); lib::Entry fresh = *a; fresh.datName = "Never Cached (World)"; ArtView vf; CHECK(waitFor(svc, [&] { vf = svc.art(fresh); return vf.state == ArtView::Missing; })); CHECK(!svc.netBusy()); svc.stop(); } } // Disc art: libretro-thumbnails first, then the site mirror; per-source miss markers; carts never see the mirror. static void testCdMirror() { std::string root = g_tmp + "mirror-root/"; std::error_code ec; fs::create_directories(root + "library/db", ec); std::vector png = makePng(300, 400, 70); const std::string UP = "up/", MIR = "mirror/"; auto pathFor = [&](const std::string& prefix, const char* system, const std::string& type, const std::string& name) { return "/" + prefix + lib::urlEncode(system) + "/" + type + "/" + lib::urlEncode(lib::sanitizeThumbName(name)) + ".png"; }; auto upPath = [&](const std::string& type, const std::string& n) { return pathFor(UP, "Atari - Jaguar", type, n); }; auto mirPath = [&](const std::string& type, const std::string& n) { return pathFor(MIR, "Atari - Jaguar CD", type, n); }; std::mutex logM; std::vector log; // request order std::map> have; // paths that answer 200, with their body TestServer srv; CHECK(srv.start()); srv.handler = [&](const std::string& p) { Reply r; std::lock_guard g(logM); log.push_back(p); auto it = have.find(p); if (it != have.end()) r.body = it->second; else r.status = 404; return r; }; auto logSize = [&] { std::lock_guard g(logM); return log.size(); }; auto logAt = [&](size_t i) { std::lock_guard g(logM); return i < log.size() ? log[i] : std::string(); }; auto setHave = [&](const std::string& p, const std::vector* body = nullptr) { std::lock_guard g(logM); have[p] = body ? *body : png; }; std::vector pngUp = makePng(150, 200, 40); // upstream's copy: a different size from the mirror's Paths paths; paths.root = root; struct Fake { int created = 0; } fake; auto runSession = [&](const std::function& body, const std::string& mirrorUrl) { Config cfg; cfg.setPath(root + "config.ini"); LibraryService svc; svc.createTexture = [&](const void*, int, int) { return (unsigned)++fake.created; }; svc.destroyTexture = [](unsigned) {}; svc.artBaseUrl = srv.url(UP); svc.artMirrorUrl = mirrorUrl; svc.dbBaseUrl = srv.url(); svc.start(paths, cfg, false); body(svc, cfg); svc.stop(); }; auto waitState = [&](LibraryService& s, const lib::Entry& e, ArtView::State want) { for (int i = 0; i < 400; i++) { s.pump(); if (s.art(e).state == want) return true; SDL_Delay(25); } return false; }; auto mkEntry = [&](const char* name, lib::Section sec) { lib::Entry e; e.section = sec; e.title = e.datName = name; return e; }; auto exists = [&](const std::string& p) { return fs::exists(fs::u8path(p)); }; auto miss = [&](const char* type, const std::string& name, const char* system) { return exists(lib::thumbnailMissPath(root, type, name, system)); }; auto ageFile = [&](const std::string& p, int days) { std::error_code e2; fs::last_write_time(fs::u8path(p), fs::file_time_type::clock::now() - std::chrono::hours(24 * days), e2); CHECK(!e2); }; const char* kB = "Named_Boxarts"; const char* kT = "Named_Titles"; const char* kS = "Named_Snaps"; lib::Entry onlyMirror = mkEntry("Mirror Only (USA)", lib::Section::Disc); // upstream has nothing lib::Entry upstreamWins = mkEntry("Upstream Wins (USA)", lib::Section::Disc); // both have it lib::Entry snapOnly = mkEntry("Snap Only (USA)", lib::Section::Disc); // mirror has just a snap lib::Entry nothing = mkEntry("Nothing Anywhere (USA)", lib::Section::Disc); lib::Entry cart = mkEntry("Some Cart (World)", lib::Section::Cart); setHave(mirPath(kB, onlyMirror.datName)); setHave(upPath(kB, upstreamWins.datName)); setHave(mirPath(kB, upstreamWins.datName)); setHave(mirPath(kS, snapOnly.datName)); setHave(mirPath(kB, cart.datName)); // must never be asked for: carts do not use the mirror runSession([&](LibraryService& svc, Config&) { // upstream misses, the mirror answers: asked in that order, cached under the shared name, one marker // for the upstream miss and none for the mirror CHECK(waitState(svc, onlyMirror, ArtView::Ready)); CHECK(logSize() == 2); CHECK_EQ(logAt(0), upPath(kB, onlyMirror.datName)); CHECK_EQ(logAt(1), mirPath(kB, onlyMirror.datName)); CHECK(exists(lib::thumbnailCachePath(root, kB, onlyMirror.datName, lib::kThumbSystemCd))); CHECK(!exists(lib::thumbnailCachePath(root, kB, onlyMirror.datName))); // never in upstream's folder CHECK(miss(kB, onlyMirror.datName, lib::kThumbSystem)); CHECK(!miss(kB, onlyMirror.datName, lib::kThumbSystemCd)); // upstream has it: the mirror is never asked size_t before = logSize(); CHECK(waitState(svc, upstreamWins, ArtView::Ready)); CHECK(logSize() == before + 1); CHECK_EQ(logAt(before), upPath(kB, upstreamWins.datName)); // box art first from both sources, then title from both, then snap: the mirror's snap is the last resort before = logSize(); CHECK(waitState(svc, snapOnly, ArtView::Ready)); CHECK(logSize() == before + 6); CHECK_EQ(logAt(before + 0), upPath(kB, snapOnly.datName)); CHECK_EQ(logAt(before + 1), mirPath(kB, snapOnly.datName)); CHECK_EQ(logAt(before + 2), upPath(kT, snapOnly.datName)); CHECK_EQ(logAt(before + 3), mirPath(kT, snapOnly.datName)); CHECK_EQ(logAt(before + 4), upPath(kS, snapOnly.datName)); CHECK_EQ(logAt(before + 5), mirPath(kS, snapOnly.datName)); // the mirror's misses are remembered for this session only, never written as files (#997) CHECK(!miss(kB, snapOnly.datName, lib::kThumbSystemCd) && !miss(kT, snapOnly.datName, lib::kThumbSystemCd)); CHECK(exists(lib::thumbnailCachePath(root, kS, snapOnly.datName, lib::kThumbSystemCd))); // a cart: upstream only (3 types), the mirror untouched even though it has a file by that name before = logSize(); CHECK(waitState(svc, cart, ArtView::Missing)); CHECK(logSize() == before + 3); for (size_t i = before; i < logSize(); i++) CHECK(logAt(i).rfind("/up/", 0) == 0); // nothing anywhere: Missing, three upstream markers and no mirror ones, and it stays quiet afterwards before = logSize(); CHECK(waitState(svc, nothing, ArtView::Missing)); CHECK(logSize() == before + 6); for (const char* t : {kB, kT, kS}) CHECK(miss(t, nothing.datName, lib::kThumbSystem) && !miss(t, nothing.datName, lib::kThumbSystemCd)); size_t quiet = logSize(); for (int i = 0; i < 20; i++) { svc.pump(); svc.art(nothing); svc.art(onlyMirror); SDL_Delay(10); } CHECK(logSize() == quiet); }, srv.url(MIR)); // New session, nothing changed: upstream's misses still hold and cached art needs no request; the mirror is // asked once more about the disc it had nothing for, because its misses last one session. size_t base0 = logSize(); runSession([&](LibraryService& svc, Config&) { CHECK(waitState(svc, nothing, ArtView::Missing)); CHECK(waitState(svc, onlyMirror, ArtView::Ready)); CHECK(waitState(svc, cart, ArtView::Missing)); CHECK(logSize() == base0 + 3); for (size_t i = base0; i < logSize(); i++) CHECK(logAt(i).rfind("/mirror/", 0) == 0); }, srv.url(MIR)); // The mirror gains art: the next session finds it, with no week-long wait; upstream's 30-day marker holds. setHave(mirPath(kT, nothing.datName)); // the mirror gained the title screen meanwhile base0 = logSize(); runSession([&](LibraryService& svc, Config&) { CHECK(waitState(svc, nothing, ArtView::Ready)); CHECK(logSize() >= base0 + 2); for (size_t i = base0; i < logSize(); i++) CHECK(logAt(i).rfind("/mirror/", 0) == 0); // upstream untouched CHECK_EQ(logAt(base0), mirPath(kB, nothing.datName)); }, srv.url(MIR)); // #997: mirror miss markers an older version wrote (fresh, so their week has not run out) are ignored and // removed, and the mirror is asked: art the site gained after that miss shows on the next launch. lib::Entry stale = mkEntry("Stale Marker (USA)", lib::Section::Disc); for (const char* t : {kB, kT, kS}) { std::string m = lib::thumbnailMissPath(root, t, stale.datName, lib::kThumbSystemCd); fs::create_directories(fs::u8path(m.substr(0, m.find_last_of('/'))), ec); std::ofstream(fs::u8path(m)).flush(); CHECK(exists(m)); } setHave(mirPath(kB, stale.datName)); base0 = logSize(); runSession([&](LibraryService& svc, Config&) { CHECK(waitState(svc, stale, ArtView::Ready)); CHECK(logSize() == base0 + 2); // upstream's box art, then the mirror's CHECK_EQ(logAt(base0 + 1), mirPath(kB, stale.datName)); CHECK(!miss(kB, stale.datName, lib::kThumbSystemCd)); }, srv.url(MIR)); // libretro-thumbnails gains the art later: its 30-day marker expires and upstream wins over the mirror's file. lib::Entry late = mkEntry("Late Upstream (USA)", lib::Section::Disc); runSession([&](LibraryService& svc, Config&) { CHECK(waitState(svc, late, ArtView::Missing)); }, srv.url(MIR)); for (const char* t : {kB, kT, kS}) ageFile(lib::thumbnailMissPath(root, t, late.datName, lib::kThumbSystem), 31); setHave(upPath(kB, late.datName)); base0 = logSize(); runSession([&](LibraryService& svc, Config&) { CHECK(waitState(svc, late, ArtView::Ready)); CHECK(logSize() == base0 + 1); CHECK_EQ(logAt(base0), upPath(kB, late.datName)); }, srv.url(MIR)); // Mirror art cached, then libretro-thumbnails gains the same picture: once its 30-day marker expires the // upstream copy is fetched, cached in its own folder, and shown from then on instead of the mirror's. ageFile(lib::thumbnailMissPath(root, kB, onlyMirror.datName), 31); setHave(upPath(kB, onlyMirror.datName), &pngUp); base0 = logSize(); runSession([&](LibraryService& svc, Config&) { ArtView v; CHECK(waitState(svc, onlyMirror, ArtView::Ready)); v = svc.art(onlyMirror); CHECK(v.w == 150 && v.h == 200); // upstream's picture, not the mirror's 300x400 (256 px cap: 192x256) CHECK(logSize() == base0 + 1); CHECK_EQ(logAt(base0), upPath(kB, onlyMirror.datName)); CHECK(exists(lib::thumbnailCachePath(root, kB, onlyMirror.datName))); CHECK(exists(lib::thumbnailCachePath(root, kB, onlyMirror.datName, lib::kThumbSystemCd))); // kept, but outranked }, srv.url(MIR)); base0 = logSize(); runSession([&](LibraryService& svc, Config&) { // and it stays that way offline CHECK(waitState(svc, onlyMirror, ArtView::Ready)); ArtView v = svc.art(onlyMirror); CHECK(v.w == 150 && v.h == 200); CHECK(logSize() == base0); }, srv.url(MIR)); // Mirror switched off (empty base): upstream only. Downloads off: nothing asked at all. lib::Entry off = mkEntry("Mirror Off (USA)", lib::Section::Disc); setHave(mirPath(kB, off.datName)); base0 = logSize(); runSession([&](LibraryService& svc, Config&) { CHECK(waitState(svc, off, ArtView::Missing)); CHECK(logSize() == base0 + 3); for (size_t i = base0; i < logSize(); i++) CHECK(logAt(i).rfind("/up/", 0) == 0); }, ""); lib::Entry noNet = mkEntry("Downloads Off (USA)", lib::Section::Disc); base0 = logSize(); runSession([&](LibraryService& svc, Config&) { svc.setDownloadArt(false); CHECK(waitState(svc, noNet, ArtView::Missing)); CHECK(logSize() == base0); }, srv.url(MIR)); // Config: absent = the site default, present = as written, empty = off. { Config cfg; cfg.setPath(root + "config2.ini"); LibraryService a; CHECK_EQ(a.artMirrorUrl, std::string(lib::kMirrorBase)); a.start(paths, cfg, false); CHECK_EQ(a.artMirrorUrl, std::string(lib::kMirrorBase)); a.stop(); cfg.set("library.art_mirror", "http://example.invalid/t/"); LibraryService b; b.start(paths, cfg, false); CHECK_EQ(b.artMirrorUrl, std::string("http://example.invalid/t/")); b.stop(); cfg.set("library.art_mirror", ""); LibraryService c; c.start(paths, cfg, false); CHECK(c.artMirrorUrl.empty()); c.stop(); } srv.stop(); } #endif // !_WIN32 // ---- covers (#838): display names, tile keys, precedence, the service --------------------------------------------------- static std::vector makeCoverPng(const std::string& name, int w, int h, unsigned char shade) { SDL_Surface* s = SDL_CreateSurface(w, h, SDL_PIXELFORMAT_RGBA32); SDL_FillSurfaceRect(s, nullptr, SDL_MapSurfaceRGBA(s, shade, 100, 200, 255)); std::string path = g_tmp + name; SDL_SavePNG(s, path.c_str()); SDL_DestroySurface(s); std::vector out; readFile(path, out); return out; } static void testDiscTitles() { CHECK_EQ(lib::titleFromDiscStem("Myst (USA) (Track 1)"), "Myst (USA)"); CHECK_EQ(lib::titleFromDiscStem("Myst_(USA)_(Track_01)"), "Myst (USA)"); CHECK_EQ(lib::titleFromDiscStem("Battlemorph [Track 1]"), "Battlemorph"); CHECK_EQ(lib::titleFromDiscStem("Highlander - Track 01"), "Highlander"); CHECK_EQ(lib::titleFromDiscStem("Space Ace.bin"), "Space Ace"); CHECK_EQ(lib::titleFromDiscStem("Dragons Lair (Track 1).bin"), "Dragons Lair"); CHECK_EQ(lib::titleFromDiscStem("Blue Lightning"), "Blue Lightning"); // kept: multi-disc and revision tags tell releases apart, and a title may end in "Track 2" CHECK_EQ(lib::titleFromDiscStem("Primal Rage (Disc 2)"), "Primal Rage (Disc 2)"); CHECK_EQ(lib::titleFromDiscStem("Baldies (Rev 1)"), "Baldies (Rev 1)"); CHECK_EQ(lib::titleFromDiscStem("Super Track 2"), "Super Track 2"); CHECK_EQ(lib::titleFromDiscStem("Track 1"), "Track 1"); // never reduced to nothing CHECK_EQ(lib::titleFromDiscStem("(Track 1)"), "(Track 1)"); lib::ScanFile f; f.path = "/rips/Unlisted Disc (USA) (Track 1).cue"; f.kind = lib::Kind::Disc; lib::Entry e = lib::identify(f, nullptr, nullptr); CHECK_EQ(e.title, "Unlisted Disc (USA)"); CHECK(e.datName.empty() && e.section == lib::Section::Disc); f.path = "/carts/Some_Cart_(Track 1).j64"; // carts keep the plain rule: "(Track 1)" is not noise there f.kind = lib::Kind::Cart; CHECK_EQ(lib::identify(f, nullptr, nullptr).title, "Some Cart (Track 1)"); } static void testCoverLookup() { lib::Entry e; e.datName = "Pipeline: Test & Co (World)"; e.title = e.datName; CHECK_EQ(lib::coverKey(e), e.datName); e.datName.clear(); e.file.path = "/rips/a.cue"; e.file.crcWhole = 0xABCDEF01u; CHECK_EQ(lib::coverKey(e), "crc-ABCDEF01"); // a content fingerprint: survives moving the folder e.file.path = "/elsewhere/renamed.cue"; CHECK_EQ(lib::coverKey(e), "crc-ABCDEF01"); e.file.crcWhole = 0; std::string k1 = lib::coverKey(e); CHECK(k1.rfind("path-", 0) == 0 && k1.size() == 5 + 8); e.file.path = "/elsewhere/other.chd"; CHECK(lib::coverKey(e) != k1); CHECK_EQ(lib::customCoverPath("/d/", "Ecco: The Tides"), "/d/covers/custom/Ecco_ The Tides.png"); CHECK_EQ(lib::localCoverPath("/d/", "A/B? (USA)"), "/d/covers/A_B_ (USA).png"); CHECK_EQ(lib::localCoverPath("/d/", ".."), "/d/covers/_.png"); // no escaping the folder CHECK_EQ(lib::localCoverPath("/d/", "Game. "), "/d/covers/Game.png"); // precedence: custom > local folder (dat name, then shown name) > downloaded > nothing std::set have; auto exists = [&](const std::string& p) { return have.count(p) != 0; }; const std::string root = "/d/", key = "Dat Name (USA)", title = "Shown Name", dat = "Dat Name (USA)"; CHECK(lib::chooseCover(root, key, title, dat, exists).source == lib::CoverSource::None); have.insert(lib::thumbnailCachePath(root, "Named_Snaps", dat)); lib::CoverChoice c = lib::chooseCover(root, key, title, dat, exists); CHECK(c.source == lib::CoverSource::Downloaded && c.path == lib::thumbnailCachePath(root, "Named_Snaps", dat)); // a disc's copy from the site mirror counts too, and libretro-thumbnails' copy of the same type wins over it have.insert(lib::thumbnailCachePath(root, "Named_Boxarts", dat, lib::kThumbSystemCd)); c = lib::chooseCover(root, key, title, dat, exists); CHECK(c.source == lib::CoverSource::Downloaded && c.path == lib::thumbnailCachePath(root, "Named_Boxarts", dat, lib::kThumbSystemCd)); have.insert(lib::thumbnailCachePath(root, "Named_Boxarts", dat)); c = lib::chooseCover(root, key, title, dat, exists); CHECK(c.source == lib::CoverSource::Downloaded && c.path == lib::thumbnailCachePath(root, "Named_Boxarts", dat)); have.erase(lib::thumbnailCachePath(root, "Named_Boxarts", dat)); have.erase(lib::thumbnailCachePath(root, "Named_Boxarts", dat, lib::kThumbSystemCd)); have.insert(lib::localCoverPath(root, title)); c = lib::chooseCover(root, key, title, dat, exists); CHECK(c.source == lib::CoverSource::LocalFolder && c.path == lib::localCoverPath(root, title)); have.insert(lib::localCoverPath(root, dat)); // the dat name outranks the shown name c = lib::chooseCover(root, key, title, dat, exists); CHECK(c.source == lib::CoverSource::LocalFolder && c.path == lib::localCoverPath(root, dat)); have.insert(lib::customCoverPath(root, key)); c = lib::chooseCover(root, key, title, dat, exists); CHECK(c.source == lib::CoverSource::Custom && c.path == lib::customCoverPath(root, key)); // an unidentified tile never counts a downloaded thumbnail, and has no dat-name local lookup have.clear(); have.insert(lib::thumbnailCachePath(root, "Named_Boxarts", title)); CHECK(lib::chooseCover(root, "crc-00000001", title, "", exists).source == lib::CoverSource::None); have.insert(lib::localCoverPath(root, title)); CHECK(lib::chooseCover(root, "crc-00000001", title, "", exists).source == lib::CoverSource::LocalFolder); } static void testCoverService() { std::string root = g_tmp + "cover-root/"; std::string games = g_tmp + "cover-games/"; std::error_code ec; fs::create_directories(root + "library/db", ec); std::vector u1 = noise(2352 * 30, 31), d1 = noise(2352 * 30, 32); writeBytes(games + "Unlisted Disc (USA) (Track 1).bin", u1); writeText(games + "Unlisted Disc (USA).cue", "FILE \"Unlisted Disc (USA) (Track 1).bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); writeBytes(games + "Listed Disc (USA) (Track 1).bin", d1); writeText(games + "Listed Disc (USA).cue", "FILE \"Listed Disc (USA) (Track 1).bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); writeBytes(games + "Image Only (USA).chd", noise(4096, 33)); // .chd: never hashed, keyed by path writeText(root + "library/db/Atari - Jaguar CD.dat", datText("2099.01.01", datGame("Listed Disc (USA)", "Listed Disc (USA) (Track 1).bin", crcOf(d1)) + datGame("F1", "x", 1) + datGame("F2", "x", 2) + datGame("F3", "x", 3) + datGame("F4", "x", 4))); writeText(root + "not-a-png.png", "this is not an image, just text that is long enough to pass for a file"); Paths paths; paths.root = root; Config cfg; cfg.setPath(root + "config.ini"); cfg.set("library.dir.0", games.substr(0, games.size() - 1)); struct Fake { int created = 0, destroyed = 0, lastW = 0, lastH = 0; } fake; LibraryService svc; svc.createTexture = [&](const void*, int w, int h) { fake.created++; fake.lastW = w; fake.lastH = h; return (unsigned)fake.created; }; svc.destroyTexture = [&](unsigned) { fake.destroyed++; }; svc.artBaseUrl = "http://127.0.0.1:1/"; // nothing listens: any request would fail loudly svc.artMirrorUrl = svc.artBaseUrl; svc.dbBaseUrl = svc.artBaseUrl; svc.start(paths, cfg, false); svc.setDownloadArt(false); auto pumpUntil = [&](const std::function& cond) { for (int i = 0; i < 400; i++) { svc.pump(); if (cond()) return true; SDL_Delay(25); } return false; }; auto find = [&](const std::string& title) { lib::Entry e; for (const lib::Entry& x : svc.entries()) if (x.title == title) e = x; return e; // a copy: a rescan rebuilds the list }; CHECK(pumpUntil([&] { return !svc.scanning() && svc.entries().size() == 3; })); lib::Entry un = find("Unlisted Disc (USA)"), listed = find("Listed Disc (USA)"), chd = find("Image Only (USA)"); CHECK(!un.file.path.empty() && !listed.file.path.empty() && !chd.file.path.empty()); CHECK(un.datName.empty() && listed.match == lib::Match::Crc && chd.datName.empty()); CHECK(lib::coverKey(un).rfind("crc-", 0) == 0 && lib::coverKey(chd).rfind("path-", 0) == 0); // nothing anywhere: Missing, for the unidentified and for the identified (downloads are off) ArtView v; CHECK(pumpUntil([&] { v = svc.art(un); return v.state == ArtView::Missing; })); CHECK(pumpUntil([&] { v = svc.art(listed); return v.state == ArtView::Missing; })); CHECK(!svc.netBusy()); // 1. a PNG named after the game in /covers/ (Rescan makes tiles look again) makeCoverPng("local.png", 40, 80, 10); fs::create_directories(fs::u8path(root + "covers"), ec); fs::copy_file(fs::u8path(g_tmp + "local.png"), fs::u8path(root + "covers/Unlisted Disc (USA).png"), fs::copy_options::overwrite_existing, ec); CHECK(!ec); svc.rescan(); CHECK(pumpUntil([&] { v = svc.art(un); return v.state == ArtView::Ready; })); CHECK(v.w == 40 && v.h == 80); lib::Entry un2 = find("Unlisted Disc (USA)"); // a rescan rebuilds entries: the key is unchanged CHECK(pumpUntil([&] { return !svc.scanning(); })); un = find("Unlisted Disc (USA)"); listed = find("Listed Disc (USA)"); chd = find("Image Only (USA)"); // 2. a cover set on the tile beats the folder; remove goes back to the folder's makeCoverPng("mine.png", 90, 30, 200); CHECK(!svc.hasCustomCover(un)); CHECK(svc.setCover(un, g_tmp + "mine.png")); CHECK(svc.hasCustomCover(un)); CHECK(fs::exists(fs::u8path(lib::customCoverPath(root, lib::coverKey(un))))); CHECK(pumpUntil([&] { v = svc.art(un); return v.state == ArtView::Ready && v.w == 90; })); CHECK(v.h == 30); int destroyedBefore = fake.destroyed; svc.removeCover(un); CHECK(!svc.hasCustomCover(un)); CHECK(fake.destroyed > destroyedBefore); // the old texture went away with the cover CHECK(pumpUntil([&] { v = svc.art(un); return v.state == ArtView::Ready && v.w == 40; })); CHECK(v.h == 80); CHECK(fs::exists(fs::u8path(root + "covers/Unlisted Disc (USA).png"))); // the user's own file is never deleted // 3. identified: custom > downloaded cache (downloads off, so only what is on disk counts) std::string cached = lib::thumbnailCachePath(root, "Named_Boxarts", listed.datName); makeCoverPng("cached.png", 64, 64, 50); fs::create_directories(fs::u8path(cached).parent_path(), ec); fs::copy_file(fs::u8path(g_tmp + "cached.png"), fs::u8path(cached), fs::copy_options::overwrite_existing, ec); CHECK(!ec); svc.rescan(); // the earlier Missing is forgotten CHECK(pumpUntil([&] { return !svc.scanning(); })); listed = find("Listed Disc (USA)"); CHECK(pumpUntil([&] { v = svc.art(listed); return v.state == ArtView::Ready; })); CHECK(v.w == 64 && v.h == 64); // downloaded makeCoverPng("tiny.png", 20, 20, 120); CHECK(svc.setCover(listed, g_tmp + "tiny.png")); CHECK(pumpUntil([&] { v = svc.art(listed); return v.state == ArtView::Ready && v.w == 20; })); CHECK(fs::exists(fs::u8path(lib::customCoverPath(root, "Listed Disc (USA)")))); svc.removeCover(listed); CHECK(pumpUntil([&] { v = svc.art(listed); return v.state == ArtView::Ready && v.w == 64; })); // 4. an image the decoder cannot read is refused and leaves nothing behind chd = find("Image Only (USA)"); CHECK(!svc.setCover(chd, root + "not-a-png.png")); CHECK(svc.status().find("PNG") != std::string::npos); CHECK(!svc.hasCustomCover(chd)); CHECK(!svc.setCover(chd, root + "does-not-exist.png")); CHECK(svc.setCover(chd, g_tmp + "mine.png")); // .chd tiles are keyed by their path CHECK(pumpUntil([&] { v = svc.art(chd); return v.state == ArtView::Ready && v.w == 90; })); // 5. finding the tile under a dropped file svc.tiles.clear(); svc.tilesFrame = 100; LibraryService::CoverTile t; t.x0 = 10; t.y0 = 20; t.x1 = 110; t.y1 = 160; t.key = "k1"; t.title = "T1"; svc.tiles.push_back(t); t.x0 = 120; t.x1 = 220; t.key = "k2"; svc.tiles.push_back(t); CHECK(svc.tileAt(50, 50, 100) && svc.tileAt(50, 50, 100)->key == "k1"); CHECK(svc.tileAt(130, 159, 101) && svc.tileAt(130, 159, 101)->key == "k2"); CHECK(!svc.tileAt(115, 50, 100)); // the gap between tiles CHECK(!svc.tileAt(50, 160, 100)); // the far edge is outside CHECK(!svc.tileAt(50, 50, 200)); // the grid has not been drawn for a while: nothing is under the pointer svc.stop(); } // ---- disc identification: name matching, .chd and .cdi track-1 checksums -------------------------------------------- static void testLooseNames() { CHECK_EQ(lib::normalizeDiscName("Dragon's Lair (USA) (Track 1)"), "dragons lair"); CHECK_EQ(lib::normalizeDiscName("Myst_(USA)_[Disc 1] - Redump"), "myst redump"); CHECK_EQ(lib::normalizeDiscName(" Blue Lightning!! (Europe) "), "blue lightning"); CHECK_EQ(lib::normalizeDiscName("Highlander - The Last of the MacLeods"), "highlander the last of the macleods"); CHECK_EQ(lib::normalizeDiscName("(USA)"), ""); CHECK_EQ(lib::normalizeDiscName("Unclosed (group"), "unclosed"); lib::Dat dat; CHECK(dat.parse(datText("1", datGame("Myst (USA)", "a", 1) + datGame("Dragon's Lair (USA)", "b", 2) + datGame("Highlander - The Last of the MacLeods (USA)", "c", 3) + datGame("Primal Rage (USA) (Disc 1)", "d", 4) + datGame("Primal Rage (USA) (Disc 2)", "e", 5) + datGame("Space Ace (USA)", "f", 6) + datGame("Space Ace Gold (USA)", "g", 7) + datGame("Blue Lightning (USA)", "h", 8)))); auto title = [&](const char* stem, const char* folder = "") { int how = 0; const lib::DatGame* g = dat.byLooseName(stem, folder, &how); return g ? g->name : std::string("-"); }; CHECK_EQ(title("Myst"), "Myst (USA)"); // region and track tags do not matter CHECK_EQ(title("myst (europe) (track 01)"), "Myst (USA)"); CHECK_EQ(title("Dragons Lair"), "Dragon's Lair (USA)"); // punctuation does not matter CHECK_EQ(title("DRAGON'S_LAIR"), "Dragon's Lair (USA)"); CHECK_EQ(title("Space Ace"), "Space Ace (USA)"); // the exact title beats a longer one CHECK_EQ(title("Highlander"), "Highlander - The Last of the MacLeods (USA)"); // a unique start of a title CHECK_EQ(title("Primal Rage"), "-"); // two discs share the title: no guess CHECK_EQ(title("Primal Rage (Disc 2)"), "-"); CHECK_EQ(title("Space"), "-"); // starts two titles CHECK_EQ(title("Spa"), "-"); // too short for a prefix match CHECK_EQ(title("Myst Masterpiece Edition"), "-"); // longer than the dat title: not a match CHECK_EQ(title("disc", "Myst (USA)"), "Myst (USA)"); // the folder name when the file says nothing CHECK_EQ(title("disc", "Highlander"), "-"); // a folder name must match exactly CHECK_EQ(title("Space Ace", "Myst"), "Space Ace (USA)"); // the file name wins over the folder int how = 0; dat.byLooseName("disc", "Myst", &how); CHECK(how == 2); dat.byLooseName("Highlander", "", &how); CHECK(how == 3); // identify(): a disc whose checksum is not in the dat is matched by name; a cart is not touched by it lib::ScanFile f; f.kind = lib::Kind::Disc; f.path = "/rips/Myst (USA)/disc.cue"; f.crcWhole = 0x12345678u; // not Redump's lib::Entry e = lib::identify(f, nullptr, &dat); CHECK(e.match == lib::Match::Name && e.datName == "Myst (USA)" && e.how == "folder name"); f.path = "/rips/Space.chd"; f.crcWhole = 0; e = lib::identify(f, nullptr, &dat); CHECK(e.match == lib::Match::Unknown && e.how == "not identified" && e.title == "Space"); f.path = "/rips/x.cue"; f.crcWhole = 6; // the dat's crc for Space Ace e = lib::identify(f, nullptr, &dat); CHECK(e.match == lib::Match::Crc && e.datName == "Space Ace (USA)" && e.how.find("track 1") != std::string::npos); f.kind = lib::Kind::Cart; f.path = "/carts/Myst (USA).j64"; f.crcWhole = 0; CHECK(lib::identify(f, &dat, nullptr).match == lib::Match::Name); // the plain name rule f.path = "/carts/Highlander.j64"; CHECK(lib::identify(f, &dat, nullptr).match == lib::Match::Unknown); // carts do not get the loose rule } static void putBE(std::vector& v, size_t off, uint64_t x, int bytes) { for (int i = 0; i < bytes; i++) v[off + (size_t)i] = (unsigned char)(x >> (8 * (bytes - 1 - i))); } struct SynthTrack { std::string type; // AUDIO, MODE1_RAW int frames = 0, pregap = 0; std::string pgtype = "MODE1"; // "VAUDIO" = virtual std::vector stored; // frames * 2352 bytes, in the order the CHD keeps them }; // A minimal uncompressed CHD v5 for CDs: 4 frames per hunk, 2448-byte frames (the subcode is filled with 0xEE // so a read that includes it shows), tracks padded to 4 frames, CHT2 track metadata and optionally CHSE. static bool writeSynthChd(const std::string& path, const std::vector& tracks, bool chse) { const uint32_t hunkFrames = 4, hunkBytes = 2448 * hunkFrames; std::vector frames; for (const SynthTrack& t : tracks) { uint32_t padded = ((uint32_t)t.frames + 3) / 4 * 4; for (uint32_t i = 0; i < padded; i++) { std::vector fr(2448, 0xEE); if (i < (uint32_t)t.frames) std::memcpy(fr.data(), t.stored.data() + (size_t)i * 2352, 2352); else std::fill(fr.begin(), fr.begin() + 2352, 0); frames.insert(frames.end(), fr.begin(), fr.end()); } } const uint32_t hunks = (uint32_t)(frames.size() / hunkBytes); std::vector md; if (chse) md.push_back("SESSION:1"); for (size_t i = 0; i < tracks.size(); i++) { char b[200]; std::snprintf(b, sizeof b, "TRACK:%d TYPE:%s SUBTYPE:NONE FRAMES:%d PREGAP:%d PGTYPE:%s PGSUB:NONE POSTGAP:0", (int)i + 1, tracks[i].type.c_str(), tracks[i].frames, tracks[i].pregap, tracks[i].pgtype.c_str()); md.push_back(b); } const uint64_t mapOff = 124, metaOff = mapOff + (uint64_t)hunks * 4; uint64_t metaEnd = metaOff; for (const std::string& m : md) metaEnd += 16 + m.size() + 1; const uint64_t dataStart = (metaEnd + hunkBytes - 1) / hunkBytes * hunkBytes; std::vector f((size_t)(dataStart + frames.size()), 0); std::memcpy(f.data(), "MComprHD", 8); putBE(f, 8, 124, 4); putBE(f, 12, 5, 4); putBE(f, 32, frames.size(), 8); putBE(f, 40, mapOff, 8); putBE(f, 48, metaOff, 8); putBE(f, 56, hunkBytes, 4); putBE(f, 60, 2448, 4); for (uint32_t h = 0; h < hunks; h++) putBE(f, (size_t)mapOff + h * 4, dataStart / hunkBytes + h, 4); uint64_t at = metaOff; for (size_t i = 0; i < md.size(); i++) { const uint32_t tag = md[i].compare(0, 7, "SESSION") == 0 ? 0x43485345u : 0x43485432u; // CHSE / CHT2 const uint64_t len = md[i].size() + 1, next = i + 1 < md.size() ? at + 16 + len : 0; putBE(f, (size_t)at, tag, 4); putBE(f, (size_t)at + 4, len, 4); // flags 0, 24-bit length putBE(f, (size_t)at + 8, next, 8); std::memcpy(f.data() + at + 16, md[i].c_str(), len); at += 16 + len; } std::memcpy(f.data() + dataStart, frames.data(), frames.size()); writeBytes(path, f); return true; } static std::vector swapWords(std::vector v) { for (size_t i = 0; i + 1 < v.size(); i += 2) std::swap(v[i], v[i + 1]); return v; } static void testChdAndCdiChecksums() { std::string dir = g_tmp + "discs/"; std::atomic noCancel{false}; // the Redump .bin of track 1 (an audio track: bin files hold word-swapped samples), 10 sectors std::vector bin1 = noise(2352 * 10, 61); std::vector data2 = noise(2352 * 5, 62); SynthTrack t1; t1.type = "AUDIO"; t1.frames = 10; t1.stored = swapWords(bin1); // the CHD keeps host-order PCM SynthTrack t2; t2.type = "MODE1_RAW"; t2.frames = 5; t2.stored = data2; writeSynthChd(dir + "good.chd", {t1, t2}, true); uint32_t crc = 0; CHECK(lib::crcOfChdTrack1(dir + "good.chd", crc, &noCancel)); CHECK_EQ(hex8(crc), hex8(crcOf(bin1))); // exactly the Redump bin's CRC32 // a virtual pregap is not part of the .bin: 3 silent frames the CHD counts but a bin does not have std::vector real7 = noise(2352 * 7, 63); SynthTrack v1; v1.type = "AUDIO"; v1.frames = 10; v1.pregap = 3; v1.pgtype = "VAUDIO"; v1.stored = std::vector(2352 * 3, 0); std::vector sw = swapWords(real7); v1.stored.insert(v1.stored.end(), sw.begin(), sw.end()); writeSynthChd(dir + "vpregap.chd", {v1, t2}, true); CHECK(lib::crcOfChdTrack1(dir + "vpregap.chd", crc, &noCancel)); CHECK_EQ(hex8(crc), hex8(crcOf(real7))); // the core refuses several audio tracks with no session metadata, so it is not hashed either SynthTrack a2 = t1; writeSynthChd(dir + "nochse.chd", {t1, a2}, false); crc = 0xDEADBEEF; CHECK(!lib::crcOfChdTrack1(dir + "nochse.chd", crc, &noCancel) && crc == 0xDEADBEEF); writeBytes(dir + "junk.chd", noise(4096, 64)); CHECK(!lib::crcOfChdTrack1(dir + "junk.chd", crc, &noCancel)); CHECK(!lib::crcOfChdTrack1(dir + "missing.chd", crc, &noCancel)); std::atomic cancelled{true}; CHECK(!lib::crcOfChdTrack1(dir + "good.chd", crc, &cancelled)); // .cdi (V3.5): track 1 data after its 2-sector pregap, then the descriptor and the trailer { std::vector img = noise(2352 * 2, 65); // pregap sectors img.insert(img.end(), bin1.begin(), bin1.end()); std::vector hdr; auto u16 = [&](unsigned x) { hdr.push_back((unsigned char)x); hdr.push_back((unsigned char)(x >> 8)); }; auto u32 = [&](uint32_t x) { u16(x & 0xFFFF); u16(x >> 16); }; u16(1); // sessions u16(1); // tracks in session 1 u32(0); // not the extended format const unsigned char marker[20] = {0, 0, 1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF}; hdr.insert(hdr.end(), marker, marker + 20); hdr.insert(hdr.end(), 4, 0); hdr.push_back(0); // empty file name hdr.insert(hdr.end(), 19, 0); u32(0); hdr.insert(hdr.end(), 2, 0); // index count std::vector td(0x57, 0); auto put32 = [&](size_t o, uint32_t x) { for (int i = 0; i < 4; i++) td[o + (size_t)i] = (unsigned char)(x >> (8 * i)); }; put32(0x00, 2); // pregap put32(0x04, 10); // length put32(0x0e, 0); // mode: audio put32(0x22, 12); put32(0x36, 2); // 2352-byte sectors hdr.insert(hdr.end(), td.begin(), td.end()); hdr.insert(hdr.end(), 64, 0); // the rest of the descriptor std::vector file = img; const size_t hdrAt = file.size(); file.insert(file.end(), hdr.begin(), hdr.end()); const uint32_t total = (uint32_t)file.size() + 8; for (int i = 0; i < 4; i++) file.push_back((unsigned char)(0x80000006u >> (8 * i))); const uint32_t fromEnd = total - (uint32_t)hdrAt; for (int i = 0; i < 4; i++) file.push_back((unsigned char)(fromEnd >> (8 * i))); writeBytes(dir + "good.cdi", file); crc = 0; CHECK(lib::crcOfCdiTrack1(dir + "good.cdi", crc, &noCancel)); CHECK_EQ(hex8(crc), hex8(crcOf(bin1))); writeBytes(dir + "junk.cdi", noise(8192, 66)); CHECK(!lib::crcOfCdiTrack1(dir + "junk.cdi", crc, &noCancel)); } // the scanner hashes both, identify() matches them to the dat by checksum, and the cache keeps the result lib::Dat discs; CHECK(discs.parse(datText("1", datGame("Synth Audio One (USA)", "Synth Audio One (USA) (Track 1).bin", crcOf(bin1)) + datGame("Other (USA)", "o", 9)))); lib::ScanOptions opt; opt.folders = {dir}; std::vector files = lib::scanFolders(opt, lib::ScanCache(), nullptr, nullptr); int hashed = 0; for (const lib::ScanFile& sf : files) { const std::string name = fs::path(sf.path).filename().string(); lib::Entry e = lib::identify(sf, nullptr, &discs); if (name == "good.chd" || name == "good.cdi") { CHECK(sf.crcWhole == crcOf(bin1) && e.match == lib::Match::Crc && e.datName == "Synth Audio One (USA)"); hashed++; } else { CHECK(sf.crcWhole == 0 || name == "vpregap.chd"); CHECK(e.match != lib::Match::Crc); } } CHECK(hashed == 2); lib::ScanCache cache = lib::parseCache(lib::serializeCache(files)); CHECK(cache.size() == files.size()); for (const lib::ScanFile& sf : files) if (sf.path.size() > 8 && sf.path.substr(sf.path.size() - 8) == "good.chd") CHECK(cache[sf.path].crcWhole == sf.crcWhole && cache[sf.path].hashed); // an old (VJLIB1) cache: disc images without a checksum are hashed again, carts and hashed discs are kept std::string old = "VJLIB1\n/x/a.chd\tD\t10\t5\t00000000\t00000000\n/x/b.cue\tD\t10\t5\t0000ABCD\t00000000\n/x/c.j64\tC\t10\t5\t00000000\t00000000\n"; lib::ScanCache oc = lib::parseCache(old); CHECK(oc.size() == 3 && !oc["/x/a.chd"].hashed && oc["/x/b.cue"].hashed && oc["/x/c.j64"].hashed); lib::ScanCache nc = lib::parseCache("VJLIB2\n/x/a.chd\tD\t10\t5\t00000000\t00000000\n"); CHECK(nc.size() == 1 && nc["/x/a.chd"].hashed); // an unhashable image is remembered as such } // ---- dev tool ------------------------------------------------------------------------------------------------------- // .m3u multi-disc playlists (m3u.h): the parser, and the scan listing the playlist in place of its discs. static void testM3u() { // parsing: comments, blank lines, a BOM, CRLF, "|label", Windows separators, absolute entries std::vector e = parseM3u("\xEF\xBB\xBF#EXTM3U\r\n\r\n#EXTINF:0,Disc 1\r\nGame (Disc 1).cue\r\n" " sub\\Game (Disc 2).chd|Second disc \r\n/abs/Game (Disc 3).cdi\nC:/win/d4.chd\n# end", "/set/"); CHECK(e.size() == 4); if (e.size() == 4) { CHECK_EQ(e[0], std::string("/set/Game (Disc 1).cue")); CHECK_EQ(e[1], std::string("/set/sub/Game (Disc 2).chd")); CHECK_EQ(e[2], std::string("/abs/Game (Disc 3).cdi")); CHECK_EQ(e[3], std::string("C:/win/d4.chd")); } CHECK(parseM3u("", "/x/").empty() && parseM3u("#EXTM3U\n\n", "/x/").empty()); CHECK(lib::isDiscExt("m3u")); // scanning: a cue set + a chd listed by one playlist, a chd-first playlist, and a loose chd no playlist names std::string dir = g_tmp + "m3u/"; std::atomic noCancel{false}; std::vector d1t1 = noise(2352 * 12, 71); writeBytes(dir + "Set (Disc 1) (Track 1).bin", d1t1); writeText(dir + "Set (Disc 1).cue", "FILE \"Set (Disc 1) (Track 1).bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); std::vector pcm = noise(2352 * 10, 72); SynthTrack t; t.type = "AUDIO"; t.frames = 10; t.stored = swapWords(pcm); writeSynthChd(dir + "discs/Set (Disc 2).chd", {t}, true); writeText(dir + "Set.m3u", "#EXTM3U\nSet (Disc 1).cue\ndiscs\\Set (Disc 2).chd\n"); writeText(dir + "Chd First.m3u", "discs/Set (Disc 2).chd\n"); writeSynthChd(dir + "Loose.chd", {t}, true); lib::ScanOptions opt; opt.folders = {dir}; std::vector files = lib::scanFolders(opt, lib::ScanCache(), &noCancel, nullptr); std::map by; for (const lib::ScanFile& f : files) by[fs::path(f.path).filename().string()] = f; CHECK(by.count("Set.m3u") && by["Set.m3u"].kind == lib::Kind::Disc); CHECK_EQ(hex8(by["Set.m3u"].crcWhole), hex8(crcOf(d1t1))); // identified by its first disc's track 1 CHECK(by.count("Chd First.m3u") && by["Chd First.m3u"].kind == lib::Kind::Disc); CHECK_EQ(hex8(by["Chd First.m3u"].crcWhole), hex8(crcOf(pcm))); CHECK(!by.count("Set (Disc 1).cue") && !by.count("Set (Disc 2).chd")); // the playlists stand for them CHECK(!by.count("Set (Disc 1) (Track 1).bin")); CHECK(by.count("Loose.chd")); CHECK(files.size() == 3); // A playlist is all-or-nothing at load time. It must not claim playable files before every entry is validated. const char* bad[] = {"Missing.chd", "Not a disc.j64", "Nested.m3u", "Directory.chd"}; for (size_t i = 0; i < 4; ++i) { std::string broken = g_tmp + "invalid-playlist-" + std::to_string(i) + "/"; writeBytes(broken + "Keep.bin", d1t1); writeText(broken + "Keep.cue", "FILE \"Keep.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); writeBytes(broken + "Not a disc.j64", noise(1024, 99)); fs::create_directories(fs::u8path(broken + "Directory.chd")); // Only the nested-playlist case has another valid playlist: make that one invalid too. writeText(broken + "Nested.m3u", "Missing.chd\n"); writeText(broken + "Broken.m3u", std::string("Keep.cue\n") + bad[i] + "\n"); opt.folders = {broken}; auto invalid = lib::scanFolders(opt, lib::ScanCache(), nullptr, nullptr); bool keptDisc = false, keptCart = false; for (const auto& f : invalid) { if (pathStem(f.path) == "Keep") keptDisc = true; if (pathStem(f.path) == "Not a disc") keptCart = true; } CHECK(keptDisc); CHECK(keptCart); } } static void testPlaylistStateOwners() { const std::string root = g_tmp + "playlist-states/", games = root + "games/"; const std::string playlist = fs::u8path(games + "Game.m3u").lexically_normal().u8string(); for (int disc = 1; disc <= 2; ++disc) { const std::string stem = disc == 1 ? "Game (Disc 1)" : "Game"; writeBytes(games + stem + ".bin", noise(2352 * 8, 90 + disc)); writeText(games + stem + ".cue", "FILE \"" + stem + ".bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); writeText(root + "states/" + stem + ".slot" + std::string("0.state"), "fixture"); } writeText(games + "Game.m3u", "Game (Disc 1).cue\nGame.cue\n"); Paths paths; paths.root = root; paths.states = root + "states/"; Config cfg; cfg.set("library.dir.0", games); LibraryService svc; svc.artBaseUrl = svc.artMirrorUrl = svc.dbBaseUrl = "http://127.0.0.1:1/"; svc.start(paths, cfg, false); svc.setDownloadArt(false); for (int i = 0; i < 400; ++i) { svc.pump(); if (!svc.scanning() && svc.entries().size() == 1) break; SDL_Delay(25); } CHECK(svc.entries().size() == 1); // disc aliases must not reappear as duplicate tiles CHECK(svc.stateGames().size() == 2); if (svc.entries().size() == 1) { const auto* all = svc.statesFor(svc.entries()[0]); CHECK(all != nullptr && all->states.size() == 2); if (all) { for (const auto& state : all->states) { CHECK(!state.disc.empty() && state.slot == 0); // equal slots on different discs stay distinct svc.launchState(svc.entries()[0], state.slot, state.disc); SDL_PumpEvents(); SDL_Event ev; const int got = SDL_PeepEvents(&ev, 1, SDL_GETEVENT, SDL_EVENT_USER, SDL_EVENT_USER); CHECK(got == 1 && ev.user.code == 0x56414A50); if (got == 1 && ev.user.code == 0x56414A50) { std::unique_ptr request((PlaylistStateLaunch*)ev.user.data1); CHECK_EQ(request->playlist, playlist); CHECK_EQ(request->disc, state.disc); CHECK((int)(intptr_t)ev.user.data2 - 1000 == 0); } else if (got == 1 && ev.user.code == 0x56414A53) SDL_free(ev.user.data1); } } } for (const auto& group : svc.stateGames()) { const auto* owner = svc.entryForState(group); CHECK(owner != nullptr); if (owner) { CHECK_EQ(pathStem(owner->file.path), group.stem); CHECK_EQ(owner->statePlaylist, playlist); CHECK(svc.statesFor(*owner) != nullptr); } } svc.stop(); svc.start(paths, cfg, false); // warm hash cache: associations must exist before the next scan completes CHECK(svc.entries().size() == 1); if (svc.entries().size() == 1) { const auto* all = svc.statesFor(svc.entries()[0]); CHECK(all != nullptr && all->states.size() == 2); } svc.stop(); } static int scanTool(const std::string& dir, const std::string& dbDir) { lib::Dat carts, discs; bool haveCarts = carts.load(dbDir + "/Atari - Jaguar.dat"); bool haveDiscs = discs.load(dbDir + "/Atari - Jaguar CD.dat"); lib::ScanOptions opt; opt.folders = {dir}; std::vector files = lib::scanFolders(opt, lib::ScanCache(), nullptr, nullptr); size_t nCart = 0, nDisc = 0, crc = 0, name = 0, unk = 0, cartCrc = 0, discCrc = 0, discNoHash = 0; for (const lib::ScanFile& f : files) { lib::Entry e = lib::identify(f, haveCarts ? &carts : nullptr, haveDiscs ? &discs : nullptr); (f.kind == lib::Kind::Cart ? nCart : nDisc)++; if (f.kind == lib::Kind::Disc && f.crcWhole == 0) discNoHash++; if (e.match == lib::Match::Crc) { crc++; (f.kind == lib::Kind::Cart ? cartCrc : discCrc)++; } else if (e.match == lib::Match::Name) { name++; } else { unk++; } } std::printf("dat: carts %s (%zu) discs %s (%zu)\n", haveCarts ? "loaded" : "missing", carts.size(), haveDiscs ? "loaded" : "missing", discs.size()); std::printf("files: %zu (cartridges %zu, disc images %zu, of which %zu have no checksum: chd/cdi/missing bin)\n", files.size(), nCart, nDisc, discNoHash); std::printf("identified by CRC: %zu (cartridges %zu, discs %zu)\n", crc, cartCrc, discCrc); std::printf("matched by file name only: %zu\n", name); std::printf("unidentified: %zu\n", unk); return 0; } // ---- favorites, recently played, display options, tabs, save states (the library's personal lists) ----------- static lib::Entry fakeEntry(const std::string& path, const std::string& title, uint32_t crc, const std::string& datName = "", lib::Kind kind = lib::Kind::Cart) { lib::Entry e; e.file.path = path; e.file.kind = kind; e.file.crcWhole = crc; e.title = title; e.datName = datName; e.section = kind == lib::Kind::Disc ? lib::Section::Disc : lib::Section::Cart; return e; } static void testDisplayOptionsAndTabs() { using namespace lib; CHECK(!showsTab(ShowHidden) && !showsStrip(ShowHidden)); CHECK(showsTab(ShowTab) && !showsStrip(ShowTab)); CHECK(!showsTab(ShowSections) && showsStrip(ShowSections)); CHECK(showsTab(ShowBoth) && showsStrip(ShowBoth)); CHECK(clampShowAs(7, ShowBoth) == ShowBoth && clampShowAs(-1, ShowTab) == ShowTab && clampShowAs(0, ShowBoth) == ShowHidden); CHECK(kFavoritesDefault == ShowBoth && kRecentsDefault == ShowBoth); for (int m = 0; m < kShowAsCount; m++) CHECK(std::strlen(showAsLabel(m)) > 0); // tab order: Cartridges, Jaguar CD, [Favorites], [Recently played], Save states, BIOS & system std::vector v = visibleTabs(ShowBoth, ShowBoth); CHECK(v == std::vector({TabCart, TabDisc, TabFavorites, TabRecents, TabStates, TabBios})); v = visibleTabs(ShowSections, ShowHidden); // strips only: no tab for either CHECK(v == std::vector({TabCart, TabDisc, TabStates, TabBios})); v = visibleTabs(ShowHidden, ShowTab); CHECK(v == std::vector({TabCart, TabDisc, TabRecents, TabStates, TabBios})); // the remembered tab that is no longer shown falls back to Cartridges; the others stay CHECK(effectiveTab(TabFavorites, ShowHidden, ShowBoth) == TabCart); CHECK(effectiveTab(TabFavorites, ShowSections, ShowBoth) == TabCart); CHECK(effectiveTab(TabFavorites, ShowTab, ShowHidden) == TabFavorites); CHECK(effectiveTab(TabRecents, ShowBoth, ShowTab) == TabRecents); CHECK(effectiveTab(TabBios, ShowHidden, ShowHidden) == TabBios && effectiveTab(TabStates, ShowHidden, ShowHidden) == TabStates); CHECK(effectiveTab(99, ShowBoth, ShowBoth) == TabCart && effectiveTab(-3, ShowBoth, ShowBoth) == TabCart); CHECK_EQ(tabName(TabRecents), "Recently played"); // the strips sit on the two media tabs only, and only when the option asks for them CHECK(stripOnTab(TabCart, ShowBoth) && stripOnTab(TabDisc, ShowSections)); CHECK(!stripOnTab(TabCart, ShowTab) && !stripOnTab(TabDisc, ShowHidden)); CHECK(!stripOnTab(TabFavorites, ShowBoth) && !stripOnTab(TabBios, ShowBoth) && !stripOnTab(TabStates, ShowBoth)); } static void testPersonalListsLogic() { using namespace lib; std::vector entries; entries.push_back(fakeEntry("/g/Alpha.j64", "Alpha", 0xA1A1A1A1u)); entries.push_back(fakeEntry("/g/Beta.j64", "beta", 0xB2B2B2B2u, "Beta (World)")); entries.push_back(fakeEntry("/g/Gamma.cue", "Gamma", 0xC3C3C3C3u, "", Kind::Disc)); entries.push_back(fakeEntry("/g/Delta.chd", "Delta", 0, "", Kind::Disc)); Entry bios = fakeEntry("/sys/jagboot.rom", "Series K boot ROM", 0xD4D4D4D4u); bios.section = Section::Bios; entries.push_back(bios); UserLists l; UserItem a = userItemFor(entries[0], 100), b = userItemFor(entries[1], 101), g = userItemFor(entries[2], 102); CHECK_EQ(a.key, "crc-A1A1A1A1"); CHECK_EQ(b.key, "Beta (World)"); CHECK(userItemFor(entries[3], 0).key.rfind("path-", 0) == 0); // favorites: add, no duplicates, remove setFavoriteIn(l, b, true); setFavoriteIn(l, a, true); setFavoriteIn(l, b, true); CHECK(l.favorites.size() == 2 && l.favorites[0].path == "/g/Beta.j64" && l.favorites[1].path == "/g/Alpha.j64"); CHECK(hasFavorite(l, a) && hasFavorite(l, b) && !hasFavorite(l, g)); CHECK(l.favorites[0].when == 101); // re-adding keeps the original date and place setFavoriteIn(l, a, false); CHECK(l.favorites.size() == 1 && !hasFavorite(l, a)); setFavoriteIn(l, a, false); // removing what is not there is fine CHECK(l.favorites.size() == 1); // recents: newest first, deduplicated, capped recordPlayIn(l, a); recordPlayIn(l, b); recordPlayIn(l, g); CHECK(l.recents.size() == 3 && l.recents[0].path == "/g/Gamma.cue" && l.recents[2].path == "/g/Alpha.j64"); recordPlayIn(l, a); // played again: moves to the front, not listed twice CHECK(l.recents.size() == 3 && l.recents[0].path == "/g/Alpha.j64" && l.recents[1].path == "/g/Gamma.cue"); removeRecentIn(l, g); CHECK(l.recents.size() == 2 && !hasRecent(l, g) && hasRecent(l, a)); UserLists many; for (int i = 0; i < 30; i++) { UserItem it; it.path = "/x/" + std::to_string(i); it.when = i; recordPlayIn(many, it); } CHECK(many.recents.size() == kMaxRecentGames && many.recents.front().path == "/x/29" && many.recents.back().path == "/x/10"); CHECK(kMaxRecentGames == 20); // the file round-trips, tabs and newlines in names cannot break the format, junk lines are skipped UserItem odd = a; odd.title = "Tab\there\nand a line"; odd.path = "/g/odd\tname.j64"; UserLists rt = l; rt.favorites.push_back(odd); UserLists back = parseUserLists(serializeUserLists(rt)); CHECK(back.favorites.size() == 2 && back.recents.size() == 2); CHECK(back.favorites[0].path == "/g/Beta.j64" && back.favorites[0].crc == 0xB2B2B2B2u && back.favorites[0].key == "Beta (World)"); CHECK(back.favorites[1].title == "Tab here and a line" && back.favorites[1].path == "/g/odd name.j64"); CHECK(back.recents[0].when == rt.recents[0].when); UserLists junk = parseUserLists("garbage\nfav\tnot enough\n\n# comment\nfav\t5\t0000000A\tk\t/p\tt\r\nrec\t6\tzz\t\t\t\nwho\t1\t1\tk\tp\tt\n"); CHECK(junk.favorites.size() == 1 && junk.favorites[0].crc == 0xA && junk.favorites[0].title == "t" && junk.recents.empty()); // resolving against the library: by path, by checksum when the folder moved, by key when the file changed UserLists r; setFavoriteIn(r, userItemFor(entries[1], 1), true); // Beta setFavoriteIn(r, userItemFor(entries[0], 2), true); // Alpha UserItem moved = userItemFor(entries[2], 3); // Gamma, now somewhere else moved.path = "/old-folder/Gamma.cue"; setFavoriteIn(r, moved, true); UserItem gone; gone.path = "/unplugged/Epsilon.j64"; gone.crc = 0xE5E5E5E5u; gone.key = "crc-E5E5E5E5"; setFavoriteIn(r, gone, true); std::vector res = resolveUserItems(r.favorites, entries); CHECK(res.size() == 3); // Epsilon's drive is away: skipped, not forgotten CHECK(r.favorites.size() == 4); sortByTitle(res); CHECK(res.size() == 3 && res[0].entry->title == "Alpha" && res[1].entry->title == "beta" && res[2].entry->title == "Gamma"); // a game whose handles all changed is a different game UserItem stranger; stranger.path = "/else/X.j64"; stranger.crc = 0x99999999u; stranger.key = "X"; CHECK(!sameGame(stranger, a) && sameGame(moved, userItemFor(entries[2], 0))); // BIOS files never resolve, even when listed std::vector biosItem; biosItem.push_back(userItemFor(bios, 1)); CHECK(resolveUserItems(biosItem, entries).empty()); // the same game twice in the list (an old and a new handle) shows once std::vector twice; twice.push_back(userItemFor(entries[0], 1)); UserItem again = userItemFor(entries[0], 2); again.path = "/elsewhere/Alpha.j64"; twice.push_back(again); CHECK(resolveUserItems(twice, entries).size() == 1); // strips: only the entries of that media res = resolveUserItems(r.favorites, entries); CHECK(filterBySection(res, Section::Cart).size() == 2 && filterBySection(res, Section::Disc).size() == 1); CHECK(filterBySection(res, Section::Bios).empty()); // a favorite made while the game was unidentified (key crc-XXXX) is still a favorite once the database names it Entry unid = fakeEntry("/g/Zeta.j64", "Zeta", 0x2E7A2E7Au); UserLists dr; setFavoriteIn(dr, userItemFor(unid, 1), true); CHECK_EQ(userItemFor(unid, 0).key, "crc-2E7A2E7A"); Entry ident = unid; ident.datName = "Zeta (USA)"; ident.title = "Zeta (USA)"; CHECK(hasFavorite(dr, userItemFor(ident, 0))); // the checksum and the path still match UserItem renamedFile = userItemFor(ident, 0); renamedFile.path = "/moved/Zeta.j64"; CHECK(hasFavorite(dr, renamedFile)); // checksum alone renamedFile.crc = 0; // an entry with no usable checksum: the key was the only handle UserLists kr; UserItem keyed = userItemFor(fakeEntry("/g/Img.chd", "Img", 0, "Img (USA)", Kind::Disc), 1); setFavoriteIn(kr, keyed, true); UserItem keyed2 = keyed; keyed2.path = "/moved/Img.chd"; CHECK(hasFavorite(kr, keyed2)); // dat name alone setFavoriteIn(dr, userItemFor(ident, 5), true); // starring it again after the update changes nothing CHECK(dr.favorites.size() == 1 && dr.favorites[0].key == "Zeta (USA)"); } static void testUserStoreAndInstances() { using namespace lib; const std::string dir = g_tmp + "userstore/"; std::error_code ec; fs::create_directories(fs::u8path(dir), ec); const std::string file = dir + "user.tsv"; UserItem a, b, c; a.path = "/g/A.j64"; a.crc = 0xA; a.key = "crc-0000000A"; a.title = "A"; b.path = "/g/B.j64"; b.crc = 0xB; b.key = "crc-0000000B"; b.title = "B"; c.path = "/g/C.j64"; c.crc = 0xC; c.key = "crc-0000000C"; c.title = "C"; UserStore one, two; // two running copies on one data folder one.open(file); two.open(file); CHECK(one.lists().favorites.empty() && !fs::exists(fs::u8path(file))); // reading creates nothing CHECK(one.setFavorite(a, true)); CHECK(fs::exists(fs::u8path(file))); // the second copy has not looked yet; it acts anyway, on top of what the file holds, not on its own snapshot CHECK(two.lists().favorites.empty()); CHECK(two.setFavorite(b, true)); CHECK(two.lists().favorites.size() == 2); // it took copy one's favorite first CHECK(two.recordPlay(c)); // copy one sees all of it after its next refresh, and refresh reports a change exactly once CHECK(one.lists().favorites.size() == 1); unsigned g0 = one.generation(); CHECK(one.refresh()); CHECK(one.generation() != g0); CHECK(one.lists().favorites.size() == 2 && one.lists().recents.size() == 1 && one.lists().recents[0].path == "/g/C.j64"); CHECK(!one.refresh()); // removing in one copy is not undone by the other copy's next unrelated action CHECK(one.setFavorite(a, false)); CHECK(two.recordPlay(a)); // two still believes A is a favorite; it only records a play CHECK(two.lists().favorites.size() == 1 && two.lists().favorites[0].path == "/g/B.j64"); CHECK(two.lists().recents.size() == 2 && two.lists().recents[0].path == "/g/A.j64"); // a copy that restarts sees the same lists UserStore three; three.open(file); CHECK(three.lists().favorites.size() == 1 && three.lists().recents.size() == 2); CHECK(three.removeRecent(c)); CHECK(three.lists().recents.size() == 1); CHECK(three.clearRecents()); CHECK(three.lists().recents.empty() && three.lists().favorites.size() == 1); // clearing recents leaves favorites CHECK(one.refresh() && one.lists().recents.empty()); // a file that cannot be read is never overwritten: simulate by a directory standing where the file is const std::string bad = dir + "dir-as-file.tsv"; fs::create_directories(fs::u8path(bad), ec); UserStore broken; broken.open(bad); CHECK(!broken.setFavorite(a, true)); CHECK(fs::is_directory(fs::u8path(bad))); // not opened at all (no data folder): every operation is a harmless no UserStore none; CHECK(!none.isOpen() && !none.setFavorite(a, true) && !none.refresh() && !none.recordPlay(a)); // a hand-edited file with a blank / missing list is fine writeText(dir + "empty.tsv", ""); UserStore e; e.open(dir + "empty.tsv"); CHECK(e.lists().favorites.empty() && e.setFavorite(a, true) && e.lists().favorites.size() == 1); } static void testStateNamesAndListing() { using namespace lib; std::string stem; int slot = -1; CHECK_EQ(stateFileName("Iron Soldier (World)", 3), "Iron Soldier (World).slot3.state"); CHECK_EQ(thumbFileName("Iron Soldier (World)", 3), "Iron Soldier (World).slot3.png"); CHECK(parseStateFileName("Iron Soldier (World).slot3.state", stem, slot) && stem == "Iron Soldier (World)" && slot == 3); CHECK(parseStateFileName("a.b.slot12.state", stem, slot) && stem == "a.b" && slot == 12); CHECK(!parseStateFileName("x.slot3.png", stem, slot)); // the screenshot CHECK(!parseStateFileName("x.slot3.state.rcp", stem, slot)); // the achievements sidecar CHECK(!parseStateFileName(".slot3.state", stem, slot)); // no game name CHECK(!parseStateFileName("x.slotA.state", stem, slot) && !parseStateFileName("x.slot.state", stem, slot)); CHECK(!parseStateFileName("x.state", stem, slot) && !parseStateFileName("notes.txt", stem, slot)); const std::string dir = g_tmp + "states/"; std::error_code ec; fs::create_directories(fs::u8path(dir), ec); CHECK(listStates(dir).empty()); CHECK(listStates(g_tmp + "no-such-states-dir/").empty()); makeCoverPng("shot.png", 32, 24, 5); auto put = [&](const std::string& st, int sl, int ageMin, bool thumb) { writeText(dir + stateFileName(st, sl), "STATE" + st + std::to_string(sl)); fs::last_write_time(fs::u8path(dir + stateFileName(st, sl)), fs::file_time_type::clock::now() - std::chrono::minutes(ageMin), ec); if (thumb) fs::copy_file(fs::u8path(g_tmp + "shot.png"), fs::u8path(dir + thumbFileName(st, sl)), fs::copy_options::overwrite_existing, ec); }; put("Cybermorph (USA)", 0, 600, true); put("Cybermorph (USA)", 4, 30, true); put("Cybermorph (USA)", 7, 90, false); // a state with no screenshot put("Tempest 2000 (World)", 1, 5, true); put("Old Game", 2, 5000, false); writeText(dir + "Cybermorph (USA).slot4.state.rcp", "side"); // sidecar: not a state of its own writeText(dir + "readme.txt", "x"); writeText(dir + "Stray.slot1.png", "x"); // a screenshot with no state: not listed std::vector g = listStates(dir); CHECK(g.size() == 3); CHECK(g.size() == 3 && g[0].stem == "Tempest 2000 (World)" && g[1].stem == "Cybermorph (USA)" && g[2].stem == "Old Game"); // newest first CHECK(g.size() == 3 && g[1].states.size() == 3); if (g.size() == 3 && g[1].states.size() == 3) { CHECK(g[1].states[0].slot == 4 && g[1].states[1].slot == 7 && g[1].states[2].slot == 0); // newest first within a game CHECK(!g[1].states[0].thumbPath.empty() && g[1].states[1].thumbPath.empty()); CHECK(g[1].states[0].path == dir + "Cybermorph (USA).slot4.state" && g[1].states[0].size > 0 && g[1].states[0].mtime > 0); CHECK(g[1].newest == g[1].states[0].mtime); } // which library entry owns a stem: the file stem, BIOS files excluded std::vector entries; entries.push_back(fakeEntry("/g/Tempest 2000 (World).j64", "Tempest 2000", 1)); entries.push_back(fakeEntry("/g/Cybermorph (USA).cue", "Cybermorph", 2, "", Kind::Disc)); Entry bios = fakeEntry("/sys/Old Game.rom", "Old Game", 3); bios.section = Section::Bios; entries.push_back(bios); CHECK(entryForStem(entries, "Cybermorph (USA)") == &entries[1] && entryForStem(entries, "Tempest 2000 (World)") == &entries[0]); CHECK(entryForStem(entries, "Old Game") == nullptr && entryForStem(entries, "Nope") == nullptr); // delete: the state, its screenshot and the achievements sidecar go; the others stay StateInfo victim = g[1].states[0]; CHECK(deleteState(victim)); CHECK(!fs::exists(fs::u8path(victim.path)) && !fs::exists(fs::u8path(victim.thumbPath))); CHECK(!fs::exists(fs::u8path(dir + "Cybermorph (USA).slot4.state.rcp"))); CHECK(fs::exists(fs::u8path(dir + "Cybermorph (USA).slot7.state")) && fs::exists(fs::u8path(dir + "readme.txt"))); CHECK(!deleteState(victim)); // already gone g = listStates(dir); CHECK(g.size() == 3 && g[1].stem == "Cybermorph (USA)" && g[1].states.size() == 2); // Show in Finder / Explorer: the folder's file:// URL CHECK_EQ(folderUrlOf("/Users/me/Jaguar Games/Iron Soldier.j64"), "file:///Users/me/Jaguar%20Games/"); CHECK_EQ(folderUrlOf("/g/Dragon's Lair (USA).cue"), "file:///g/"); CHECK_EQ(folderUrlOf("C:\\Games\\Jag #1\\x.j64"), "file:///C:/Games/Jag%20%231/"); CHECK_EQ(folderUrlOf("/g/caf\xC3\xA9/x.j64"), "file:///g/caf%C3%A9/"); CHECK_EQ(folderUrlOf("x.j64"), ""); } // ---- resume where I left off: the policy, the auto-save slot's names and where each running copy keeps it -------- static void testResumePolicy() { using namespace lib; CHECK(kResumeDefault == ResumeAsk && kResumeModeCount == 3 && kAutoSaveMinSeconds >= 3.0 && kAutoSaveMinSeconds <= 10.0); CHECK(clampResumeMode(0) == ResumeOff && clampResumeMode(2) == ResumeAlways && clampResumeMode(3) == ResumeAsk && clampResumeMode(-1) == ResumeAsk); CHECK_EQ(resumeModeLabel(ResumeAlways), "Always"); // writing: only with the option on, past the minimum runtime, and not in hardcore / netplay / after a crash const double ok = kAutoSaveMinSeconds + 1.0; CHECK(shouldAutoSave(ResumeAsk, false, false, ok, false)); CHECK(shouldAutoSave(ResumeAlways, false, false, ok, false)); CHECK(!shouldAutoSave(ResumeOff, false, false, ok, false)); // Off writes nothing CHECK(!shouldAutoSave(ResumeAsk, true, false, ok, false)); // hardcore refuses state loads: skip CHECK(!shouldAutoSave(ResumeAlways, true, false, 9999.0, false)); CHECK(!shouldAutoSave(ResumeAsk, false, true, ok, false)); // netplay: a state would desync the link CHECK(!shouldAutoSave(ResumeAsk, false, false, kAutoSaveMinSeconds - 0.01, false)); // ran too briefly CHECK(shouldAutoSave(ResumeAsk, false, false, kAutoSaveMinSeconds, false)); // exactly the minimum counts CHECK(!shouldAutoSave(ResumeAsk, false, false, 0.0, false)); CHECK(!shouldAutoSave(ResumeAsk, false, false, ok, true)); // the crash watchdog flagged the run // booting: Always loads, Ask asks, Off and anything blocked does nothing; no auto-save means nothing to do CHECK(resumeAction(ResumeAlways, false, false, true) == ResumeAction::Load); CHECK(resumeAction(ResumeAsk, false, false, true) == ResumeAction::Ask); CHECK(resumeAction(ResumeOff, false, false, true) == ResumeAction::None); CHECK(resumeAction(ResumeAlways, true, false, true) == ResumeAction::None); // hardcore: skip, and do not prompt CHECK(resumeAction(ResumeAsk, true, false, true) == ResumeAction::None); CHECK(resumeAction(ResumeAlways, false, true, true) == ResumeAction::None); // netplay CHECK(resumeAction(ResumeAlways, false, false, false) == ResumeAction::None); CHECK(resumeAction(ResumeAsk, false, false, false) == ResumeAction::None); // the slot: its own file pair per game, beside (never colliding with) the numbered ones std::string stem; int slot = 0; CHECK_EQ(stateFileName("Iron Soldier (World)", kAutoSlot), "Iron Soldier (World).auto.state"); CHECK_EQ(thumbFileName("Iron Soldier (World)", kAutoSlot), "Iron Soldier (World).auto.png"); CHECK_EQ(stateFileName("Iron Soldier (World)", 0), "Iron Soldier (World).slot0.state"); // unchanged CHECK(parseStateFileName("Iron Soldier (World).auto.state", stem, slot) && stem == "Iron Soldier (World)" && slot == kAutoSlot); CHECK(parseStateFileName("a.auto.auto.state", stem, slot) && stem == "a.auto" && slot == kAutoSlot); // a game called "a.auto" CHECK(parseStateFileName("a.auto.slot2.state", stem, slot) && stem == "a.auto" && slot == 2); CHECK(!parseStateFileName(".auto.state", stem, slot) && !parseStateFileName("x.auto.png", stem, slot) && !parseStateFileName("x.auto.state.rcp", stem, slot)); CHECK_EQ(stateLabel(kAutoSlot), "Auto-save"); CHECK_EQ(stateLabel(4), "Slot 4"); // where each running copy keeps it CHECK_EQ(autoStatesDir("/d/states/", 1), "/d/states/"); CHECK_EQ(autoStatesDir("/d/states/", 0), "/d/states/"); CHECK_EQ(autoStatesDir("/d/states/", 2), "/d/states/instance-2/"); CHECK_EQ(autoStatesDir("/d/states/", 3), "/d/states/instance-3/"); const std::string dir = g_tmp + "autostates/"; std::error_code ec; fs::create_directories(fs::u8path(dir + "instance-2"), ec); auto put = [&](const std::string& d, const std::string& name, int ageMin) { writeText(d + name, "S"); fs::last_write_time(fs::u8path(d + name), fs::file_time_type::clock::now() - std::chrono::minutes(ageMin), ec); }; put(dir, "Alpha.slot1.state", 50); put(dir, "Alpha.auto.state", 10); // the first copy's auto-save put(dir, "Beta.auto.state", 20); // a game only the first copy auto-saved put(dir + "instance-2/", "Alpha.auto.state", 5); // copy 2's own // copy 1: numbered states and its auto-saves in the states folder; copy 2's folder is not looked at std::vector one = listStatesFor(dir, 1); CHECK(one.size() == 2 && one[0].stem == "Alpha" && one[0].states.size() == 2 && one[0].states[0].slot == kAutoSlot); CHECK(one.size() == 2 && one[0].states[0].path == dir + "Alpha.auto.state"); // copy 2: the shared numbered states, ITS auto-save, not copy 1's std::vector two = listStatesFor(dir, 2); CHECK(two.size() == 1 && two[0].stem == "Alpha" && two[0].states.size() == 2); if (two.size() == 1 && two[0].states.size() == 2) { CHECK(two[0].states[0].slot == kAutoSlot && two[0].states[0].path == dir + "instance-2/Alpha.auto.state"); CHECK(two[0].states[1].slot == 1); CHECK(two[0].newest == two[0].states[0].mtime); } // an auto-save is deleted like any state (and its screenshot) writeText(dir + "Alpha.auto.png", "P"); StateInfo a = one[0].states[0]; a.thumbPath = dir + "Alpha.auto.png"; CHECK(deleteState(a) && !fs::exists(fs::u8path(a.path)) && !fs::exists(fs::u8path(a.thumbPath))); CHECK(listStatesFor(dir, 1)[0].states.size() == 1 || listStatesFor(dir, 1).size() == 2); } // The service wiring: scan a folder, star and play games, see them resolve, share the file with a second copy, // list and delete states, and the settings round-trip. static void testPersonalListsService() { std::string root = g_tmp + "lists-root/", games = g_tmp + "lists-games/"; std::error_code ec; fs::create_directories(fs::u8path(root + "library/db"), ec); fs::create_directories(fs::u8path(root + "states"), ec); writeBytes(games + "Alpha (World).j64", noise(1048576, 71)); writeBytes(games + "Beta (World).j64", noise(1048576, 72)); std::vector d1 = noise(2352 * 30, 73); writeBytes(games + "Gamma Disc (USA) (Track 1).bin", d1); writeText(games + "Gamma Disc (USA).cue", "FILE \"Gamma Disc (USA) (Track 1).bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\n"); Paths paths; paths.root = root; paths.states = root + "states/"; Config cfg; cfg.setPath(root + "config.ini"); cfg.set("library.dir.0", games.substr(0, games.size() - 1)); LibraryService svc; svc.createTexture = [](const void*, int, int) { return 1u; }; svc.destroyTexture = [](unsigned) {}; svc.artBaseUrl = "http://127.0.0.1:1/"; svc.artMirrorUrl = svc.artBaseUrl; svc.dbBaseUrl = svc.artBaseUrl; svc.start(paths, cfg, false); svc.setDownloadArt(false); auto pumpUntil = [&](const std::function& cond) { for (int i = 0; i < 400; i++) { svc.pump(); if (cond()) return true; SDL_Delay(25); } return false; }; CHECK(pumpUntil([&] { return !svc.scanning() && svc.entries().size() == 3; })); auto find = [&](const std::string& title) { lib::Entry e; for (const lib::Entry& x : svc.entries()) if (x.title == title) e = x; return e; }; lib::Entry alpha = find("Alpha (World)"), beta = find("Beta (World)"), gamma = find("Gamma Disc (USA)"); CHECK(!alpha.file.path.empty() && !beta.file.path.empty() && !gamma.file.path.empty()); // defaults: both lists shown as a tab and in the sections CHECK(svc.favoritesShow == lib::ShowBoth && svc.recentsShow == lib::ShowBoth && svc.activeTab() == lib::TabCart); CHECK(svc.favoriteEntries().empty() && svc.recentEntries().empty()); svc.setFavorite(beta, true); svc.setFavorite(gamma, true); CHECK(svc.isFavorite(beta) && svc.isFavorite(gamma) && !svc.isFavorite(alpha)); CHECK(svc.favoriteEntries().size() == 2 && svc.favoriteEntries()[0].entry->title == "Beta (World)"); // by title svc.noteLaunched(alpha.file.path); svc.noteLaunched(beta.file.path); svc.noteLaunched("/not/in/the/library.j64"); // scanned, not one of ours: not listed CHECK(svc.recentEntries().size() == 2 && svc.recentEntries()[0].entry->title == "Beta (World)" && svc.recentEntries()[1].entry->title == "Alpha (World)"); CHECK(svc.recentEntries()[0].when > 1700000000); CHECK(svc.isRecent(alpha) && !svc.isRecent(gamma)); svc.removeRecent(beta); CHECK(svc.recentEntries().size() == 1 && svc.isFavorite(beta)); // not the same thing // the lists survive a restart of the service svc.setFavorite(gamma, false); svc.stop(); LibraryService svc2; svc2.createTexture = svc.createTexture; svc2.destroyTexture = svc.destroyTexture; svc2.artBaseUrl = svc2.artMirrorUrl = svc2.dbBaseUrl = "http://127.0.0.1:1/"; Paths p2 = paths; p2.instance = 2; // a second copy on the same data folder Config cfg2; cfg2.setPath(root + "config-2.ini"); cfg2.setSavePrefixes({"window.", "input.pads."}); // what App::loadConfig sets for instance 2+ (the #902 rule) cfg2.set("library.dir.0", games.substr(0, games.size() - 1)); svc2.start(p2, cfg2, false); svc2.setDownloadArt(false); auto pump2 = [&](const std::function& cond) { for (int i = 0; i < 400; i++) { svc2.pump(); if (cond()) return true; SDL_Delay(25); } return false; }; CHECK(pump2([&] { return !svc2.scanning() && svc2.entries().size() == 3; })); CHECK(svc2.instance == 2); CHECK(svc2.favoriteEntries().size() == 1 && svc2.favoriteEntries()[0].entry->title == "Beta (World)"); CHECK(svc2.recentEntries().size() == 1 && svc2.recentEntries()[0].entry->title == "Alpha (World)"); // ...and what the second copy does is in the shared file, for the first copy to read lib::Entry alpha2; for (const lib::Entry& x : svc2.entries()) if (x.title == "Alpha (World)") alpha2 = x; svc2.setFavorite(alpha2, true); lib::UserStore reader; reader.open(svc2.userFile()); CHECK(reader.lists().favorites.size() == 2); // display options persist (clamped on the way in) svc2.favoritesShow = lib::ShowSections; svc2.recentsShow = lib::ShowHidden; svc2.section = lib::TabRecents; // remembered tab, now hidden svc2.saveSettings(); CHECK_EQ(cfg2.get("library.favorites.show", ""), "2"); CHECK_EQ(cfg2.get("library.recents.show", ""), "0"); { // ...but the display options are ordinary settings: copy 2 reads them and its config-2.ini never gets them std::vector raw; std::string written; if (readFile(root + "config-2.ini", raw)) written.assign(raw.begin(), raw.end()); CHECK(written.find("library.") == std::string::npos); } CHECK(svc2.activeTab() == lib::TabCart); svc2.stop(); Config cfg3; cfg3.set("library.dir.0", games.substr(0, games.size() - 1)); cfg3.set("library.favorites.show", "9"); // junk: back to the default cfg3.set("library.recents.show", "1"); cfg3.setInt("library.section", 4); LibraryService svc3; svc3.artBaseUrl = svc3.artMirrorUrl = svc3.dbBaseUrl = "http://127.0.0.1:1/"; svc3.start(paths, cfg3, false); CHECK(svc3.favoritesShow == lib::ShowBoth && svc3.recentsShow == lib::ShowTab && svc3.section == lib::TabRecents && svc3.activeTab() == lib::TabRecents); svc3.stop(); LibraryService svc4; Config cfg4; cfg4.setInt("library.section", 77); svc4.artBaseUrl = svc4.artMirrorUrl = svc4.dbBaseUrl = "http://127.0.0.1:1/"; svc4.start(paths, cfg4, false); CHECK(svc4.section == lib::kTabCount - 1); // an out-of-range remembered tab is clamped, then falls back at draw time svc4.stop(); // save states: listing, per-game lookup, screenshots, delete LibraryService s5; int created = 0, destroyed = 0; s5.createTexture = [&](const void*, int, int) { return (unsigned)++created; }; s5.destroyTexture = [&](unsigned) { destroyed++; }; s5.artBaseUrl = s5.artMirrorUrl = s5.dbBaseUrl = "http://127.0.0.1:1/"; Config cfg5; cfg5.set("library.dir.0", games.substr(0, games.size() - 1)); s5.start(paths, cfg5, false); s5.setDownloadArt(false); CHECK(s5.stateGames().empty()); makeCoverPng("st.png", 48, 36, 9); writeText(paths.states + "Alpha (World).slot0.state", "S0"); writeText(paths.states + "Alpha (World).slot2.state", "S2"); fs::copy_file(fs::u8path(g_tmp + "st.png"), fs::u8path(paths.states + "Alpha (World).slot2.png"), fs::copy_options::overwrite_existing, ec); writeText(paths.states + "Removed Game.slot1.state", "S"); s5.invalidateStates(); CHECK(s5.stateGames().size() == 2); auto pump5 = [&](const std::function& cond) { for (int i = 0; i < 400; i++) { s5.pump(); if (cond()) return true; SDL_Delay(25); } return false; }; CHECK(pump5([&] { return !s5.scanning() && s5.entries().size() == 3; })); lib::Entry a5; for (const lib::Entry& x : s5.entries()) if (x.title == "Alpha (World)") a5 = x; const lib::StateGame* sg = s5.statesFor(a5); CHECK(sg != nullptr && sg->states.size() == 2); if (sg && sg->states.size() == 2) { lib::StateInfo withShot = sg->states[0].thumbPath.empty() ? sg->states[1] : sg->states[0]; lib::StateInfo noShot = sg->states[0].thumbPath.empty() ? sg->states[0] : sg->states[1]; CHECK(withShot.slot == 2 && noShot.slot == 0); ArtView t = s5.stateThumb(withShot); CHECK(t.state == ArtView::Ready && t.w == 48 && t.h == 36 && t.tex != 0); ArtView t2 = s5.stateThumb(withShot); CHECK(t2.state == ArtView::Ready && t2.tex == t.tex && created == 1); // cached CHECK(s5.stateThumb(noShot).state == ArtView::Missing); const lib::Entry* owner = s5.entryForState(*sg); CHECK(owner && owner->title == "Alpha (World)"); CHECK(s5.deleteState(withShot)); CHECK(destroyed == 1); const lib::StateGame* again = s5.statesFor(a5); CHECK(again && again->states.size() == 1 && again->states[0].slot == 0); } const lib::StateGame* orphan = nullptr; for (const lib::StateGame& g : s5.stateGames()) if (g.stem == "Removed Game") orphan = &g; CHECK(orphan != nullptr && s5.entryForState(*orphan) == nullptr); // a game that left the library: listed, cannot be played from here s5.stop(); } int main(int argc, char** argv) { if (argc >= 3 && !std::strcmp(argv[1], "--scan")) { std::string db = "."; for (int i = 3; i + 1 < argc; i++) if (!std::strcmp(argv[i], "--db")) db = argv[i + 1]; return scanTool(argv[2], db); } #ifndef _WIN32 std::signal(SIGPIPE, SIG_IGN); // the loopback server writes to clients that hang up on purpose #endif SDL_Init(SDL_INIT_EVENTS); g_tmp = mkTmp(); std::printf("library tests, HTTP backend: %s\n", http::backendName()); testCrc(); testDatParsing(); testHeaderDetection(); testThumbnailNames(); testScanAndIdentify(); testBiosClassification(); testBiosInLibraryFolders(); testTileZoom(); testDiscTitles(); testCoverLookup(); testCoverService(); testLooseNames(); testChdAndCdiChecksums(); testM3u(); testPlaylistStateOwners(); testDisplayOptionsAndTabs(); testPersonalListsLogic(); testUserStoreAndInstances(); testStateNamesAndListing(); testResumePolicy(); testPersonalListsService(); #ifndef _WIN32 if (http::available()) { testHttp(); testServicePipeline(); testCdMirror(); } else { std::printf("no HTTP backend in this build: loopback HTTP tests skipped\n"); } #else std::printf("loopback HTTP tests are POSIX-only: skipped\n"); #endif std::error_code ec; fs::remove_all(fs::u8path(g_tmp), ec); // our own temp dir only std::printf("%d checks, %d failed\n", g_checks, g_fail); return g_fail ? 1 : 0; }