feat(cheats): convert community source freezes with revision guards

(cherry picked from commit e6fa96e1d0)
This commit is contained in:
Joseph Mattiello committed 2026-10-20 16:24:29 -04:00
1 parent 378c15afc9
commit 98e1327cf6
10 files changed
+1024 -1

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: Community cheat converter
on:
push:
branches: [develop, master]
paths: ['tools/cheat-import/**', '.github/workflows/cheat-import.yml']
pull_request:
branches: [develop, master, 'release/**']
paths: ['tools/cheat-import/**', '.github/workflows/cheat-import.yml']
workflow_dispatch:
permissions:
contents: read
jobs:
converter:
runs-on: ubuntu-latest
strategy:
matrix:
python: ['3.10', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- run: python -m pip install -r tools/cheat-import/requirements.txt
- run: python -m unittest discover -s tools/cheat-import -v
+7
View File
@@ -635,6 +635,13 @@ Database entries are RetroArch memory cheats (decimal address/value, width in
`memory_search_size`); they become the core's hex codes, value little-endian in
memory as RetroArch writes it. `cheatN_enable` is ignored: nothing starts on.
For community-authored C cheat sources, the optional offline
[source converter](../tools/cheat-import/README.md) can produce `.cht` freezes
without an emulator binary or SDK. It reports unsupported operations, leaves
every cheat disabled, and binds the set to the matching cartridge CRC32.
The app blocks revision mismatches and malformed/duplicate guards; legacy
unbound `.cht` files continue to work. No community script pack is bundled.
## RetroAchievements
**Achievements > Sign in...** takes your RetroAchievements user name and password
+45 -1
View File
@@ -340,11 +340,42 @@ bool parseCht(const std::string& textIn, std::vector<ChtCheat>& out) {
}
bool loadChtFile(const std::string& path, ChtSet& out) {
out = ChtSet();
std::vector<unsigned char> bytes;
if (!readFile(path, bytes)) return false;
out.stem = pathStem(path);
out.path = path;
return parseCht(std::string(bytes.begin(), bytes.end()), out.cheats);
std::string text(bytes.begin(), bytes.end());
if (text.compare(0, 3, "\xEF\xBB\xBF") == 0) text.erase(0, 3);
// Optional exact-revision binding for locally converted script cheats.
// Scan independently of the permissive legacy entry parser: an invalid or
// duplicate guard must never degrade into an unbound set.
size_t start = 0;
bool seen = false;
while (start < text.size()) {
size_t end = text.find('\n', start);
if (end == std::string::npos) end = text.size();
std::string line = trim(text.substr(start, end - start));
start = end + 1;
size_t eq = line.find('=');
std::string key = trim(line.substr(0, eq));
if (key != "vj_rom_crc32") continue;
out.revisionBound = true;
std::string value = eq == std::string::npos ? "" : unquote(line.substr(eq + 1));
uint32_t crc = 0;
bool valid = value.size() == 8;
for (unsigned char c : value) {
int digit = c >= '0' && c <= '9' ? c - '0' :
c >= 'a' && c <= 'f' ? c - 'a' + 10 :
c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1;
if (digit < 0) { valid = false; break; }
crc = (crc << 4) | (uint32_t)digit;
}
if (!valid || seen) out.restriction = "Invalid or duplicate cartridge revision guard.";
out.romCrc = crc;
seen = true;
}
return parseCht(text, out.cheats);
}
// ---------------------------------------------------------------------------------
@@ -441,6 +472,18 @@ void CheatManager::loadSelectedSet() {
if (r.stem != selected_) continue;
setLoaded_ = loadChtFile(r.path, set_);
if (!setLoaded_ && warn) warn("cheats: could not read " + r.path);
if (setLoaded_ && set_.revisionBound) {
if (set_.restriction.empty() && (!game_.crc || game_.crc != set_.romCrc))
set_.restriction = "Cheat set requires a different cartridge revision (CRC32).";
if (!set_.restriction.empty()) {
for (ChtCheat& cheat : set_.cheats) {
cheat.supported = false;
cheat.code.clear();
cheat.segments = 0;
cheat.note = set_.restriction;
}
}
}
return;
}
}
@@ -471,6 +514,7 @@ void CheatManager::gameClosed() {
}
std::string CheatManager::setStatus() const {
if (setLoaded_ && !set_.restriction.empty()) return set_.restriction;
if (library_.empty()) return "No cheat files found.";
if (!selected_.empty() && !setLoaded_) return "The cheat set '" + selected_ + "' is not in the library.";
if (selected_.empty() && matches_.size() > 1)
+3
View File
@@ -51,6 +51,9 @@ struct ChtSet {
std::string stem; // file name without extension
std::string path;
std::vector<ChtCheat> cheats;
bool revisionBound = false;
uint32_t romCrc = 0;
std::string restriction; // malformed binding, or loaded cartridge does not match
};
// A set found in a library folder (not parsed until it is selected).
+71
View File
@@ -547,6 +547,76 @@ static void testCommitBatch() {
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);
}
int main(int argc, char** argv) {
if (argc >= 3 && !std::strcmp(argv[1], "--corpus")) return corpus(argv[2]);
testHelpers();
@@ -555,6 +625,7 @@ int main(int argc, char** argv) {
testLibraryMatching();
testPersistence();
testCommitBatch();
testRevisionGuard();
std::printf("%d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}
+1
View File
@@ -7,6 +7,7 @@ have.
| I have… | Use | Doc |
|---|---|---|
| A hex code from a cheat site | **Cheats** | [below](#i-have-a-hex-code-from-a-cheat-site) |
| Community-authored C memory-freeze cheats | **Offline source conversion** to `.cht` | [`tools/cheat-import`](../tools/cheat-import/README.md) |
| An `.ips` / `.bps` / `.ups` file (translation, romhack) | **Soft patching** | [`rom-patches.md`](rom-patches.md) |
| Specific bytes I want changed in one game | **Enhancement hooks** | [`enhancement-hooks.md`](enhancement-hooks.md) |
| A game that needs different *emulator settings* | **Per-title defaults** — not a patch at all | [`enhancement-hooks.md`](enhancement-hooks.md#is-my-idea-a-hook) |
+108
View File
@@ -0,0 +1,108 @@
# Community cheat source converter
Translate a limited subset of user-authored C cheat sources into the app's
existing `.cht` memory freezes. This is an offline, static source converter:
it does not execute or compile the input, follow includes, contact an emulator,
or require an SDK. Python 3.10+ and `pycparser` are needed only for conversion;
the app and libretro core gain no Python dependency or script VM.
## Use
Use sources you have permission to translate and a matching native cartridge
dump. Review the author's provenance, including credits to other authors,
before choosing a source. Do not supply compiled scripts, bundled proprietary
scripts, SDK headers, or emulator binaries.
```sh
python3 -m venv /tmp/vj-cheat-import-env
/tmp/vj-cheat-import-env/bin/pip install -r tools/cheat-import/requirements.txt
/tmp/vj-cheat-import-env/bin/python tools/cheat-import/convert.py community-cheat.c \
--rom '/path/to/matching-cart.j64' \
--output '/path/to/cheats/Game Title.cht' \
--report '/path/to/conversion-report.json'
```
All output files must be new. The converter creates a JSON report with source
SHA256, ROM CRC32/FNV1a64, and the result for each setting. It emits no ROM bytes.
The source's load callback must accept the supplied ROM hash and each write
must be controlled by that hash and a boolean setting. A wrong revision
produces no active cheats.
By default, any unsupported setting prevents `.cht` output (exit 1); the JSON
report explains the omissions. Add `--allow-partial` to export only complete
supported toggles. A toggle with one unsupported operation is omitted in full.
Parse errors, unsupported module structure, interacting toggles, and file errors
fail conversion (exit 2). Those structural failures are printed to stderr and
may occur before a report is created. Success is exit 0.
Put the `.cht` in the app's cheat directory or pass its directory with
`--cheats-dir`. Select it in **Cheats** and enable individual entries. Choose a
filename matching the game title for automatic set selection. Source default
settings never auto-enable entries. The existing per-game persistence, atomic
cheat updates, 256-write limit, and RetroAchievements hardcore block apply.
Generated files contain `vj_rom_crc32 = "XXXXXXXX"`. The Virtual Jaguar app
blocks the whole set when the loaded cartridge's CRC differs, is unknown, or
the guard is malformed/duplicated. Legacy files without this field still work.
**RetroArch does not enforce this app-specific guard**; only use converted
codes with the exact matching cartridge there. Keep the JSON report with the
file. The guard prevents accidental revision mismatches; it is not an
authenticity signature. Do not remove it to make a different dump pass.
## Supported subset
- One category, boolean settings, a software-load callback, and an emulation
frame callback, using the registration/call names present in the author's
community C sources.
- Constant byte, word, and long writes to the 2 MiB main RAM, including grouped
writes and bounded integer loops. Word/long addresses must be even.
- Integer constants, object-like macros, enums, scalar locals, explicit integer
casts, simple arithmetic/bitwise expressions and conditions. Fixed-width C
integer promotion and unsigned wrapping are modeled; signed overflow fails.
- At most ten settings, with every combination of the exported toggles checked
for equivalent final byte writes. Cheats run as an ordered group per toggle;
sources whose combined behavior cannot be preserved are refused.
RAM reads, copy/conditional/masked cheats, parameter-valued settings, helper
functions, pointers/arrays, static local state, frame-to-frame state changes,
extra events, early returns, general preprocessing, and other unsupported C
syntax need a manual port. No runtime values are guessed. Conversion assumes
omitted settings remain off and produces only the exported constant freezes;
it cannot provide the omitted source features. Overlapping cheats still follow
the app's normal application order. Test converted cheats in the game and
keep save backups before relying on them.
Raw uncompressed `.j64`, `.jag`, `.rom`, and `.bin` cartridge inputs up to 16 MiB
are accepted. Recognized copier headers are refused: provide the native payload
so both the source hash and app CRC refer to the same bytes. Disc images and
other software containers are outside this first version.
## Provenance and validation
This feature responds to [#1050](https://github.com/libretro/virtualjaguar-libretro/issues/1050),
where the owner of `jguffer/BigPEmu_Scripts` says they generated those scripts
and requests translation. That repository has no stated license. This project
does not redistribute its sources or a converted cheat pack, and does not
infer redistribution permission from the request.
Local conversion was checked against the author's `cheat_kasumi_ninja.c`,
`cheat_atari_karts.c`, and `cheat_doom.c` at community repository commit
`24211503339a969f8494b8e189b8f2aa0cb9eba6`, using matching private cartridges.
Their hashes matched; one, two of three, and eight toggles respectively
converted. Atari Karts' read/modify/write unlock setting was refused. An
additional file credited values to a third-party bundled script and was
excluded from conversion validation. No emulator binary, SDK header, compiled
script, or proprietary bundled script was used to implement the converter.
This is a statement of sources used, not a legal certification of a clean-room
process. Community conversion output remains local and uncommitted.
All committed tests use original synthetic inputs:
```sh
python3 -m unittest discover -s tools/cheat-import -v
```
The app's `cheats_unit` test additionally checks the revision guard, saved
toggles, legacy files, hardcore gating, and grouped byte/word/long replay through
the core's real parser. This validates translation and application mechanics;
it does not establish that a community author's cheat is correct in gameplay.
+559
View File
@@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""Statically translate a conservative subset of community cheat C sources.
Does not compile, import, execute, preprocess includes, or download input code.
No SDK or proprietary emulator implementation is used. See README.md.
"""
import argparse
import ast
import hashlib
import itertools
import json
import re
import sys
import zlib
from dataclasses import dataclass, replace
from pathlib import Path
from pycparser import c_ast as C, c_parser
class Unsupported(ValueError):
pass
@dataclass(frozen=True)
class Value:
n: int
deps: frozenset = frozenset()
bits: int = 32
unsigned: bool = False
def integer(value, bits=32, unsigned=False):
number = value.n
if unsigned:
number &= (1 << bits)-1
elif not -(1 << (bits-1)) <= number < (1 << (bits-1)):
raise Unsupported("signed integer overflow/conversion requires manual review")
return Value(number, value.deps, bits, unsigned)
def type_info(node):
if not isinstance(node, C.TypeDecl) or not isinstance(node.type, C.IdentifierType):
raise Unsupported("only scalar integer declarations are supported")
typ = " ".join(node.type.names)
match = re.fullmatch(r"(u?)int(8|16|32|64)_t", typ)
if match: return int(match[2]), bool(match[1])
if typ in ("int", "signed int", "bool"): return 32, False
if typ in ("unsigned", "unsigned int"): return 32, True
raise Unsupported("unsupported integer type " + typ)
def promote(value):
return integer(value) if value.bits < 32 else value
def common(left, right):
left, right = promote(left), promote(right)
bits = max(left.bits, right.bits)
unsigned = any(v.unsigned and v.bits == bits for v in (left, right))
return integer(left, bits, unsigned), integer(right, bits, unsigned)
TYPES = """
typedef unsigned char uint8_t; typedef unsigned short uint16_t;
typedef unsigned int uint32_t; typedef unsigned long long uint64_t;
typedef signed char int8_t; typedef short int16_t; typedef int int32_t;
typedef long long int64_t; typedef unsigned int size_t; typedef int bool;
"""
def uncomment(text):
# Keep quoted strings/characters intact, including comment-looking text.
pattern = r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|//[^\n]*|/\*.*?\*/'
return re.sub(pattern, lambda m: "\n" * m[0].count("\n") + " "
if m[0].startswith(("//", "/*")) else m[0], text, flags=re.S)
def fnv1a64(data):
value = 0xCBF29CE484222325
for byte in data:
value = ((value ^ byte) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
return value
def rom_bytes(path):
if path.suffix.lower() not in (".j64", ".jag", ".rom", ".bin"):
raise Unsupported("use an uncompressed raw cartridge (.j64/.jag/.rom/.bin)")
if not 0 < path.stat().st_size <= 16*1024*1024:
raise Unsupported("raw cartridge size must be 1 byte through 16 MiB")
data = path.read_bytes()
if not data or len(data) > 16 * 1024 * 1024:
raise Unsupported("raw cartridge size must be 1 byte through 16 MiB")
# Same header detection as src/core/file.c. Refuse instead of guessing which
# bytes a foreign script's hash referred to. The user supplies a native dump.
marker = b"\x04\x04\x04\x04"
if (len(data) >= 0x604 and len(data) % 0x100000 and data[0x600:0x604] == marker
and ((len(data)-512) % 0x100000 == 0
or (len(data)-512 < 0x100000 and data[0x400:0x404] != marker))):
raise Unsupported("copier-headered ROM: supply the native cartridge payload")
return data
class Runtime:
"""Bounded abstract evaluation: integer constants and setting dependencies.
RAM reads and arbitrary calls have no implementation. A write can only be
emitted if its value/control depend solely on the one selected toggle.
"""
def __init__(self, module, rom_hash, selected=None):
self.module = module
self.rom_hash = rom_hash
self.selected = selected
self.scopes = [{}]
self.types = [{}]
self.frame = False
self.path = frozenset()
self.writes = []
self.steps = 0
self.resolving = set()
def tick(self):
self.steps += 1
if self.steps > 20000:
raise Unsupported("evaluation/loop budget exceeded")
def get(self, name):
for scope in reversed(self.scopes):
if name in scope:
value = scope[name]
if value is None: raise Unsupported("read of uninitialised local " + name)
return value
if name in self.module.macros:
if name in self.resolving:
raise Unsupported("recursive macro " + name)
self.resolving.add(name)
try:
return self.expr(self.module.macros[name])
finally:
self.resolving.remove(name)
raise Unsupported("unknown identifier " + name)
def put(self, name, value, declare=False, typ=None):
if value is not None:
value = replace(value, deps=value.deps | self.path)
if declare:
self.types[-1][name] = typ or (32, False)
self.scopes[-1][name] = integer(value, *self.types[-1][name]) if value else None
return
for i in reversed(range(len(self.scopes))):
scope = self.scopes[i]
if name in scope:
if self.frame and i == 0:
raise Unsupported("frame callback changes persistent state")
scope[name] = integer(value, *self.types[i][name])
return
raise Unsupported("assignment to unknown identifier " + name)
def expr(self, node):
self.tick()
if isinstance(node, C.Constant):
if node.type == "string":
raise Unsupported("string used as an integer")
if node.type == "char":
number = ord(ast.literal_eval(node.value))
if number > 127: raise Unsupported("non-ASCII character constant")
return Value(number)
token = re.sub(r"[uUlL]+$", "", node.value)
if node.value.lower().count("l") == 1:
raise Unsupported("platform-dependent long literal requires manual review")
try:
number = int(token, 8 if re.fullmatch(r"0[0-7]+", token) else 0)
unsigned = "u" in node.value.lower()
bits = 64 if "ll" in node.value.lower() else 32
if not unsigned and number >= 1 << (bits-1):
if token.lower().startswith("0x"): unsigned = True
else: bits = 64
if number >= 1 << bits: raise Unsupported("integer literal out of range")
return integer(Value(number), bits, unsigned)
except ValueError:
raise Unsupported("non-integer constant " + node.value)
if isinstance(node, C.ID):
return self.get(node.name)
if isinstance(node, C.Cast):
val = self.expr(node.expr)
return integer(val, *type_info(node.to_type.type))
if isinstance(node, C.UnaryOp):
if node.op in ("++", "p++", "--", "p--") and isinstance(node.expr, C.ID):
old = self.get(node.expr.name)
new = integer(replace(old, n=old.n + (1 if "+" in node.op else -1)), old.bits, old.unsigned)
self.put(node.expr.name, new)
return old if node.op.startswith("p") else new
val = promote(self.expr(node.expr))
if node.op == "+": return val
if node.op == "-": return integer(replace(val, n=-val.n), val.bits, val.unsigned)
if node.op == "!": return Value(int(not val.n), val.deps)
if node.op == "~": return integer(replace(val, n=~val.n), val.bits, val.unsigned)
raise Unsupported("unsupported unary operator " + node.op)
if isinstance(node, C.BinaryOp):
left = self.expr(node.left)
if node.op == "&&" and not left.n: return Value(0, left.deps)
if node.op == "||" and left.n: return Value(1, left.deps)
right = self.expr(node.right)
if node.op in ("<<", ">>"):
left, right = promote(left), promote(right)
else:
left, right = common(left, right)
a, b = left.n, right.n
ops = {"+": lambda: a+b, "-": lambda: a-b, "*": lambda: a*b,
"&": lambda: a & b, "|": lambda: a | b, "^": lambda: a ^ b,
"==": lambda: int(a == b), "!=": lambda: int(a != b),
"<": lambda: int(a < b), ">": lambda: int(a > b),
"<=": lambda: int(a <= b), ">=": lambda: int(a >= b),
"&&": lambda: int(bool(a and b)), "||": lambda: int(bool(a or b))}
if node.op in ("<<", ">>"):
if not 0 <= b < left.bits or a < 0:
raise Unsupported("unsupported shift")
number = a << b if node.op == "<<" else a >> b
elif node.op in ops:
number = ops[node.op]()
else:
raise Unsupported("unsupported binary operator " + node.op)
result = Value(number, left.deps | right.deps)
if node.op in ("==", "!=", "<", ">", "<=", ">=", "&&", "||"):
return result
return integer(result, left.bits, left.unsigned)
if isinstance(node, C.TernaryOp):
# C chooses a common type from both arms, even when only one runs.
raise Unsupported("conditional expression requires manual review")
if isinstance(node, C.FuncCall):
return self.call(node)
raise Unsupported("unsupported expression " + type(node).__name__)
def call(self, node):
name = getattr(node.name, "name", "")
args = node.args.exprs if node.args else []
if name == "bigpemu_get_loaded_fnv1a64" and not args:
return Value(self.rom_hash, frozenset(["rom"]), 64, True)
if name == "bigpemu_get_setting_value" and len(args) == 2:
ptr = args[0]
if not isinstance(ptr, C.UnaryOp) or ptr.op != "&" or not isinstance(ptr.expr, C.ID):
raise Unsupported("setting output must be a scalar local variable")
identifier = getattr(args[1], "name", None)
if identifier not in self.module.handles or any(identifier in scope for scope in self.scopes[1:]):
raise Unsupported("setting handle must be its registered global identifier")
handle = self.get(identifier).n
if handle != self.module.handles[identifier]:
raise Unsupported("setting handle was changed")
self.put(ptr.expr.name, Value(int(handle in (self.selected or ())), frozenset([handle])))
return Value(0)
write = re.fullmatch(r"bigpemu_jag_write(8|16|32)", name)
if write and len(args) == 2:
address, value = map(self.expr, args)
deps = self.path | address.deps | value.deps
if not self.selected or "rom" not in deps or not (deps - {"rom"}) or not deps <= self.selected | {"rom"}:
raise Unsupported("write needs a ROM-hash guard and exactly one independent setting")
if not address.deps <= self.path:
raise Unsupported("setting-dependent write address")
width = int(write[1]) // 8
if not 0 <= address.n <= 0x200000-width:
raise Unsupported("write outside 2 MiB main RAM")
if width > 1 and address.n % 2:
raise Unsupported("unaligned word/long write requires manual review")
if len(self.writes) >= 256:
raise Unsupported("cheat exceeds the core's 256-write limit")
self.writes.append((address.n, value.n & ((1 << (8*width))-1), width))
return Value(0)
raise Unsupported("unsupported call " + name + " (RAM-dependent operations are not constant cheats)")
def stmt(self, node):
if node is None: return
self.tick()
if isinstance(node, C.Compound):
self.scopes.append({})
self.types.append({})
try:
for child in node.block_items or []: self.stmt(child)
finally:
self.scopes.pop()
self.types.pop()
elif isinstance(node, C.Decl):
if isinstance(node.type, (C.PtrDecl, C.ArrayDecl)):
raise Unsupported("pointer/array declarations require manual review")
if "static" in node.storage and len(self.scopes) > 1:
raise Unsupported("static local state requires manual review")
value = self.expr(node.init) if node.init else (Value(0) if len(self.scopes) == 1 else None)
self.put(node.name, value, declare=True, typ=type_info(node.type))
elif isinstance(node, C.DeclList):
for child in node.decls: self.stmt(child)
elif isinstance(node, C.Assignment) and isinstance(node.lvalue, C.ID):
if node.op == "=": value = self.expr(node.rvalue)
else:
value = self.expr(C.BinaryOp(node.op[:-1], node.lvalue, node.rvalue))
self.put(node.lvalue.name, value)
elif isinstance(node, C.If):
cond = self.expr(node.cond)
old = self.path
self.path |= cond.deps
try: self.stmt(node.iftrue if cond.n else node.iffalse)
finally: self.path = old
elif isinstance(node, C.For):
self.scopes.append({})
self.types.append({})
try:
self.stmt(node.init)
while True:
cond = self.expr(node.cond)
if not cond.deps <= self.path:
raise Unsupported("setting-dependent loop bound")
if not cond.n: break
self.stmt(node.stmt); self.stmt(node.next)
finally:
self.scopes.pop()
self.types.pop()
elif isinstance(node, (C.FuncCall, C.UnaryOp)):
self.expr(node)
elif isinstance(node, C.Return):
# Callbacks in this subset must end with a literal return 0. An early
# return changes execution and is deliberately not approximated.
raise Unsupported("early return requires manual review")
elif not isinstance(node, C.EmptyStatement):
raise Unsupported("unsupported statement " + type(node).__name__)
def run(self, name):
body = self.module.functions[name].body
statements = list(body.block_items or [])
if not statements or not isinstance(statements[-1], C.Return):
raise Unsupported("callback must end with return 0")
if not isinstance(statements[-1].expr, C.Constant) or statements[-1].expr.value != "0":
raise Unsupported("callback must return 0")
self.scopes.append({})
self.types.append({})
self.frame = name == self.module.events["bigpemu_register_event_emu_thread_frame"]
try:
for statement in statements[:-1]: self.stmt(statement)
finally:
self.scopes.pop()
self.types.pop()
self.frame = False
class Module:
def __init__(self, source):
if len(source) > 1024*1024: raise Unsupported("source exceeds 1 MiB")
self.parser = c_parser.CParser()
self.macros = {}
clean = uncomment(source.replace("\\\n", ""))
lines = []
for line in clean.splitlines():
if not line.lstrip().startswith("#"):
lines.append(line); continue
define = re.fullmatch(r"\s*#\s*define\s+([A-Za-z_]\w*)\s+(.+)", line)
if define:
name, expr = define.groups()
tree = self.parser.parse(TYPES + "void value(void) { return (" + expr + "); }")
if name in self.macros: raise Unsupported("duplicate macro " + name)
self.macros[name] = tree.ext[-1].body.block_items[0].expr
elif not re.fullmatch(r'\s*#\s*include\s*["<]bigpcrt\.h[">]\s*', line):
raise Unsupported("unsupported preprocessor directive: " + line.strip())
tree = self.parser.parse(TYPES + "\n" + "\n".join(lines))
self.functions = {n.decl.name: n for n in tree.ext if isinstance(n, C.FuncDef)}
if len(self.functions) != sum(isinstance(n, C.FuncDef) for n in tree.ext):
raise Unsupported("duplicate function definition")
if "bigp_init" not in self.functions: raise Unsupported("missing bigp_init")
self.handles, self.settings, self.events = {}, [], {}
categories = []
registered = set()
class Registrations(C.NodeVisitor):
def visit_FuncCall(visitor, node):
name = getattr(node.name, "name", "")
if name not in {"bigpemu_get_module_handle", "bigpemu_set_module_usage_flags",
"bigpemu_register_setting_category", "bigpemu_register_setting_bool",
"bigpemu_register_setting_int", "bigpemu_register_event_sw_loaded",
"bigpemu_register_event_emu_thread_frame"}:
raise Unsupported("unsupported initialisation call " + name)
if name.startswith("bigpemu_register_") and id(node) not in registered:
raise Unsupported("registration must assign a handle")
visitor.generic_visit(node)
def visit_If(visitor, node):
raise Unsupported("conditional initialisation requires manual review")
def visit_For(visitor, node):
raise Unsupported("initialisation loop requires manual review")
visit_While = visit_For
visit_DoWhile = visit_For
visit_Switch = visit_If
def visit_Return(visitor, node):
raise Unsupported("initialisation return requires manual review")
def visit_Decl(visitor, node):
if not isinstance(node.init, C.FuncCall):
raise Unsupported("unsupported initialisation declaration")
scan(node.name, node.init)
visitor.generic_visit(node)
def visit_Assignment(visitor, node):
if not (node.op == "=" and isinstance(node.rvalue, C.FuncCall) and isinstance(node.lvalue, C.ID)):
raise Unsupported("unsupported initialisation assignment")
scan(node.lvalue.name, node.rvalue)
visitor.generic_visit(node)
def scan(handle, call):
registered.add(id(call))
name = getattr(call.name, "name", "")
args = call.args.exprs if call.args else []
if name == "bigpemu_register_setting_category" and len(args) == 2:
categories.append(ast.literal_eval(args[1].value))
elif name.startswith("bigpemu_register_setting_") and len(args) >= 3:
if handle in self.handles: raise Unsupported("duplicate setting handle")
key = len(self.settings)+1
self.handles[handle] = key
self.settings.append((key, ast.literal_eval(args[1].value), name.endswith("_bool")))
elif name.startswith("bigpemu_register_event_") and len(args) == 2:
if name in self.events: raise Unsupported("duplicate event registration")
self.events[name] = args[-1].name
Registrations().visit(self.functions["bigp_init"].body)
required = {"bigpemu_register_event_sw_loaded", "bigpemu_register_event_emu_thread_frame"}
if set(self.events) != required or len(set(categories)) != 1 or not self.settings:
raise Unsupported("expected one title, settings, and load/frame callbacks only")
if any(name not in self.functions for name in self.events.values()):
raise Unsupported("missing registered callback body")
self.title = categories[0]
labels = [label for _, label, _ in self.settings]
if len(set(label.casefold() for label in labels)) != len(labels) or any(
re.search(r" \(part [0-9]+\)$", label, re.I) for label in labels):
raise Unsupported("setting labels would be ambiguous in the .cht frontend")
if len(self.settings) > 10:
raise Unsupported("at most 10 settings supported (exhaustive interaction check)")
self.globals = []
for node in tree.ext:
if isinstance(node, C.Decl) and not isinstance(node.type, C.FuncDecl):
if isinstance(getattr(node.type, "type", None), C.Enum):
enum = node.type.type
elif isinstance(node.type, C.Enum): enum = node.type
else: enum = None
self.globals.append((node, enum))
def runtime(self, digest, selected):
runtime = Runtime(self, digest, selected)
for node, enum in self.globals:
if enum:
number = -1
for item in enum.values.enumerators:
number = runtime.expr(item.value).n if item.value else number+1
runtime.put(item.name, Value(number), declare=True)
if node.name: runtime.put(node.name, Value(0), declare=True)
else: runtime.stmt(node)
for name, handle in self.handles.items():
runtime.put(name, Value(handle))
runtime.run(self.events["bigpemu_register_event_sw_loaded"])
if runtime.writes: raise Unsupported("load-time memory changes are not frame cheats")
return runtime
def convert(self, digest):
baseline = self.runtime(digest, None)
baseline.run(self.events["bigpemu_register_event_emu_thread_frame"])
results = []
for key, label, boolean in self.settings:
result = {"name": label}
try:
if not boolean: raise Unsupported("parameter setting requires manual value selection")
runtime = self.runtime(digest, frozenset([key]))
runtime.run(self.events["bigpemu_register_event_emu_thread_frame"])
if not runtime.writes: raise Unsupported("no writes activated (check ROM revision/hash)")
result["code"] = "+".join(f"{a:08X}:{v:0{w*2}X}" for a, v, w in runtime.writes)
result["writes"] = len(runtime.writes)
except Unsupported as error:
result["unsupported"] = str(error)
results.append(result)
# Individual traces cannot prove independence: an extra write might be
# guarded by (A && B). Check every combination of exported toggles.
supported = [(key, result) for (key, _, _), result in zip(self.settings, results) if "code" in result]
def image(writes):
out = {}
for address, value, width in writes:
for i, byte in enumerate(value.to_bytes(width, "big")):
out[address+i] = byte
return out
for count in range(2, len(supported)+1):
for combination in itertools.combinations(supported, count):
selected = frozenset(key for key, _ in combination)
runtime = self.runtime(digest, selected)
runtime.run(self.events["bigpemu_register_event_emu_thread_frame"])
expected = []
for _, result in combination:
for segment in result["code"].split("+"):
address, value = segment.split(":")
expected.append((int(address, 16), int(value, 16), len(value)//2))
if image(runtime.writes) != image(expected):
raise Unsupported("toggles interact; independent freezes cannot preserve combined behaviour")
return results
def quoted(value):
if any(c in value for c in ('"', "\\")) or any(ord(c) < 32 for c in value):
raise Unsupported("quote, backslash or control character in metadata")
return '"' + value + '"'
def cht_text(title, crc, entries, source_name, source_sha):
lines = ["# Locally converted community cheats; nothing is enabled automatically.",
"# Source: " + source_name.replace("\n", " ").replace("\r", " "),
"# Source SHA256: " + source_sha,
"# Cartridge revision guard is enforced by the Virtual Jaguar app.",
"vj_rom_crc32 = " + quoted(f"{crc:08X}"),
"cheats = " + str(len(entries)), ""]
for i, entry in enumerate(entries):
lines += [f"cheat{i}_desc = " + quoted(entry["name"]),
f"cheat{i}_code = " + quoted(entry["code"]),
f"cheat{i}_enable = false", ""]
return "\n".join(lines)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="community-authored C source, never a compiled script")
parser.add_argument("--rom", type=Path, required=True, help="matching native cartridge dump")
parser.add_argument("--output", type=Path, required=True, help="new .cht file; existing files are refused")
parser.add_argument("--report", type=Path, required=True, help="new JSON conversion/provenance report")
parser.add_argument("--allow-partial", action="store_true", help="export complete supported toggles; report every omission")
args = parser.parse_args(argv)
try:
if args.output.exists() or args.report.exists() or args.output.resolve() == args.report.resolve():
raise Unsupported("output/report must be distinct new files")
if args.source.suffix.lower() != ".c": raise Unsupported("input must be a .c source file")
if args.source.stat().st_size > 1024*1024: raise Unsupported("source exceeds 1 MiB")
raw = args.source.read_bytes()
data = rom_bytes(args.rom)
module = Module(raw.decode("utf-8-sig"))
digest = fnv1a64(data)
entries = module.convert(digest)
supported = [entry for entry in entries if "code" in entry]
report = {"format": 1, "title": module.title, "source": str(args.source),
"source_sha256": hashlib.sha256(raw).hexdigest(), "rom_crc32": f"{zlib.crc32(data):08X}",
"rom_fnv1a64": f"{digest:016X}", "cheats": entries}
# Report before refusing an incomplete conversion, so omissions are reviewable.
with args.report.open("x", encoding="utf-8") as report_file:
report_file.write(json.dumps(report, indent=2)+"\n")
if not supported or (len(supported) != len(entries) and not args.allow_partial):
print("Incomplete conversion; no .cht written. See " + str(args.report), file=sys.stderr)
return 1
text = cht_text(module.title, zlib.crc32(data), supported, str(args.source), report["source_sha256"])
with args.output.open("x", encoding="utf-8") as output_file:
output_file.write(text)
print(f"Converted {len(supported)}/{len(entries)} toggles for {module.title}: {args.output}")
return 0
except (Unsupported, ValueError, OSError, RecursionError, KeyError, AttributeError, IndexError,
c_parser.ParseError) as error:
print("Cannot convert: " + str(error), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
pycparser>=2.22,<4
+202
View File
@@ -0,0 +1,202 @@
"""Original synthetic sources: no downloaded community or proprietary fixtures."""
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
spec = importlib.util.spec_from_file_location("cheat_converter", Path(__file__).with_name("convert.py"))
converter = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = converter
spec.loader.exec_module(converter)
ROM = bytes(range(256)) * 8
DIGEST = converter.fnv1a64(ROM)
def source(body, extra="", load=None):
# Deliberately not derived from an existing script; addresses are scratch RAM.
return r'''
#include "bigpcrt.h"
#define MATCH_HASH HASH_VALUEULL
int flag;
int first = -1, second = -1;
int loaded_event, frame_event;
uint32_t load(int ignored, void *unused) {
uint64_t digest = bigpemu_get_loaded_fnv1a64();
LOAD_BODY
return 0;
}
uint32_t frame(int ignored, void *unused) {
if (flag) {
int a, b;
bigpemu_get_setting_value(&a, first);
bigpemu_get_setting_value(&b, second);
FRAME_BODY
}
return 0;
}
void bigp_init(void) {
void *module = bigpemu_get_module_handle();
int category = bigpemu_register_setting_category(module, "Synthetic Game");
first = bigpemu_register_setting_bool(category, "Grouped freeze", 1);
second = bigpemu_register_setting_bool(category, "Other freeze", 0);
loaded_event = bigpemu_register_event_sw_loaded(module, load);
frame_event = bigpemu_register_event_emu_thread_frame(module, frame);
}
EXTRA
'''.replace("HASH_VALUE", f"0x{DIGEST:016X}").replace(
"LOAD_BODY", load or "flag = 0; if (digest == MATCH_HASH) { flag = 1; }").replace(
"FRAME_BODY", body).replace("EXTRA", extra)
SIMPLE = "if (a) { bigpemu_jag_write8(0x1f0000, 0xab); } if (b) { bigpemu_jag_write16(0x1f0002, 0x1234); }"
class ConversionTests(unittest.TestCase):
def convert(self, body=SIMPLE, **kwargs):
return converter.Module(source(body, **kwargs)).convert(DIGEST)
def skipped(self, statement, reason):
result = self.convert("if (a) { " + statement + " } if (b) { bigpemu_jag_write8(0x1f1000, 1); }")
self.assertIn(reason, result[0]["unsupported"])
self.assertIn("code", result[1])
def test_grouped_widths_and_loops(self):
results = self.convert('''
if (a) {
bigpemu_jag_write8(0x1f0000, 0xab);
bigpemu_jag_write16(0x1f0002, 0x1234);
for (uint32_t i=0; i<2; ++i) {
bigpemu_jag_write32(0x1f0004 + i*4, 0x89abcdefU);
}
}
if (b) { bigpemu_jag_write8(0x1f1000, 9); }
''')
self.assertEqual(results[0]["writes"], 4)
self.assertEqual(results[0]["code"], "001F0000:AB+001F0002:1234+001F0004:89ABCDEF+001F0008:89ABCDEF")
self.assertEqual(results[1]["code"], "001F1000:09")
def test_unsigned_semantics_and_promotions(self):
results = self.convert('''
if (a) {
uint8_t small = 255;
uint32_t wrap = 0xffffffffU;
++wrap;
bigpemu_jag_write16(0x1f0000, small + 1);
bigpemu_jag_write32(0x1f0002, wrap);
if (-1 < 1U) { bigpemu_jag_write8(0x1f0006, 7); }
}
if (b) { bigpemu_jag_write8(0x1f1000, 1); }
''')
self.assertEqual(results[0]["code"], "001F0000:0100+001F0002:00000000")
def test_enums_macros_comments(self):
text = source(SIMPLE.replace("0xab", "VAL"), "enum { ZERO, VAL = (3 << 2) }; // comment")
self.assertEqual(converter.Module(text).convert(DIGEST)[0]["code"], "001F0000:0C")
def test_wrong_hash(self):
results = converter.Module(source(SIMPLE)).convert(DIGEST ^ 1)
self.assertTrue(all("no writes activated" in r["unsupported"] for r in results))
def test_hash_guard_required(self):
results = self.convert(load="flag = 1;")
self.assertTrue(all("ROM-hash guard" in r["unsupported"] for r in results))
def test_ram_read_and_partial_write_rejected(self):
self.skipped("bigpemu_jag_write8(0x1f0000, 1); bigpemu_jag_write8(0x1f0001, bigpemu_jag_read8(0x1f0002));", "unsupported call")
def test_outside_ram_and_alignment(self):
self.skipped("bigpemu_jag_write32(0x1ffffe, 1);", "outside")
self.skipped("bigpemu_jag_write8(0xf00000, 1);", "outside")
self.skipped("bigpemu_jag_write16(0x1f0001, 1);", "unaligned")
def test_persistent_and_uninitialised_state(self):
self.skipped("flag++; bigpemu_jag_write8(0x1f0000, 1);", "persistent")
self.skipped("static int n = 0; bigpemu_jag_write8(0x1f0000, n);", "static local")
self.skipped("int n; bigpemu_jag_write8(0x1f0000, n);", "uninitialised")
def test_signed_overflow_and_unsupported_operations(self):
self.skipped("int n = 0x7fffffff; ++n; bigpemu_jag_write32(0x1f0000, n);", "overflow")
self.skipped("bigpemu_jag_write32(0x1f0000, a ? 1 : 2);", "conditional expression")
self.skipped("return 0;", "early return")
self.skipped("bigpemu_jag_write32(0x1f0000, 0xffffffffUL + 1);", "platform-dependent")
def test_parameter_and_ambiguous_labels(self):
text = source(SIMPLE).replace('bigpemu_register_setting_bool(category, "Other freeze", 0)',
'bigpemu_register_setting_int(category, "Other freeze", 0, 0, 10, 1)')
results = converter.Module(text).convert(DIGEST)
self.assertIn("code", results[0])
self.assertIn("parameter", results[1]["unsupported"])
for label in ("Grouped freeze", "Something (Part 1)"):
with self.assertRaisesRegex(converter.Unsupported, "ambiguous"):
converter.Module(source(SIMPLE).replace("Other freeze", label))
def test_setting_handle_arithmetic_refused(self):
with self.assertRaisesRegex(converter.Unsupported, "registered global"):
converter.Module(source(SIMPLE).replace("&a, first", "&a, first + 0")).convert(DIGEST)
def test_copier_header_refused(self):
with tempfile.TemporaryDirectory() as directory:
rom = Path(directory)/"header.j64"
data = bytearray(1024*1024 + 512)
data[0x600:0x604] = b"\x04"*4
rom.write_bytes(data)
with self.assertRaisesRegex(converter.Unsupported, "copier-headered"):
converter.rom_bytes(rom)
def test_write_and_execution_limits(self):
self.skipped("for (int i=0; i<257; ++i) { bigpemu_jag_write8(0x1f0000+i, 1); }", "256-write")
self.skipped("for (int i=0; i<100000; ++i) { }", "budget")
def test_combined_only_extra_write_rejected(self):
with self.assertRaisesRegex(converter.Unsupported, "interact"):
self.convert(SIMPLE + "if (a && b) { bigpemu_jag_write8(0x1f0010, 7); }")
def test_combined_only_ram_read_rejected(self):
with self.assertRaisesRegex(converter.Unsupported, "unsupported call"):
self.convert(SIMPLE + "if (a && b) { bigpemu_jag_read8(0x1f0010); }")
def test_cross_toggle_local_changes_rejected(self):
with self.assertRaisesRegex(converter.Unsupported, "interact"):
self.convert("int n = 0; if (b) { n=7; bigpemu_jag_write8(0x1f0000, 1); } if (a) { bigpemu_jag_write8(0x1f0001,n); }")
def test_no_includes_preprocessing_or_execution(self):
with self.assertRaisesRegex(converter.Unsupported, "preprocessor"):
converter.Module(source(SIMPLE).replace('"bigpcrt.h"', '"/etc/passwd"'))
with self.assertRaisesRegex(converter.Unsupported, "initialisation call"):
converter.Module(source(SIMPLE).replace("bigpemu_get_module_handle()", 'system("touch /tmp/never-execute-community-source")'))
with self.assertRaisesRegex(converter.Unsupported, "initialisation assignment"):
converter.Module(source(SIMPLE).replace("void bigp_init(void) {", "void bigp_init(void) { flag = 1;"))
with self.assertRaisesRegex(converter.Unsupported, "initialisation call"):
converter.Module(source(SIMPLE).replace("void bigp_init(void) {",
"void bigp_init(void) { bigpemu_register_event_unhandled(module, frame);"))
def test_cli_strict_partial_provenance_and_no_overwrite(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
src, rom, out, report = [root/n for n in ("input.c", "game.j64", "output.cht", "report.json")]
src.write_text(source(SIMPLE.replace("0x1234", "bigpemu_jag_read16(0x1f1000)")))
rom.write_bytes(ROM)
args = [str(src), "--rom", str(rom), "--output", str(out), "--report", str(report)]
with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(converter.main(args), 1)
self.assertFalse(out.exists())
data = json.loads(report.read_text())
self.assertIn("unsupported", data["cheats"][1])
self.assertEqual(data["rom_fnv1a64"], f"{DIGEST:016X}")
report.unlink()
self.assertEqual(converter.main(args + ["--allow-partial"]), 0)
text = out.read_text()
self.assertIn('vj_rom_crc32 = "', text)
self.assertIn("cheat0_enable = false", text)
self.assertNotIn("cheat1_code", text)
self.assertEqual(converter.main(args + ["--allow-partial"]), 2)
self.assertEqual(out.read_text(), text)
if __name__ == "__main__":
unittest.main()