Files
virtualjaguar-libretro/app/tests/cheats_test.cpp
Joe MattielloandClaude Opus 5.5 a1d9c4807a feat(app): Community cheats in the Cheats window (#1071, part 2) (#1076)
* feat(app): sha256 for verified downloads

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): CRC-keyed community cheat set in CheatManager

CheatManager loads <communityDir>/<CRC32>.cht for the running cartridge by CRC,
independent of the filename/title-matched database set. The pack is guarded by
vj_rom_crc32 like a revision-bound database set (unbound or mismatched: every
cheat unsupported plus a restriction), crc 0 (discs) never loads one.

Toggles persist under cp.<id> (database keeps on.<id>); ids are unchanged so an
updated pack keeps toggles for unchanged cheats and a changed cheat comes back
off, and cp.* keys that match no cheat are pruned on load. apply() orders
database, community (5000+i), then manual codes (10000+i). New public API:
loadChtText, setCommunityDir, reloadCommunity, communitySet,
removeCommunityToggles, CheatOrigin on isEnabled/setEnabled.

Also corrects two stale comments in cheats_test.cpp (sha256 vectors, linked
sources).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): drop dead prune loop, document setEnabledByName fall-through

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): community cheat store service

CheatStore fetches the community manifest, installs a cartridge's pack after
verifying it (size, sha256, vj_rom_crc32 guard, parse), tracks per-pack state,
and feeds CheatManager. Network and worker are injected hooks; results are
applied on the main thread through pump().

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): harden cheat store against throwing hooks, racing remove, message floods

A throwing fetch hook can no longer leave stop() hanging (RAII job slot plus
catch); remove() refuses while an install for the same CRC is in flight; and
manifest messages are capped at 8 entries of 200 bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(app): Community cheats section in the Cheats window

Wires CheatStore into the app (#1071): the store, its one-request worker and
the http::get fetch hook (any status but 200 is an error, the store's cancel
flag is passed through) are set up in cheatsInit with downloads gated by the
single Library "Download art and cheat packs" switch (library.art, label
renamed) and cheats.community.url as the base URL override. The store is
pumped every frame, told of game load/close, and stopped (cancel first, then
the worker) before the cheat manager goes.

The Cheats window gains a "Community cheats" section between Database cheats
and Manual codes (install / update / remove / check states, credit line,
collapsed conversion notes, 256-write warning, hardcore line); both sections
share drawCheatRows, which now passes CheatOrigin. Buttons are disabled while
a request is in flight and stay live in hardcore, where only toggles are off.

--cheats-community-install is the headless twin (fetch the manifest, install
the loaded cartridge's pack); cheat_community_install runs it against a
loopback server serving site/cheats, then offline, then with nothing to
install from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(cheats): describe the community cheat store (#1071)

Privacy page: a row for the project website's cheat store (when it is asked,
what is sent: the pack index and the one pack named by the cartridge's CRC32,
nothing else) and cheats/community/ in the local-files list; the art row
follows the renamed "Download art and cheat packs" switch. App page, app
README, the converter README and WHATSNEW describe installing packs from the
Cheats window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): marshal cheat store game events to the main thread, report turned-off cheats

The store is main-thread only but cheatsAfterLoad / cheatsOnClose run on the
emulation thread in threaded mode. They now post the load / close
(CheatStore::postGameLoaded / postGameClosed, last post wins) and pump()
applies it before finished requests, so refresh(), announce() and the message
toasts stay on the main thread.

After an Update, the section says how many cheats that were on changed and
came back off (CheatManager::lastPrunedEnabled, CheatStore::turnedOff; cleared
by the next load or Remove). A store that was never reached says so instead of
"No community pack", and Check for updates is shown disabled rather than
hidden while checking or with downloads off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): keep an unreadable community pack removable, pump the store during a halt

The Cheats window derived Remove (and Update) from cm.communitySet(), so a
<CRC>.cht that exists but does not parse showed sidecar text with no way out.
Remove and Update now follow the store state alone; only the cheat rows need
the parsed set, and an unreadable or restricted pack gets a one-line notice.
The database "no set matches" status is dimmed when a community pack already
supplies cheats, a greyed Update says downloads are off, and haltedFrame pumps
the store so installs finish during a debugger halt.

Also documents the emulation-thread toast/notice contract in cheatsAfterLoad
and tidies style (comma-operator statements, std::set, include order).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(app): keep the community store fixtures byte-exact on Windows

The fixture pack's sha256 and size are pinned in the fixture manifest.
Git for Windows' autocrlf turned its LF into CRLF on checkout, so
cheats_unit failed verifyPack on every Windows app job. Mark the
fixtures -text so they are checked out exactly as committed.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): headless community install repairs a locally edited pack

--cheats-community-install skipped install() when the installed pack's
bytes no longer matched its sidecar (EditedLocally), then failed its own
Installed check, although the Cheats window offers Update in that state.
Install there too when the store offers the pack. The loopback smoke test
now hand-edits the installed pack and expects the command to replace it.

Refs #1071

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 03:23:05 -04:00

1891 lines
84 KiB
C++

