From 02469c725b7efde5a0008e6e429b2fbbef2b403f Mon Sep 17 00:00:00 2001 From: phantomptr Date: Sun, 18 Oct 2026 11:27:08 -0700 Subject: [PATCH] cheats: write game memory through its physical pages, all-or-nothing Every Marvel's Wolverine cheat crashed the console; CheatRunner's worked. Our engine PT_ATTACHed the game, set the protection byte of the vm_map entry holding the address to RWX with kernel_mprotect, wrote with PT_IO, and "restored" it to R-X. Measured on the Pro (FW 13.60): the game's .text is one 95 MiB entry (0x400000-0x6324000), execute-only (0x04), so each toggle made the whole executable RWX under a copy-on-write PT_IO write and left it R-X. kernel_mprotect edits the entry byte directly (no pmap update, no split); CheatRunner traced two kernel panics to that call and moved to the page-table walk this commit uses. - Read/write through kernel_proc_getpaddr + the direct map (mdbg as a fallback), inside kernel_rw_lock; read back after every write. No ptrace stop, no protection change. - Apply a cheat as a whole: every location is read and checked against its cheat and original bytes before anything is written; on a write failure the earlier writes are undone. The old engine wrote whichever locations matched, so a jump could land without its code cave. - Write code caves before the jumps into them when turning on, and the reverse when turning off (cheats_plan.c, host-tested on the real Wolverine pack). - Refuse a cheat the parser had to truncate, and a base of 0. - Toggle state is keyed by the flat index across all of a title's files; a toggle in the second file used to mark the first file's mods, which the watcher then re-applied at the next launch. Verified on the Pro with Wolverine running: God Mode, Max Resources/XP (20 locations), Infinite Item Charges and an MC4 Infinite Health on and off, 60 s each, game and console stayed up; a clashing MC4 cheat was refused with nothing written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS --- Makefile | 6 + payload/Makefile | 2 +- payload/include/cheats_plan.h | 58 +++ payload/src/cheats.c | 560 ++++++++++++++------------- payload/src/cheats_plan.c | 95 +++++ payload/tests/cheats_plan_selftest.c | 176 +++++++++ 6 files changed, 620 insertions(+), 277 deletions(-) create mode 100644 payload/include/cheats_plan.h create mode 100644 payload/src/cheats_plan.c create mode 100644 payload/tests/cheats_plan_selftest.c diff --git a/Makefile b/Makefile index 499f611c..942871a3 100644 --- a/Makefile +++ b/Makefile @@ -972,6 +972,12 @@ test-payload: payload $(PAYLOAD_DIR)/tests/cheats_list_selftest.c @/tmp/ps5upload-cheats-list-selftest @echo "✓ a large or badly encoded cheat pack lists as valid, bounded JSON" + @echo "Running cheat apply-plan self-test (host build)..." + @cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \ + -o /tmp/ps5upload-cheats-plan-selftest \ + $(PAYLOAD_DIR)/tests/cheats_plan_selftest.c + @/tmp/ps5upload-cheats-plan-selftest + @echo "✓ a cheat's code caves are written before the jumps into them, and removed after" @echo "Checking per-console isolation..." @./scripts/check-per-console-isolation.sh @echo "✓ one console's data cannot be shown under another's name" diff --git a/payload/Makefile b/payload/Makefile index 69cae94c..3164491e 100644 --- a/payload/Makefile +++ b/payload/Makefile @@ -190,7 +190,7 @@ SRCS := src/main.c src/runtime.c src/shell_builtin.c src/takeover.c src/takeover src/sony_api_lock.c src/kernel_rw_lock.c src/sys_time.c \ src/sys_registry.c src/smp_meta.c src/profile.c \ src/backup.c src/remoteplay.c src/rp_pair.c src/fan_curve.c src/fan_map.c src/indicator.c src/notif.c \ - src/wake_watchdog.c src/icon_keeper.c src/ffpfsc_unpack.c src/cheats.c src/cheats_list.c src/xml_encoding.c src/activity.c src/sdk_changer.c src/fakelib_overlay.c \ + src/wake_watchdog.c src/icon_keeper.c src/ffpfsc_unpack.c src/cheats.c src/cheats_plan.c src/cheats_list.c src/xml_encoding.c src/activity.c src/sdk_changer.c src/fakelib_overlay.c \ src/tmdb.c src/fw_spoof.c src/focus_probe.c \ src/content_db.c src/mgmt_rpc.c src/fs_jobs.c src/net_probe.c src/net_ports.c src/mgmt_fs.c src/path_policy.c \ src/webui_http.c src/webui_auth.c src/webui_server.c src/webui_codec.c src/webui_api.c \ diff --git a/payload/include/cheats_plan.h b/payload/include/cheats_plan.h new file mode 100644 index 00000000..203c5ec6 --- /dev/null +++ b/payload/include/cheats_plan.h @@ -0,0 +1,58 @@ +#ifndef PS5UPLOAD_CHEATS_PLAN_H +#define PS5UPLOAD_CHEATS_PLAN_H + +/* cheats_plan — the pure part of applying one cheat: what each location holds now, which + * locations are code caves, and the order the writes go in. No console calls, so the host + * self-test (tests/cheats_plan_selftest.c) runs it on real cheat files. + * + * A cheat is applied as a whole or not at all. The old engine wrote whichever locations + * matched and skipped the rest, so a cheat whose jump landed but whose code cave did not + * sent the game into int3 padding. */ + +#include +#include + +#define CHEAT_PLAN_MAX_BYTES 256 + +/* One location of a cheat, with the bytes read from the game before anything is written. */ +typedef struct { + intptr_t addr; + int len; /* on, off and cur are all this long */ + uint8_t on[CHEAT_PLAN_MAX_BYTES]; + uint8_t off[CHEAT_PLAN_MAX_BYTES]; + uint8_t cur[CHEAT_PLAN_MAX_BYTES]; +} cheat_loc_t; + +enum { + CHEAT_LOC_MISMATCH = -1, /* neither the cheat's bytes nor the original: wrong game build */ + CHEAT_LOC_OFF = 0, /* holds the original bytes */ + CHEAT_LOC_ON = 1, /* holds the cheat's bytes */ +}; + +/* Parse "90 1F-00,00" / "901F0000" into bytes. Separators: space , - : tab CR LF. A trailing + * odd nibble becomes the low nibble of a last byte. Returns the byte count, or -1 on a + * non-hex character, an empty string, or more than `max` bytes. */ +int cheat_parse_hex(const char *s, uint8_t *out, int max); + +/* Bare or 0x-prefixed hex offset. Returns 0 and fills *out, or -1 when it is not hex. */ +int cheat_parse_offset(const char *s, uint64_t *out); + +/* Which state a location is in, from the bytes read before writing. */ +int cheat_loc_state(const cheat_loc_t *l); + +/* A code cave: the original bytes are all int3 (0xCC) or all zero padding, so nothing in the + * game runs there until the cheat's own jump sends it there. */ +int cheat_loc_is_cave(const cheat_loc_t *l); + +/* The order to write the n locations in. Turning on: caves first, then everything else, each + * group in file order, so no jump is live before the code it jumps to. Turning off is the exact + * reverse, so the jumps are restored before their caves go back to padding. The game keeps + * running while we write, so this order is what keeps it from executing half a cheat. + * Fills order[0..n-1] with indices into locs. */ +void cheat_plan_order(const cheat_loc_t *locs, int n, int turn_on, int *order); + +/* A user-space address the engine is willing to write: above the null page, below the + * canonical user limit, and not wrapping. */ +int cheat_addr_ok(intptr_t addr, int len); + +#endif /* PS5UPLOAD_CHEATS_PLAN_H */ diff --git a/payload/src/cheats.c b/payload/src/cheats.c index f4c4d67c..496af41a 100644 --- a/payload/src/cheats.c +++ b/payload/src/cheats.c @@ -6,16 +6,17 @@ * - Three file formats: JSON (native), SHN (XML trainer), MC4 (base64 over * AES-256-CBC, decrypting to the same XML as SHN). All parsed into a * common in-memory representation. - * - Memory writes via ptrace (PT_ATTACH + PT_IO), with kernel_mprotect - * to flip execute-only pages to RWX temporarily. + * - Memory reads/writes through the game's physical pages (page-table + * walk + kernel direct map), applied all-or-nothing per cheat. See + * "Game memory access" below for why not ptrace + kernel_mprotect. * - A background watcher thread polls every 3s for new game processes * and auto-applies "patches" (always-on) + re-applies user-toggled * cheats. * - Toggle state persisted to sidecar JSON files so it survives payload * redeploy. * - * Ported from elf-arsenal/src/cheats.c, adapted to ps5upload's ptrace - * infrastructure (ptrace_remote.c) and management protocol. + * Ported from elf-arsenal/src/cheats.c, adapted to ps5upload's + * management protocol. */ #include "cheats.h" @@ -43,9 +44,17 @@ #include "notif.h" #include "proc_list.h" -#include "ptrace_remote.h" #include "aes.h" /* tiny-AES, vendored — MC4 cheat decryption */ #include "xml_encoding.h" /* UTF-16 → UTF-8 for SHN/MC4 trainer XML (#317) */ +#include "cheats_plan.h" +#include "kernel_rw_lock.h" + +#include + +/* Defined by the payload's own build of the SDK startup code (third_party/sdk-crt), which every + * payload links: the kernel direct-map base and the process page-table walk. */ +extern unsigned long KERNEL_ADDRESS_DMAP_BASE; +int kernel_proc_getpaddr(int pid, unsigned long vaddr, unsigned long *paddr, unsigned long *plen); /* ── Constants ───────────────────────────────────────────────────── */ @@ -64,7 +73,6 @@ #define MAX_CHEAT_NAME 256 #define MAX_MODS_PER_FILE 64 #define MAX_MEM_ENTRIES 32 -#define MAX_HEX_BYTES 256 #define MAX_JSON_BUF (256 * 1024) #define CHEAT_FILE_MAX (4 * 1024 * 1024) @@ -72,14 +80,6 @@ #define KINFO_STRUCT_MINSIZE 448 -#define PROT_READ 0x01 -#define PROT_WRITE 0x02 -#define PROT_EXEC 0x04 - -#define PAGE_SIZE_16K 0x4000 -#define ROUND_PG_DOWN(a) ((intptr_t)((a) & ~(intptr_t)(PAGE_SIZE_16K - 1))) -#define ROUND_PG_UP(a) ((intptr_t)(((a) + PAGE_SIZE_16K - 1) & ~(intptr_t)(PAGE_SIZE_16K - 1))) - /* Canonical layout + title-id helpers. */ #include "app_info.h" @@ -101,47 +101,6 @@ static struct { struct timespec ts; } g_rg_cache; -/* ── Hex parsing ─────────────────────────────────────────────────── */ - -static int hex_val(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; -} - -/* Parse a hex string like "90 1F 00 00" or "901F0000" into bytes. - * Separators (space, comma, dash, colon, tab, newline) are skipped. - * Returns byte count, or -1 on invalid hex. */ -static int parse_hex_bytes(const char *str, uint8_t *out, int max) { - int n = 0; - int hi = -1; - for (const char *p = str; *p && n < max; p++) { - if (*p == ' ' || *p == ',' || *p == '-' || *p == ':' || - *p == '\t' || *p == '\n' || *p == '\r') continue; - int v = hex_val(*p); - if (v < 0) return -1; - if (hi < 0) { - hi = v; - } else { - out[n++] = (uint8_t)(hi * 16 + v); - hi = -1; - } - } - if (hi >= 0) { - /* dangling nibble — treat as low nibble of a byte */ - out[n++] = (uint8_t)(hi); - } - return n; -} - -/* Parse offset string to intptr_t. Accepts bare hex or 0x-prefixed. */ -static intptr_t parse_offset(const char *s) { - if (!s || !*s) return 0; - if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s += 2; - return (intptr_t)strtoull(s, NULL, 16); -} - /* ── JSON string extraction helpers (from runtime.c patterns) ────── */ /* Find the value of a string field in a JSON blob. Writes the unquoted, @@ -395,68 +354,73 @@ static void invalidate_rg_cache(void) { pthread_mutex_unlock(&g_rg_lock); } -/* ── Memory write via ptrace ─────────────────────────────────────── */ +/* ── Game memory access ──────────────────────────────────────────── */ -/* Write bytes into a target process at addr. Temporarily flips page - * protection to RWX if needed, writes, verifies by readback, restores. - * Returns 0 on success, -1 on write failure, -2 on readback failure, - * -3 on verify mismatch. */ -static int write_process_memory(pid_t pid, intptr_t addr, - const uint8_t *data, size_t len) { - if (len == 0) return 0; +/* Read and write the game's memory through its physical pages: walk the process page tables + * to the physical address and copy through the kernel's direct map. Nothing about the game + * changes except the bytes: no ptrace stop, no page-protection change. + * + * The engine used to PT_ATTACH the game, flip the protection byte of the vm_map entry holding + * the address to RWX with kernel_mprotect, write with PT_IO, and "restore" it to R-X. Measured + * on FW 13.60 (Marvel's Wolverine, 2026-10-18): the game's .text is ONE 95 MiB vm_map entry + * (0x400000-0x6324000) whose protection is execute-only (0x04). Every toggle therefore made the + * whole executable writable and then left it R-X, a protection it never had, under a PT_IO + * write that has to copy-on-write a page of Sony's executable mapping. kernel_mprotect edits + * the entry byte directly (no pmap update, no entry split), and CheatRunner traced two real + * kernel panics / console freezes to exactly that call before it moved to this physical-page + * path, which works on the same consoles. The user report: every Wolverine cheat crashed the + * console with ps5upload, while CheatRunner worked. + * + * mdbg (the debugger syscall) is the fallback for firmwares where the page walk is not + * available; on FW 13.60 its write fails and its reads of code pages return filler, which is + * why it is not the first choice. Every write is read back through the same physical path. */ - intptr_t page = ROUND_PG_DOWN(addr); - intptr_t span = ROUND_PG_UP(addr + len) - page; - - /* Flip to RWX for the write */ - if (kernel_mprotect(pid, page, span, - PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { - return -1; +static int phys_copy(pid_t pid, intptr_t addr, void *buf, size_t len, int write) { + if (!KERNEL_ADDRESS_DMAP_BASE) return -1; + size_t done = 0; + while (done < len) { + unsigned long pa = 0, plen = 0; + pthread_mutex_lock(&kernel_rw_lock); + int rc = kernel_proc_getpaddr(pid, (unsigned long)(addr + (intptr_t)done), &pa, &plen); + if (rc == 0 && plen > 0) { + size_t chunk = len - done; + if (plen < chunk) chunk = (size_t)plen; + rc = write ? kernel_copyin((uint8_t *)buf + done, KERNEL_ADDRESS_DMAP_BASE + pa, chunk) + : kernel_copyout(KERNEL_ADDRESS_DMAP_BASE + pa, (uint8_t *)buf + done, chunk); + if (rc == 0) done += chunk; + } else { + rc = -1; + } + pthread_mutex_unlock(&kernel_rw_lock); + if (rc != 0) return -1; } - - /* Write */ - if (pt_copyin(pid, data, addr, len) != 0) { - (void)kernel_mprotect(pid, page, span, PROT_READ | PROT_EXEC); - return -1; - } - - /* Verify by readback */ - uint8_t readback[MAX_HEX_BYTES]; - if (len > sizeof(readback)) len = sizeof(readback); - if (pt_copyout(pid, addr, readback, len) != 0) { - (void)kernel_mprotect(pid, page, span, PROT_READ | PROT_EXEC); - return -2; - } - - int ok = (memcmp(readback, data, len) == 0); - - /* Restore to R-X */ - (void)kernel_mprotect(pid, page, span, PROT_READ | PROT_EXEC); - - return ok ? 0 : -3; + return 0; } -/* Read bytes from target process, handling execute-only (XOM) pages. */ -static int read_process_memory(pid_t pid, intptr_t addr, - uint8_t *out, size_t len) { +static int game_mem_read(pid_t pid, intptr_t addr, uint8_t *out, size_t len) { if (len == 0) return 0; - if (pt_copyout(pid, addr, out, len) == 0) return 0; + if (!cheat_addr_ok(addr, (int)len)) return -1; + if (phys_copy(pid, addr, out, len, 0) == 0) return 0; + /* mdbg swaps this process's authid, so it runs inside the shared swap-window lock. */ + pthread_mutex_lock(&kernel_rw_lock); + int rc = mdbg_copyout(pid, addr, out, len); + pthread_mutex_unlock(&kernel_rw_lock); + return rc == 0 ? 0 : -1; +} - /* pt_copyout can fail on XOM pages — temporarily add PROT_READ */ - intptr_t page = ROUND_PG_DOWN(addr); - intptr_t span = ROUND_PG_UP(addr + len) - page; - int orig_prot = kernel_get_vmem_protection(pid, page, span); - if (orig_prot < 0) orig_prot = PROT_READ | PROT_EXEC; - - if (!(orig_prot & PROT_READ)) { - (void)kernel_mprotect(pid, page, span, - (orig_prot | PROT_READ)); +/* Returns 0 when the bytes were written and read back equal. */ +static int game_mem_write(pid_t pid, intptr_t addr, const uint8_t *data, size_t len) { + if (len == 0) return 0; + if (!cheat_addr_ok(addr, (int)len) || len > CHEAT_PLAN_MAX_BYTES) return -1; + if (phys_copy(pid, addr, (void *)data, len, 1) != 0) { + pthread_mutex_lock(&kernel_rw_lock); + int rc = mdbg_copyin(pid, data, addr, len); + pthread_mutex_unlock(&kernel_rw_lock); + if (rc != 0) return -1; } - int rc = pt_copyout(pid, addr, out, len); - if (!(orig_prot & PROT_READ)) { - (void)kernel_mprotect(pid, page, span, orig_prot); - } - return rc; + uint8_t back[CHEAT_PLAN_MAX_BYTES]; + if (game_mem_read(pid, addr, back, len) != 0) return -2; + return memcmp(back, data, len) == 0 ? 0 : -3; } /* ── Module base resolution ──────────────────────────────────────── */ @@ -490,6 +454,7 @@ typedef struct { char type[16]; /* "checkbox" or "button" */ char module_name[128]; int enabled; /* persisted toggle state */ + int incomplete; /* the file had more than the parser could hold: never apply */ mem_entry_t mem[MAX_MEM_ENTRIES]; int mem_count; } cheat_mod_t; @@ -546,13 +511,15 @@ static int parse_json_file(const char *json, size_t json_len, /* Extract this mod's substring */ size_t mod_len = (size_t)(mod_end - mod_start); char mod_buf[8192]; - if (mod_len >= sizeof(mod_buf)) mod_len = sizeof(mod_buf) - 1; + int mod_cut = 0; + if (mod_len >= sizeof(mod_buf)) { mod_len = sizeof(mod_buf) - 1; mod_cut = 1; } memcpy(mod_buf, mod_start, mod_len); mod_buf[mod_len] = '\0'; cheat_mod_t *m = &cf->mods[mod_idx]; memset(m, 0, sizeof(*m)); strcpy(m->type, "checkbox"); + m->incomplete = mod_cut; json_str(mod_buf, "name", m->name, sizeof(m->name)); json_str(mod_buf, "hint", m->hint, sizeof(m->hint)); @@ -591,8 +558,10 @@ static int parse_json_file(const char *json, size_t json_len, size_t me_len = (size_t)(me_end - me_start); char me_buf[2048]; - if (me_len >= sizeof(me_buf)) + if (me_len >= sizeof(me_buf)) { me_len = sizeof(me_buf) - 1; + m->incomplete = 1; + } memcpy(me_buf, me_start, me_len); me_buf[me_len] = '\0'; @@ -614,6 +583,8 @@ static int parse_json_file(const char *json, size_t json_len, if (e->offset_str[0]) mi++; mp = me_end; } + /* Stopped at the cap with another entry still to come. */ + if (mi >= MAX_MEM_ENTRIES && strchr(mp, '{')) m->incomplete = 1; m->mem_count = mi; } } @@ -742,13 +713,15 @@ static int parse_shn_file(const char *xml, size_t xml_len, size_t cheat_len = (size_t)(close_cheat - cheat_end); char cheat_buf[8192]; - if (cheat_len >= sizeof(cheat_buf)) cheat_len = sizeof(cheat_buf) - 1; + int cheat_cut = 0; + if (cheat_len >= sizeof(cheat_buf)) { cheat_len = sizeof(cheat_buf) - 1; cheat_cut = 1; } memcpy(cheat_buf, cheat_end, cheat_len); cheat_buf[cheat_len] = '\0'; cheat_mod_t *m = &cf->mods[mod_idx]; memset(m, 0, sizeof(*m)); strcpy(m->type, "checkbox"); + m->incomplete = cheat_cut; xml_attr(cheat_start, "Text", m->name, sizeof(m->name)); if (!m->name[0]) @@ -787,7 +760,7 @@ static int parse_shn_file(const char *xml, size_t xml_len, } char cl_buf[2048]; - if (cl_len >= sizeof(cl_buf)) cl_len = sizeof(cl_buf) - 1; + if (cl_len >= sizeof(cl_buf)) { cl_len = sizeof(cl_buf) - 1; m->incomplete = 1; } memcpy(cl_buf, cl_end, cl_len); cl_buf[cl_len] = '\0'; @@ -797,7 +770,7 @@ static int parse_shn_file(const char *xml, size_t xml_len, /* For self-closing tags, extract attributes directly */ char cl_full[2048]; size_t full_len = (size_t)(cl_real_end - cl_start); - if (full_len >= sizeof(cl_full)) full_len = sizeof(cl_full) - 1; + if (full_len >= sizeof(cl_full)) { full_len = sizeof(cl_full) - 1; m->incomplete = 1; } memcpy(cl_full, cl_start, full_len); cl_full[full_len] = '\0'; @@ -838,6 +811,11 @@ static int parse_shn_file(const char *xml, size_t xml_len, if (e->offset_str[0]) mi++; cl_cursor = cl_real_end; } + { + const char *dummy_s, *dummy_e; + if (mi >= MAX_MEM_ENTRIES && xml_find_tag(cl_cursor, "Cheatline", &dummy_s, &dummy_e)) + m->incomplete = 1; + } m->mem_count = mi; if (m->name[0] || mi > 0) mod_idx++; @@ -1041,68 +1019,63 @@ static void state_path(const char *title_id, char *out, size_t cap) { snprintf(out, cap, "%s/%s.json", CHEATS_STATE_DIR, title_id); } -/* Load enabled state from sidecar. Updates cf->mods[].enabled. */ -static void cheats_load_state(const char *title_id, cheat_file_t *cf) { +/* The sidecar is {"":true,...}: one flat index across every cheat file of the + * title, in find_cheat_files() order — the same numbering the mod list and toggles use. */ +static int state_read(const char *title_id, char *buf, size_t cap) { char path[MAX_CHEAT_FILEPATH]; state_path(title_id, path, sizeof(path)); + buf[0] = '\0'; int fd = open(path, O_RDONLY); - if (fd < 0) return; - struct stat st; - if (fstat(fd, &st) != 0 || st.st_size > 4096) { close(fd); return; } - char buf[4096]; - ssize_t rd = read(fd, buf, sizeof(buf) - 1); + if (fd < 0) return -1; + ssize_t rd = read(fd, buf, cap - 1); close(fd); - if (rd <= 0) return; + if (rd <= 0) { buf[0] = '\0'; return -1; } buf[rd] = '\0'; - - /* Parse the flat index keys: {"0":true,"3":true, ...} - * These are flat indices across all files, but we only support - * one file at a time here, so they map directly to cf->mods[]. */ - for (int i = 0; i < cf->mod_count; i++) { - char key[16]; - snprintf(key, sizeof(key), "\"%d\"", i); - const char *p = strstr(buf, key); - if (p) { - p += strlen(key); - while (*p && *p != ':') p++; - if (*p == ':') { - p++; - while (*p == ' ' || *p == '\t' || *p == '\n') p++; - if (strncmp(p, "true", 4) == 0) { - cf->mods[i].enabled = 1; - } - } - } - } + return 0; } -/* Save enabled state to sidecar. */ -static void save_state(const char *title_id, cheat_file_t *files, - int n_files) { +static int state_has(const char *buf, int flat) { + char key[24]; + snprintf(key, sizeof(key), "\"%d\"", flat); + const char *p = strstr(buf, key); + if (!p) return 0; + p += strlen(key); + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == ':') p++; + return strncmp(p, "true", 4) == 0; +} + +/* Load enabled state from sidecar into one file's mods, whose first mod has flat index + * `flat_base`. */ +static void cheats_load_state(const char *title_id, cheat_file_t *cf, int flat_base) { + char buf[4096]; + if (state_read(title_id, buf, sizeof(buf)) != 0) return; + for (int i = 0; i < cf->mod_count; i++) + if (state_has(buf, flat_base + i)) cf->mods[i].enabled = 1; +} + +/* Record one mod's state, keeping every other mod's. (This used to rewrite the file from the + * one cheat file being toggled with per-file indices, so a toggle in a title's second file + * marked the first file's mods instead, and the watcher re-applied those on the next launch.) */ +static void state_set(const char *title_id, int flat, int on) { + char buf[4096]; + if (state_read(title_id, buf, sizeof(buf)) != 0) buf[0] = '\0'; + char path[MAX_CHEAT_FILEPATH]; state_path(title_id, path, sizeof(path)); - - char tmp[MAX_CHEAT_FILEPATH]; + char tmp[MAX_CHEAT_FILEPATH + 8]; snprintf(tmp, sizeof(tmp), "%s.tmp", path); - FILE *f = fopen(tmp, "w"); if (!f) return; - fprintf(f, "{"); int first = 1; - int flat = 0; - for (int fi = 0; fi < n_files; fi++) { - for (int mi = 0; mi < files[fi].mod_count; mi++) { - if (files[fi].mods[mi].enabled) { - if (!first) fprintf(f, ","); - fprintf(f, "\"%d\":true", flat); - first = 0; - } - flat++; - } + /* Keys are small: one per mod of the title (MAX_MODS_PER_FILE x 16 files at most). */ + for (int i = 0; i < MAX_MODS_PER_FILE * 16; i++) { + int en = (i == flat) ? on : state_has(buf, i); + if (!en) continue; + fprintf(f, "%s\"%d\":true", first ? "" : ",", i); + first = 0; } fprintf(f, "}"); - if (fclose(f) == 0) { rename(tmp, path); } else { @@ -1112,97 +1085,151 @@ static void save_state(const char *title_id, cheat_file_t *files, /* ── Core: apply a single mod ────────────────────────────────────── */ -static int apply_mod(pid_t pid, intptr_t base, cheat_mod_t *mod, - int turn_on, char *err, size_t err_cap) { +/* Apply or remove one cheat, all of its locations or none of them. + * + * 1. Resolve and parse every location and read what it holds now. A location that holds + * neither the cheat's bytes nor the original bytes means a different game build (or a + * clashing cheat), and the whole cheat is refused before anything is written. + * 2. Write in cheat_plan_order(): caves before the jumps into them when turning on, the + * reverse when turning off. The game is not stopped, so this order is what keeps it from + * running a jump whose target is still padding. + * 3. If a write fails, put back what was already written, newest first. + * + * Returns 0 when the cheat is in the asked-for state (including "it already was"). */ +static int apply_mod_locked(pid_t pid, intptr_t base, cheat_mod_t *mod, + int turn_on, char *err, size_t err_cap) { + if (mod->incomplete) { + if (err) snprintf(err, err_cap, + "cheat has more locations than the engine can hold; not applied"); + return -1; + } if (mod->mem_count == 0) { if (err) snprintf(err, err_cap, "mod has no memory entries"); return -1; } + /* No base yet means the module is not mapped: a relative offset would be used as an + * absolute address. */ + if (base <= 0) { + if (err) snprintf(err, err_cap, "the game's code is not loaded yet; try again"); + return -1; + } /* "button" type always writes "on" regardless of turn_on */ int write_on = (strcmp(mod->type, "button") == 0) ? 1 : turn_on; - int wrote = 0; - int skipped = 0; + int n = mod->mem_count; + cheat_loc_t *locs = (cheat_loc_t *)calloc((size_t)n, sizeof(cheat_loc_t)); + if (!locs) { + if (err) snprintf(err, err_cap, "out of memory"); + return -1; + } + int rc = 0; - for (int i = 0; i < mod->mem_count; i++) { + for (int i = 0; i < n && rc == 0; i++) { mem_entry_t *e = &mod->mem[i]; - if (!e->offset_str[0] || !e->on_str[0]) { - skipped++; - continue; + cheat_loc_t *l = &locs[i]; + uint64_t off = 0; + if (cheat_parse_offset(e->offset_str, &off) != 0) { + if (err) snprintf(err, err_cap, "location %d: bad offset \"%s\"", i + 1, e->offset_str); + rc = -1; + break; } + if (strlen(e->on_str) + 1 >= sizeof(e->on_str) || + strlen(e->off_str) + 1 >= sizeof(e->off_str)) { + if (err) snprintf(err, err_cap, "location %d: value too long for the engine", i + 1); + rc = -1; + break; + } + int on_len = cheat_parse_hex(e->on_str, l->on, CHEAT_PLAN_MAX_BYTES); + int off_len = cheat_parse_hex(e->off_str, l->off, CHEAT_PLAN_MAX_BYTES); + if (on_len <= 0 || off_len <= 0) { + if (err) snprintf(err, err_cap, + "location %d: needs both its cheat and original bytes", i + 1); + rc = -1; + break; + } + if (on_len != off_len) { + if (err) snprintf(err, err_cap, + "location %d: cheat and original bytes differ in length (%d vs %d)", + i + 1, on_len, off_len); + rc = -1; + break; + } + l->len = on_len; - intptr_t addr; if (e->absolute) { - addr = parse_offset(e->offset_str); + l->addr = (intptr_t)off; } else if (e->section > 0) { intptr_t sec_base = kernel_dynlib_mapbase_addr(pid, (uint32_t)e->section); - addr = sec_base + parse_offset(e->offset_str); - } else { - addr = base + parse_offset(e->offset_str); - } - - /* Determine which bytes to write */ - const char *write_hex = write_on ? e->on_str : e->off_str; - if (!write_hex[0]) write_hex = e->on_str; - - uint8_t write_bytes[MAX_HEX_BYTES]; - int wlen = parse_hex_bytes(write_hex, write_bytes, sizeof(write_bytes)); - if (wlen <= 0) { - skipped++; - continue; - } - - /* Baseline guard: read current bytes and verify they match - * either the "on" or "off" pattern. This prevents crashes from - * wrong-game-build cheats. */ - uint8_t current[MAX_HEX_BYTES]; - memset(current, 0, sizeof(current)); - - /* Parse on/off patterns for comparison */ - uint8_t on_bytes[MAX_HEX_BYTES], off_bytes[MAX_HEX_BYTES]; - int on_len = e->on_str[0] ? - parse_hex_bytes(e->on_str, on_bytes, sizeof(on_bytes)) : 0; - int off_len = e->off_str[0] ? - parse_hex_bytes(e->off_str, off_bytes, sizeof(off_bytes)) : 0; - - /* Read current bytes at the address */ - int read_ok = (read_process_memory(pid, addr, current, - (size_t)(on_len > 0 ? on_len : wlen)) == 0); - if (read_ok) { - int matches_on = (on_len > 0 && - memcmp(current, on_bytes, (size_t)on_len) == 0); - int matches_off = (off_len > 0 && - memcmp(current, off_bytes, (size_t)off_len) == 0); - if (!matches_on && !matches_off) { - /* Baseline mismatch — wrong game build or wrong base */ - skipped++; - continue; + if (sec_base <= 0) { + if (err) snprintf(err, err_cap, "location %d: module %d is not loaded", + i + 1, e->section); + rc = -1; + break; } - } - - /* Write the bytes */ - int rc = write_process_memory(pid, addr, write_bytes, (size_t)wlen); - if (rc == 0) { - wrote++; + l->addr = (intptr_t)((uint64_t)sec_base + off); } else { - /* Write failed — don't abort, try remaining entries */ - skipped++; + l->addr = (intptr_t)((uint64_t)base + off); + } + + if (game_mem_read(pid, l->addr, l->cur, (size_t)l->len) != 0) { + if (err) snprintf(err, err_cap, "location %d (0x%lx) cannot be read", + i + 1, (unsigned long)l->addr); + rc = -1; + break; + } + if (cheat_loc_state(l) == CHEAT_LOC_MISMATCH) { + if (err) snprintf(err, err_cap, + "cheat does not match this game build " + "(location %d at 0x%lx holds other bytes); nothing was written", + i + 1, (unsigned long)l->addr); + rc = -1; + break; } } - if (wrote > 0) { - return 0; + int order[MAX_MEM_ENTRIES]; + int written[MAX_MEM_ENTRIES]; + int n_written = 0; + if (rc == 0) { + cheat_plan_order(locs, n, write_on, order); + for (int k = 0; k < n; k++) { + cheat_loc_t *l = &locs[order[k]]; + const uint8_t *want = write_on ? l->on : l->off; + if (memcmp(l->cur, want, (size_t)l->len) == 0) continue; /* already there */ + int wrc = game_mem_write(pid, l->addr, want, (size_t)l->len); + if (wrc != 0) { + if (err) snprintf(err, err_cap, "write failed at 0x%lx (rc=%d)%s", + (unsigned long)l->addr, wrc, + n_written ? "; earlier writes were undone" : ""); + rc = -1; + break; + } + written[n_written++] = order[k]; + } } - if (skipped > 0 && err) { - snprintf(err, err_cap, - "cheat does not match this game build " - "(%d location(s) skipped)", skipped); - } else if (err) { - snprintf(err, err_cap, "no writable memory entries"); + if (rc != 0) { + for (int k = n_written - 1; k >= 0; k--) { + cheat_loc_t *l = &locs[written[k]]; + (void)game_mem_write(pid, l->addr, l->cur, (size_t)l->len); + } } - return -1; + + free(locs); + return rc; +} + +/* One apply at a time: the PC app, the console page and the watcher can all ask at once, and + * two applies of overlapping cheats must not interleave their reads and writes. */ +static pthread_mutex_t g_apply_lock = PTHREAD_MUTEX_INITIALIZER; + +static int apply_mod(pid_t pid, intptr_t base, cheat_mod_t *mod, + int turn_on, char *err, size_t err_cap) { + pthread_mutex_lock(&g_apply_lock); + int rc = apply_mod_locked(pid, base, mod, turn_on, err, err_cap); + pthread_mutex_unlock(&g_apply_lock); + return rc; } /* ── Background watcher thread ───────────────────────────────────── */ @@ -1246,22 +1273,20 @@ static int apply_patches_for_game(pid_t pid, intptr_t base, continue; } - if (pt_attach(pid) != 0) { - free(cf); - return total_writes; - } if (name_out && name_cap && !name_out[0] && is_usable_game_name(cf->game_name, title_id)) { snprintf(name_out, name_cap, "%s", cf->game_name); } for (int m = 0; m < cf->mod_count; m++) { - char err[128]; + char err[160]; /* Patches are always-on: turn_on = 1 */ - if (apply_mod(pid, base, &cf->mods[m], 1, err, sizeof(err)) == 0) { + intptr_t mod_base = base; + if (cf->mods[m].module_name[0]) + mod_base = resolve_module_base(pid, cf->mods[m].module_name); + if (mod_base && apply_mod(pid, mod_base, &cf->mods[m], 1, err, sizeof(err)) == 0) { total_writes++; } } - pt_detach(pid, 0); free(cf); } atomic_store(&g_patches_last, n); @@ -1277,40 +1302,36 @@ static int reapply_enabled_for_game(pid_t pid, intptr_t base, found_file_t files[16]; int n = find_cheat_files(title_id, files, 16, 0 /* cheats */); + int applied = 0; + int flat_base = 0; for (int i = 0; i < n; i++) { cheat_file_t *cf = (cheat_file_t *)malloc(sizeof(cheat_file_t)); if (!cf) continue; + /* A file that fails to load lists no mods, so it adds nothing to the flat numbering + * (cheats_list_mods skips it the same way). */ if (load_cheat_file(files[i].path, files[i].format, cf) != 0) { free(cf); continue; } - cheats_load_state(title_id, cf); + cheats_load_state(title_id, cf, flat_base); + flat_base += cf->mod_count; - int has_enabled = 0; for (int m = 0; m < cf->mod_count; m++) { - if (cf->mods[m].enabled) { has_enabled = 1; break; } - } - if (!has_enabled) { free(cf); continue; } - - if (pt_attach(pid) != 0) { free(cf); return 0; } - if (name_out && name_cap && !name_out[0] && - is_usable_game_name(cf->game_name, title_id)) { - snprintf(name_out, name_cap, "%s", cf->game_name); - } - int applied = 0; - for (int m = 0; m < cf->mod_count; m++) { - if (cf->mods[m].enabled) { - char err[128]; - if (apply_mod(pid, base, &cf->mods[m], 1, err, sizeof(err)) == 0) { - applied++; - } + if (!cf->mods[m].enabled) continue; + if (name_out && name_cap && !name_out[0] && + is_usable_game_name(cf->game_name, title_id)) { + snprintf(name_out, name_cap, "%s", cf->game_name); } + char err[160]; + intptr_t mod_base = base; + if (cf->mods[m].module_name[0]) + mod_base = resolve_module_base(pid, cf->mods[m].module_name); + if (mod_base && apply_mod(pid, mod_base, &cf->mods[m], 1, err, sizeof(err)) == 0) + applied++; } - pt_detach(pid, 0); free(cf); - return applied; /* Only process the first matching file */ } - return 0; + return applied; } static void *watcher_thread(void *arg) { @@ -1480,7 +1501,7 @@ int cheats_list_mods(const char *title_id, char *buf, size_t cap, free(cf); continue; } - cheats_load_state(title_id, cf); + cheats_load_state(title_id, cf, flat); for (int mi = 0; mi < cf->mod_count; mi++) { cheat_mod_t *m = &cf->mods[mi]; @@ -1593,36 +1614,23 @@ int cheats_toggle(const char *title_id, int mod_index, int turn_on, } } - /* Attach and apply. A failed attach is told apart, because the console page (webui_codec.c - * webui_cheats_toggle_error matches these texts; keep them in step) says each differently: - * - the game exited a moment ago (the 2 s running-game cache still had its pid): the same - * text as no game at all, and the cache is dropped so the next status read sees it gone; - * - the game is alive but another debugger is attached (EBUSY: the PC app toggling at the - * same moment, or another cheat tool): "is attached to the game"; - * - anything else: the plain "pt_attach failed (errno=N)". */ - if (pt_attach(pid) != 0) { - int e = errno; + /* The game may have exited a moment ago (the 2 s running-game cache still had its pid): say + * so with the same text as no game at all (webui_codec.c webui_cheats_toggle_error matches + * it), and drop the cache so the next status read sees it gone. */ + if (kill(pid, 0) != 0 && errno == ESRCH) { free(target_cf); invalidate_rg_cache(); - if (kill(pid, 0) != 0 && errno == ESRCH) { - if (err) snprintf(err, err_cap, "no game is currently running"); - } else if (e == EBUSY) { - if (err) snprintf(err, err_cap, "pt_attach failed: another debugger or cheat tool is attached to the game (errno=%d)", e); - } else if (err) { - snprintf(err, err_cap, "pt_attach failed (errno=%d)", e); - } + if (err) snprintf(err, err_cap, "no game is currently running"); return -1; } int rc = apply_mod(pid, mod_base, mod, turn_on, err, err_cap); - pt_detach(pid, 0); - if (rc == 0) { /* Update persisted state (only for checkbox type) */ if (strcmp(mod->type, "button") != 0) { mod->enabled = turn_on; - save_state(title_id, target_cf, 1); + state_set(title_id, mod_index, turn_on); } /* Confirm on the console. diff --git a/payload/src/cheats_plan.c b/payload/src/cheats_plan.c new file mode 100644 index 00000000..dcc7a025 --- /dev/null +++ b/payload/src/cheats_plan.c @@ -0,0 +1,95 @@ +/* cheats_plan.c — see cheats_plan.h. Pure C, no console calls. */ + +#include "cheats_plan.h" + +#include +#include +#include + +static int hex_val(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; +} + +static int is_sep(char c) { + return c == ' ' || c == ',' || c == '-' || c == ':' || c == '\t' || c == '\n' || c == '\r'; +} + +int cheat_parse_hex(const char *s, uint8_t *out, int max) { + if (!s || !out || max <= 0) return -1; + int n = 0; + int hi = -1; + for (const char *p = s; *p; p++) { + if (is_sep(*p)) continue; + int v = hex_val(*p); + if (v < 0) return -1; + if (hi < 0) { + hi = v; + } else { + if (n >= max) return -1; + out[n++] = (uint8_t)(hi * 16 + v); + hi = -1; + } + } + if (hi >= 0) { + if (n >= max) return -1; + out[n++] = (uint8_t)hi; + } + return n > 0 ? n : -1; +} + +int cheat_parse_offset(const char *s, uint64_t *out) { + if (!s || !out) return -1; + while (*s == ' ' || *s == '\t') s++; + if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s += 2; + if (!*s) return -1; + const char *p = s; + while (hex_val(*p) >= 0) p++; + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; + if (*p) return -1; + errno = 0; + unsigned long long v = strtoull(s, NULL, 16); + if (errno != 0) return -1; + *out = (uint64_t)v; + return 0; +} + +int cheat_loc_state(const cheat_loc_t *l) { + size_t n = (size_t)l->len; + if (memcmp(l->cur, l->on, n) == 0) return CHEAT_LOC_ON; + if (memcmp(l->cur, l->off, n) == 0) return CHEAT_LOC_OFF; + return CHEAT_LOC_MISMATCH; +} + +int cheat_loc_is_cave(const cheat_loc_t *l) { + if (l->len <= 0) return 0; + uint8_t first = l->off[0]; + if (first != 0xCC && first != 0x00) return 0; + for (int i = 1; i < l->len; i++) + if (l->off[i] != first) return 0; + return 1; +} + +void cheat_plan_order(const cheat_loc_t *locs, int n, int turn_on, int *order) { + int k = 0; + for (int i = 0; i < n; i++) + if (cheat_loc_is_cave(&locs[i])) order[k++] = i; + for (int i = 0; i < n; i++) + if (!cheat_loc_is_cave(&locs[i])) order[k++] = i; + if (!turn_on) { + for (int i = 0, j = n - 1; i < j; i++, j--) { + int t = order[i]; + order[i] = order[j]; + order[j] = t; + } + } +} + +int cheat_addr_ok(intptr_t addr, int len) { + if (len <= 0) return 0; + uint64_t a = (uint64_t)addr; + uint64_t end = a + (uint64_t)len; + return a >= 0x10000ULL && end > a && end <= 0x800000000000ULL; +} diff --git a/payload/tests/cheats_plan_selftest.c b/payload/tests/cheats_plan_selftest.c new file mode 100644 index 00000000..ec6cdb37 --- /dev/null +++ b/payload/tests/cheats_plan_selftest.c @@ -0,0 +1,176 @@ +/* Host-side tests for cheats_plan.c: parsing a cheat's locations, telling which state each is + * in, and the order the writes go in. The data is the real Marvel's Wolverine (PPSA03671, + * 01.001.005) cheat pack the app downloads, whose cheats crashed the console with the old + * engine (fix/cheats-wolverine). */ +#include +#include + +#include "../src/cheats_plan.c" + +static int failures = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \ + failures++; \ + } \ + } while (0) + +typedef struct { const char *off, *on, *orig; } raw_t; + +/* "Max Resources / XP" from PPSA03671_01.001.005_50d8b46b.json, in file order: 16 code caves + * (original bytes are int3 padding) and 4 hooks that jump into them. */ +static const raw_t MAX_RES[] = { + {"05CC0A91", "4C8B184D8B5B30E9E4060000", "CCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC1181", "4D85DB0F84D72D0000E932060000", "CCCCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC17C1", "450FB65370E9F6050000", "CCCCCCCCCCCCCCCCCCCC"}, + {"05CC1DC1", "4584D20F8497210000E942010000", "CCCCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC1F11", "837808000F8E46200000E9111E0000", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC3D31", "C740089F860100C3", "CCCCCCCCCCCCCCCC"}, + {"05CC3F61", "C3", "CC"}, + {"05CC4091", "E8FBC9FFFF8B4808E97D7F0100", "CCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CDC014", "E97880FEFF", "8B4808EB02"}, + {"05CC5041", "E84BBAFFFF8B4808E99D700100", "CCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CDC0E4", "E9588FFEFF", "8B4808EB02"}, + {"05CC50A1", "E8EBB9FFFF8B7008E91E710100", "CCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CDC1C5", "E9D78EFEFF", "8B7008EB02"}, + {"05CC7A81", "4C8B5B30E907000000", "CCCCCCCCCCCCCCCCCC"}, + {"05CC7A91", "4D85DB0F84570F0000E922020000", "CCCCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC7CC1", "450FB65370E926030000", "CCCCCCCCCCCCCCCCCCCC"}, + {"05CC7FF1", "4584D20F84F7090000E952000000", "CCCCCCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC8051", "41BD9F860100E995090000", "CCCCCCCCCCCCCCCCCCCCCC"}, + {"05CC89F1", "4489EB45896F08E9F73F0100", "CCCCCCCCCCCCCCCCCCCCCCCC"}, + {"05CDC9ED", "E98FB0FEFF9090", "4489EB45896F08"}, +}; +#define N_MAX_RES ((int)(sizeof MAX_RES / sizeof MAX_RES[0])) + +/* "Infinite Health" from PPSA03671_01.001.005_d02e93c8.mc4: the hook comes SECOND in the file, + * the cave is zero padding at the end of .text, and the values use dash separators. */ +static const raw_t MC4_HEALTH[] = { + {"4803568", "E9-D3-04-72-01-90-90-90", "C5-FA-11-83-D0-00-00-00"}, + {"5F23A40", + "80-BB-4C-01-00-00-00-75-0F-C7-83-D0-00-00-00-00-00-96-43-E9-18-FB-8D-FE-C5-FA-11-83-D0-00-00-00-E9-0B-FB-8D-FE", + "00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00"}, +}; + +static int build(const raw_t *raw, int n, intptr_t base, cheat_loc_t *locs) { + for (int i = 0; i < n; i++) { + uint64_t off = 0; + if (cheat_parse_offset(raw[i].off, &off) != 0) return -1; + int a = cheat_parse_hex(raw[i].on, locs[i].on, CHEAT_PLAN_MAX_BYTES); + int b = cheat_parse_hex(raw[i].orig, locs[i].off, CHEAT_PLAN_MAX_BYTES); + if (a <= 0 || a != b) return -1; + locs[i].len = a; + locs[i].addr = base + (intptr_t)off; + memcpy(locs[i].cur, locs[i].off, (size_t)a); /* the game as it boots */ + } + return 0; +} + +/* Position of location `idx` in a write order. */ +static int pos_of(const int *order, int n, int idx) { + for (int k = 0; k < n; k++) + if (order[k] == idx) return k; + return -1; +} + +static void test_parse(void) { + uint8_t b[8]; + CHECK(cheat_parse_hex("90 1F-00,00", b, 8) == 4 && b[0] == 0x90 && b[1] == 0x1F && b[3] == 0); + CHECK(cheat_parse_hex("C3", b, 8) == 1 && b[0] == 0xC3); + CHECK(cheat_parse_hex("ABC", b, 8) == 2 && b[0] == 0xAB && b[1] == 0x0C); + CHECK(cheat_parse_hex("", b, 8) == -1); + CHECK(cheat_parse_hex("9G", b, 8) == -1); + CHECK(cheat_parse_hex("0102030405", b, 4) == -1); /* longer than the buffer: refused, not cut */ + + uint64_t o = 0; + CHECK(cheat_parse_offset("01BAD908", &o) == 0 && o == 0x1BAD908); + CHECK(cheat_parse_offset("0x1bad908", &o) == 0 && o == 0x1BAD908); + CHECK(cheat_parse_offset(" 5F23A40\n", &o) == 0 && o == 0x5F23A40); + CHECK(cheat_parse_offset("5C0-executable[0]-5", &o) == -1); /* MC4 form */ + CHECK(cheat_parse_offset("", &o) == -1); +} + +static void test_state(void) { + cheat_loc_t l[2]; + /* God Mode, the JSON pack: base 0x400000 measured on the Pro (FW 13.60). */ + static const raw_t god[] = { + {"01BAD908", "909090909090909090", "C4E2719D8FF0010000"}, + {"01BAD911", "C5F828C1", "C5F25DC0"}, + }; + CHECK(build(god, 2, 0x400000, l) == 0); + CHECK(l[0].addr == 0x1FAD908 && l[0].len == 9 && l[1].len == 4); + CHECK(cheat_loc_state(&l[0]) == CHEAT_LOC_OFF); + memcpy(l[0].cur, l[0].on, 9); + CHECK(cheat_loc_state(&l[0]) == CHEAT_LOC_ON); + l[0].cur[3] ^= 0xFF; /* another build, or another cheat's jump already there */ + CHECK(cheat_loc_state(&l[0]) == CHEAT_LOC_MISMATCH); + CHECK(!cheat_loc_is_cave(&l[0]) && !cheat_loc_is_cave(&l[1])); +} + +static void test_order_max_resources(void) { + cheat_loc_t locs[N_MAX_RES]; + CHECK(build(MAX_RES, N_MAX_RES, 0x400000, locs) == 0); + int hooks[] = {8, 10, 12, 19}; + int caves = 0; + for (int i = 0; i < N_MAX_RES; i++) caves += cheat_loc_is_cave(&locs[i]); + CHECK(caves == 16); + for (int h = 0; h < 4; h++) CHECK(!cheat_loc_is_cave(&locs[hooks[h]])); + + int on[N_MAX_RES], off[N_MAX_RES]; + cheat_plan_order(locs, N_MAX_RES, 1, on); + cheat_plan_order(locs, N_MAX_RES, 0, off); + /* Every location exactly once. */ + int seen[N_MAX_RES] = {0}; + for (int k = 0; k < N_MAX_RES; k++) seen[on[k]]++; + for (int i = 0; i < N_MAX_RES; i++) CHECK(seen[i] == 1); + /* Turning on: every cave is written before any hook. In file order, the hook at + * 05CDC014 (index 8) came before the cave at 05CC5041 (index 9). */ + for (int h = 0; h < 4; h++) + for (int i = 0; i < N_MAX_RES; i++) + if (cheat_loc_is_cave(&locs[i])) CHECK(pos_of(on, N_MAX_RES, i) < pos_of(on, N_MAX_RES, hooks[h])); + /* Turning off: every hook is restored before any cave goes back to int3. In file order + * the cave at 05CC4091 (index 7) was cleared while the hook into it (index 8) was live. */ + for (int h = 0; h < 4; h++) + for (int i = 0; i < N_MAX_RES; i++) + if (cheat_loc_is_cave(&locs[i])) CHECK(pos_of(off, N_MAX_RES, hooks[h]) < pos_of(off, N_MAX_RES, i)); + for (int k = 0; k < N_MAX_RES; k++) CHECK(off[k] == on[N_MAX_RES - 1 - k]); +} + +static void test_order_mc4_zero_cave(void) { + cheat_loc_t locs[2]; + CHECK(build(MC4_HEALTH, 2, 0x400000, locs) == 0); + CHECK(locs[0].len == 8 && locs[1].len == 37); + CHECK(!cheat_loc_is_cave(&locs[0]) && cheat_loc_is_cave(&locs[1])); + int on[2], off[2]; + cheat_plan_order(locs, 2, 1, on); + cheat_plan_order(locs, 2, 0, off); + CHECK(on[0] == 1 && on[1] == 0); /* cave, then the jump into it */ + CHECK(off[0] == 0 && off[1] == 1); /* jump restored, then the cave cleared */ + /* The cave ends inside .text: 0x400000 + 0x5F23A40 + 37 < 0x6324000 (measured end). */ + CHECK(locs[1].addr + locs[1].len <= 0x6324000); +} + +static void test_addr_ok(void) { + CHECK(cheat_addr_ok(0x1FAD908, 9)); + CHECK(!cheat_addr_ok(0, 4)); + CHECK(!cheat_addr_ok(0x1BAD908 - 0x1BAD908 + 0x100, 4)); /* null page */ + CHECK(!cheat_addr_ok((intptr_t)0xFFFFF04080F57368ULL, 4)); /* kernel address */ + CHECK(!cheat_addr_ok(0x7FFFFFFFFFFE, 4)); /* crosses the user limit */ + CHECK(!cheat_addr_ok(0x400000, 0)); +} + +int main(void) { + test_parse(); + test_state(); + test_order_max_resources(); + test_order_mc4_zero_cave(); + test_addr_ok(); + if (failures) { + fprintf(stderr, "cheats_plan_selftest: %d failure(s)\n", failures); + return 1; + } + printf("cheats_plan_selftest: ok\n"); + return 0; +}