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.
-
Load ps5debug-NG on the console as usual.
-
Send
payload/cheatspecter.elfto 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 -
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
offin the file, the bytes must equaloff(andoffmay not be longer thanon). - Without
offbut witharound, the stretch of the saved code that the patch covers is used asoff; 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
(
detailsays 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":""}]}
runningis false when the debugger does not answer (not loaded, or not ps5debug-NG); thengameis null andcheatsis empty.gameis null when no game is running.fileis the cheat file in use,""when there is none.exactis present whenever a file is in use: whether it is this version's own (see "Which file").busyis present (andtrue) only while the engine is holding back for a browser.crashGuardis alwaystrue: 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.)erroris present, with a sentence for the user, when the file could not be used or holds more kept cheats than the engine takes (256).cheatslists 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 fromsources.stateison,waiting,mismatch,error,refusedorheld, anddetailsays 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.