// Unit tests for the cheat database parser, the conversion to the core's code
// format and the per-game persistence. Links only cheats.cpp, config.cpp,
// paths.cpp, sha256.cpp and the core's own cheat.c (so every code we emit is run
// through the real parser): no SDL window, no GL, no emulation.
//
// vj_cheats_test the unit tests (fixtures are synthetic .cht files)
// vj_cheats_test --corpus DIR parse every .cht in DIR (the downloaded libretro database)
#include "cheats.h"
#include "cheat_store.h"
#include "cheat.h"
#include "neuro/neuro_json.h"
#include "paths.h"
#include "sha256.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <future>
#include <chrono>
#include <map>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#ifndef VJ_TEST_FIXTURES
#error VJ_TEST_FIXTURES must name the fixtures directory
#endif
using namespace vj;
namespace fs = std::filesystem;
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 va_ = (a), vb_ = (b); \
if (va_ != vb_) { \
g_fail++; \
std::fprintf(stderr, "FAIL %s:%d %s\n got '%s'\n expected '%s'\n", __FILE__, __LINE__, #a, \
va_.c_str(), vb_.c_str()); \
} \
} while (0)
static std::string fixture(const std::string& name) { return std::string(VJ_TEST_FIXTURES) + "/" + name; }
static const ChtCheat* find(const ChtSet& s, const std::string& desc) {
for (const ChtCheat& c : s.cheats)
if (c.desc == desc) return &c;
return nullptr;
}
// The emulated main RAM as the core stores it: byte i is the byte at bus address i.
struct Ram {
std::vector<uint8_t> b = std::vector<uint8_t>(0x200000, 0);
};
static void ramWrite(uint32_t a, uint32_t v, uint8_t size, void* user) {
Ram* r = (Ram*)user;
for (unsigned i = 0; i < size; i++) r->b[(a + i) & 0x1FFFFF] = (uint8_t)(v >> (8 * (size - 1 - i))); // Jaguar is big-endian
}
// Run `code` through the core's real cheat list and write it into `ram`.
static void play(Ram& ram, const std::string& code) {
static cheat_list_t list;
cheat_list_reset(&list);
cheat_list_set(&list, 0, true, code.c_str());
cheat_list_apply(&list, ramWrite, &ram);
}
struct Recorder {
std::vector<std::string> log;
void attach(CheatManager& m) {
m.commit = [this](CheatBatch b) {
batches++;
for (const CheatOp& o : b) {
if (o.reset) log.push_back("reset");
else log.push_back("set " + std::to_string(o.index) + (o.enabled ? " on " : " off ") + o.code);
}
};
}
int batches = 0;
};
// ---------------------------------------------------------------------------------
static void testParse() {
ChtSet s;
CHECK(loadChtFile(fixture("Test Game.cht"), s));
CHECK_EQ(s.stem, "Test Game");
// 13 entries, the three "Score (Part N)" lines merge into one toggle.
CHECK(s.cheats.size() == 11);
const ChtCheat* c = find(s, "Lives");
CHECK(c && c->supported);
if (c) {
CHECK_EQ(c->code, "00007D3D:05"); // 8-bit: the ':' stays, or the core reads 6+4 digits
CHECK(c->segments == 1);
}
c = find(s, "Score");
CHECK(c && c->supported);
if (c) {
CHECK(c->parts == 3);
CHECK(c->segments == 3);
CHECK_EQ(c->code, "0000C972:09090000+0000C974:0909+0000C976:0909");
}
CHECK(!find(s, "Score (Part 1)"));
c = find(s, "Odd word");
CHECK(c && c->supported);
if (c) CHECK_EQ(c->code, "00092255:02+00092256:01"); // odd address: bytes, never a word
c = find(s, "Odd long");
CHECK(c && c->supported);
if (c) CHECK_EQ(c->code, "000003E9:01+000003EA:0203+000003EC:04");
c = find(s, "Big endian word");
CHECK(c && c->supported);
if (c) CHECK_EQ(c->code, "00001000:1234");
c = find(s, "Embedded code");
CHECK(c && c->supported);
if (c) CHECK_EQ(c->code, "001CC84F:01"); // passed through unchanged
c = find(s, "Two-write code");
CHECK(c && c->supported && c->segments == 2);
for (const char* name : {"Increase by value", "Past the end of RAM", "Garbage code", "Emulator handler"}) {
c = find(s, name);
CHECK(c && !c->supported && !c->note.empty() && c->code.empty());
}
// ids are stable across parses and unique within a set
ChtSet s2;
CHECK(loadChtFile(fixture("Test Game.cht"), s2));
for (size_t i = 0; i < s.cheats.size(); i++) {
CHECK_EQ(s.cheats[i].id, s2.cheats[i].id);
for (size_t j = i + 1; j < s.cheats.size(); j++) CHECK(s.cheats[i].id != s.cheats[j].id);
}
// unquoted values, "enable = false", no cheat count line
ChtSet u;
CHECK(loadChtFile(fixture("Unquoted Style (World).cht"), u));
CHECK(u.cheats.size() == 2);
CHECK_EQ(u.cheats[0].code, "001CC80B:05");
CHECK_EQ(u.cheats[1].desc, "Unquoted desc");
CHECK_EQ(u.cheats[1].code, "00000100:7F");
// cheatN_enable is ignored: nothing is on by itself
CheatManager m;
Recorder rec;
rec.attach(m);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
GameIdentity g;
g.key = "crc-00000001";
g.title = "Test Game";
m.setStateDir((fs::temp_directory_path() / "vj-cheats-enable-ignored/").string());
fs::remove_all(fs::temp_directory_path() / "vj-cheats-enable-ignored");
m.gameLoaded(g);
CHECK(m.enabledCount() == 0);
for (const std::string& l : rec.log) CHECK(l == "reset");
fs::remove_all(fs::temp_directory_path() / "vj-cheats-enable-ignored");
}
// Every supported cheat, played through the core's real parser and list, must leave
// exactly the bytes RetroArch's own memory cheat would: the value little-endian
// (big_endian = false) at consecutive addresses.
static void testReplay() {
ChtSet s;
CHECK(loadChtFile(fixture("Test Game.cht"), s));
Ram ram;
for (const ChtCheat& c : s.cheats)
if (c.supported) play(ram, c.code);
CHECK(ram.b[0x7D3D] == 0x05);
for (uint32_t a = 0xC972; a <= 0xC977; a++) CHECK(ram.b[a] == 0x09); // 999999 as six digit bytes
CHECK(ram.b[0x92255] == 0x02 && ram.b[0x92256] == 0x01);
CHECK(ram.b[0x3E9] == 0x01 && ram.b[0x3EA] == 0x02 && ram.b[0x3EB] == 0x03 && ram.b[0x3EC] == 0x04);
CHECK(ram.b[0x1000] == 0x12 && ram.b[0x1001] == 0x34); // big_endian = true: most significant byte first
CHECK(ram.b[0x1CC84F] == 0x01);
CHECK(ram.b[0x58C87] == 0x03 && ram.b[0x58C88] == 0x04);
// untouched
CHECK(ram.b[0x7D3C] == 0 && ram.b[0x7D3E] == 0 && ram.b[0xC971] == 0 && ram.b[0xC978] == 0);
// The conversion itself, for every width and alignment, against a byte-by-byte model.
for (uint32_t addr : {0x100u, 0x101u, 0x1FFFF0u, 0x1FFFF1u}) {
for (unsigned n = 1; n <= 4; n++) {
uint8_t bytes[4] = {0xA1, 0xB2, 0xC3, 0xD4};
std::string code = memoryCheatToCode(addr, bytes, n);
unsigned segs = 0;
std::string why;
CHECK(validateCode(code, &segs, &why));
Ram r;
play(r, code);
for (unsigned i = 0; i < n; i++) CHECK(r.b[addr + i] == bytes[i]);
CHECK(r.b[addr + n] == 0);
CHECK(addr == 0 || r.b[addr - 1] == 0);
}
}
// The trap this guards: eight address digits + a byte with no separator reads as 6+4 to the core.
uint32_t a, v;
uint8_t sz;
CHECK(cheat_parse_one("00007D3D05", &a, &v, &sz) && sz == 2); // (documents the core's rule)
CHECK(cheat_parse_one("00007D3D:05", &a, &v, &sz) && a == 0x7D3D && v == 5 && sz == 1);
}
static void testHelpers() {
CHECK_EQ(normalizeTitle("Raiden (World) (Rev A)"), "raiden");
CHECK_EQ(normalizeTitle("Val d'Isere Skiing and Snowboarding"), "valdisereskiingandsnowboarding");
CHECK_EQ(normalizeTitle("Cybermorph [b]"), "cybermorph");
CHECK_EQ(slugify("Tempest 2000 (World)"), "tempest-2000-world");
CHECK_EQ(slugify("../../etc"), "etc");
CHECK(slugify("???").empty());
unsigned segs = 0;
std::string why;
CHECK(validateCode("00007D3D:05", &segs, &why) && segs == 1);
CHECK(validateCode("00007D3D:05+00007D3E:06\n00007D3F:07", &segs, &why) && segs == 3);
CHECK(!validateCode("", &segs, &why));
CHECK(!validateCode("hello", &segs, &why) && !why.empty());
CHECK(!validateCode("00007D3D:0", &segs, &why));
CHECK(!validateCode(std::string(80, 'A'), &segs, &why));
}
static void testLibraryMatching() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-lib";
fs::remove_all(tmp);
fs::create_directories(tmp / "Atari - Jaguar");
fs::copy_file(fixture("Test Game.cht"), tmp / "Cybermorph (1mb) (Rev A) (1993).cht");
fs::copy_file(fixture("Test Game.cht"), tmp / "Atari - Jaguar" / "Cybermorph (2mb) (Rev B) (1993).cht");
fs::copy_file(fixture("Test Game.cht"), tmp / "Tempest 2000.cht");
fs::copy_file(fixture("Test Game.cht"), tmp / "Tempest 2000 Plus Extras.cht");
std::ofstream(tmp / "notes.txt") << "not a cheat file";
fs::path state = tmp / "state";
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(state.string() + "/");
m.setLibraryDirs({tmp.string()});
m.rescan();
CHECK(m.library().size() == 4); // the "Atari - Jaguar" subfolder is searched, notes.txt is not
// one name match is chosen without asking
GameIdentity t;
t.key = "crc-1";
t.title = "Tempest 2000";
t.fileStem = "tempest_file";
m.gameLoaded(t);
CHECK(m.matches().size() == 1);
CHECK_EQ(m.selectedStem(), "Tempest 2000");
CHECK(m.selectedSet() != nullptr);
CHECK(m.suggestions().size() == 1); // "Tempest 2000 Plus Extras" contains it: offered, not chosen
// the file stem matches too (an unrecognised ROM named after its game)
GameIdentity f;
f.key = "crc-2";
f.fileStem = "Tempest 2000 (World)";
m.gameLoaded(f);
CHECK_EQ(m.selectedStem(), "Tempest 2000");
// two matches: nothing is chosen silently
GameIdentity c;
c.key = "crc-3";
c.title = "Cybermorph";
m.gameLoaded(c);
CHECK(m.matches().size() == 2);
CHECK(m.selectedStem().empty());
CHECK(m.selectedSet() == nullptr);
CHECK(!m.setStatus().empty());
CHECK(!m.selectSet("No such set"));
CHECK(m.selectSet("Cybermorph (2mb) (Rev B) (1993)"));
CHECK(m.selectedSet() != nullptr);
// ...and the choice is remembered for that game only
CheatManager m2;
m2.setStateDir(state.string() + "/");
m2.setLibraryDirs({tmp.string()});
m2.rescan();
m2.gameLoaded(c);
CHECK_EQ(m2.selectedStem(), "Cybermorph (2mb) (Rev B) (1993)");
m2.gameLoaded(t);
CHECK_EQ(m2.selectedStem(), "Tempest 2000");
// an explicit "no set" is remembered too, instead of the automatic match coming back
m2.selectSet("");
CheatManager m3;
m3.setStateDir(state.string() + "/");
m3.setLibraryDirs({tmp.string()});
m3.rescan();
m3.gameLoaded(t);
CHECK(m3.selectedStem().empty());
// the saved set vanished from the library
fs::remove(tmp / "Tempest 2000.cht");
m3.rescan();
m3.gameLoaded(c);
m3.selectSet("Cybermorph (1mb) (Rev A) (1993)");
fs::remove(tmp / "Cybermorph (1mb) (Rev A) (1993).cht");
m3.rescan();
CHECK(m3.selectedSet() == nullptr);
CHECK(!m3.setStatus().empty());
fs::remove_all(tmp);
}
static void testPersistence() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-persist";
fs::remove_all(tmp);
std::string state = tmp.string() + "/games/";
GameIdentity g;
g.key = "crc-deadbeef";
g.title = "Test Game";
g.fileStem = "Test Game (World)";
std::string livesCode, scoreCode;
{
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
m.gameLoaded(g);
CHECK_EQ(m.selectedStem(), "Test Game");
const ChtSet* s = m.selectedSet();
CHECK(s != nullptr);
if (!s) return;
const ChtCheat* lives = find(*s, "Lives");
const ChtCheat* score = find(*s, "Score");
const ChtCheat* bad = find(*s, "Garbage code");
CHECK(lives && score && bad);
livesCode = lives->code;
scoreCode = score->code;
rec.log.clear();
m.setEnabled(*bad, true); // unsupported: refused
CHECK(!m.isEnabled(*bad));
m.setEnabled(*score, true);
m.setEnabled(*lives, true);
CHECK(m.isEnabled(*lives) && m.isEnabled(*score));
CHECK(m.enabledCount() == 2);
CHECK(m.appliedSegments() == 4);
// every change is a full reset + set-all in list order (Lives is entry 0, Score entry 1)
CHECK(!rec.log.empty());
CHECK_EQ(rec.log.back(), "set 1 on " + scoreCode);
CHECK_EQ(rec.log[rec.log.size() - 2], "set 0 on " + livesCode);
CHECK_EQ(rec.log[rec.log.size() - 3], "reset");
std::string err;
CHECK(!m.addUser("bad", "not a code", err) && !err.empty());
CHECK(m.addUser("My lives", "00001000:07", err));
CHECK(m.addUser("Two", "00001002:01\n00001003:02", err));
CHECK(m.addUser("My lives", "00001000:07", err)); // same code again: no duplicate
CHECK(m.userCheats().size() == 2);
CHECK_EQ(m.userCheats()[1].code, "00001002:01+00001003:02");
m.setUserEnabled(1, false);
CHECK(m.enabledCount() == 3);
}
CHECK(fs::exists(tmp / "games" / "crc-deadbeef.ini"));
// "restart": a new manager, nothing in memory
{
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
m.gameLoaded(g);
const ChtSet* s = m.selectedSet();
CHECK(s != nullptr);
if (!s) return;
CHECK(m.isEnabled(*find(*s, "Lives")));
CHECK(m.isEnabled(*find(*s, "Score")));
CHECK(!m.isEnabled(*find(*s, "Odd word")));
CHECK(m.userCheats().size() == 2);
CHECK(m.userCheats()[0].enabled && !m.userCheats()[1].enabled);
CHECK(m.enabledCount() == 3);
// the load itself re-applies, in a fixed order, to a core that just dropped its list
std::vector<std::string> want = {"reset", "set 0 on " + livesCode, "set 1 on " + scoreCode,
"set " + std::to_string(CheatManager::kUserIndexBase) + " on 00001000:07"};
CHECK(rec.log == want);
// ...and again after a state load
rec.log.clear();
m.apply();
CHECK(rec.log == want);
// a different game has none of it
GameIdentity other;
other.key = "crc-00000002";
other.title = "Test Game";
rec.log.clear();
m.gameLoaded(other);
CHECK(m.enabledCount() == 0);
CHECK(rec.log.size() == 1 && rec.log[0] == "reset");
// closing clears the core's list
rec.log.clear();
m.gameClosed();
CHECK(!m.active());
CHECK(rec.log.size() == 1 && rec.log[0] == "reset");
// by name, the way the command line does it
m.gameLoaded(g);
m.disableAll();
CHECK(m.enabledCount() == 0);
CHECK(m.setEnabledByName("lives", true));
CHECK(m.setEnabledByName("0", true));
CHECK(!m.setEnabledByName("Garbage code", true));
CHECK(!m.setEnabledByName("no such cheat", true));
CHECK(m.setEnabledByName("My lives", true));
CHECK(m.enabledCount() == 2);
}
{
CheatManager m;
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
m.gameLoaded(g);
CHECK(m.enabledCount() == 2);
m.disableAll();
}
{
CheatManager m;
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
m.gameLoaded(g);
CHECK(m.enabledCount() == 0);
CHECK(m.userCheats().size() == 2); // turned off, not deleted
m.removeUser(0);
m.removeUser(0);
}
{
CheatManager m;
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
m.gameLoaded(g);
CHECK(m.userCheats().empty());
}
// a game that never had a cheat touched leaves no file behind
{
CheatManager m;
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.rescan();
GameIdentity quiet;
quiet.key = "crc-0000000f";
quiet.title = "Test Game";
m.gameLoaded(quiet);
CHECK(!fs::exists(tmp / "games" / "crc-0000000f.ini"));
}
fs::remove_all(tmp);
}
// ---------------------------------------------------------------------------------
static int corpus(const std::string& dir) {
unsigned files = 0, total = 0, supported = 0, merged = 0;
std::vector<std::string> unsupported;
std::error_code ec;
for (const auto& e : fs::directory_iterator(dir, ec)) {
if (e.path().extension() != ".cht") continue;
ChtSet s;
files++;
if (!loadChtFile(e.path().string(), s)) {
std::fprintf(stderr, "FAIL %s: no cheats parsed\n", e.path().string().c_str());
g_fail++;
continue;
}
std::vector<std::string> ids;
for (const ChtCheat& c : s.cheats) {
total++;
ids.push_back(c.id);
if (c.parts > 1) merged++;
if (c.supported) {
supported++;
unsigned segs = 0;
std::string why;
if (!validateCode(c.code, &segs, &why) || segs != c.segments) {
std::fprintf(stderr, "FAIL %s: %s: %s\n", s.stem.c_str(), c.desc.c_str(), why.c_str());
g_fail++;
}
// The memory it writes must lie inside the 2 MB of RAM the database addresses.
Ram r;
play(r, c.code);
} else {
unsupported.push_back(s.stem + ": " + c.desc + " -- " + c.note);
if (c.note.empty()) {
std::fprintf(stderr, "FAIL %s: %s is unsupported with no reason\n", s.stem.c_str(), c.desc.c_str());
g_fail++;
}
}
}
std::sort(ids.begin(), ids.end());
if (std::adjacent_find(ids.begin(), ids.end()) != ids.end()) {
std::fprintf(stderr, "FAIL %s: duplicate cheat ids\n", s.stem.c_str());
g_fail++;
}
}
std::printf("corpus: %u files, %u cheats (%u merged multi-part), %u supported, %zu unsupported\n", files, total,
merged, supported, unsupported.size());
for (const std::string& u : unsupported) std::printf(" unsupported: %s\n", u.c_str());
if (files == 0) {
std::fprintf(stderr, "FAIL no .cht files in %s\n", dir.c_str());
return 1;
}
if (supported * 100 < total * 95) {
std::fprintf(stderr, "FAIL only %u of %u cheats are supported\n", supported, total);
g_fail++;
}
return g_fail ? 1 : 0;
}
// #967: one apply() is one command: the reset, then every set.
static void testCommitBatch() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-commit";
fs::remove_all(tmp);
GameIdentity g;
g.key = "crc-deadbeef";
g.title = "Test Game";
g.fileStem = "Test Game (World)";
CheatManager m;
Recorder r;
r.attach(m);
m.setStateDir(tmp.string() + "/games/");
m.gameLoaded(g);
std::string err;
CHECK(m.addUser("My lives", "00001000:07", err)); // one enabled user cheat, so apply() has a set to send
r.log.clear();
r.batches = 0;
m.apply();
CHECK(r.batches == 1); // one apply, one command
CHECK(r.log.size() == 2 && r.log[0] == "reset" && r.log[1].compare(0, 3, "set") == 0);
m.blocked = [] { return true; };
r.log.clear();
r.batches = 0;
m.apply();
CHECK(r.batches == 1 && r.log.size() == 1 && r.log[0] == "reset"); // blocked: the reset still goes
fs::remove_all(tmp);
}
// Imported freezes must never follow a matching title onto the wrong revision.
static void testRevisionGuard() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-revision-guard";
fs::create_directories(tmp);
const std::string entries = "cheat0_desc = \"Imported freeze\"\n"
"cheat0_code = \"001F0000:AB+001F0002:1234+001F0004:89ABCDEF\"\n"
"cheat0_enable = true\n";
auto write = [&](const std::string& guard) {
std::ofstream(tmp / "Imported.cht", std::ios::binary) << guard << entries;
};
write("vj_rom_crc32 = \"1234ABCD\"\n");
GameIdentity g;
g.key = "crc-1234abcd";
g.fileStem = "Imported";
g.crc = 0x1234ABCD;
CheatManager m;
Recorder recorder;
recorder.attach(m);
m.setLibraryDirs({tmp.string()});
m.setStateDir((tmp / "games").string() + "/");
m.rescan();
m.gameLoaded(g);
CHECK(m.selectedSet() && m.selectedSet()->revisionBound);
CHECK(m.setStatus().empty());
CHECK(m.appliedSegments() == 0); // source defaults never auto-enable
CHECK(m.setEnabledByName("Imported freeze", true));
CHECK(m.appliedSegments() == 3);
Ram ram;
play(ram, m.selectedSet()->cheats[0].code);
CHECK(ram.b[0x1F0000] == 0xAB && ram.b[0x1F0002] == 0x12 && ram.b[0x1F0003] == 0x34);
CHECK(ram.b[0x1F0004] == 0x89 && ram.b[0x1F0007] == 0xEF);
m.blocked = [] { return true; };
m.apply();
CHECK(m.appliedSegments() == 0);
m.blocked = nullptr;
// Saved toggles cannot bypass the guard, even with the same saved key.
for (uint32_t crc : {0u, 0x87654321u}) {
g.crc = crc;
m.gameLoaded(g);
CHECK(m.selectedSet() && !m.selectedSet()->cheats[0].supported);
CHECK(m.selectedSet()->cheats[0].code.empty());
CHECK(!m.setStatus().empty());
CHECK(!m.setEnabledByName("Imported freeze", true));
CHECK(m.appliedSegments() == 0);
}
g.crc = 0x1234ABCD;
m.gameLoaded(g);
CHECK(m.appliedSegments() == 3); // switching back restores the saved toggle
for (const std::string& guard : {
"vj_rom_crc32\n", "vj_rom_crc32 = \"1234ABCG\"\n",
"vj_rom_crc32 = \"1234ABCD\"\nvj_rom_crc32 = \"1234ABCD\"\n",
"vj_rom_crc32 = \"0x1234ABCD\"\n", "vj_rom_crc32 = \"\"\n"}) {
write(guard);
m.gameLoaded(g);
CHECK(!m.setStatus().empty());
CHECK(m.appliedSegments() == 0);
CHECK(!m.setEnabledByName("Imported freeze", true));
}
write("\xEF\xBB\xBFvj_rom_crc32 = \"87654321\"\r\n");
m.gameLoaded(g);
CHECK(m.appliedSegments() == 0); // BOM must not hide the guard
write("");
g.crc = 0;
m.gameLoaded(g);
CHECK(m.setStatus().empty());
CHECK(m.appliedSegments() == 3); // legacy unbound .cht files remain usable
fs::remove_all(tmp);
}
// ---- community pack (#1071) ----------------------------------------------------------------------
struct PackCheat {
std::string desc, code;
};
// Write <dir>/<fileCrc>.cht: a revision-bound pack (guard `guardCrc`; "" = no guard line at all).
static void writePack(const fs::path& dir, const std::string& fileCrc, const std::string& guardCrc,
const std::vector<PackCheat>& cheats) {
fs::create_directories(dir);
std::ofstream f(dir / (fileCrc + ".cht"), std::ios::binary);
if (!guardCrc.empty()) f << "vj_rom_crc32 = \"" << guardCrc << "\"\n";
for (size_t i = 0; i < cheats.size(); i++)
f << "cheat" << i << "_desc = \"" << cheats[i].desc << "\"\ncheat" << i << "_code = \"" << cheats[i].code
<< "\"\ncheat" << i << "_enable = true\n";
}
static std::string slurp(const fs::path& p) {
std::ifstream f(p, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
}
static const std::vector<PackCheat> kPack = {
{"Infinite lives", "001F0000:AB"}, {"Max score", "001F0010:1234"}, {"Gold", "001F0020:01+001F0021:02"}};
static GameIdentity cartGame(uint32_t crc, const char* title = "Unmatched Cart") {
GameIdentity g;
char key[32];
std::snprintf(key, sizeof key, "crc-%08x", crc);
g.key = key;
g.title = title;
g.fileStem = title;
g.crc = crc;
return g;
}
static void testCommunityLoad() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-community-load";
fs::remove_all(tmp);
const fs::path packs = tmp / "packs";
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(tmp.string() + "/games/");
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.setCommunityDir(packs.string());
m.rescan();
// Found by CRC alone: no library match, no selectSet.
m.gameLoaded(cartGame(0x5E2CDBC0));
CHECK(m.selectedSet() == nullptr);
const ChtSet* c = m.communitySet();
CHECK(c != nullptr);
if (!c) return;
CHECK(c->cheats.size() == 3 && c->restriction.empty() && c->revisionBound && c->romCrc == 0x5E2CDBC0);
CHECK(c->cheats[0].supported && c->cheats[2].supported && c->cheats[2].segments == 2);
CHECK(m.enabledCount() == 0 && m.appliedSegments() == 0); // never auto-enabled
// Independent of the database set: choosing one leaves the pack alone.
CHECK(m.selectSet("Test Game"));
CHECK(m.communitySet() && m.communitySet()->cheats.size() == 3);
CHECK(m.selectSet(""));
// Another cartridge: no file, no pack.
m.gameLoaded(cartGame(0x12345678));
CHECK(m.communitySet() == nullptr);
// Closing the game forgets the pack.
m.gameLoaded(cartGame(0x5E2CDBC0));
CHECK(m.communitySet() != nullptr);
m.gameClosed();
CHECK(m.communitySet() == nullptr);
// A disc (crc 0) never loads a pack, even when a 00000000.cht is lying there.
writePack(packs, "00000000", "00000000", kPack);
GameIdentity disc;
disc.key = "title-some-disc";
disc.title = "Some Disc";
disc.fileStem = "Some Disc";
m.gameLoaded(disc);
CHECK(m.communitySet() == nullptr);
// Guard for another CRC: loaded, every cheat unsupported, restriction set, nothing applied.
writePack(packs, "5E2CDBC0", "11111111", kPack);
m.gameLoaded(cartGame(0x5E2CDBC0));
c = m.communitySet();
CHECK(c != nullptr && !c->restriction.empty());
if (c) {
for (const ChtCheat& k : c->cheats) CHECK(!k.supported && k.code.empty() && k.segments == 0 && !k.note.empty());
m.setEnabled(c->cheats[0], true, CheatOrigin::Community); // refused: unsupported
CHECK(!m.isEnabled(c->cheats[0], CheatOrigin::Community));
CHECK(!m.setEnabledByName("Infinite lives", true));
}
CHECK(m.appliedSegments() == 0);
// Unbound pack (no guard line) and a malformed guard are restricted the same way.
for (const std::string& guard : {std::string(), std::string("5E2CDBCG")}) {
writePack(packs, "5E2CDBC0", guard, kPack);
m.gameLoaded(cartGame(0x5E2CDBC0));
c = m.communitySet();
CHECK(c != nullptr && !c->restriction.empty() && !c->cheats[0].supported);
CHECK(m.appliedSegments() == 0);
}
// The matching guard is back to normal.
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
m.gameLoaded(cartGame(0x5E2CDBC0));
CHECK(m.communitySet() && m.communitySet()->restriction.empty() && m.communitySet()->cheats[0].supported);
// A file that holds no cheat at all is not a pack.
writePack(packs, "5E2CDBC0", "5E2CDBC0", {});
m.gameLoaded(cartGame(0x5E2CDBC0));
CHECK(m.communitySet() == nullptr);
fs::remove_all(tmp);
}
static void testCommunityTogglesAcrossUpdate() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-community-toggles";
fs::remove_all(tmp);
const fs::path packs = tmp / "packs";
const std::string state = tmp.string() + "/games/";
const GameIdentity g = cartGame(0x5E2CDBC0);
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
std::string livesId, scoreId;
{
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(state);
m.setCommunityDir(packs.string());
m.gameLoaded(g);
const ChtSet* c = m.communitySet();
CHECK(c != nullptr);
if (!c) return;
livesId = c->cheats[0].id;
scoreId = c->cheats[1].id;
m.setEnabled(c->cheats[0], true, CheatOrigin::Community);
m.setEnabled(c->cheats[1], true, CheatOrigin::Community);
CHECK(m.isEnabled(c->cheats[0], CheatOrigin::Community) && m.isEnabled(c->cheats[1], CheatOrigin::Community));
CHECK(!m.isEnabled(c->cheats[2], CheatOrigin::Community));
// The database namespace is separate: same ids, other origin, still off.
CHECK(!m.isEnabled(c->cheats[0], CheatOrigin::Database));
CHECK(m.enabledCount() == 2 && m.appliedSegments() == 2);
}
const std::string ini = slurp(state + g.key + ".ini");
CHECK(ini.find("cp." + livesId) != std::string::npos && ini.find("cp." + scoreId) != std::string::npos);
CHECK(ini.find("on.") == std::string::npos);
// A fresh manager (restart) still has both on.
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(state);
m.setCommunityDir(packs.string());
m.gameLoaded(g);
CHECK(m.communitySet() != nullptr && m.enabledCount() == 2);
CHECK(m.isEnabled(m.communitySet()->cheats[0], CheatOrigin::Community));
CHECK(rec.log.size() == 3 && rec.log[0] == "reset" && rec.log[1] == "set 5000 on 001F0000:AB" &&
rec.log[2] == "set 5001 on 001F0010:1234");
// The pack is updated: "Max score" has a new code, the others are untouched.
writePack(packs, "5E2CDBC0", "5E2CDBC0",
{kPack[0], {"Max score", "001F0010:5678"}, kPack[2]});
rec.log.clear();
m.reloadCommunity();
const ChtSet* c = m.communitySet();
CHECK(c != nullptr);
if (!c) return;
CHECK(c->cheats[0].id == livesId && c->cheats[1].id != scoreId);
CHECK(m.lastPrunedEnabled() == 1); // "Max score" was on and came back off; the untouched cheat does not count
CHECK(m.isEnabled(c->cheats[0], CheatOrigin::Community));
CHECK(!m.isEnabled(c->cheats[1], CheatOrigin::Community)); // changed: back off
CHECK(m.enabledCount() == 1 && m.appliedSegments() == 1);
CHECK(rec.log.size() == 2 && rec.log[0] == "reset" && rec.log[1] == "set 5000 on 001F0000:AB");
const std::string ini2 = slurp(state + g.key + ".ini");
CHECK(ini2.find("cp." + livesId) != std::string::npos);
CHECK(ini2.find("cp." + scoreId) == std::string::npos); // no stale key
// ...and after a restart too.
CheatManager m2;
m2.setStateDir(state);
m2.setCommunityDir(packs.string());
m2.gameLoaded(g);
CHECK(m2.enabledCount() == 1 && m2.isEnabled(m2.communitySet()->cheats[0], CheatOrigin::Community));
CHECK(!m2.isEnabled(m2.communitySet()->cheats[1], CheatOrigin::Community));
// A stale cp. key (pack replaced while the app was closed) is pruned at load time.
writePack(packs, "5E2CDBC0", "5E2CDBC0", {kPack[1]});
CheatManager m3;
m3.setStateDir(state);
m3.setCommunityDir(packs.string());
m3.gameLoaded(g);
CHECK(m3.enabledCount() == 0);
CHECK(m3.lastPrunedEnabled() == 1);
m3.reloadCommunity();
CHECK(m3.lastPrunedEnabled() == 0); // nothing left to prune: the count describes the last load only
CHECK(slurp(state + g.key + ".ini").find("cp.") == std::string::npos);
fs::remove_all(tmp);
}
static void testApplyOrder() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-community-order";
fs::remove_all(tmp);
const fs::path packs = tmp / "packs";
GameIdentity g = cartGame(0x5E2CDBC0, "Test Game");
g.fileStem = "Test Game (World)";
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
CheatManager m;
Recorder rec;
rec.attach(m);
std::vector<std::string> warns;
m.warn = [&](const std::string& w) { warns.push_back(w); };
m.setStateDir(tmp.string() + "/games/");
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.setCommunityDir(packs.string());
m.rescan();
m.gameLoaded(g);
CHECK_EQ(m.selectedStem(), "Test Game");
const ChtSet* db = m.selectedSet();
const ChtSet* cp = m.communitySet();
CHECK(db && cp);
if (!db || !cp) return;
const ChtCheat* lives = find(*db, "Lives");
const ChtCheat* score = find(*db, "Score");
CHECK(lives && score);
if (!lives || !score) return;
std::string err;
// Enable in the "wrong" order: user first, then the pack, then the database.
CHECK(m.addUser("Mine", "00002000:11", err));
m.setEnabled(cp->cheats[2], true, CheatOrigin::Community);
m.setEnabled(cp->cheats[0], true, CheatOrigin::Community);
m.setEnabled(*score, true);
m.setEnabled(*lives, true);
const size_t livesIdx = size_t(lives - &db->cheats[0]), scoreIdx = size_t(score - &db->cheats[0]);
CHECK(livesIdx < scoreIdx);
rec.log.clear();
m.apply();
// reset, then database (set order), community (5000 + i), user (10000 + i).
const std::vector<std::string> want = {
"reset",
"set " + std::to_string(livesIdx) + " on " + lives->code,
"set " + std::to_string(scoreIdx) + " on " + score->code,
"set 5000 on 001F0000:AB",
"set 5002 on 001F0020:01+001F0021:02",
"set 10000 on 00002000:11"};
CHECK(rec.log.size() == want.size());
for (size_t i = 0; i < want.size() && i < rec.log.size(); i++) CHECK_EQ(rec.log[i], want[i]);
CHECK(m.appliedSegments() == lives->segments + score->segments + 1 + 2 + 1);
CHECK(m.enabledCount() == 5);
CHECK(warns.empty());
// Replay the batch through the core's own list: the writes all land.
Ram ram;
static cheat_list_t list;
cheat_list_reset(&list);
CHECK(rec.log.size() == want.size());
for (size_t i = 1; i < rec.log.size(); i++) {
const size_t sp1 = rec.log[i].find(' '), sp2 = rec.log[i].find(' ', sp1 + 1), sp3 = rec.log[i].find(' ', sp2 + 1);
cheat_list_set(&list, (uint32_t)std::strtoul(rec.log[i].substr(sp1 + 1, sp2 - sp1 - 1).c_str(), nullptr, 10), true,
rec.log[i].substr(sp3 + 1).c_str());
}
cheat_list_apply(&list, ramWrite, &ram);
CHECK(ram.b[0x1F0000] == 0xAB && ram.b[0x1F0020] == 0x01 && ram.b[0x1F0021] == 0x02 && ram.b[0x2000] == 0x11);
// disableAll clears the pack too, and names reach the pack (after the database, before the manual codes).
m.disableAll();
CHECK(m.enabledCount() == 0 && m.appliedSegments() == 0);
CHECK(slurp(tmp / "games" / (g.key + ".ini")).find("cp.") == std::string::npos);
CHECK(m.setEnabledByName("max score", true)); // community desc, case-insensitive
CHECK(m.isEnabled(cp->cheats[1], CheatOrigin::Community) && !m.isEnabled(*score));
CHECK(m.setEnabledByName("Lives", true)); // database wins over nothing else here
CHECK(m.isEnabled(*lives) && !m.isEnabled(cp->cheats[0], CheatOrigin::Community));
CHECK(m.setEnabledByName("Mine", true));
CHECK(!m.setEnabledByName("No such cheat", true));
CHECK(m.setEnabledByName(cp->cheats[2].id, true));
CHECK(m.isEnabled(cp->cheats[2], CheatOrigin::Community));
// Above the core's limit: every segment is counted and the single warning fires.
m.disableAll();
std::vector<PackCheat> big;
for (int i = 0; i < 100; i++) {
char code[64];
std::snprintf(code, sizeof code, "%08X:01+%08X:02+%08X:03", 0x10000 + i * 4, 0x10001 + i * 4, 0x10002 + i * 4);
big.push_back({"Big " + std::to_string(i), code});
}
writePack(packs, "5E2CDBC0", "5E2CDBC0", big);
m.reloadCommunity();
warns.clear();
for (const ChtCheat& k : m.communitySet()->cheats) m.setEnabled(k, true, CheatOrigin::Community);
CHECK(m.appliedSegments() == 300);
CHECK(!warns.empty() && warns.back().find("300") != std::string::npos);
fs::remove_all(tmp);
}
static void testCommunityHardcore() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-community-hardcore";
fs::remove_all(tmp);
const fs::path packs = tmp / "packs";
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(tmp.string() + "/games/");
m.setCommunityDir(packs.string());
m.gameLoaded(cartGame(0x5E2CDBC0));
const ChtSet* c = m.communitySet();
CHECK(c != nullptr);
if (!c) return;
m.setEnabled(c->cheats[0], true, CheatOrigin::Community);
CHECK(m.appliedSegments() == 1);
m.blocked = [] { return true; };
rec.log.clear();
m.apply();
CHECK(rec.log.size() == 1 && rec.log[0] == "reset"); // only the reset reaches the core
CHECK(m.appliedSegments() == 0);
// The saved selection is neither touched nor forgotten: it goes back in once the block lifts.
CHECK(m.isEnabled(c->cheats[0], CheatOrigin::Community) && m.enabledCount() == 1);
m.reloadCommunity(); // an install during hardcore is data-only
CHECK(rec.log.size() == 2 && rec.log[0] == "reset" && rec.log[1] == "reset");
m.blocked = nullptr;
rec.log.clear();
m.apply();
CHECK(rec.log.size() == 2 && rec.log[1] == "set 5000 on 001F0000:AB");
fs::remove_all(tmp);
}
static void testRemoveToggles() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-community-remove";
fs::remove_all(tmp);
const fs::path packs = tmp / "packs";
const std::string state = tmp.string() + "/games/";
GameIdentity g = cartGame(0x5E2CDBC0, "Test Game");
g.fileStem = "Test Game (World)";
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
CheatManager m;
Recorder rec;
rec.attach(m);
m.setStateDir(state);
m.setLibraryDirs({VJ_TEST_FIXTURES});
m.setCommunityDir(packs.string());
m.rescan();
m.gameLoaded(g);
const ChtSet* db = m.selectedSet();
const ChtSet* cp = m.communitySet();
CHECK(db && cp);
if (!db || !cp) return;
const ChtCheat* lives = find(*db, "Lives");
CHECK(lives != nullptr);
if (!lives) return;
m.setEnabled(*lives, true);
m.setEnabled(cp->cheats[0], true, CheatOrigin::Community);
m.setEnabled(cp->cheats[1], true, CheatOrigin::Community);
CHECK(slurp(state + g.key + ".ini").find("cp.") != std::string::npos);
// The store deletes the pack, then asks the manager to forget it.
fs::remove(packs / "5E2CDBC0.cht");
m.reloadCommunity();
CHECK(m.communitySet() == nullptr);
rec.log.clear();
m.removeCommunityToggles();
CHECK(slurp(state + g.key + ".ini").find("cp.") == std::string::npos);
CHECK(slurp(state + g.key + ".ini").find("on.") != std::string::npos); // the database toggle survives
CHECK(m.isEnabled(*lives) && m.enabledCount() == 1);
CHECK(rec.log.size() == 2 && rec.log[0] == "reset" && rec.log[1].compare(0, 5, "set 0") == 0);
// Reinstalling starts with everything off, also after a restart.
writePack(packs, "5E2CDBC0", "5E2CDBC0", kPack);
CheatManager m2;
m2.setStateDir(state);
m2.setLibraryDirs({VJ_TEST_FIXTURES});
m2.setCommunityDir(packs.string());
m2.rescan();
m2.gameLoaded(g);
CHECK(m2.communitySet() != nullptr);
if (m2.communitySet())
for (const ChtCheat& k : m2.communitySet()->cheats) CHECK(!m2.isEnabled(k, CheatOrigin::Community));
// No game: nothing to do, no crash.
CheatManager idle;
idle.removeCommunityToggles();
idle.reloadCommunity();
CHECK(idle.communitySet() == nullptr);
fs::remove_all(tmp);
}
// An unsupported database cheat does not shadow a same-named supported community cheat.
static void testSetEnabledByNameFallthrough() {
fs::path tmp = fs::temp_directory_path() / "vj-cheats-community-fallthrough";
fs::remove_all(tmp);
const fs::path lib = tmp / "lib", packs = tmp / "packs";
fs::create_directories(lib);
std::ofstream(lib / "Fall Game.cht", std::ios::binary)
<< "cheat0_desc = \"Infinite Lives\"\ncheat0_code = \"not a code\"\ncheat0_enable = true\n";
auto setup = [&](CheatManager& m, Recorder& rec) {
rec.attach(m);
m.setStateDir(tmp.string() + "/games/");
m.setLibraryDirs({lib.string()});
m.setCommunityDir(packs.string());
m.rescan();
m.gameLoaded(cartGame(0x5E2CDBC0, "Fall Game"));
};
// No community match: still false, nothing enabled.
{
CheatManager m;
Recorder rec;
setup(m, rec);
CHECK(m.selectedSet() && !m.selectedSet()->cheats[0].supported);
CHECK(!m.setEnabledByName("Infinite Lives", true));
CHECK(m.enabledCount() == 0);
}
// Same name in the pack and supported: that one is enabled.
writePack(packs, "5E2CDBC0", "5E2CDBC0", {{"Infinite Lives", "001F0000:AB"}});
CheatManager m;
Recorder rec;
setup(m, rec);
CHECK(m.setEnabledByName("Infinite Lives", true));
CHECK(m.isEnabled(m.communitySet()->cheats[0], CheatOrigin::Community));
CHECK(!m.isEnabled(m.selectedSet()->cheats[0]));
CHECK(m.enabledCount() == 1 && m.appliedSegments() == 1);
fs::remove_all(tmp);
}
// FIPS 180-4 / NIST vectors. The one-million-'a' message crosses many blocks; the 55/56/64-byte
// lengths straddle the padding boundary (55: the length field still fits in the one block, 56: it forces an extra
// block, 64: exactly one full block of data).
static void testSha256() {
CHECK_EQ(sha256Hex(nullptr, 0), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
CHECK_EQ(sha256Hex("", 0), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
CHECK_EQ(sha256Hex("abc", 3), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
const std::string two = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
CHECK_EQ(sha256Hex(two.data(), two.size()), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
const std::string mil(1000000, 'a');
CHECK_EQ(sha256Hex(mil.data(), mil.size()), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
// Padding boundaries: 55 bytes = one block, 56 = length no longer fits, 64 = exactly one full block.
CHECK_EQ(sha256Hex(mil.data(), 55), "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318");
CHECK_EQ(sha256Hex(mil.data(), 56), "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a");
CHECK_EQ(sha256Hex(mil.data(), 64), "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb");
// Bytes above 0x7F must not be sign-extended.
const unsigned char hi[4] = {0xff, 0x80, 0x00, 0xfe};
CHECK_EQ(sha256Hex(hi, 4), "eadfaeb12caa89eba3075f360d3d47938bd2fa6863a01e86bbb3b61b92086ce5");
}
// ---- community store (#1071) -----------------------------------------------------------------------
static const uint32_t kStoreCrc = 0x5E2CDBC0;
static std::string storeFixture(const char* name) { return slurp(fixture(std::string("community/") + name)); }
// One manifest entry / a whole manifest, built by hand so a test can vary a single field.
static std::string entryJson(const std::string& crc, const std::string& sha, size_t bytes, const std::string& file,
const std::string& messages = "[]", const std::string& title = "Test Game") {
return "{\"bytes\":" + std::to_string(bytes) + ",\"cheats\":3,\"crc32\":\"" + crc + "\",\"credit\":\"Cheats by the test suite.\","
"\"exceeds_core_limit\":false,\"file\":\"" + file + "\",\"messages\":" + messages + ",\"name\":\"" + title +
" (1994)\",\"notes\":[{\"reason\":\"manual\",\"setting\":null}],\"partial\":false,\"sha256\":\"" + sha +
"\",\"title\":\"" + title + "\",\"updated\":\"2026-04-15\",\"writes\":4}";
}
static std::string manifestJson(const std::vector<std::string>& entries, const std::string& format = "1") {
std::string s = "{\"format\":" + format + ",\"packs\":[";
for (size_t i = 0; i < entries.size(); i++) s += (i ? "," : "") + entries[i];
return s + "]}";
}
static std::string entryFor(const std::string& body, const std::string& messages = "[]") {
return entryJson("5E2CDBC0", sha256Hex(body.data(), body.size()), body.size(), "packs/5E2CDBC0.cht", messages);
}
static void testManifestParse() {
const std::string good = storeFixture("manifest.json");
std::vector<PackMeta> v;
std::string err;
CHECK(parseManifest(good, v, err) && err.empty() && v.size() == 1);
if (v.size() == 1) {
const PackMeta& p = v[0];
CHECK_EQ(p.crc32, "5E2CDBC0");
CHECK_EQ(p.title, "Test Game");
CHECK_EQ(p.name, "Test Game (1994)");
CHECK_EQ(p.file, "packs/5E2CDBC0.cht");
CHECK(p.bytes == 350 && p.cheats == 3 && p.writes == 4 && p.partial && !p.exceedsCoreLimit);
CHECK(p.sha256.size() == 64);
CHECK(p.notes.size() == 2 && p.notes[0].first == "Start At Level" && p.notes[1].first.empty() &&
p.notes[1].second == "no writes activated"); // a null setting is kept as ""
CHECK(p.messages.size() == 2 && p.messages[1] == "Fixture message two.");
PackMeta back;
CHECK(!p.json.empty() && parsePackMeta(p.json, back) && back.sha256 == p.sha256 && back.messages == p.messages);
}
// The real shipped manifest, when the checkout has the site.
{
std::string real = slurp(fixture("../../../site/cheats/manifest.json"));
if (!real.empty()) {
CHECK(parseManifest(real, v, err) && !v.empty());
for (const PackMeta& p : v)
CHECK(p.crc32.size() == 8 && p.sha256.size() == 64 && p.file == "packs/" + p.crc32 + ".cht" && p.bytes > 0);
}
}
const std::string sha(64, 'a');
// format 2 and a missing format: whole manifest refused
v.assign(3, PackMeta());
CHECK(!parseManifest(manifestJson({entryJson("5E2CDBC0", sha, 10, "packs/x.cht")}, "2"), v, err));
CHECK(err.find("format 2") != std::string::npos && v.size() == 3); // `out` untouched on failure
CHECK(!parseManifest("{\"packs\":[]}", v, err) && err.find("format ?") != std::string::npos);
// a bad sha hex skips that entry alone
{
std::string bad = entryJson("11111111", std::string(63, 'a') + "g", 10, "packs/11111111.cht");
CHECK(parseManifest(manifestJson({bad, entryJson("22222222", sha, 10, "packs/22222222.cht")}), v, err));
CHECK(v.size() == 1 && v[0].crc32 == "22222222");
std::string shortSha = entryJson("11111111", std::string(63, 'a'), 10, "packs/11111111.cht");
CHECK(parseManifest(manifestJson({shortSha}), v, err) && v.empty());
}
// a bad CRC, a zero / oversize / fractional byte count, and unsafe files skip the entry
for (const std::string& e : {entryJson("5E2CDBC", sha, 10, "packs/a.cht"), entryJson("5E2CDBCG", sha, 10, "packs/a.cht"),
entryJson("5E2CDBC0", sha, 0, "packs/a.cht"), entryJson("5E2CDBC0", sha, 65537, "packs/a.cht"),
entryJson("5E2CDBC0", sha, 10, "../packs/a.cht"), entryJson("5E2CDBC0", sha, 10, "packs/../../a.cht"),
entryJson("5E2CDBC0", sha, 10, "/etc/passwd"), entryJson("5E2CDBC0", sha, 10, "http://evil/a.cht"),
entryJson("5E2CDBC0", sha, 10, "packs\\\\a.cht"), entryJson("5E2CDBC0", sha, 10, "")}) {
CHECK(parseManifest(manifestJson({e}), v, err) && v.empty());
}
// upper-case sha / lower-case crc are accepted and normalised; a repeated CRC keeps the first
{
std::string up = entryJson("5e2cdbc0", std::string(64, 'A'), 10, "packs/5E2CDBC0.cht");
std::string dup = entryJson("5E2CDBC0", sha, 11, "packs/other.cht");
CHECK(parseManifest(manifestJson({up, dup}), v, err) && v.size() == 1);
CHECK(v.size() == 1 && v[0].crc32 == "5E2CDBC0" && v[0].sha256 == sha && v[0].bytes == 10);
}
// a required key missing (or the wrong type) is "not understood", not a skipped entry
CHECK(!parseManifest("{\"format\":1,\"packs\":[{\"crc32\":\"5E2CDBC0\",\"file\":\"packs/a.cht\",\"bytes\":10}]}", v, err));
CHECK(err.find("not understood") != std::string::npos);
CHECK(!parseManifest("{\"format\":1,\"packs\":[7]}", v, err));
CHECK(!parseManifest("{\"format\":1}", v, err));
// truncated / not JSON / not an object
CHECK(!parseManifest(good.substr(0, good.size() / 2), v, err) && !err.empty());
CHECK(!parseManifest("", v, err));
CHECK(!parseManifest("[1,2]", v, err));
CHECK(parseManifest("{\"format\":1,\"packs\":[]}", v, err) && v.empty());
}
static void testFixtureConsistent() {
const std::string body = storeFixture("5E2CDBC0.cht");
std::vector<PackMeta> v;
std::string err;
CHECK(parseManifest(storeFixture("manifest.json"), v, err) && v.size() == 1);
if (v.empty()) return;
CHECK_EQ(sha256Hex(body.data(), body.size()), v[0].sha256);
CHECK(body.size() == v[0].bytes);
CHECK(verifyPack(v[0], kStoreCrc, body, err));
CHECK(!verifyPack(v[0], 0x12345678, body, err) && err.find("not bound") != std::string::npos);
}
// A fetch hook backed by a map; run is inline unless a test replaces it.
struct Net {
std::map<std::string, std::string> bodies;
std::vector<std::string> calls;
bool offline = false;
bool get(const std::string& url, size_t maxBytes, std::string& body, std::string& err) {
calls.push_back(url);
if (offline) { err = "connection refused"; return false; }
auto it = bodies.find(url);
if (it == bodies.end()) { err = "HTTP 404"; return false; }
if (it->second.size() > maxBytes) { err = "response too large"; return false; }
body = it->second;
return true;
}
};
static const char* kBase = "https://store.test/cheats/";
struct Rig {
fs::path tmp;
Config cfg;
CheatManager cm;
Recorder rec;
CheatStore store;
Net net;
int64_t t = 1700000000;
bool downloads = true;
std::vector<std::string> msgs;
explicit Rig(const char* name) {
tmp = fs::temp_directory_path() / name;
fs::remove_all(tmp);
fs::create_directories(tmp);
cfg.setPath((tmp / "config.ini").string());
wire(store);
store.setDir((tmp / "community").string());
cm.setStateDir(tmp.string() + "/games/");
cm.setCommunityDir(store.packsDir());
rec.attach(cm);
}
~Rig() { store.stop(); fs::remove_all(tmp); }
void wire(CheatStore& s) {
s.fetch = [this](const std::string& u, size_t m, std::string& b, std::string& e) { return net.get(u, m, b, e); };
s.downloadsAllowed = [this] { return downloads; };
s.now = [this] { return t; };
s.message = [this](const std::string& m) { msgs.push_back(m); };
s.setBaseUrl(kBase);
}
void serve(const std::string& manifest, const std::string& pack = "") {
net.bodies[std::string(kBase) + "manifest.json"] = manifest;
if (!pack.empty()) net.bodies[std::string(kBase) + "packs/5E2CDBC0.cht"] = pack;
}
void open(uint32_t crc = kStoreCrc) { store.windowOpened(crc, cfg); store.pump(cm); }
bool installNow(uint32_t crc = kStoreCrc) { bool ok = store.install(crc); store.pump(cm); return ok; }
std::string cht() const { return (tmp / "community/packs/5E2CDBC0.cht").string(); }
std::string sidecar() const { return (tmp / "community/packs/5E2CDBC0.json").string(); }
bool hasFiles() const { return fs::exists(cht()) || fs::exists(sidecar()); }
};
static void testInstallVerify() {
const std::string body = storeFixture("5E2CDBC0.cht");
{ // refusals: each leaves no .cht / .json, sets a matching status, and the pack stays offered
struct Case { const char* what; std::string served, manifestSha; size_t manifestBytes; bool notServed; const char* expect; } cases[] = {
{"sha mismatch", [&] { std::string b = body; b[b.size() / 2] ^= 1; return b; }(), sha256Hex(body.data(), body.size()), body.size(), false, "checksum"},
{"byte mismatch", body, sha256Hex(body.data(), body.size()), body.size() + 1, false, "size"},
{"served body shorter", body.substr(0, body.size() - 1), sha256Hex(body.data(), body.size()), body.size(), false, "size"},
{"guard for another CRC", "vj_rom_crc32 = \"11111111\"\ncheat0_desc = \"A\"\ncheat0_code = \"001F0000:AB\"\n", "", 0, false, "not bound"},
{"no guard", "cheat0_desc = \"A\"\ncheat0_code = \"001F0000:AB\"\n", "", 0, false, "not bound"},
{"malformed guard", "vj_rom_crc32 = \"5E2CDBCG\"\ncheat0_desc = \"A\"\ncheat0_code = \"001F0000:AB\"\n", "", 0, false, "not bound"},
{"no cheats", "vj_rom_crc32 = \"5E2CDBC0\"\n# nothing here\n", "", 0, false, "no cheats"},
{"HTTP 404", body, sha256Hex(body.data(), body.size()), body.size(), true, "HTTP 404"},
{"oversize", std::string(kCheatPackMaxBytes + 1, 'x'), std::string(64, 'b'), 100, false, "response too large"},
};
for (Case& c : cases) {
Rig r("vj-store-verify");
r.cm.gameLoaded(cartGame(kStoreCrc));
std::string sha = c.manifestSha.empty() ? sha256Hex(c.served.data(), c.served.size()) : c.manifestSha;
size_t bytes = c.manifestBytes ? c.manifestBytes : c.served.size();
r.serve(manifestJson({entryJson("5E2CDBC0", sha, bytes, "packs/5E2CDBC0.cht")}), c.notServed ? "" : c.served);
r.open();
CHECK(r.store.state(kStoreCrc) == PackState::Offered);
CHECK(r.installNow()); // it starts; the verdict arrives through pump
if (!r.hasFiles() && r.store.status().find(c.expect) != std::string::npos) {}
else std::fprintf(stderr, " case '%s': status '%s'\n", c.what, r.store.status().c_str());
CHECK(!r.hasFiles());
CHECK(r.store.status().find(c.expect) != std::string::npos);
CHECK(r.store.state(kStoreCrc) == PackState::Offered);
CHECK(r.cm.communitySet() == nullptr);
}
}
{ // success: both files, the pack is loaded, every cheat off
Rig r("vj-store-verify-ok");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
CHECK(fs::exists(r.cht()) && fs::exists(r.sidecar()));
CHECK_EQ(slurp(r.cht()), body); // bytes exactly as served
PackMeta side;
CHECK(parsePackMeta(slurp(r.sidecar()), side) && side.title == "Test Game" && side.messages.size() == 2);
CHECK(r.store.status().find("Installed Test Game") == 0);
const ChtSet* s = r.cm.communitySet();
CHECK(s && s->cheats.size() == 3 && s->restriction.empty() && r.cm.enabledCount() == 0);
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
const PackMeta* in = r.store.installed(kStoreCrc);
CHECK(in && in->credit == "Cheats by the test suite.");
// no .tmp debris
size_t n = 0;
for (auto& e : fs::directory_iterator(r.tmp / "community/packs")) { (void)e; n++; }
CHECK(n == 2);
}
{ // a failed verification does not disturb the previous install
Rig r("vj-store-verify-keep");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
std::string tampered = body;
tampered[tampered.size() / 2] ^= 1;
r.net.bodies[std::string(kBase) + "packs/5E2CDBC0.cht"] = tampered;
CHECK(r.installNow());
CHECK_EQ(slurp(r.cht()), body);
CHECK(r.store.status().find("checksum") != std::string::npos);
}
{ // a fetch hook that ignores maxBytes cannot push an oversize body through
Rig r("vj-store-verify-limit");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(manifestJson({entryFor(body)}), body);
r.open();
r.store.fetch = [&](const std::string& u, size_t, std::string& b, std::string&) {
b = u.find("manifest") != std::string::npos ? std::string(kCheatManifestMaxBytes + 1, ' ') : std::string(kCheatPackMaxBytes + 1, 'x');
return true;
};
CHECK(r.installNow() && !r.hasFiles() && r.store.status().find("response too large") != std::string::npos);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.store.status().find("response too large") != std::string::npos && r.store.state(kStoreCrc) == PackState::Offered);
}
{ // an install for something the manifest does not list, or a disc, never starts
Rig r("vj-store-verify-refuse");
r.serve(storeFixture("manifest.json"), body);
r.open();
size_t calls = r.net.calls.size();
CHECK(!r.store.install(0x12345678) && !r.store.status().empty());
CHECK(!r.store.install(0));
CHECK(r.net.calls.size() == calls);
}
}
static void testStates() {
const std::string body = storeFixture("5E2CDBC0.cht");
const std::string shaA = sha256Hex(body.data(), body.size());
{ // no game / disc / downloads off: no fetch, ever
Rig r("vj-store-states-off");
r.serve(storeFixture("manifest.json"), body);
CHECK(r.store.state(kStoreCrc) == PackState::NoGame);
r.store.checkNow();
r.open(0);
CHECK(r.store.state(0) == PackState::Disc);
r.store.checkNow();
CHECK(!r.store.install(0));
CHECK(r.net.calls.empty());
r.downloads = false;
r.open(kStoreCrc);
CHECK(r.store.state(kStoreCrc) == PackState::DownloadsOff);
r.store.checkNow();
CHECK(!r.store.install(kStoreCrc) && r.store.status().find("Downloads are off") != std::string::npos);
CHECK(r.net.calls.empty());
r.store.gameClosed();
CHECK(r.store.state(kStoreCrc) == PackState::NoGame);
// an unwired store (no downloadsAllowed hook) is off as well
CheatStore bare;
Config c;
bare.fetch = [&](const std::string&, size_t, std::string&, std::string&) { r.net.calls.push_back("bare"); return false; };
bare.setDir((r.tmp / "bare").string());
bare.windowOpened(kStoreCrc, c);
bare.checkNow();
CHECK(r.net.calls.empty() && bare.state(kStoreCrc) == PackState::DownloadsOff);
}
{ // the whole life of a pack
Rig r("vj-store-states");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(manifestJson({entryFor(body)}), body);
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(r.store.state(kStoreCrc) == PackState::Checking && r.store.busy());
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Offered && !r.store.busy());
CHECK(r.store.offered(kStoreCrc) && r.store.installed(kStoreCrc) == nullptr);
CHECK(r.store.state(0x12345678) == PackState::NotOffered);
CHECK(r.installNow());
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
// the store ships a new pack (same CRC, new bytes): Update available, Update installs it
std::string body2 = body + "\ncheat3_desc = \"Extra\"\ncheat3_code = \"001F0030:01\"\n";
r.serve(manifestJson({entryFor(body2)}), body2);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::UpdateAvailable);
CHECK(r.installNow());
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
CHECK(r.cm.communitySet() && r.cm.communitySet()->cheats.size() == 4);
// the manifest drops it: the file stays, "no longer offered"
r.serve(manifestJson({}), "");
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::NoLongerOffered && r.store.offered(kStoreCrc) == nullptr);
CHECK(fs::exists(r.cht()) && r.store.installed(kStoreCrc));
CHECK(!r.store.install(kStoreCrc)); // Update is not offered
// edited on disk: detected on the next window open; Update (when offered again) replaces it
r.serve(manifestJson({entryFor(body2)}), body2);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
{ std::ofstream f(r.cht(), std::ios::binary | std::ios::app); f << "# my tweak\n"; }
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(r.store.state(kStoreCrc) == PackState::EditedLocally);
r.store.pump(r.cm);
CHECK(r.installNow());
CHECK(r.store.state(kStoreCrc) == PackState::Installed && slurp(r.cht()) == body2);
// a hand-copied pack with no sidecar: still works, credits unavailable, Update offered
fs::remove(r.sidecar());
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(r.store.installed(kStoreCrc) == nullptr && r.store.state(kStoreCrc) == PackState::UpdateAvailable);
}
{ // hardcore: install is data-only; apply() still sends only the reset
Rig r("vj-store-states-hardcore");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.cm.blocked = [] { return true; };
r.serve(manifestJson({entryFor(body)}), body);
r.open();
CHECK(r.installNow());
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
CHECK(r.cm.communitySet() && r.cm.communitySet()->cheats.size() == 3);
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
r.rec.log.clear();
r.cm.apply();
CHECK(r.rec.log.size() == 1 && r.rec.log[0] == "reset");
}
{ // an install for a pack that is not the loaded game's does not touch the loaded game
Rig r("vj-store-states-other");
r.cm.gameLoaded(cartGame(0x12345678));
r.serve(manifestJson({entryFor(body)}), body);
r.open(kStoreCrc);
int batches = r.rec.batches;
CHECK(r.installNow() && r.store.state(kStoreCrc) == PackState::Installed);
CHECK(r.rec.batches == batches && r.cm.communitySet() == nullptr);
}
}
static void testRetryGate() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-gate");
const std::string murl = std::string(kBase) + "manifest.json";
r.serve(manifestJson({entryFor(body)}), body);
r.open();
CHECK(r.net.calls.size() == 1 && r.net.calls[0] == murl);
CHECK_EQ(r.cfg.get("cheats.community.checked"), std::to_string(r.t));
CHECK(fs::exists(r.tmp / "community/manifest.json"));
// within 6 h: no fetch
r.t += 5 * 3600;
r.open();
CHECK(r.net.calls.size() == 1);
// the button forces it, and moves the check time
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.net.calls.size() == 2);
CHECK_EQ(r.cfg.get("cheats.community.checked"), std::to_string(r.t));
// 6 h later: due again
r.t += 6 * 3600;
r.open();
CHECK(r.net.calls.size() == 3);
// a failed fetch changes neither the check time nor the cache, and the next open retries
const std::string cachedBefore = slurp(r.tmp / "community/manifest.json");
const std::string checkedBefore = r.cfg.get("cheats.community.checked");
r.net.offline = true;
r.t += 7 * 3600;
r.open();
CHECK(r.net.calls.size() == 4);
CHECK(r.store.status().find("Could not reach the cheat store: connection refused") == 0);
CHECK(r.store.status().find("Installed packs still work.") != std::string::npos);
CHECK_EQ(r.cfg.get("cheats.community.checked"), checkedBefore);
CHECK_EQ(slurp(r.tmp / "community/manifest.json"), cachedBefore);
CHECK(r.store.state(kStoreCrc) == PackState::Offered); // the cached manifest still answers
r.open();
CHECK(r.net.calls.size() == 5);
r.net.offline = false;
r.open();
CHECK(r.net.calls.size() == 6 && r.store.status().empty());
CHECK_EQ(r.cfg.get("cheats.community.checked"), std::to_string(r.t));
// a clock that went backwards (checked in the future) does not wedge the gate
r.t -= 100000;
r.open();
CHECK(r.net.calls.size() == 7);
// a manifest nobody can read (oversize, format 2, junk) keeps the cache and the check time
const std::string keep = slurp(r.tmp / "community/manifest.json");
const std::string keepChecked = r.cfg.get("cheats.community.checked");
r.net.bodies[murl] = std::string(kCheatManifestMaxBytes + 1, ' ');
r.store.checkNow(); r.store.pump(r.cm);
CHECK(r.store.status().find("response too large") != std::string::npos);
for (const std::string& bad : {manifestJson({}, "2"), std::string("{\"format\":1,\"packs\":[{"), std::string("<html>")}) {
r.net.bodies[murl] = bad;
r.store.checkNow(); r.store.pump(r.cm);
CHECK(r.store.status().find("not understood") != std::string::npos);
}
CHECK_EQ(slurp(r.tmp / "community/manifest.json"), keep);
CHECK_EQ(r.cfg.get("cheats.community.checked"), keepChecked);
CHECK(r.store.state(kStoreCrc) == PackState::Offered);
}
static void testOfflineAfterInstall() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-offline");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
// "restart": a new store over the same directory, and a network that is down
CheatStore again;
Rig* rp = &r;
again.fetch = [rp](const std::string&, size_t, std::string&, std::string& e) { rp->net.calls.push_back("again"); e = "no route"; return false; };
again.downloadsAllowed = [] { return true; };
again.now = [rp] { return rp->t + 99 * 3600; };
again.setDir((r.tmp / "community").string());
Config cfg2;
again.windowOpened(kStoreCrc, cfg2);
again.pump(r.cm);
CHECK(again.status().find("Could not reach the cheat store: no route") == 0);
CHECK(again.state(kStoreCrc) == PackState::Installed); // the cached manifest still says current
const PackMeta* in = again.installed(kStoreCrc);
CHECK(in && in->credit == "Cheats by the test suite." && in->title == "Test Game" && in->notes.size() == 2 && in->partial);
CHECK(r.cm.communitySet() && r.cm.communitySet()->cheats.size() == 3);
// no cached manifest either (first run, offline): the installed pack is still Installed
fs::remove(r.tmp / "community/manifest.json");
CheatStore cold;
cold.fetch = again.fetch;
cold.downloadsAllowed = [] { return true; };
cold.setDir((r.tmp / "community").string());
Config cfg3;
cold.windowOpened(kStoreCrc, cfg3);
cold.pump(r.cm);
CHECK(cold.state(kStoreCrc) == PackState::Installed && cold.installed(kStoreCrc) != nullptr);
CHECK(cold.state(0x12345678) == PackState::NotOffered);
}
static void testRemove() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-remove");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
CHECK(r.cm.enabledCount() == 1);
CHECK(r.store.remove(kStoreCrc, r.cm));
CHECK(!r.hasFiles() && r.cm.communitySet() == nullptr && r.cm.enabledCount() == 0);
CHECK(r.store.state(kStoreCrc) == PackState::Offered && r.store.installed(kStoreCrc) == nullptr);
CHECK(!r.store.remove(kStoreCrc, r.cm));
CHECK(slurp(r.tmp / "games/crc-5e2cdbc0.ini").find("cp.") == std::string::npos); // no community toggles left in the ini
// reinstall starts with everything off
CHECK(r.installNow());
CHECK(r.cm.communitySet() && r.cm.enabledCount() == 0);
}
// A pack file that exists but does not parse (zero-byte, truncated, junk) must stay removable: the UI derives Remove (and
// Update when offered) from state() alone, not from cm.communitySet().
static void testUnreadablePack() {
const std::string body = storeFixture("5E2CDBC0.cht");
{ // sidecar present, file emptied: EditedLocally, Update offered, Remove clears files and cp. toggles
Rig r("vj-store-unreadable");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
{ std::ofstream f(r.cht(), std::ios::binary | std::ios::trunc); }
r.store.windowOpened(kStoreCrc, r.cfg);
r.cm.gameLoaded(cartGame(kStoreCrc));
CHECK(r.cm.communitySet() == nullptr);
CHECK(r.store.state(kStoreCrc) == PackState::EditedLocally && r.store.offered(kStoreCrc));
CHECK(r.store.remove(kStoreCrc, r.cm));
CHECK(!r.hasFiles() && r.store.state(kStoreCrc) == PackState::Offered);
CHECK(slurp(r.tmp / "games/crc-5e2cdbc0.ini").find("cp.") == std::string::npos);
}
{ // no sidecar, junk file, manifest loaded and still offering it: Update offered (UpdateAvailable), Remove works
Rig r("vj-store-unreadable-nosidecar");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
fs::remove(r.sidecar());
{ std::ofstream f(r.cht(), std::ios::binary | std::ios::trunc); f << "junk\n"; }
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(r.store.state(kStoreCrc) == PackState::UpdateAvailable);
CHECK(r.store.remove(kStoreCrc, r.cm) && !r.hasFiles());
}
{ // no manifest at all and a junk file: Installed (nothing to compare against), Remove works
Rig r("vj-store-unreadable-offline");
r.downloads = false;
r.cm.gameLoaded(cartGame(kStoreCrc));
r.store.windowOpened(kStoreCrc, r.cfg);
fs::create_directories(fs::path(r.cht()).parent_path());
{ std::ofstream f(r.cht(), std::ios::binary | std::ios::trunc); }
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
CHECK(r.store.remove(kStoreCrc, r.cm) && !r.hasFiles());
}
}
static void testMessagesOncePerLoad() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-messages");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
// nothing enabled: nothing said, at load or on later applies
r.store.gameLoaded(kStoreCrc, r.cm);
r.store.afterApply(r.cm);
CHECK(r.msgs.empty());
// enabling one later in the same load says the messages, once
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 2 && r.msgs[0] == "Test Game: Fixture message one." && r.msgs[1] == "Test Game: Fixture message two.");
r.cm.setEnabled(r.cm.communitySet()->cheats[1], true, CheatOrigin::Community); // a second apply in the same load
r.store.afterApply(r.cm);
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 2);
// a new load (the toggles are saved): again, once
r.cm.gameLoaded(cartGame(kStoreCrc));
r.store.gameLoaded(kStoreCrc, r.cm);
CHECK(r.msgs.size() == 4);
r.store.afterApply(r.cm);
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 4);
// hardcore: cheats cannot apply, so no messages; they come once it lifts
r.cm.blocked = [] { return true; };
r.cm.gameLoaded(cartGame(kStoreCrc));
r.store.gameLoaded(kStoreCrc, r.cm);
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 4);
r.cm.blocked = nullptr;
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 6);
// every cheat off again: a load says nothing
r.cm.disableAll();
r.cm.gameLoaded(cartGame(kStoreCrc));
r.store.gameLoaded(kStoreCrc, r.cm);
CHECK(r.msgs.size() == 6);
// a database cheat or a manual code does not count: only the community pack's own
std::string err;
CHECK(r.cm.addUser("manual", "001F0100:01", err));
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 6);
// another game, a pack without messages, a missing hook: silent
r.cm.gameLoaded(cartGame(0x12345678));
r.store.gameLoaded(0x12345678, r.cm);
r.store.afterApply(r.cm);
CHECK(r.msgs.size() == 6);
r.cm.gameLoaded(cartGame(kStoreCrc));
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
r.store.message = nullptr;
r.store.gameLoaded(kStoreCrc, r.cm);
CHECK(r.msgs.size() == 6);
r.store.gameClosed();
CHECK(r.store.state(kStoreCrc) == PackState::NoGame);
}
static void testUpdateTurnedOff() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-turned-off");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
CHECK(r.store.turnedOff() == 0);
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community); // Infinite lives
r.cm.setEnabled(r.cm.communitySet()->cheats[1], true, CheatOrigin::Community); // Max score
// the update changes Max score's code and adds a cheat: exactly the one cheat that was on and changed is reported
std::string body2 = body;
const size_t at = body2.find("001F0010:1234");
CHECK(at != std::string::npos);
body2.replace(at, 13, "001F0010:5678");
body2 += "\ncheat3_desc = \"Extra\"\ncheat3_code = \"001F0030:01\"\n";
r.serve(manifestJson({entryFor(body2)}), body2);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::UpdateAvailable);
CHECK(r.installNow());
CHECK(r.store.turnedOff() == 1 && r.cm.enabledCount() == 1);
// a changed cheat that was OFF is not counted: update again, changing "Gold" (off)
const size_t g2 = body2.find("001F0020:01");
CHECK(g2 != std::string::npos);
std::string body3 = body2;
body3.replace(g2, 11, "001F0020:07");
r.serve(manifestJson({entryFor(body3)}), body3);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.installNow());
CHECK(r.store.turnedOff() == 0);
// the notice is cleared by a new load and by Remove
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
std::string body4 = body3;
body4.replace(body4.find("001F0000:AB"), 11, "001F0000:AC");
r.serve(manifestJson({entryFor(body4)}), body4);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.installNow() && r.store.turnedOff() == 1);
r.cm.gameLoaded(cartGame(kStoreCrc));
r.store.gameLoaded(kStoreCrc, r.cm);
CHECK(r.store.turnedOff() == 0);
r.cm.setEnabled(r.cm.communitySet()->cheats[1], true, CheatOrigin::Community);
std::string body5 = body4;
body5.replace(body5.find("001F0010:5678"), 13, "001F0010:9999");
r.serve(manifestJson({entryFor(body5)}), body5);
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.installNow() && r.store.turnedOff() == 1);
CHECK(r.store.remove(kStoreCrc, r.cm) && r.store.turnedOff() == 0);
}
static void testPostedGameEvents() {
// The app's game load / close run on the emulation thread: they are posted and the store only changes in pump().
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-posted");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(storeFixture("manifest.json"), body);
r.open();
CHECK(r.installNow());
r.cm.setEnabled(r.cm.communitySet()->cheats[0], true, CheatOrigin::Community);
r.store.gameClosed();
CHECK(r.store.state(kStoreCrc) == PackState::NoGame);
r.msgs.clear();
std::thread([&] { r.store.postGameLoaded(kStoreCrc); }).join();
CHECK(r.store.state(kStoreCrc) == PackState::NoGame && r.msgs.empty()); // nothing happened off the main thread
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Installed); // applied by pump, which also announced
CHECK(r.msgs.size() == 2);
r.store.pump(r.cm);
CHECK(r.msgs.size() == 2); // consumed once
std::thread([&] { r.store.postGameClosed(); }).join();
CHECK(r.store.state(kStoreCrc) == PackState::Installed);
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::NoGame);
// only the last post counts: close then load is a load (a new load, announced again); load then close is a close
std::thread([&] { r.store.postGameClosed(); r.store.postGameLoaded(kStoreCrc); }).join();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Installed && r.msgs.size() == 4);
std::thread([&] { r.store.postGameLoaded(kStoreCrc); r.store.postGameClosed(); }).join();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::NoGame && r.msgs.size() == 4);
// stop() drops a post nobody pumped
std::thread([&] { r.store.postGameLoaded(kStoreCrc); }).join();
r.store.stop();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::NoGame);
}
static void testAsyncHandoff() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-async");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(manifestJson({entryFor(body)}), body);
std::vector<std::thread> threads;
r.store.run = [&](std::function<void()> job) { threads.emplace_back(std::move(job)); };
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(r.store.busy() && r.store.state(kStoreCrc) == PackState::Checking);
CHECK(!r.store.install(kStoreCrc)); // one request at a time
for (std::thread& t : threads) t.join();
threads.clear();
CHECK(r.store.state(kStoreCrc) == PackState::Checking); // the result is not applied until pump()
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Offered && !r.store.busy());
CHECK(r.store.install(kStoreCrc));
for (std::thread& t : threads) t.join();
threads.clear();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Installed && r.cm.communitySet());
// stop(): a closure that starts afterwards does nothing, and nothing is applied
std::vector<std::function<void()>> later;
r.store.run = [&](std::function<void()> job) { later.push_back(std::move(job)); };
r.net.calls.clear();
r.t += 7 * 3600;
r.store.windowOpened(kStoreCrc, r.cfg);
CHECK(later.size() == 1);
r.store.stop();
CHECK(r.store.cancelFlag() && !r.store.busy());
later[0]();
r.store.pump(r.cm);
CHECK(r.net.calls.empty());
}
static void testFetchThrows() {
const std::string body = storeFixture("5E2CDBC0.cht");
for (int which = 0; which < 2; which++) { // 0: the manifest fetch throws, 1: the pack fetch throws
Rig r("vj-store-throws");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(manifestJson({entryFor(body)}), body);
std::vector<std::thread> threads;
r.store.run = [&](std::function<void()> job) { threads.emplace_back(std::move(job)); };
if (which == 1) {
r.store.windowOpened(kStoreCrc, r.cfg);
for (std::thread& t : threads) t.join();
threads.clear();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Offered);
}
r.store.fetch = [](const std::string&, size_t, std::string&, std::string&) -> bool { throw std::runtime_error("boom"); };
if (which == 0) r.store.windowOpened(kStoreCrc, r.cfg);
else CHECK(r.store.install(kStoreCrc));
for (std::thread& t : threads) t.join(); // the closure must not escape with the exception
// stop() must return (it waits for started jobs); a hang fails the check instead of the run
std::future<void> f = std::async(std::launch::async, [&] { r.store.stop(); });
CHECK(f.wait_for(std::chrono::seconds(10)) == std::future_status::ready);
if (f.wait_for(std::chrono::seconds(0)) != std::future_status::ready) std::exit(1); // stuck: do not hang the suite
CheatStore again; // a second store with the same hook: the failure reaches status(), not terminate()
r.wire(again);
again.run = r.store.run;
again.fetch = r.store.fetch;
again.setDir((r.tmp / "community2").string());
threads.clear();
again.windowOpened(kStoreCrc, r.cfg);
for (std::thread& t : threads) t.join();
again.pump(r.cm);
CHECK(again.status().find("Could not reach the cheat store") == 0 && !again.busy());
CHECK(!fs::exists(r.cht()));
}
}
static void testRemoveDuringInstall() {
const std::string body = storeFixture("5E2CDBC0.cht");
Rig r("vj-store-remove-busy");
r.cm.gameLoaded(cartGame(kStoreCrc));
r.serve(manifestJson({entryFor(body)}), body);
r.open();
CHECK(r.installNow()); // installed once
std::string body2 = body + "\ncheat3_desc = \"Extra\"\ncheat3_code = \"001F0030:01\"\n";
r.serve(manifestJson({entryFor(body2)}), body2);
r.store.checkNow();
r.store.pump(r.cm);
std::vector<std::function<void()>> jobs;
r.store.run = [&](std::function<void()> job) { jobs.push_back(std::move(job)); };
CHECK(r.store.install(kStoreCrc) && jobs.size() == 1); // the update is in flight
CHECK(!r.store.remove(kStoreCrc, r.cm));
CHECK(r.store.status().find("in progress") != std::string::npos);
CHECK(fs::exists(r.cht()) && fs::exists(r.sidecar()));
jobs[0]();
r.store.pump(r.cm);
CHECK(r.store.state(kStoreCrc) == PackState::Installed && slurp(r.cht()) == body2); // the update was not undone
CHECK(r.store.remove(kStoreCrc, r.cm) && !r.hasFiles()); // and Remove works again
// a request for another CRC does not block Remove of this one
r.store.run = nullptr; // inline again
r.net.bodies[std::string(kBase) + "packs/5E2CDBC0.cht"] = body;
r.net.bodies[std::string(kBase) + "manifest.json"] =
manifestJson({entryFor(body), entryJson("0A0B0C0D", std::string(64, 'c'), 10, "packs/0A0B0C0D.cht")});
r.store.checkNow();
r.store.pump(r.cm);
CHECK(r.installNow());
r.store.run = [&](std::function<void()> job) { jobs.push_back(std::move(job)); };
jobs.clear();
CHECK(r.store.install(0x0A0B0C0D) && jobs.size() == 1);
CHECK(r.store.remove(kStoreCrc, r.cm) && !r.hasFiles());
jobs[0]();
r.store.pump(r.cm);
}
static void testMessageCaps() {
std::string many = "[";
for (int i = 0; i < 12; i++) many += (i ? "," : "") + std::string("\"m") + std::to_string(i) + "\"";
many += ",\"" + std::string("a") + [] { std::string e; for (int i = 0; i < 150; i++) e += "\xC3\xA9"; return e; }() + "\"]";
std::vector<PackMeta> v;
std::string err;
const std::string sha(64, 'a');
CHECK(parseManifest(manifestJson({entryJson("5E2CDBC0", sha, 10, "packs/5E2CDBC0.cht", many)}), v, err) && v.size() == 1);
if (v.size() == 1) {
CHECK(v[0].messages.size() == 8 && v[0].messages[0] == "m0" && v[0].messages[7] == "m7");
}
// one long message: cut to <= 200 bytes on a character boundary; the sidecar copy is capped the same way
std::string longMsg = "[\"a";
for (int i = 0; i < 150; i++) longMsg += "\xC3\xA9";
longMsg += "\",\"" + std::string(500, 'x') + "\"]";
CHECK(parseManifest(manifestJson({entryJson("5E2CDBC0", sha, 10, "packs/5E2CDBC0.cht", longMsg)}), v, err) && v.size() == 1);
if (v.size() == 1 && v[0].messages.size() == 2) {
CHECK(v[0].messages[0].size() == 199); // 1 + 99 two-byte characters: byte 200 would split one
CHECK((unsigned char)v[0].messages[0].back() == 0xA9 && v[0].messages[1].size() == 200);
PackMeta back;
CHECK(parsePackMeta(v[0].json, back) && back.messages.size() == 2 && back.messages[1].size() == 200);
}
}
int main(int argc, char** argv) {
if (argc >= 3 && !std::strcmp(argv[1], "--corpus")) return corpus(argv[2]);
testHelpers();
testParse();
testReplay();
testLibraryMatching();
testPersistence();
testCommitBatch();
testRevisionGuard();
testCommunityLoad();
testCommunityTogglesAcrossUpdate();
testApplyOrder();
testCommunityHardcore();
testRemoveToggles();
testSetEnabledByNameFallthrough();
testSha256();
testManifestParse();
testFixtureConsistent();
testInstallVerify();
testStates();
testRetryGate();
testOfflineAfterInstall();
testRemove();
testUnreadablePack();
testMessagesOncePerLoad();
testAsyncHandoff();
testFetchThrows();
testRemoveDuringInstall();
testMessageCaps();
testUpdateTurnedOff();
testPostedGameEvents();
std::printf("%d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}