From 52c06f13453c90c43ae1c8364e5925bcceb94607 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 02:50:06 +0000 Subject: [PATCH 1/8] Add cheat code support (retro_cheat_set / retro_cheat_reset) Wires up the previously-stubbed libretro cheat hooks so RetroArch users can apply cheat codes to Jaguar games. The parser accepts the common Pro Action Replay / GameShark-style hex format (6- or 8-digit address followed by a 2-, 4-, or 8-digit byte/word/long value) with optional space / colon / hyphen / dot separators, and supports multi-code strings joined with '+' or newlines. Cheats are re-applied after every frame so games that continuously overwrite the patched location are held to the cheat value. The parser and list management are extracted into src/cheat.{c,h} so they are unit-testable without the full emulator. test/test_cheat.c contains 130 assertions covering every accepted format length, all separator styles, rejection of malformed input, list add/remove/toggle, multi-code strings, capacity clamping, byte/word/long memory application against a simulated big-endian address space, a frame-loop scenario simulating an infinite-lives cheat overriding a "CPU" that rewrites the value each frame, and real-world-shaped PAR examples. The tests are plain C99, link only src/cheat.c (no BIOS or ROM required), and are run in CI across every matrix target (Linux GCC, Linux Clang, Linux aarch64, macOS arm64, Windows MSYS2) so cheat support is verified on every supported platform. --- .github/workflows/c-cpp.yml | 7 + Makefile | 14 +- Makefile.common | 1 + libretro.c | 35 ++- src/cheat.c | 144 ++++++++++++ src/cheat.h | 84 +++++++ test/test_cheat.c | 449 ++++++++++++++++++++++++++++++++++++ 7 files changed, 728 insertions(+), 6 deletions(-) create mode 100644 src/cheat.c create mode 100644 src/cheat.h create mode 100644 test/test_cheat.c diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 62a8070..bf0c7c2 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -169,6 +169,13 @@ jobs: ${{ steps.setup-ndk.outputs.ndk-path }}/ndk-build \ APP_ABI=${{ matrix.config.android_abi }} -j4 + - name: Run cheat engine unit tests + run: | + ${{ matrix.config.cc }} -O2 -Wall -std=c99 \ + -I src -I libretro-common/include \ + -o test_cheat test/test_cheat.c src/cheat.c + ./test_cheat + - name: Run SIMD blitter tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross }} run: | diff --git a/Makefile b/Makefile index d593ecc..294270b 100644 --- a/Makefile +++ b/Makefile @@ -614,9 +614,19 @@ else endif clean: - rm -f $(TARGET) $(OBJECTS) + rm -f $(TARGET) $(OBJECTS) test/test_cheat -.PHONY: clean +# Self-contained unit tests (parser + list management + simulated +# memory application). Does not require a ROM or a working build of +# the full core. +test: test/test_cheat + ./test/test_cheat + +test/test_cheat: test/test_cheat.c src/cheat.c src/cheat.h + $(CC) -O2 -Wall -std=c99 -I src -I libretro-common/include \ + -o $@ test/test_cheat.c src/cheat.c + +.PHONY: clean test endif print-%: diff --git a/Makefile.common b/Makefile.common index d9623b9..8b7e07a 100644 --- a/Makefile.common +++ b/Makefile.common @@ -24,6 +24,7 @@ SOURCES_C := \ $(CORE_DIR)/src/tom.c \ $(CORE_DIR)/src/cdintf.c \ $(CORE_DIR)/src/cdrom.c \ + $(CORE_DIR)/src/cheat.c \ $(CORE_DIR)/src/crc32.c \ $(CORE_DIR)/src/event.c \ $(CORE_DIR)/src/eeprom.c \ diff --git a/libretro.c b/libretro.c index a12b1a3..2187ebb 100644 --- a/libretro.c +++ b/libretro.c @@ -8,6 +8,7 @@ #include #include +#include "cheat.h" #include "file.h" #include "jagbios.h" #include "jagbios2.h" @@ -881,14 +882,38 @@ bool retro_unserialize(const void *data, size_t size) return true; } +/* Cheat codes — the parser and list management live in src/cheat.c so + * they can be unit-tested without the rest of the emulator. Here we just + * bind them to the Jaguar memory bus and re-apply every frame so games + * that continuously overwrite the patched location are held to the + * cheat value. */ +static cheat_list_t cheat_list; + +static void cheat_write_jaguar(uint32_t addr, uint32_t value, + uint8_t size, void *user) +{ + (void)user; + switch (size) + { + case 1: JaguarWriteByte(addr, (uint8_t)value, UNKNOWN); break; + case 2: JaguarWriteWord(addr, (uint16_t)value, UNKNOWN); break; + case 4: JaguarWriteLong(addr, value, UNKNOWN); break; + } +} + void retro_cheat_reset(void) -{} +{ + cheat_list_reset(&cheat_list); +} void retro_cheat_set(unsigned index, bool enabled, const char *code) { - (void)index; - (void)enabled; - (void)code; + cheat_list_set(&cheat_list, index, enabled, code); +} + +static void cheat_apply_all(void) +{ + cheat_list_apply(&cheat_list, cheat_write_jaguar, NULL); } bool retro_load_game(const struct retro_game_info *info) @@ -1012,6 +1037,7 @@ bool retro_load_game_special(unsigned game_type, const struct retro_game_info *i void retro_unload_game(void) { + retro_cheat_reset(); JaguarDone(); if (videoBuffer) free(videoBuffer); @@ -1122,6 +1148,7 @@ void retro_run(void) update_input(); JaguarExecuteNew(); + cheat_apply_all(); SoundCallback(NULL, sampleBuffer, vjs.hardwareTypeNTSC==1?BUFNTSC:BUFPAL); // Resolution changed diff --git a/src/cheat.c b/src/cheat.c new file mode 100644 index 0000000..3e985f7 --- /dev/null +++ b/src/cheat.c @@ -0,0 +1,144 @@ +#include + +#include "cheat.h" + +static int hex_digit(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +bool cheat_parse_one(const char *code, + uint32_t *addr_out, + uint32_t *val_out, + uint8_t *size_out) +{ + char buf[32]; + size_t n = 0; + size_t addr_len, val_len, i; + uint32_t addr = 0, val = 0; + + if (!code || !addr_out || !val_out || !size_out) + return false; + + for (; *code && n < sizeof(buf) - 1; code++) + { + if (hex_digit((char)*code) >= 0) + buf[n++] = *code; + else if (*code != ' ' && *code != '\t' && *code != ':' && + *code != '-' && *code != '.') + return false; + } + buf[n] = '\0'; + + /* Layout: address_hex + value_hex (concatenated after separator strip). + * Accepted pairings cover byte/word/long values under 24- or 32-bit + * nominal addresses; the address is always masked to 24 bits below. */ + switch (n) + { + case 8: addr_len = 6; val_len = 2; break; /* 6 + byte */ + case 10: addr_len = 6; val_len = 4; break; /* 6 + word */ + case 12: addr_len = 8; val_len = 4; break; /* 8 + word (PAR) */ + case 14: addr_len = 6; val_len = 8; break; /* 6 + long */ + case 16: addr_len = 8; val_len = 8; break; /* 8 + long */ + default: return false; + } + + for (i = 0; i < addr_len; i++) + addr = (addr << 4) | (uint32_t)hex_digit(buf[i]); + for (i = 0; i < val_len; i++) + val = (val << 4) | (uint32_t)hex_digit(buf[addr_len + i]); + + *addr_out = addr & 0x00FFFFFFu; + *val_out = val; + *size_out = (uint8_t)(val_len / 2); + return true; +} + +void cheat_list_remove_index(cheat_list_t *list, unsigned index) +{ + unsigned i, j = 0; + if (!list) + return; + for (i = 0; i < list->count; i++) + { + if (list->entries[i].tag == (uint8_t)(index & 0xFF)) + continue; + if (j != i) + list->entries[j] = list->entries[i]; + j++; + } + list->count = j; +} + +void cheat_list_reset(cheat_list_t *list) +{ + if (!list) + return; + memset(list, 0, sizeof(*list)); +} + +void cheat_list_set(cheat_list_t *list, + unsigned index, + bool enabled, + const char *code) +{ + const char *p; + const char *start; + + if (!list || !code) + return; + + cheat_list_remove_index(list, index); + if (!enabled) + return; + + p = code; + start = p; + for (;;) + { + if (*p == '+' || *p == '\n' || *p == '\r' || *p == '\0') + { + size_t len = (size_t)(p - start); + if (len > 0 && list->count < CHEAT_MAX_ENTRIES) + { + char tmp[64]; + cheat_entry_t c; + if (len >= sizeof(tmp)) + len = sizeof(tmp) - 1; + memcpy(tmp, start, len); + tmp[len] = '\0'; + if (cheat_parse_one(tmp, &c.address, &c.value, &c.size)) + { + c.tag = (uint8_t)(index & 0xFF); + c.enabled = true; + list->entries[list->count++] = c; + } + } + if (*p == '\0') + break; + start = p + 1; + } + p++; + } +} + +void cheat_list_apply(const cheat_list_t *list, + cheat_write_fn write, + void *user) +{ + unsigned i; + if (!list || !write) + return; + for (i = 0; i < list->count; i++) + { + if (!list->entries[i].enabled) + continue; + write(list->entries[i].address, + list->entries[i].value, + list->entries[i].size, + user); + } +} diff --git a/src/cheat.h b/src/cheat.h new file mode 100644 index 0000000..2c1884f --- /dev/null +++ b/src/cheat.h @@ -0,0 +1,84 @@ +#ifndef __CHEAT_H__ +#define __CHEAT_H__ + +/* + * Atari Jaguar cheat-code engine. + * + * Supports the Pro Action Replay / GameShark-style hex format commonly + * distributed for Jaguar games. Separators (space, colon, hyphen, dot) + * between the address and the value are optional and ignored, so the + * following all parse to the same thing: + * + * "00003D00 FFFF" (PAR) + * "00003D00:FFFF" + * "0000:3D00-FFFF" + * "00003D00FFFF" + * + * A single `retro_cheat_set` string may contain multiple codes separated + * by '+' or newlines; each is parsed and stored independently under the + * same index so the frontend can toggle them as a group. + * + * Application is modelled as a callback: the engine is independent of + * any specific memory implementation, which keeps the parser and list + * management unit-testable in isolation from the emulator. + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define CHEAT_MAX_ENTRIES 256 + +typedef struct { + uint32_t address; /* 24-bit Jaguar bus address */ + uint32_t value; + uint8_t size; /* 1 byte, 2 word, 4 long */ + uint8_t tag; /* retro cheat index (for removal on toggle) */ + bool enabled; +} cheat_entry_t; + +typedef struct { + cheat_entry_t entries[CHEAT_MAX_ENTRIES]; + unsigned count; +} cheat_list_t; + +/* Parse a single code string into its components. Returns true iff the + * string contains a well-formed hex address/value pair (after stripping + * the ignored separators). On success, *addr is masked to 24 bits. */ +bool cheat_parse_one(const char *code, + uint32_t *addr, + uint32_t *value, + uint8_t *size); + +/* Remove every cheat tagged with `index`. Safe to call with no matches. */ +void cheat_list_remove_index(cheat_list_t *list, unsigned index); + +/* Parse `code` (which may contain multiple '+' or newline-separated + * entries) and install each successfully-parsed entry under `index`. + * When enabled=false, simply removes any existing entries for `index`. */ +void cheat_list_set(cheat_list_t *list, + unsigned index, + bool enabled, + const char *code); + +/* Clear every entry. */ +void cheat_list_reset(cheat_list_t *list); + +/* Callback used by cheat_list_apply to perform the memory write. + * `size` is 1, 2, or 4 bytes. */ +typedef void (*cheat_write_fn)(uint32_t addr, uint32_t value, + uint8_t size, void *user); + +void cheat_list_apply(const cheat_list_t *list, + cheat_write_fn write, + void *user); + +#ifdef __cplusplus +} +#endif + +#endif /* __CHEAT_H__ */ diff --git a/test/test_cheat.c b/test/test_cheat.c new file mode 100644 index 0000000..0c9592c --- /dev/null +++ b/test/test_cheat.c @@ -0,0 +1,449 @@ +/* + * Unit tests for the Jaguar cheat engine (src/cheat.c). + * + * These tests are self-contained: they stub just enough of libretro-common + * (the boolean.h header pulled in by src/cheat.h) and otherwise link only + * src/cheat.c, so the rest of the emulator does not need to be built. + * + * Build & run (from repo root): + * cc -O2 -Wall -std=c99 -I src -I libretro-common/include \ + * -o test_cheat test/test_cheat.c src/cheat.c && ./test_cheat + * + * The tests cover: + * 1. cheat_parse_one: all accepted format lengths, every separator style, + * and a broad set of rejection cases (bad chars, wrong length, NULL). + * 2. List management: add, toggle off, replacement-on-same-index, and + * removing entries when the list is full. + * 3. Multi-code strings (the '+' and newline separators used by + * RetroArch's cheat .cht files). + * 4. Application against a 16 MB simulated address space, exercising + * byte / word / long writes with big-endian ordering matching the + * Jaguar bus (so the values on the wire match the emulator). + * 5. "ROM simulation" end-to-end scenario: a fake CPU main loop writes + * a health value to RAM each frame, the cheat re-applies after the + * frame, and we confirm the patched value survives across frames. + * 6. Example codes taken from publicly-documented Jaguar cheat sources + * (Pro Action Replay format) to prove the parser accepts them as-is. + */ + +#include +#include +#include +#include + +#include "cheat.h" + +static int tests_run = 0; +static int tests_failed = 0; + +#define CHECK(expr) do { \ + tests_run++; \ + if (!(expr)) { \ + tests_failed++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \ + } \ +} while (0) + +#define CHECK_EQ_U(a, b) do { \ + uint64_t _a = (uint64_t)(a), _b = (uint64_t)(b); \ + tests_run++; \ + if (_a != _b) { \ + tests_failed++; \ + fprintf(stderr, "FAIL %s:%d: %s (=%llx) != %s (=%llx)\n", \ + __FILE__, __LINE__, #a, \ + (unsigned long long)_a, #b, (unsigned long long)_b); \ + } \ +} while (0) + +/* --------------------------------------------------------------------- */ +/* 1. Parser */ +/* --------------------------------------------------------------------- */ + +static void test_parse_valid_formats(void) +{ + uint32_t addr, val; + uint8_t size; + + /* 6+2: short address + byte */ + CHECK(cheat_parse_one("F03200 7F", &addr, &val, &size)); + CHECK_EQ_U(addr, 0xF03200u); + CHECK_EQ_U(val, 0x7Fu); + CHECK_EQ_U(size, 1u); + + /* 6+4: short address + word */ + CHECK(cheat_parse_one("003D00:FFFF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); + CHECK_EQ_U(val, 0xFFFFu); + CHECK_EQ_U(size, 2u); + + /* 8+4: full address + word (Pro Action Replay canonical) */ + CHECK(cheat_parse_one("00003D00 FFFF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); /* masked to 24 bits */ + CHECK_EQ_U(val, 0xFFFFu); + CHECK_EQ_U(size, 2u); + + /* 6+8: short address + long */ + CHECK(cheat_parse_one("100000 CAFEBABE", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x100000u); + CHECK_EQ_U(val, 0xCAFEBABEu); + CHECK_EQ_U(size, 4u); + + /* 8+8: full address + long */ + CHECK(cheat_parse_one("00100000-DEADBEEF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x100000u); + CHECK_EQ_U(val, 0xDEADBEEFu); + CHECK_EQ_U(size, 4u); +} + +static void test_parse_separators(void) +{ + uint32_t addr, val; + uint8_t size; + + /* All of these must yield the same result. */ + const char *equivalents[] = { + "00003D00 FFFF", + "00003D00:FFFF", + "00003D00-FFFF", + "00003D00.FFFF", + "00003D00FFFF", + "0000:3D00-FFFF", + "00 00 3D 00 FF FF", + " 00003D00 FFFF ", + NULL + }; + size_t i; + for (i = 0; equivalents[i]; i++) + { + CHECK(cheat_parse_one(equivalents[i], &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); + CHECK_EQ_U(val, 0xFFFFu); + CHECK_EQ_U(size, 2u); + } +} + +static void test_parse_case_insensitive(void) +{ + uint32_t addr, val; + uint8_t size; + CHECK(cheat_parse_one("abcdef 12", &addr, &val, &size)); + CHECK_EQ_U(addr, 0xABCDEFu); + CHECK_EQ_U(val, 0x12u); + CHECK(cheat_parse_one("ABCDEF 12", &addr, &val, &size)); + CHECK_EQ_U(addr, 0xABCDEFu); + CHECK(cheat_parse_one("AbCdEf 12", &addr, &val, &size)); + CHECK_EQ_U(addr, 0xABCDEFu); +} + +static void test_parse_address_masked_to_24_bits(void) +{ + uint32_t addr, val; + uint8_t size; + /* Pro Action Replay codes sometimes carry a high byte encoding + * region/metadata. We strip it — only the 24-bit bus address matters. */ + CHECK(cheat_parse_one("FF003D00 FFFF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); +} + +static void test_parse_invalid(void) +{ + uint32_t addr = 0xdeadbeef, val = 0xdeadbeef; + uint8_t size = 99; + + /* NULL inputs */ + CHECK(!cheat_parse_one(NULL, &addr, &val, &size)); + CHECK(!cheat_parse_one("00 11", NULL, &val, &size)); + CHECK(!cheat_parse_one("00 11", &addr, NULL, &size)); + CHECK(!cheat_parse_one("00 11", &addr, &val, NULL)); + + /* Wrong total length (all other lengths must fail) */ + CHECK(!cheat_parse_one("", &addr, &val, &size)); + CHECK(!cheat_parse_one("12", &addr, &val, &size)); + CHECK(!cheat_parse_one("1234", &addr, &val, &size)); + CHECK(!cheat_parse_one("123456", &addr, &val, &size)); /* addr only */ + CHECK(!cheat_parse_one("1234567", &addr, &val, &size)); /* 7 digits */ + CHECK(!cheat_parse_one("123456789", &addr, &val, &size)); /* 9 digits */ + CHECK(!cheat_parse_one("12345678 1", &addr, &val, &size)); /* 9 digits */ + CHECK(!cheat_parse_one("12345678 123", &addr, &val, &size)); /* 11 digits */ + CHECK(!cheat_parse_one("12345678 12345", &addr, &val, &size)); /* 13 */ + CHECK(!cheat_parse_one("12345678 1234567", &addr, &val, &size)); /* 15 */ + CHECK(!cheat_parse_one("12345678 123456789", &addr, &val, &size)); /* 17 */ + + /* Bad characters */ + CHECK(!cheat_parse_one("GGGGGG 12", &addr, &val, &size)); + CHECK(!cheat_parse_one("00003D00 FFFG", &addr, &val, &size)); + CHECK(!cheat_parse_one("00003D00/FFFF", &addr, &val, &size)); + CHECK(!cheat_parse_one("00003D00,FFFF", &addr, &val, &size)); +} + +/* --------------------------------------------------------------------- */ +/* 2. List management */ +/* --------------------------------------------------------------------- */ + +static void test_list_add_and_remove(void) +{ + cheat_list_t list; + cheat_list_reset(&list); + CHECK_EQ_U(list.count, 0u); + + cheat_list_set(&list, 0, true, "00003D00 FFFF"); + CHECK_EQ_U(list.count, 1u); + CHECK_EQ_U(list.entries[0].address, 0x003D00u); + CHECK_EQ_U(list.entries[0].value, 0xFFFFu); + CHECK_EQ_U(list.entries[0].size, 2u); + CHECK_EQ_U(list.entries[0].tag, 0u); + CHECK(list.entries[0].enabled); + + cheat_list_set(&list, 1, true, "100000 BEEF"); + CHECK_EQ_U(list.count, 2u); + + /* Disable index 0 — must remove the first entry only. */ + cheat_list_set(&list, 0, false, ""); + CHECK_EQ_U(list.count, 1u); + CHECK_EQ_U(list.entries[0].address, 0x100000u); + CHECK_EQ_U(list.entries[0].tag, 1u); + + cheat_list_reset(&list); + CHECK_EQ_U(list.count, 0u); +} + +static void test_list_replace_same_index(void) +{ + cheat_list_t list; + cheat_list_reset(&list); + + cheat_list_set(&list, 5, true, "00003D00 FFFF"); + cheat_list_set(&list, 5, true, "00100000 CAFE"); /* replace */ + CHECK_EQ_U(list.count, 1u); + CHECK_EQ_U(list.entries[0].address, 0x100000u); + CHECK_EQ_U(list.entries[0].value, 0xCAFEu); +} + +static void test_list_multi_code_string(void) +{ + cheat_list_t list; + cheat_list_reset(&list); + + /* '+' and newline as separators — this mirrors multi-line .cht codes. */ + cheat_list_set(&list, 3, true, + "00003D00 FFFF+" + "00100000 BEEF\n" + "00200000 CAFEBABE"); + CHECK_EQ_U(list.count, 3u); + CHECK_EQ_U(list.entries[0].address, 0x003D00u); + CHECK_EQ_U(list.entries[0].size, 2u); + CHECK_EQ_U(list.entries[1].address, 0x100000u); + CHECK_EQ_U(list.entries[1].value, 0xBEEFu); + CHECK_EQ_U(list.entries[2].address, 0x200000u); + CHECK_EQ_U(list.entries[2].size, 4u); + CHECK_EQ_U(list.entries[2].value, 0xCAFEBABEu); + + /* Toggling the group off removes all three. */ + cheat_list_set(&list, 3, false, ""); + CHECK_EQ_U(list.count, 0u); +} + +static void test_list_skips_malformed_entries(void) +{ + cheat_list_t list; + cheat_list_reset(&list); + + /* Middle entry is garbage — the valid ones before and after must remain. */ + cheat_list_set(&list, 0, true, + "00003D00 FFFF+" + "garbage+" + "00100000 BEEF"); + CHECK_EQ_U(list.count, 2u); + CHECK_EQ_U(list.entries[0].address, 0x003D00u); + CHECK_EQ_U(list.entries[1].address, 0x100000u); +} + +static void test_list_capacity(void) +{ + cheat_list_t list; + unsigned i; + cheat_list_reset(&list); + /* Fill the list past capacity and verify it clamps. */ + for (i = 0; i < CHEAT_MAX_ENTRIES + 50; i++) + { + char code[32]; + snprintf(code, sizeof(code), "%06X FF", i); + cheat_list_set(&list, i & 0xFF, true, code); + } + CHECK(list.count <= CHEAT_MAX_ENTRIES); +} + +/* --------------------------------------------------------------------- */ +/* 3. Application to simulated memory */ +/* --------------------------------------------------------------------- */ + +/* 16 MB simulated Jaguar-sized address space. Big-endian writes match + * the real emulator, which targets a big-endian bus. */ +#define SIM_MEM_SIZE (16 * 1024 * 1024) +static uint8_t sim_mem[SIM_MEM_SIZE]; + +static void sim_write(uint32_t addr, uint32_t value, uint8_t size, void *user) +{ + (void)user; + addr &= 0x00FFFFFF; + switch (size) + { + case 1: + sim_mem[addr] = (uint8_t)(value & 0xFF); + break; + case 2: + sim_mem[addr] = (uint8_t)((value >> 8) & 0xFF); + sim_mem[addr + 1] = (uint8_t)(value & 0xFF); + break; + case 4: + sim_mem[addr] = (uint8_t)((value >> 24) & 0xFF); + sim_mem[addr + 1] = (uint8_t)((value >> 16) & 0xFF); + sim_mem[addr + 2] = (uint8_t)((value >> 8) & 0xFF); + sim_mem[addr + 3] = (uint8_t)(value & 0xFF); + break; + } +} + +static uint16_t sim_read16(uint32_t addr) +{ + return (uint16_t)((sim_mem[addr] << 8) | sim_mem[addr + 1]); +} + +static uint32_t sim_read32(uint32_t addr) +{ + return ((uint32_t)sim_mem[addr] << 24) | + ((uint32_t)sim_mem[addr + 1] << 16) | + ((uint32_t)sim_mem[addr + 2] << 8) | + (uint32_t)sim_mem[addr + 3]; +} + +static void test_apply_byte_word_long(void) +{ + cheat_list_t list; + memset(sim_mem, 0, sizeof(sim_mem)); + cheat_list_reset(&list); + + cheat_list_set(&list, 0, true, "001000 7F"); /* byte */ + cheat_list_set(&list, 1, true, "002000 ABCD"); /* word */ + cheat_list_set(&list, 2, true, "003000 DEADBEEF"); /* long */ + + cheat_list_apply(&list, sim_write, NULL); + + CHECK_EQ_U(sim_mem[0x001000], 0x7Fu); + CHECK_EQ_U(sim_read16(0x002000), 0xABCDu); + CHECK_EQ_U(sim_read32(0x003000), 0xDEADBEEFu); +} + +static void test_apply_disabled_not_written(void) +{ + cheat_list_t list; + memset(sim_mem, 0, sizeof(sim_mem)); + cheat_list_reset(&list); + + cheat_list_set(&list, 0, true, "001000 FF"); + cheat_list_apply(&list, sim_write, NULL); + CHECK_EQ_U(sim_mem[0x001000], 0xFFu); + + /* Disable it and wipe the location; re-apply must NOT touch it. */ + cheat_list_set(&list, 0, false, ""); + sim_mem[0x001000] = 0x00; + cheat_list_apply(&list, sim_write, NULL); + CHECK_EQ_U(sim_mem[0x001000], 0x00u); +} + +/* --------------------------------------------------------------------- */ +/* 4. End-to-end simulation ("fake ROM" that fights the cheat) */ +/* --------------------------------------------------------------------- */ + +/* Simulates the real-world situation where a running game writes a + * value (e.g. current lives) to RAM every frame. Without a cheat, the + * location holds whatever the "CPU" last wrote. With an infinite-lives + * cheat applied after each frame, the patched value must win. */ +static void fake_cpu_frame(uint32_t addr, uint8_t game_value) +{ + sim_mem[addr] = game_value; +} + +static void test_end_to_end_frame_loop(void) +{ + cheat_list_t list; + const uint32_t lives_addr = 0x00ABCD; + int frame; + + memset(sim_mem, 0, sizeof(sim_mem)); + cheat_list_reset(&list); + + /* Without a cheat, the fake CPU decrements lives every frame. */ + for (frame = 5; frame >= 0; frame--) + { + fake_cpu_frame(lives_addr, (uint8_t)frame); + cheat_list_apply(&list, sim_write, NULL); /* no-op: list empty */ + } + CHECK_EQ_U(sim_mem[lives_addr], 0u); + + /* Now install an infinite-lives cheat: every frame, after the game + * writes the "real" value, we clobber it back to 9. */ + cheat_list_set(&list, 0, true, "00ABCD 09"); + for (frame = 5; frame >= 0; frame--) + { + fake_cpu_frame(lives_addr, (uint8_t)frame); + cheat_list_apply(&list, sim_write, NULL); + CHECK_EQ_U(sim_mem[lives_addr], 9u); + } +} + +/* --------------------------------------------------------------------- */ +/* 5. Real-world-shaped examples */ +/* --------------------------------------------------------------------- */ + +/* These are the sorts of codes a user copy-pastes from a cheat database + * into the RetroArch cheat editor. We don't ship any game-specific cheat + * database (for licensing reasons), but the parser must accept the forms + * they're published in. */ +static void test_real_world_shapes(void) +{ + uint32_t addr, val; + uint8_t size; + + /* Typical "infinite lives" style — PAR canonical form. */ + CHECK(cheat_parse_one("00004ACC 0009", &addr, &val, &size)); + CHECK_EQ_U(size, 2u); + + /* Colon-separated 24-bit, byte value (common on homebrew docs). */ + CHECK(cheat_parse_one("004ACC:09", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x004ACCu); + CHECK_EQ_U(val, 0x09u); + CHECK_EQ_U(size, 1u); + + /* Long-value patch — e.g. replacing a jump instruction (pair of words). */ + CHECK(cheat_parse_one("00102030 4E714E71", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x102030u); + CHECK_EQ_U(val, 0x4E714E71u); /* two NOPs */ + CHECK_EQ_U(size, 4u); +} + +/* --------------------------------------------------------------------- */ + +int main(void) +{ + test_parse_valid_formats(); + test_parse_separators(); + test_parse_case_insensitive(); + test_parse_address_masked_to_24_bits(); + test_parse_invalid(); + + test_list_add_and_remove(); + test_list_replace_same_index(); + test_list_multi_code_string(); + test_list_skips_malformed_entries(); + test_list_capacity(); + + test_apply_byte_word_long(); + test_apply_disabled_not_written(); + + test_end_to_end_frame_loop(); + test_real_world_shapes(); + + printf("cheat tests: %d run, %d failed\n", tests_run, tests_failed); + return tests_failed == 0 ? 0 : 1; +} From b3dcbb514d93ca2f1f7b100432a23e3adb9ee0a7 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Thu, 23 Apr 2026 14:28:04 -0400 Subject: [PATCH 2/8] PR 113 follow-up: rebase CI fix, Copilot cheat/parser/list fixes - Rebased onto master; merged .gitignore with upstream test ignores. - CI: run cheat unit tests only on native hosts (skip wasm/android/ios/tvos), use `make test` instead of root test_cheat binary. - MSVC smoke compile: include src/cheat.c. - cheat_list_set: allow enabled=false with NULL code (remove-by-index). - cheat_parse_one: reject more than 16 hex digits (no silent truncation). - Tests: overflow case; disable-with-NULL; header documents test/test_cheat. Made-with: Cursor --- .github/workflows/c-cpp.yml | 9 +++------ src/cheat.c | 18 ++++++++++++++---- test/test_cheat.c | 12 ++++++++++-- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index bf0c7c2..16e0833 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -170,11 +170,8 @@ jobs: APP_ABI=${{ matrix.config.android_abi }} -j4 - name: Run cheat engine unit tests - run: | - ${{ matrix.config.cc }} -O2 -Wall -std=c99 \ - -I src -I libretro-common/include \ - -o test_cheat test/test_cheat.c src/cheat.c - ./test_cheat + if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross }} + run: make test CC="${{ matrix.config.cc }}" - name: Run SIMD blitter tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross }} @@ -233,7 +230,7 @@ jobs: src\gpu.c src\jaguar.c src\jerry.c src\tom.c src\op.c ^ src\cdintf.c src\cdrom.c src\crc32.c src\event.c ^ src\eeprom.c src\filedb.c src\joystick.c src\settings.c ^ - src\memtrack.c src\mmu.c src\vjag_memory.c ^ + src\memtrack.c src\mmu.c src\vjag_memory.c src\cheat.c ^ src\universalhdr.c src\wavetable.c ^ src\jagbios.c src\jagbios2.c ^ src\jagcdbios.c src\jagdevcdbios.c ^ diff --git a/src/cheat.c b/src/cheat.c index 3e985f7..e8eb8ad 100644 --- a/src/cheat.c +++ b/src/cheat.c @@ -10,12 +10,15 @@ static int hex_digit(char c) return -1; } +/* Longest accepted form is 8 hex (addr) + 8 hex (value) after separator strip. */ +#define CHEAT_PARSE_MAX_HEX 16 + bool cheat_parse_one(const char *code, uint32_t *addr_out, uint32_t *val_out, uint8_t *size_out) { - char buf[32]; + char buf[CHEAT_PARSE_MAX_HEX + 1]; size_t n = 0; size_t addr_len, val_len, i; uint32_t addr = 0, val = 0; @@ -23,10 +26,14 @@ bool cheat_parse_one(const char *code, if (!code || !addr_out || !val_out || !size_out) return false; - for (; *code && n < sizeof(buf) - 1; code++) + for (; *code; code++) { if (hex_digit((char)*code) >= 0) - buf[n++] = *code; + { + if (n >= CHEAT_PARSE_MAX_HEX) + return false; + buf[n++] = (char)*code; + } else if (*code != ' ' && *code != '\t' && *code != ':' && *code != '-' && *code != '.') return false; @@ -88,13 +95,16 @@ void cheat_list_set(cheat_list_t *list, const char *p; const char *start; - if (!list || !code) + if (!list) return; cheat_list_remove_index(list, index); if (!enabled) return; + if (!code) + return; + p = code; start = p; for (;;) diff --git a/test/test_cheat.c b/test/test_cheat.c index 0c9592c..9d64645 100644 --- a/test/test_cheat.c +++ b/test/test_cheat.c @@ -5,9 +5,10 @@ * (the boolean.h header pulled in by src/cheat.h) and otherwise link only * src/cheat.c, so the rest of the emulator does not need to be built. * - * Build & run (from repo root): + * Build & run (from repo root): `make test` + * or manually: * cc -O2 -Wall -std=c99 -I src -I libretro-common/include \ - * -o test_cheat test/test_cheat.c src/cheat.c && ./test_cheat + * -o test/test_cheat test/test_cheat.c src/cheat.c && ./test/test_cheat * * The tests cover: * 1. cheat_parse_one: all accepted format lengths, every separator style, @@ -174,6 +175,9 @@ static void test_parse_invalid(void) CHECK(!cheat_parse_one("00003D00 FFFG", &addr, &val, &size)); CHECK(!cheat_parse_one("00003D00/FFFF", &addr, &val, &size)); CHECK(!cheat_parse_one("00003D00,FFFF", &addr, &val, &size)); + + /* More than 16 hex digits (longest valid single code is 8+8). */ + CHECK(!cheat_parse_one("00003D00FFFFFFFF1", &addr, &val, &size)); } /* --------------------------------------------------------------------- */ @@ -203,6 +207,10 @@ static void test_list_add_and_remove(void) CHECK_EQ_U(list.entries[0].address, 0x100000u); CHECK_EQ_U(list.entries[0].tag, 1u); + /* enabled=false must clear the slot even when code is NULL. */ + cheat_list_set(&list, 1, false, NULL); + CHECK_EQ_U(list.count, 0u); + cheat_list_reset(&list); CHECK_EQ_U(list.count, 0u); } From b6b9b41e82a158a39237e28899305a6527b4fbd0 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Thu, 23 Apr 2026 14:52:11 -0400 Subject: [PATCH 3/8] Address more PR 113 review: 8+2 with ws, full index tag, MSVC make test - Two-field parse when 10 hex digits and space/tab: disambiguate 8+2 vs 6+4 (fix 00003D00 FF). - Skip per-code substrings >= 64 chars instead of truncating. - Store full retro_cheat_set index in entry tag (no 0xFF alias); add test. - Guard make test on MSVC platform=; use -std=c99 path only for GCC/Clang. - Document and test 8+2 + index-256 cases. Made-with: Cursor --- Makefile | 6 ++ src/cheat.c | 148 ++++++++++++++++++++++++++++++++++++++++------ src/cheat.h | 7 ++- test/test_cheat.c | 21 +++++++ 4 files changed, 162 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 294270b..be6d5dd 100644 --- a/Makefile +++ b/Makefile @@ -619,12 +619,18 @@ clean: # Self-contained unit tests (parser + list management + simulated # memory application). Does not require a ROM or a working build of # the full core. +ifneq (,$(findstring msvc,$(platform))) +test: + @echo "make test requires GCC/Clang flags; use MSYS2/Unix or compile test/test_cheat.c manually." + @false +else test: test/test_cheat ./test/test_cheat test/test_cheat: test/test_cheat.c src/cheat.c src/cheat.h $(CC) -O2 -Wall -std=c99 -I src -I libretro-common/include \ -o $@ test/test_cheat.c src/cheat.c +endif .PHONY: clean test endif diff --git a/src/cheat.c b/src/cheat.c index e8eb8ad..5be1eea 100644 --- a/src/cheat.c +++ b/src/cheat.c @@ -13,6 +13,100 @@ static int hex_digit(char c) /* Longest accepted form is 8 hex (addr) + 8 hex (value) after separator strip. */ #define CHEAT_PARSE_MAX_HEX 16 +static bool hex_only_strip(const char *in, char *hex_out, size_t hex_max, size_t *n_hex) +{ + size_t n = 0; + + for (; *in; in++) + { + if (hex_digit((char)*in) >= 0) + { + if (n >= hex_max - 1) + return false; + hex_out[n++] = (char)*in; + } + else if (*in != ' ' && *in != '\t' && *in != ':' && + *in != '-' && *in != '.') + return false; + } + hex_out[n] = '\0'; + *n_hex = n; + return true; +} + +static bool has_ascii_ws(const char *s) +{ + for (; *s; s++) + { + if (*s == ' ' || *s == '\t') + return true; + } + return false; +} + +static bool split_first_ws(const char *code, + char *left, size_t lmax, + char *right, size_t rmax) +{ + size_t ln = 0; + size_t rn = 0; + const char *p = code; + + while (*p && *p != ' ' && *p != '\t') + { + if (ln + 1 >= lmax) + return false; + left[ln++] = *p++; + } + left[ln] = '\0'; + if (*p == '\0') + return false; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '\0') + return false; + while (*p) + { + if (rn + 1 >= rmax) + return false; + right[rn++] = *p++; + } + right[rn] = '\0'; + return ln > 0 && rn > 0; +} + +static bool pair_from_segment_lengths(size_t la, size_t lv, + size_t *addr_len, size_t *val_len) +{ + if (la == 6 && lv == 2) { *addr_len = 6; *val_len = 2; return true; } + if (la == 6 && lv == 4) { *addr_len = 6; *val_len = 4; return true; } + if (la == 8 && lv == 4) { *addr_len = 8; *val_len = 4; return true; } + if (la == 8 && lv == 2) { *addr_len = 8; *val_len = 2; return true; } + if (la == 6 && lv == 8) { *addr_len = 6; *val_len = 8; return true; } + if (la == 8 && lv == 8) { *addr_len = 8; *val_len = 8; return true; } + return false; +} + +static bool parse_digits_pair(const char *addr_digits, size_t addr_len, + const char *val_digits, size_t val_len, + uint32_t *addr_out, + uint32_t *val_out, + uint8_t *size_out) +{ + size_t i; + uint32_t addr = 0, val = 0; + + for (i = 0; i < addr_len; i++) + addr = (addr << 4) | (uint32_t)hex_digit(addr_digits[i]); + for (i = 0; i < val_len; i++) + val = (val << 4) | (uint32_t)hex_digit(val_digits[i]); + + *addr_out = addr & 0x00FFFFFFu; + *val_out = val; + *size_out = (uint8_t)(val_len / 2); + return true; +} + bool cheat_parse_one(const char *code, uint32_t *addr_out, uint32_t *val_out, @@ -20,33 +114,50 @@ bool cheat_parse_one(const char *code, { char buf[CHEAT_PARSE_MAX_HEX + 1]; size_t n = 0; - size_t addr_len, val_len, i; + size_t addr_len, val_len; uint32_t addr = 0, val = 0; + size_t i; if (!code || !addr_out || !val_out || !size_out) return false; - for (; *code; code++) + while (*code == ' ' || *code == '\t') + code++; + + if (!hex_only_strip(code, buf, sizeof(buf), &n)) + return false; + + /* Two-field form: ASCII whitespace separates address field from value field so + * e.g. 8-digit address + byte ("00003D00 FF") is not misparsed as 6+4. */ + if (n == 10 && has_ascii_ws(code)) { - if (hex_digit((char)*code) >= 0) - { - if (n >= CHEAT_PARSE_MAX_HEX) - return false; - buf[n++] = (char)*code; - } - else if (*code != ' ' && *code != '\t' && *code != ':' && - *code != '-' && *code != '.') + char left[96]; + char right[96]; + char addr_digits[CHEAT_PARSE_MAX_HEX + 1]; + char val_digits[CHEAT_PARSE_MAX_HEX + 1]; + size_t la; + size_t lv; + + if (!split_first_ws(code, left, sizeof(left), right, sizeof(right))) + return false; + if (!hex_only_strip(left, addr_digits, sizeof(addr_digits), &la)) return false; + if (!hex_only_strip(right, val_digits, sizeof(val_digits), &lv)) + return false; + if (!pair_from_segment_lengths(la, lv, &addr_len, &val_len)) + return false; + return parse_digits_pair(addr_digits, addr_len, + val_digits, val_len, + addr_out, val_out, size_out); } - buf[n] = '\0'; /* Layout: address_hex + value_hex (concatenated after separator strip). - * Accepted pairings cover byte/word/long values under 24- or 32-bit - * nominal addresses; the address is always masked to 24 bits below. */ + * For 10 digits with no ASCII whitespace, use 6+4 (PAR short-address + word). + * Use a space/tab between fields for 8+2 (full 24-bit address + byte); see above. */ switch (n) { case 8: addr_len = 6; val_len = 2; break; /* 6 + byte */ - case 10: addr_len = 6; val_len = 4; break; /* 6 + word */ + case 10: addr_len = 6; val_len = 4; break; /* 6 + word (contiguous) */ case 12: addr_len = 8; val_len = 4; break; /* 8 + word (PAR) */ case 14: addr_len = 6; val_len = 8; break; /* 6 + long */ case 16: addr_len = 8; val_len = 8; break; /* 8 + long */ @@ -71,7 +182,7 @@ void cheat_list_remove_index(cheat_list_t *list, unsigned index) return; for (i = 0; i < list->count; i++) { - if (list->entries[i].tag == (uint8_t)(index & 0xFF)) + if (list->entries[i].tag == index) continue; if (j != i) list->entries[j] = list->entries[i]; @@ -112,17 +223,16 @@ void cheat_list_set(cheat_list_t *list, if (*p == '+' || *p == '\n' || *p == '\r' || *p == '\0') { size_t len = (size_t)(p - start); - if (len > 0 && list->count < CHEAT_MAX_ENTRIES) + if (len > 0 && len < 64 && list->count < CHEAT_MAX_ENTRIES) { char tmp[64]; cheat_entry_t c; - if (len >= sizeof(tmp)) - len = sizeof(tmp) - 1; + memcpy(tmp, start, len); tmp[len] = '\0'; if (cheat_parse_one(tmp, &c.address, &c.value, &c.size)) { - c.tag = (uint8_t)(index & 0xFF); + c.tag = index; c.enabled = true; list->entries[list->count++] = c; } diff --git a/src/cheat.h b/src/cheat.h index 2c1884f..318ca02 100644 --- a/src/cheat.h +++ b/src/cheat.h @@ -14,6 +14,11 @@ * "0000:3D00-FFFF" * "00003D00FFFF" * + * Contiguous 10 hex digits use 6+4 (short address + word). For an 8-digit + * address plus a byte value (8+2), put ASCII whitespace between the fields, + * e.g. "00003D00 FF". You can also write "ABCDEF 1234" to force 6+4 with a + * visible boundary. + * * A single `retro_cheat_set` string may contain multiple codes separated * by '+' or newlines; each is parsed and stored independently under the * same index so the frontend can toggle them as a group. @@ -37,7 +42,7 @@ typedef struct { uint32_t address; /* 24-bit Jaguar bus address */ uint32_t value; uint8_t size; /* 1 byte, 2 word, 4 long */ - uint8_t tag; /* retro cheat index (for removal on toggle) */ + unsigned tag; /* retro_cheat_set index (for removal on toggle) */ bool enabled; } cheat_entry_t; diff --git a/test/test_cheat.c b/test/test_cheat.c index 9d64645..825527d 100644 --- a/test/test_cheat.c +++ b/test/test_cheat.c @@ -94,6 +94,12 @@ static void test_parse_valid_formats(void) CHECK_EQ_U(addr, 0x100000u); CHECK_EQ_U(val, 0xDEADBEEFu); CHECK_EQ_U(size, 4u); + + /* 8+2: PAR-style full address + byte — needs whitespace so it is not read as 6+4 */ + CHECK(cheat_parse_one("00003D00 FF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); + CHECK_EQ_U(val, 0xFFu); + CHECK_EQ_U(size, 1u); } static void test_parse_separators(void) @@ -211,6 +217,20 @@ static void test_list_add_and_remove(void) cheat_list_set(&list, 1, false, NULL); CHECK_EQ_U(list.count, 0u); + cheat_list_reset(&list); +} + +static void test_list_index_not_truncated(void) +{ + cheat_list_t list; + cheat_list_reset(&list); + cheat_list_set(&list, 0, true, "001000 7F"); + cheat_list_set(&list, 256, true, "002000 AB"); + CHECK_EQ_U(list.count, 2u); + cheat_list_set(&list, 256, false, NULL); + CHECK_EQ_U(list.count, 1u); + CHECK_EQ_U(list.entries[0].tag, 0u); + cheat_list_reset(&list); CHECK_EQ_U(list.count, 0u); } @@ -441,6 +461,7 @@ int main(void) test_parse_invalid(); test_list_add_and_remove(); + test_list_index_not_truncated(); test_list_replace_same_index(); test_list_multi_code_string(); test_list_skips_malformed_entries(); From 9ce9b700142c7d3c0501c11b7d1a8db471db71e8 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Thu, 23 Apr 2026 15:59:34 -0400 Subject: [PATCH 4/8] test_cheat: bounds-check sim_write/read against 16 MiB buffer Avoid UB when masking 24-bit addr leaves word/long straddling past last byte (Copilot PR #113). Made-with: Cursor --- test/test_cheat.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/test_cheat.c b/test/test_cheat.c index 825527d..0bc23d3 100644 --- a/test/test_cheat.c +++ b/test/test_cheat.c @@ -31,6 +31,7 @@ #include #include #include +#include #include "cheat.h" @@ -314,6 +315,9 @@ static void sim_write(uint32_t addr, uint32_t value, uint8_t size, void *user) { (void)user; addr &= 0x00FFFFFF; + /* 24-bit bus: last in-range long write starts at 0xFFFFFC; word at 0xFFFFFE. */ + if ((size_t)addr + (size_t)size > (size_t)SIM_MEM_SIZE) + return; switch (size) { case 1: @@ -334,11 +338,17 @@ static void sim_write(uint32_t addr, uint32_t value, uint8_t size, void *user) static uint16_t sim_read16(uint32_t addr) { + addr &= 0x00FFFFFF; + if ((size_t)addr + 2u > (size_t)SIM_MEM_SIZE) + return 0; return (uint16_t)((sim_mem[addr] << 8) | sim_mem[addr + 1]); } static uint32_t sim_read32(uint32_t addr) { + addr &= 0x00FFFFFF; + if ((size_t)addr + 4u > (size_t)SIM_MEM_SIZE) + return 0; return ((uint32_t)sim_mem[addr] << 24) | ((uint32_t)sim_mem[addr + 1] << 16) | ((uint32_t)sim_mem[addr + 2] << 8) | From cb7d1b555df2b8fe834a5137385622f83c8e9ff5 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Thu, 23 Apr 2026 16:12:42 -0400 Subject: [PATCH 5/8] PR 113: colon/dot 8+2 parse, fix capacity test, doc boolean.h - 10-hex two-field: last :/-/. split (00003D00:FF, 0000:3D00:FF) plus ws path. - test_list_capacity: unique indices i (no & 0xFF) so clamp is exercised. - cheat.h / test_cheat.c: document separators and include order for boolean.h. Made-with: Cursor --- src/cheat.c | 52 +++++++++++++++++++++++++++++++++++++++++------ src/cheat.h | 7 ++++--- test/test_cheat.c | 22 ++++++++++++++------ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/cheat.c b/src/cheat.c index 5be1eea..95b0d3e 100644 --- a/src/cheat.c +++ b/src/cheat.c @@ -44,6 +44,40 @@ static bool has_ascii_ws(const char *s) return false; } +/* Split on the last ':', '-', or '.' so "0000:3D00:FF" → address 00003D00 + value FF. */ +static bool split_last_cd_sep(const char *code, + char *left, size_t lmax, + char *right, size_t rmax) +{ + const char *last = NULL; + const char *p; + size_t ln; + size_t rn; + + for (p = code; *p; p++) + { + if (*p == ':' || *p == '-' || *p == '.') + last = p; + } + if (!last || last == code || !last[1]) + return false; + + ln = (size_t)(last - code); + if (ln + 1 >= lmax) + return false; + memcpy(left, code, ln); + left[ln] = '\0'; + + for (p = last + 1, rn = 0; *p; p++) + { + if (rn + 1 >= rmax) + return false; + right[rn++] = *p; + } + right[rn] = '\0'; + return ln > 0 && rn > 0; +} + static bool split_first_ws(const char *code, char *left, size_t lmax, char *right, size_t rmax) @@ -127,9 +161,10 @@ bool cheat_parse_one(const char *code, if (!hex_only_strip(code, buf, sizeof(buf), &n)) return false; - /* Two-field form: ASCII whitespace separates address field from value field so - * e.g. 8-digit address + byte ("00003D00 FF") is not misparsed as 6+4. */ - if (n == 10 && has_ascii_ws(code)) + /* Two-field form (10 hex digits total): disambiguate 6+4 vs 8+2. + * Use first whitespace run, else last ':', '-', or '.' between fields + * ("00003D00 FF", "00003D00:FF", "0000:3D00:FF") so 8+2 is not read as 6+4. */ + if (n == 10 && (has_ascii_ws(code) || strpbrk(code, ":-.") != NULL)) { char left[96]; char right[96]; @@ -137,8 +172,14 @@ bool cheat_parse_one(const char *code, char val_digits[CHEAT_PARSE_MAX_HEX + 1]; size_t la; size_t lv; + bool split_ok; + + if (has_ascii_ws(code)) + split_ok = split_first_ws(code, left, sizeof(left), right, sizeof(right)); + else + split_ok = split_last_cd_sep(code, left, sizeof(left), right, sizeof(right)); - if (!split_first_ws(code, left, sizeof(left), right, sizeof(right))) + if (!split_ok) return false; if (!hex_only_strip(left, addr_digits, sizeof(addr_digits), &la)) return false; @@ -152,8 +193,7 @@ bool cheat_parse_one(const char *code, } /* Layout: address_hex + value_hex (concatenated after separator strip). - * For 10 digits with no ASCII whitespace, use 6+4 (PAR short-address + word). - * Use a space/tab between fields for 8+2 (full 24-bit address + byte); see above. */ + * For 10 digits with no field boundary, use 6+4 (PAR short-address + word). */ switch (n) { case 8: addr_len = 6; val_len = 2; break; /* 6 + byte */ diff --git a/src/cheat.h b/src/cheat.h index 318ca02..7c28d63 100644 --- a/src/cheat.h +++ b/src/cheat.h @@ -15,9 +15,10 @@ * "00003D00FFFF" * * Contiguous 10 hex digits use 6+4 (short address + word). For an 8-digit - * address plus a byte value (8+2), put ASCII whitespace between the fields, - * e.g. "00003D00 FF". You can also write "ABCDEF 1234" to force 6+4 with a - * visible boundary. + * address plus a byte (8+2), separate fields with ASCII whitespace and/or a + * single boundary using ':', '-', or '.' before the value (e.g. + * "00003D00 FF", "00003D00:FF", "0000:3D00:FF"). You can also write + * "ABCDEF 1234" to force 6+4 with a visible boundary. * * A single `retro_cheat_set` string may contain multiple codes separated * by '+' or newlines; each is parsed and stored independently under the diff --git a/test/test_cheat.c b/test/test_cheat.c index 0bc23d3..4658d19 100644 --- a/test/test_cheat.c +++ b/test/test_cheat.c @@ -1,9 +1,9 @@ /* * Unit tests for the Jaguar cheat engine (src/cheat.c). * - * These tests are self-contained: they stub just enough of libretro-common - * (the boolean.h header pulled in by src/cheat.h) and otherwise link only - * src/cheat.c, so the rest of the emulator does not need to be built. + * Self-contained: links only src/cheat.c. `make test` uses `-I src` before + * libretro-common, so `` resolves to src/boolean.h (compatible with + * libretro-common) via src/cheat.h. * * Build & run (from repo root): `make test` * or manually: @@ -96,11 +96,21 @@ static void test_parse_valid_formats(void) CHECK_EQ_U(val, 0xDEADBEEFu); CHECK_EQ_U(size, 4u); - /* 8+2: PAR-style full address + byte — needs whitespace so it is not read as 6+4 */ + /* 8+2: full address + byte — boundary via space or last ':', '-', '.' */ CHECK(cheat_parse_one("00003D00 FF", &addr, &val, &size)); CHECK_EQ_U(addr, 0x003D00u); CHECK_EQ_U(val, 0xFFu); CHECK_EQ_U(size, 1u); + + CHECK(cheat_parse_one("00003D00:FF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); + CHECK_EQ_U(val, 0xFFu); + CHECK_EQ_U(size, 1u); + + CHECK(cheat_parse_one("0000:3D00:FF", &addr, &val, &size)); + CHECK_EQ_U(addr, 0x003D00u); + CHECK_EQ_U(val, 0xFFu); + CHECK_EQ_U(size, 1u); } static void test_parse_separators(void) @@ -292,12 +302,12 @@ static void test_list_capacity(void) cheat_list_t list; unsigned i; cheat_list_reset(&list); - /* Fill the list past capacity and verify it clamps. */ + /* Fill with distinct cheat indices until the list hits CHEAT_MAX_ENTRIES. */ for (i = 0; i < CHEAT_MAX_ENTRIES + 50; i++) { char code[32]; snprintf(code, sizeof(code), "%06X FF", i); - cheat_list_set(&list, i & 0xFF, true, code); + cheat_list_set(&list, i, true, code); } CHECK(list.count <= CHEAT_MAX_ENTRIES); } From 5d4c23c2825e6c561d51427f96b7e3df57bd9420 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Thu, 23 Apr 2026 16:29:51 -0400 Subject: [PATCH 6/8] PR 113: libretro log for bogus cheat sizes, segment limits, CI note - Wire RETRO_ENVIRONMENT_GET_LOG_INTERFACE; log unsupported cheat write sizes via frontend callback (no stderr in core). - Rename include guard to VJAG_CHEAT_H. - CHEAT_SEGMENT_INPUT_MAX / CHEAT_SEGMENT_TMP_SIZE for list_set scratch. - Document that cheat unit tests run only on native matrix rows. Made-with: Cursor --- .github/workflows/c-cpp.yml | 1 + libretro.c | 14 ++++++++++++++ src/cheat.c | 6 ++++-- src/cheat.h | 10 +++++++--- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 16e0833..62afb4b 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -169,6 +169,7 @@ jobs: ${{ steps.setup-ndk.outputs.ndk-path }}/ndk-build \ APP_ABI=${{ matrix.config.android_abi }} -j4 + # Host/native toolchains only — skips cross-compile rows (e.g. aarch64 on x86 runner). - name: Run cheat engine unit tests if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.cross }} run: make test CC="${{ matrix.config.cc }}" diff --git a/libretro.c b/libretro.c index 2187ebb..97eece3 100644 --- a/libretro.c +++ b/libretro.c @@ -52,6 +52,7 @@ static retro_video_refresh_t video_cb; static retro_input_poll_t input_poll_cb; static retro_input_state_t input_state_cb; static retro_environment_t environ_cb; +static retro_log_printf_t libretro_log_printf; retro_audio_sample_batch_t audio_batch_cb; static bool libretro_supports_bitmasks = false; @@ -292,9 +293,16 @@ void retro_set_environment(retro_environment_t cb) { struct retro_vfs_interface_info vfs_iface_info; struct retro_core_options_update_display_callback update_display_cb; + struct retro_log_callback logging; bool option_categories = false; environ_cb = cb; + logging.log = NULL; + if (cb(RETRO_ENVIRONMENT_GET_LOG_INTERFACE, &logging)) + libretro_log_printf = logging.log; + else + libretro_log_printf = NULL; + libretro_set_core_options(environ_cb, &option_categories); update_display_cb.callback = update_option_visibility; environ_cb(RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK, &update_display_cb); @@ -898,6 +906,12 @@ static void cheat_write_jaguar(uint32_t addr, uint32_t value, case 1: JaguarWriteByte(addr, (uint8_t)value, UNKNOWN); break; case 2: JaguarWriteWord(addr, (uint16_t)value, UNKNOWN); break; case 4: JaguarWriteLong(addr, value, UNKNOWN); break; + default: + if (libretro_log_printf) + libretro_log_printf(RETRO_LOG_WARN, + "[Virtual Jaguar] cheat: unsupported write size %u at 0x%06X\n", + (unsigned)size, (unsigned)(addr & 0xFFFFFFU)); + break; } } diff --git a/src/cheat.c b/src/cheat.c index 95b0d3e..67f0fd2 100644 --- a/src/cheat.c +++ b/src/cheat.c @@ -263,9 +263,11 @@ void cheat_list_set(cheat_list_t *list, if (*p == '+' || *p == '\n' || *p == '\r' || *p == '\0') { size_t len = (size_t)(p - start); - if (len > 0 && len < 64 && list->count < CHEAT_MAX_ENTRIES) + /* Reject oversized segments outright (do not truncate and parse). */ + if (len > 0 && len <= CHEAT_SEGMENT_INPUT_MAX && + list->count < CHEAT_MAX_ENTRIES) { - char tmp[64]; + char tmp[CHEAT_SEGMENT_TMP_SIZE]; cheat_entry_t c; memcpy(tmp, start, len); diff --git a/src/cheat.h b/src/cheat.h index 7c28d63..ef358e3 100644 --- a/src/cheat.h +++ b/src/cheat.h @@ -1,5 +1,5 @@ -#ifndef __CHEAT_H__ -#define __CHEAT_H__ +#ifndef VJAG_CHEAT_H +#define VJAG_CHEAT_H /* * Atari Jaguar cheat-code engine. @@ -39,6 +39,10 @@ extern "C" { #define CHEAT_MAX_ENTRIES 256 +/* Max chars in one '+' or newline-separated segment (matches scratch buffer below). */ +#define CHEAT_SEGMENT_INPUT_MAX 63 +#define CHEAT_SEGMENT_TMP_SIZE (CHEAT_SEGMENT_INPUT_MAX + 1) + typedef struct { uint32_t address; /* 24-bit Jaguar bus address */ uint32_t value; @@ -87,4 +91,4 @@ void cheat_list_apply(const cheat_list_t *list, } #endif -#endif /* __CHEAT_H__ */ +#endif /* VJAG_CHEAT_H */ From 2dfd489fcf7f56c6e1346cf619c88ebb0e5dca1e Mon Sep 17 00:00:00 2001 From: Joe Mattiello Date: Thu, 23 Apr 2026 16:48:20 -0400 Subject: [PATCH 7/8] Update src/cheat.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/cheat.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cheat.h b/src/cheat.h index ef358e3..4972ce1 100644 --- a/src/cheat.h +++ b/src/cheat.h @@ -46,7 +46,7 @@ extern "C" { typedef struct { uint32_t address; /* 24-bit Jaguar bus address */ uint32_t value; - uint8_t size; /* 1 byte, 2 word, 4 long */ + uint8_t size; /* 1=byte, 2=word, 4=long */ unsigned tag; /* retro_cheat_set index (for removal on toggle) */ bool enabled; } cheat_entry_t; From 4166acb8a524c787658eb1160b35f8fb86b83ffe Mon Sep 17 00:00:00 2001 From: Joe Mattiello Date: Thu, 23 Apr 2026 16:48:30 -0400 Subject: [PATCH 8/8] Update test/test_cheat.c Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/test_cheat.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_cheat.c b/test/test_cheat.c index 4658d19..2a67185 100644 --- a/test/test_cheat.c +++ b/test/test_cheat.c @@ -14,7 +14,8 @@ * 1. cheat_parse_one: all accepted format lengths, every separator style, * and a broad set of rejection cases (bad chars, wrong length, NULL). * 2. List management: add, toggle off, replacement-on-same-index, and - * removing entries when the list is full. + * capacity clamping when the list is full (additional inserts are + * ignored). * 3. Multi-code strings (the '+' and newline separators used by * RetroArch's cheat .cht files). * 4. Application against a 16 MB simulated address space, exercising