mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 04:36:00 -10:00
feat(cheats): convert community source freezes with revision guards
(cherry picked from commit e6fa96e1d0)
This commit is contained in:
10 files changed
+1024
-1
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
pycparser>=2.22,<4
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user