Files
2026-10-12 01:39:58 -07:00
..
2026-10-12 01:39:58 -07:00
2026-10-12 01:39:58 -07:00

On-console payload

cheatspecter.elf runs on the PS5 and removes the computer from the picture. It serves the web app and bridges it to the debugger, so any phone or laptop on the network just opens http://<PS5 IP>:8744.

Status. A 0.3.0 build has been used on a real PS5 for the debugger relay (/ws), the game library and saved cheat files. The cheat engine, the settings files and the rules for sharing the debugger with a browser have only run on a computer: the same source is compiled natively and exercised against the mock console (test/payload.test.mjs, test/engine.test.mjs, test/prefs.test.mjs). The PS5 build of the current source compiles and links; it has not been loaded on a console.

Use it

Send cheatspecter-v<version>.elf if you use a payload manager: it reads the version from the file name and shows it next to the payload. cheatspecter.elf is the same build under a fixed name, for scripts.

  1. Load ps5debug-NG on the console as usual.

  2. Send payload/cheatspecter.elf to your ELF loader, the same way you load any other payload. From this folder:

    npm run payload:send -- 192.168.1.20        # loader port 9021 by default
    
  3. A notification on the TV shows the address to open.

It has to be sent again after each reboot, like any payload. Sending it twice is harmless: the second copy sees the port is taken, says so, and exits.

What it does

Request Behavior
GET /, /assets/*, /ghost.svg The web app, embedded in the ELF and served gzip-compressed.
GET /api/info {"mode":"console","version":"..."}. The app sees this and skips the "find your PS5" screen.
WS /ws Opens a TCP connection to 127.0.0.1:744 and relays binary frames both ways, unchanged. One WebSocket is one TCP connection.
GET /api/library Installed titles: {"titles":[{"id","meta":"json"|"sfo","size","path"}]}. meta is which metadata file the title has (param.json = PS5, param.sfo = PS4).
GET /api/appmeta/<title id>/<param.json|param.sfo|icon0.png> The system's own metadata file for a title, untouched. The web app reads the name from it.
POST /api/launch/<title id> Closes the running game, if any, and starts this one.
GET /api/tables/<title id> Which game versions have a saved cheat file.
GET, PUT /api/tables/<title id>/<version> The user's cheats for that game version, stored as /data/cheatspecter/cheats/<title id>-<version>.cspector.
DELETE /api/tables/<title id>/<version> Deletes that one file (204; 404 when there is none). Only an ordinary .cspector file in the cheats folder, named from a checked title ID and version; asked for from the page's own origin.
GET /api/console-cheats/<title id> Cheat files other tools keep for that title: {"files":[{"path","folder","name","size"}]}, from /data/etaHEN/cheats, /data/elf-arsenal/cheats and /data/GoldHEN/cheats, each read itself and in its json, shn and mc4 subfolders. Read-only.
GET /api/console-cheats/<title id>/<path> One of those files as it is, path as the list gives it. Only names that start with the title ID and end in .json, .shn or .mc4, only ordinary files (not links), nothing outside those folders.
WS /events Debugger stop reports. The payload listens on 127.0.0.1:755, where the debugger connects when something attaches, and forwards what arrives.
GET /api/engine What the cheat engine is keeping on, and how each cheat is doing (see below).
POST /api/engine/reload Has the engine read the running game's cheat file again now. Answers 204 at once.
GET /api/prefs/<name> A small settings file the web app saved: 200 with the JSON, or 404 when there is none.
PUT /api/prefs/<name> Saves one (a JSON body of at most 64 KB) as /data/cheatspecter/prefs/<name>.json. Answers 204.

Apart from the cheat engine it is deliberately thin. All other protocol knowledge and file parsing stay in the web app, and ps5debug-NG keeps doing the kernel work.

WebSocket upgrades, launches, file saves, settings saves and engine reloads are refused when the Origin host differs from Host, so another web page cannot drive the console through a visitor's browser. Title IDs, versions and settings names are checked against a strict pattern before they are used in a path.

Settings exist because what a browser stores belongs to the page's address, and a console's address can change with every restart. A name is 1 to 32 lower-case letters, digits and dashes; anything else (capitals, dots, slashes, escapes) is answered 400 and touches no file. The body must be JSON (nested at most 32 deep), or it is answered 400; more than 64 KB is answered 413. The web app's side is src/lib/prefs.ts.

A connection that has not sent its request within 15 seconds is closed. An open WebSocket may stay quiet for as long as it likes.

The library is built from the system's metadata folders (/system_data/priv/appmeta, /user/appmeta). Sizes are the files found under /user/app, /user/patch, the same folders on /mnt/ext0 and /mnt/ext1, and /system_ex/app, where game folders kept elsewhere are mounted. A title with none of those is listed without a size; one with more than 200,000 files is marked partial.

Cheats that stay on by themselves

Status: passes its tests against the mock console on a computer (test/engine.test.mjs); not yet run on a real PS5.

A cheat in a .cspector file can carry "keep": true. The payload keeps those applied with no browser open: it has its own thread and its own connection to the debugger on 127.0.0.1:744.

This section is the contract between the engine (src/engine.c) and the web app (src/lib/table.ts, keep.ts, proof.ts). Both follow it, and test/engine.test.mjs checks each rule against both.

Which file

Every 2 seconds the engine asks the debugger which game is running. For a new game, or the same game under a new process id, it forgets everything and reads /data/cheatspecter/cheats/<title id>-<version>.cspector, the version being the running game's as it appears in file names (fileVersion() in tablestore.ts). With no file for the running version it takes the highest-versioned file for that title, as the web app does when it opens one.

The file counts as this version's own only when its name matches the running game and what it says inside agrees: game.titleId and game.version, where present, must be the running game's. A file that was renamed or copied from another version, or from another game, is used with the same care as an older version's file. GET /api/engine reports this as "exact".

What is run

Only entries with "keep": true (exactly true) are looked at.

Entry Run?
patch, every place (more included) at a module offset, a byte pattern or a pointer path Yes
value with "freeze": true, at a module offset, a byte pattern or a pointer path Yes: written again every 250 ms
value without "freeze": true Refused
inject, call, script Refused
Anything with a place at a raw address ("kind": "address") Refused
Anything with "reviewed": false Refused
Anything with "pageOnly": true (a community cheat only the page can place) Refused
No name, or a type the web app does not know Passed over, as the web app passes over it on loading

These are the rules of keepProblem() in table.ts, in the same order, so what the engine refuses is exactly what the web app does not let be kept. A refused cheat is listed with state refused and the reason; it is never tried.

Places. A module offset counts from the named module (found by its name in the memory map, ignoring case), or from the game's main executable when no module is named or the name is one the web app takes to mean the game (game, base, eboot, eboot.bin, executable, or the process's own name). A byte pattern must match exactly once in the game's executable memory. A pointer path is followed afresh on every write.

fallback. A pattern made by the web app carries the fixed position it was made at. The engine first looks right there (one small read); if the pattern is there, that settles it without a search, as in the web app. If not, it searches the game's code once. If the pattern is then not found, or found more than once, the fixed position is used, but only for a place whose original bytes are known (below): a place with nothing to compare against is not written through a fallback. Only a fallback of kind module is used.

What must be there before anything is written. Every place of a cheat is read first. Bytes equal to on mean that place is already on. Otherwise:

  • With off in the file, the bytes must equal off (and off may not be longer than on).
  • Without off but with around, the stretch of the saved code that the patch covers is used as off; bytes marked ? in its mask (they depend on where the game is loaded) are not compared. The code before and after the patch is not compared, because another kept cheat may have changed it.
  • With neither, the place is written unchecked, but only in a file that is this version's own, and never through a fallback.

If any place fails, nothing of that cheat is written and it is reported as mismatch. Code that reads as all zeros is given 20 seconds to load first. The engine never writes when the bytes found are neither on nor the expected original.

Another version's file. When the file in use is not this version's own, a cheat is only run if it can be checked here: every place needs off bytes, around, or a byte pattern. Held values and unchecked patches are reported as mismatch instead of being written blindly into a different build.

Held values and choices. A held value is encoded exactly as the web app encodes it (encodeValue() in scanner.ts); one it would refuse is an error. The engine holds whatever value says. choices is the web app's list to pick from: picking one saves the file with the new value, which the engine picks up without undoing or announcing anything.

Not there yet. A module still loading, a pointer still null, code not loaded: tried again later, waiting longer each time (waiting). A pattern search is tried five times over about 22 seconds, then given up (error).

The start set

A file can also have a startSet: the cheats to switch on whenever the game starts, by name, each {"name": "..."} for one of the person's own cheats or {"name": "...", "from": "<source id>"} for one saved from a cheat table (the file's sources, each with an id and its own cheats). The first cheat of that name in that list is meant. Unlike keep, the start set is not held against the person: they can switch any of it off as usual.

The engine treats a start set cheat like a kept one, with three differences:

  • It runs only what it could keep on: anything it would refuse is left to the web app, which switches it on when it opens on the game (code cheats, scripts, cheats only the page can place), and it is not listed.
  • A held value is written once, when it can be, and then left alone (detail says so); holding it is the page's job while it is open.
  • When the start set changes, a cheat that leaves it stays as it is in the game: the start set only says what to switch on at the start.

Start set cheats are listed with "start": true. startsOnPs5() in table.ts makes the same choice on the web app's side. A cheat that is both kept and in the start set is simply kept.

Changes while the game runs

When the file changes (saved through PUT /api/tables/..., changed some other way and noticed within 2 seconds, or POST /api/engine/reload) it is read again without restarting anything. A cheat that is the same as before keeps its state, whatever its name. A cheat that is no longer kept has its original bytes put back, if the engine wrote them and they are still as the engine left them; a held value simply stops being written. A file that cannot be read changes nothing: what is on stays on.

A new launch of the game (a new process id) starts from nothing and applies the file again. When the game closes, nothing more is written.

Sharing the debugger with a browser

The engine uses only the debugger's plain memory commands (read, write, memory map, pattern search, "which game is running"). It never attaches, so the console's one debugging session is always free for the browser, and the engine goes on working while the browser has the game paused at a breakpoint.

The payload relays every browser connection, so it can tell when a browser is in the middle of something long with the debugger: a request unanswered for more than 400 ms, or a long answer still arriving (a memory search). While that is so the engine starts no pattern search of its own (those cheats say "Waiting for a search in CheatSpecter to finish"), and does not take a slow answer to mean the debugger has gone. Held values are still written: that is one small exchange. GET /api/engine reports "busy": true meanwhile.

Crash protection

A cheat switched on less than a minute before the game ended may be why it ended. The engine notices every game ending (the game gone, or a new launch of it), page open or not, and marks such cheats in /data/cheatspecter/prefs/crash-<title id, lower case>.json, a settings file the web app reads and writes through /api/prefs too:

{"marks":[{"name":"Infinite ammo","at":1760190000000,"by":"ps5","version":"01.020.000"}],
 "armed":[{"name":"God mode","pid":137,"at":1760189990000}]}

marks are the cheats held back: at is when the game ended (ms since 1970), by what switched the cheat on (ps5: the engine kept it on; page: it was switched on in the web app). armed is the web app's list of cheats it switched on in the launch with that process id; the engine counts each from when it first saw it listed (it checks the file every 2 seconds), and ignores what was listed before it saw the launch start. A game that ends more than a minute after its cheats went on marks nothing.

A kept cheat with a mark is not switched on: it is listed as held, with a detail saying why, until the mark is gone. The web app takes a mark away when the person switches that cheat on by hand; the engine then carries on with it as with any kept cheat. src/lib/crashguard.ts is the web app's side.

Telling the user

One notification per game launch, or per change to which cheats are kept: "CheatSpecter: 2 cheats on for Ready or Not", with further lines when some did not fit, could not be switched on (refused ones count there) or were held back by crash protection. It waits up to 30 seconds for every cheat to settle; cheats that only become possible later (a pointer path that needs a level loaded) get one more.

GET /api/engine

{"running":true,
 "crashGuard":true,
 "game":{"titleId":"PPSA18605","version":"01.001.008","pid":137},
 "file":"PPSA18605-01.001.008.cspector",
 "exact":true,
 "cheats":[{"name":"Infinite ammo","state":"on","detail":""}]}
  • running is false when the debugger does not answer (not loaded, or not ps5debug-NG); then game is null and cheats is empty.
  • game is null when no game is running.
  • file is the cheat file in use, "" when there is none.
  • exact is present whenever a file is in use: whether it is this version's own (see "Which file").
  • busy is present (and true) only while the engine is holding back for a browser.
  • crashGuard is always true: this engine does crash protection, so the web app leaves the marking to it. (The bridge's demo stand-in leaves it out, and the web app marks by itself there.)
  • error is present, with a sentence for the user, when the file could not be used or holds more kept cheats than the engine takes (256).
  • cheats lists every entry marked to keep and every start set cheat the engine runs (see "The start set"; those carry "start": true): the person's own in the file's order, then those from sources. state is on, waiting, mismatch, error, refused or held, and detail says why in a sentence for anything that is not on.

The web app matches a cheat to its line by name; cheats with the same name are told apart by their order, not counting refused ones (keptStatus() in keep.ts).

The dev bridge passes both engine requests on to the console. Its demo console has no engine, so the bridge answers GET /api/engine itself in the same shape (demoEngineStatus() in bridge/server.mjs): the kept cheats of the file the app saved, refused ones by the same rules, the rest as waiting with a detail saying the demo does not apply them.

Limits: 256 kept cheats per game, 64 places per patch, 4096 bytes per place, 256 bytes per pattern, 16 offsets per pointer path, files up to 4 MB.

Build it

npm run payload:host     # for this computer; then: CS_PORT=8745 CS_DEBUGGER_PORT=7440 payload/build/cheatspecter-host
PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk npm run payload     # cheatspecter.elf

Both embed whatever npm run build produced, so the ELF needs rebuilding after any change to the web app. The PS5 build needs ps5-payload-sdk with clang and lld. With CS_PAYLOAD_OUT=<folder> the PS5 build is written to that folder and the ELFs here are left alone, which is the way to check that a change still compiles and links for the console.

src/main.c is the server: HTTP, a WebSocket relay with one thread per direction, and SHA-1 and Base64 for the handshake. The cheat engine is src/engine.c, on top of src/dbgclient.c (the few debugger commands it needs, spoken from C) and src/json.c (a small strict JSON reader).

The threads: one accepts connections and starts one per request (two for a /ws relay, one per direction); one is the cheat engine; one listens for debugger stop reports. The engine wakes every 50 ms, shares nothing with the others but a status snapshot and a "read the file again" flag behind one lock held only to copy them, and has its own debugger connection with a time limit on every exchange. So a stuck engine cannot hold up a request, and a busy server cannot hold up the engine.

On a computer, CS_ROOT stands in for the console's disk, CS_ENGINE_SPEED=10 makes every wait of the engine ten times shorter (which is how the tests get through a "30 second" wait in three), and CS_IDLE_SECONDS sets how long a silent connection is kept.