mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-18 05:35:16 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bda44ebec |
No files matched your search
+132
@@ -1,5 +1,137 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.3
|
||||
|
||||
Everything new in this version passes the automated tests against the
|
||||
simulated console. **None of it has been tried on a real PS5 yet**, so every
|
||||
new feature below is marked **Beta (not yet tried on a real PS5)**.
|
||||
|
||||
### Cheat files
|
||||
- **Beta (not yet tried on a real PS5).** The game's cheat file on the PS5
|
||||
opens first. The online collections are no longer waited for, so the
|
||||
switches are ready even when GitHub is slow or out of reach.
|
||||
- **Beta (not yet tried on a real PS5).** A community table made for exactly
|
||||
the running game's title ID and version is converted and saved into the
|
||||
game's `.cspector` file on the PS5, next to your own cheats, and opens from
|
||||
there next time with no download. This happens once per game version, not
|
||||
again after you delete the file in the same visit, and not while the cheats
|
||||
showing came from another version's file (a **Save it into** button,
|
||||
naming the file, is offered instead). Saved tables look and work exactly
|
||||
like downloaded ones.
|
||||
- **Beta (not yet tried on a real PS5).** **Check for updates** reads a saved
|
||||
table's source again and lists the new, changed and removed cheats before
|
||||
replacing it; your own cheats are never touched. A quiet check in the
|
||||
background shows **Review update** when something changed.
|
||||
- **Beta (not yet tried on a real PS5).** A bar above **Your cheats** shows
|
||||
the file's name, whether it is saved, saving, not on the PS5 yet, or failed
|
||||
(with **Try again**), how many cheats it holds, and the start set.
|
||||
- **Beta (not yet tried on a real PS5).** **Save** now downloads the game's
|
||||
`.cspector` file to the phone or computer you are using: your own cheats,
|
||||
the tables saved for the game and the start set. Saving on the PS5 happens
|
||||
by itself as you make changes, so **Save to PS5** is gone. Exporting for
|
||||
GoldHEN / etaHEN and **Re-find my cheats after a game update…** moved to
|
||||
the **…** menu of **Your cheats**.
|
||||
- **Beta (not yet tried on a real PS5).** **… → Delete from PS5…** deletes
|
||||
the game's cheat file for this version, after a confirmation that names the
|
||||
file and says what is lost (Cancel has the focus). Cheats this page has on
|
||||
are switched off first. The payload only deletes `.cspector` files in its
|
||||
own cheats folder.
|
||||
- **Beta (not yet tried on a real PS5).** **On this PS5**: cheat files that
|
||||
etaHEN, elf-arsenal or GoldHEN keep on the console
|
||||
(`/data/etaHEN/cheats`, `/data/elf-arsenal/cheats`, `/data/GoldHEN/cheats`
|
||||
and their `json`, `shn` and `mc4` folders), named with the game's title ID,
|
||||
are listed as a source and read without being changed. One for the exact
|
||||
version is saved into the game's file like an online table.
|
||||
- **Beta (not yet tried on a real PS5).** The start set: **… → Save what's on
|
||||
as this game's start set** and **Clear start set**. Each time the game
|
||||
starts, the PS5 switches on what it can by itself (patches; a held value is
|
||||
set once, not held), and the page switches on the rest (code cheats,
|
||||
scripts) when it opens on the game, once per launch, and says what
|
||||
happened. **Keep on** stays a separate setting on each cheat: the start set
|
||||
switches cheats on at the start, Keep on holds a cheat on the whole time.
|
||||
- **Beta (not yet tried on a real PS5).** Deleting one of your cheats asks
|
||||
first, and says what goes with it.
|
||||
- The file format stays at `formatVersion` 1. The new fields (`sources`,
|
||||
`startSet`, and `origin` and `pageOnly` on a cheat) are optional, and 0.4.2
|
||||
reads files that have them.
|
||||
|
||||
### Game patches
|
||||
- **Beta (not yet tried on a real PS5).** A **Game patches** card on the
|
||||
Cheats tab offers the patches from
|
||||
[PS4-PS5-Game-Patch](https://github.com/illusion0001/PS4-PS5-Game-Patch)
|
||||
(frame rate, resolution, quality modes and the like) as switches, each with
|
||||
**Add to your cheats**. Only patches for exactly the running version are
|
||||
offered, or ones made to work on any version. Patches CheatSpecter cannot
|
||||
apply safely are shown dimmed with the reason, and a patch that is already
|
||||
in the game (most likely applied by etaHEN) is shown as on and locked.
|
||||
- **Beta (not yet tried on a real PS5).** The patches for your exact version
|
||||
are saved into the game's cheat file on the PS5, next to your own cheats,
|
||||
and open from there with no download. **Check for updates** and **Review
|
||||
update** work as for saved cheat tables. Game patches can be part of the
|
||||
start set.
|
||||
|
||||
### Crash protection
|
||||
- **Beta (not yet tried on a real PS5).** A cheat switched on (by the page
|
||||
or by the PS5) less than a minute before the game closed is marked "May
|
||||
have crashed the game" on its row. It is not switched on by itself again
|
||||
(by Keep on, or by the start set on the PS5 or in the page) until you
|
||||
switch it on by hand, which clears the mark. The page and the TV
|
||||
notification say when one was held back.
|
||||
Closing the game yourself within a minute of switching a cheat on also
|
||||
marks it.
|
||||
|
||||
### Controller shortcut
|
||||
- **Beta (not yet tried on a real PS5).** Off until you switch it on in
|
||||
Settings. Holding L2 + R3 (or 2 to 4 buttons you pick) for 0.8 seconds (or
|
||||
a time you pick) opens the PS5's web browser on the running game's Cheats
|
||||
screen, or on the start page with no game running. Settings warns that a
|
||||
similar tool has been reported to crash the home screen on some system
|
||||
software; if the PS5 stops while the shortcut is starting, it stays paused
|
||||
until you choose **Try again**.
|
||||
|
||||
### Settings and diagnostics
|
||||
- **Beta (not yet tried on a real PS5).** A **Settings** screen, from the
|
||||
gear button at the top of every screen, with the controller shortcut,
|
||||
**Diagnostics**, and **Shut down CheatSpecter on the PS5** (with a
|
||||
confirmation, Cancel focused). On a phone, **Disconnect** moved there.
|
||||
- **Beta (not yet tried on a real PS5).** **Diagnostics** keeps the last
|
||||
3,000 lines of what the page did (connections, cheats switched, cheat files
|
||||
loaded, saved and deleted, errors), at **Problems only**, **Normal** or
|
||||
**Everything**. **Copy log** and **Save log** add the app's version, the
|
||||
browser, the console and the game at the top, for a bug report.
|
||||
|
||||
### Links
|
||||
- **Beta (not yet tried on a real PS5).** The address in the browser follows
|
||||
the screen (`#/game/<title ID>/cheats`, `#/library`, `#/learn`,
|
||||
`#/settings` and so on), so Reload, Back, Forward and a saved link land on
|
||||
the same screen. A link to a game that is not running says so and offers
|
||||
the game that is.
|
||||
|
||||
### Scripts and the MCP server
|
||||
- **Beta (not yet tried on a real PS5).** Scripts: `cs.search(type, value,
|
||||
{from, to, limit})` searches the game's memory for a value and returns the
|
||||
addresses, and `cs.readText(address, max)` reads text.
|
||||
- **Beta (not yet tried on a real PS5).** The MCP server's `set_cheat` runs a
|
||||
script to the end and returns its log. Scripts that offer commands or keys
|
||||
still need the web app.
|
||||
- **Beta (not yet tried on a real PS5).** A code cheat switched on somewhere
|
||||
else (another browser, the MCP server) is taken over when you switch it in
|
||||
the app, so it can be switched off. `add_cheat` now records the game's
|
||||
original bytes with a code cheat so this works.
|
||||
|
||||
### Learn
|
||||
- Lesson 7 (Save, name, group and share) explains the start set, how it
|
||||
differs from Keep on, and that community cheats for your exact game version
|
||||
are saved on the PS5. Lessons that described the old Save menu are updated.
|
||||
|
||||
### Fixed
|
||||
- Dialogs taller than the screen could not be scrolled in some cases (no
|
||||
`dvh` support in the browser, a dialog that set its own height, a paused
|
||||
game on the Debug tab moving dialogs down, the Android keyboard). The cause
|
||||
was the dialog itself being the part that scrolls. Now the whole backdrop
|
||||
scrolls, a tall dialog starts at its top, its buttons stay in view, and the
|
||||
focus goes back to what opened it.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- The TV shows a notification when a cheat is switched on or off, or run, from
|
||||
|
||||
@@ -8,9 +8,9 @@ browser. Load one small payload on the console, open a page on your phone or
|
||||
computer, and switch cheats on, or find and make your own.
|
||||
|
||||
> **Use at your own risk.** CheatSpecter changes the memory of a running game,
|
||||
> which can crash it or damage a save. It has only been tested on one
|
||||
> jailbroken PS5, on firmware 13.60. What you do with it is your
|
||||
> responsibility.
|
||||
> which can crash it or damage a save.
|
||||
> It has only been tested on a Jailbroken 13.60 PS5.
|
||||
> What you do with it is your responsibility.
|
||||
|
||||

|
||||
|
||||
@@ -33,8 +33,14 @@ has the details.
|
||||
on the **Cheats** tab. To make your own, press **Guide me through my first
|
||||
cheat**.
|
||||
|
||||
Your cheats are saved on the PS5 by themselves as you make them, and open
|
||||
from there the next time the game runs. **Save** downloads a copy to the
|
||||
device you browse from. (New in 0.4.3, beta.)
|
||||
|
||||
The payload has to be loaded again after each reboot, like any payload.
|
||||
Nothing is installed on the device you browse from.
|
||||
Nothing is installed on the device you browse from. To stop it without a
|
||||
reboot, open the page from the PS5's address, press **Settings** (the gear at
|
||||
the top) and choose **Shut down CheatSpecter on the PS5** (beta).
|
||||
|
||||
No console to hand? `npm install && npm run demo` runs the app against a
|
||||
pretend PS5 so you can click around. See
|
||||
@@ -49,12 +55,42 @@ for that and for running the app from a computer instead of the console.
|
||||
as switches. Before a cheat is switched on, the game's memory is compared
|
||||
with what the table expects, so a cheat for the wrong version is left off
|
||||
instead of corrupting the game.
|
||||
- Your game library, with a Start button for each game.
|
||||
- Save your own cheats to the PS5, where they load by themselves for that
|
||||
game, or to your device as a `.cspector` file.
|
||||
- Mark a cheat **Keep on** and the PS5 applies it every time the game starts,
|
||||
with no browser open.
|
||||
- The game's cheat file on the PS5 opens first, without waiting for the
|
||||
internet. A community table made for exactly your version of the game is
|
||||
saved into that file, so next time it opens from the PS5 with no download.
|
||||
**Check for updates** shows what changed online before anything is
|
||||
replaced. *(beta)*
|
||||
- Your own cheats are saved on the PS5 as you make them. **Save** downloads
|
||||
the game's `.cspector` file to the phone or computer you are using, and
|
||||
**Delete from PS5…** (in the file's **…** menu) removes it from the
|
||||
console after asking. *(beta)*
|
||||
- Cheat files that etaHEN, elf-arsenal or GoldHEN keep on the console are
|
||||
listed as **On this PS5** and can be used like the online tables. *(beta)*
|
||||
- **Game patches** from the PS4-PS5-Game-Patch collection (frame rate,
|
||||
resolution and the like) as switches, for the exact version they were made
|
||||
for. *(beta)*
|
||||
- A **start set**: the cheats to switch on each time the game starts. You
|
||||
can still switch them off as usual. *(beta)*
|
||||
- Mark a cheat **Keep on** and the PS5 holds it on the whole time the game
|
||||
runs, with no browser open.
|
||||
- **Crash protection**: a cheat switched on less than a minute before the
|
||||
game closed is marked, and is not switched on by itself again until you
|
||||
switch it on by hand. *(beta)*
|
||||
- **Restore everything** puts back every change in one go.
|
||||
- The TV shows a short notification when a cheat is switched on or off.
|
||||
- Your game library, with a Start button for each game.
|
||||
|
||||
**Get around**
|
||||
|
||||
- A **controller shortcut**, off until you switch it on: hold L2 + R3 (or
|
||||
buttons you pick) and the PS5's own browser opens on the game's cheats.
|
||||
*(beta)*
|
||||
- The address in the browser follows the screen, so Reload, Back and a
|
||||
saved link land where you were (for example `#/game/PPSA01234/cheats`).
|
||||
*(beta)*
|
||||
- **Settings**, from the gear at the top of every screen: the controller
|
||||
shortcut, **Diagnostics** (a log you can copy or save to send with a bug
|
||||
report), and **Shut down CheatSpecter on the PS5**. *(beta)*
|
||||
|
||||
**Make cheats**
|
||||
|
||||
@@ -64,7 +100,8 @@ for that and for running the app from a computer instead of the console.
|
||||
lasts across restarts, and can be made to survive game updates.
|
||||
- **Code cheats** with decisions ("only for the player"), built from a plain
|
||||
rule or written in assembly with labels and variables, and **scripts** in
|
||||
JavaScript.
|
||||
JavaScript. Scripts can search the game's memory for a value
|
||||
(`cs.search`) and read text (`cs.readText`). *(beta)*
|
||||
- **Debug**: pause the game, set breakpoints, step, read and change registers,
|
||||
and compare objects field by field.
|
||||
- **Engine names**: for Unreal, Unity, Godot and GameMaker games, and C++
|
||||
@@ -90,6 +127,9 @@ The [wiki](https://github.com/TiagoVal97/cheatspecter/wiki) has the full guide:
|
||||
| Make a first cheat | [Your first cheat](https://github.com/TiagoVal97/cheatspecter/wiki/Your-First-Cheat) |
|
||||
| Use every kind of search | [Finding values](https://github.com/TiagoVal97/cheatspecter/wiki/Finding-Values) |
|
||||
| Save, group, share and keep cheats on | [Making and managing cheats](https://github.com/TiagoVal97/cheatspecter/wiki/Making-and-Managing-Cheats) |
|
||||
| Use frame-rate and resolution patches | [Game patches](https://github.com/TiagoVal97/cheatspecter/wiki/Game-Patches) |
|
||||
| Open CheatSpecter from the controller | [Controller shortcut](https://github.com/TiagoVal97/cheatspecter/wiki/Controller-Shortcut) |
|
||||
| Send a log, or stop CheatSpecter on the PS5 | [Settings and diagnostics](https://github.com/TiagoVal97/cheatspecter/wiki/Settings-and-Diagnostics) |
|
||||
| Write code cheats and scripts | [Code cheats and scripts](https://github.com/TiagoVal97/cheatspecter/wiki/Code-Cheats-and-Scripts) |
|
||||
| Pause, step and inspect | [Debugging](https://github.com/TiagoVal97/cheatspecter/wiki/Debugging) |
|
||||
| See class and field names | [Engine helpers](https://github.com/TiagoVal97/cheatspecter/wiki/Engine-Helpers) |
|
||||
@@ -108,6 +148,14 @@ community tables, known-value searches, "what writes to this", byte patches
|
||||
located by offset and by pattern, and saving and auto-loading `.cspector`
|
||||
files on the console.
|
||||
|
||||
**Everything new in 0.4.3 has not been tested on the console yet and is
|
||||
beta.** That is the cheat file opening from the PS5 first, community tables
|
||||
saved into it, the new Save and Delete from PS5, the start set, cheat files
|
||||
already on the console, game patches, crash protection, the controller
|
||||
shortcut, Settings and Diagnostics, links that survive a reload, and the
|
||||
script and MCP server additions. It passes the automated tests against the
|
||||
simulated console, and is marked *(beta)* above.
|
||||
|
||||
**Tested only against the built-in simulated console so far:** everything new
|
||||
in 0.4.0 (the Debug and Engine tabs, code cheats, text and grouped search,
|
||||
cheats the PS5 keeps on by itself, section "master code" cheats, the Learn
|
||||
@@ -125,7 +173,14 @@ in their original location. Nothing from them is copied into this repository.
|
||||
- [GoldHEN Cheat Repository](https://github.com/GoldHEN/GoldHEN_Cheat_Repository)
|
||||
- [HEN Cheats Collection](https://github.com/TeeKay87/HEN-Cheats-Collection)
|
||||
|
||||
All three formats they publish are read: JSON, SHN and MC4.
|
||||
All three formats they publish are read: JSON, SHN and MC4. A table made for
|
||||
exactly your version of the game is converted and saved into that game's
|
||||
cheat file on your PS5 the first time it is opened, so it keeps working with
|
||||
no internet.
|
||||
|
||||
Game patches come from
|
||||
[PS4-PS5-Game-Patch](https://github.com/illusion0001/PS4-PS5-Game-Patch),
|
||||
read the same way, by your browser.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+20
-3
@@ -177,6 +177,7 @@ function safeJoin(base, urlPath) {
|
||||
const DEMO_KEPT_DETAIL = 'The demo console does not keep cheats on by itself. On a real PS5 this would be switched on whenever the game runs.';
|
||||
const DEMO_REFUSALS = {
|
||||
review: 'This cheat has not been looked at yet. Open CheatSpecter and review it first.',
|
||||
pageOnly: 'Only CheatSpecter in the browser can place this cheat, so the PS5 cannot keep it on.',
|
||||
kind: 'Only patches and held values can be kept on by the PS5.',
|
||||
notHeld: 'Only a value that is held in place can be kept on. Turn on "Hold it at this value" in CheatSpecter first.',
|
||||
patchAddress: 'This cheat uses a raw memory address, which changes every time the game starts. Give it a lasting location in CheatSpecter first.',
|
||||
@@ -189,6 +190,7 @@ const demoFileVersion = (version) => String(version ?? '').replace(/[^0-9A-Za-z.
|
||||
/** Why the engine would not run an entry marked to keep, or null when it would. */
|
||||
function demoRefusal(e) {
|
||||
if (e.reviewed === false) return DEMO_REFUSALS.review;
|
||||
if (e.pageOnly === true) return DEMO_REFUSALS.pageOnly;
|
||||
if (e.type !== 'patch' && e.type !== 'value') return DEMO_REFUSALS.kind;
|
||||
if (e.type === 'value' && e.freeze !== true) return DEMO_REFUSALS.notHeld;
|
||||
const places = e.type === 'patch' && Array.isArray(e.more) ? [e, ...e.more] : [e];
|
||||
@@ -326,10 +328,13 @@ export function startBridge(opts = {}) {
|
||||
const engine = p === '/api/engine' || engineReload;
|
||||
const table = /^\/api\/tables\/([A-Z]{4}\d{5})(?:\/([0-9A-Za-z][0-9A-Za-z.]{0,15}))?$/.exec(p);
|
||||
const saving = !!table && !!table[2] && req.method === 'PUT';
|
||||
const deleting = !!table && !!table[2] && req.method === 'DELETE';
|
||||
// Cheat files other tools keep on the console: a list for one game, and one file. Read-only.
|
||||
const consoleCheats = /^\/api\/console-cheats\/([A-Z]{4}\d{5})(?:\/([A-Za-z0-9._()+%\- /]{1,300}))?$/.exec(p);
|
||||
// Settings: GET and PUT /api/prefs/<name>, the name being lower-case letters, digits and dashes only.
|
||||
const pref = /^\/api\/prefs\/([a-z0-9-]{1,32})$/.exec(p);
|
||||
const storing = !!pref && req.method === 'PUT';
|
||||
const methodOk = launching || engineReload ? req.method === 'POST' : saving || storing || req.method === 'GET';
|
||||
const methodOk = launching || engineReload ? req.method === 'POST' : saving || storing || deleting || req.method === 'GET';
|
||||
if (!methodOk || !sameOrigin(req)) {
|
||||
res.writeHead(sameOrigin(req) ? 405 : 403);
|
||||
res.end();
|
||||
@@ -397,6 +402,14 @@ export function startBridge(opts = {}) {
|
||||
: { ok: false, error: known ? 'The demo console only has one game it can actually run' : 'That game is not on this console' });
|
||||
return;
|
||||
}
|
||||
if (consoleCheats) {
|
||||
// The demo console has no other tools' cheat folders.
|
||||
if (consoleCheats[2]) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
} else sendJson(res, 200, { files: [] });
|
||||
return;
|
||||
}
|
||||
if (table) {
|
||||
const [, tid, version] = table;
|
||||
if (!version) {
|
||||
@@ -406,6 +419,10 @@ export function startBridge(opts = {}) {
|
||||
demoTables.set(`${tid}-${version}`, body);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
} else if (deleting) {
|
||||
const had = demoTables.delete(`${tid}-${version}`);
|
||||
res.writeHead(had ? 204 : 404);
|
||||
res.end();
|
||||
} else if (demoTables.has(`${tid}-${version}`)) {
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(demoTables.get(`${tid}-${version}`));
|
||||
@@ -425,7 +442,7 @@ export function startBridge(opts = {}) {
|
||||
res.end(answer.body);
|
||||
return;
|
||||
}
|
||||
if (!isPrivateAddress(host) || !(table || engine || pref || /^\/api\/(library|appmeta\/[A-Z]{4}\d{5}\/[a-z0-9.]+|launch\/[A-Z]{4}\d{5})$/.test(p))) {
|
||||
if (!isPrivateAddress(host) || !(table || engine || pref || consoleCheats || /^\/api\/(library|appmeta\/[A-Z]{4}\d{5}\/[a-z0-9.]+|launch\/[A-Z]{4}\d{5})$/.test(p))) {
|
||||
res.writeHead(400);
|
||||
res.end();
|
||||
return;
|
||||
@@ -453,7 +470,7 @@ export function startBridge(opts = {}) {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const p = url.pathname;
|
||||
|
||||
if (p.startsWith('/api/tables/') || p === '/api/library' || p.startsWith('/api/appmeta/') || p.startsWith('/api/launch/') || p === '/api/engine' || p === '/api/engine/reload' || p.startsWith('/api/prefs/')) {
|
||||
if (p.startsWith('/api/tables/') || p.startsWith('/api/console-cheats/') || p === '/api/library' || p.startsWith('/api/appmeta/') || p.startsWith('/api/launch/') || p === '/api/engine' || p === '/api/engine/reload' || p.startsWith('/api/prefs/')) {
|
||||
void consoleApi(req, res, url);
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>CheatSpecter</title>
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ commit:
|
||||
node scripts/check-lessons.mjs
|
||||
|
||||
It prints what is wrong and where (`lessons/find-a-number.json: lesson.steps[2].goal.field: …`),
|
||||
or `12 lessons, 74 steps: all fine.` The test suite (`test/learn.test.mjs`) runs the same check,
|
||||
or `12 lessons, 76 steps: all fine.` The test suite (`test/learn.test.mjs`) runs the same check,
|
||||
and then has a scripted learner complete every practice step, so a goal that cannot be reached
|
||||
fails the tests.
|
||||
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"id": "save-and-share",
|
||||
"title": "Save, name, group and share",
|
||||
"summary": "Keep your cheats in order, and learn what is inside a .cspector file.",
|
||||
"minutes": 7,
|
||||
"minutes": 9,
|
||||
"practice": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "When a pattern is not enough",
|
||||
"text": "Patterns survive most updates, not all of them.\n\n- If the update **rewrote** the code around your instruction, nothing matches any more. The tool says so and does not touch the game. In **Your cheats**, the **Save** menu has **Re-find my cheats after a game update**: it searches for the code your cheat remembered, more and more loosely, and shows you what it found before changing anything.\n- If a pattern matches in **more than one place**, the tool refuses to use it. Patching the wrong one of two look-alike instructions is how games crash.\n\nAnd some things are simply gone: if the developers removed the feature, there is nothing to find. Then it is back to lesson 2, on the new version.\n\n> warning: When a cheat says it does not fit this version of the game, believe it. Forcing bytes into code that has changed is the most reliable way to crash a game."
|
||||
"text": "Patterns survive most updates, not all of them.\n\n- If the update **rewrote** the code around your instruction, nothing matches any more. The tool says so and does not touch the game. In **Your cheats**, the **…** menu has **Re-find my cheats after a game update…**: it searches for the code your cheat remembered, more and more loosely, and shows you what it found before changing anything.\n- If a pattern matches in **more than one place**, the tool refuses to use it. Patching the wrong one of two look-alike instructions is how games crash.\n\nAnd some things are simply gone: if the developers removed the feature, there is nothing to find. Then it is back to lesson 2, on the new version.\n\n> warning: When a cheat says it does not fit this version of the game, believe it. Forcing bytes into code that has changed is the most reliable way to crash a game."
|
||||
},
|
||||
{
|
||||
"kind": "practice",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "At your own risk",
|
||||
"text": "Changing a running game's memory is never completely safe, and what you do with these tools is your choice and **your responsibility**. Nobody can promise that a change is harmless.\n\n- **Crashes.** A wrong value or a wrong patch can stop the game on the spot. Anything not saved is lost.\n- **Saves.** A game can write a changed value into your save file, and it stays there after the cheat is off.\n\nCheatSpecter has only been tested on one jailbroken PS5, on firmware 13.60. The rest of this lesson is about keeping the risk small: back up, change one thing at a time, and know how to put everything back."
|
||||
"text": "Changing a running game's memory is never completely safe, and what you do with these tools is your choice and **your responsibility**. Nobody can promise that a change is harmless.\n\n- **Crashes.** A wrong value or a wrong patch can stop the game on the spot. Anything not saved is lost.\n- **Saves.** A game can write a changed value into your save file, and it stays there after the cheat is off.\n\nCheatSpecter has only been tested on a Jailbroken 13.60 PS5. The rest of this lesson is about keeping the risk small: back up, change one thing at a time, and know how to put everything back."
|
||||
},
|
||||
{
|
||||
"kind": "read",
|
||||
@@ -141,7 +141,7 @@
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "When the game crashes",
|
||||
"text": "Sooner or later a cheat will crash a game. It is not a disaster. The game closes, the console shows an error, and nothing on the PS5 itself is harmed. What matters is what you do next.\n\n1. **Start the game again with no cheats on.** Check that your save is fine.\n2. **Switch cheats on one at a time**, playing a little between each. The one that brings the crash back is the culprit. When CheatSpecter notices a game closed with cheats on, it tells you what was on and what was changed last.\n3. **Ask why.** The usual causes, most likely first: the cheat was made for a different version of the game; a raw address from before a restart; a value set to something the game cannot cope with; an instruction removed that other things depended on.\n4. **Throw the cheat away or remake it.** Do not force a cheat that says it does not fit.\n\n> tip: The practice games in these lessons crash too, on purpose, if you write nonsense over their code. Try it some time in the Memory tab: it is the safest place there is to see what a crash looks like, and \"Restart game\" brings it straight back.\n\n# That is the course\n\nYou know what memory is, how to find a value by its number or by its behavior, how to hold it, how to switch off the code behind it, how to make that last through restarts and updates, and how to look after your saves while you do. The rest is practice, on games you enjoy."
|
||||
"text": "Sooner or later a cheat will crash a game. It is not a disaster. The game closes, the console shows an error, and nothing on the PS5 itself is harmed. What matters is what you do next.\n\n1. **Start the game again with no cheats on.** Check that your save is fine.\n2. **Switch cheats on one at a time**, playing a little between each. The one that brings the crash back is the culprit. When CheatSpecter notices a game closed with cheats on, it tells you what was on and what was changed last. A cheat switched on less than a minute before the game closed is marked **May have crashed the game**, and it is not switched on by itself again (not by **Keep on**, not by the start set) until you switch it on by hand.\n3. **Ask why.** The usual causes, most likely first: the cheat was made for a different version of the game; a raw address from before a restart; a value set to something the game cannot cope with; an instruction removed that other things depended on.\n4. **Throw the cheat away or remake it.** Do not force a cheat that says it does not fit.\n\n> tip: The practice games in these lessons crash too, on purpose, if you write nonsense over their code. Try it some time in the Memory tab: it is the safest place there is to see what a crash looks like, and \"Restart game\" brings it straight back.\n\n# That is the course\n\nYou know what memory is, how to find a value by its number or by its behavior, how to hold it, how to switch off the code behind it, how to make that last through restarts and updates, and how to look after your saves while you do. The rest is practice, on games you enjoy."
|
||||
},
|
||||
{
|
||||
"kind": "choice",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"id": "save-and-share",
|
||||
"title": "Save, name, group and share",
|
||||
"summary": "Keep your cheats in order, and learn what is inside a .cspector file.",
|
||||
"minutes": 7,
|
||||
"minutes": 9,
|
||||
"games": {
|
||||
"range": {
|
||||
"name": "Specter Range",
|
||||
@@ -79,7 +79,7 @@
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "From a finding to something you keep",
|
||||
"text": "Everything you make ends up under **Your cheats**: a list of things with switches. A cheat that removes an instruction lands there by itself when you press \"Disable it\". A value you found can be put there too.\n\nThree small habits make that list worth having a month from now:\n\n- **Name things for what they do.** \"Infinite ammo\", not \"Skip sub dword ptr [rbx+0x28], 1\".\n- **Group them.** Weapons, Money, Movement. A group has one switch for everything in it.\n- **Save the list to a file**, so it does not live only in this browser."
|
||||
"text": "Everything you make ends up under **Your cheats**: a list of things with switches. A cheat that removes an instruction lands there by itself when you press \"Disable it\". A value you found can be put there too.\n\nThree small habits make that list worth having a month from now:\n\n- **Name things for what they do.** \"Infinite ammo\", not \"Skip sub dword ptr [rbx+0x28], 1\".\n- **Group them.** Weapons, Money, Movement. A group has one switch for everything in it.\n- **Keep a copy.** With a real game your cheats are saved on the PS5 by themselves; **Save** also gives you a file to keep or to share."
|
||||
},
|
||||
{
|
||||
"kind": "practice",
|
||||
@@ -142,7 +142,25 @@
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "What is in a .cspector file",
|
||||
"text": "Under **Your cheats**, the **Save** button offers two places:\n\n- **Save to PS5** puts the file on the console, where it loads by itself whenever that game runs. That needs the CheatSpecter payload on the PS5, so it does not work for the practice game, which is not on a PS5 at all.\n- **Save to this device** downloads a file ending in `.cspector` to the phone or computer you are using. Drop that file onto the page to load it again, or send it to a friend.\n\nA `.cspector` file is plain text. Open one in any text editor and you will see the game's name, its ID and version, and a list of cheats, each with its name, its kind, and **where** it applies. No program code from your computer, nothing personal.\n\n# What does not go in the file\n\nA cheat that points at a raw address, like the one you just saved, is only good until the game restarts. Putting it in a file would be handing someone a cheat that does nothing, or worse, writes to the wrong place. So those are left out when you save to a file, and the tool tells you how many.\n\nWhat does go in: cheats that switch off an instruction (lesson 6), and values reached through a pointer path (lesson 9). Both can be found again in a freshly started game."
|
||||
"text": "Under **Your cheats**, the **Save** button downloads a file ending in `.cspector` to the phone or computer you are using. Drop that file onto the page to load it again, or send it to a friend.\n\nWith a real game, your cheats are also saved on the PS5 by themselves as you make them, in a file that loads whenever that game runs. That needs the CheatSpecter payload on the PS5, so it does not happen for the practice game, which is not on a PS5 at all.\n\nA `.cspector` file is plain text. Open one in any text editor and you will see the game's name, its ID and version, and a list of cheats, each with its name, its kind, and **where** it applies. No program code from your computer, nothing personal.\n\n# What does not go in the file\n\nA cheat that points at a raw address, like the one you just saved, is only good until the game restarts. Putting it in a file would be handing someone a cheat that does nothing, or worse, writes to the wrong place. So those are left out when you save to a file, and the tool tells you how many.\n\nWhat does go in: cheats that switch off an instruction (lesson 6), and values reached through a pointer path (lesson 9). Both can be found again in a freshly started game."
|
||||
},
|
||||
{
|
||||
"kind": "read",
|
||||
"title": "Cheats that switch on by themselves",
|
||||
"text": "Two things only work with a real game on a PS5, so you cannot try them here. They are worth knowing before you start.\n\n# The start set\n\nThe **start set** is a list of cheats to switch on every time the game starts. Switch on the ones you want, then in the bar above **Your cheats** open **…** and choose **Save what's on as this game's start set**. Next time, they come on by themselves, and you can still switch any of them off while you play.\n\n# Keep on\n\n**Keep on, even with this page closed**, in a cheat's **…** menu, is stronger: the PS5 holds that one cheat on the whole time the game runs, with no phone or computer needed. It works for patches and held values.\n\nIn short: the start set switches cheats on **at the start**, and then they are yours to switch off. Keep on holds a cheat on **all the time**.\n\n> note: If the game closes less than a minute after a cheat was switched on, CheatSpecter marks that cheat as one that may have crashed the game, and does not switch it on by itself again until you switch it on by hand.\n\n# Community cheats are saved too\n\nWhen a community cheat table is made for **exactly** your version of the game, CheatSpecter saves it on the PS5, in that game's cheat file, the first time you open it. After that it opens from the PS5 straight away, even with no internet. A table for another version is not saved by itself, because its cheats may not fit your game."
|
||||
},
|
||||
{
|
||||
"kind": "choice",
|
||||
"title": "Start set or Keep on?",
|
||||
"text": "",
|
||||
"question": "You want infinite ammo on whenever you start the game, but you also want to switch it off for a boss fight. Which do you use?",
|
||||
"options": [
|
||||
"Keep on, even with this page closed.",
|
||||
"The start set.",
|
||||
"Neither: switch it on by hand every time."
|
||||
],
|
||||
"answer": 1,
|
||||
"explain": "The start set switches it on when the game starts, and after that it is a normal switch. Keep on would hold it on the whole time, and you would have to change the setting to stop it."
|
||||
},
|
||||
{
|
||||
"kind": "choice",
|
||||
@@ -176,7 +194,7 @@
|
||||
"The new cheat is called something like Skip \"sub dword ptr…\". Rename it with … and Edit.",
|
||||
"Then … and \"Put in a group…\"."
|
||||
],
|
||||
"done": "That one would go into a file and work for anyone with the same game. To try the file itself, press Save and choose \"Save to this device\".",
|
||||
"done": "That one would go into a file and work for anyone with the same game. To try the file itself, press Save.",
|
||||
"challenge": true
|
||||
}
|
||||
]
|
||||
|
||||
+77
-1
@@ -36,6 +36,10 @@ import type { Scan, ScanRegion } from '../src/lib/scanner.ts';
|
||||
import { Session, formatFirmware } from '../src/lib/session.ts';
|
||||
import type { Game } from '../src/lib/session.ts';
|
||||
import { MAX_STRUCTURE_SIZE, readStructure } from '../src/lib/structure.ts';
|
||||
import { checkSource, codeSites } from '../src/lib/codecheat.ts';
|
||||
import { WORKER_SOURCE, hostScript } from '../src/lib/script.ts';
|
||||
import type { WorkerLike } from '../src/lib/script.ts';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { TableRunner, describeEntry, emptyTable, isAction, keepProblem, keptEntries, newId, parseOwnTable, saveFileText } from '../src/lib/table.ts';
|
||||
import type { Entry, OwnTable } from '../src/lib/table.ts';
|
||||
import { fileVersion, pickVersion, tableFileName } from '../src/lib/tablestore.ts';
|
||||
@@ -329,6 +333,62 @@ async function waitJob(): Promise<string> {
|
||||
return mine.result ?? '';
|
||||
}
|
||||
|
||||
// ---- scripts -------------------------------------------------------------------
|
||||
|
||||
/** How long a script may run before it is stopped. A search over a big game takes about a minute by itself. */
|
||||
const SCRIPT_TIME_LIMIT = 10 * 60 * 1000;
|
||||
|
||||
/** The script worker of the web app, made to run in a Node worker thread. */
|
||||
const NODE_WORKER_SOURCE = `
|
||||
const { parentPort } = require('node:worker_threads');
|
||||
globalThis.postMessage = (m) => parentPort.postMessage(m);
|
||||
${WORKER_SOURCE}
|
||||
parentPort.on('message', (data) => onmessage({ data }));
|
||||
`;
|
||||
|
||||
/** Runs a script to its end and returns what it logged. A script that waits for commands or keys is stopped: those need the web app. */
|
||||
function runScriptHere(st: GameState, name: string, code: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const lines: string[] = [];
|
||||
const thread = new Worker(NODE_WORKER_SOURCE, { eval: true });
|
||||
const worker: WorkerLike = { postMessage: (m) => thread.postMessage(m), terminate: () => void thread.terminate(), onmessage: null, onerror: null };
|
||||
thread.on('message', (data) => worker.onmessage?.({ data }));
|
||||
thread.on('error', (e: Error) => worker.onerror?.({ message: e.message }));
|
||||
let settled = false;
|
||||
const finish = (text: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(idle);
|
||||
handle.stop();
|
||||
const log = lines.length ? '\n' + lines.slice(-200).join('\n') : '';
|
||||
resolve(`${text}${log}`);
|
||||
};
|
||||
const handle = hostScript(
|
||||
worker,
|
||||
st.ctx,
|
||||
{
|
||||
variables: st.runner,
|
||||
setEntry: async (other, on) => {
|
||||
const target = st.table?.entries.find((e) => e.name === other);
|
||||
if (!target) throw new Error(`There is no entry called "${other}"`);
|
||||
if (target.name === name) throw new Error('A script cannot switch itself');
|
||||
if (on) await st.runner.enable(target);
|
||||
else await st.runner.disable(target);
|
||||
},
|
||||
},
|
||||
code,
|
||||
(line) => lines.push(line),
|
||||
(error) => finish(error ? `"${name}" stopped with an error: ${error}` : `Ran "${name}".`),
|
||||
);
|
||||
const timer = setTimeout(() => finish(`"${name}" was stopped after ${SCRIPT_TIME_LIMIT / 60000} minutes.`), SCRIPT_TIME_LIMIT);
|
||||
// A script that offers commands or keys never ends by itself.
|
||||
const idle = setInterval(() => {
|
||||
if (handle.commands().length || handle.hotkeys().length) finish(`"${name}" offers commands or keys, which only work in the CheatSpecter web app. It was stopped here.`);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- cheat files ---------------------------------------------------------------
|
||||
|
||||
async function loadTable(st: GameState, refresh = false): Promise<OwnTable> {
|
||||
@@ -773,6 +833,19 @@ const tools: Tool[] = [
|
||||
const probe = parseOwnTable(JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: {}, cheats: [{ ...raw, reviewed: true }] }), { own: true });
|
||||
const entry = probe.entries[0];
|
||||
if (!entry) throw new ToolError(`That is not a cheat CheatSpecter understands.\n\n${CHEAT_FORMAT}`);
|
||||
// The game's own bytes at each hooked place are kept with the cheat, so any app can switch it off again later.
|
||||
if (entry.type === 'inject') {
|
||||
for (const site of codeSites(entry)) {
|
||||
if (site.off) continue;
|
||||
const check = await checkSource(st.ctx, site).catch(() => null);
|
||||
if (check?.status !== 'ok' || !check.bytes) continue;
|
||||
if (site.at === entry.at) entry.off = check.bytes;
|
||||
else {
|
||||
const more = entry.more?.find((m) => m.at === site.at);
|
||||
if (more) more.off = check.bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
const table = await loadTable(st);
|
||||
const old = table.entries.find((e) => e.name.toLowerCase() === entry.name.toLowerCase());
|
||||
if (old && st.runner.isOn(old.id)) await st.runner.disable(old).catch(() => undefined);
|
||||
@@ -811,7 +884,10 @@ const tools: Tool[] = [
|
||||
const st = await current();
|
||||
const table = await loadTable(st);
|
||||
const entry = findEntry(table, a.name);
|
||||
if (entry.type === 'script') throw new ToolError('Scripts run in the CheatSpecter web app, not here.');
|
||||
if (entry.type === 'script') {
|
||||
if (a.on === false || a.on === 'false') return `"${entry.name}" is a script; it is not running here.`;
|
||||
return runScriptHere(st, entry.name, entry.code);
|
||||
}
|
||||
if (a.on === false || a.on === 'false') {
|
||||
if (entry.keep) {
|
||||
throw new ToolError(
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"description": "Browser-based real-time modding tool for jailbroken PS5 consoles running a ps5debug payload.",
|
||||
"license": "GPL-3.0-only",
|
||||
|
||||
+63
-3
@@ -43,6 +43,9 @@ is harmless: the second copy sees the port is taken, says so, and exits.
|
||||
| `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. |
|
||||
@@ -117,6 +120,7 @@ Only entries with `"keep": true` (exactly `true`) are looked at.
|
||||
| `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
|
||||
@@ -171,6 +175,29 @@ engine picks up without undoing or announcing anything.
|
||||
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
|
||||
@@ -199,11 +226,38 @@ that is so the engine starts no pattern search of its own (those cheats say
|
||||
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:
|
||||
|
||||
```json
|
||||
{"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 or could not be switched on (refused ones count there). It waits up to
|
||||
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.
|
||||
|
||||
@@ -211,6 +265,7 @@ not fit or could not be switched on (refused ones count there). It waits up to
|
||||
|
||||
```json
|
||||
{"running":true,
|
||||
"crashGuard":true,
|
||||
"game":{"titleId":"PPSA18605","version":"01.001.008","pid":137},
|
||||
"file":"PPSA18605-01.001.008.cspector",
|
||||
"exact":true,
|
||||
@@ -225,10 +280,15 @@ not fit or could not be switched on (refused ones count there). It waits up to
|
||||
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, in the file's order. `state` is
|
||||
`on`, `waiting`, `mismatch`, `error` or `refused`, and `detail` says why in
|
||||
- `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
|
||||
|
||||
+519
-9
@@ -142,7 +142,7 @@ struct site {
|
||||
};
|
||||
|
||||
enum cheat_kind { CHEAT_PATCH, CHEAT_VALUE };
|
||||
enum cheat_state { ST_WAITING, ST_ON, ST_MISMATCH, ST_ERROR, ST_REFUSED };
|
||||
enum cheat_state { ST_WAITING, ST_ON, ST_MISMATCH, ST_ERROR, ST_REFUSED, ST_HELD /* see crash protection */ };
|
||||
|
||||
struct cheat {
|
||||
char name[NAME_MAX_BYTES];
|
||||
@@ -162,6 +162,8 @@ struct cheat {
|
||||
int64_t grace_ends; /* set while unreadable code is being waited for: when that wait is over */
|
||||
int searches; /* pattern searches that found nothing so far */
|
||||
int write_failures;
|
||||
int start_only; /* here only because it is in the file's start set, not marked to keep */
|
||||
int once; /* a held value from the start set: written once when it can be, then left to the page */
|
||||
};
|
||||
|
||||
struct table {
|
||||
@@ -233,7 +235,7 @@ static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static char *g_status;
|
||||
static int g_reload;
|
||||
|
||||
static const char STATUS_IDLE[] = "{\"running\":false,\"game\":null,\"file\":\"\",\"cheats\":[]}";
|
||||
static const char STATUS_IDLE[] = "{\"running\":false,\"crashGuard\":true,\"game\":null,\"file\":\"\",\"cheats\":[]}";
|
||||
|
||||
/* ---- small helpers -------------------------------------------------------------- */
|
||||
|
||||
@@ -449,6 +451,8 @@ static const char R_ADDRESS_PATCH[] =
|
||||
static const char R_ADDRESS_VALUE[] =
|
||||
"This value uses a raw memory address, which changes every time the game starts. Give it a pointer path in CheatSpecter first.";
|
||||
|
||||
static const char R_PAGE_ONLY[] = "Only CheatSpecter in the browser can place this cheat, so the PS5 cannot keep it on.";
|
||||
|
||||
/* "0x1F40", "1f40" -> number. Up to 16 hex digits, nothing else. */
|
||||
static int parse_hex(const char *text, uint64_t *out) {
|
||||
while (*text == ' ' || *text == '\t') text++;
|
||||
@@ -608,10 +612,23 @@ static void parse_loc(struct json_reader *r, struct loc *out) {
|
||||
}
|
||||
}
|
||||
|
||||
#define START_NAME_MAX NAME_MAX_BYTES
|
||||
#define SOURCE_ID_MAX 256
|
||||
|
||||
/* One entry of a file's start set: a cheat's name, and the source it was saved under ("" for the person's own). */
|
||||
struct start_item {
|
||||
char name[START_NAME_MAX];
|
||||
char from[SOURCE_ID_MAX];
|
||||
};
|
||||
|
||||
/* Scratch space for one file's worth of parsing. */
|
||||
struct parse_ctx {
|
||||
char *hex; /* text of an "on" or "off" string */
|
||||
size_t hex_cap;
|
||||
/* The file's start set (read_start_set()), and which list of cheats is being read: NULL for the person's own. */
|
||||
struct start_item *start;
|
||||
int start_count;
|
||||
const char *group;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -892,6 +909,8 @@ static void clip_text(char *text, size_t max) {
|
||||
text[n] = 0;
|
||||
}
|
||||
|
||||
static int in_start_set(const struct parse_ctx *ctx, const char *name);
|
||||
|
||||
/*
|
||||
* Reads one entry of the "cheats" list. Returns 1 with *out filled in when it
|
||||
* is a cheat the engine keeps on, 0 when it is anything else. `out` owns
|
||||
@@ -903,7 +922,7 @@ static int parse_entry(struct json_reader *r, struct parse_ctx *ctx, struct chea
|
||||
struct site first;
|
||||
struct site *more = NULL;
|
||||
int more_count = 0, too_many = 0;
|
||||
int has_name = 0, keep = 0, not_reviewed = 0, freeze = 0, has_value = 0;
|
||||
int has_name = 0, keep = 0, not_reviewed = 0, freeze = 0, has_value = 0, page_only = 0;
|
||||
|
||||
memset(&c, 0, sizeof c);
|
||||
memset(&first, 0, sizeof first);
|
||||
@@ -929,6 +948,9 @@ static int parse_entry(struct json_reader *r, struct parse_ctx *ctx, struct chea
|
||||
} else if (strcmp(key, "freeze") == 0) {
|
||||
freeze = t == JSON_TRUE;
|
||||
json_skip(r);
|
||||
} else if (strcmp(key, "pageOnly") == 0) {
|
||||
page_only = t == JSON_TRUE;
|
||||
json_skip(r);
|
||||
} else if (strcmp(key, "reviewed") == 0) {
|
||||
not_reviewed = t == JSON_FALSE;
|
||||
json_skip(r);
|
||||
@@ -969,7 +991,10 @@ static int parse_entry(struct json_reader *r, struct parse_ctx *ctx, struct chea
|
||||
int is_any_value = strcmp(type, "value") == 0;
|
||||
int is_value = is_any_value && freeze;
|
||||
int runs_code = strcmp(type, "inject") == 0 || strcmp(type, "call") == 0 || strcmp(type, "script") == 0;
|
||||
int listed = !r->failed && has_name && keep && (is_patch || is_any_value || runs_code);
|
||||
/* Cheats saved from a source are never kept; the start set is how they come on by themselves. */
|
||||
if (ctx->group) keep = 0;
|
||||
int start = !keep && has_name && in_start_set(ctx, c.name);
|
||||
int listed = !r->failed && has_name && (keep || start) && (is_patch || is_any_value || runs_code);
|
||||
if (!is_patch) {
|
||||
/* Only a patch has further places. */
|
||||
for (int i = 0; i < more_count; i++) site_free(&more[i]);
|
||||
@@ -979,11 +1004,14 @@ static int parse_entry(struct json_reader *r, struct parse_ctx *ctx, struct chea
|
||||
/* The engine runs patches and held values only, and never at a raw address: that is only right for the launch it was found in. */
|
||||
const char *refusal = NULL;
|
||||
if (not_reviewed) refusal = R_REVIEW;
|
||||
else if (page_only) refusal = R_PAGE_ONLY;
|
||||
else if (runs_code) refusal = R_KIND;
|
||||
else if (is_any_value && !freeze) refusal = R_NOT_HELD;
|
||||
else if (first.at.kind == LOC_ADDRESS) refusal = is_patch ? R_ADDRESS_PATCH : R_ADDRESS_VALUE;
|
||||
for (int i = 0; i < more_count && !refusal; i++)
|
||||
if (more[i].at.kind == LOC_ADDRESS) refusal = R_ADDRESS_PATCH;
|
||||
/* What the start set asks for and the engine cannot do is the page's to switch on: not listed, so nothing is said about it here. */
|
||||
if (refusal && start) listed = 0;
|
||||
if (refusal) {
|
||||
/* Nothing of it is kept but the name and the reason. */
|
||||
site_free(&first);
|
||||
@@ -1009,6 +1037,8 @@ static int parse_entry(struct json_reader *r, struct parse_ctx *ctx, struct chea
|
||||
}
|
||||
|
||||
c.kind = is_patch ? CHEAT_PATCH : CHEAT_VALUE;
|
||||
c.start_only = start;
|
||||
c.once = start && is_value;
|
||||
if (is_value) {
|
||||
/* A held value is one place whose bytes are the value; there is nothing to compare against. */
|
||||
uint8_t bytes[8];
|
||||
@@ -1055,6 +1085,105 @@ static int parse_entry(struct json_reader *r, struct parse_ctx *ctx, struct chea
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Whether the cheat named `name`, in the list being read, is in the file's start set. */
|
||||
static int in_start_set(const struct parse_ctx *ctx, const char *name) {
|
||||
const char *group = ctx->group ? ctx->group : "";
|
||||
for (int i = 0; i < ctx->start_count; i++)
|
||||
if (strcmp(ctx->start[i].name, name) == 0 && strcmp(ctx->start[i].from, group) == 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reads the file's "startSet" (see StartItem in table.ts) before anything
|
||||
* else, wherever it is in the file: whether a cheat is in it is needed while
|
||||
* the cheats themselves are read. Anything odd in it is passed over.
|
||||
*/
|
||||
static void read_start_set(const char *text, size_t len, struct parse_ctx *ctx) {
|
||||
struct json_reader r;
|
||||
char key[20];
|
||||
json_init(&r, text, len);
|
||||
if (json_peek(&r) != JSON_OBJECT || json_object_begin(&r) != 0) return;
|
||||
for (int f = 1; json_object_next(&r, f, key, sizeof key) > 0; f = 0) {
|
||||
if (strcmp(key, "startSet") != 0 || json_peek(&r) != JSON_ARRAY) {
|
||||
if (json_skip(&r) != 0) return;
|
||||
continue;
|
||||
}
|
||||
if (json_array_begin(&r) != 0) return;
|
||||
for (int af = 1; json_array_next(&r, af) > 0; af = 0) {
|
||||
struct start_item item;
|
||||
memset(&item, 0, sizeof item);
|
||||
int named = 0;
|
||||
enum json_type t = json_peek(&r);
|
||||
if (t == JSON_STRING) {
|
||||
/* A plain name is one of the person's own cheats. */
|
||||
named = json_string(&r, item.name, sizeof item.name) >= 0;
|
||||
} else if (t == JSON_OBJECT && json_object_begin(&r) == 0) {
|
||||
for (int of = 1; json_object_next(&r, of, key, sizeof key) > 0; of = 0) {
|
||||
int text_value = json_peek(&r) == JSON_STRING;
|
||||
if (strcmp(key, "name") == 0 && text_value) named = json_string(&r, item.name, sizeof item.name) >= 0;
|
||||
else if (strcmp(key, "from") == 0 && text_value) {
|
||||
if (json_string(&r, item.from, sizeof item.from) >= (long)sizeof item.from) item.from[0] = 0x7f; /* too long to match anything */
|
||||
} else json_skip(&r);
|
||||
}
|
||||
} else {
|
||||
json_skip(&r);
|
||||
}
|
||||
if (r.failed) return;
|
||||
if (!named || !item.name[0] || ctx->start_count >= MAX_KEPT) continue;
|
||||
if (!ctx->start && (ctx->start = calloc(MAX_KEPT, sizeof *ctx->start)) == NULL) return;
|
||||
ctx->start[ctx->start_count++] = item;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adds the cheats of a "cheats" array to `t`, after what is already there. */
|
||||
static void append_cheat_list(struct json_reader *r, struct parse_ctx *ctx, struct table *t) {
|
||||
if (json_array_begin(r) != 0) return;
|
||||
for (int f = 1; json_array_next(r, f) > 0; f = 0) {
|
||||
struct cheat c;
|
||||
if (!parse_entry(r, ctx, &c)) continue;
|
||||
if (!t->cheats) t->cheats = calloc(MAX_KEPT, sizeof *t->cheats);
|
||||
if (!t->cheats || t->count >= MAX_KEPT) {
|
||||
t->truncated = 1;
|
||||
cheat_free(&c);
|
||||
} else {
|
||||
t->cheats[t->count++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Reads the file's "sources" (see TableSource in table.ts): cheats saved from
|
||||
* community tables and the like. Only those in the start set are listed. A
|
||||
* source's "id" comes before its "cheats" in files the app writes; cheats
|
||||
* that come before any id are read as belonging to no source the start set
|
||||
* can name, so none of them is listed.
|
||||
*/
|
||||
static void parse_sources(struct json_reader *r, struct parse_ctx *ctx, struct table *t) {
|
||||
char key[20], id[SOURCE_ID_MAX];
|
||||
if (json_array_begin(r) != 0) return;
|
||||
for (int f = 1; json_array_next(r, f) > 0; f = 0) {
|
||||
if (json_peek(r) != JSON_OBJECT || json_object_begin(r) != 0) {
|
||||
json_skip(r);
|
||||
continue;
|
||||
}
|
||||
snprintf(id, sizeof id, "%c", 0x7f); /* matches no start set entry until the real id is read */
|
||||
for (int of = 1; json_object_next(r, of, key, sizeof key) > 0; of = 0) {
|
||||
enum json_type type = json_peek(r);
|
||||
if (strcmp(key, "id") == 0 && type == JSON_STRING) {
|
||||
if (json_string(r, id, sizeof id) >= (long)sizeof id) snprintf(id, sizeof id, "%c", 0x7f);
|
||||
} else if (strcmp(key, "cheats") == 0 && type == JSON_ARRAY) {
|
||||
ctx->group = id;
|
||||
append_cheat_list(r, ctx, t);
|
||||
ctx->group = NULL;
|
||||
} else {
|
||||
json_skip(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reads a "cheats" array into a fresh list. Whatever was in `t` before is freed first. */
|
||||
static void parse_cheat_list(struct json_reader *r, struct parse_ctx *ctx, struct table *t) {
|
||||
char name[sizeof t->game_name];
|
||||
@@ -1084,6 +1213,7 @@ static int parse_table(const char *text, size_t len, struct table *out, char *er
|
||||
struct json_reader r;
|
||||
struct parse_ctx ctx;
|
||||
struct table cheats, entries; /* "cheats" in a .cspector file; "entries" in files from before that format */
|
||||
struct table sourced; /* cheats saved from sources, that the start set asks for */
|
||||
char key[20], format[24] = "", game_name[96] = "", legacy_name[96] = "";
|
||||
/* [0] is the .cspector layout's "game" object, [1] the top level, where the older layout kept these. */
|
||||
char title[2][24] = {"", ""}, version[2][24] = {"", ""};
|
||||
@@ -1093,6 +1223,8 @@ static int parse_table(const char *text, size_t len, struct table *out, char *er
|
||||
memset(out, 0, sizeof *out);
|
||||
memset(&cheats, 0, sizeof cheats);
|
||||
memset(&entries, 0, sizeof entries);
|
||||
memset(&sourced, 0, sizeof sourced);
|
||||
memset(&ctx, 0, sizeof ctx);
|
||||
error[0] = 0;
|
||||
ctx.hex_cap = MAX_PATCH_BYTES * 3 + 16;
|
||||
ctx.hex = malloc(ctx.hex_cap);
|
||||
@@ -1100,6 +1232,7 @@ static int parse_table(const char *text, size_t len, struct table *out, char *er
|
||||
snprintf(error, error_cap, "The console ran out of memory reading the cheat file");
|
||||
return -1;
|
||||
}
|
||||
read_start_set(text, len, &ctx);
|
||||
|
||||
json_init(&r, text, len);
|
||||
int is_object = json_peek(&r) == JSON_OBJECT;
|
||||
@@ -1136,6 +1269,8 @@ static int parse_table(const char *text, size_t len, struct table *out, char *er
|
||||
have_entries = t == JSON_ARRAY;
|
||||
if (have_entries) parse_cheat_list(&r, &ctx, &entries);
|
||||
else json_skip(&r);
|
||||
} else if (strcmp(key, "sources") == 0 && t == JSON_ARRAY && sourced.count == 0) {
|
||||
parse_sources(&r, &ctx, &sourced);
|
||||
} else {
|
||||
json_skip(&r);
|
||||
}
|
||||
@@ -1144,6 +1279,7 @@ static int parse_table(const char *text, size_t len, struct table *out, char *er
|
||||
json_skip(&r);
|
||||
}
|
||||
free(ctx.hex);
|
||||
free(ctx.start);
|
||||
|
||||
int legacy = strcmp(format, "cheatspecter/1") == 0;
|
||||
struct table *chosen = legacy ? &entries : &cheats;
|
||||
@@ -1156,12 +1292,26 @@ static int parse_table(const char *text, size_t len, struct table *out, char *er
|
||||
snprintf(error, error_cap, "%s", problem);
|
||||
table_free(&cheats);
|
||||
table_free(&entries);
|
||||
table_free(&sourced);
|
||||
return -1;
|
||||
}
|
||||
/* The older layout has no sources. The person's own cheats come first, then the sources' in file order. */
|
||||
for (int i = 0; !legacy && i < sourced.count; i++) {
|
||||
if (!chosen->cheats) chosen->cheats = calloc(MAX_KEPT, sizeof *chosen->cheats);
|
||||
if (!chosen->cheats || chosen->count >= MAX_KEPT) {
|
||||
chosen->truncated = 1;
|
||||
cheat_free(&sourced.cheats[i]);
|
||||
} else {
|
||||
chosen->cheats[chosen->count++] = sourced.cheats[i];
|
||||
}
|
||||
memset(&sourced.cheats[i], 0, sizeof sourced.cheats[i]);
|
||||
}
|
||||
if (sourced.truncated) chosen->truncated = 1;
|
||||
*out = *chosen;
|
||||
memset(chosen, 0, sizeof *chosen);
|
||||
table_free(&cheats);
|
||||
table_free(&entries);
|
||||
table_free(&sourced);
|
||||
snprintf(out->game_name, sizeof out->game_name, "%s", legacy ? legacy_name : game_name);
|
||||
tidy_name(out->game_name);
|
||||
memcpy(out->title_id, title[legacy], sizeof out->title_id);
|
||||
@@ -1262,6 +1412,333 @@ static char *read_file(const char *name, size_t *len, struct file_stamp *stamp,
|
||||
return text;
|
||||
}
|
||||
|
||||
/* ---- crash protection ---------------------------------------------------------------- */
|
||||
/*
|
||||
* A cheat switched on less than a minute before the game ended may be why it
|
||||
* ended. It gets a mark, and from then on is held back instead of switched on
|
||||
* by itself, until the person switches it on by hand in the web app (which
|
||||
* takes the mark away). src/lib/crashguard.ts is the web app's side and the
|
||||
* reference for the file both share:
|
||||
*
|
||||
* <root>/data/cheatspecter/prefs/crash-<title id, lower case>.json
|
||||
* {"marks":[{"name":"..","at":<ms since 1970>,"by":"page"|"ps5","version":".."}],
|
||||
* "armed":[{"name":"..","pid":<process id>,"at":<ms since 1970>}]}
|
||||
*
|
||||
* The engine sees every game end, page open or not, so it does the marking:
|
||||
* for the kept cheats it switched on itself, and for the cheats the page
|
||||
* switched on, which the page lists under "armed" with the game's process id.
|
||||
* The page's clock is not the console's, so an armed cheat counts from when
|
||||
* the engine first saw it listed (at most a file check later than the
|
||||
* switch). Ones already listed when a launch is first seen are from before
|
||||
* the engine was watching, and are not counted.
|
||||
*/
|
||||
|
||||
#define CRASH_WINDOW_MS (60000 / g_speed)
|
||||
#define CRASH_FILE_MAX (64u << 10)
|
||||
#define CRASH_MAX 64
|
||||
|
||||
static const char CRASH_HELD[] =
|
||||
"Held back: the game closed less than a minute after this was switched on last time, so it may have crashed it. "
|
||||
"Switch it on by hand in CheatSpecter to try again.";
|
||||
|
||||
struct crash_mark {
|
||||
char name[NAME_MAX_BYTES];
|
||||
double at;
|
||||
char by[8];
|
||||
char version[24];
|
||||
};
|
||||
|
||||
struct crash_armed {
|
||||
char name[NAME_MAX_BYTES];
|
||||
double pid, at;
|
||||
int64_t seen; /* engine time it was first seen listed; 0 when it was there before this launch was seen */
|
||||
};
|
||||
|
||||
static struct {
|
||||
char title_id[10]; /* the game the lists are for */
|
||||
struct file_stamp stamp;
|
||||
struct crash_mark marks[CRASH_MAX];
|
||||
int mark_count;
|
||||
struct crash_armed armed[CRASH_MAX];
|
||||
int armed_count;
|
||||
/* Kept cheats this engine switched on during this launch, and when. */
|
||||
struct {
|
||||
char name[NAME_MAX_BYTES];
|
||||
int64_t at;
|
||||
} on[MAX_KEPT];
|
||||
int on_count;
|
||||
int64_t last_alive; /* when the running game was last seen running */
|
||||
} C;
|
||||
|
||||
static int64_t wall_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
static void crash_path(char *out, size_t cap, const char *suffix) {
|
||||
char lower[10];
|
||||
size_t i = 0;
|
||||
for (; C.title_id[i] && i < sizeof lower - 1; i++) lower[i] = (char)tolower((unsigned char)C.title_id[i]);
|
||||
lower[i] = 0;
|
||||
snprintf(out, cap, "%s/data/cheatspecter/prefs/crash-%s.json%s", E.cfg.root, lower, suffix);
|
||||
}
|
||||
|
||||
static int crash_marked(const char *name) {
|
||||
for (int i = 0; i < C.mark_count; i++)
|
||||
if (strcmp(C.marks[i].name, name) == 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reads the shared file into C.marks and C.armed. `initial` is set for the
|
||||
* first read of a launch. A missing or damaged file means nothing is marked.
|
||||
*/
|
||||
static void crash_read(int64_t now, int initial) {
|
||||
char path[600];
|
||||
struct crash_armed before[CRASH_MAX];
|
||||
int before_count = C.armed_count;
|
||||
memcpy(before, C.armed, sizeof before);
|
||||
C.mark_count = C.armed_count = 0;
|
||||
|
||||
crash_path(path, sizeof path, "");
|
||||
int fd = open(path, O_RDONLY);
|
||||
struct stat st;
|
||||
char *text = NULL;
|
||||
size_t len = 0;
|
||||
if (fd >= 0 && fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && (uint64_t)st.st_size <= CRASH_FILE_MAX &&
|
||||
(text = malloc((size_t)st.st_size + 1)) != NULL) {
|
||||
while (len < (size_t)st.st_size) {
|
||||
ssize_t n = read(fd, text + len, (size_t)st.st_size - len);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) break;
|
||||
len += (size_t)n;
|
||||
}
|
||||
}
|
||||
if (fd >= 0) close(fd);
|
||||
if (!text) return;
|
||||
|
||||
struct json_reader r;
|
||||
char key[16];
|
||||
json_init(&r, text, len);
|
||||
if (json_peek(&r) == JSON_OBJECT && json_object_begin(&r) == 0) {
|
||||
for (int f = 1; json_object_next(&r, f, key, sizeof key) > 0; f = 0) {
|
||||
int marks = strcmp(key, "marks") == 0, armed = strcmp(key, "armed") == 0;
|
||||
if ((!marks && !armed) || json_peek(&r) != JSON_ARRAY || json_array_begin(&r) != 0) {
|
||||
json_skip(&r);
|
||||
continue;
|
||||
}
|
||||
for (int af = 1; json_array_next(&r, af) > 0; af = 0) {
|
||||
struct crash_mark m;
|
||||
struct crash_armed a;
|
||||
int named = 0, has_pid = 0;
|
||||
memset(&m, 0, sizeof m);
|
||||
memset(&a, 0, sizeof a);
|
||||
if (json_peek(&r) != JSON_OBJECT || json_object_begin(&r) != 0) {
|
||||
json_skip(&r);
|
||||
continue;
|
||||
}
|
||||
for (int of = 1; json_object_next(&r, of, key, sizeof key) > 0; of = 0) {
|
||||
enum json_type t = json_peek(&r);
|
||||
if (strcmp(key, "name") == 0 && t == JSON_STRING) {
|
||||
long n = json_string(&r, m.name, sizeof m.name);
|
||||
named = n > 0 && n < (long)sizeof m.name;
|
||||
memcpy(a.name, m.name, sizeof a.name);
|
||||
} else if (strcmp(key, "at") == 0 && t == JSON_NUMBER) {
|
||||
json_number(&r, &m.at);
|
||||
a.at = m.at;
|
||||
} else if (strcmp(key, "pid") == 0 && t == JSON_NUMBER) {
|
||||
has_pid = json_number(&r, &a.pid) == 0;
|
||||
} else if (strcmp(key, "by") == 0 && t == JSON_STRING) {
|
||||
if (json_string(&r, m.by, sizeof m.by) >= (long)sizeof m.by) m.by[0] = 0;
|
||||
} else if (strcmp(key, "version") == 0 && t == JSON_STRING) {
|
||||
if (json_string(&r, m.version, sizeof m.version) >= (long)sizeof m.version) m.version[0] = 0;
|
||||
} else {
|
||||
json_skip(&r);
|
||||
}
|
||||
}
|
||||
if (!named) continue;
|
||||
if (marks && C.mark_count < CRASH_MAX && !crash_marked(m.name)) {
|
||||
if (strcmp(m.by, "ps5") != 0) snprintf(m.by, sizeof m.by, "page");
|
||||
C.marks[C.mark_count++] = m;
|
||||
} else if (armed && has_pid && C.armed_count < CRASH_MAX) {
|
||||
/* Seen before: it keeps the time it was first seen. New: counts from now, unless the launch was not being watched yet. */
|
||||
a.seen = initial || (uint32_t)a.pid != E.game.pid ? 0 : now;
|
||||
for (int k = 0; k < before_count; k++)
|
||||
if (before[k].pid == a.pid && before[k].at == a.at && strcmp(before[k].name, a.name) == 0) a.seen = before[k].seen;
|
||||
C.armed[C.armed_count++] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.failed) C.mark_count = C.armed_count = 0;
|
||||
free(text);
|
||||
}
|
||||
|
||||
/* Writes C.marks and C.armed back, beside the real file and moved into place. Returns 0 on success. */
|
||||
static int crash_write(void) {
|
||||
char path[600], tmp[600], num[64];
|
||||
struct strbuf b = {0};
|
||||
sb_add(&b, "{\"marks\":[");
|
||||
for (int i = 0; i < C.mark_count; i++) {
|
||||
const struct crash_mark *m = &C.marks[i];
|
||||
sb_add(&b, i ? ",{\"name\":" : "{\"name\":");
|
||||
sb_add_json(&b, m->name);
|
||||
snprintf(num, sizeof num, ",\"at\":%.0f,\"by\":", m->at);
|
||||
sb_add(&b, num);
|
||||
sb_add_json(&b, m->by);
|
||||
if (m->version[0]) {
|
||||
sb_add(&b, ",\"version\":");
|
||||
sb_add_json(&b, m->version);
|
||||
}
|
||||
sb_add(&b, "}");
|
||||
}
|
||||
sb_add(&b, "],\"armed\":[");
|
||||
for (int i = 0; i < C.armed_count; i++) {
|
||||
const struct crash_armed *a = &C.armed[i];
|
||||
sb_add(&b, i ? ",{\"name\":" : "{\"name\":");
|
||||
sb_add_json(&b, a->name);
|
||||
snprintf(num, sizeof num, ",\"pid\":%.0f,\"at\":%.0f}", a->pid, a->at);
|
||||
sb_add(&b, num);
|
||||
}
|
||||
sb_add(&b, "]}");
|
||||
if (b.failed || !b.data) {
|
||||
free(b.data);
|
||||
return -1;
|
||||
}
|
||||
snprintf(path, sizeof path, "%s/data", E.cfg.root);
|
||||
mkdir(path, 0777);
|
||||
snprintf(path, sizeof path, "%s/data/cheatspecter", E.cfg.root);
|
||||
mkdir(path, 0777);
|
||||
snprintf(path, sizeof path, "%s/data/cheatspecter/prefs", E.cfg.root);
|
||||
mkdir(path, 0777);
|
||||
crash_path(path, sizeof path, "");
|
||||
/* Not ".saving": that is where the HTTP server puts a file the web app is saving at the same moment. */
|
||||
crash_path(tmp, sizeof tmp, ".engine");
|
||||
FILE *f = fopen(tmp, "wb");
|
||||
int ok = f != NULL && fwrite(b.data, 1, b.len, f) == b.len;
|
||||
if (f && fclose(f) != 0) ok = 0;
|
||||
if (ok) ok = rename(tmp, path) == 0;
|
||||
if (!ok) unlink(tmp);
|
||||
free(b.data);
|
||||
struct stat st;
|
||||
/* What the engine wrote itself is not news to it. */
|
||||
if (ok && stat(path, &st) == 0) stamp_of(&st, &C.stamp);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Holds back the kept cheats that are marked, and lets go of ones whose mark was taken away. */
|
||||
static void crash_apply_holds(void) {
|
||||
for (int i = 0; i < E.table.count; i++) {
|
||||
struct cheat *c = &E.table.cheats[i];
|
||||
if (c->broken) continue;
|
||||
int marked = crash_marked(c->name);
|
||||
if (marked && c->state == ST_WAITING) {
|
||||
set_detail(c, "%s", CRASH_HELD);
|
||||
settle(c, ST_HELD);
|
||||
} else if (!marked && c->state == ST_HELD) {
|
||||
set_detail(c, "Waiting for the game to finish starting");
|
||||
c->reported = 0;
|
||||
settle(c, ST_WAITING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reads the shared file again when it has changed (or `forced`), then applies the marks to the cheats. */
|
||||
static void crash_refresh(int64_t now, int forced) {
|
||||
if (!E.have_game) return;
|
||||
char path[600];
|
||||
struct stat st;
|
||||
struct file_stamp stamp;
|
||||
memset(&stamp, 0, sizeof stamp);
|
||||
crash_path(path, sizeof path, "");
|
||||
if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) stamp_of(&st, &stamp);
|
||||
if (forced || !same_stamp(&stamp, &C.stamp)) {
|
||||
C.stamp = stamp;
|
||||
crash_read(now, 0);
|
||||
}
|
||||
crash_apply_holds();
|
||||
}
|
||||
|
||||
/* A game has just been seen starting: nothing from an earlier launch is on in it. */
|
||||
static void crash_launch(int64_t now) {
|
||||
memset(&C, 0, sizeof C);
|
||||
snprintf(C.title_id, sizeof C.title_id, "%s", E.game.title_id);
|
||||
C.last_alive = now;
|
||||
char path[600];
|
||||
struct stat st;
|
||||
crash_path(path, sizeof path, "");
|
||||
if (stat(path, &st) == 0 && S_ISREG(st.st_mode)) stamp_of(&st, &C.stamp);
|
||||
crash_read(now, 1);
|
||||
}
|
||||
|
||||
/* The running game was seen running just now. */
|
||||
static void crash_alive(int64_t now) {
|
||||
C.last_alive = now;
|
||||
}
|
||||
|
||||
/* The engine has just switched a kept cheat on. */
|
||||
static void crash_note_on(const struct cheat *c, int64_t now) {
|
||||
int i = 0;
|
||||
while (i < C.on_count && strcmp(C.on[i].name, c->name) != 0) i++;
|
||||
if (i == C.on_count) {
|
||||
if (C.on_count == MAX_KEPT) return;
|
||||
C.on_count++;
|
||||
memcpy(C.on[i].name, c->name, sizeof C.on[i].name);
|
||||
}
|
||||
C.on[i].at = now;
|
||||
}
|
||||
|
||||
static void crash_add_mark(const char *name, const char *by, int64_t at, int *changed) {
|
||||
int i = 0;
|
||||
while (i < C.mark_count && strcmp(C.marks[i].name, name) != 0) i++;
|
||||
if (i == C.mark_count) {
|
||||
if (C.mark_count == CRASH_MAX) {
|
||||
/* The oldest mark makes room. */
|
||||
memmove(&C.marks[0], &C.marks[1], sizeof C.marks[0] * (CRASH_MAX - 1));
|
||||
i = CRASH_MAX - 1;
|
||||
} else {
|
||||
C.mark_count++;
|
||||
}
|
||||
}
|
||||
struct crash_mark *m = &C.marks[i];
|
||||
memset(m, 0, sizeof *m);
|
||||
snprintf(m->name, sizeof m->name, "%s", name);
|
||||
snprintf(m->by, sizeof m->by, "%s", by);
|
||||
snprintf(m->version, sizeof m->version, "%s", E.game.version);
|
||||
m->at = (double)at;
|
||||
*changed = 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* The running game has ended (gone, or replaced by another launch). Marks
|
||||
* what was switched on less than CRASH_WINDOW_MS before it was last seen
|
||||
* running, and drops the page's list for that launch. Called before the
|
||||
* engine forgets the game.
|
||||
*/
|
||||
static void crash_game_ended(int64_t now) {
|
||||
if (!E.have_game || strcmp(C.title_id, E.game.title_id) != 0) return;
|
||||
/* The page may have listed something since the last look. */
|
||||
crash_read(now, 0);
|
||||
int changed = 0;
|
||||
int64_t at = wall_ms();
|
||||
for (int i = 0; i < C.on_count; i++)
|
||||
if (C.last_alive - C.on[i].at <= CRASH_WINDOW_MS) crash_add_mark(C.on[i].name, "ps5", at, &changed);
|
||||
int keep = 0;
|
||||
for (int i = 0; i < C.armed_count; i++) {
|
||||
struct crash_armed *a = &C.armed[i];
|
||||
if ((uint32_t)a->pid != E.game.pid) {
|
||||
C.armed[keep++] = *a;
|
||||
continue;
|
||||
}
|
||||
changed = 1;
|
||||
if (a->seen && C.last_alive - a->seen <= CRASH_WINDOW_MS && !crash_marked(a->name)) crash_add_mark(a->name, "page", at, &changed);
|
||||
}
|
||||
C.armed_count = keep;
|
||||
if (changed) crash_write();
|
||||
C.on_count = 0;
|
||||
}
|
||||
|
||||
/* ---- talking to the game ----------------------------------------------------------- */
|
||||
|
||||
/* The game's memory map, fetched at most once per pass and only when something needs it. */
|
||||
@@ -1602,6 +2079,7 @@ static enum outcome try_patch(struct cheat *c, struct mapview *mv, int64_t now)
|
||||
return O_OK;
|
||||
}
|
||||
|
||||
int wrote = 0;
|
||||
for (int i = 0; i < c->site_count; i++) {
|
||||
struct site *s = &c->sites[i];
|
||||
if (!s->planned) continue;
|
||||
@@ -1609,6 +2087,7 @@ static enum outcome try_patch(struct cheat *c, struct mapview *mv, int64_t now)
|
||||
if (rc == DBG_OK) {
|
||||
s->wrote = 1;
|
||||
s->planned = 0;
|
||||
wrote = 1;
|
||||
continue;
|
||||
}
|
||||
/* With the connection gone the places already written stay; the next attempt carries on from them. */
|
||||
@@ -1623,6 +2102,8 @@ static enum outcome try_patch(struct cheat *c, struct mapview *mv, int64_t now)
|
||||
return O_WAIT;
|
||||
}
|
||||
set_detail(c, "");
|
||||
/* Only a switch the engine made itself counts for crash protection; bytes already in place were put there by someone else. */
|
||||
if (wrote) crash_note_on(c, now);
|
||||
settle(c, ST_ON);
|
||||
return O_OK;
|
||||
}
|
||||
@@ -1685,6 +2166,8 @@ static int freeze_pass(int64_t now) {
|
||||
struct site *s = c->kind == CHEAT_VALUE ? &c->sites[0] : NULL;
|
||||
if (!s || c->broken || !s->anchored || c->retry_at > now) continue;
|
||||
if (c->state != ST_WAITING && c->state != ST_ON) continue;
|
||||
/* A held value from the start set is set once; holding it is the page's job (see startsOnPs5() in table.ts). */
|
||||
if (c->once && c->state == ST_ON) continue;
|
||||
switch (site_address(c, s, &s->address)) {
|
||||
case O_OK:
|
||||
E.writes[n].address = s->address;
|
||||
@@ -1711,7 +2194,8 @@ static int freeze_pass(int64_t now) {
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
struct cheat *c = owner[i];
|
||||
if (rc == DBG_OK && !E.writes[i].failed) {
|
||||
set_detail(c, "");
|
||||
set_detail(c, c->once ? "Set when the game started. Open CheatSpecter to hold it there." : "");
|
||||
if (c->state != ST_ON) crash_note_on(c, now);
|
||||
settle(c, ST_ON);
|
||||
c->write_failures = 0;
|
||||
} else if (c->sites[0].at.kind != LOC_POINTER && ++c->write_failures >= WRITE_ATTEMPTS) {
|
||||
@@ -1800,6 +2284,9 @@ static void adopt_table(struct table *fresh, int exact, int64_t now) {
|
||||
/* Carry on as the old cheat, under the new name and (for a held value) with the new number. */
|
||||
struct cheat kept = *o;
|
||||
memcpy(kept.name, n->name, sizeof kept.name);
|
||||
/* Marked to keep now, or now only in the start set: the file says which. */
|
||||
kept.start_only = n->start_only;
|
||||
kept.once = n->once;
|
||||
if (kept.kind == CHEAT_VALUE) memcpy(kept.sites[0].on, n->sites[0].on, kept.sites[0].on_len);
|
||||
memset(o, 0, sizeof *o); /* its memory now belongs to `kept` */
|
||||
cheat_free(n);
|
||||
@@ -1817,6 +2304,12 @@ static void adopt_table(struct table *fresh, int exact, int64_t now) {
|
||||
struct cheat *o = &old.cheats[k];
|
||||
if (!o->sites) continue;
|
||||
changed = 1;
|
||||
/*
|
||||
* The start set only says what to switch on when the game starts, so a
|
||||
* cheat leaving it stays as it is in the game; one no longer kept on is
|
||||
* put back.
|
||||
*/
|
||||
if (o->start_only) continue;
|
||||
E.cheats_removed = 1;
|
||||
if (o->kind == CHEAT_PATCH) restore_patch(o);
|
||||
}
|
||||
@@ -1904,7 +2397,9 @@ static void start_game(const struct dbg_app *app, int64_t now) {
|
||||
file_version(E.game.version, E.game.file_version);
|
||||
copy_ascii(E.game.process, sizeof E.game.process, app->name);
|
||||
snprintf(E.game.label, sizeof E.game.label, "%s", E.game.title_id);
|
||||
crash_launch(now);
|
||||
load_cheats(now, 1);
|
||||
crash_refresh(now, 0);
|
||||
E.next_file_check = now + FILE_CHECK_MS;
|
||||
E.next_freeze = now;
|
||||
}
|
||||
@@ -1917,13 +2412,22 @@ static int check_foreground(int64_t now) {
|
||||
copy_ascii(tid, sizeof tid, app.title_id);
|
||||
int present = app.pid != 0 && valid_title_id(tid);
|
||||
if (!present) {
|
||||
if (E.have_game && ++E.gone_checks >= GONE_CHECKS) forget_game();
|
||||
if (E.have_game && ++E.gone_checks >= GONE_CHECKS) {
|
||||
crash_game_ended(now);
|
||||
forget_game();
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
E.gone_checks = 0;
|
||||
snprintf(app.title_id, sizeof app.title_id, "%s", tid);
|
||||
/* A new process id is a new launch even of the same game: nothing from before is in its memory. */
|
||||
if (!E.have_game || E.game.pid != app.pid || strcmp(E.game.title_id, tid) != 0) start_game(&app, now);
|
||||
if (!E.have_game || E.game.pid != app.pid || strcmp(E.game.title_id, tid) != 0) {
|
||||
/* The launch before this one has ended. */
|
||||
crash_game_ended(now);
|
||||
start_game(&app, now);
|
||||
} else {
|
||||
crash_alive(now);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1948,7 +2452,7 @@ static void add_line(struct strbuf *b, const char *fmt, ...) {
|
||||
*/
|
||||
static void summarize(int64_t now) {
|
||||
if (!E.summary_due && !E.late_summary_due) return;
|
||||
int waiting = 0, on = 0, mismatched = 0, failed = 0, fresh = 0;
|
||||
int waiting = 0, on = 0, mismatched = 0, failed = 0, held = 0, fresh = 0;
|
||||
for (int i = 0; i < E.table.count; i++) {
|
||||
struct cheat *c = &E.table.cheats[i];
|
||||
if (c->state == ST_WAITING) {
|
||||
@@ -1960,6 +2464,7 @@ static void summarize(int64_t now) {
|
||||
fresh++;
|
||||
if (c->state == ST_MISMATCH) mismatched++;
|
||||
if (c->state == ST_ERROR || c->state == ST_REFUSED) failed++;
|
||||
if (c->state == ST_HELD) held++;
|
||||
}
|
||||
if (waiting > 0 && (E.late_summary_due || now < E.summary_deadline)) return;
|
||||
if (E.summary_due && waiting > 0) E.late_summary_due = 1;
|
||||
@@ -1974,6 +2479,7 @@ static void summarize(int64_t now) {
|
||||
const char *where = on > 0 ? "the game" : game;
|
||||
if (mismatched > 0) add_line(&b, "%d %s not match this version of %s", mismatched, mismatched == 1 ? "cheat does" : "cheats do", where);
|
||||
if (failed > 0) add_line(&b, "%d %s could not be switched on%s%s", failed, failed == 1 ? "cheat" : "cheats", on > 0 || mismatched > 0 ? "" : " for ", on > 0 || mismatched > 0 ? "" : game);
|
||||
if (held > 0) add_line(&b, "%d %s held back: the game closed soon after %s switched on last time", held, held == 1 ? "cheat" : "cheats", held == 1 ? "it was" : "they were");
|
||||
}
|
||||
E.cheats_removed = 0;
|
||||
for (int i = 0; i < E.table.count; i++)
|
||||
@@ -1988,6 +2494,8 @@ static void publish(void) {
|
||||
char num[32];
|
||||
sb_add(&b, "{\"running\":");
|
||||
sb_add(&b, E.running ? "true" : "false");
|
||||
/* Tells the web app that this engine marks cheats that may have crashed the game (crash protection). */
|
||||
sb_add(&b, ",\"crashGuard\":true");
|
||||
/* Without the debugger nothing below can be vouched for, so nothing is claimed. */
|
||||
int show = E.running && E.have_game;
|
||||
if (show) {
|
||||
@@ -2011,13 +2519,14 @@ static void publish(void) {
|
||||
sb_add(&b, ",\"cheats\":[");
|
||||
for (int i = 0; show && i < E.table.count; i++) {
|
||||
const struct cheat *c = &E.table.cheats[i];
|
||||
static const char *const STATES[] = {"waiting", "on", "mismatch", "error", "refused"};
|
||||
static const char *const STATES[] = {"waiting", "on", "mismatch", "error", "refused", "held"};
|
||||
sb_add(&b, i ? ",{\"name\":" : "{\"name\":");
|
||||
sb_add_json(&b, c->name);
|
||||
sb_add(&b, ",\"state\":\"");
|
||||
sb_add(&b, STATES[c->state]);
|
||||
sb_add(&b, "\",\"detail\":");
|
||||
sb_add_json(&b, c->detail);
|
||||
if (c->start_only) sb_add(&b, ",\"start\":true");
|
||||
sb_add(&b, "}");
|
||||
}
|
||||
sb_add(&b, "]}");
|
||||
@@ -2089,6 +2598,7 @@ static void *engine_thread(void *arg) {
|
||||
if (reload || now >= E.next_file_check) {
|
||||
E.next_file_check = now + FILE_CHECK_MS;
|
||||
load_cheats(now, reload);
|
||||
crash_refresh(now, reload);
|
||||
reload = 0;
|
||||
}
|
||||
if (dbg_is_open(&E.dbg) && work_on_cheats(now) && now >= E.next_freeze) {
|
||||
|
||||
+227
-4
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <netinet/in.h>
|
||||
@@ -50,6 +51,7 @@
|
||||
#include "assets.h"
|
||||
#include "engine.h"
|
||||
#include "json.h"
|
||||
#include "pad.h"
|
||||
|
||||
#define DEFAULT_PORT 8744
|
||||
#define DEFAULT_DEBUGGER_PORT 744
|
||||
@@ -1322,6 +1324,187 @@ static void store_table(int fd, const char *head, const char *rest, const char *
|
||||
}
|
||||
}
|
||||
|
||||
/* DELETE /api/tables/<title id>/<version>: removes that one file, and nothing else. */
|
||||
static void delete_table(int fd, const char *head, const char *rest) {
|
||||
char tid[10], version[17], path[512];
|
||||
struct stat st;
|
||||
if (!same_origin(head)) {
|
||||
send_simple(fd, "403 Forbidden", "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
/* The title ID and version are checked character by character, so the path cannot leave the cheats folder. */
|
||||
if (!split_table_ref(rest, tid, version)) {
|
||||
send_simple(fd, "400 Bad Request", "text/plain", "Bad request");
|
||||
return;
|
||||
}
|
||||
table_path(tid, version, "", path, sizeof path);
|
||||
/* Only an ordinary file is deleted: not a folder, and not a link that happens to have a cheat file's name. */
|
||||
if (lstat(path, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||
send_simple(fd, "404 Not Found", "text/plain", "Not found");
|
||||
return;
|
||||
}
|
||||
if (unlink(path) != 0) {
|
||||
send_simple(fd, "500 Internal Server Error", "text/plain", "Could not delete the file");
|
||||
return;
|
||||
}
|
||||
/* The engine may be keeping cheats from this file on: have it notice at once. */
|
||||
engine_reload();
|
||||
write_all(fd, NO_CONTENT, sizeof NO_CONTENT - 1);
|
||||
}
|
||||
|
||||
/* ---- cheat files other tools keep on the console ------------------------------------ */
|
||||
|
||||
/*
|
||||
* Read-only. The web app lists the files for one game and reads the ones the
|
||||
* person picks; it never writes here. Paths are built from these fixed
|
||||
* folders and a checked file name, never taken from the request as they are.
|
||||
*/
|
||||
static const struct {
|
||||
const char *key;
|
||||
const char *dir;
|
||||
} CHEAT_FOLDERS[] = {
|
||||
{"etahen", "/data/etaHEN/cheats"},
|
||||
{"elf-arsenal", "/data/elf-arsenal/cheats"},
|
||||
{"goldhen", "/data/GoldHEN/cheats"},
|
||||
};
|
||||
/* Each folder is read itself and in the subfolders the tools sort files into. */
|
||||
static const char *const CHEAT_SUBFOLDERS[] = {"", "json", "shn", "mc4"};
|
||||
#define CHEAT_NAME_MAX 128
|
||||
|
||||
/* A cheat file name for this title: "<title id>...<.json|.shn|.mc4>", in plain characters, with no way out of its folder. */
|
||||
static int cheat_file_name(const char *name, const char *tid) {
|
||||
size_t n = strlen(name);
|
||||
if (n < 9 + 4 || n > CHEAT_NAME_MAX || strncasecmp(name, tid, 9) != 0 || strstr(name, "..")) return 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
char c = name[i];
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || strchr("._-+() ", c))) return 0;
|
||||
}
|
||||
const char *dot = strrchr(name, '.');
|
||||
return dot && (strcasecmp(dot, ".json") == 0 || strcasecmp(dot, ".shn") == 0 || strcasecmp(dot, ".mc4") == 0);
|
||||
}
|
||||
|
||||
/* Undoes the %XX escapes of one path segment. Returns 0 when it holds a slash, a NUL or a broken escape. */
|
||||
static int url_segment(const char *in, size_t len, char *out, size_t cap) {
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = in[i];
|
||||
if (c == '%') {
|
||||
if (i + 2 >= len || !isxdigit((unsigned char)in[i + 1]) || !isxdigit((unsigned char)in[i + 2])) return 0;
|
||||
char hex[3] = {in[i + 1], in[i + 2], 0};
|
||||
long v = strtol(hex, NULL, 16);
|
||||
if (v == 0 || v == '/') return 0;
|
||||
c = (char)v;
|
||||
i += 2;
|
||||
}
|
||||
if (c == '/' || o + 1 >= cap) return 0;
|
||||
out[o++] = c;
|
||||
}
|
||||
out[o] = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int cheat_folder_path(int folder, int sub, char *out, size_t cap) {
|
||||
int n = snprintf(out, cap, "%s%s%s%s", g_root, CHEAT_FOLDERS[folder].dir, CHEAT_SUBFOLDERS[sub][0] ? "/" : "", CHEAT_SUBFOLDERS[sub]);
|
||||
return n > 0 && (size_t)n < cap;
|
||||
}
|
||||
|
||||
/* GET /api/console-cheats/<title id>: {"files":[{"path","folder","name","size"}]} */
|
||||
static void list_console_cheats(int fd, const char *tid) {
|
||||
char dir[512], path[800], item[1400];
|
||||
struct strbuf b = {0};
|
||||
int written = 0;
|
||||
if (!valid_title_id(tid)) {
|
||||
send_simple(fd, "404 Not Found", "text/plain", "Not found");
|
||||
return;
|
||||
}
|
||||
sb_add(&b, "{\"files\":[");
|
||||
for (int f = 0; f < (int)(sizeof CHEAT_FOLDERS / sizeof CHEAT_FOLDERS[0]); f++) {
|
||||
for (int s = 0; s < (int)(sizeof CHEAT_SUBFOLDERS / sizeof CHEAT_SUBFOLDERS[0]); s++) {
|
||||
if (!cheat_folder_path(f, s, dir, sizeof dir)) continue;
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) continue;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL && written < 200) {
|
||||
struct stat st;
|
||||
if (!cheat_file_name(e->d_name, tid)) continue;
|
||||
int n = snprintf(path, sizeof path, "%s/%s", dir, e->d_name);
|
||||
if (n < 0 || (size_t)n >= sizeof path) continue;
|
||||
if (lstat(path, &st) != 0 || !S_ISREG(st.st_mode) || (uint64_t)st.st_size > TABLE_MAX) continue;
|
||||
/* The name holds only plain characters (cheat_file_name), so it needs no escaping in JSON. */
|
||||
snprintf(item, sizeof item, "%s{\"path\":\"%s%s%s/%s\",\"folder\":\"%s%s%s\",\"name\":\"%s\",\"size\":%lld}", written++ ? "," : "",
|
||||
CHEAT_FOLDERS[f].key, CHEAT_SUBFOLDERS[s][0] ? "/" : "", CHEAT_SUBFOLDERS[s], e->d_name, CHEAT_FOLDERS[f].dir,
|
||||
CHEAT_SUBFOLDERS[s][0] ? "/" : "", CHEAT_SUBFOLDERS[s], e->d_name, (long long)st.st_size);
|
||||
sb_add(&b, item);
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
}
|
||||
sb_add(&b, "]}");
|
||||
if (b.failed) send_simple(fd, "500 Internal Server Error", "text/plain", "Out of memory");
|
||||
else send_simple(fd, "200 OK", "application/json; charset=utf-8", b.data);
|
||||
free(b.data);
|
||||
}
|
||||
|
||||
/* GET /api/console-cheats/<title id>/<folder>[/<subfolder>]/<file name>: the file as it is. */
|
||||
static void serve_console_cheat(int fd, const char *rest, int head_only) {
|
||||
char tid[10], name[CHEAT_NAME_MAX + 1], path[700];
|
||||
const char *parts[4];
|
||||
size_t lens[4];
|
||||
int count = 0;
|
||||
struct stat st;
|
||||
int folder = -1, sub = 0;
|
||||
|
||||
/* "<title id>/<folder>/<name>" or "<title id>/<folder>/<subfolder>/<name>" */
|
||||
const char *p = rest;
|
||||
while (count < 4) {
|
||||
const char *slash = strchr(p, '/');
|
||||
parts[count] = p;
|
||||
lens[count] = slash ? (size_t)(slash - p) : strlen(p);
|
||||
count++;
|
||||
if (!slash) break;
|
||||
p = slash + 1;
|
||||
}
|
||||
int ok = (count == 3 || count == 4) && !strchr(parts[count - 1], '/') && lens[0] == 9;
|
||||
if (ok) {
|
||||
memcpy(tid, parts[0], 9);
|
||||
tid[9] = 0;
|
||||
ok = valid_title_id(tid);
|
||||
}
|
||||
for (int f = 0; ok && f < (int)(sizeof CHEAT_FOLDERS / sizeof CHEAT_FOLDERS[0]); f++)
|
||||
if (strlen(CHEAT_FOLDERS[f].key) == lens[1] && strncmp(parts[1], CHEAT_FOLDERS[f].key, lens[1]) == 0) folder = f;
|
||||
if (ok && count == 4) {
|
||||
sub = -1;
|
||||
for (int s = 1; s < (int)(sizeof CHEAT_SUBFOLDERS / sizeof CHEAT_SUBFOLDERS[0]); s++)
|
||||
if (strlen(CHEAT_SUBFOLDERS[s]) == lens[2] && strncmp(parts[2], CHEAT_SUBFOLDERS[s], lens[2]) == 0) sub = s;
|
||||
}
|
||||
ok = ok && folder >= 0 && sub >= 0 && url_segment(parts[count - 1], lens[count - 1], name, sizeof name) && cheat_file_name(name, tid);
|
||||
FILE *f = NULL;
|
||||
if (ok && cheat_folder_path(folder, sub, path, sizeof path)) {
|
||||
size_t used = strlen(path);
|
||||
snprintf(path + used, sizeof path - used, "/%s", name);
|
||||
if (lstat(path, &st) == 0 && S_ISREG(st.st_mode) && (uint64_t)st.st_size <= TABLE_MAX) f = fopen(path, "rb");
|
||||
}
|
||||
if (!f) {
|
||||
send_simple(fd, "404 Not Found", "text/plain", "Not found");
|
||||
return;
|
||||
}
|
||||
size_t size = (size_t)st.st_size;
|
||||
char *data = malloc(size + 1);
|
||||
if (data && fread(data, 1, size, f) == size) {
|
||||
char head[256];
|
||||
/* As bytes: these files come as UTF-8 or UTF-16, and the web app works out which. */
|
||||
int n = snprintf(head, sizeof head,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %zu\r\n"
|
||||
"X-Content-Type-Options: nosniff\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
|
||||
size);
|
||||
if (n > 0 && write_all(fd, head, (size_t)n) == 0 && !head_only) write_all(fd, data, size);
|
||||
} else {
|
||||
send_simple(fd, "500 Internal Server Error", "text/plain", "Could not read the file");
|
||||
}
|
||||
free(data);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* ---- settings the web app keeps on the console ---------------------------------------- */
|
||||
|
||||
/* One small JSON file per name: /data/cheatspecter/prefs/<name>.json */
|
||||
@@ -1418,6 +1601,7 @@ static void store_pref(int fd, const char *head, const char *name, const char *e
|
||||
if (ok) ok = rename(tmp, path) == 0;
|
||||
if (!ok) unlink(tmp);
|
||||
free(body);
|
||||
if (ok && strcmp(name, PAD_PREF_NAME) == 0) pad_settings_changed();
|
||||
if (ok) write_all(fd, NO_CONTENT, sizeof NO_CONTENT - 1);
|
||||
else send_simple(fd, "500 Internal Server Error", "text/plain", "Could not save the file");
|
||||
}
|
||||
@@ -1443,6 +1627,28 @@ static void serve_engine_reload(int fd, const char *head) {
|
||||
write_all(fd, NO_CONTENT, sizeof NO_CONTENT - 1);
|
||||
}
|
||||
|
||||
/* ---- the controller shortcut ------------------------------------------------------- */
|
||||
|
||||
/* GET /api/pad: whether the controller shortcut is on and what it is doing. */
|
||||
static void serve_pad(int fd) {
|
||||
char *status = pad_status_json();
|
||||
if (status) send_simple(fd, "200 OK", "application/json; charset=utf-8", status);
|
||||
else send_simple(fd, "500 Internal Server Error", "text/plain", "Out of memory");
|
||||
free(status);
|
||||
}
|
||||
|
||||
/* The running game's title id, as the cheat engine last saw it. Called by the pad thread only when the shortcut goes off. */
|
||||
static int running_title(char *out) {
|
||||
char *status = engine_status_json();
|
||||
if (!status) return 0;
|
||||
const char *game = strstr(status, "\"game\":{");
|
||||
char tid[16];
|
||||
int ok = game && json_string_after(game, game + strlen(game), "titleId", tid, sizeof tid) && valid_title_id(tid);
|
||||
if (ok) memcpy(out, tid, 10);
|
||||
free(status);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* ---- request dispatch ---------------------------------------------------------- */
|
||||
|
||||
static void handle_connection(int fd) {
|
||||
@@ -1466,15 +1672,21 @@ static void handle_connection(int fd) {
|
||||
|
||||
int is_head = strcmp(method, "HEAD") == 0;
|
||||
if (strcmp(method, "POST") == 0 && strcmp(target, "/api/shutdown") == 0) {
|
||||
/* A newer copy of this payload asking the running one to make way. Only
|
||||
something on the console itself may ask. */
|
||||
/* A newer copy of this payload asking the running one to make way (only
|
||||
something on the console itself may), or the app's own Settings screen.
|
||||
The app sends a header a page from another site cannot add without
|
||||
asking first (which this server never allows), and its Origin must be
|
||||
this server. */
|
||||
struct sockaddr_in peer;
|
||||
socklen_t plen = sizeof peer;
|
||||
if (getpeername(fd, (struct sockaddr *)&peer, &plen) == 0 && peer.sin_family == AF_INET &&
|
||||
peer.sin_addr.s_addr == htonl(INADDR_LOOPBACK)) {
|
||||
char asked[32];
|
||||
int from_app = header_value(head, "X-CheatSpecter", asked, sizeof asked) && strcmp(asked, "shutdown") == 0 && same_origin(head);
|
||||
if (from_app || (getpeername(fd, (struct sockaddr *)&peer, &plen) == 0 && peer.sin_family == AF_INET &&
|
||||
peer.sin_addr.s_addr == htonl(INADDR_LOOPBACK))) {
|
||||
send_simple(fd, "200 OK", "text/plain", "bye");
|
||||
shutdown(fd, SHUT_RDWR);
|
||||
close(fd);
|
||||
pad_stop();
|
||||
_exit(0);
|
||||
}
|
||||
send_simple(fd, "403 Forbidden", "text/plain", "Forbidden");
|
||||
@@ -1485,6 +1697,8 @@ static void handle_connection(int fd) {
|
||||
serve_launch(fd, head, target + 12);
|
||||
} else if (strcmp(method, "PUT") == 0 && strncmp(target, "/api/tables/", 12) == 0) {
|
||||
store_table(fd, head, target + 12, body, got - (size_t)(body - head));
|
||||
} else if (strcmp(method, "DELETE") == 0 && strncmp(target, "/api/tables/", 12) == 0) {
|
||||
delete_table(fd, head, target + 12);
|
||||
} else if (strcmp(method, "PUT") == 0 && strncmp(target, "/api/prefs/", 11) == 0) {
|
||||
store_pref(fd, head, target + 11, body, got - (size_t)(body - head));
|
||||
} else if (strcmp(method, "POST") == 0 && strcmp(target, "/api/engine/reload") == 0) {
|
||||
@@ -1505,8 +1719,13 @@ static void handle_connection(int fd) {
|
||||
else list_tables(fd, target + 12);
|
||||
} else if (strncmp(target, "/api/prefs/", 11) == 0) {
|
||||
serve_pref(fd, target + 11, is_head);
|
||||
} else if (strncmp(target, "/api/console-cheats/", 20) == 0) {
|
||||
if (strchr(target + 20, '/')) serve_console_cheat(fd, target + 20, is_head);
|
||||
else list_console_cheats(fd, target + 20);
|
||||
} else if (strcmp(target, "/api/engine") == 0) {
|
||||
serve_engine(fd);
|
||||
} else if (strcmp(target, "/api/pad") == 0) {
|
||||
serve_pad(fd);
|
||||
} else if (strcmp(target, "/api/library") == 0) {
|
||||
serve_library(fd);
|
||||
} else if (strncmp(target, "/api/appmeta/", 13) == 0) {
|
||||
@@ -1650,6 +1869,10 @@ int main(void) {
|
||||
struct engine_config engine = {g_root, g_debugger_port, notify, g_engine_speed, browser_busy};
|
||||
engine_start(&engine);
|
||||
|
||||
/* The controller shortcut. It does nothing (and leaves the controller alone) until switched on in the web app. */
|
||||
struct pad_config pad = {g_root, g_port, notify, running_title, g_engine_speed};
|
||||
pad_start(&pad);
|
||||
|
||||
char ip[64], msg[256];
|
||||
if (lan_address(ip, sizeof ip) == 0) {
|
||||
snprintf(msg, sizeof msg, "CheatSpecter %s is ready\nOpen http://%s:%d in a browser", CS_VERSION, ip, g_port);
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only
|
||||
*
|
||||
* The controller shortcut. See pad.h for what it does and how it is shared.
|
||||
*
|
||||
* How the controller is read: the payload is a process of its own, not the
|
||||
* game, so it opens its own handle on the signed-in user's controller and
|
||||
* only ever asks for the current state (scePadReadState). Each process gets
|
||||
* its own copy of every press, so nothing is taken from the game; nothing
|
||||
* here asks for focus, remaps buttons or changes the light bar. A process in
|
||||
* the background is normally sent empty readings, which is what
|
||||
* scePadSetProcessPrivilege(1) lifts (etaHEN's daemon declares it for the
|
||||
* same reason).
|
||||
*
|
||||
* How the browser is opened: sceSystemServiceLaunchWebBrowser, as the
|
||||
* ps5-payload-sdk "browser" sample and ps5-payload-manager do. It needs the
|
||||
* user service, which main() starts before anything else.
|
||||
*
|
||||
* A similar tool has been reported to make the home screen crash on some
|
||||
* firmware. So that a crash while opening the controller cannot turn into a
|
||||
* crash at every start (the payload may be loaded automatically at boot), a
|
||||
* marker file is written just before the controller is opened and removed
|
||||
* once it has been read without trouble for a while, or when the payload
|
||||
* stops normally. Finding the marker at start means the console stopped in
|
||||
* that window last time: the shortcut then waits, paused, until the person
|
||||
* saves the setting again.
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "json.h"
|
||||
#include "pad.h"
|
||||
|
||||
/* How often the controller is looked at. Ten times a second is cheap and plenty for a hold. */
|
||||
#define POLL_MS 100
|
||||
/* How often the prefs file and the signed-in user are checked when nothing says they changed. */
|
||||
#define CHECK_MS 2000
|
||||
/* Retries of a controller that could not be opened or read slow down to this. */
|
||||
#define MAX_RETRY_MS 30000
|
||||
/* The controller has been read this long without trouble: the marker can go. */
|
||||
#define GUARD_CLEAR_MS 20000
|
||||
#define GUARD_FILE "/data/cheatspecter/pad-starting"
|
||||
#define PREFS_FILE "/data/cheatspecter/prefs/" PAD_PREF_NAME ".json"
|
||||
#define PREFS_MAX (64u << 10)
|
||||
|
||||
/* ---- the parts that decide (no console calls; built everywhere) ------------------ */
|
||||
|
||||
static const struct {
|
||||
const char *name;
|
||||
uint32_t bit;
|
||||
} BUTTONS[] = {
|
||||
{"L1", PAD_L1}, {"R1", PAD_R1}, {"L2", PAD_L2}, {"R2", PAD_R2},
|
||||
{"L3", PAD_L3}, {"R3", PAD_R3}, {"Triangle", PAD_TRIANGLE}, {"Circle", PAD_CIRCLE},
|
||||
{"Cross", PAD_CROSS}, {"Square", PAD_SQUARE}, {"Up", PAD_UP}, {"Down", PAD_DOWN},
|
||||
{"Left", PAD_LEFT}, {"Right", PAD_RIGHT}, {"Options", PAD_OPTIONS}, {"Touchpad", PAD_TOUCHPAD},
|
||||
};
|
||||
#define BUTTON_COUNT ((int)(sizeof BUTTONS / sizeof BUTTONS[0]))
|
||||
|
||||
uint32_t pad_button_bit(const char *name) {
|
||||
for (int i = 0; i < BUTTON_COUNT; i++)
|
||||
if (strcasecmp(BUTTONS[i].name, name) == 0) return BUTTONS[i].bit;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int bit_count(uint32_t v) {
|
||||
int n = 0;
|
||||
for (; v; v &= v - 1) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
void pad_describe(uint32_t buttons, char *out, size_t cap) {
|
||||
size_t used = 0;
|
||||
if (cap == 0) return;
|
||||
out[0] = 0;
|
||||
for (int i = 0; i < BUTTON_COUNT; i++) {
|
||||
if (!(buttons & BUTTONS[i].bit)) continue;
|
||||
int n = snprintf(out + used, cap - used, "%s%s", used ? " + " : "", BUTTONS[i].name);
|
||||
if (n < 0 || (size_t)n >= cap - used) break;
|
||||
used += (size_t)n;
|
||||
}
|
||||
}
|
||||
|
||||
static void default_settings(struct pad_settings *s) {
|
||||
s->enabled = 0;
|
||||
s->buttons = PAD_DEFAULT_BUTTONS;
|
||||
s->hold_ms = PAD_DEFAULT_HOLD_MS;
|
||||
}
|
||||
|
||||
int pad_parse_settings(const char *json, size_t len, struct pad_settings *out) {
|
||||
struct json_reader r;
|
||||
char key[32], name[16];
|
||||
default_settings(out);
|
||||
json_init(&r, json, len);
|
||||
if (json_peek(&r) != JSON_OBJECT || json_object_begin(&r) != 0) return -1;
|
||||
int enabled = 0;
|
||||
uint32_t buttons = 0;
|
||||
int buttons_ok = 0;
|
||||
double hold = 0;
|
||||
int hold_ok = 0;
|
||||
for (int f = 1; json_object_next(&r, f, key, sizeof key) > 0; f = 0) {
|
||||
enum json_type t = json_peek(&r);
|
||||
if (strcmp(key, "enabled") == 0 && (t == JSON_TRUE || t == JSON_FALSE)) {
|
||||
json_bool(&r, &enabled);
|
||||
} else if (strcmp(key, "holdMs") == 0 && t == JSON_NUMBER) {
|
||||
hold_ok = json_number(&r, &hold) == 0;
|
||||
} else if (strcmp(key, "buttons") == 0 && t == JSON_ARRAY) {
|
||||
buttons_ok = 1;
|
||||
if (json_array_begin(&r) != 0) break;
|
||||
for (int af = 1; json_array_next(&r, af) > 0; af = 0) {
|
||||
uint32_t bit = 0;
|
||||
if (json_peek(&r) == JSON_STRING) {
|
||||
long n = json_string(&r, name, sizeof name);
|
||||
if (n >= 0 && n < (long)sizeof name) bit = pad_button_bit(name);
|
||||
} else {
|
||||
json_skip(&r);
|
||||
}
|
||||
/* One name that is not a button spoils the list: better the default than a combination missing a button. */
|
||||
if (!bit) buttons_ok = 0;
|
||||
buttons |= bit;
|
||||
}
|
||||
} else {
|
||||
json_skip(&r);
|
||||
}
|
||||
}
|
||||
if (r.failed || !json_at_end(&r)) {
|
||||
default_settings(out);
|
||||
return -1;
|
||||
}
|
||||
out->enabled = enabled;
|
||||
int count = bit_count(buttons);
|
||||
if (buttons_ok && count >= PAD_MIN_BUTTONS && count <= PAD_MAX_BUTTONS) out->buttons = buttons;
|
||||
if (hold_ok && hold >= PAD_MIN_HOLD_MS && hold <= PAD_MAX_HOLD_MS) out->hold_ms = (int)hold;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void pad_tracker_reset(struct pad_tracker *t) {
|
||||
int64_t fired = t->fired_at;
|
||||
memset(t, 0, sizeof *t);
|
||||
t->fired_at = fired;
|
||||
/* Buttons that were already down when watching (re)started do not count as a hold. */
|
||||
t->need_release = 1;
|
||||
}
|
||||
|
||||
int pad_track(struct pad_tracker *t, const struct pad_settings *s, int64_t now_ms, int connected, uint32_t buttons) {
|
||||
if (!connected) {
|
||||
pad_tracker_reset(t);
|
||||
return 0;
|
||||
}
|
||||
if (t->last_ms != 0 && (now_ms - t->last_ms > PAD_GAP_MS || now_ms < t->last_ms)) pad_tracker_reset(t);
|
||||
t->last_ms = now_ms;
|
||||
int down = (buttons & s->buttons) == s->buttons;
|
||||
if (!down) {
|
||||
t->holding = 0;
|
||||
t->need_release = 0;
|
||||
return 0;
|
||||
}
|
||||
if (t->need_release) return 0;
|
||||
if (!t->holding) {
|
||||
t->holding = 1;
|
||||
t->held_since = now_ms;
|
||||
}
|
||||
if (now_ms - t->held_since < s->hold_ms) return 0;
|
||||
/* Held long enough. Either way this hold is used up. */
|
||||
t->holding = 0;
|
||||
t->need_release = 1;
|
||||
if (t->fired_at != 0 && now_ms - t->fired_at < PAD_COOLDOWN_MS) return 0;
|
||||
t->fired_at = now_ms;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void pad_page_url(int port, const char *title_id, char *out, size_t cap) {
|
||||
/* The same form the web app's own addresses take (src/lib/route.ts), so it lands on that game's Cheats screen. */
|
||||
if (title_id && title_id[0]) snprintf(out, cap, "http://127.0.0.1:%d/#/game/%s/cheats", port, title_id);
|
||||
else snprintf(out, cap, "http://127.0.0.1:%d/", port);
|
||||
}
|
||||
|
||||
/* ---- talking to the console ------------------------------------------------------ */
|
||||
|
||||
/* Every call returns a negative number on failure; the number is shown in the status as a code. */
|
||||
|
||||
#ifdef __PROSPERO__
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
int sceUserServiceGetForegroundUser(int *user_id);
|
||||
int sceSystemServiceLaunchWebBrowser(const char *uri, void *param);
|
||||
|
||||
/*
|
||||
* The controller library is loaded only once the shortcut is switched on,
|
||||
* not linked: a payload that needed it to start would not start at all on a
|
||||
* console where it cannot be loaded, and while the shortcut is off this
|
||||
* process should not be one the controller service knows about.
|
||||
*/
|
||||
static int (*scePadInit)(void);
|
||||
static int (*scePadSetProcessPrivilege)(int privilege);
|
||||
static int (*scePadOpen)(int user_id, int type, int index, const void *param);
|
||||
static int (*scePadGetHandle)(int user_id, int type, int index);
|
||||
static int (*scePadReadState)(int handle, void *data);
|
||||
static int (*scePadClose)(int handle);
|
||||
|
||||
#define PAD_ERROR_ALREADY_OPENED ((int)0x80920004)
|
||||
|
||||
static int drv_available(void) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int drv_init(void) {
|
||||
void *lib = dlopen("libScePad.sprx", RTLD_LAZY);
|
||||
if (!lib) return -1;
|
||||
*(void **)&scePadInit = dlsym(lib, "scePadInit");
|
||||
*(void **)&scePadSetProcessPrivilege = dlsym(lib, "scePadSetProcessPrivilege");
|
||||
*(void **)&scePadOpen = dlsym(lib, "scePadOpen");
|
||||
*(void **)&scePadGetHandle = dlsym(lib, "scePadGetHandle");
|
||||
*(void **)&scePadReadState = dlsym(lib, "scePadReadState");
|
||||
*(void **)&scePadClose = dlsym(lib, "scePadClose");
|
||||
if (!scePadInit || !scePadOpen || !scePadGetHandle || !scePadReadState || !scePadClose) return -2;
|
||||
/* Already done by something else in this process is fine too. */
|
||||
scePadInit();
|
||||
if (scePadSetProcessPrivilege) scePadSetProcessPrivilege(1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int drv_user(int *user) {
|
||||
int rc = sceUserServiceGetForegroundUser(user);
|
||||
return rc == 0 && *user > 0 ? 0 : (rc < 0 ? rc : -1);
|
||||
}
|
||||
|
||||
static int drv_open(int user) {
|
||||
/* Port type 0, index 0: the user's standard controller, the one they play with. */
|
||||
int h = scePadOpen(user, 0, 0, NULL);
|
||||
if (h == PAD_ERROR_ALREADY_OPENED) h = scePadGetHandle(user, 0, 0);
|
||||
return h;
|
||||
}
|
||||
|
||||
static int drv_read(int handle, int *connected, uint32_t *buttons) {
|
||||
/*
|
||||
* ScePadData is about 0x80 bytes and has grown between system versions, so
|
||||
* it is read into room to spare. Only two fields are used, at offsets that
|
||||
* have not moved: the buttons first, and the "connected" flag at 0x4c.
|
||||
*/
|
||||
_Alignas(16) unsigned char data[512];
|
||||
memset(data, 0, sizeof data);
|
||||
int rc = scePadReadState(handle, data);
|
||||
if (rc < 0) return rc;
|
||||
memcpy(buttons, data, sizeof *buttons);
|
||||
*connected = data[0x4c] != 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void drv_close(int handle) {
|
||||
scePadClose(handle);
|
||||
}
|
||||
|
||||
static int drv_browser(const char *url) {
|
||||
return sceSystemServiceLaunchWebBrowser(url, NULL);
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
/*
|
||||
* On a computer there is no controller. When CS_PAD_FILE names a file, its
|
||||
* text stands in for one, so tests can press buttons: button names separated
|
||||
* by spaces ("L2 R3"), "disconnected", "fail" (a read error) or "nouser"
|
||||
* (nobody signed in); "user <n>" first switches the signed-in user. Opening
|
||||
* the browser appends the address to CS_BROWSER_LOG.
|
||||
*/
|
||||
|
||||
static int drv_available(void) {
|
||||
return getenv("CS_PAD_FILE") != NULL;
|
||||
}
|
||||
|
||||
static int drv_init(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int read_stand_in(char *buf, size_t cap) {
|
||||
const char *path = getenv("CS_PAD_FILE");
|
||||
FILE *f = path ? fopen(path, "rb") : NULL;
|
||||
if (!f) return -1;
|
||||
size_t n = fread(buf, 1, cap - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int drv_user(int *user) {
|
||||
char buf[256];
|
||||
if (read_stand_in(buf, sizeof buf) != 0) return -1;
|
||||
if (strstr(buf, "nouser")) return -2;
|
||||
const char *u = strstr(buf, "user ");
|
||||
*user = u ? atoi(u + 5) : 1;
|
||||
return *user > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static int g_next_handle = 1;
|
||||
|
||||
static int drv_open(int user) {
|
||||
(void)user;
|
||||
return g_next_handle++;
|
||||
}
|
||||
|
||||
static int drv_read(int handle, int *connected, uint32_t *buttons) {
|
||||
char buf[256];
|
||||
(void)handle;
|
||||
if (read_stand_in(buf, sizeof buf) != 0) return -3;
|
||||
if (strstr(buf, "fail")) return -4;
|
||||
*connected = strstr(buf, "disconnected") == NULL;
|
||||
*buttons = 0;
|
||||
for (char *save = NULL, *word = strtok_r(buf, " \t\r\n", &save); word; word = strtok_r(NULL, " \t\r\n", &save))
|
||||
*buttons |= pad_button_bit(word);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void drv_close(int handle) {
|
||||
(void)handle;
|
||||
}
|
||||
|
||||
static int drv_browser(const char *url) {
|
||||
const char *log = getenv("CS_BROWSER_LOG");
|
||||
FILE *f = log ? fopen(log, "a") : NULL;
|
||||
if (!f) return -1;
|
||||
fprintf(f, "%s\n", url);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/* ---- the thread ------------------------------------------------------------------- */
|
||||
|
||||
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_t g_thread;
|
||||
static int g_started;
|
||||
static int g_stop;
|
||||
static int g_changed;
|
||||
|
||||
/* The snapshot pad_status_json hands out; written by the pad thread under g_lock. */
|
||||
static struct pad_settings g_shown_settings = {0, PAD_DEFAULT_BUTTONS, PAD_DEFAULT_HOLD_MS};
|
||||
static const char *g_state = "off";
|
||||
static char g_detail[160];
|
||||
static int g_fired;
|
||||
|
||||
static struct pad_config P;
|
||||
|
||||
static int64_t now_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
static int should_stop(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int stop = g_stop;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return stop;
|
||||
}
|
||||
|
||||
/* Sleeps in small steps, so stopping never waits long. */
|
||||
static void nap(int ms) {
|
||||
while (ms > 0 && !should_stop()) {
|
||||
int step = ms < 50 ? ms : 50;
|
||||
usleep((useconds_t)step * 1000);
|
||||
ms -= step;
|
||||
}
|
||||
}
|
||||
|
||||
static void set_state(const struct pad_settings *s, const char *state, const char *detail) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_shown_settings = *s;
|
||||
g_state = state;
|
||||
snprintf(g_detail, sizeof g_detail, "%s", detail ? detail : "");
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
static void path_of(const char *rel, char *out, size_t cap) {
|
||||
snprintf(out, cap, "%s%s", P.root, rel);
|
||||
}
|
||||
|
||||
/* Reads the setting. Leaves *s alone (and returns 0) when the file has not changed since `stamp`. */
|
||||
static int load_settings(struct pad_settings *s, struct stat *stamp) {
|
||||
char path[512];
|
||||
struct stat st;
|
||||
path_of(PREFS_FILE, path, sizeof path);
|
||||
if (stat(path, &st) != 0 || !S_ISREG(st.st_mode) || (uint64_t)st.st_size > PREFS_MAX) {
|
||||
/* No file (or not one that can be a setting): the shortcut is off. */
|
||||
int had = stamp->st_ino != 0 || stamp->st_mtime != 0;
|
||||
memset(stamp, 0, sizeof *stamp);
|
||||
default_settings(s);
|
||||
return had;
|
||||
}
|
||||
if (st.st_ino == stamp->st_ino && st.st_mtime == stamp->st_mtime && st.st_size == stamp->st_size) return 0;
|
||||
*stamp = st;
|
||||
FILE *f = fopen(path, "rb");
|
||||
char *text = f ? malloc((size_t)st.st_size + 1) : NULL;
|
||||
size_t n = text ? fread(text, 1, (size_t)st.st_size, f) : 0;
|
||||
if (f) fclose(f);
|
||||
if (text) pad_parse_settings(text, n, s);
|
||||
else default_settings(s);
|
||||
free(text);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int guard_exists(void) {
|
||||
char path[512];
|
||||
struct stat st;
|
||||
path_of(GUARD_FILE, path, sizeof path);
|
||||
return stat(path, &st) == 0;
|
||||
}
|
||||
|
||||
static void guard_set(int on) {
|
||||
char path[512];
|
||||
path_of(GUARD_FILE, path, sizeof path);
|
||||
if (!on) {
|
||||
unlink(path);
|
||||
return;
|
||||
}
|
||||
char dir[512];
|
||||
path_of("/data", dir, sizeof dir);
|
||||
mkdir(dir, 0777);
|
||||
path_of("/data/cheatspecter", dir, sizeof dir);
|
||||
mkdir(dir, 0777);
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (f) {
|
||||
fputs("The controller shortcut was starting. If this file is here when CheatSpecter starts, the shortcut stays paused.\n", f);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
static void open_browser(const struct pad_settings *s) {
|
||||
char tid[16] = {0}, url[128], combo[64], msg[256];
|
||||
if (!P.current_title || !P.current_title(tid)) tid[0] = 0;
|
||||
pad_page_url(P.http_port, tid, url, sizeof url);
|
||||
int rc = drv_browser(url);
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int first = g_fired == 0;
|
||||
if (rc >= 0) g_fired++;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (!P.notify) return;
|
||||
if (rc < 0) {
|
||||
snprintf(msg, sizeof msg, "CheatSpecter could not open the web browser (code 0x%08x). Open it from the home screen instead.", (unsigned)rc);
|
||||
P.notify(msg);
|
||||
} else if (first) {
|
||||
pad_describe(s->buttons, combo, sizeof combo);
|
||||
snprintf(msg, sizeof msg, "Opening CheatSpecter (%s held).\nTo change or switch off this shortcut, open Settings in CheatSpecter.", combo);
|
||||
P.notify(msg);
|
||||
}
|
||||
}
|
||||
|
||||
static void *pad_thread(void *arg) {
|
||||
(void)arg;
|
||||
struct pad_settings s;
|
||||
struct stat stamp;
|
||||
struct pad_tracker tracker;
|
||||
memset(&stamp, 0, sizeof stamp);
|
||||
memset(&tracker, 0, sizeof tracker);
|
||||
default_settings(&s);
|
||||
|
||||
int speed = P.speed > 0 ? P.speed : 1;
|
||||
int initialized = 0;
|
||||
int handle = -1, user = 0;
|
||||
int paused = 0, guard_checked = 0, guard_mine = 0;
|
||||
int retry_ms = 1000;
|
||||
int64_t next_check = 0, next_open = 0, next_user_check = 0, healthy_since = 0;
|
||||
char detail[160];
|
||||
|
||||
while (!should_stop()) {
|
||||
int64_t now = now_ms();
|
||||
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int changed = g_changed;
|
||||
g_changed = 0;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (changed) {
|
||||
memset(&stamp, 0, sizeof stamp);
|
||||
/* The person saved the setting: that is the go-ahead after a pause. */
|
||||
if (paused) {
|
||||
paused = 0;
|
||||
guard_set(0);
|
||||
}
|
||||
next_open = 0;
|
||||
retry_ms = 1000;
|
||||
}
|
||||
if (changed || now >= next_check) {
|
||||
load_settings(&s, &stamp);
|
||||
next_check = now + CHECK_MS / speed;
|
||||
}
|
||||
|
||||
if (!s.enabled || paused || !drv_available()) {
|
||||
if (handle >= 0) {
|
||||
drv_close(handle);
|
||||
handle = -1;
|
||||
}
|
||||
if (guard_mine) {
|
||||
guard_set(0);
|
||||
guard_mine = 0;
|
||||
}
|
||||
if (paused)
|
||||
set_state(&s, "paused", "Paused because the PS5 stopped while the shortcut was starting last time.");
|
||||
else if (!s.enabled)
|
||||
set_state(&s, "off", "");
|
||||
else
|
||||
set_state(&s, "unavailable", "This build of CheatSpecter cannot read a controller.");
|
||||
nap(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!guard_checked) {
|
||||
guard_checked = 1;
|
||||
if (guard_exists()) {
|
||||
paused = 1;
|
||||
if (P.notify)
|
||||
P.notify("The CheatSpecter controller shortcut is paused: the PS5 stopped while it was starting last time.\n"
|
||||
"To try again, switch it on again in CheatSpecter's Settings.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (handle < 0) {
|
||||
if (now < next_open) {
|
||||
nap(POLL_MS);
|
||||
continue;
|
||||
}
|
||||
if (!initialized) {
|
||||
int rc = drv_init();
|
||||
if (rc != 0) {
|
||||
snprintf(detail, sizeof detail, "The PS5 would not load its controller library (code %d). Trying again in a while.", rc);
|
||||
set_state(&s, "unavailable", detail);
|
||||
next_open = now + MAX_RETRY_MS / speed;
|
||||
continue;
|
||||
}
|
||||
initialized = 1;
|
||||
}
|
||||
int rc = drv_user(&user);
|
||||
if (rc == 0) {
|
||||
if (!guard_mine) {
|
||||
guard_set(1);
|
||||
guard_mine = 1;
|
||||
}
|
||||
rc = handle = drv_open(user);
|
||||
}
|
||||
if (rc < 0) {
|
||||
handle = -1;
|
||||
snprintf(detail, sizeof detail, "%s (code 0x%08x). Trying again by itself.",
|
||||
user > 0 ? "Could not open the controller" : "Nobody is signed in on the PS5", (unsigned)rc);
|
||||
set_state(&s, "no-controller", detail);
|
||||
next_open = now + retry_ms / speed;
|
||||
retry_ms = retry_ms * 2 > MAX_RETRY_MS ? MAX_RETRY_MS : retry_ms * 2;
|
||||
user = 0;
|
||||
continue;
|
||||
}
|
||||
set_state(&s, "starting", "");
|
||||
pad_tracker_reset(&tracker);
|
||||
next_user_check = now + CHECK_MS / speed;
|
||||
healthy_since = 0;
|
||||
}
|
||||
|
||||
/* A different person signed in, or everyone signed out: start over with whoever is there now. */
|
||||
if (now >= next_user_check) {
|
||||
int who = 0;
|
||||
next_user_check = now + CHECK_MS / speed;
|
||||
if (drv_user(&who) != 0 || who != user) {
|
||||
drv_close(handle);
|
||||
handle = -1;
|
||||
next_open = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
int connected = 0;
|
||||
uint32_t buttons = 0;
|
||||
int rc = drv_read(handle, &connected, &buttons);
|
||||
if (rc < 0) {
|
||||
/* After rest mode or a crash elsewhere the handle can be gone; open a new one in a while. */
|
||||
drv_close(handle);
|
||||
handle = -1;
|
||||
snprintf(detail, sizeof detail, "Lost the controller (code 0x%08x). Trying again by itself.", (unsigned)rc);
|
||||
set_state(&s, "no-controller", detail);
|
||||
next_open = now + retry_ms / speed;
|
||||
retry_ms = retry_ms * 2 > MAX_RETRY_MS ? MAX_RETRY_MS : retry_ms * 2;
|
||||
continue;
|
||||
}
|
||||
retry_ms = 1000;
|
||||
if (connected) set_state(&s, "listening", "");
|
||||
else set_state(&s, "no-controller", "No controller is connected. Turn one on to use the shortcut.");
|
||||
|
||||
if (guard_mine) {
|
||||
if (healthy_since == 0) healthy_since = now;
|
||||
if (now - healthy_since >= GUARD_CLEAR_MS / speed) {
|
||||
guard_set(0);
|
||||
guard_mine = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (pad_track(&tracker, &s, now, connected, buttons)) open_browser(&s);
|
||||
nap(POLL_MS);
|
||||
}
|
||||
|
||||
if (handle >= 0) drv_close(handle);
|
||||
if (guard_mine) guard_set(0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int pad_start(const struct pad_config *config) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int already = g_started;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (already) return 0;
|
||||
P = *config;
|
||||
if (!P.root) P.root = "";
|
||||
if (pthread_create(&g_thread, NULL, pad_thread, NULL) != 0) return -1;
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_started = 1;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void pad_settings_changed(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
g_changed = 1;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
}
|
||||
|
||||
void pad_stop(void) {
|
||||
pthread_mutex_lock(&g_lock);
|
||||
int started = g_started;
|
||||
g_stop = 1;
|
||||
g_started = 0;
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (started) pthread_join(g_thread, NULL);
|
||||
}
|
||||
|
||||
char *pad_status_json(void) {
|
||||
char combo[160], detail[sizeof g_detail], escaped[2 * sizeof g_detail];
|
||||
pthread_mutex_lock(&g_lock);
|
||||
struct pad_settings s = g_shown_settings;
|
||||
const char *state = g_state;
|
||||
int fired = g_fired;
|
||||
snprintf(detail, sizeof detail, "%s", g_detail);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
|
||||
/* Names as a JSON list: ["L2","R3"] */
|
||||
size_t used = 0;
|
||||
combo[0] = 0;
|
||||
for (int i = 0; i < BUTTON_COUNT; i++) {
|
||||
if (!(s.buttons & BUTTONS[i].bit)) continue;
|
||||
int n = snprintf(combo + used, sizeof combo - used, "%s\"%s\"", used ? "," : "", BUTTONS[i].name);
|
||||
if (n < 0 || (size_t)n >= sizeof combo - used) break;
|
||||
used += (size_t)n;
|
||||
}
|
||||
/* The details are written here and hold no quotes or backslashes, but are escaped anyway. */
|
||||
size_t e = 0;
|
||||
for (size_t i = 0; detail[i] && e + 2 < sizeof escaped; i++) {
|
||||
if (detail[i] == '"' || detail[i] == '\\') escaped[e++] = '\\';
|
||||
escaped[e++] = (unsigned char)detail[i] < 0x20 ? ' ' : detail[i];
|
||||
}
|
||||
escaped[e] = 0;
|
||||
|
||||
size_t cap = 512 + sizeof combo + sizeof escaped;
|
||||
char *out = malloc(cap);
|
||||
if (!out) return NULL;
|
||||
snprintf(out, cap, "{\"enabled\":%s,\"buttons\":[%s],\"holdMs\":%d,\"state\":\"%s\",\"detail\":\"%s\",\"fired\":%d}",
|
||||
s.enabled ? "true" : "false", combo, s.hold_ms, state, escaped, fired);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only
|
||||
*
|
||||
* The controller shortcut: holding a few buttons together (L2 + R3 unless the
|
||||
* person picked others) opens CheatSpecter in the PS5's own web browser, on
|
||||
* the Cheats screen of the game that is running.
|
||||
*
|
||||
* Off unless switched on in the web app, which saves the setting as the
|
||||
* prefs file "pad-shortcut" ({"enabled":true,"buttons":["L2","R3"],"holdMs":800}).
|
||||
* While it is off the controller is not touched at all.
|
||||
*
|
||||
* It runs on one thread of its own. It only ever looks at the controller's
|
||||
* state (it never takes the controller over, so the game keeps getting every
|
||||
* press), and the HTTP server never waits on it: pad_status_json hands out a
|
||||
* snapshot and pad_settings_changed only leaves a note.
|
||||
*
|
||||
* The parts that decide things (reading the setting, telling when the buttons
|
||||
* have been held long enough) are plain functions with no console calls in
|
||||
* them, built into the computer version too, so they can be tested there.
|
||||
*/
|
||||
#ifndef CS_PAD_H
|
||||
#define CS_PAD_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* The prefs file the web app writes: /data/cheatspecter/prefs/pad-shortcut.json */
|
||||
#define PAD_PREF_NAME "pad-shortcut"
|
||||
|
||||
#define PAD_DEFAULT_BUTTONS (PAD_L2 | PAD_R3)
|
||||
#define PAD_DEFAULT_HOLD_MS 800
|
||||
#define PAD_MIN_HOLD_MS 300
|
||||
#define PAD_MAX_HOLD_MS 5000
|
||||
/* One button alone would go off in ordinary play; more than four is not something a hand does. */
|
||||
#define PAD_MIN_BUTTONS 2
|
||||
#define PAD_MAX_BUTTONS 4
|
||||
/* Readings further apart than this mean the console slept or the thread stalled: start the hold again. */
|
||||
#define PAD_GAP_MS 1000
|
||||
/* After going off, the shortcut stays quiet this long even if the buttons are pressed again. */
|
||||
#define PAD_COOLDOWN_MS 3000
|
||||
|
||||
/* Button bits, as the console reports them. */
|
||||
#define PAD_L3 0x00000002u
|
||||
#define PAD_R3 0x00000004u
|
||||
#define PAD_OPTIONS 0x00000008u
|
||||
#define PAD_UP 0x00000010u
|
||||
#define PAD_RIGHT 0x00000020u
|
||||
#define PAD_DOWN 0x00000040u
|
||||
#define PAD_LEFT 0x00000080u
|
||||
#define PAD_L2 0x00000100u
|
||||
#define PAD_R2 0x00000200u
|
||||
#define PAD_L1 0x00000400u
|
||||
#define PAD_R1 0x00000800u
|
||||
#define PAD_TRIANGLE 0x00001000u
|
||||
#define PAD_CIRCLE 0x00002000u
|
||||
#define PAD_CROSS 0x00004000u
|
||||
#define PAD_SQUARE 0x00008000u
|
||||
#define PAD_TOUCHPAD 0x00100000u
|
||||
|
||||
struct pad_settings {
|
||||
int enabled;
|
||||
uint32_t buttons; /* always PAD_MIN_BUTTONS..PAD_MAX_BUTTONS known bits */
|
||||
int hold_ms; /* always PAD_MIN_HOLD_MS..PAD_MAX_HOLD_MS */
|
||||
};
|
||||
|
||||
/* Where a hold has got to. Zero it, or call pad_tracker_reset, before the first reading. */
|
||||
struct pad_tracker {
|
||||
int64_t last_ms; /* time of the previous reading, 0 before the first */
|
||||
int64_t held_since; /* when the buttons went down together, valid while holding */
|
||||
int64_t fired_at; /* when the shortcut last went off, 0 if never */
|
||||
int holding;
|
||||
int need_release; /* went off (or just started watching): the buttons must be let go first */
|
||||
};
|
||||
|
||||
/* The bit for a button name ("L2", "r3", "Triangle", ...), 0 when it is not one. Case does not matter. */
|
||||
uint32_t pad_button_bit(const char *name);
|
||||
|
||||
/* "L2 + R3". Always NUL-terminated. */
|
||||
void pad_describe(uint32_t buttons, char *out, size_t cap);
|
||||
|
||||
/*
|
||||
* Reads the prefs file's text. Anything missing, unknown or out of range
|
||||
* falls back to the default for that field, so a damaged file leaves the
|
||||
* shortcut off rather than on with something odd. Returns 0 when the text
|
||||
* was JSON, -1 when it was not (out then holds the defaults: off).
|
||||
*/
|
||||
int pad_parse_settings(const char *json, size_t len, struct pad_settings *out);
|
||||
|
||||
void pad_tracker_reset(struct pad_tracker *t);
|
||||
|
||||
/*
|
||||
* Feeds one reading of the controller. Returns 1 exactly when the shortcut
|
||||
* should go off: every button of the combination has been down for hold_ms,
|
||||
* they were let go since the last time, and the cooldown has passed. Other
|
||||
* buttons pressed at the same time do not matter.
|
||||
*/
|
||||
int pad_track(struct pad_tracker *t, const struct pad_settings *s, int64_t now_ms, int connected, uint32_t buttons);
|
||||
|
||||
/* The address the browser is sent to. `title_id` may be NULL or "" when no game is running. */
|
||||
void pad_page_url(int port, const char *title_id, char *out, size_t cap);
|
||||
|
||||
struct pad_config {
|
||||
/* Prefix for every console path: "" on the console, a test folder on a computer. Must stay valid. */
|
||||
const char *root;
|
||||
/* This server's port, for the address the browser opens. */
|
||||
int http_port;
|
||||
/* Shows a notification on the TV. Called from the pad thread. */
|
||||
void (*notify)(const char *text);
|
||||
/* Copies the running game's title id into out (10 bytes) and returns 1, or returns 0 when none is known. May be NULL. */
|
||||
int (*current_title)(char *out);
|
||||
/* 1 on the console. Tests set it higher to make the slow retries that many times quicker. */
|
||||
int speed;
|
||||
};
|
||||
|
||||
/* Starts the thread. Returns 0 on success; on failure the rest of the payload carries on without it. */
|
||||
int pad_start(const struct pad_config *config);
|
||||
|
||||
/* The prefs file was just written: read it again now. Returns at once; safe from any thread. */
|
||||
void pad_settings_changed(void);
|
||||
|
||||
/* Stops the thread and lets go of the controller. Waits at most a moment. Safe to call when it never started. */
|
||||
void pad_stop(void);
|
||||
|
||||
/*
|
||||
* The body of GET /api/pad:
|
||||
* {"enabled":false,"buttons":["L2","R3"],"holdMs":800,"state":"off","detail":"","fired":0}
|
||||
* "state" is "off", "starting", "listening", "no-controller", "paused" (it was
|
||||
* switched off by itself after the console stopped while it was starting; saving
|
||||
* the setting again resumes it) or "unavailable" (this build cannot read a
|
||||
* controller). The caller frees the result. NULL when out of memory.
|
||||
*/
|
||||
char *pad_status_json(void);
|
||||
|
||||
#endif
|
||||
@@ -20,7 +20,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const target = process.argv[2] ?? 'ps5';
|
||||
const payload = path.join(root, 'payload');
|
||||
const assets = path.join(payload, 'build/assets.c');
|
||||
const sources = [...['main.c', 'engine.c', 'dbgclient.c', 'json.c'].map((f) => path.join(payload, 'src', f)), assets];
|
||||
const sources = [...['main.c', 'engine.c', 'dbgclient.c', 'json.c', 'pad.c'].map((f) => path.join(payload, 'src', f)), assets];
|
||||
const flags = ['-O2', '-Wall', '-Wextra', `-I${path.join(payload, 'src')}`];
|
||||
|
||||
const run = (cmd, args) => execFileSync(cmd, args, { stdio: 'inherit' });
|
||||
|
||||
@@ -7,20 +7,42 @@ import { Button } from "@/components/ui/button"
|
||||
|
||||
// Most dialogs here are opened from a menu item or by code rather than by a
|
||||
// Radix trigger, so Radix has nothing to hand the keyboard focus back to when
|
||||
// one closes and it would drop to the top of the page. This remembers the last
|
||||
// lasting control that had focus (menu items go away with their menu, so they
|
||||
// do not count), and each dialog returns focus to the one it was opened from.
|
||||
let lastFocused: HTMLElement | null = null
|
||||
// one closes and it would drop to the top of the page. This remembers the
|
||||
// lasting controls that had focus most recently (menu items go away with their
|
||||
// menu, so they do not count), and each dialog returns focus to the one it was
|
||||
// opened from: the most recent of them outside the dialog itself, still in the
|
||||
// page and visible when the dialog closes.
|
||||
//
|
||||
// The list, not just the last one, matters: a field inside the dialog that
|
||||
// takes the focus as it appears (autoFocus) is the last focused control by the
|
||||
// time the dialog notices it has opened, and the control a dialog was opened
|
||||
// from may be gone by the time it closes (the row of a cheat just deleted).
|
||||
const recentlyFocused: HTMLElement[] = []
|
||||
const RECENT_MAX = 16
|
||||
if (typeof document !== "undefined") {
|
||||
const note = (e: Event) => {
|
||||
const el = e.target instanceof Element ? e.target.closest<HTMLElement>("button,a[href],input,select,textarea,[tabindex]") : null
|
||||
if (el && !el.closest('[role="menu"],[role="listbox"]')) lastFocused = el
|
||||
if (!el || el.closest('[role="menu"],[role="listbox"]')) return
|
||||
const at = recentlyFocused.indexOf(el)
|
||||
if (at >= 0) recentlyFocused.splice(at, 1)
|
||||
recentlyFocused.push(el)
|
||||
if (recentlyFocused.length > RECENT_MAX) recentlyFocused.shift()
|
||||
}
|
||||
document.addEventListener("focusin", note, true)
|
||||
// A menu's button opens on press without taking focus, so presses count too.
|
||||
document.addEventListener("pointerdown", note, true)
|
||||
}
|
||||
|
||||
/** The controls a dialog could give the focus back to, likeliest first: recently focused, and not inside `dialog`. */
|
||||
function openersOutside(dialog: Element | null): HTMLElement[] {
|
||||
return recentlyFocused.filter((el) => el.isConnected && !(dialog && dialog.contains(el))).reverse().slice(0, 4)
|
||||
}
|
||||
|
||||
/** Whether focus can usefully go back to `el`: still in the page, shown, and not switched off. */
|
||||
function canTakeFocus(el: HTMLElement): boolean {
|
||||
return el.isConnected && el.getClientRects().length > 0 && !(el as HTMLButtonElement).disabled && !el.closest("[inert]")
|
||||
}
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
@@ -45,6 +67,22 @@ function DialogClose({
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
// Why dialogs are built this way round. A dialog used to be a box fixed in the
|
||||
// middle of the window, capped at the window's height and scrolling inside
|
||||
// itself. That breaks whenever the cap does not hold: a browser without `dvh`
|
||||
// (the cap is then thrown away, and a box taller than the window has its top
|
||||
// and bottom cut off with nothing to scroll), a dialog that passes its own
|
||||
// height, overflow or position classes, a rule elsewhere that moves dialogs
|
||||
// (the paused-game bar did, with !important), or a phone keyboard covering the
|
||||
// bottom part. Here the dim backdrop itself is the scroll container: it always
|
||||
// fills the window, and the dialog sits inside it like a page. However tall the
|
||||
// dialog gets and whatever classes it is given, the backdrop scrolls to every
|
||||
// part of it, with the wheel, a finger or the keyboard. The page behind stays
|
||||
// locked, because the backdrop is what Radix locks scrolling around.
|
||||
//
|
||||
// The top of the window can be kept clear (the paused-game bar does this) by
|
||||
// setting --cs-dialog-top on the root element to that bar's height.
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
@@ -69,38 +107,64 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
// Taken on the first render, before the dialog moves focus into itself.
|
||||
const opener = React.useRef(lastFocused)
|
||||
const { onCloseAutoFocus, ...rest } = props
|
||||
// Taken as the dialog opens, before it moves focus into itself. Not when this
|
||||
// component first renders: a dialog that stays in the page while closed
|
||||
// would remember whatever had focus when the page was drawn.
|
||||
const openers = React.useRef<HTMLElement[]>([])
|
||||
const { onCloseAutoFocus, onOpenAutoFocus, onPointerDownOutside, ...rest } = props
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] scroll-pb-32 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] grid-cols-[minmax(0,1fr)] gap-4 overflow-y-auto overscroll-contain rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
onCloseAutoFocus={(e) => {
|
||||
onCloseAutoFocus?.(e)
|
||||
const el = opener.current
|
||||
if (e.defaultPrevented || !el || !el.isConnected) return
|
||||
e.preventDefault()
|
||||
el.focus()
|
||||
}}
|
||||
<DialogOverlay
|
||||
// The scroll container. Not styleable from outside on purpose: no dialog can turn its scrolling off.
|
||||
// A column of one with auto margins centers the dialog while it fits, and starts it at the top once it
|
||||
// does not (plain centering would push its top out of reach). The bottom padding gives the last
|
||||
// button some air above the screen edge.
|
||||
className="flex flex-col overflow-y-auto overscroll-contain px-4 pt-[calc(1rem+var(--cs-dialog-top,0px))] pb-4 scroll-pt-[calc(1rem+var(--cs-dialog-top,0px))] scroll-pb-32"
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"relative z-50 m-auto grid w-full max-w-lg shrink-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
onOpenAutoFocus={(e) => {
|
||||
openers.current = openersOutside(e.currentTarget instanceof Element ? e.currentTarget : null)
|
||||
onOpenAutoFocus?.(e)
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
// A press on the backdrop's own scrollbar is someone scrolling, not someone leaving. Scrollbars
|
||||
// that float over the page (phones, macOS) take no width, so the edge strip counts as one.
|
||||
const ev = e.detail.originalEvent
|
||||
const t = ev.target
|
||||
const bar = t instanceof HTMLElement ? Math.max(t.offsetWidth - t.clientWidth, 16) : 0
|
||||
if (t instanceof HTMLElement && t.dataset.slot === "dialog-overlay" && t.scrollHeight > t.clientHeight && ev.clientX >= t.getBoundingClientRect().right - bar) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
onPointerDownOutside?.(e)
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
onCloseAutoFocus?.(e)
|
||||
const el = openers.current.find(canTakeFocus)
|
||||
openers.current = []
|
||||
if (e.defaultPrevented || !el) return
|
||||
e.preventDefault()
|
||||
el.focus()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
+320
-3
@@ -8,10 +8,15 @@
|
||||
// Files are read from their original location at runtime; nothing is copied
|
||||
// into this project.
|
||||
|
||||
import { bytesToHex, hexToBytes } from './bytes.ts';
|
||||
import { sameVersion } from './cheats.ts';
|
||||
import { decodeText, parseAnyTable } from './trainer.ts';
|
||||
import type { CheatTable } from './cheats.ts';
|
||||
import type { CheatTable, Mod, Patch } from './cheats.ts';
|
||||
import { consoleApiUrl } from './library.ts';
|
||||
import type { Target } from './session.ts';
|
||||
import { loadJson, saveJson } from './storage.ts';
|
||||
import { patchSites, sourceDigest, sourceId, stableJson } from './table.ts';
|
||||
import type { CommunityPlace, Entry, PatchEntry, PatchSite, TableSource } from './table.ts';
|
||||
|
||||
/** One list of tables in a source: an index file and the folder its files sit in. */
|
||||
export interface CatalogIndex {
|
||||
@@ -67,6 +72,13 @@ export const LOCAL_SOURCE: CatalogSource = {
|
||||
/** Tables the user imported; stored in this browser. */
|
||||
export const MINE_SOURCE_ID = 'mine';
|
||||
|
||||
/**
|
||||
* Cheat files the person keeps on the console for other tools (etaHEN,
|
||||
* elf-arsenal, GoldHEN), read through the payload (GET api/console-cheats/...).
|
||||
*/
|
||||
export const CONSOLE_SOURCE_ID = 'ps5';
|
||||
export const CONSOLE_SOURCE_NAME = 'On this PS5';
|
||||
|
||||
export interface CatalogEntry {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
@@ -78,6 +90,17 @@ export interface CatalogEntry {
|
||||
format: TableFormat;
|
||||
/** True when the table's version matches the running game. */
|
||||
exact: boolean;
|
||||
/**
|
||||
* Set when this is a table saved in the game's .cspector file on the PS5
|
||||
* (a TableSource): its id there. It opens from the file, with no download.
|
||||
*/
|
||||
stored?: string;
|
||||
/** Saved tables: the TableSource's kind ("community", "console", "gamepatch", ...). */
|
||||
kind?: string;
|
||||
/** Saved tables: the format as the source names it, for formats a catalog entry has no word for ("XML"). */
|
||||
label?: string;
|
||||
/** Files on the console: the folder it is in, for telling the person. */
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
interface CachedIndex {
|
||||
@@ -154,16 +177,77 @@ export interface CatalogResult {
|
||||
stale: string[];
|
||||
}
|
||||
|
||||
/** One cheat file in a folder another tool keeps on the console, as GET api/console-cheats/<title id> lists it. */
|
||||
export interface ConsoleFile {
|
||||
/** Where it is, relative to the folders the payload reads, e.g. "etahen/json/PPSA01575_01.020.000.json". */
|
||||
path: string;
|
||||
/** The folder on the console, e.g. "/data/etaHEN/cheats/json". */
|
||||
folder: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the cheat files for a title in the folders other tools use on the
|
||||
* console. Empty when there are none, and also when the console cannot be
|
||||
* asked (no payload, or one from before it could).
|
||||
*/
|
||||
export async function listConsoleFiles(target: Target | null, titleId: string): Promise<ConsoleFile[]> {
|
||||
try {
|
||||
const res = await fetch(consoleApiUrl(target, `api/console-cheats/${titleId.toUpperCase()}`), { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
const doc = (await res.json()) as { files?: unknown };
|
||||
if (!Array.isArray(doc.files)) return [];
|
||||
return (doc.files as Record<string, unknown>[])
|
||||
.filter((f) => f && typeof f.path === 'string' && typeof f.name === 'string')
|
||||
.map((f) => ({ path: f.path as string, folder: typeof f.folder === 'string' ? f.folder : '', name: f.name as string, size: Number(f.size) || 0 }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the payload hands out one of those files. */
|
||||
export function consoleFileUrl(target: Target | null, titleId: string, path: string): string {
|
||||
return consoleApiUrl(target, `api/console-cheats/${titleId.toUpperCase()}/${path.split('/').map(encodeURIComponent).join('/')}`);
|
||||
}
|
||||
|
||||
/** The format of a cheat file by its name. */
|
||||
function formatOfName(name: string): TableFormat {
|
||||
const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
|
||||
return ext === 'shn' || ext === 'mc4' ? ext : 'json';
|
||||
}
|
||||
|
||||
/** Finds every table for a title, best match first. */
|
||||
export async function findTables(
|
||||
titleId: string,
|
||||
version: string,
|
||||
opts: { includeLocal: boolean },
|
||||
opts: { includeLocal: boolean; console?: { target: Target | null } },
|
||||
): Promise<CatalogResult> {
|
||||
const sources = opts.includeLocal ? [LOCAL_SOURCE, ...ONLINE_SOURCES] : ONLINE_SOURCES;
|
||||
const result: CatalogResult = { entries: [], failed: [], stale: [] };
|
||||
const wanted = titleId.toUpperCase();
|
||||
|
||||
if (opts.console) {
|
||||
const target = opts.console.target;
|
||||
for (const f of await listConsoleFiles(target, wanted)) {
|
||||
// The file name says the version where it starts the way the collections name files
|
||||
// ("PPSA01575_01.020.000…"); otherwise it is learned on opening.
|
||||
const named = /^[A-Z]{4}\d{5}[_-](\d+(?:\.\d+)+)/i.exec(f.name);
|
||||
result.entries.push({
|
||||
sourceId: CONSOLE_SOURCE_ID,
|
||||
sourceName: CONSOLE_SOURCE_NAME,
|
||||
file: f.path,
|
||||
url: consoleFileUrl(target, wanted, f.path),
|
||||
titleId: wanted,
|
||||
version: named ? named[1] : '',
|
||||
gameName: '',
|
||||
format: formatOfName(f.name),
|
||||
exact: !!named && sameVersion(named[1], version),
|
||||
folder: f.folder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const mine of listMyTables(wanted)) {
|
||||
result.entries.push({
|
||||
sourceId: MINE_SOURCE_ID,
|
||||
@@ -232,12 +316,15 @@ export async function loadTable(entry: CatalogEntry): Promise<CheatTable> {
|
||||
return parseAnyTable(text);
|
||||
}
|
||||
const key = `table.${entry.sourceId}.${entry.file}`;
|
||||
// Files on this computer or on the console are always at hand; only downloads are worth a copy.
|
||||
const cache = entry.sourceId !== LOCAL_SOURCE.id && entry.sourceId !== CONSOLE_SOURCE_ID;
|
||||
try {
|
||||
const text = await fetchText(entry.url);
|
||||
const table = parseAnyTable(text);
|
||||
if (entry.sourceId !== LOCAL_SOURCE.id) saveJson(key, { at: Date.now(), text } satisfies CachedTable);
|
||||
if (cache) saveJson(key, { at: Date.now(), text } satisfies CachedTable);
|
||||
return table;
|
||||
} catch (e) {
|
||||
if (!cache) throw e;
|
||||
const cached = loadJson<CachedTable | null>(key, null);
|
||||
if (cached) return parseAnyTable(cached.text);
|
||||
throw e;
|
||||
@@ -274,3 +361,233 @@ export function importTable(fileName: string, text: string): CheatTable {
|
||||
if (!saveJson('mine', mine)) throw new Error('This browser would not save the table (storage is full or blocked)');
|
||||
return table;
|
||||
}
|
||||
|
||||
// ---- community tables saved into the game's .cspector file -----------------------
|
||||
//
|
||||
// A community table that matches the running game exactly is converted into
|
||||
// .cspector entries and saved in the game's file on the PS5 as a TableSource
|
||||
// (table.ts), so the next time the game starts it shows at once, with no
|
||||
// download. The conversion keeps everything the original said (sections,
|
||||
// fixed addresses, buttons, what this app cannot do with a cheat), so the
|
||||
// saved copy opens as exactly the same table.
|
||||
|
||||
const offsetHex = (n: bigint) => `0x${n.toString(16).toUpperCase()}`;
|
||||
|
||||
/** One community cheat as a .cspector patch entry. */
|
||||
function modEntry(mod: Mod, origin: { source: string; file: string; format: string }): PatchEntry {
|
||||
const sites: PatchSite[] = mod.patches.map((p) => ({
|
||||
// A fixed address stays one; anything else counts from the executable (or the section, which `places` records).
|
||||
at: p.absolute ? { kind: 'address', address: offsetHex(p.offset) } : { kind: 'module', offset: offsetHex(p.offset) },
|
||||
on: bytesToHex(p.on),
|
||||
...(p.off.length ? { off: bytesToHex(p.off) } : {}),
|
||||
}));
|
||||
const places: CommunityPlace[] = mod.patches.map((p) => {
|
||||
const place: CommunityPlace = {};
|
||||
if (p.section) place.section = p.section;
|
||||
if (p.absolute) place.absolute = true;
|
||||
if (p.where) place.where = { name: p.where.name, prot: p.where.prot, offset: offsetHex(p.where.offset) };
|
||||
return place;
|
||||
});
|
||||
// A cheat with nothing to write still gets a row, as it does today: greyed out, saying why.
|
||||
const first: PatchSite = sites[0] ?? { at: { kind: 'module', offset: '0x0' }, on: '' };
|
||||
const entry: PatchEntry = {
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
type: 'patch',
|
||||
name: mod.name,
|
||||
...first,
|
||||
origin: { ...origin },
|
||||
};
|
||||
if (mod.description) entry.note = mod.description;
|
||||
if (sites.length > 1) entry.more = sites.slice(1);
|
||||
if (mod.type === 'button') entry.origin!.button = true;
|
||||
if (mod.unsupported) entry.origin!.unsupported = mod.unsupported;
|
||||
if (places.some((p) => Object.keys(p).length > 0)) entry.origin!.places = places;
|
||||
if (mod.unsupported || places.some((p) => p.section || p.absolute)) entry.pageOnly = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** What a source needs to say about itself besides its cheats. */
|
||||
export interface SourceInfo {
|
||||
kind: string;
|
||||
source: string;
|
||||
sourceName: string;
|
||||
file: string;
|
||||
format: string;
|
||||
url?: string;
|
||||
/** Overrides the version the table states (some files leave it out). */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** The kind of source a catalog entry is, as saved in the file. */
|
||||
export function sourceKind(entry: CatalogEntry): string {
|
||||
return entry.sourceId === CONSOLE_SOURCE_ID ? 'console' : 'community';
|
||||
}
|
||||
|
||||
/** The details a catalog entry gives a saved source. */
|
||||
export function entryInfo(entry: CatalogEntry): SourceInfo {
|
||||
return {
|
||||
kind: sourceKind(entry),
|
||||
source: entry.sourceId,
|
||||
sourceName: entry.sourceName,
|
||||
file: entry.file,
|
||||
format: entry.format,
|
||||
// A file on the console is read again from the console, whatever address it is reached at next time.
|
||||
url: entry.sourceId === CONSOLE_SOURCE_ID || entry.sourceId === MINE_SOURCE_ID ? undefined : entry.url,
|
||||
version: entry.version,
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a community table into a source to save in the game's .cspector file. */
|
||||
export function communitySource(table: CheatTable, info: SourceInfo, now = new Date()): TableSource {
|
||||
const origin = { source: info.source, file: info.file, format: info.format };
|
||||
const cheats: Entry[] = table.mods.map((m) => modEntry(m, origin));
|
||||
const src: TableSource = {
|
||||
id: sourceId(info.source, info.file),
|
||||
kind: info.kind,
|
||||
source: info.source,
|
||||
sourceName: info.sourceName,
|
||||
file: info.file,
|
||||
format: info.format,
|
||||
version: table.version || info.version || '',
|
||||
savedAt: now.toISOString(),
|
||||
digest: sourceDigest(cheats),
|
||||
cheats,
|
||||
};
|
||||
if (info.url) src.url = info.url;
|
||||
if (table.name && table.name !== 'Unknown game') src.name = table.name;
|
||||
if (table.process && table.process.toLowerCase() !== 'eboot.bin') src.process = table.process;
|
||||
if (table.credits.length) src.credits = [...table.credits];
|
||||
return src;
|
||||
}
|
||||
|
||||
/** Why an entry saved from another kind of source cannot be shown as a community cheat; null when it can. */
|
||||
function plainProblem(site: PatchSite): string | null {
|
||||
if (site.at.kind === 'address') return null;
|
||||
if (site.at.kind !== 'module') return 'This cheat is found by a byte pattern or pointer, which only "Your cheats" can do';
|
||||
if (site.at.module) return `This cheat changes ${site.at.module}, not the game's main program`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved source as the community table it was made from, to show and switch
|
||||
* exactly as before. Entries saved by another kind of source (game patches)
|
||||
* work the same way when they are patches at fixed offsets in the game's
|
||||
* main program; anything else is listed as not supported, with the reason.
|
||||
*/
|
||||
export function sourceTable(src: TableSource, titleId: string): CheatTable {
|
||||
const mods: Mod[] = src.cheats.map((e) => {
|
||||
const origin = e.origin;
|
||||
const mod: Mod = {
|
||||
name: e.name,
|
||||
description: e.note ?? '',
|
||||
type: origin?.button ? 'button' : 'checkbox',
|
||||
patches: [],
|
||||
unsupported: origin?.unsupported ?? null,
|
||||
};
|
||||
if (e.type !== 'patch') {
|
||||
mod.unsupported ??= 'Only patches can be listed here';
|
||||
return mod;
|
||||
}
|
||||
const sites = patchSites(e);
|
||||
sites.forEach((site, i) => {
|
||||
if (!site.on) return;
|
||||
const problem = plainProblem(site);
|
||||
if (problem) {
|
||||
mod.unsupported ??= problem;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const place = origin?.places?.[i] ?? {};
|
||||
const at = site.at as { kind: string; offset?: string; address?: string };
|
||||
const patch: Patch = {
|
||||
offset: BigInt(String(at.kind === 'address' ? at.address : at.offset)),
|
||||
on: hexToBytes(site.on),
|
||||
off: hexToBytes(site.off ?? ''),
|
||||
section: place.section ?? 0,
|
||||
};
|
||||
if (place.absolute || at.kind === 'address') patch.absolute = true;
|
||||
if (place.where) patch.where = { name: place.where.name, prot: place.where.prot, offset: BigInt(place.where.offset) };
|
||||
mod.patches.push(patch);
|
||||
} catch {
|
||||
mod.unsupported ??= 'Could not read this cheat';
|
||||
}
|
||||
});
|
||||
if (mod.patches.length === 0 && !mod.unsupported) mod.unsupported = 'Has no patches';
|
||||
return mod;
|
||||
});
|
||||
return {
|
||||
name: src.name ?? '',
|
||||
titleId: titleId.toUpperCase(),
|
||||
version: src.version,
|
||||
process: src.process ?? 'eboot.bin',
|
||||
credits: src.credits ?? [],
|
||||
mods,
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved source as an entry in the list of tables to pick from. */
|
||||
export function storedEntry(src: TableSource, titleId: string, runningVersion: string): CatalogEntry {
|
||||
return {
|
||||
sourceId: src.source,
|
||||
sourceName: src.sourceName,
|
||||
file: src.file,
|
||||
url: src.url ?? '',
|
||||
titleId: titleId.toUpperCase(),
|
||||
version: src.version,
|
||||
gameName: src.name ?? '',
|
||||
format: (['json', 'shn', 'mc4'].includes(src.format) ? src.format : 'json') as TableFormat,
|
||||
exact: sameVersion(src.version, runningVersion),
|
||||
stored: src.id,
|
||||
kind: src.kind,
|
||||
label: src.format.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SourceChanges {
|
||||
added: string[];
|
||||
removed: string[];
|
||||
changed: string[];
|
||||
}
|
||||
|
||||
/** What is different between a saved source and a fresh copy of it, cheat by cheat, by name. */
|
||||
export function sourceChanges(saved: TableSource, fresh: TableSource): SourceChanges {
|
||||
const key = (e: Entry) => stableJson({ ...e, id: undefined, unreviewed: undefined, keep: undefined });
|
||||
const byName = (list: Entry[]) => {
|
||||
const m = new Map<string, string[]>();
|
||||
for (const e of list) m.set(e.name, [...(m.get(e.name) ?? []), key(e)]);
|
||||
return m;
|
||||
};
|
||||
const before = byName(saved.cheats);
|
||||
const after = byName(fresh.cheats);
|
||||
const out: SourceChanges = { added: [], removed: [], changed: [] };
|
||||
for (const [name, list] of after) {
|
||||
const old = before.get(name);
|
||||
if (!old) out.added.push(name);
|
||||
else if (old.length !== list.length || old.some((k, i) => k !== list[i])) out.changed.push(name);
|
||||
}
|
||||
for (const name of before.keys()) if (!after.has(name)) out.removed.push(name);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SourceCheck {
|
||||
/** The source as it would be saved now. */
|
||||
fresh: TableSource;
|
||||
changes: SourceChanges;
|
||||
/** True when nothing about the cheats is different. */
|
||||
same: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a saved source again from where it came from (the online collection,
|
||||
* or the file on the console) and says what is different. Never answers from
|
||||
* a copy kept in the browser: an update check that could not reach the
|
||||
* source fails, with the reason.
|
||||
*/
|
||||
export async function checkSource(src: TableSource, target: Target | null, titleId: string): Promise<SourceCheck> {
|
||||
const url = src.source === CONSOLE_SOURCE_ID ? consoleFileUrl(target, titleId, src.file) : src.url;
|
||||
if (!url) throw new Error('This table does not say where it came from, so it cannot be checked');
|
||||
const table = parseAnyTable(await fetchText(url));
|
||||
const fresh = communitySource(table, { kind: src.kind, source: src.source, sourceName: src.sourceName, file: src.file, format: src.format, url: src.url, version: src.version });
|
||||
const changes = sourceChanges(src, fresh);
|
||||
return { fresh, changes, same: fresh.digest === src.digest };
|
||||
}
|
||||
@@ -571,7 +571,11 @@ export async function applyCode(ctx: GameContext, entry: CodeEntry, opts: ApplyO
|
||||
throw e;
|
||||
}
|
||||
const head = await dbg.read(ctx.pid, found.address, 48);
|
||||
if (isJumpAt(head)) throw new Error(`${which}There is already a jump at this spot, probably a hook left from earlier. Restart the game to clear it.`);
|
||||
if (isJumpAt(head)) {
|
||||
throw new Error(
|
||||
`${which}There is already a jump at this spot: this cheat, or another one for the same place, is on in the game but was switched on somewhere else (another browser, or the MCP server). Switch it off there, or restart the game to clear it.`,
|
||||
);
|
||||
}
|
||||
const off = site.off ? safeHex(site.off) : null;
|
||||
const fits = !off || bytesEqual(head.subarray(0, off.length), off);
|
||||
// A fixed position used because the pattern failed is only trusted when the code there is known to be right.
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Crash protection.
|
||||
//
|
||||
// A cheat that was switched on less than a minute before the game ended may
|
||||
// well be why it ended. Such a cheat gets a mark, with the time, and is not
|
||||
// switched on automatically again (not by "Keep on", not by a start set)
|
||||
// until the person switches it on by hand, which clears the mark. A game
|
||||
// that ends after a long session marks nothing.
|
||||
//
|
||||
// The record is one small settings file per game on the console,
|
||||
// crash-<title id>.json (see prefs.ts), so every device and the PS5's own
|
||||
// keep-on engine (payload/src/engine.c) see the same marks:
|
||||
//
|
||||
// {
|
||||
// "marks": [{ "name": "Infinite ammo", "at": 1760190000000, "by": "page" | "ps5", "version": "01.020.000" }],
|
||||
// "armed": [{ "name": "Infinite ammo", "pid": 1234, "at": 1760189990000 }]
|
||||
// }
|
||||
//
|
||||
// "marks" are the cheats held back; "at" is when the game ended (ms since
|
||||
// 1970, by the clock of whoever noticed). "by" says what switched the cheat
|
||||
// on: this page, or the PS5 keeping it on. "armed" are cheats this page
|
||||
// switched on in the game with that process id, so whoever sees the game end
|
||||
// knows what was switched on when. The engine on the PS5 notices the game
|
||||
// ending even with no page open, so where it is running (its status says
|
||||
// "crashGuard": true) it does the marking for both; the page only marks by
|
||||
// itself where there is no such engine (the computer bridge, an older payload).
|
||||
//
|
||||
// Cheats are known by name, as the engine knows them.
|
||||
|
||||
import { fetchEngineStatus } from './keep.ts';
|
||||
import { loadPref, savePref } from './prefs.ts';
|
||||
import type { Target } from './session.ts';
|
||||
|
||||
/** A cheat switched on less than this long before the game ended is marked. */
|
||||
export const CRASH_WINDOW_MS = 60_000;
|
||||
/**
|
||||
* The page can only say when the game ended if it was watching: the last time
|
||||
* it saw the game running must be this close to when it noticed it gone.
|
||||
* Otherwise (the phone was asleep) it cannot tell, and marks nothing.
|
||||
*/
|
||||
export const WATCH_GAP_MS = 15_000;
|
||||
const MAX_MARKS = 64;
|
||||
const MAX_ARMED = 64;
|
||||
|
||||
export interface CrashMark {
|
||||
name: string;
|
||||
/** When the game ended, ms since 1970. */
|
||||
at: number;
|
||||
/** What switched the cheat on: this page ("page") or the PS5 keeping it on ("ps5"). */
|
||||
by: 'page' | 'ps5';
|
||||
/** The game version that ended, when known. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ArmedCheat {
|
||||
name: string;
|
||||
/** The game's process: a record from an earlier launch means nothing for this one. */
|
||||
pid: number;
|
||||
/** When it was switched on, ms since 1970 by the clock of the device that did it. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface CrashRecord {
|
||||
marks: CrashMark[];
|
||||
armed: ArmedCheat[];
|
||||
}
|
||||
|
||||
export const emptyRecord = (): CrashRecord => ({ marks: [], armed: [] });
|
||||
|
||||
/** The settings name the record is kept under, e.g. "crash-ppsa01575". */
|
||||
export function crashPrefName(titleId: string): string {
|
||||
const t = titleId.trim().toLowerCase();
|
||||
if (!/^[a-z]{4}\d{5}$/.test(t)) throw new Error(`"${titleId}" is not a title ID`);
|
||||
return `crash-${t}`;
|
||||
}
|
||||
|
||||
/** Reads a record, keeping only what has the right shape: the file is shared with the engine and could be edited by hand. */
|
||||
export function parseCrashRecord(doc: unknown): CrashRecord {
|
||||
const d = doc as { marks?: unknown; armed?: unknown } | null;
|
||||
const out = emptyRecord();
|
||||
if (!d || typeof d !== 'object') return out;
|
||||
for (const m of Array.isArray(d.marks) ? (d.marks as Record<string, unknown>[]) : []) {
|
||||
if (!m || typeof m.name !== 'string' || !m.name || out.marks.some((x) => x.name === m.name)) continue;
|
||||
out.marks.push({
|
||||
name: m.name,
|
||||
at: typeof m.at === 'number' && Number.isFinite(m.at) ? m.at : 0,
|
||||
by: m.by === 'ps5' ? 'ps5' : 'page',
|
||||
...(typeof m.version === 'string' && m.version ? { version: m.version } : {}),
|
||||
});
|
||||
}
|
||||
for (const a of Array.isArray(d.armed) ? (d.armed as Record<string, unknown>[]) : []) {
|
||||
if (!a || typeof a.name !== 'string' || !a.name || !Number.isInteger(a.pid)) continue;
|
||||
out.armed.push({ name: a.name, pid: a.pid as number, at: typeof a.at === 'number' && Number.isFinite(a.at) ? a.at : 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function markOf(record: CrashRecord | null, name: string): CrashMark | null {
|
||||
return record?.marks.find((m) => m.name === name) ?? null;
|
||||
}
|
||||
|
||||
/** False for a marked cheat: nothing may switch it on except the person, by hand. */
|
||||
export function mayAutoSwitch(record: CrashRecord | null, name: string): boolean {
|
||||
return markOf(record, name) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record after a cheat was switched on in the game with process `pid`.
|
||||
* By hand, that also clears its mark: the person has decided to try again.
|
||||
* Records for earlier launches are dropped; they can no longer matter.
|
||||
*/
|
||||
export function withSwitchedOn(record: CrashRecord, name: string, pid: number, at: number, byHand: boolean): CrashRecord {
|
||||
const armed = record.armed.filter((a) => a.pid === pid && a.name !== name);
|
||||
armed.push({ name, pid, at });
|
||||
return {
|
||||
marks: byHand ? record.marks.filter((m) => m.name !== name) : record.marks,
|
||||
armed: armed.slice(-MAX_ARMED),
|
||||
};
|
||||
}
|
||||
|
||||
/** The record with one mark taken away (the person cleared it without switching the cheat on). */
|
||||
export function withoutMark(record: CrashRecord, name: string): CrashRecord {
|
||||
return { ...record, marks: record.marks.filter((m) => m.name !== name) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The record after the game with process `pid` ended, as seen from this page:
|
||||
* the cheats it switched on within CRASH_WINDOW_MS of `lastSeen` (the last
|
||||
* time it saw the game running) get marks. Nothing is marked when the page
|
||||
* was not watching closely enough to tell (see WATCH_GAP_MS). The armed
|
||||
* records of that launch are dropped either way.
|
||||
*/
|
||||
export function withGameEnded(
|
||||
record: CrashRecord,
|
||||
game: { pid: number; version?: string },
|
||||
lastSeen: number,
|
||||
noticed: number,
|
||||
): { record: CrashRecord; marked: string[] } {
|
||||
const watching = noticed - lastSeen <= WATCH_GAP_MS;
|
||||
const marked: string[] = [];
|
||||
const marks = record.marks.slice();
|
||||
for (const a of record.armed) {
|
||||
if (a.pid !== game.pid || !watching || lastSeen - a.at > CRASH_WINDOW_MS || marked.includes(a.name)) continue;
|
||||
marked.push(a.name);
|
||||
const mark: CrashMark = { name: a.name, at: noticed, by: 'page', ...(game.version ? { version: game.version } : {}) };
|
||||
const i = marks.findIndex((m) => m.name === a.name);
|
||||
if (i >= 0) marks[i] = mark;
|
||||
else marks.push(mark);
|
||||
}
|
||||
return { record: { marks: marks.slice(-MAX_MARKS), armed: record.armed.filter((a) => a.pid !== game.pid) }, marked };
|
||||
}
|
||||
|
||||
// ---- the record on the console, shared by everything on the page ---------------------
|
||||
|
||||
const records = new Map<string, CrashRecord>();
|
||||
const listeners = new Set<() => void>();
|
||||
/** One change at a time per game, so two quick switches cannot overwrite each other's record. */
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
const keyOf = (titleId: string) => titleId.toUpperCase();
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
|
||||
/** The last record read or written for a game on this page; null before the first read. */
|
||||
export function crashRecord(titleId: string): CrashRecord | null {
|
||||
return records.get(keyOf(titleId)) ?? null;
|
||||
}
|
||||
|
||||
export function subscribeCrashRecords(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Reads the record from the console. A console that cannot be asked has nothing marked. */
|
||||
export async function loadCrashRecord(target: Target | null, titleId: string): Promise<CrashRecord> {
|
||||
const record = parseCrashRecord(await loadPref(target, crashPrefName(titleId)));
|
||||
records.set(keyOf(titleId), record);
|
||||
emit();
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Reads, changes and writes back the record. Returns the new record, or null when nothing changed. */
|
||||
function update(target: Target | null, titleId: string, change: (r: CrashRecord) => CrashRecord | null): Promise<CrashRecord | null> {
|
||||
const key = keyOf(titleId);
|
||||
const run = async () => {
|
||||
// Read fresh each time: the PS5's engine writes this file too.
|
||||
const now = parseCrashRecord(await loadPref(target, crashPrefName(titleId)));
|
||||
const next = change(now);
|
||||
if (!next) {
|
||||
records.set(key, now);
|
||||
emit();
|
||||
return null;
|
||||
}
|
||||
records.set(key, next);
|
||||
emit();
|
||||
await savePref(target, crashPrefName(titleId), next);
|
||||
return next;
|
||||
};
|
||||
const job = (queues.get(key) ?? Promise.resolve()).then(run, run);
|
||||
queues.set(
|
||||
key,
|
||||
job.catch(() => undefined),
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that this page switched a cheat on. `byHand` is true when the person
|
||||
* did it (a switch, a hotkey): that clears the cheat's mark. Anything the page
|
||||
* switches on by itself (a start set) passes false, and must check
|
||||
* mayAutoSwitch() first. Never throws: crash protection must not get in the
|
||||
* way of a cheat that is already on.
|
||||
*/
|
||||
export async function noteSwitchedOn(target: Target | null, game: { titleId: string; pid: number }, name: string, opts: { byHand: boolean }): Promise<void> {
|
||||
try {
|
||||
await update(target, game.titleId, (r) => withSwitchedOn(r, name, game.pid, Date.now(), opts.byHand));
|
||||
} catch {
|
||||
// Not kept on the console this time (no payload, a network hiccup).
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes the mark off a cheat without switching it on. */
|
||||
export async function clearCrashMark(target: Target | null, titleId: string, name: string): Promise<void> {
|
||||
await update(target, titleId, (r) => (markOf(r, name) ? withoutMark(r, name) : null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the page sees that a game it was showing has ended (not when
|
||||
* the person started another one from the library). Marks the cheats this
|
||||
* page switched on less than a minute before, unless the PS5's engine does
|
||||
* that itself. Returns the names marked here.
|
||||
*/
|
||||
export async function noteGameEnded(
|
||||
target: Target | null,
|
||||
game: { titleId: string; pid: number; version?: string },
|
||||
lastSeen: number,
|
||||
noticed = Date.now(),
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const engine = await fetchEngineStatus(target);
|
||||
// The engine saw it end too, and also knows what it switched on itself. Two writers would only race.
|
||||
if (engine?.crashGuard) {
|
||||
await loadCrashRecord(target, game.titleId);
|
||||
return [];
|
||||
}
|
||||
let marked: string[] = [];
|
||||
await update(target, game.titleId, (r) => {
|
||||
const out = withGameEnded(r, game, lastSeen, noticed);
|
||||
marked = out.marked;
|
||||
return out.marked.length || out.record.armed.length !== r.armed.length ? out.record : null;
|
||||
});
|
||||
return marked;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- words -------------------------------------------------------------------------
|
||||
|
||||
function when(at: number): string {
|
||||
if (!at) return '';
|
||||
try {
|
||||
return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** One or two sentences for a marked cheat's row. */
|
||||
export function crashMarkText(mark: CrashMark): string {
|
||||
const time = when(mark.at);
|
||||
const who = mark.by === 'ps5' ? 'your PS5 switched it on' : 'it was switched on';
|
||||
return (
|
||||
`May have crashed the game: ${who} less than a minute before the game closed${time ? ` (${time})` : ''}. ` +
|
||||
'It will not be switched on by itself again until you switch it on here.'
|
||||
);
|
||||
}
|
||||
|
||||
/** A notice for cheats that were held back instead of switched on by themselves. */
|
||||
export function heldBackText(names: string[]): string {
|
||||
if (names.length === 0) return '';
|
||||
const list = names.length === 1 ? `"${names[0]}" was` : `${names.length} cheats were`;
|
||||
return `${list} not switched on by itself because the game closed less than a minute after ${names.length === 1 ? 'it was' : 'they were'} switched on last time. Switch ${names.length === 1 ? 'it' : 'them'} on by hand to try again.`;
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
// The diagnostics log: a record, kept in the page's memory, of what the app
|
||||
// did and what went wrong. When something misbehaves on the owner's console,
|
||||
// this is what he copies or saves and sends along, so it has to say plainly
|
||||
// what happened without anyone opening the browser's developer tools.
|
||||
//
|
||||
// It keeps the last few thousand lines only, so a page left open for hours
|
||||
// does not grow without end, and nothing is written anywhere until the person
|
||||
// asks for it (Copy log, Save log).
|
||||
|
||||
/** How serious one line is. */
|
||||
export type DiagLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
|
||||
/** How much is kept: problems only, problems and what the app did, or every request as well. */
|
||||
export type LogSetting = 'errors' | 'normal' | 'everything';
|
||||
|
||||
export const LOG_SETTINGS: readonly LogSetting[] = ['errors', 'normal', 'everything'];
|
||||
|
||||
export interface DiagLine {
|
||||
/** Milliseconds since 1970, as Date.now(). */
|
||||
time: number;
|
||||
level: DiagLevel;
|
||||
/** What part of the app it is about: connection, cheats, payload, page, ... */
|
||||
area: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The most lines kept. Older ones are dropped first. */
|
||||
export const DIAG_MAX_LINES = 3000;
|
||||
/** A single line longer than this is cut: a whole file in an error message helps nobody. */
|
||||
const MAX_TEXT = 2000;
|
||||
|
||||
const RANK: Record<DiagLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
|
||||
const KEEPS: Record<LogSetting, number> = { errors: 1, normal: 2, everything: 3 };
|
||||
|
||||
/** Whether a line of `level` is kept under `setting`. */
|
||||
export function keeps(setting: LogSetting, level: DiagLevel): boolean {
|
||||
return RANK[level] <= KEEPS[setting];
|
||||
}
|
||||
|
||||
export function isLogSetting(v: unknown): v is LogSetting {
|
||||
return v === 'errors' || v === 'normal' || v === 'everything';
|
||||
}
|
||||
|
||||
export class DiagLog {
|
||||
private kept: DiagLine[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
/** Lines trimmed off the start to stay under the limit. */
|
||||
private trimmed = 0;
|
||||
setting: LogSetting = 'normal';
|
||||
readonly max: number;
|
||||
/** Facts about the setup (version, console, game) that head every copied or saved log. */
|
||||
private context = new Map<string, string>();
|
||||
/**
|
||||
* Bumped after changes, so a screen can tell cheaply that it is out of date.
|
||||
* Listeners hear of changes a moment later, never in the middle of what wrote
|
||||
* the line: React logs its warnings while drawing, and a screen asked to
|
||||
* redraw in the middle of that would cause a warning of its own.
|
||||
*/
|
||||
revision = 0;
|
||||
private pending = false;
|
||||
|
||||
constructor(max = DIAG_MAX_LINES) {
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
add(level: DiagLevel, area: string, text: string, time = Date.now()): void {
|
||||
if (!keeps(this.setting, level)) return;
|
||||
const clean = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + ` … (${text.length - MAX_TEXT} more characters)` : text;
|
||||
this.kept.push({ time, level, area, text: clean });
|
||||
// Trimmed in chunks rather than one line at a time: shifting a long array on every line is slow.
|
||||
if (this.kept.length > this.max + 200) {
|
||||
const extra = this.kept.length - this.max;
|
||||
this.kept.splice(0, extra);
|
||||
this.trimmed += extra;
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
error(area: string, text: string): void {
|
||||
this.add('error', area, text);
|
||||
}
|
||||
warn(area: string, text: string): void {
|
||||
this.add('warn', area, text);
|
||||
}
|
||||
info(area: string, text: string): void {
|
||||
this.add('info', area, text);
|
||||
}
|
||||
debug(area: string, text: string): void {
|
||||
this.add('debug', area, text);
|
||||
}
|
||||
|
||||
/** The lines kept, oldest first (at most `max`). */
|
||||
lines(): DiagLine[] {
|
||||
return this.kept.length > this.max ? this.kept.slice(this.kept.length - this.max) : this.kept.slice();
|
||||
}
|
||||
|
||||
/** Lines no longer kept, so a report can say its start is missing. */
|
||||
get dropped(): number {
|
||||
return this.trimmed + Math.max(0, this.kept.length - this.max);
|
||||
}
|
||||
|
||||
/** How many lines are kept now. */
|
||||
get size(): number {
|
||||
return Math.min(this.kept.length, this.max);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.kept = [];
|
||||
this.trimmed = 0;
|
||||
this.changed();
|
||||
}
|
||||
|
||||
setSetting(setting: LogSetting): void {
|
||||
if (setting === this.setting) return;
|
||||
this.setting = setting;
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** Sets (or with null, removes) one fact for the head of the report. */
|
||||
setContext(key: string, value: string | null): void {
|
||||
if (value === null) this.context.delete(key);
|
||||
else this.context.set(key, value);
|
||||
}
|
||||
|
||||
contextEntries(): [string, string][] {
|
||||
return [...this.context.entries()];
|
||||
}
|
||||
|
||||
/** Calls `listener` after every change. Returns the way to stop. */
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private changed(): void {
|
||||
if (this.pending) return;
|
||||
this.pending = true;
|
||||
void Promise.resolve().then(() => {
|
||||
this.pending = false;
|
||||
this.revision++;
|
||||
for (const l of this.listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch {
|
||||
// A broken screen must not stop the log.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The whole log as text, with what is known about the setup at the top. */
|
||||
report(extra: [string, string][] = [], now = Date.now()): string {
|
||||
const head: [string, string][] = [['Written', stamp(now)], ...this.contextEntries(), ...extra, ['Log level', this.setting], ['Lines', String(this.size)]];
|
||||
const width = Math.max(...head.map(([k]) => k.length));
|
||||
const out = ['CheatSpecter diagnostics', ...head.map(([k, v]) => `${(k + ':').padEnd(width + 1)} ${v}`), ''];
|
||||
if (this.dropped > 0) out.push(`(${this.dropped} older lines were dropped to stay under ${this.max})`);
|
||||
for (const l of this.lines()) out.push(formatLine(l));
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
/** The one log the app writes to. */
|
||||
export const diag = new DiagLog();
|
||||
|
||||
function pad(n: number, w = 2): string {
|
||||
return String(n).padStart(w, '0');
|
||||
}
|
||||
|
||||
/** Local date and time to the millisecond: 2026-10-11 14:03:22.123 */
|
||||
export function stamp(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
||||
}
|
||||
|
||||
/** One line of the report. Continuation lines of a multi-line text are indented under it. */
|
||||
export function formatLine(l: DiagLine): string {
|
||||
const text = l.text.replace(/\r?\n/g, '\n ');
|
||||
return `${stamp(l.time)} ${l.level.toUpperCase().padEnd(5)} ${l.area}: ${text}`;
|
||||
}
|
||||
|
||||
/** Something passed to console.error or thrown, as readable text. */
|
||||
export function describeValue(v: unknown): string {
|
||||
if (v instanceof Error) {
|
||||
// The first few frames say where; the rest is noise in a log.
|
||||
const stack = typeof v.stack === 'string' ? v.stack.split('\n').slice(1, 6).map((s) => s.trim()).filter(Boolean) : [];
|
||||
return `${v.name}: ${v.message}` + (stack.length ? `\n${stack.join('\n')}` : '');
|
||||
}
|
||||
if (typeof v === 'string') return v;
|
||||
if (v === undefined) return 'undefined';
|
||||
if (typeof v === 'bigint') return `0x${v.toString(16)}`;
|
||||
try {
|
||||
const s = JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? `0x${x.toString(16)}` : x));
|
||||
return s === undefined ? String(v) : s.length > 500 ? s.slice(0, 500) + '…' : s;
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
/** Joins console arguments the way the console shows them, filling in React's "%s" placeholders. */
|
||||
export function describeArgs(args: unknown[]): string {
|
||||
if (typeof args[0] === 'string' && args[0].includes('%')) {
|
||||
let i = 1;
|
||||
const first = args[0].replace(/%[sdifoO]/g, (m) => (i < args.length ? describeValue(args[i++]) : m));
|
||||
return [first, ...args.slice(i).map(describeValue)].join(' ');
|
||||
}
|
||||
return args.map(describeValue).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* What a finished request is worth writing down, or null for nothing. Only
|
||||
* requests to the payload (or the bridge) and to the online cheat collections
|
||||
* are looked at; `status` is 0 when the request failed without an answer.
|
||||
*/
|
||||
export function classifyRequest(method: string, url: string, status: number, error?: string): { level: DiagLevel; area: string; text: string } | null {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url, 'http://page.invalid/');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const online = u.hostname === 'raw.githubusercontent.com' || u.hostname === 'api.github.com';
|
||||
const api = u.pathname.match(/\/(api\/.*)$/)?.[1];
|
||||
if (!online && !api) return null;
|
||||
const m = method.toUpperCase();
|
||||
if (online) {
|
||||
const file = decodeURIComponent(u.pathname.split('/').slice(-2).join('/'));
|
||||
if (status === 0) return { level: 'warn', area: 'online', text: `Could not download ${file}: ${error || 'no answer'}` };
|
||||
if (status >= 400) return { level: status === 404 ? 'debug' : 'warn', area: 'online', text: `Downloading ${file} failed (${status})` };
|
||||
return { level: 'info', area: 'online', text: `Downloaded ${file}` };
|
||||
}
|
||||
const path = decodeURIComponent(api!);
|
||||
if (status === 0) return { level: 'warn', area: 'payload', text: `${m} ${path}: no answer (${error || 'the request failed'})` };
|
||||
const table = path.match(/^api\/tables\/([^/]+)\/([^/]+)$/);
|
||||
if (status >= 400) {
|
||||
// A file or setting that was never saved, or a game without an icon, is routine.
|
||||
const routine = status === 404 && m === 'GET' && /^api\/(tables|prefs|appmeta)\//.test(path);
|
||||
return { level: routine ? 'debug' : status >= 500 ? 'error' : 'warn', area: 'payload', text: `${m} ${path} was refused: ${status}${error ? ` (${error})` : ''}` };
|
||||
}
|
||||
if (table && m === 'GET') return { level: 'info', area: 'cheats', text: `Loaded the cheat file for ${table[1]} ${table[2]} from the PS5` };
|
||||
if (table && m === 'PUT') return { level: 'info', area: 'cheats', text: `Saved the cheat file for ${table[1]} ${table[2]} on the PS5` };
|
||||
if (table && m === 'DELETE') return { level: 'info', area: 'cheats', text: `Deleted the cheat file for ${table[1]} ${table[2]} from the PS5` };
|
||||
if (/^api\/(launch|shutdown)/.test(path)) return { level: 'info', area: 'payload', text: `${m} ${path}: ${status}` };
|
||||
return { level: 'debug', area: 'payload', text: `${m} ${path}: ${status}` };
|
||||
}
|
||||
|
||||
interface HookTarget {
|
||||
console: Pick<Console, 'error' | 'warn'>;
|
||||
addEventListener: (type: string, fn: (e: Event) => void) => void;
|
||||
removeEventListener: (type: string, fn: (e: Event) => void) => void;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends console.error and console.warn, uncaught errors, unhandled promise
|
||||
* rejections and the app's requests into the log as well as wherever they
|
||||
* went before. Returns the way to undo it. Safe to call more than once: the
|
||||
* second call does nothing until the first is undone.
|
||||
*/
|
||||
export function installDiagHooks(target: HookTarget = globalThis as unknown as HookTarget, log: DiagLog = diag): () => void {
|
||||
const marker = target as HookTarget & { __csDiag?: boolean };
|
||||
if (marker.__csDiag) return () => undefined;
|
||||
marker.__csDiag = true;
|
||||
|
||||
const { error, warn } = target.console;
|
||||
// Reentry guard: if writing to the log itself ends up calling the console, it must not loop.
|
||||
let inside = false;
|
||||
const wrap = (level: DiagLevel, orig: (...a: unknown[]) => void) =>
|
||||
function (this: unknown, ...args: unknown[]) {
|
||||
if (!inside) {
|
||||
inside = true;
|
||||
try {
|
||||
log.add(level, 'page', describeArgs(args));
|
||||
} finally {
|
||||
inside = false;
|
||||
}
|
||||
}
|
||||
orig.apply(this, args);
|
||||
};
|
||||
target.console.error = wrap('error', error);
|
||||
target.console.warn = wrap('warn', warn);
|
||||
|
||||
const onError = (e: Event) => {
|
||||
const ev = e as ErrorEvent;
|
||||
// A resource that failed to load (an image, a script) reports here too, without a message.
|
||||
if (!ev.message && !ev.error) return;
|
||||
const where = ev.filename ? ` in ${ev.filename.replace(/^.*\//, '')}:${ev.lineno}` : '';
|
||||
log.error('page', `Uncaught${where}: ${ev.error ? describeValue(ev.error) : ev.message}`);
|
||||
};
|
||||
const onRejection = (e: Event) => {
|
||||
log.error('page', `Unhandled rejection: ${describeValue((e as PromiseRejectionEvent).reason)}`);
|
||||
};
|
||||
target.addEventListener('error', onError);
|
||||
target.addEventListener('unhandledrejection', onRejection);
|
||||
|
||||
const origFetch = target.fetch;
|
||||
if (typeof origFetch === 'function') {
|
||||
target.fetch = async function (this: unknown, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const method = init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET');
|
||||
let res: Response;
|
||||
try {
|
||||
res = await origFetch.call(this ?? globalThis, input, init);
|
||||
} catch (e) {
|
||||
const n = classifyRequest(method, url, 0, e instanceof Error ? e.message : String(e));
|
||||
if (n && !(e instanceof DOMException && e.name === 'AbortError')) log.add(n.level, n.area, n.text);
|
||||
throw e;
|
||||
}
|
||||
const n = classifyRequest(method, url, res.status);
|
||||
if (n) log.add(n.level, n.area, n.text);
|
||||
return res;
|
||||
} as typeof fetch;
|
||||
}
|
||||
|
||||
return () => {
|
||||
target.console.error = error;
|
||||
target.console.warn = warn;
|
||||
target.removeEventListener('error', onError);
|
||||
target.removeEventListener('unhandledrejection', onRejection);
|
||||
if (typeof origFetch === 'function') target.fetch = origFetch;
|
||||
marker.__csDiag = false;
|
||||
};
|
||||
}
|
||||
|
||||
/** A short description of the browser, from its user agent, for the head of the report. */
|
||||
export function browserName(ua: string): string {
|
||||
const pick = (re: RegExp, name: string) => {
|
||||
const m = ua.match(re);
|
||||
return m ? `${name} ${m[1]}` : null;
|
||||
};
|
||||
const browser =
|
||||
pick(/PlayStation 5[/ ]([\d.]+)/, 'PS5 browser, system') ??
|
||||
pick(/Edg\/([\d.]+)/, 'Edge') ??
|
||||
pick(/OPR\/([\d.]+)/, 'Opera') ??
|
||||
pick(/Firefox\/([\d.]+)/, 'Firefox') ??
|
||||
pick(/Chrome\/([\d.]+)/, 'Chrome') ??
|
||||
pick(/Version\/([\d.]+).*Safari/, 'Safari') ??
|
||||
'Unknown browser';
|
||||
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iOS' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : /PlayStation/.test(ua) ? 'PS5' : '';
|
||||
return os ? `${browser} on ${os}` : browser;
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
// Game patches: the community's PS4 and PS5 patches (60 FPS, resolution and
|
||||
// quality modes, debug menus) from github.com/illusion0001/PS4-PS5-Game-Patch,
|
||||
// turned into CheatSpecter patch entries.
|
||||
//
|
||||
// The format. One XML file per game (named after the game, not its title
|
||||
// ID), listing the title IDs it covers and any number of patches:
|
||||
//
|
||||
// <Patch>
|
||||
// <TitleID><ID>PPSA01339</ID>…</TitleID>
|
||||
// <Metadata Title=".." Name=".." Note=".." Author=".." PatchVer="1.0"
|
||||
// AppVer="01.004.000" AppElf="eboot.bin" ImageBase="0x584a8000">
|
||||
// <PatchList>
|
||||
// <Line Type="bytes" Address="0x58cfbb49" Value="e937010000"/>
|
||||
// <Line Type="mask" Address="48 8b ?? 05" Offset="+3" Value="90"/>
|
||||
// </PatchList>
|
||||
// </Metadata>
|
||||
// </Patch>
|
||||
//
|
||||
// What a line means follows the two programs that apply these files on a
|
||||
// console: GoldHEN's game_patch plugin (PS4) and etaHEN's patch plugin (PS5),
|
||||
// which share their code. An address is where the game would be with its
|
||||
// executable at ImageBase (0x400000 when the patch does not say; an
|
||||
// ImageBase of 0 means the address is already an offset). A "mask" line finds
|
||||
// its place by a byte pattern in the game's executable instead, then moves by
|
||||
// Offset (decimal, "+N" or "-N"). A patch only applies to exactly its AppVer,
|
||||
// unless AppVer starts with "mask" or "all" (patterns work on any version).
|
||||
//
|
||||
// The repository has no index by title ID. Its releases carry a zip with one
|
||||
// file per title ID, but a browser cannot download release files from
|
||||
// GitHub (no CORS), so the list of files comes from GitHub's API in one
|
||||
// request, and each file is read once to learn which title IDs it covers.
|
||||
// What was learned is kept, keyed by each file's content hash, so the next
|
||||
// look for any game only reads the files that changed.
|
||||
|
||||
import { bytesToHex, hexToBytes } from './bytes.ts';
|
||||
import { sameVersion } from './cheats.ts';
|
||||
import { resolveLocation } from './locations.ts';
|
||||
import type { GameContext, Location } from './locations.ts';
|
||||
import { loadJson, saveJson } from './storage.ts';
|
||||
import { newId, patchSites, sourceDigest, sourceId, stableJson } from './table.ts';
|
||||
import type { Entry, EntryOrigin, PatchEntry, PatchSite, TableSource } from './table.ts';
|
||||
import { decodeText } from './trainer.ts';
|
||||
import { parsePattern } from './x86.ts';
|
||||
|
||||
// ---- the XML ------------------------------------------------------------------
|
||||
|
||||
interface XmlElement {
|
||||
name: string;
|
||||
attrs: Record<string, string>;
|
||||
children: XmlElement[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ENTITIES: Record<string, string> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
|
||||
|
||||
function decodeEntities(text: string): string {
|
||||
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, name: string) => {
|
||||
if (name[0] === '#') {
|
||||
const code = name[1] === 'x' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
||||
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
||||
}
|
||||
return ENTITIES[name] ?? whole;
|
||||
});
|
||||
}
|
||||
|
||||
const TAG = /<\/?([A-Za-z_][\w.:-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/y;
|
||||
const ATTR = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
|
||||
/**
|
||||
* Just enough XML for these files: elements, attributes, text, comments and
|
||||
* the declaration. Throws with a plain reason on anything malformed, rather
|
||||
* than guess at what a damaged file meant.
|
||||
*/
|
||||
function parseXml(text: string): XmlElement {
|
||||
const root: XmlElement = { name: '', attrs: {}, children: [], text: '' };
|
||||
const stack: XmlElement[] = [root];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const lt = text.indexOf('<', i);
|
||||
const chunk = lt < 0 ? text.slice(i) : text.slice(i, lt);
|
||||
if (chunk.trim()) stack[stack.length - 1].text += decodeEntities(chunk);
|
||||
if (lt < 0) break;
|
||||
const skip = (open: string, close: string): boolean => {
|
||||
if (!text.startsWith(open, lt)) return false;
|
||||
const end = text.indexOf(close, lt + open.length);
|
||||
if (end < 0) throw new Error('The patch file is cut short (a comment or declaration is never closed)');
|
||||
i = end + close.length;
|
||||
return true;
|
||||
};
|
||||
if (skip('<!--', '-->') || skip('<?', '?>') || skip('<!DOCTYPE', '>')) continue;
|
||||
if (text.startsWith('<![CDATA[', lt)) {
|
||||
const end = text.indexOf(']]>', lt);
|
||||
if (end < 0) throw new Error('The patch file is cut short');
|
||||
stack[stack.length - 1].text += text.slice(lt + 9, end);
|
||||
i = end + 3;
|
||||
continue;
|
||||
}
|
||||
TAG.lastIndex = lt;
|
||||
const m = TAG.exec(text);
|
||||
if (!m) throw new Error(`The patch file is not valid XML (near character ${lt})`);
|
||||
i = TAG.lastIndex;
|
||||
const closing = text[lt + 1] === '/';
|
||||
if (closing) {
|
||||
const open = stack.pop();
|
||||
if (!open || open === root || open.name !== m[1]) throw new Error(`The patch file is not valid XML (</${m[1]}> does not close anything)`);
|
||||
continue;
|
||||
}
|
||||
const el: XmlElement = { name: m[1], attrs: {}, children: [], text: '' };
|
||||
for (const a of m[2].matchAll(ATTR)) el.attrs[a[1]] = decodeEntities(a[2] ?? a[3] ?? '');
|
||||
stack[stack.length - 1].children.push(el);
|
||||
if (!m[3]) stack.push(el);
|
||||
}
|
||||
if (stack.length !== 1) throw new Error(`The patch file is cut short (<${stack[stack.length - 1].name}> is never closed)`);
|
||||
return root;
|
||||
}
|
||||
|
||||
function* descendants(el: XmlElement, name: string): Generator<XmlElement> {
|
||||
for (const c of el.children) {
|
||||
if (c.name === name) yield c;
|
||||
yield* descendants(c, name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the patches in a file -----------------------------------------------------
|
||||
|
||||
export interface GamePatchLine {
|
||||
type: string;
|
||||
/** A hex address, or for "mask" lines the byte pattern. */
|
||||
address: string;
|
||||
value: string;
|
||||
/** "mask" lines: where the place is from the start of the match ("+3", "-8"). */
|
||||
offset: string;
|
||||
/** "mask_jump32": the pattern of the free space the code goes into, and how many bytes the jump replaces. */
|
||||
target: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface GamePatch {
|
||||
/** The game's name as the patch gives it. */
|
||||
title: string;
|
||||
name: string;
|
||||
note: string;
|
||||
author: string;
|
||||
patchVer: string;
|
||||
/** The game version it is for; "mask" or "all" (and longer words starting so) for any. */
|
||||
appVer: string;
|
||||
/** The program it patches; "eboot.bin" for the game itself. */
|
||||
appElf: string;
|
||||
/** As written; "" when the patch does not say. */
|
||||
imageBase: string;
|
||||
lines: GamePatchLine[];
|
||||
/**
|
||||
* Tells this patch apart from the others for its game, for as long as its
|
||||
* game title, name, version and program stay the same: a djb2 hash of
|
||||
* those, as the console plugins hash a patch to key its on/off setting.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface GamePatchFile {
|
||||
/** Title IDs the file covers, upper case. */
|
||||
titleIds: string[];
|
||||
patches: GamePatch[];
|
||||
}
|
||||
|
||||
/** djb2, as the console plugins hash a patch's identity; 64 bits, in hex. */
|
||||
function djb2(text: string): string {
|
||||
let h = 5381n;
|
||||
for (const b of new TextEncoder().encode(text)) h = BigInt.asUintN(64, (h * 33n) ^ BigInt(b));
|
||||
return h.toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
export function parseGamePatchFile(text: string): GamePatchFile {
|
||||
const root = parseXml(text.replace(/^/, ''));
|
||||
const top = root.children.find((c) => c.name === 'Patch');
|
||||
if (!top) throw new Error('This is not a game patch file (it has no <Patch> in it)');
|
||||
const titleIds: string[] = [];
|
||||
for (const id of descendants(top, 'ID')) {
|
||||
const t = id.text.trim().toUpperCase();
|
||||
if (/^[A-Z]{4}\d{5}$/.test(t) && !titleIds.includes(t)) titleIds.push(t);
|
||||
}
|
||||
const patches: GamePatch[] = [];
|
||||
for (const meta of descendants(top, 'Metadata')) {
|
||||
const a = meta.attrs;
|
||||
const lines: GamePatchLine[] = [];
|
||||
for (const line of descendants(meta, 'Line')) {
|
||||
const l = line.attrs;
|
||||
lines.push({ type: l.Type ?? '', address: l.Address ?? '', value: l.Value ?? '', offset: l.Offset ?? '', target: l.Target ?? '', size: l.Size ?? '' });
|
||||
}
|
||||
const p: Omit<GamePatch, 'key'> = {
|
||||
title: a.Title ?? '',
|
||||
name: (a.Name ?? '').trim(),
|
||||
note: (a.Note ?? '').trim(),
|
||||
author: (a.Author ?? '').trim(),
|
||||
patchVer: a.PatchVer ?? '',
|
||||
appVer: (a.AppVer ?? '').trim(),
|
||||
appElf: (a.AppElf ?? '').trim(),
|
||||
imageBase: (a.ImageBase ?? '').trim(),
|
||||
lines,
|
||||
};
|
||||
patches.push({ ...p, key: djb2(`${p.title}${p.name}${p.appVer}${p.appElf}`) });
|
||||
}
|
||||
return { titleIds, patches };
|
||||
}
|
||||
|
||||
/** "exact" for a patch made for this version, "any" for one that works on every version, null for neither. */
|
||||
export function versionMatch(appVer: string, running: string): 'exact' | 'any' | null {
|
||||
const v = appVer.trim().toLowerCase();
|
||||
if (v.startsWith('mask') || v.startsWith('all')) return 'any';
|
||||
return sameVersion(appVer, running) ? 'exact' : null;
|
||||
}
|
||||
|
||||
// ---- turning lines into bytes ----------------------------------------------------
|
||||
|
||||
/** Where a game's executable sits in the addresses of a patch that does not say (PS4 games without ASLR). */
|
||||
export const DEFAULT_IMAGE_BASE = 0x400000n;
|
||||
|
||||
/** A problem with one patch, in words for the person: why it cannot be used. */
|
||||
export class PatchRefused extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PatchRefused';
|
||||
}
|
||||
}
|
||||
|
||||
const refuse = (message: string): never => {
|
||||
throw new PatchRefused(message);
|
||||
};
|
||||
|
||||
const INT_BITS: Record<string, number> = { byte: 8, bytes16: 16, bytes32: 32, bytes64: 64 };
|
||||
|
||||
/** A whole number as the plugins read one: hex with 0x (or # or $, as the format's guide allows), else decimal. */
|
||||
function integer(value: string, bits: number): bigint {
|
||||
const t = value.trim();
|
||||
let n: bigint;
|
||||
try {
|
||||
const hex = /^(?:0x|#|\$)([0-9a-fA-F]+)$/i.exec(t);
|
||||
if (hex) n = BigInt('0x' + hex[1]);
|
||||
else if (/^[+-]?\d+$/.test(t)) n = BigInt(t.replace(/^\+/, ''));
|
||||
else return refuse(`"${value}" is not a number`);
|
||||
} catch {
|
||||
return refuse(`"${value}" is not a number`);
|
||||
}
|
||||
const max = 1n << BigInt(bits);
|
||||
if (n >= max || n < -(max >> 1n)) refuse(`${value} does not fit in ${bits / 8} ${bits === 8 ? 'byte' : 'bytes'}`);
|
||||
return BigInt.asUintN(bits, n);
|
||||
}
|
||||
|
||||
/** Hex digits to bytes; an odd count gets a leading zero, as the plugins read it. */
|
||||
function hexValue(value: string): Uint8Array {
|
||||
const t = value.replace(/\s+/g, '');
|
||||
if (!t) refuse('a line has no bytes to write');
|
||||
if (!/^[0-9a-fA-F]+$/.test(t)) refuse(`"${value}" is not written as hex bytes`);
|
||||
return hexToBytes(t.length % 2 ? '0' + t : t);
|
||||
}
|
||||
|
||||
/** A string with the escapes the plugins understand (\n \t \r \0 \\ \xHH), as code units; \xHH gives that exact unit. */
|
||||
function unescape(value: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const c = value[i];
|
||||
if (c !== '\\' || i === value.length - 1) {
|
||||
out.push(value.charCodeAt(i));
|
||||
continue;
|
||||
}
|
||||
const e = value[++i];
|
||||
if (e === 'n') out.push(10);
|
||||
else if (e === 't') out.push(9);
|
||||
else if (e === 'r') out.push(13);
|
||||
else if (e === '0') out.push(0);
|
||||
else if (e === 'x' && /^[0-9a-fA-F]{2}$/.test(value.slice(i + 1, i + 3))) {
|
||||
out.push(parseInt(value.slice(i + 1, i + 3), 16) | 0x10000); // marked as a raw byte
|
||||
i += 2;
|
||||
} else out.push(value.charCodeAt(i));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The bytes one line writes. Throws PatchRefused when the line cannot be applied safely. */
|
||||
export function lineBytes(line: GamePatchLine): Uint8Array {
|
||||
const kind = line.type.replace(/^mask_/, '').replace(/^mask$/, 'bytes');
|
||||
if (line.value === '') refuse('a line has no value to write');
|
||||
const bits = INT_BITS[kind];
|
||||
if (bits) {
|
||||
const n = integer(line.value, bits);
|
||||
const out = new Uint8Array(bits / 8);
|
||||
for (let i = 0; i < out.length; i++) out[i] = Number((n >> BigInt(8 * i)) & 0xffn);
|
||||
return out;
|
||||
}
|
||||
switch (kind) {
|
||||
case 'bytes':
|
||||
return hexValue(line.value);
|
||||
case 'float32':
|
||||
case 'float64': {
|
||||
const t = line.value.trim();
|
||||
const f = Number(t);
|
||||
if (!t || !Number.isFinite(f)) refuse(`"${line.value}" is not a number`);
|
||||
const out = new Uint8Array(kind === 'float32' ? 4 : 8);
|
||||
const view = new DataView(out.buffer);
|
||||
if (kind === 'float32') view.setFloat32(0, f, true);
|
||||
else view.setFloat64(0, f, true);
|
||||
return out;
|
||||
}
|
||||
case 'utf8': {
|
||||
// Written with its ending zero, as the plugins do.
|
||||
// Runs of ordinary characters are encoded together, so characters outside the basic plane stay whole.
|
||||
const bytes: number[] = [];
|
||||
let run: number[] = [];
|
||||
const flush = () => {
|
||||
if (run.length) bytes.push(...new TextEncoder().encode(String.fromCharCode(...run)));
|
||||
run = [];
|
||||
};
|
||||
for (const unit of unescape(line.value)) {
|
||||
if (unit & 0x10000) {
|
||||
flush();
|
||||
bytes.push(unit & 0xff);
|
||||
} else run.push(unit);
|
||||
}
|
||||
flush();
|
||||
bytes.push(0);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
case 'utf16': {
|
||||
const units = [...unescape(line.value).map((u) => u & 0xffff), 0];
|
||||
const out = new Uint8Array(units.length * 2);
|
||||
units.forEach((u, i) => {
|
||||
out[i * 2] = u & 0xff;
|
||||
out[i * 2 + 1] = u >> 8;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
case 'jump32':
|
||||
return refuse('it writes new code into free space it has to find in the game, which CheatSpecter does not do for game patches');
|
||||
case 'patchCall':
|
||||
return refuse('it changes the function a call in the game leads to, which CheatSpecter does not do for game patches');
|
||||
default:
|
||||
return refuse(line.type ? `it uses a kind of line ("${line.type}") CheatSpecter does not know` : 'a line does not say what kind it is');
|
||||
}
|
||||
}
|
||||
|
||||
/** A hex number as the plugins read an address (strtoull base 16: "0x" optional). */
|
||||
function address(text: string, what: string): bigint {
|
||||
const t = text.trim().replace(/^0x/i, '');
|
||||
if (!/^[0-9a-fA-F]{1,16}$/.test(t)) refuse(`${what} "${text}" is not a hex address`);
|
||||
return BigInt('0x' + t);
|
||||
}
|
||||
|
||||
/** "+3", "-8", "3", "0x10" or nothing: how far from a pattern's match the place is. */
|
||||
function maskOffset(text: string): number {
|
||||
const t = text.trim();
|
||||
if (!t || t === '0') return 0;
|
||||
const m = /^([+-]?)(0x[0-9a-fA-F]+|\d+)$/i.exec(t);
|
||||
if (!m) return refuse(`"${text}" is not a distance from the pattern`);
|
||||
const n = Number(m[2]);
|
||||
if (!Number.isSafeInteger(n) || n > 0x1000000) refuse(`"${text}" is too far from the pattern`);
|
||||
return m[1] === '-' ? -n : n;
|
||||
}
|
||||
|
||||
export interface ConvertedSite {
|
||||
at: Location;
|
||||
/** Bytes written when the patch is on, in hex. */
|
||||
on: string;
|
||||
}
|
||||
|
||||
/** True when the patch is for the game's own executable (the program CheatSpecter works on), going by its AppElf. */
|
||||
export function forMainProgram(patch: GamePatch, processName = 'eboot.bin'): boolean {
|
||||
const elf = patch.appElf.toLowerCase();
|
||||
return !elf || elf === 'eboot.bin' || elf === processName.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every place the patch changes, with what goes there. Works without the game:
|
||||
* places are offsets into the game's executable or byte patterns. Throws
|
||||
* PatchRefused, with the reason, for a patch that cannot be applied safely;
|
||||
* a patch is never used in part.
|
||||
*/
|
||||
export function convertPatch(patch: GamePatch, processName = 'eboot.bin'): ConvertedSite[] {
|
||||
if (!forMainProgram(patch, processName)) {
|
||||
refuse(`it is for ${patch.appElf}, a different program of the game than the one CheatSpecter changes`);
|
||||
}
|
||||
if (patch.lines.length === 0) refuse('it has no lines');
|
||||
let base = DEFAULT_IMAGE_BASE;
|
||||
if (patch.imageBase) base = address(patch.imageBase, 'Its ImageBase');
|
||||
const sites: ConvertedSite[] = [];
|
||||
for (const line of patch.lines) {
|
||||
const bytes = lineBytes(line);
|
||||
let at: Location;
|
||||
if (line.type.startsWith('mask')) {
|
||||
let pattern: string;
|
||||
try {
|
||||
const parsed = parsePattern(line.address);
|
||||
pattern = [...parsed.pattern].map((b, i) => (parsed.mask[i] ? b.toString(16).padStart(2, '0').toUpperCase() : '??')).join(' ');
|
||||
} catch {
|
||||
return refuse(`"${line.address}" is not a byte pattern`);
|
||||
}
|
||||
at = { kind: 'signature', pattern, offset: maskOffset(line.offset) };
|
||||
} else {
|
||||
const where = address(line.address, 'Address');
|
||||
// ImageBase 0 means the addresses are offsets already.
|
||||
const offset = base === 0n ? where : where - base;
|
||||
if (offset < 0n) refuse(`the address ${line.address} is before the start of the game's code`);
|
||||
at = { kind: 'module', offset: offset.toString(16).toUpperCase() };
|
||||
}
|
||||
sites.push({ at, on: bytesToHex(bytes).toUpperCase() });
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
// ---- finding the file for a game ---------------------------------------------------
|
||||
|
||||
export const GAME_PATCH_SOURCE_ID = 'gamepatch';
|
||||
export const GAME_PATCH_SOURCE = {
|
||||
id: GAME_PATCH_SOURCE_ID,
|
||||
name: 'PS4-PS5-Game-Patch',
|
||||
home: 'https://github.com/illusion0001/PS4-PS5-Game-Patch',
|
||||
} as const;
|
||||
|
||||
const TREE_URL = 'https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main?recursive=1';
|
||||
const RAW_BASE = 'https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/';
|
||||
/** PS5 games' patches; PS4 games' are in "patches/xml/". */
|
||||
const PS5_FOLDER = 'patches/xml_prospero/';
|
||||
const PS4_FOLDER = 'patches/xml/';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
/** How long the list of files is trusted before it is asked for again. GitHub allows 60 asks an hour without an account. */
|
||||
const TREE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
/** Patch files read at the same time while looking for a game. */
|
||||
const PARALLEL = 4;
|
||||
|
||||
export interface PatchFileRef {
|
||||
/** e.g. "patches/xml_prospero/DemonSouls.xml" */
|
||||
path: string;
|
||||
/** Git's hash of the file's content: a new one means the file changed. */
|
||||
sha: string;
|
||||
}
|
||||
|
||||
/** Anything that downloads text; the browser's fetch by default, replaced in tests. */
|
||||
export type FetchText = (url: string, signal?: AbortSignal) => Promise<string>;
|
||||
|
||||
async function defaultFetchText(url: string, signal?: AbortSignal): Promise<string> {
|
||||
const stop = new AbortController();
|
||||
const timer = setTimeout(() => stop.abort(), FETCH_TIMEOUT_MS);
|
||||
const onAbort = () => stop.abort();
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-cache', signal: stop.signal });
|
||||
if (res.status === 403 || res.status === 429) throw new Error('GitHub is limiting how often it answers. Try again in an hour');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return decodeText(new Uint8Array(await res.arrayBuffer()));
|
||||
} catch (e) {
|
||||
if (signal?.aborted) throw new DOMException('Canceled', 'AbortError');
|
||||
if (stop.signal.aborted) throw new Error('the download timed out');
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function rawUrl(path: string): string {
|
||||
return RAW_BASE + path.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
/** The patch files in the repository's listing, from GitHub's tree API answer. */
|
||||
export function parseTree(text: string): PatchFileRef[] {
|
||||
let doc: { tree?: { path?: unknown; type?: unknown; sha?: unknown }[] };
|
||||
try {
|
||||
doc = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('GitHub sent something that is not a file list');
|
||||
}
|
||||
if (!doc || !Array.isArray(doc.tree)) throw new Error('GitHub sent something that is not a file list');
|
||||
const out: PatchFileRef[] = [];
|
||||
for (const t of doc.tree) {
|
||||
if (t.type !== 'blob' || typeof t.path !== 'string' || typeof t.sha !== 'string') continue;
|
||||
if (!/\.xml$/i.test(t.path)) continue;
|
||||
const folder = t.path.startsWith(PS5_FOLDER) ? PS5_FOLDER : t.path.startsWith(PS4_FOLDER) ? PS4_FOLDER : null;
|
||||
// Only files directly in the folder; the plugins never look deeper.
|
||||
if (!folder || t.path.slice(folder.length).includes('/')) continue;
|
||||
out.push({ path: t.path, sha: t.sha });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which folder a title's patches are in: PS5 games (PPSA) and PS4 games (CUSA) are kept apart, as etaHEN reads them. */
|
||||
export function folderFor(titleId: string): string {
|
||||
return /^PPSA/i.test(titleId) ? PS5_FOLDER : PS4_FOLDER;
|
||||
}
|
||||
|
||||
/** Letters and digits only, lower case, without the "Orbis" (PS4) the file names often carry. */
|
||||
function squash(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/['’]s\b/g, '')
|
||||
.replace(/['’]/g, '')
|
||||
.replace(/\.xml$/, '')
|
||||
.replace(/[-_ ]?orbis$/, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/** How likely a file is to be the game's, by its name; higher first. Used only to choose what to read first. */
|
||||
export function nameScore(path: string, gameName: string): number {
|
||||
const file = squash(path.slice(path.lastIndexOf('/') + 1));
|
||||
const game = squash(gameName);
|
||||
if (!game || !file) return 0;
|
||||
if (file === game) return 100;
|
||||
if (file.includes(game) || game.includes(file)) return 50;
|
||||
const words = gameName.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2);
|
||||
return words.filter((w) => file.includes(w)).length;
|
||||
}
|
||||
|
||||
interface IdCache {
|
||||
[path: string]: { sha: string; ids: string[] };
|
||||
}
|
||||
interface TreeCache {
|
||||
at: number;
|
||||
files: PatchFileRef[];
|
||||
}
|
||||
interface TextCache {
|
||||
sha: string;
|
||||
path: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const TREE_KEY = 'gamepatch.tree';
|
||||
const IDS_KEY = 'gamepatch.ids';
|
||||
const textKey = (titleId: string) => `gamepatch.file.${titleId.toUpperCase()}`;
|
||||
|
||||
export interface GamePatchLookup {
|
||||
titleId: string;
|
||||
/** The file the patches came from; null when no file covers this game. */
|
||||
file: (PatchFileRef & { url: string }) | null;
|
||||
/** Every patch in that file (all versions). Empty when there is no file. */
|
||||
patches: GamePatch[];
|
||||
/** True when the answer came from a saved copy because GitHub could not be reached. */
|
||||
stale: boolean;
|
||||
/** Patch files read during this look, for honest "it took a while" wording. */
|
||||
read: number;
|
||||
/**
|
||||
* Patch files of the game's folder not read yet, when the look stopped
|
||||
* short of them (see FindOptions.maxReads): a `file` of null then means
|
||||
* "not found yet", not "there is none". 0 otherwise.
|
||||
*/
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export interface FindOptions {
|
||||
/** The game's name, to read the likeliest files first. */
|
||||
gameName?: string;
|
||||
fetchText?: FetchText;
|
||||
signal?: AbortSignal;
|
||||
/** Called as files are read while looking: how many so far, of how many that may need reading. */
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
/**
|
||||
* Reads no files at all when more than this many would need reading (the
|
||||
* first look for a PS4 game reads a whole folder of them), and answers
|
||||
* with `unread` set, so the person can be asked first.
|
||||
*/
|
||||
maxReads?: number;
|
||||
/** Asks GitHub for the list of files again even when the copy from the last 12 hours would do ("Check for updates"). */
|
||||
fresh?: boolean;
|
||||
}
|
||||
|
||||
async function loadTree(fetchText: FetchText, signal?: AbortSignal, fresh = false): Promise<{ files: PatchFileRef[]; stale: boolean }> {
|
||||
const cached = loadJson<TreeCache | null>(TREE_KEY, null);
|
||||
if (!fresh && cached && Array.isArray(cached.files) && Date.now() - cached.at < TREE_TTL_MS) return { files: cached.files, stale: false };
|
||||
try {
|
||||
const files = parseTree(await fetchText(TREE_URL, signal));
|
||||
saveJson(TREE_KEY, { at: Date.now(), files } satisfies TreeCache);
|
||||
return { files, stale: false };
|
||||
} catch (e) {
|
||||
if (signal?.aborted) throw e;
|
||||
if (cached && Array.isArray(cached.files)) return { files: cached.files, stale: true };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the patch file that covers a game, reading as few files as it can:
|
||||
* none when an earlier look already learned which file it is (and that file
|
||||
* has not changed), else the files not seen yet, likeliest first by name,
|
||||
* stopping at the first that lists the title ID. Throws when GitHub cannot be
|
||||
* reached and nothing was saved from before; an AbortError when canceled.
|
||||
*/
|
||||
export async function findGamePatches(titleId: string, opts: FindOptions = {}): Promise<GamePatchLookup> {
|
||||
const fetchText = opts.fetchText ?? defaultFetchText;
|
||||
const wanted = titleId.toUpperCase();
|
||||
const saved = loadJson<TextCache | null>(textKey(wanted), null);
|
||||
const fromSaved = (): GamePatchLookup => {
|
||||
const parsed = parseGamePatchFile(saved!.text);
|
||||
return { titleId: wanted, file: { path: saved!.path, sha: saved!.sha, url: rawUrl(saved!.path) }, patches: parsed.patches, stale: true, read: 0, unread: 0 };
|
||||
};
|
||||
|
||||
let tree: { files: PatchFileRef[]; stale: boolean };
|
||||
try {
|
||||
tree = await loadTree(fetchText, opts.signal, opts.fresh);
|
||||
} catch (e) {
|
||||
if (saved && !opts.signal?.aborted) return fromSaved();
|
||||
throw e;
|
||||
}
|
||||
const folder = folderFor(wanted);
|
||||
const files = tree.files.filter((f) => f.path.startsWith(folder));
|
||||
const ids = loadJson<IdCache>(IDS_KEY, {});
|
||||
// Forget what was learned about files that are gone, so the record cannot grow without end.
|
||||
for (const path of Object.keys(ids)) if (!tree.files.some((f) => f.path === path)) delete ids[path];
|
||||
|
||||
let read = 0;
|
||||
const readFile = async (f: PatchFileRef): Promise<GamePatchFile | null> => {
|
||||
if (saved && saved.sha === f.sha && saved.path === f.path) return parseGamePatchFile(saved.text);
|
||||
const text = await fetchText(rawUrl(f.path), opts.signal);
|
||||
read++;
|
||||
let parsed: GamePatchFile;
|
||||
try {
|
||||
parsed = parseGamePatchFile(text);
|
||||
} catch {
|
||||
// A damaged file covers nothing; remember that, so it is not read again until it changes.
|
||||
ids[f.path] = { sha: f.sha, ids: [] };
|
||||
return null;
|
||||
}
|
||||
ids[f.path] = { sha: f.sha, ids: parsed.titleIds };
|
||||
if (parsed.titleIds.includes(wanted)) saveJson(textKey(wanted), { sha: f.sha, path: f.path, text } satisfies TextCache);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
try {
|
||||
const known = files.find((f) => ids[f.path]?.sha === f.sha && ids[f.path].ids.includes(wanted));
|
||||
if (known) {
|
||||
const parsed = await readFile(known);
|
||||
if (parsed?.titleIds.includes(wanted)) {
|
||||
return { titleId: wanted, file: { ...known, url: rawUrl(known.path) }, patches: parsed.patches, stale: tree.stale, read, unread: 0 };
|
||||
}
|
||||
}
|
||||
const unknown = files.filter((f) => ids[f.path]?.sha !== f.sha);
|
||||
const name = opts.gameName ?? '';
|
||||
unknown.sort((a, b) => nameScore(b.path, name) - nameScore(a.path, name));
|
||||
if (opts.maxReads !== undefined && unknown.length > opts.maxReads) {
|
||||
return { titleId: wanted, file: null, patches: [], stale: tree.stale, read, unread: unknown.length };
|
||||
}
|
||||
opts.onProgress?.(0, unknown.length);
|
||||
let found: { ref: PatchFileRef; parsed: GamePatchFile } | null = null;
|
||||
let next = 0;
|
||||
let done = 0;
|
||||
const worker = async () => {
|
||||
while (!found && next < unknown.length) {
|
||||
if (opts.signal?.aborted) throw new DOMException('Canceled', 'AbortError');
|
||||
const ref = unknown[next++];
|
||||
const parsed = await readFile(ref);
|
||||
opts.onProgress?.(++done, unknown.length);
|
||||
if (parsed?.titleIds.includes(wanted) && !found) found = { ref, parsed };
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, unknown.length) }, worker));
|
||||
const hit = found as { ref: PatchFileRef; parsed: GamePatchFile } | null;
|
||||
if (hit) return { titleId: wanted, file: { ...hit.ref, url: rawUrl(hit.ref.path) }, patches: hit.parsed.patches, stale: tree.stale, read, unread: 0 };
|
||||
return { titleId: wanted, file: null, patches: [], stale: tree.stale, read, unread: 0 };
|
||||
} catch (e) {
|
||||
if (saved && !opts.signal?.aborted) return fromSaved();
|
||||
throw e;
|
||||
} finally {
|
||||
saveJson(IDS_KEY, ids);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- patches for the running game, as .cspector entries --------------------------------
|
||||
|
||||
/**
|
||||
* Where a converted patch came from, kept on the entry (as `origin`, the same
|
||||
* EntryOrigin every cheat saved from elsewhere has; see table.ts) so it can
|
||||
* be told apart from the person's own cheats, matched with its update, and
|
||||
* found again. What is true of the whole file (its address, its hash, the
|
||||
* game's name) is kept once, on the TableSource the entries are saved under
|
||||
* (see gamePatchSource()).
|
||||
*/
|
||||
export type GamePatchOrigin = EntryOrigin & {
|
||||
source: typeof GAME_PATCH_SOURCE_ID;
|
||||
format: 'xml';
|
||||
/** See GamePatch.key. Unique within a game; use it to match an entry with its patch. */
|
||||
key: string;
|
||||
/** The version the patch is for (its AppVer), e.g. "01.004.000", or "mask" for any. */
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type GamePatchEntry = PatchEntry & { origin: GamePatchOrigin };
|
||||
|
||||
export interface PatchChoice {
|
||||
patch: GamePatch;
|
||||
/** How it matches the running version; null for another version's. */
|
||||
match: 'exact' | 'any' | null;
|
||||
/** Why it cannot be used; null when it can. */
|
||||
problem: string | null;
|
||||
}
|
||||
|
||||
/** Every patch in the file for this version (exact first, then any-version ones), each with why it cannot be used, if so. */
|
||||
export function patchesForVersion(lookup: GamePatchLookup, version: string, processName = 'eboot.bin'): PatchChoice[] {
|
||||
const out: PatchChoice[] = [];
|
||||
for (const patch of lookup.patches) {
|
||||
const match = versionMatch(patch.appVer, version);
|
||||
if (!match) continue;
|
||||
let problem: string | null = null;
|
||||
try {
|
||||
convertPatch(patch, processName);
|
||||
} catch (e) {
|
||||
if (!(e instanceof PatchRefused)) throw e;
|
||||
problem = `This patch cannot be used: ${e.message}.`;
|
||||
}
|
||||
out.push({ patch, match, problem });
|
||||
}
|
||||
return out.sort((a, b) => Number(a.match === 'any') - Number(b.match === 'any'));
|
||||
}
|
||||
|
||||
/** Versions the file has patches for, other than the running one: to say "patches exist, for other versions". */
|
||||
export function otherVersions(lookup: GamePatchLookup, version: string): string[] {
|
||||
const out: string[] = [];
|
||||
// "1.0" and "01.000.000" are one version, listed once.
|
||||
for (const p of lookup.patches) if (!versionMatch(p.appVer, version) && !out.some((v) => sameVersion(v, p.appVer))) out.push(p.appVer);
|
||||
return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
/** What a patch says about itself, for the entry's note. */
|
||||
function noteOf(patch: GamePatch): string | undefined {
|
||||
const parts = [patch.note, patch.author ? `By ${patch.author}.` : ''].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One patch as a .cspector patch entry: each line one place. `file` is the
|
||||
* patch file's path in the repository. With `ctx` (the game running), the
|
||||
* bytes there now are read and kept as the entry's `off`, so the cheat can
|
||||
* tell when it does not fit and what to put back; without it, or where a
|
||||
* place cannot be read, or holds the patch's bytes already (another tool
|
||||
* applied it), `off` is left out. Throws PatchRefused.
|
||||
*/
|
||||
export async function patchEntry(patch: GamePatch, file: string, ctx?: GameContext, processName = 'eboot.bin'): Promise<GamePatchEntry> {
|
||||
const sites: PatchSite[] = convertPatch(patch, processName).map((s) => ({ at: s.at, on: s.on }));
|
||||
if (ctx) {
|
||||
for (const site of sites) {
|
||||
try {
|
||||
const where = await resolveLocation(ctx, site.at);
|
||||
const on = hexToBytes(site.on);
|
||||
const now = await ctx.dbg().read(ctx.pid, where, on.length);
|
||||
if (bytesToHex(now).toUpperCase() !== site.on) site.off = bytesToHex(now).toUpperCase();
|
||||
// A pattern found now also gets the fixed position it was found at, which the
|
||||
// keep-on engine and later loads use to place it without a search.
|
||||
if (site.at.kind === 'signature') {
|
||||
const base = await resolveLocation(ctx, { kind: 'module', offset: '0' });
|
||||
if (where >= base) site.fallback = { kind: 'module', offset: (where - base).toString(16).toUpperCase() };
|
||||
}
|
||||
} catch {
|
||||
// Not readable right now (the game still loading, a pattern not found): the entry is still good, just less checked.
|
||||
}
|
||||
}
|
||||
}
|
||||
const [first, ...more] = sites;
|
||||
const origin: GamePatchOrigin = { source: GAME_PATCH_SOURCE_ID, file, format: 'xml', key: patch.key, version: patch.appVer };
|
||||
if (patch.author) origin.author = patch.author;
|
||||
const entry: GamePatchEntry = {
|
||||
id: newId(),
|
||||
type: 'patch',
|
||||
name: patch.name || 'Game patch',
|
||||
...first,
|
||||
...(more.length ? { more } : {}),
|
||||
origin,
|
||||
};
|
||||
const note = noteOf(patch);
|
||||
if (note) entry.note = note;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export interface PatchesForGame {
|
||||
/** The file the patches came from; null when no file covers the game. */
|
||||
source: GamePatchLookup['file'];
|
||||
/** The game's name as the patch file gives it; "" when it does not. */
|
||||
gameName: string;
|
||||
/** The patches for exactly this version (and any-version ones), as entries ready for the game's .cspector file. */
|
||||
entries: GamePatchEntry[];
|
||||
/** Patches for this version that cannot be used, and why. */
|
||||
refused: { name: string; reason: string }[];
|
||||
/** Versions the file has patches for, other than this one. Nothing for them is in `entries`. */
|
||||
otherVersions: string[];
|
||||
/** True when GitHub could not be reached and a saved copy was used. */
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The game patches for a running game, as .cspector entries plus where they
|
||||
* came from. Only patches for exactly `game.version` (or for any version)
|
||||
* are converted. Pass `ctx` while the game runs to record each place's
|
||||
* original bytes. Throws when the patch collection cannot be reached and
|
||||
* nothing was saved from before.
|
||||
*
|
||||
* Saving them is the caller's job: gamePatchSource() makes the TableSource
|
||||
* that goes into the game's file with withSource() (table.ts), next to the
|
||||
* person's own cheats and never in place of them.
|
||||
*/
|
||||
export async function patchesForGame(
|
||||
game: { titleId: string; version: string },
|
||||
opts: FindOptions & { ctx?: GameContext; processName?: string; lookup?: GamePatchLookup } = {},
|
||||
): Promise<PatchesForGame> {
|
||||
const lookup = opts.lookup ?? (await findGamePatches(game.titleId, opts));
|
||||
const gameName = lookup.patches.find((p) => p.title)?.title ?? '';
|
||||
const result: PatchesForGame = { source: lookup.file, gameName, entries: [], refused: [], otherVersions: otherVersions(lookup, game.version), stale: lookup.stale };
|
||||
if (!lookup.file) return result;
|
||||
for (const choice of patchesForVersion(lookup, game.version, opts.processName)) {
|
||||
if (choice.problem) {
|
||||
result.refused.push({ name: choice.patch.name, reason: choice.problem });
|
||||
continue;
|
||||
}
|
||||
result.entries.push(await patchEntry(choice.patch, lookup.file.path, opts.ctx, opts.processName));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patches for the running version as a source to save in the game's
|
||||
* .cspector file (kind "gamepatch"; see TableSource in table.ts). Null when
|
||||
* there is nothing usable to save.
|
||||
*/
|
||||
export function gamePatchSource(result: PatchesForGame, game: { version: string }, now = new Date()): TableSource | null {
|
||||
if (!result.source || result.entries.length === 0) return null;
|
||||
const cheats: Entry[] = result.entries;
|
||||
const credits = [...new Set(result.entries.map((e) => e.origin.author ?? '').filter(Boolean))];
|
||||
const src: TableSource = {
|
||||
id: sourceId(GAME_PATCH_SOURCE_ID, result.source.path),
|
||||
kind: GAME_PATCH_SOURCE_ID,
|
||||
source: GAME_PATCH_SOURCE_ID,
|
||||
sourceName: GAME_PATCH_SOURCE.name,
|
||||
file: result.source.path,
|
||||
format: 'xml',
|
||||
url: result.source.url,
|
||||
sha: result.source.sha,
|
||||
version: game.version,
|
||||
savedAt: now.toISOString(),
|
||||
digest: sourceDigest(cheats),
|
||||
cheats,
|
||||
};
|
||||
if (result.gameName) src.name = result.gameName;
|
||||
if (credits.length) src.credits = credits;
|
||||
return src;
|
||||
}
|
||||
|
||||
/** The saved game patches source of a table that is for the running version, if there is one. */
|
||||
export function savedGamePatches(sources: TableSource[] | undefined, version: string): TableSource | null {
|
||||
return (sources ?? []).find((s) => s.kind === GAME_PATCH_SOURCE_ID && sameVersion(s.version, version)) ?? null;
|
||||
}
|
||||
|
||||
/** What a patch does, for telling whether two copies of it differ: its places and bytes, not what the game held when each was saved. */
|
||||
function patchShape(e: Entry): string {
|
||||
if (e.type !== 'patch') return stableJson({ ...e, id: undefined });
|
||||
return stableJson({ name: e.name, note: e.note ?? '', sites: patchSites(e).map((s) => ({ at: s.at, on: s.on })) });
|
||||
}
|
||||
|
||||
/** What is different between two copies of the game patches, patch by patch, by name. */
|
||||
export function patchChanges(saved: TableSource, fresh: TableSource): { added: string[]; removed: string[]; changed: string[] } {
|
||||
const byName = (list: Entry[]) => {
|
||||
const m = new Map<string, string[]>();
|
||||
for (const e of list) m.set(e.name, [...(m.get(e.name) ?? []), patchShape(e)]);
|
||||
return m;
|
||||
};
|
||||
const before = byName(saved.cheats);
|
||||
const after = byName(fresh.cheats);
|
||||
const out = { added: [] as string[], removed: [] as string[], changed: [] as string[] };
|
||||
for (const [name, list] of after) {
|
||||
const old = before.get(name);
|
||||
if (!old) out.added.push(name);
|
||||
else if (old.length !== list.length || old.some((k, i) => k !== list[i])) out.changed.push(name);
|
||||
}
|
||||
for (const name of before.keys()) if (!after.has(name)) out.removed.push(name);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface GamePatchCheck {
|
||||
/** What the patch collection has for the game now. */
|
||||
result: PatchesForGame;
|
||||
/** The source as it would be saved now; null when there is nothing to save any more. */
|
||||
fresh: TableSource | null;
|
||||
changes: { added: string[]; removed: string[]; changed: string[] };
|
||||
/** True when the file is the one saved (same path and hash). */
|
||||
same: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a saved game patches source against the patch collection: the
|
||||
* file's hash in GitHub's list of files tells whether anything changed, so
|
||||
* an unchanged file is not read again. `fresh: true` asks GitHub for the list
|
||||
* again rather than trusting the copy from the last 12 hours. Throws when
|
||||
* GitHub cannot be reached (an update check never answers from an old copy).
|
||||
*/
|
||||
export async function checkGamePatches(
|
||||
src: TableSource,
|
||||
game: { titleId: string; version: string },
|
||||
opts: FindOptions & { ctx?: GameContext; processName?: string } = {},
|
||||
): Promise<GamePatchCheck> {
|
||||
const lookup = await findGamePatches(game.titleId, opts);
|
||||
if (lookup.stale) throw new Error('GitHub could not be reached');
|
||||
if (lookup.file && lookup.file.path === src.file && !!src.sha && lookup.file.sha === src.sha) {
|
||||
// Unchanged: only the list of what cannot be used is needed, and no memory has to be read for it.
|
||||
const result = await patchesForGame(game, { ...opts, ctx: undefined, lookup });
|
||||
return { result, fresh: null, changes: { added: [], removed: [], changed: [] }, same: true };
|
||||
}
|
||||
const result = await patchesForGame(game, { ...opts, lookup });
|
||||
const fresh = gamePatchSource(result, game);
|
||||
const changes = fresh ? patchChanges(src, fresh) : { added: [], removed: src.cheats.map((e) => e.name), changed: [] };
|
||||
return { result, fresh, changes, same: false };
|
||||
}
|
||||
|
||||
/** The origin of an entry when it is a converted game patch; null for anything else. */
|
||||
export function gamePatchOrigin(entry: unknown): GamePatchOrigin | null {
|
||||
const o = (entry as { origin?: Partial<GamePatchOrigin> } | null)?.origin;
|
||||
return o && typeof o === 'object' && o.source === GAME_PATCH_SOURCE_ID && typeof o.key === 'string' ? (o as GamePatchOrigin) : null;
|
||||
}
|
||||
|
||||
/** True when two patch entries change the same places to the same bytes: one is a copy of the other. */
|
||||
export function samePatch(a: Entry, b: Entry): boolean {
|
||||
if (a.type !== 'patch' || b.type !== 'patch') return false;
|
||||
const shape = (e: PatchEntry) => stableJson(patchSites(e).map((s) => ({ at: s.at, on: s.on.toUpperCase() })));
|
||||
return shape(a) === shape(b);
|
||||
}
|
||||
+36
-9
@@ -16,15 +16,23 @@ import { tableFileName } from './tablestore.ts';
|
||||
/**
|
||||
* "refused" is a cheat marked to keep that the engine never runs (the wrong
|
||||
* kind, or at a raw address). The app does not let one be marked, so it only
|
||||
* turns up for a file changed by hand.
|
||||
* turns up for a file changed by hand. "held" is one the engine did not
|
||||
* switch on because it may have crashed the game last time (crashguard.ts).
|
||||
*/
|
||||
export type KeptState = 'on' | 'waiting' | 'mismatch' | 'error' | 'refused';
|
||||
export type KeptState = 'on' | 'waiting' | 'mismatch' | 'error' | 'refused' | 'held';
|
||||
|
||||
export interface KeptCheat {
|
||||
name: string;
|
||||
state: KeptState;
|
||||
/** A plain-English reason, for anything that is not simply on. */
|
||||
detail: string;
|
||||
/**
|
||||
* True for a cheat the engine switches on only because it is in the file's
|
||||
* start set (StartItem in table.ts), not because it is marked to keep.
|
||||
* Such a cheat can share its name with a kept one, so the two are told
|
||||
* apart by this, never by position alone.
|
||||
*/
|
||||
start?: boolean;
|
||||
}
|
||||
|
||||
export interface EngineStatus {
|
||||
@@ -42,12 +50,14 @@ export interface EngineStatus {
|
||||
exact: boolean;
|
||||
/** True while the engine holds back searches of its own because a browser is searching. */
|
||||
busy: boolean;
|
||||
/** True when the engine marks cheats that may have crashed the game, and holds them back (crashguard.ts). */
|
||||
crashGuard: boolean;
|
||||
/** Why the file could not be used, or was only used in part; "" when it is fine. */
|
||||
error: string;
|
||||
cheats: KeptCheat[];
|
||||
}
|
||||
|
||||
const STATES = ['on', 'waiting', 'mismatch', 'error', 'refused'];
|
||||
const STATES = ['on', 'waiting', 'mismatch', 'error', 'refused', 'held'];
|
||||
|
||||
/** Reads an answer from `api/engine`. Returns null when it is not one. */
|
||||
export function parseEngineStatus(doc: unknown): EngineStatus | null {
|
||||
@@ -63,6 +73,7 @@ export function parseEngineStatus(doc: unknown): EngineStatus | null {
|
||||
// A state this version does not know is shown as a problem, with the engine's own words.
|
||||
state: STATES.includes(c.state as string) ? (c.state as KeptState) : 'error',
|
||||
detail: typeof c.detail === 'string' ? c.detail : '',
|
||||
...(c.start === true ? { start: true } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -72,6 +83,7 @@ export function parseEngineStatus(doc: unknown): EngineStatus | null {
|
||||
// An engine from before it reported this only ever said so through each cheat's state.
|
||||
exact: d.exact !== false,
|
||||
busy: d.busy === true,
|
||||
crashGuard: d.crashGuard === true,
|
||||
error: typeof d.error === 'string' ? d.error : '',
|
||||
cheats,
|
||||
};
|
||||
@@ -114,7 +126,7 @@ export function keptStatus(status: EngineStatus | null, titleId: string, name: s
|
||||
if (!status.running) return { name, state: 'error', detail: 'The PS5 cannot reach its debugger right now, so nothing is being kept on.' };
|
||||
// The engine is busy with another game, or none; this game's cheats are waiting for it.
|
||||
if (!status.game || status.game.titleId.toUpperCase() !== titleId.toUpperCase()) return null;
|
||||
return status.cheats.filter((c) => c.name === name && c.state !== 'refused')[index] ?? null;
|
||||
return status.cheats.filter((c) => c.name === name && c.state !== 'refused' && !c.start)[index] ?? null;
|
||||
}
|
||||
|
||||
export interface KeptOverview {
|
||||
@@ -163,24 +175,30 @@ export function keptOverview(status: EngineStatus | null, game: { titleId: strin
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const by = (state: KeptState) => status.cheats.filter((c) => c.state === state);
|
||||
// Cheats on only for the start set are counted apart: they are switched on once, not kept on.
|
||||
const keptList = status.cheats.filter((c) => !c.start);
|
||||
const started = status.cheats.filter((c) => c.start && c.state === 'on').length;
|
||||
const by = (state: KeptState) => keptList.filter((c) => c.state === state);
|
||||
const on = by('on').length;
|
||||
const waiting = by('waiting').length;
|
||||
const mismatch = by('mismatch').length;
|
||||
const failed = by('error').length;
|
||||
const refused = by('refused');
|
||||
const held = status.cheats.filter((c) => c.state === 'held');
|
||||
if (kept === 0 && status.cheats.length === 0 && !status.error) return null;
|
||||
|
||||
if (status.error) lines.push(status.error);
|
||||
if (!status.file) {
|
||||
lines.push('There is no cheat file for this game on your PS5 yet, so it has nothing to keep on. Use Save, then Save to PS5.');
|
||||
lines.push(
|
||||
'There is no cheat file for this game on your PS5 yet, so it has nothing to keep on. It is made by itself as soon as you change one of your cheats here.',
|
||||
);
|
||||
} else if (!status.exact) {
|
||||
const own = tableFileName(game.titleId, game.version);
|
||||
lines.push(
|
||||
(status.file === own
|
||||
? `The file on your PS5 (${status.file}) says it was made for another version of this game`
|
||||
: `Your PS5 is using the file saved for another version of this game (${status.file})`) +
|
||||
', so cheats it cannot check against this version are left off. Use Save, then Save to PS5, to make this version its own file.',
|
||||
', so cheats it cannot check against this version are left off. Change any of your cheats here to give this version a file of its own.',
|
||||
);
|
||||
}
|
||||
for (const c of refused) lines.push(`"${c.name}" is marked to keep on but cannot be: ${c.detail}`);
|
||||
@@ -188,12 +206,21 @@ export function keptOverview(status: EngineStatus | null, game: { titleId: strin
|
||||
|
||||
const parts: string[] = [];
|
||||
if (on > 0) parts.push(`Your PS5 is keeping ${count(on, 'cheat', 'cheats')} on by itself, with this page open or not.`);
|
||||
else if (status.cheats.length > 0) parts.push('Your PS5 is not keeping any cheat on right now.');
|
||||
else if (keptList.length > 0) parts.push('Your PS5 is not keeping any cheat on right now.');
|
||||
else if (started > 0) parts.push('Your PS5 has no cheats to keep on for this game.');
|
||||
else parts.push('Your PS5 has no cheats to keep on for this game.');
|
||||
// Why differs from cheat to cheat (the game still loading, a level not entered yet), and each one's row says.
|
||||
if (waiting > 0) parts.push(`${count(waiting, 'is', 'are')} not on yet; ${waiting === 1 ? 'its row says' : 'their rows say'} why.`);
|
||||
if (mismatch > 0) parts.push(`${count(mismatch, 'does', 'do')} not fit this version of the game and ${mismatch === 1 ? 'was' : 'were'} left off.`);
|
||||
if (failed > 0) parts.push(`${count(failed, 'has', 'have')} a problem.`);
|
||||
const troubled = mismatch + failed + refused.length > 0 || !!status.error || !status.file || !status.exact;
|
||||
if (started > 0) parts.push(`It switched on ${count(started, 'cheat', 'cheats')} of this game's start set when the game started.`);
|
||||
if (held.length > 0) {
|
||||
parts.push(
|
||||
held.length === 1
|
||||
? `It did not switch "${held[0].name}" on, because the game closed less than a minute after it was switched on last time. Switch it on by hand to try again.`
|
||||
: `It did not switch ${held.length} cheats on, because the game closed less than a minute after they were switched on last time; their rows say which. Switch them on by hand to try again.`,
|
||||
);
|
||||
}
|
||||
const troubled = mismatch + failed + refused.length + held.length > 0 || !!status.error || !status.file || !status.exact;
|
||||
return { kind: troubled ? 'warn' : waiting > 0 || on === 0 ? 'info' : 'good', text: parts.join(' '), lines };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// The controller shortcut: hold a few buttons on the PS5's controller to open
|
||||
// CheatSpecter in the PS5's own web browser, on the running game's Cheats
|
||||
// screen. The payload does the watching (payload/src/pad.c); this side only
|
||||
// keeps the setting, as the prefs file "pad-shortcut", and asks the payload
|
||||
// how it is doing (GET api/pad).
|
||||
//
|
||||
// The names, limits and defaults here must match pad.h and pad.c: the payload
|
||||
// reads the same file and falls back to the default for anything it does not
|
||||
// accept.
|
||||
|
||||
import { consoleApiUrl } from './library.ts';
|
||||
import { loadPref, savePref } from './prefs.ts';
|
||||
import type { Target } from './session.ts';
|
||||
|
||||
export const PAD_PREF = 'pad-shortcut';
|
||||
|
||||
/** Every button the shortcut can use, in the order they are offered. `id` is what the file stores. */
|
||||
export const PAD_BUTTONS: readonly { id: string; label: string }[] = [
|
||||
{ id: 'L1', label: 'L1' },
|
||||
{ id: 'R1', label: 'R1' },
|
||||
{ id: 'L2', label: 'L2' },
|
||||
{ id: 'R2', label: 'R2' },
|
||||
{ id: 'L3', label: 'L3 (press left stick)' },
|
||||
{ id: 'R3', label: 'R3 (press right stick)' },
|
||||
{ id: 'Triangle', label: 'Triangle' },
|
||||
{ id: 'Circle', label: 'Circle' },
|
||||
{ id: 'Cross', label: 'Cross' },
|
||||
{ id: 'Square', label: 'Square' },
|
||||
{ id: 'Up', label: 'Up' },
|
||||
{ id: 'Down', label: 'Down' },
|
||||
{ id: 'Left', label: 'Left' },
|
||||
{ id: 'Right', label: 'Right' },
|
||||
{ id: 'Options', label: 'Options' },
|
||||
{ id: 'Touchpad', label: 'Touchpad' },
|
||||
];
|
||||
|
||||
export const PAD_MIN_BUTTONS = 2;
|
||||
export const PAD_MAX_BUTTONS = 4;
|
||||
export const PAD_MIN_HOLD_MS = 300;
|
||||
export const PAD_MAX_HOLD_MS = 5000;
|
||||
/** The hold times offered. */
|
||||
export const PAD_HOLD_CHOICES: readonly number[] = [500, 800, 1000, 1500, 2000, 3000];
|
||||
|
||||
export interface PadShortcut {
|
||||
enabled: boolean;
|
||||
/** Button ids from PAD_BUTTONS, in PAD_BUTTONS order. */
|
||||
buttons: string[];
|
||||
holdMs: number;
|
||||
}
|
||||
|
||||
export const PAD_DEFAULT: PadShortcut = Object.freeze({ enabled: false, buttons: ['L2', 'R3'], holdMs: 800 }) as PadShortcut;
|
||||
|
||||
/** The PAD_BUTTONS id for a name in any case, or null. */
|
||||
function buttonId(name: unknown): string | null {
|
||||
if (typeof name !== 'string') return null;
|
||||
const lower = name.toLowerCase();
|
||||
return PAD_BUTTONS.find((b) => b.id.toLowerCase() === lower)?.id ?? null;
|
||||
}
|
||||
|
||||
/** Puts button ids in the one order everything uses, without repeats. */
|
||||
export function sortButtons(ids: readonly string[]): string[] {
|
||||
const set = new Set(ids.map(buttonId).filter((id): id is string => id !== null));
|
||||
return PAD_BUTTONS.map((b) => b.id).filter((id) => set.has(id));
|
||||
}
|
||||
|
||||
/** Whether a combination is one the payload accepts. */
|
||||
export function validCombo(ids: readonly string[]): boolean {
|
||||
const sorted = sortButtons(ids);
|
||||
return sorted.length === ids.length && sorted.length >= PAD_MIN_BUTTONS && sorted.length <= PAD_MAX_BUTTONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever was stored into a setting, field by field, the way the
|
||||
* payload reads it: anything missing or not acceptable takes its default.
|
||||
*/
|
||||
export function normalizePadShortcut(value: unknown): PadShortcut {
|
||||
const v = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;
|
||||
const enabled = v.enabled === true;
|
||||
let buttons = PAD_DEFAULT.buttons;
|
||||
if (Array.isArray(v.buttons) && v.buttons.every((b) => buttonId(b) !== null)) {
|
||||
const ids = sortButtons(v.buttons as string[]);
|
||||
if (ids.length >= PAD_MIN_BUTTONS && ids.length <= PAD_MAX_BUTTONS) buttons = ids;
|
||||
}
|
||||
const hold = v.holdMs;
|
||||
const holdMs = typeof hold === 'number' && Number.isFinite(hold) && hold >= PAD_MIN_HOLD_MS && hold <= PAD_MAX_HOLD_MS ? Math.trunc(hold) : PAD_DEFAULT.holdMs;
|
||||
return { enabled, buttons: [...buttons], holdMs };
|
||||
}
|
||||
|
||||
/** "L2 + R3" */
|
||||
export function describeCombo(ids: readonly string[]): string {
|
||||
return sortButtons(ids).join(' + ');
|
||||
}
|
||||
|
||||
/** "0.8 seconds" */
|
||||
export function describeHold(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
return `${s} ${s === 1 ? 'second' : 'seconds'}`;
|
||||
}
|
||||
|
||||
export async function loadPadShortcut(target: Target | null): Promise<PadShortcut> {
|
||||
return normalizePadShortcut(await loadPref(target, PAD_PREF));
|
||||
}
|
||||
|
||||
/** Returns false when the console did not keep it. */
|
||||
export async function savePadShortcut(target: Target | null, value: PadShortcut): Promise<boolean> {
|
||||
const clean = normalizePadShortcut(value);
|
||||
return savePref(target, PAD_PREF, clean);
|
||||
}
|
||||
|
||||
export type PadState = 'off' | 'starting' | 'listening' | 'no-controller' | 'paused' | 'unavailable';
|
||||
|
||||
/** What the payload reports at GET api/pad. */
|
||||
export interface PadStatus {
|
||||
enabled: boolean;
|
||||
buttons: string[];
|
||||
holdMs: number;
|
||||
state: PadState;
|
||||
/** A sentence from the payload, "" when there is nothing to add. */
|
||||
detail: string;
|
||||
/** How many times it has opened the browser since the payload started. */
|
||||
fired: number;
|
||||
}
|
||||
|
||||
const STATES: readonly PadState[] = ['off', 'starting', 'listening', 'no-controller', 'paused', 'unavailable'];
|
||||
|
||||
/**
|
||||
* Asks the payload how the shortcut is doing. Null when it cannot say: no
|
||||
* payload, or one from before the shortcut existed (it answers 404).
|
||||
*/
|
||||
export async function loadPadStatus(target: Target | null): Promise<PadStatus | null> {
|
||||
try {
|
||||
const res = await fetch(consoleApiUrl(target, 'api/pad'), { cache: 'no-store' });
|
||||
if (!res.ok || !(res.headers.get('content-type') ?? '').includes('json')) return null;
|
||||
const v = (await res.json()) as Record<string, unknown>;
|
||||
if (!v || typeof v !== 'object' || !STATES.includes(v.state as PadState)) return null;
|
||||
const base = normalizePadShortcut(v);
|
||||
return {
|
||||
...base,
|
||||
state: v.state as PadState,
|
||||
detail: typeof v.detail === 'string' ? v.detail : '',
|
||||
fired: typeof v.fired === 'number' ? v.fired : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Where the person is in the app, kept in the address (the part after #).
|
||||
//
|
||||
// Reloading the page, Back and Forward, and opening a copied link all land on
|
||||
// the same screen because the screen is read from the address, not only kept
|
||||
// in memory. The forms:
|
||||
//
|
||||
// #/ the start: the running game, or the library when none is running
|
||||
// #/game/PPSA01575/cheats a game's tab (cheats, find, memory, code, debug, engine)
|
||||
// #/library the games installed on the PS5
|
||||
// #/learn the Learn section's list of lessons
|
||||
// #/learn/find-a-number/3 one lesson, at its third step
|
||||
// #/settings settings
|
||||
// #/settings/diagnostics the diagnostics log
|
||||
//
|
||||
// A hash is used rather than a path because the PS5 serves one page at "/"
|
||||
// and knows nothing of other paths; the part after # never reaches it.
|
||||
// Anything not understood is treated as the start.
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
export const GAME_TABS = ['cheats', 'find', 'memory', 'code', 'debug', 'engine'] as const;
|
||||
export type GameTab = (typeof GAME_TABS)[number];
|
||||
|
||||
export type SettingsSection = 'main' | 'diagnostics';
|
||||
|
||||
export type Route =
|
||||
| { screen: 'home' }
|
||||
| { screen: 'game'; titleId: string; tab: GameTab }
|
||||
| { screen: 'library' }
|
||||
/** `step` counts from 1, as people do; null when the address names no step. */
|
||||
| { screen: 'learn'; lesson: string | null; step: number | null }
|
||||
| { screen: 'settings'; section: SettingsSection };
|
||||
|
||||
export const HOME: Route = { screen: 'home' };
|
||||
|
||||
/** PS5 (PPSA) and PS4 (CUSA) title IDs, and the other four-letter regions: four letters and five digits. */
|
||||
const TITLE_ID = /^[A-Z]{4}\d{5}$/;
|
||||
const LESSON_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export function isGameTab(v: unknown): v is GameTab {
|
||||
return typeof v === 'string' && (GAME_TABS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/** Reads an address's hash (with or without the leading #). Never throws. */
|
||||
export function parseRoute(hash: string): Route {
|
||||
let raw = hash.replace(/^#/, '');
|
||||
try {
|
||||
raw = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// A broken escape: read it as it is.
|
||||
}
|
||||
const parts = raw.split('?')[0].split('/').filter(Boolean);
|
||||
const [first, second, third] = parts;
|
||||
switch (first) {
|
||||
case 'game': {
|
||||
const titleId = (second ?? '').toUpperCase();
|
||||
if (!TITLE_ID.test(titleId)) return HOME;
|
||||
return { screen: 'game', titleId, tab: isGameTab(third) ? third : 'cheats' };
|
||||
}
|
||||
case 'library':
|
||||
case 'games':
|
||||
return { screen: 'library' };
|
||||
case 'learn': {
|
||||
const lesson = second && LESSON_ID.test(second) ? second : null;
|
||||
const n = lesson && third && /^\d{1,4}$/.test(third) ? Number(third) : null;
|
||||
return { screen: 'learn', lesson, step: n !== null && n >= 1 ? n : null };
|
||||
}
|
||||
case 'settings':
|
||||
return { screen: 'settings', section: second === 'diagnostics' ? 'diagnostics' : 'main' };
|
||||
case 'diagnostics':
|
||||
return { screen: 'settings', section: 'diagnostics' };
|
||||
default:
|
||||
return HOME;
|
||||
}
|
||||
}
|
||||
|
||||
/** The hash for a route, starting with "#/". */
|
||||
export function formatRoute(route: Route): string {
|
||||
switch (route.screen) {
|
||||
case 'home':
|
||||
return '#/';
|
||||
case 'game':
|
||||
return `#/game/${route.titleId}/${route.tab}`;
|
||||
case 'library':
|
||||
return '#/library';
|
||||
case 'learn':
|
||||
if (!route.lesson) return '#/learn';
|
||||
return `#/learn/${route.lesson}` + (route.step ? `/${route.step}` : '');
|
||||
case 'settings':
|
||||
return route.section === 'diagnostics' ? '#/settings/diagnostics' : '#/settings';
|
||||
}
|
||||
}
|
||||
|
||||
export function sameRoute(a: Route, b: Route): boolean {
|
||||
return formatRoute(a) === formatRoute(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full address of a game's screen on a console's CheatSpecter, for
|
||||
* opening it from somewhere else (the PS5's own browser, a bookmark).
|
||||
* gameAddress('PPSA01575') is http://127.0.0.1:8744/#/game/PPSA01575/cheats
|
||||
*/
|
||||
export function gameAddress(titleId: string, tab: GameTab = 'cheats', base = 'http://127.0.0.1:8744/'): string {
|
||||
return base + formatRoute({ screen: 'game', titleId: titleId.toUpperCase(), tab });
|
||||
}
|
||||
|
||||
// ---- the address bar ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Entries this page added to the browser's history carry this mark, so a
|
||||
* "Back" button on screen can tell whether the previous entry is still this
|
||||
* app (then it goes back, as the browser's button would) or something else
|
||||
* (then it goes to a sensible screen instead of leaving the app).
|
||||
*/
|
||||
interface HistoryMark {
|
||||
csDepth?: number;
|
||||
}
|
||||
|
||||
function depth(): number {
|
||||
try {
|
||||
const d = (history.state as HistoryMark | null)?.csDepth;
|
||||
return typeof d === 'number' ? d : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
function notify(): void {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('hashchange', notify);
|
||||
window.addEventListener('popstate', notify);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function currentHash(): string {
|
||||
return typeof location === 'undefined' ? '' : location.hash;
|
||||
}
|
||||
|
||||
/** The route in the address bar now. */
|
||||
export function currentRoute(): Route {
|
||||
return parseRoute(currentHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `route` in the address bar. `replace` changes the current history
|
||||
* entry instead of adding one: for corrections the person did not ask for
|
||||
* (the start becoming the running game's address, a step being filled in),
|
||||
* so Back does not lead to them.
|
||||
*/
|
||||
export function navigate(route: Route, opts: { replace?: boolean } = {}): void {
|
||||
const hash = formatRoute(route);
|
||||
if (hash === currentHash() || (hash === '#/' && currentHash() === '')) return;
|
||||
const url = location.pathname + location.search + hash;
|
||||
try {
|
||||
if (opts.replace) history.replaceState({ ...((history.state as object | null) ?? {}), csDepth: depth() } satisfies HistoryMark, '', url);
|
||||
else history.pushState({ csDepth: depth() + 1 } satisfies HistoryMark, '', url);
|
||||
} catch {
|
||||
// history can refuse (too many calls in a short time in some browsers); the plain way still works.
|
||||
if (opts.replace) location.replace(url);
|
||||
else location.hash = hash;
|
||||
return;
|
||||
}
|
||||
// pushState and replaceState do not fire hashchange.
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the current screen the way the browser's Back button would when the
|
||||
* previous entry is this app's own, and goes to `fallback` otherwise (the
|
||||
* page was opened at this address, from a link or a reload).
|
||||
*/
|
||||
export function goBack(fallback: Route): void {
|
||||
if (depth() > 0) history.back();
|
||||
else navigate(fallback, { replace: true });
|
||||
}
|
||||
|
||||
/** The route in the address bar, kept current. */
|
||||
export function useRoute(): Route {
|
||||
const hash = useSyncExternalStore(subscribe, currentHash, () => '');
|
||||
// Parsing is cheap; a new object each render is fine as long as callers compare with sameRoute.
|
||||
return parseRoute(hash);
|
||||
}
|
||||
|
||||
/** navigate() as a stable callback for components. */
|
||||
export function useNavigate(): typeof navigate {
|
||||
return useCallback((route: Route, opts?: { replace?: boolean }) => navigate(route, opts), []);
|
||||
}
|
||||
+39
-1
@@ -29,7 +29,7 @@ import type { PlacedVariable } from './asm.ts';
|
||||
import { hotkeyFromEvent } from './hotkeys.ts';
|
||||
import { findPattern, parseAddress, resolveLocation } from './locations.ts';
|
||||
import type { GameContext, Location } from './locations.ts';
|
||||
import { decodeValue, encodeValue, valueKind } from './scanner.ts';
|
||||
import { ScanSession, compareKind, decodeValue, encodeValue, scannableRegions, valueKind } from './scanner.ts';
|
||||
|
||||
/** The part of the table runner a script reaches code-cheat variables through. */
|
||||
export interface ScriptVariables {
|
||||
@@ -91,6 +91,9 @@ async function toAddress(ctx: GameContext, a: Addr): Promise<bigint> {
|
||||
return parseAddress(ctx, String(a));
|
||||
}
|
||||
|
||||
/** The most addresses one cs.search can hand back. */
|
||||
export const SEARCH_LIMIT_MAX = 100000;
|
||||
|
||||
/** Carries out one API call made by a script. Kept separate from the worker plumbing so it can be tested. */
|
||||
export async function handleScriptCall(ctx: GameContext, api: ScriptHostApi, method: string, args: unknown[]): Promise<unknown> {
|
||||
const dbg = ctx.dbg();
|
||||
@@ -116,6 +119,36 @@ export async function handleScriptCall(ctx: GameContext, api: ScriptHostApi, met
|
||||
await dbg.write(ctx.pid, await toAddress(ctx, args[0] as Addr), hexToBytes(String(args[1])));
|
||||
return undefined;
|
||||
}
|
||||
case 'readText': {
|
||||
const max = args[1] === undefined || args[1] === null ? 256 : Number(args[1]);
|
||||
if (!Number.isInteger(max) || max < 1 || max > 0x10000) throw new Error('readText: the longest text to read must be between 1 and 65536 bytes');
|
||||
const raw = await dbg.read(ctx.pid, await toAddress(ctx, args[0] as Addr), max);
|
||||
const end = raw.indexOf(0);
|
||||
return new TextDecoder('utf-8').decode(end < 0 ? raw : raw.subarray(0, end));
|
||||
}
|
||||
case 'search': {
|
||||
const kind = valueKind(String(args[0] ?? 'u32'));
|
||||
const o = (typeof args[2] === 'object' && args[2] !== null ? args[2] : {}) as { from?: Addr; to?: Addr; limit?: unknown };
|
||||
const limit = o.limit === undefined ? 10000 : Number(o.limit);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > SEARCH_LIMIT_MAX) throw new Error(`search: "limit" must be between 1 and ${SEARCH_LIMIT_MAX}`);
|
||||
const from = o.from === undefined ? null : await toAddress(ctx, o.from);
|
||||
const to = o.to === undefined ? null : await toAddress(ctx, o.to);
|
||||
if (from !== null && to !== null && to <= from) throw new Error('search: "to" must be above "from"');
|
||||
// Whole blocks of memory are searched, so a block that only partly lies in the range is cut to it.
|
||||
const regions = scannableRegions(await ctx.maps(true)).flatMap((r) => {
|
||||
const start = from !== null && from > r.start ? from : r.start;
|
||||
const stop = to !== null && to < r.start + BigInt(r.length) ? to : r.start + BigInt(r.length);
|
||||
return stop > start ? [{ ...r, start, length: Number(stop - start) }] : [];
|
||||
});
|
||||
if (regions.length === 0) return [];
|
||||
const scan = new ScanSession(ctx.scanDbg, ctx.pid, kind);
|
||||
try {
|
||||
await scan.first(regions, compareKind('exact'), encodeValue(kind, String(args[1])), () => undefined);
|
||||
return (await scan.hits(limit)).map((h) => h.address);
|
||||
} finally {
|
||||
await scan.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
case 'address':
|
||||
return typeof args[0] === 'object' && args[0] !== null
|
||||
? resolveLocation(ctx, args[0] as Location)
|
||||
@@ -169,6 +202,9 @@ export const SCRIPT_REFERENCE = `await cs.read(address, type) // type:
|
||||
await cs.write(address, type, value)
|
||||
await cs.readBytes(address, count) // -> "89 81 90 03"
|
||||
await cs.writeBytes(address, "90 90 90")
|
||||
await cs.readText(address) // text up to its closing zero byte (at most 256 bytes; pass a second value for more)
|
||||
await cs.search("u64", 0x8139D40) // every place in the game's memory holding that value -> list of addresses
|
||||
// narrow it to go faster: cs.search("u32", 100, { from: 0x2000000000, to: 0x2100000000, limit: 500 })
|
||||
await cs.address("game+1A2B3C") // offset into the game's executable -> address
|
||||
await cs.find("89 81 ?? ?? 00 00") // byte pattern in the game's code -> address or null
|
||||
await cs.pointer("game+10100", 0x340) // follow a pointer path -> address
|
||||
@@ -255,6 +291,8 @@ const cs = {
|
||||
write: (address, type, value) => rpc('write', [address, type, typeof value === 'bigint' ? value.toString() : value]),
|
||||
readBytes: (address, count) => rpc('readBytes', [address, count]),
|
||||
writeBytes: (address, hex) => rpc('writeBytes', [address, hex]),
|
||||
readText: (address, max) => rpc('readText', [address, max]),
|
||||
search: (type, value, options) => rpc('search', [type, typeof value === 'bigint' ? value.toString() : String(value), options]),
|
||||
address: (what) => rpc('address', [what]),
|
||||
find: (pattern) => rpc('find', [pattern]),
|
||||
pointer: (base, ...offsets) => rpc('pointer', [base, offsets]),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// writes, one for long scans so a scan never freezes the rest of the app) and
|
||||
// the question "what game is running right now?".
|
||||
|
||||
import { diag } from './diag.ts';
|
||||
import { Ps5Debug } from './ps5debug.ts';
|
||||
import type { Branding } from './ps5debug.ts';
|
||||
import { openWebSocketStream } from './transport.ts';
|
||||
@@ -143,6 +144,7 @@ export class Session {
|
||||
*/
|
||||
private async reopen(channel: Channel, reason: string): Promise<void> {
|
||||
if (this.ended) return;
|
||||
diag.info('connection', `The ${channel} connection closed (${reason}); reopening it`);
|
||||
const now = Date.now();
|
||||
this.reopens = this.reopens.filter((t) => now - t < REOPEN_WINDOW_MS);
|
||||
this.reopens.push(now);
|
||||
|
||||
+339
-19
@@ -54,6 +54,107 @@ interface EntryBase {
|
||||
* the label to `call`.
|
||||
*/
|
||||
symbol?: string;
|
||||
/**
|
||||
* Set on cheats that came from somewhere other than the person (a community
|
||||
* table, a cheat file another tool keeps on the console, a game patch), and
|
||||
* only on those: where they came from, so they can be updated, and told
|
||||
* apart from the person's own. See TableSource.
|
||||
*/
|
||||
origin?: EntryOrigin;
|
||||
/**
|
||||
* Only the page can place this cheat (it needs a look at the running game's
|
||||
* memory first), so the PS5 never applies it by itself, not even as part
|
||||
* of the start set.
|
||||
*/
|
||||
pageOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Where a cheat that the person did not make came from. */
|
||||
export interface EntryOrigin {
|
||||
/** The source's id: "etahen", "goldhen", "hencc", "local", "ps5" (a file on the console), "gamepatch", ... */
|
||||
source: string;
|
||||
/** The file it was read from, as the source names it. */
|
||||
file: string;
|
||||
/** That file's format: "json", "shn", "mc4", "xml", ... */
|
||||
format: string;
|
||||
/**
|
||||
* What the source calls this cheat, when it has a name for it that stays
|
||||
* the same from one copy of the file to the next (a game patch's key), so a
|
||||
* cheat can be matched with its update, or with a copy in the person's own.
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Game patches: the game version this one cheat is for, as the source says
|
||||
* it ("01.020.000"; "mask" or "all" for patches that find their place by a
|
||||
* byte pattern on any version). The source's own `version` is the game's.
|
||||
*/
|
||||
version?: string;
|
||||
/** Who made this one cheat, when the source credits each cheat on its own. */
|
||||
author?: string;
|
||||
/** Community cheats: applied once, with no "off" (a "button" in those formats). */
|
||||
button?: boolean;
|
||||
/** Community cheats: what this app cannot do with it, in words for the user. */
|
||||
unsupported?: string;
|
||||
/**
|
||||
* Community cheats: what the original file said about each place
|
||||
* (patchSites() order) beyond its offset. See Patch in cheats.ts.
|
||||
*/
|
||||
places?: CommunityPlace[];
|
||||
}
|
||||
|
||||
export interface CommunityPlace {
|
||||
section?: number;
|
||||
absolute?: boolean;
|
||||
where?: { name: string; prot: number | null; offset: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheats saved into the game's file from somewhere other than the person:
|
||||
* a community table converted when it matched the running game exactly, a
|
||||
* cheat file found on the console, or game patches. Kept apart from the
|
||||
* person's own cheats ("cheats" in the file), so an update replaces one
|
||||
* source as a whole and never touches what the person made.
|
||||
*
|
||||
* Any source of converted entries plugs in by building one of these (see
|
||||
* catalog.ts for community tables) and passing it to withSource().
|
||||
*/
|
||||
export interface TableSource {
|
||||
/** `${source}/${file}`; unique within a table, and what the start set names a source by. */
|
||||
id: string;
|
||||
/** "community", "console" or "gamepatch"; the page shows each kind the same way. */
|
||||
kind: string;
|
||||
/** The source's id, as in EntryOrigin. */
|
||||
source: string;
|
||||
/** The source's name for people, e.g. "etaHEN PS5 Cheats". */
|
||||
sourceName: string;
|
||||
file: string;
|
||||
format: string;
|
||||
/** Where it was downloaded from, if anywhere; checked again by "Check for updates". */
|
||||
url?: string;
|
||||
/**
|
||||
* The content hash the source gives its file (game patches: git's hash),
|
||||
* when it has one: "Check for updates" compares it before reading the file.
|
||||
*/
|
||||
sha?: string;
|
||||
/** The game version the source says its cheats are for. */
|
||||
version: string;
|
||||
/** When it was saved into the file (ISO 8601). */
|
||||
savedAt: string;
|
||||
/** sourceDigest() of `cheats` when saved: the same cheats give the same digest. */
|
||||
digest: string;
|
||||
/** The game's name as the source gives it. */
|
||||
name?: string;
|
||||
/** Community tables: the executable their offsets count from, when not the game's own (eboot.bin). */
|
||||
process?: string;
|
||||
/** Who made the cheats, as the source credits them. */
|
||||
credits?: string[];
|
||||
cheats: Entry[];
|
||||
}
|
||||
|
||||
/** One cheat switched on whenever the game starts. `from` is the TableSource id; absent for the person's own cheats. */
|
||||
export interface StartItem {
|
||||
name: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +294,10 @@ export interface OwnTable {
|
||||
version: string;
|
||||
name: string;
|
||||
entries: Entry[];
|
||||
/** Cheats from elsewhere saved into the same file; see TableSource. */
|
||||
sources?: TableSource[];
|
||||
/** The cheats switched on whenever the game starts; see StartItem. */
|
||||
startSet?: StartItem[];
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
@@ -212,8 +317,14 @@ export function emptyTable(titleId: string, version: string, name = ''): OwnTabl
|
||||
// "format": "cheatspecter",
|
||||
// "formatVersion": 1,
|
||||
// "game": { "name": "…", "titleId": "PPSA07391", "version": "01.009.000" },
|
||||
// "cheats": [ { "name": "…", "type": "patch", … } ]
|
||||
// "startSet": [ { "name": "…" }, { "name": "…", "from": "etahen/PPSA07391_01.009.000.json" } ],
|
||||
// "cheats": [ { "name": "…", "type": "patch", … } ],
|
||||
// "sources": [ { "id": "etahen/PPSA07391_01.009.000.json", "kind": "community", …, "cheats": [ … ] } ]
|
||||
// }
|
||||
//
|
||||
// "startSet" and "sources" are optional and were added without changing
|
||||
// formatVersion: an app or payload from before them reads the file as it
|
||||
// always did and skips both.
|
||||
|
||||
export const FILE_FORMAT = 'cheatspecter';
|
||||
export const FILE_FORMAT_VERSION = 1;
|
||||
@@ -226,7 +337,10 @@ export function loadOwnTable(titleId: string, version: string): OwnTable {
|
||||
}
|
||||
|
||||
export function saveOwnTable(table: OwnTable): boolean {
|
||||
return saveJson(`own.${table.titleId}`, table);
|
||||
// Tables saved from elsewhere stay out of it: they can be large, browser
|
||||
// storage is small, and they are on the PS5 or can be downloaded again.
|
||||
const { sources: _left, ...copy } = table;
|
||||
return saveJson(`own.${table.titleId}`, copy);
|
||||
}
|
||||
|
||||
export function isOwnTableText(text: string): boolean {
|
||||
@@ -246,6 +360,7 @@ export function isOwnTableText(text: string): boolean {
|
||||
*/
|
||||
export function keepProblem(e: Entry): string | null {
|
||||
if (e.unreviewed) return 'Review this cheat first.';
|
||||
if (e.pageOnly) return 'Only CheatSpecter in the browser can place this cheat, so the PS5 cannot keep it on.';
|
||||
if (e.type === 'patch') {
|
||||
return patchSites(e).some((p) => p.at.kind === 'address')
|
||||
? 'This cheat uses a raw memory address, which changes every time the game starts. Give it a lasting location first.'
|
||||
@@ -302,6 +417,22 @@ export function parseOwnTable(text: string, opts: { own?: boolean } = {}): OwnTa
|
||||
const game = (legacy ? doc : doc.game && typeof doc.game === 'object' ? doc.game : {}) as Record<string, unknown>;
|
||||
const list = legacy ? doc.entries : doc.cheats;
|
||||
if (!Array.isArray(list)) throw new Error('This table has no cheats');
|
||||
const table: OwnTable = {
|
||||
format: TABLE_FORMAT,
|
||||
titleId: String(game.titleId ?? '').toUpperCase(),
|
||||
version: String(game.version ?? ''),
|
||||
name: String(game.name ?? ''),
|
||||
entries: parseEntries(list, opts, null),
|
||||
};
|
||||
const sources = legacy ? [] : parseSources(doc.sources, opts);
|
||||
if (sources.length) table.sources = sources;
|
||||
const start = legacy ? [] : parseStartSet(doc.startSet);
|
||||
if (start.length) table.startSet = start;
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Reads a list of cheats. `from` is the source they were saved under, or null for the person's own. */
|
||||
function parseEntries(list: unknown[], opts: { own?: boolean }, from: TableSource | null): Entry[] {
|
||||
const entries: Entry[] = [];
|
||||
for (const item of list as (Entry & { reviewed?: boolean })[]) {
|
||||
if (!item || typeof item !== 'object' || typeof item.name !== 'string') continue;
|
||||
@@ -311,6 +442,8 @@ export function parseOwnTable(text: string, opts: { own?: boolean } = {}): OwnTa
|
||||
const entry = {
|
||||
// Fields a file may leave out because they were empty.
|
||||
...(e.type === 'call' ? { args: [] } : e.type === 'value' ? { freeze: false } : e.type === 'inject' ? { code: '', placement: 'code' } : e.type === 'shared' ? { code: '' } : {}),
|
||||
// A community cheat this app cannot use can have nothing to write; it is still listed, saying why.
|
||||
...(from && e.type === 'patch' ? { on: '' } : {}),
|
||||
...e,
|
||||
id: newId(),
|
||||
unreviewed: runsCode && !(opts.own && reviewed === true) ? true : undefined,
|
||||
@@ -319,19 +452,109 @@ export function parseOwnTable(text: string, opts: { own?: boolean } = {}): OwnTa
|
||||
// missing. Such a cheat is left out rather than let through to break the page.
|
||||
if (!wellFormed(entry)) continue;
|
||||
tidyExtras(entry);
|
||||
tidyOrigin(entry, from);
|
||||
// "Keep on" is the user's own decision for their own console. A file from
|
||||
// someone else never switches it on, and neither does a file that asks for
|
||||
// it on something that cannot be kept.
|
||||
if (entry.keep !== undefined && !(opts.own && entry.keep === true && canKeep(entry))) delete entry.keep;
|
||||
// it on something that cannot be kept. Cheats saved from a source are
|
||||
// never kept: the start set is how those come on by themselves.
|
||||
if (entry.keep !== undefined && !(opts.own && !from && entry.keep === true && canKeep(entry))) delete entry.keep;
|
||||
entries.push(entry);
|
||||
}
|
||||
return {
|
||||
format: TABLE_FORMAT,
|
||||
titleId: String(game.titleId ?? '').toUpperCase(),
|
||||
version: String(game.version ?? ''),
|
||||
name: String(game.name ?? ''),
|
||||
entries,
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Drops an origin or "page only" mark that is not the shape this version understands, and fills in the origin of a source's cheats. */
|
||||
function tidyOrigin(entry: Entry, from: TableSource | null): void {
|
||||
const e = entry as Entry & Record<string, unknown>;
|
||||
if (e.pageOnly !== true) delete e.pageOnly;
|
||||
const o = e.origin as Partial<EntryOrigin> | undefined;
|
||||
if (!from) {
|
||||
// The person's own cheats have no origin; one that claims one was edited by hand.
|
||||
delete e.origin;
|
||||
return;
|
||||
}
|
||||
const origin: EntryOrigin = {
|
||||
source: typeof o?.source === 'string' ? o.source : from.source,
|
||||
file: typeof o?.file === 'string' ? o.file : from.file,
|
||||
format: typeof o?.format === 'string' ? o.format : from.format,
|
||||
};
|
||||
for (const k of ['key', 'version', 'author'] as const) if (typeof o?.[k] === 'string' && o[k]) origin[k] = o[k];
|
||||
if (o?.button === true) origin.button = true;
|
||||
if (typeof o?.unsupported === 'string' && o.unsupported) origin.unsupported = o.unsupported;
|
||||
if (Array.isArray(o?.places)) {
|
||||
origin.places = (o.places as unknown[]).map((raw) => {
|
||||
const p = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
||||
const place: CommunityPlace = {};
|
||||
if (Number.isInteger(p.section) && (p.section as number) !== 0) place.section = p.section as number;
|
||||
if (p.absolute === true) place.absolute = true;
|
||||
const w = p.where as Record<string, unknown> | undefined;
|
||||
if (w && typeof w === 'object' && typeof w.name === 'string' && typeof w.offset === 'string') {
|
||||
place.where = { name: w.name, prot: typeof w.prot === 'number' ? w.prot : null, offset: w.offset };
|
||||
}
|
||||
return place;
|
||||
});
|
||||
}
|
||||
e.origin = origin;
|
||||
// A cheat the page alone can place says so, whatever the file claims.
|
||||
if (origin.unsupported || origin.places?.some((p) => p.section || p.absolute)) e.pageOnly = true;
|
||||
}
|
||||
|
||||
/** At most this many sources in one file, and this many cheats in the start set: far beyond any real table. */
|
||||
const MAX_SOURCES = 32;
|
||||
const MAX_START = 256;
|
||||
|
||||
function parseSources(raw: unknown, opts: { own?: boolean }): TableSource[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: TableSource[] = [];
|
||||
for (const item of raw.slice(0, MAX_SOURCES) as Record<string, unknown>[]) {
|
||||
if (!item || typeof item !== 'object' || !Array.isArray(item.cheats)) continue;
|
||||
const str = (k: string) => (typeof item[k] === 'string' ? (item[k] as string) : '');
|
||||
const source: TableSource = {
|
||||
id: str('id'),
|
||||
kind: str('kind') || 'community',
|
||||
source: str('source'),
|
||||
sourceName: str('sourceName') || str('source'),
|
||||
file: str('file'),
|
||||
format: str('format'),
|
||||
version: str('version'),
|
||||
savedAt: str('savedAt'),
|
||||
digest: str('digest'),
|
||||
cheats: [],
|
||||
};
|
||||
if (!source.source || !source.file) continue;
|
||||
if (!source.id) source.id = sourceId(source.source, source.file);
|
||||
// Two sources under one id would make the start set ambiguous; the first one stands.
|
||||
if (out.some((s) => s.id === source.id)) continue;
|
||||
if (str('url')) source.url = str('url');
|
||||
if (str('sha')) source.sha = str('sha');
|
||||
if (str('name')) source.name = str('name');
|
||||
if (str('process')) source.process = str('process');
|
||||
if (Array.isArray(item.credits)) source.credits = (item.credits as unknown[]).filter((c): c is string => typeof c === 'string' && c !== '');
|
||||
// Cheats from elsewhere are reviewed like any imported file's, unless the person's own file vouches for them.
|
||||
source.cheats = parseEntries(item.cheats as unknown[], opts, source);
|
||||
out.push(source);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseStartSet(raw: unknown): StartItem[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: StartItem[] = [];
|
||||
for (const item of raw as Record<string, unknown>[]) {
|
||||
// A plain name is read as one of the person's own cheats.
|
||||
const it = typeof item === 'string' ? { name: item } : item;
|
||||
if (!it || typeof it !== 'object' || typeof it.name !== 'string' || !it.name) continue;
|
||||
const next: StartItem = typeof it.from === 'string' && it.from ? { name: it.name, from: it.from } : { name: it.name };
|
||||
if (out.some((o) => o.name === next.name && o.from === next.from)) continue;
|
||||
out.push(next);
|
||||
if (out.length >= MAX_START) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The id a source is saved under: `${source}/${file}`. */
|
||||
export function sourceId(source: string, file: string): string {
|
||||
return `${source}/${file}`;
|
||||
}
|
||||
|
||||
function isLocation(v: unknown): v is Location {
|
||||
@@ -470,7 +693,7 @@ function lean(entry: Entry): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
function fileText(table: OwnTable, cheats: unknown[]): string {
|
||||
function fileText(table: OwnTable, cheats: unknown[], extra: { startSet?: StartItem[]; sources?: unknown[] } = {}): string {
|
||||
// Minified: a table with scripts and injected code would otherwise be largely
|
||||
// whitespace. Any JSON formatter makes it readable again, and a file someone
|
||||
// formatted by hand loads just the same.
|
||||
@@ -478,30 +701,125 @@ function fileText(table: OwnTable, cheats: unknown[]): string {
|
||||
format: FILE_FORMAT,
|
||||
formatVersion: FILE_FORMAT_VERSION,
|
||||
game: { name: table.name, titleId: table.titleId, version: table.version },
|
||||
// Before the cheats, so a reader going through the file once (the PS5's engine) knows it by the time it reaches them.
|
||||
...(extra.startSet?.length ? { startSet: extra.startSet } : {}),
|
||||
cheats,
|
||||
...(extra.sources?.length ? { sources: extra.sources } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** An entry as the person's own saved file has it. Only a "yes" to reviewed is recorded; an entry without it is treated as not looked at yet. */
|
||||
const savedEntry = (e: Entry) => (RUNS_CODE.has(e.type) && !e.unreviewed ? { ...lean(e), reviewed: true } : lean(e));
|
||||
|
||||
/** A source as it is written: the id first, so a reader going through the file once knows which source the cheats belong to. */
|
||||
function savedSource(src: TableSource): Record<string, unknown> {
|
||||
const { id, cheats, ...rest } = src;
|
||||
const out: Record<string, unknown> = { id };
|
||||
for (const [k, v] of Object.entries(rest)) if (v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)) out[k] = v;
|
||||
out.cheats = cheats.map((e) => savedEntry({ ...e, keep: undefined }));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The table as the user's own saved file: everything, including which entries they have reviewed. */
|
||||
export function saveFileText(table: OwnTable): string {
|
||||
return fileText(
|
||||
table,
|
||||
// Only a "yes" is recorded; an entry without it is treated as not looked at yet.
|
||||
table.entries.map((e) => (RUNS_CODE.has(e.type) && !e.unreviewed ? { ...lean(e), reviewed: true } : lean(e))),
|
||||
return fileText(table, table.entries.map(savedEntry), {
|
||||
startSet: table.startSet,
|
||||
sources: (table.sources ?? []).map(savedSource),
|
||||
});
|
||||
}
|
||||
|
||||
/** JSON with every object's keys in order, so the same data always gives the same text however it was put together. */
|
||||
export function stableJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v: unknown) =>
|
||||
v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.keys(v as Record<string, unknown>).sort().map((k) => [k, (v as Record<string, unknown>)[k]]))
|
||||
: v,
|
||||
);
|
||||
}
|
||||
|
||||
/** The table as a file to share. Per-session details are stripped. */
|
||||
/**
|
||||
* A fingerprint of a source's cheats, to tell whether the online copy has
|
||||
* changed since it was saved: the same cheats in the same order give the
|
||||
* same digest, whatever their ids.
|
||||
*/
|
||||
export function sourceDigest(cheats: Entry[]): string {
|
||||
const text = stableJson(cheats.map((e) => lean({ ...e, keep: undefined })));
|
||||
// Two 32-bit FNV-1a hashes with different starting points; plenty to notice a change, and cheap.
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x01000193 ^ text.length;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text.charCodeAt(i);
|
||||
a = Math.imul(a ^ c, 0x01000193);
|
||||
b = Math.imul(b ^ c, 0x5bd1e995);
|
||||
}
|
||||
return (a >>> 0).toString(16).padStart(8, '0') + (b >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/** The table with `source` added, or put in place of the one saved under the same id. Start set entries naming it stay. */
|
||||
export function withSource(table: OwnTable, source: TableSource): OwnTable {
|
||||
const list = table.sources ?? [];
|
||||
const at = list.findIndex((s) => s.id === source.id);
|
||||
return { ...table, sources: at < 0 ? [...list, source] : list.map((s, i) => (i === at ? source : s)) };
|
||||
}
|
||||
|
||||
/** The table without the source saved under `id`, and without the start set entries that name it. */
|
||||
export function withoutSource(table: OwnTable, id: string): OwnTable {
|
||||
const sources = (table.sources ?? []).filter((s) => s.id !== id);
|
||||
const startSet = (table.startSet ?? []).filter((s) => s.from !== id);
|
||||
return { ...table, sources: sources.length ? sources : undefined, startSet: startSet.length ? startSet : undefined };
|
||||
}
|
||||
|
||||
/** The cheat a start set entry names: the first of that name in the person's own cheats, or in the named source. Null when it is gone. */
|
||||
export function startEntry(table: OwnTable, item: StartItem): Entry | null {
|
||||
const list = item.from === undefined ? table.entries : table.sources?.find((s) => s.id === item.from)?.cheats;
|
||||
return list?.find((e) => e.name === item.name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the PS5 switches this cheat on by itself when it is in the start
|
||||
* set: anything it could keep on. A patch is written once and stays. A held
|
||||
* value is set once when the game starts but not held there: holding it
|
||||
* would fight the person's own switch on this page (that is what "Keep on"
|
||||
* is for), so the page holds it while it is open. Everything else in the
|
||||
* start set (code cheats, scripts, cheats only the page can place) is
|
||||
* switched on by the page when it opens on the game. The engine in
|
||||
* payload/src/engine.c makes the same choice.
|
||||
*/
|
||||
export function startsOnPs5(e: Entry): boolean {
|
||||
return (e.type === 'patch' || e.type === 'value') && canKeep(e);
|
||||
}
|
||||
|
||||
/** Whether a cheat of the person's own is only good until the game restarts: it points at a raw address. */
|
||||
export function atRawAddress(e: Entry): boolean {
|
||||
return (
|
||||
('at' in e && e.at.kind === 'address') ||
|
||||
(e.type === 'call' && e.fn.kind === 'address') ||
|
||||
(e.type === 'patch' && patchSites(e).some((p) => p.at.kind === 'address')) ||
|
||||
(e.type === 'inject' && codeSites(e).some((p) => p.at.kind === 'address'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The table as a file to share, or to keep on a device. Per-session details
|
||||
* are stripped. Tables saved from elsewhere and the start set go along as
|
||||
* they are: a community table's fixed addresses are what its makers wrote.
|
||||
*/
|
||||
export function exportOwnTable(table: OwnTable): string {
|
||||
return fileText(
|
||||
table,
|
||||
table.entries
|
||||
// A raw address means nothing on anyone else's console, or after a restart.
|
||||
.filter((e) => !('at' in e && e.at.kind === 'address') && !(e.type === 'call' && e.fn.kind === 'address'))
|
||||
.filter((e) => !(e.type === 'patch' && patchSites(e).some((p) => p.at.kind === 'address')))
|
||||
.filter((e) => !(e.type === 'inject' && codeSites(e).some((p) => p.at.kind === 'address')))
|
||||
.filter((e) => !atRawAddress(e))
|
||||
// "Keep on" belongs to the console the table was saved on, not to whoever opens the file.
|
||||
.map((e) => lean({ ...e, keep: undefined })),
|
||||
{
|
||||
startSet: table.startSet,
|
||||
sources: (table.sources ?? []).map((src) => {
|
||||
const out = savedSource(src);
|
||||
out.cheats = src.cheats.map((e) => lean({ ...e, keep: undefined }));
|
||||
return out;
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -841,6 +1159,8 @@ export class TableRunner {
|
||||
running.auto = false;
|
||||
return;
|
||||
}
|
||||
// Switched on from somewhere else (another browser, the MCP server): take it over as it is, so it can be switched off from here.
|
||||
if (entry.type === 'inject' && !running && (await this.adopt([entry]))) return;
|
||||
const all = this.tableWith(entry);
|
||||
const order = enableOrder(all, entry);
|
||||
for (const e of order) {
|
||||
|
||||
+25
-5
@@ -1,9 +1,9 @@
|
||||
// Saving the user's own tables as real files.
|
||||
//
|
||||
// "Save to PS5" keeps them in one folder on the console, one file per game
|
||||
// and game version, where they are found and loaded again whenever that game
|
||||
// is running, from any device. "Save to this device" hands the same kind of
|
||||
// file to the browser to store wherever the user likes.
|
||||
// The console keeps them in one folder, one file per game and game version,
|
||||
// where they are found and loaded again whenever that game is running, from
|
||||
// any device; the page saves to it as changes are made. "Save" hands a copy
|
||||
// of the same file to the browser to store wherever the user likes.
|
||||
//
|
||||
// Browser storage is tied to the page's address, and a console's address can
|
||||
// change with every restart, so it is never more than a working copy.
|
||||
@@ -84,6 +84,20 @@ export async function savePs5Table(target: Target | null, table: OwnTable): Prom
|
||||
if (!res.ok) throw new Error(`The console could not save the file (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the game's file for this version from the console. Returns false
|
||||
* when there was no such file. The payload deletes nothing but .cspector
|
||||
* files in its own cheats folder.
|
||||
*/
|
||||
export async function deletePs5Table(target: Target | null, titleId: string, version: string): Promise<boolean> {
|
||||
const res = await request(target, `api/tables/${titleId}/${fileVersion(version)}`, { method: 'DELETE' });
|
||||
if (res.status === 404) return false;
|
||||
// A payload from before files could be deleted turns the method down.
|
||||
if (res.status === 405) throw new Ps5StorageUnavailable();
|
||||
if (!res.ok) throw new Error(`The console could not delete the file (HTTP ${res.status})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface SavePicker {
|
||||
showSaveFilePicker?: (options: {
|
||||
suggestedName: string;
|
||||
@@ -121,7 +135,13 @@ export async function saveToDevice(suggestedName: string, text: string, descript
|
||||
return suggestedName;
|
||||
}
|
||||
|
||||
/** The table as the file "Save to this device" writes. */
|
||||
/**
|
||||
* The table as the file "Save" writes to this device: what the PS5 keeps,
|
||||
* community cheats and start set included, less what only means something
|
||||
* on this console until the game restarts (raw addresses, "Keep on").
|
||||
* Opening it again treats it like any other file from outside: code cheats
|
||||
* need a look first.
|
||||
*/
|
||||
export function deviceFileText(table: OwnTable): string {
|
||||
return exportOwnTable(table);
|
||||
}
|
||||
+170
-15
@@ -1,26 +1,53 @@
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRightIcon, GraduationCapIcon, RefreshCwIcon } from 'lucide-react';
|
||||
import { ChevronRightIcon, GraduationCapIcon, PowerOffIcon, RefreshCwIcon, SettingsIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { LEARN_EVENT } from '../lib/learnlink.ts';
|
||||
import { Session, discoverConsoles, fetchServerInfo } from '../lib/session.ts';
|
||||
import { browserName, diag, installDiagHooks } from '../lib/diag.ts';
|
||||
import { HOME, navigate, useRoute } from '../lib/route.ts';
|
||||
import type { Route } from '../lib/route.ts';
|
||||
import { Session, discoverConsoles, fetchServerInfo, formatFirmware } from '../lib/session.ts';
|
||||
import type { ServerInfo, Target } from '../lib/session.ts';
|
||||
import { loadJson, saveJson } from '../lib/storage.ts';
|
||||
import { Ghost, Notice, Safe, Wordmark, errorText } from './bits.tsx';
|
||||
import { ConsoleView } from './ConsoleView.tsx';
|
||||
import { Settings } from './Settings.tsx';
|
||||
import { restoreLogLevel } from './Diagnostics.tsx';
|
||||
|
||||
// The Learn section is its own download, fetched the first time it is opened.
|
||||
const LearnPage = lazy(() => import('./LearnPage.tsx'));
|
||||
|
||||
// Everything that goes wrong on the page from here on is also written to the
|
||||
// diagnostics log (Settings, Diagnostics), where the person can copy it.
|
||||
installDiagHooks();
|
||||
if (typeof navigator !== 'undefined') {
|
||||
diag.setContext('Browser', browserName(navigator.userAgent));
|
||||
diag.setContext('User agent', navigator.userAgent);
|
||||
}
|
||||
|
||||
/** Asks the CheatSpecter payload on the PS5 to stop. Throws with words for the person when it will not. */
|
||||
async function stopPayload(): Promise<void> {
|
||||
let res: Response;
|
||||
try {
|
||||
// The extra header is what a page on another site cannot send without the payload's leave, so only this app can ask.
|
||||
res = await fetch('api/shutdown', { method: 'POST', cache: 'no-store', headers: { 'X-CheatSpecter': 'shutdown' } });
|
||||
} catch {
|
||||
throw new Error('The PS5 did not answer. CheatSpecter may already have stopped.');
|
||||
}
|
||||
if (res.status === 403) throw new Error('The PS5 refused. The CheatSpecter running there is older and can only be stopped from the PS5 itself; restarting the PS5 also stops it.');
|
||||
if (!res.ok) throw new Error(`The PS5 answered with an error (${res.status}). Nothing was stopped.`);
|
||||
}
|
||||
type Phase =
|
||||
| { name: 'boot' }
|
||||
| { name: 'unavailable'; reason: string }
|
||||
| { name: 'pick'; error?: string }
|
||||
| { name: 'connecting'; label: string }
|
||||
| { name: 'connected'; session: Session };
|
||||
| { name: 'connected'; session: Session }
|
||||
/** The person stopped the payload on the PS5 from Settings. */
|
||||
| { name: 'stopped' };
|
||||
|
||||
export function App() {
|
||||
// The last line of defense: a fault nothing closer caught still leaves words on the screen instead of a blank page.
|
||||
@@ -35,14 +62,21 @@ function AppBody() {
|
||||
const [phase, setPhase] = useState<Phase>({ name: 'boot' });
|
||||
const [info, setInfo] = useState<ServerInfo | null>(null);
|
||||
const sessionRef = useRef<Session | null>(null);
|
||||
/** The Learn section is showing, over whatever else is going on. `lesson` opens one right away. */
|
||||
const [learn, setLearn] = useState<{ lesson: string | null } | null>(null);
|
||||
// The Learn section and Settings show over whatever else is going on, which
|
||||
// stays in place behind them, so a search or a cheat carries on.
|
||||
const route = useRoute();
|
||||
const learn = route.screen === 'learn' ? route : null;
|
||||
const settings = route.screen === 'settings' ? route : null;
|
||||
/** Where "Back" from the Learn section or Settings goes: the screen the person came from. */
|
||||
const returnTo = useRef<Route>(HOME);
|
||||
if (!learn && !settings) returnTo.current = route;
|
||||
const leave = useCallback(() => navigate(returnTo.current), []);
|
||||
|
||||
// Other screens ask for the Learn section through openLearn() (learnlink.ts).
|
||||
useEffect(() => {
|
||||
const open = (e: Event) => {
|
||||
const lesson = (e as CustomEvent<{ lesson?: unknown }>).detail?.lesson;
|
||||
setLearn({ lesson: typeof lesson === 'string' ? lesson : null });
|
||||
navigate({ screen: 'learn', lesson: typeof lesson === 'string' ? lesson : null, step: null });
|
||||
};
|
||||
window.addEventListener(LEARN_EVENT, open);
|
||||
return () => window.removeEventListener(LEARN_EVENT, open);
|
||||
@@ -50,7 +84,9 @@ function AppBody() {
|
||||
|
||||
// `background`: a retry nobody pressed a button for. It changes nothing on screen unless it succeeds.
|
||||
const connect = useCallback(async (target: Target | null, opts: { quiet?: boolean; background?: boolean } = {}) => {
|
||||
if (!opts.background) setPhase({ name: 'connecting', label: target ? target.label || target.host : 'your PS5' });
|
||||
const label = target ? target.label || target.host : 'your PS5';
|
||||
if (!opts.background) setPhase({ name: 'connecting', label });
|
||||
diag.add(opts.background ? 'debug' : 'info', 'connection', `Connecting to ${label}${target ? ` (${target.host}:${target.port})` : ''}`);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const session = await Session.connect(target);
|
||||
@@ -60,13 +96,23 @@ function AppBody() {
|
||||
return;
|
||||
}
|
||||
sessionRef.current = session;
|
||||
const fw = session.firmware ? `firmware ${formatFirmware(session.firmware)}` : 'firmware not reported';
|
||||
const about = `${session.branding.brand || 'debugger did not say its name'}, ${fw}`;
|
||||
diag.setContext('Console', `${target ? `${target.label ?? 'PS5'} ${target.host}:${target.port}` : 'the PS5 serving this page'}; ${about}`);
|
||||
diag.info('connection', `Connected to ${label}: ${about}`);
|
||||
session.onLost = (reason) => {
|
||||
sessionRef.current = null;
|
||||
diag.warn('connection', `Lost the connection to ${label}: ${reason}`);
|
||||
setPhase({ name: 'pick', error: `Disconnected: ${reason}` });
|
||||
};
|
||||
if (target) saveJson('lastTarget', target);
|
||||
if (target) {
|
||||
saveJson('lastTarget', target);
|
||||
// Through a computer's bridge the console's settings are only reachable once it is known which console.
|
||||
void restoreLogLevel(target);
|
||||
}
|
||||
setPhase({ name: 'connected', session });
|
||||
} catch (e) {
|
||||
diag.add(opts.background ? 'debug' : 'warn', 'connection', `Could not connect to ${label}: ${errorText(e)}`);
|
||||
if (opts.background) return;
|
||||
// A console that refuses does so at once. Stay on "Connecting" long enough to be seen, or "Try again" looks like it did nothing.
|
||||
const shown = Date.now() - started;
|
||||
@@ -75,13 +121,17 @@ function AppBody() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [boot, setBoot] = useState(0);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
fetchServerInfo()
|
||||
.then((i) => {
|
||||
if (canceled) return;
|
||||
setInfo(i);
|
||||
diag.setContext('CheatSpecter', `${i.version} (${i.mode === 'console' ? 'served by the PS5' : i.demo ? 'computer bridge, demo console available' : 'computer bridge'})`);
|
||||
diag.info('page', `Opened CheatSpecter ${i.version} at ${location.href}`);
|
||||
if (i.mode === 'console') {
|
||||
void restoreLogLevel(null);
|
||||
// Served from the PS5 itself: there is only one console to talk to.
|
||||
void connect(null);
|
||||
return;
|
||||
@@ -91,12 +141,51 @@ function AppBody() {
|
||||
else setPhase({ name: 'pick' });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!canceled) setPhase({ name: 'unavailable', reason: errorText(e) });
|
||||
if (canceled) return;
|
||||
diag.error('page', `The server that sent this page does not answer: ${errorText(e)}`);
|
||||
setPhase({ name: 'unavailable', reason: errorText(e) });
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [connect]);
|
||||
}, [connect, boot]);
|
||||
|
||||
/** Stops the payload on the PS5, after the person confirmed it in Settings. */
|
||||
const shutDown = useCallback(async () => {
|
||||
diag.info('payload', 'Asked the PS5 to stop CheatSpecter');
|
||||
await stopPayload();
|
||||
diag.info('payload', 'CheatSpecter stopped on the PS5');
|
||||
const s = sessionRef.current;
|
||||
sessionRef.current = null;
|
||||
if (s) {
|
||||
// Going away now is expected: it must not read as a lost connection.
|
||||
s.onLost = null;
|
||||
s.close();
|
||||
}
|
||||
setPhase({ name: 'stopped' });
|
||||
navigate(HOME);
|
||||
}, []);
|
||||
|
||||
/** Whether the payload answers again; when it does, start over as if the page had just been opened. */
|
||||
const checkBack = useCallback(async () => {
|
||||
try {
|
||||
await fetchServerInfo();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
diag.info('payload', 'CheatSpecter is answering again');
|
||||
setBoot((n) => n + 1);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Once stopped, notice by itself when the payload is loaded again.
|
||||
useEffect(() => {
|
||||
if (phase.name !== 'stopped') return;
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') void checkBack();
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [phase.name, checkBack]);
|
||||
|
||||
const disconnect = () => {
|
||||
const s = sessionRef.current;
|
||||
@@ -110,6 +199,23 @@ function AppBody() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]">
|
||||
{settings && (
|
||||
<Safe what="Settings">
|
||||
<Settings
|
||||
section={settings.section}
|
||||
info={info}
|
||||
target={phase.name === 'connected' ? phase.session.target : null}
|
||||
connected={phase.name === 'connected'}
|
||||
stopped={phase.name === 'stopped'}
|
||||
onShutDown={shutDown}
|
||||
onDisconnect={() => {
|
||||
disconnect();
|
||||
navigate(HOME);
|
||||
}}
|
||||
onClose={leave}
|
||||
/>
|
||||
</Safe>
|
||||
)}
|
||||
{learn && (
|
||||
<Safe what="the Learn section">
|
||||
<Suspense
|
||||
@@ -124,26 +230,40 @@ function AppBody() {
|
||||
connected={phase.name === 'connected'}
|
||||
onConsole={info?.mode === 'console'}
|
||||
lesson={learn.lesson}
|
||||
onClose={() => setLearn(null)}
|
||||
step={learn.step ? learn.step - 1 : null}
|
||||
onPlace={(lesson, step, fill) =>
|
||||
navigate({ screen: 'learn', lesson, step: lesson && step !== null ? step + 1 : null }, { replace: fill })
|
||||
}
|
||||
onClose={leave}
|
||||
/>
|
||||
</Suspense>
|
||||
</Safe>
|
||||
)}
|
||||
{phase.name === 'connected' ? (
|
||||
// Kept in place while the Learn section is open, so a search or a cheat on the real game carries on behind it.
|
||||
<div hidden={learn !== null}>
|
||||
<div hidden={learn !== null || settings !== null}>
|
||||
<ConsoleView
|
||||
session={phase.session}
|
||||
localCheats={info?.mode === 'bridge'}
|
||||
onConsole={info?.mode === 'console'}
|
||||
onDisconnect={disconnect}
|
||||
onLearn={() => setLearn({ lesson: null })}
|
||||
onLearn={() => navigate({ screen: 'learn', lesson: null, step: null })}
|
||||
/>
|
||||
</div>
|
||||
) : learn ? null : (
|
||||
) : learn || settings ? null : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-center py-1">
|
||||
<Wordmark />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
onClick={() => navigate({ screen: 'settings', section: 'main' })}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</header>
|
||||
{phase.name === 'boot' && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -161,6 +281,7 @@ function AppBody() {
|
||||
<h2 className="text-xl font-semibold">Connecting to {phase.label}…</h2>
|
||||
</div>
|
||||
)}
|
||||
{phase.name === 'stopped' && <Stopped onCheck={checkBack} />}
|
||||
{phase.name === 'pick' && (
|
||||
<Picker
|
||||
info={info}
|
||||
@@ -170,7 +291,9 @@ function AppBody() {
|
||||
consoleMode={info?.mode === 'console'}
|
||||
/>
|
||||
)}
|
||||
{(phase.name === 'pick' || phase.name === 'unavailable') && <LearnDoor onOpen={() => setLearn({ lesson: null })} />}
|
||||
{(phase.name === 'pick' || phase.name === 'unavailable') && (
|
||||
<LearnDoor onOpen={() => navigate({ screen: 'learn', lesson: null, step: null })} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
@@ -180,6 +303,38 @@ function AppBody() {
|
||||
);
|
||||
}
|
||||
|
||||
/** After the person stopped CheatSpecter on the PS5 from Settings. */
|
||||
function Stopped(props: { onCheck: () => Promise<boolean> }) {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [notYet, setNotYet] = useState(false);
|
||||
const check = async () => {
|
||||
setChecking(true);
|
||||
setNotYet(false);
|
||||
const back = await props.onCheck();
|
||||
setChecking(false);
|
||||
setNotYet(!back);
|
||||
};
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<PowerOffIcon className="size-8 text-primary" aria-hidden />
|
||||
<h2 className="text-lg font-semibold">CheatSpecter has stopped on the PS5</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Load the CheatSpecter payload on the PS5 again to reconnect. This page notices by itself when it is back, or you can check now.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void check()} disabled={checking}>
|
||||
{checking ? <Spinner className="size-4" /> : <RefreshCwIcon />} {checking ? 'Checking…' : 'Check again'}
|
||||
</Button>
|
||||
{notYet && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Not back yet. Load the payload on the PS5, wait for its "ready" notification, then check again.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The way into the Learn section from the first screen: no console needed. */
|
||||
function LearnDoor(props: { onOpen: () => void }) {
|
||||
return (
|
||||
|
||||
+839
-74
File diff suppressed because it is too large.
Load diff
+138
-15
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LibraryIcon, TriangleAlertIcon, UnplugIcon } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { LibraryIcon, SettingsIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -23,9 +23,13 @@ import {
|
||||
StatusIndicator,
|
||||
StatusLabel,
|
||||
} from "@/components/kibo-ui/status";
|
||||
import { noteGameEnded } from "../lib/crashguard.ts";
|
||||
import { cachedMeta, fetchTitleMeta, iconUrl } from "../lib/library.ts";
|
||||
import type { TitleMeta } from "../lib/library.ts";
|
||||
import { formatFirmware } from "../lib/session.ts";
|
||||
import { diag } from "../lib/diag.ts";
|
||||
import { formatRoute, goBack, isGameTab, navigate, useRoute } from "../lib/route.ts";
|
||||
import type { GameTab, Route } from "../lib/route.ts";
|
||||
import { loadJson, removeKey, saveJson } from "../lib/storage.ts";
|
||||
import type { Game, Session } from "../lib/session.ts";
|
||||
import { Ghost, Notice, Safe, Wordmark, errorText } from "./bits.tsx";
|
||||
@@ -46,6 +50,10 @@ import type { ActivityRecord, TabId } from "./workbench.tsx";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
function gameKeyOf(game: Game | null): string {
|
||||
return game ? `${game.pid}:${game.titleId}:${game.version}` : "none";
|
||||
}
|
||||
|
||||
/** A game that went away with cheats on, kept until the user has read about it. */
|
||||
interface ClosedGame extends ActivityRecord {
|
||||
/** When the page noticed it was gone. */
|
||||
@@ -73,9 +81,12 @@ export function ConsoleView(props: {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** The name a cheat table gives the game; used when the console cannot be asked. */
|
||||
const [catalogName, setCatalogName] = useState("");
|
||||
// Temporary home for the controller shortcut setting until there is a Settings screen to render it in.
|
||||
/** The game's own name and platform, from the console's metadata. */
|
||||
const [meta, setMeta] = useState<TitleMeta | null>(null);
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
// Which screen shows is read from the address (route.ts), so a reload or a link lands on it.
|
||||
const route = useRoute();
|
||||
const routeKey = formatRoute(route);
|
||||
const [closed, setClosed] = useState<ClosedGame | null>(() =>
|
||||
loadJson<ClosedGame | null>(CLOSED_KEY, null),
|
||||
);
|
||||
@@ -88,8 +99,15 @@ export function ConsoleView(props: {
|
||||
const switching = useRef<{ from: number; at: number } | null>(null);
|
||||
const gameRef = useRef(game);
|
||||
gameRef.current = game;
|
||||
const libraryOpen = route.screen === "library" && game !== null;
|
||||
const libraryOpenRef = useRef(libraryOpen);
|
||||
libraryOpenRef.current = libraryOpen;
|
||||
/** The tab last shown, for coming back from the library to where the person was. */
|
||||
const lastTab = useRef<GameTab>("cheats");
|
||||
if (route.screen === "game") lastTab.current = route.tab;
|
||||
const gameRoute = (titleId: string, tab: GameTab = lastTab.current): Route => ({ screen: "game", titleId, tab });
|
||||
/** The game last seen running, and when: for crash protection (crashguard.ts) when it goes away. */
|
||||
const lastSeen = useRef<{ game: Game; at: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
@@ -119,6 +137,14 @@ export function ConsoleView(props: {
|
||||
setClosed(note);
|
||||
}
|
||||
}
|
||||
// A game that ended by itself, not because the person started another from the library.
|
||||
const seen = lastSeen.current;
|
||||
if (seen && (!g || g.pid !== seen.game.pid)) {
|
||||
const sw = switching.current;
|
||||
if (!(sw !== null && Date.now() - sw.at < 120_000))
|
||||
void noteGameEnded(session.target, seen.game, seen.at);
|
||||
}
|
||||
lastSeen.current = g ? { game: g, at: Date.now() } : null;
|
||||
if (g && switching.current && g.pid !== switching.current.from)
|
||||
switching.current = null;
|
||||
setGame((prev) => (sameGame(prev, g) ? prev : g));
|
||||
@@ -156,15 +182,49 @@ export function ConsoleView(props: {
|
||||
canceled = true;
|
||||
};
|
||||
}, [session, titleId, game?.pid]);
|
||||
// The library has done its job once the game it started is on screen.
|
||||
// The game in the diagnostics log, and at the head of a copied log.
|
||||
useEffect(() => {
|
||||
setLibraryOpen(false);
|
||||
}, [titleId]);
|
||||
if (!checked) return;
|
||||
diag.setContext("Game", titleId ? `${titleId} ${game?.version ? "v" + game.version : "(version unknown)"} pid ${game?.pid}` : "none running");
|
||||
diag.info("game", titleId ? `Running: ${titleId} ${game?.version ?? ""} (pid ${game?.pid})` : "No game running");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [checked, gameKeyOf(game)]);
|
||||
|
||||
/**
|
||||
* The game whose screen was last on show. When the address names it and it
|
||||
* stops or another game starts, the address follows the game that runs now:
|
||||
* the person was simply using the app. An address naming some other game
|
||||
* (a link, an old bookmark) is not followed; the screen says it is not running.
|
||||
*/
|
||||
const shown = useRef<string | null>(null);
|
||||
/** The running game when the address was last checked; undefined until the first check. */
|
||||
const before = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!checked) return;
|
||||
const tid = game?.titleId ?? null;
|
||||
const prev = before.current;
|
||||
before.current = tid;
|
||||
if (route.screen === "home") {
|
||||
if (tid) navigate(gameRoute(tid, "cheats"), { replace: true });
|
||||
} else if (route.screen === "library") {
|
||||
// The library has done its job once a game it started (or any other new one) is on screen.
|
||||
if (tid && prev !== undefined && prev !== tid) navigate(gameRoute(tid, "cheats"), { replace: true });
|
||||
} else if (route.screen === "game") {
|
||||
if (tid === route.titleId) shown.current = tid;
|
||||
else if (tid && shown.current === route.titleId) {
|
||||
shown.current = tid;
|
||||
navigate(gameRoute(tid, "cheats"), { replace: true });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [checked, titleId, routeKey]);
|
||||
const linked = route.screen === "game" ? route.titleId : null;
|
||||
const elsewhere = game !== null && linked !== null && linked !== game.titleId;
|
||||
|
||||
const displayName = meta?.name || catalogName;
|
||||
|
||||
const brand = session.branding.brand.replace(/\s*\[[^\]]*\]\s*$/, "");
|
||||
const gameKey = game ? `${game.pid}:${game.titleId}:${game.version}` : "none";
|
||||
const gameKey = gameKeyOf(game);
|
||||
const isDemo = session.target?.label === "Demo console";
|
||||
|
||||
return (
|
||||
@@ -188,7 +248,7 @@ export function ConsoleView(props: {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
onClick={() => navigate({ screen: "library" })}
|
||||
>
|
||||
<LibraryIcon /> Games
|
||||
</Button>
|
||||
@@ -203,18 +263,28 @@ export function ConsoleView(props: {
|
||||
Learn
|
||||
</Button>
|
||||
)}
|
||||
{/* Served by the PS5 itself there is no other console to go to, so there is nothing to disconnect for. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 max-sm:px-2"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
onClick={() => navigate({ screen: "settings", section: "main" })}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
{/* Served by the PS5 itself there is no other console to go to, so there is nothing to disconnect for.
|
||||
On a phone there is no room for it beside Settings, so it is in Settings there. */}
|
||||
{!props.onConsole && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 max-sm:px-2"
|
||||
className="shrink-0 max-sm:hidden"
|
||||
aria-label="Disconnect"
|
||||
title="Disconnect"
|
||||
onClick={props.onDisconnect}
|
||||
>
|
||||
<UnplugIcon className="sm:hidden" />
|
||||
<span className="max-sm:hidden">Disconnect</span>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
@@ -242,7 +312,9 @@ export function ConsoleView(props: {
|
||||
No game running
|
||||
</h2>
|
||||
<p className="text-sm">
|
||||
Start a game on your PS5 and it will show up here.
|
||||
{linked
|
||||
? `This address is for ${cachedMeta(linked)?.name || linked}, which is not running. Start it on your PS5, or from your games below, and it will show up here.`
|
||||
: "Start a game on your PS5 and it will show up here."}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -271,6 +343,29 @@ export function ConsoleView(props: {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{elsewhere && (
|
||||
<Card className="border-primary/60" role="status">
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{cachedMeta(linked!)?.name || linked} is not running
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This address is for {cachedMeta(linked!)?.name || linked}. The game
|
||||
running on your PS5 now is {displayName || game.titleId}.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => navigate(gameRoute(game.titleId, "cheats"))}>
|
||||
Show {displayName || game.titleId}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate({ screen: "library" })}>
|
||||
<LibraryIcon /> Open your games
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Kept in place (hidden) while the address names another game, so nothing running in it is lost. */}
|
||||
<div hidden={elsewhere} className="flex flex-col gap-4">
|
||||
<Card className="border-primary/60">
|
||||
<CardContent className="flex items-center gap-4">
|
||||
<GameLogo
|
||||
@@ -330,12 +425,20 @@ export function ConsoleView(props: {
|
||||
<GameTabs
|
||||
localCheats={props.localCheats}
|
||||
onGameName={setCatalogName}
|
||||
tab={route.screen === "game" && !elsewhere ? route.tab : null}
|
||||
onTab={(tab) => navigate(gameRoute(game.titleId, tab))}
|
||||
/>
|
||||
</WorkbenchProvider>
|
||||
</div>
|
||||
|
||||
<Dialog open={libraryOpen} onOpenChange={setLibraryOpen}>
|
||||
<Dialog
|
||||
open={libraryOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) goBack(gameRoute(game.titleId));
|
||||
}}
|
||||
>
|
||||
{/* Pinned to the top instead of centered: the list changes length while searching, and a centered dialog would move the search box about under the finger. */}
|
||||
<DialogContent className="top-4 translate-y-0 sm:max-w-xl">
|
||||
<DialogContent className="mt-0 sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Your games</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -455,8 +558,28 @@ function GameLogo(props: { src: string }) {
|
||||
function GameTabs(props: {
|
||||
localCheats: boolean;
|
||||
onGameName: (name: string) => void;
|
||||
/** The tab the address names; null while the address is about something else. */
|
||||
tab: GameTab | null;
|
||||
/** The tab changed here (a click, or a screen sending the person to another tab). */
|
||||
onTab: (tab: GameTab) => void;
|
||||
}) {
|
||||
const wb = useWorkbench();
|
||||
// The address and the workbench each say which tab shows. Whichever changed
|
||||
// last wins: the address after a reload, Back or a link; the workbench after
|
||||
// a click or a "Go to the Code tab" button. Before paint, so the wrong tab never flashes.
|
||||
const synced = useRef<string | null>(null);
|
||||
const { tab: wanted, onTab } = props;
|
||||
useLayoutEffect(() => {
|
||||
// The address is about another screen (the library, Settings): leave it alone.
|
||||
if (!wanted) return;
|
||||
if (wanted !== synced.current) {
|
||||
synced.current = wanted;
|
||||
if (wb.tab !== wanted) wb.setTab(wanted);
|
||||
} else if (wb.tab !== synced.current && isGameTab(wb.tab)) {
|
||||
synced.current = wb.tab;
|
||||
onTab(wb.tab);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Crash protection on the page: the mark on a cheat's row. See crashguard.ts.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { crashMarkText, crashRecord, loadCrashRecord, markOf, subscribeCrashRecords } from '../lib/crashguard.ts';
|
||||
import type { CrashRecord } from '../lib/crashguard.ts';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
/** Launches whose record has been read on this page: one read per launch, however many rows ask. */
|
||||
const loaded = new Set<string>();
|
||||
|
||||
/** The crash record of the game being worked on, read from the console once per launch and kept current after that. */
|
||||
export function useCrashRecord(): CrashRecord | null {
|
||||
const { session, game } = useWorkbench();
|
||||
const titleId = game.titleId;
|
||||
useEffect(() => {
|
||||
const key = `${session.target?.host ?? ''}/${titleId}/${game.pid}`;
|
||||
if (loaded.has(key)) return;
|
||||
loaded.add(key);
|
||||
void loadCrashRecord(session.target, titleId).catch(() => loaded.delete(key));
|
||||
}, [session, titleId, game.pid]);
|
||||
return useSyncExternalStore(subscribeCrashRecords, () => crashRecord(titleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Says on a cheat's row that it may have crashed the game, and what that
|
||||
* means; nothing when it has no mark. `heldDetail` is the PS5's own word when
|
||||
* it held the cheat back, shown if the mark itself could not be read.
|
||||
*/
|
||||
export function CrashMarkNote(props: { name: string; heldDetail?: string }) {
|
||||
const mark = markOf(useCrashRecord(), props.name);
|
||||
const text = mark ? crashMarkText(mark) : props.heldDetail;
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="text-sm text-primary" role="status">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-8
@@ -106,27 +106,26 @@ function PausedBanner(props: { onDebugTab: boolean }) {
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
const bar = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Push the page down by the bar's height so it covers nothing. Dialogs are
|
||||
// centered in the window and would slide under the bar on a short screen
|
||||
// (a phone on its side), hiding their title and close button, so they are
|
||||
// told to center themselves in what is left below it.
|
||||
// Push the page down by the bar's height so it covers nothing. Dialogs would
|
||||
// slide under the bar on a short screen (a phone on its side), hiding their
|
||||
// title and close button, so they are told to keep clear of it too
|
||||
// (--cs-dialog-top, read by the dialog component).
|
||||
useEffect(() => {
|
||||
const el = bar.current;
|
||||
if (!el) return;
|
||||
const before = document.body.style.paddingTop;
|
||||
const rule = document.createElement('style');
|
||||
document.head.appendChild(rule);
|
||||
const root = document.documentElement.style;
|
||||
const fit = () => {
|
||||
const h = el.offsetHeight;
|
||||
document.body.style.paddingTop = `${h}px`;
|
||||
rule.textContent = `[data-slot="dialog-content"]{top:calc(50% + ${h / 2}px) !important;max-height:calc(100dvh - 2rem - ${h}px) !important}`;
|
||||
root.setProperty('--cs-dialog-top', `${h}px`);
|
||||
};
|
||||
fit();
|
||||
const ro = new ResizeObserver(fit);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
rule.remove();
|
||||
root.removeProperty('--cs-dialog-top');
|
||||
document.body.style.paddingTop = before;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// The diagnostics screen (#/settings/diagnostics): the log diag.ts keeps,
|
||||
// how much it keeps, and the two ways to get it off the page.
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { ArrowLeftIcon, CopyIcon, DownloadIcon, Trash2Icon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyText } from '../lib/clipboard.ts';
|
||||
import { DIAG_MAX_LINES, diag, formatLine, isLogSetting } from '../lib/diag.ts';
|
||||
import type { DiagLine, LogSetting } from '../lib/diag.ts';
|
||||
import { loadPref, savePref } from '../lib/prefs.ts';
|
||||
import { goBack } from '../lib/route.ts';
|
||||
import type { Target } from '../lib/session.ts';
|
||||
import { loadJson, saveJson } from '../lib/storage.ts';
|
||||
import { Wordmark } from './bits.tsx';
|
||||
|
||||
/** The settings file on the console: { level: "errors" | "normal" | "everything" }. */
|
||||
export const DIAG_PREF = 'diagnostics';
|
||||
const LOCAL_KEY = 'diagnosticsLevel';
|
||||
/** Drawing thousands of lines on a phone is slow; the screen shows the newest, Copy and Save have them all. */
|
||||
const SHOWN = 400;
|
||||
|
||||
const CHOICES: { value: LogSetting; label: string; detail: string }[] = [
|
||||
{ value: 'errors', label: 'Problems only', detail: 'Errors and warnings.' },
|
||||
{ value: 'normal', label: 'Normal', detail: 'Also connections, cheats switched and cheat files loaded.' },
|
||||
{ value: 'everything', label: 'Everything', detail: 'Also every request to the PS5. Use it when asked to.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Sets the log level from what this browser remembers, then from the
|
||||
* console's copy (which every device shares). Called once when the app starts.
|
||||
*/
|
||||
export async function restoreLogLevel(target: Target | null): Promise<void> {
|
||||
const local = loadJson<unknown>(LOCAL_KEY, null);
|
||||
if (isLogSetting(local)) diag.setSetting(local);
|
||||
const saved = await loadPref<{ level?: unknown }>(target, DIAG_PREF);
|
||||
if (saved && isLogSetting(saved.level)) {
|
||||
diag.setSetting(saved.level);
|
||||
saveJson(LOCAL_KEY, saved.level);
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = (listener: () => void) => diag.subscribe(listener);
|
||||
const revision = () => diag.revision;
|
||||
|
||||
function useLog(): { lines: DiagLine[]; setting: LogSetting; total: number; dropped: number } {
|
||||
const rev = useSyncExternalStore(subscribe, revision);
|
||||
// Copied out of the log only when it changed; `rev` is what says so.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const lines = useMemo(() => diag.lines().slice(-SHOWN), [rev]);
|
||||
return { lines, setting: diag.setting, total: diag.size, dropped: diag.dropped };
|
||||
}
|
||||
|
||||
function fileStamp(d = new Date()): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** What is only known when the log is copied: the screen as it is now, and where the page is. */
|
||||
function reportNow(): string {
|
||||
return diag.report([
|
||||
['Screen', `${window.innerWidth}x${window.innerHeight} at ${window.devicePixelRatio || 1}x`],
|
||||
['Page', location.href],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Saves text as a file: the browser's "Save as" window where there is one, else an ordinary download. */
|
||||
async function saveTextFile(name: string, text: string): Promise<'saved' | 'canceled'> {
|
||||
type Picker = (o: object) => Promise<{ createWritable: () => Promise<{ write: (t: string) => Promise<void>; close: () => Promise<void> }> }>;
|
||||
const picker = (window as unknown as { showSaveFilePicker?: Picker }).showSaveFilePicker;
|
||||
if (typeof picker === 'function') {
|
||||
try {
|
||||
const handle = await picker({ suggestedName: name, types: [{ description: 'Text file', accept: { 'text/plain': ['.txt'] } }] });
|
||||
const out = await handle.createWritable();
|
||||
await out.write(text);
|
||||
await out.close();
|
||||
return 'saved';
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return 'canceled';
|
||||
// Exposed but refused here (plain http, for one): a download still works.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
export function Diagnostics(props: { target: Target | null; onConsole: boolean }) {
|
||||
const { lines, setting, total, dropped } = useLog();
|
||||
const [said, setSaid] = useState<{ text: string; bad?: boolean } | null>(null);
|
||||
const [savedWhere, setSavedWhere] = useState<'console' | 'browser' | null>(null);
|
||||
|
||||
const choose = (value: LogSetting) => {
|
||||
if (value === diag.setting) return;
|
||||
diag.setSetting(value);
|
||||
saveJson(LOCAL_KEY, value);
|
||||
diag.info('page', `Log level set to ${value}`);
|
||||
setSavedWhere(null);
|
||||
void savePref(props.target, DIAG_PREF, { level: value }).then((ok) => setSavedWhere(ok ? 'console' : 'browser'));
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
const ok = await copyText(reportNow());
|
||||
setSaid(ok ? { text: `Copied ${total} ${total === 1 ? 'line' : 'lines'}. Paste it into your message.` } : { text: 'This browser would not copy. Use Save log instead.', bad: true });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const done = await saveTextFile(`cheatspecter-log-${fileStamp()}.txt`, reportNow());
|
||||
setSaid(done === 'saved' ? { text: 'Saved. Look in your downloads (or where you chose).' } : null);
|
||||
} catch (e) {
|
||||
setSaid({ text: `Could not save: ${e instanceof Error ? e.message : String(e)}. Use Copy log instead.`, bad: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-center py-1">
|
||||
<Wordmark />
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => goBack({ screen: 'settings', section: 'main' })}>
|
||||
<ArrowLeftIcon /> Settings
|
||||
</Button>
|
||||
</header>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Diagnostics</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
What CheatSpecter did on this page since it was opened. It stays in this browser, and is gone when the page is closed or reloaded,
|
||||
until you copy or save it. Both include the app's version, your browser and the console at the top.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>How much to keep</CardTitle>
|
||||
<CardDescription>The last {DIAG_MAX_LINES.toLocaleString('en-US')} lines are kept. Changing this applies from now on.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div role="radiogroup" aria-label="How much to keep" className="grid gap-2 sm:grid-cols-3">
|
||||
{CHOICES.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={setting === c.value}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-0.5 rounded-lg border border-input bg-input/30 px-3 py-2 text-left outline-none hover:border-primary/60 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
setting === c.value && 'border-primary bg-primary/10',
|
||||
)}
|
||||
onClick={() => choose(c.value)}
|
||||
>
|
||||
<span className="text-sm font-medium">{c.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{c.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{savedWhere && (
|
||||
<p className="text-xs text-muted-foreground" role="status">
|
||||
{savedWhere === 'console' ? 'Saved on the PS5 for every device.' : 'Saved in this browser only (the PS5 did not keep it).'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => void copy()}>
|
||||
<CopyIcon /> Copy log
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void save()}>
|
||||
<DownloadIcon /> Save log
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
disabled={total === 0}
|
||||
onClick={() => {
|
||||
diag.clear();
|
||||
setSaid({ text: 'Cleared. New lines appear as things happen.' });
|
||||
}}
|
||||
>
|
||||
<Trash2Icon /> Clear
|
||||
</Button>
|
||||
</div>
|
||||
{said && (
|
||||
<p className={cn('text-sm', said.bad ? 'text-destructive' : 'text-muted-foreground')} role="status">
|
||||
{said.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section aria-label="The log" className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{total === 0
|
||||
? 'Nothing written yet.'
|
||||
: `${total} ${total === 1 ? 'line' : 'lines'}, newest first${total > lines.length ? `; the newest ${lines.length} are shown here, Copy and Save have all of them` : ''}${dropped > 0 ? `. ${dropped} older lines were dropped` : ''}.`}
|
||||
</p>
|
||||
{lines.length > 0 && (
|
||||
<ol className="flex flex-col divide-y rounded-md border bg-muted/40 font-mono text-xs" role="log">
|
||||
{lines
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((l, i) => (
|
||||
<li
|
||||
key={`${l.time}-${lines.length - i}`}
|
||||
className={cn('px-3 py-1.5 break-words whitespace-pre-wrap', l.level === 'error' ? 'text-destructive' : l.level === 'warn' ? 'text-primary' : l.level === 'debug' && 'text-muted-foreground')}
|
||||
>
|
||||
{formatLine(l).replace(/^\d{4}-\d\d-\d\d /, '')}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// "Game patches" on the Cheats tab: the community's patches (frame rate,
|
||||
// resolution, debug menus) for exactly the version of the game that is
|
||||
// running, from PS4-PS5-Game-Patch. See gamepatch.ts.
|
||||
//
|
||||
// Patches for the running version are saved into the game's .cspector file on
|
||||
// the PS5 as a "gamepatch" source (TableSource in table.ts), the way a
|
||||
// community table for the exact version is: the next time, they show from
|
||||
// that copy at once and GitHub is only asked, quietly, whether the file has
|
||||
// changed. The switches are the page's own (the workbench's runner, as for
|
||||
// the person's own cheats), so "Restore everything", the start set and crash
|
||||
// protection treat them like any other cheat. A patch also in the person's
|
||||
// own cheats ("Add to your cheats") is switched there and not listed here.
|
||||
|
||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { RefreshCwIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { bytesEqual, hexToBytes } from '../lib/bytes.ts';
|
||||
import {
|
||||
GAME_PATCH_SOURCE,
|
||||
checkGamePatches,
|
||||
findGamePatches,
|
||||
gamePatchOrigin,
|
||||
gamePatchSource,
|
||||
patchesForGame,
|
||||
samePatch,
|
||||
savedGamePatches,
|
||||
} from '../lib/gamepatch.ts';
|
||||
import type { GamePatchCheck, GamePatchEntry, PatchesForGame } from '../lib/gamepatch.ts';
|
||||
import type { GameContext } from '../lib/locations.ts';
|
||||
import { newId, patchSites, resolveSite, withSource, withoutSource } from '../lib/table.ts';
|
||||
import type { Entry, OwnTable, TableSource } from '../lib/table.ts';
|
||||
import type { RestoreLine } from './workbench.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { CrashMarkNote } from './CrashMark.tsx';
|
||||
import { UpdateDialog } from './UpdateDialog.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
/** A first look that would read more patch files than this asks first (PS4 games: the folder has over a hundred). */
|
||||
const ASK_ABOVE = 12;
|
||||
|
||||
type Phase =
|
||||
| { kind: 'waiting' }
|
||||
| { kind: 'looking'; done: number; total: number }
|
||||
| { kind: 'ask'; unread: number }
|
||||
| { kind: 'canceled' }
|
||||
| { kind: 'failed'; reason: string }
|
||||
| { kind: 'ready'; result: PatchesForGame };
|
||||
|
||||
/** An update check: running, its answer, or why it could not be done. */
|
||||
interface UpdateState {
|
||||
checking: boolean;
|
||||
/** Pressed by the person (not the quiet check after opening): only then "Up to date" or why it failed is shown. */
|
||||
asked: boolean;
|
||||
check?: GamePatchCheck;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every place of a patch already holds its bytes and nothing says
|
||||
* what was there before: applied when the game started, by another tool.
|
||||
*/
|
||||
async function alreadyIn(ctx: GameContext, entry: Entry): Promise<boolean> {
|
||||
if (entry.type !== 'patch') return false;
|
||||
const sites = patchSites(entry);
|
||||
if (sites.some((s) => s.off)) return false;
|
||||
try {
|
||||
for (const site of sites) {
|
||||
const on = hexToBytes(site.on);
|
||||
const { address } = await resolveSite(ctx, site);
|
||||
if (!bytesEqual(await ctx.dbg().read(ctx.pid, address, on.length), on)) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const versionList = (versions: string[]): string => {
|
||||
const v = versions.map((x) => `v${x}`);
|
||||
return v.length < 2 ? v.join('') : `${v.slice(0, -1).join(', ')} and ${v[v.length - 1]}`;
|
||||
};
|
||||
|
||||
const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1);
|
||||
|
||||
/** " on Oct 11, 2026, 2:05 PM", or "" when the date is unreadable. */
|
||||
function savedDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (!iso || Number.isNaN(d.getTime())) return '';
|
||||
return ` on ${d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}`;
|
||||
}
|
||||
|
||||
/** The table with the saved game patches replaced by `fresh` (which may be under another file name), start set entries following along. */
|
||||
function replacePatches(t: OwnTable, old: TableSource, fresh: TableSource): OwnTable {
|
||||
if (old.id === fresh.id) return withSource(t, fresh);
|
||||
const startSet = (t.startSet ?? []).map((s) => (s.from === old.id ? { ...s, from: fresh.id } : s));
|
||||
return withSource({ ...withoutSource(t, old.id), startSet: startSet.length ? startSet : undefined }, fresh);
|
||||
}
|
||||
|
||||
export function GamePatches(props: {
|
||||
/** Patches for this version may be saved into the game's file without asking (see CheatsTab). */
|
||||
autoSave: boolean;
|
||||
/** The console keeps cheat files, so the patches can be saved there when the person asks. */
|
||||
canSave: boolean;
|
||||
/** The game's cheat file, e.g. "PPSA01575-01.020.000.cspector". */
|
||||
fileName: string;
|
||||
/** Called when the person saves them by hand. */
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const wb = useWorkbench();
|
||||
const { game, ctx, runner } = wb;
|
||||
const fileReady = wb.ps5.status !== 'checking';
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'waiting' });
|
||||
/** Bumped by "Try again" and "Look again". */
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
/** The person agreed to read a whole folder of patch files. */
|
||||
const [readAll, setReadAll] = useState(false);
|
||||
const looking = useRef<AbortController | null>(null);
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
/** Bumped when patches are found on in the game without this page switching them, so "Restore everything" hears of them. */
|
||||
const [adoptions, noteAdopted] = useReducer((n: number) => n + 1, 0);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [update, setUpdate] = useState<UpdateState | null>(null);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
const [already, setAlready] = useState<Set<string>>(() => new Set());
|
||||
const alive = useRef(true);
|
||||
useEffect(() => () => void (alive.current = false), []);
|
||||
|
||||
// What the effect below reads without running again when it changes.
|
||||
const latest = useRef({ table: wb.table, autoSave: props.autoSave, name: '' });
|
||||
latest.current = { table: wb.table, autoSave: props.autoSave, name: wb.table.name };
|
||||
|
||||
const saved = savedGamePatches(wb.table.sources, game.version);
|
||||
const hasSaved = saved !== null;
|
||||
|
||||
// Look the game up once its cheat file has been read. With the patches saved
|
||||
// there they show at once, and this only checks quietly for a newer file.
|
||||
useEffect(() => {
|
||||
if (!fileReady) return;
|
||||
const stop = new AbortController();
|
||||
looking.current = stop;
|
||||
const have = savedGamePatches(latest.current.table.sources, game.version);
|
||||
if (have) {
|
||||
// What cannot be used, and the other versions, come from the check; the saved list is already showing.
|
||||
setPhase((p) => (p.kind === 'ready' ? p : { kind: 'waiting' }));
|
||||
setUpdate((u) => (u?.check ? u : { checking: true, asked: false }));
|
||||
(async () => {
|
||||
const check = await checkGamePatches(have, game, { ctx, gameName: have.name ?? latest.current.name, signal: stop.signal, maxReads: ASK_ABOVE });
|
||||
if (stop.signal.aborted) return;
|
||||
setPhase({ kind: 'ready', result: check.result });
|
||||
setUpdate({ checking: false, asked: false, check });
|
||||
})().catch(() => {
|
||||
// The saved copy works the same; a check that could not be done is only mentioned when asked for.
|
||||
if (!stop.signal.aborted) setUpdate({ checking: false, asked: false });
|
||||
});
|
||||
return () => stop.abort();
|
||||
}
|
||||
setPhase({ kind: 'looking', done: 0, total: 0 });
|
||||
(async () => {
|
||||
const lookup = await findGamePatches(game.titleId, {
|
||||
gameName: latest.current.name,
|
||||
signal: stop.signal,
|
||||
maxReads: readAll ? undefined : ASK_ABOVE,
|
||||
onProgress: (done, total) => {
|
||||
if (!stop.signal.aborted) setPhase({ kind: 'looking', done, total });
|
||||
},
|
||||
});
|
||||
if (stop.signal.aborted) return;
|
||||
if (!lookup.file && lookup.unread > 0) {
|
||||
setPhase({ kind: 'ask', unread: lookup.unread });
|
||||
return;
|
||||
}
|
||||
// Reads what the game holds at each place now, so switching off can put it back.
|
||||
const result = await patchesForGame(game, { lookup, ctx });
|
||||
if (stop.signal.aborted) return;
|
||||
// For exactly this version: into the game's file, so they open from there next time.
|
||||
const src = latest.current.autoSave ? gamePatchSource(result, game) : null;
|
||||
if (src) {
|
||||
// The rows show the saved copy from here on: the very entries just saved.
|
||||
result.entries = src.cheats as GamePatchEntry[];
|
||||
wb.setTable((t) => (savedGamePatches(t.sources, game.version) ? t : withSource(t, src)));
|
||||
}
|
||||
setPhase({ kind: 'ready', result });
|
||||
})().catch((e) => {
|
||||
if (!stop.signal.aborted) setPhase({ kind: 'failed', reason: errorText(e) });
|
||||
});
|
||||
return () => stop.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileReady, hasSaved, game, ctx, attempt, readAll]);
|
||||
|
||||
const ready = phase.kind === 'ready' ? phase : null;
|
||||
// The saved copy when there is one, else what the look found.
|
||||
const entries: Entry[] = saved ? saved.cheats : (ready?.result.entries ?? []);
|
||||
const refused = ready?.result.refused ?? [];
|
||||
const otherVersions = ready?.result.otherVersions ?? [];
|
||||
const file = saved ? { path: saved.file, url: saved.url ?? '' } : ready?.result.source ? { path: ready.result.source.path, url: ready.result.source.url } : null;
|
||||
|
||||
// Patches already among the person's own cheats are switched there, not here.
|
||||
const own = wb.table.entries;
|
||||
const inMine = (e: Entry) => {
|
||||
const key = gamePatchOrigin(e)?.key;
|
||||
return own.some((o) => samePatch(o, e) || (key !== undefined && gamePatchOrigin(o)?.key === key));
|
||||
};
|
||||
const shown = entries.filter((e) => !inMine(e));
|
||||
const mineCount = entries.length - shown.length;
|
||||
|
||||
// Which are in the game already (applied at boot by another tool), and which
|
||||
// were switched on before a reload or by the PS5's start set: looked at again
|
||||
// when the list changes, or when the PS5 reports switching something on.
|
||||
const entriesKey = entries.map((e) => e.id).join(',');
|
||||
const onByPs5 = (wb.engine?.cheats ?? []).filter((c) => c.state === 'on').map((c) => c.name).join('\n');
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
useEffect(() => {
|
||||
const list = entriesRef.current;
|
||||
if (list.length === 0) {
|
||||
setAlready((a) => (a.size ? new Set() : a));
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
(async () => {
|
||||
const found = new Set<string>();
|
||||
for (const e of list) if (await alreadyIn(ctx, e)) found.add(e.id);
|
||||
if (canceled) return;
|
||||
setAlready(found);
|
||||
if (await runner.adopt(list).catch(() => false)) noteAdopted();
|
||||
})();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [entriesKey, ctx, runner, onByPs5]);
|
||||
|
||||
// "Restore everything" switches off what was switched on here.
|
||||
const { registerRestorer } = wb;
|
||||
const shownRef = useRef(shown);
|
||||
shownRef.current = shown;
|
||||
const alreadyRef = useRef(already);
|
||||
alreadyRef.current = already;
|
||||
useEffect(() => {
|
||||
const active = () => shownRef.current.filter((e) => runner.isOn(e.id) && !alreadyRef.current.has(e.id));
|
||||
return registerRestorer({
|
||||
active: () => active().map((e) => e.name),
|
||||
restore: async () => {
|
||||
const lines: RestoreLine[] = [];
|
||||
for (const e of active().reverse()) {
|
||||
try {
|
||||
await runner.disable(e);
|
||||
lines.push({ label: e.name, result: 'restored' });
|
||||
} catch (err) {
|
||||
lines.push({ label: e.name, result: 'failed', detail: errorText(err) });
|
||||
}
|
||||
}
|
||||
bump();
|
||||
return lines;
|
||||
},
|
||||
});
|
||||
// Registered again when something was found on, which also has the workbench look again at what is on.
|
||||
}, [runner, registerRestorer, adoptions]);
|
||||
|
||||
const toggle = (entry: Entry, on: boolean) => {
|
||||
setNote(null);
|
||||
void wb.toggle(entry, on);
|
||||
};
|
||||
|
||||
const addToCheats = (entry: Entry) => {
|
||||
// Among the person's own cheats it is theirs: no origin, like anything they made.
|
||||
const { origin: _from, ...rest } = entry;
|
||||
const copy = { ...rest, id: newId() } as Entry;
|
||||
wb.setTable((t) => ({ ...t, entries: [...t.entries, copy] }));
|
||||
// On already: the copy shows as on too, and is switched off from there.
|
||||
if (runner.isOn(entry.id)) void runner.adopt([copy]).then((changed) => changed && noteAdopted());
|
||||
setNote(`Added "${entry.name}" to your cheats, at the top of this page. There it is saved along with your own cheats, and your PS5 can keep it on.`);
|
||||
};
|
||||
|
||||
const saveNow = () => {
|
||||
if (!ready) return;
|
||||
const src = gamePatchSource(ready.result, game);
|
||||
if (!src) return;
|
||||
wb.setTable((t) => withSource(t, src));
|
||||
props.onSaved?.();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
looking.current?.abort();
|
||||
setPhase({ kind: 'canceled' });
|
||||
};
|
||||
|
||||
const checkNow = async () => {
|
||||
if (!saved) return;
|
||||
setUpdate({ checking: true, asked: true });
|
||||
try {
|
||||
const check = await checkGamePatches(saved, game, { ctx, gameName: saved.name, fresh: true });
|
||||
if (!alive.current) return;
|
||||
setPhase({ kind: 'ready', result: check.result });
|
||||
setUpdate({ checking: false, asked: true, check });
|
||||
if (!check.same && check.fresh) setReviewing(true);
|
||||
} catch (e) {
|
||||
if (alive.current) setUpdate({ checking: false, asked: true, error: errorText(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const offer = update?.check && !update.check.same ? update.check : null;
|
||||
const applyUpdate = () => {
|
||||
setReviewing(false);
|
||||
if (!offer?.fresh || !saved) return;
|
||||
const fresh = offer.fresh;
|
||||
const n = offer.changes.added.length + offer.changes.changed.length + offer.changes.removed.length;
|
||||
wb.setTable((t) => replacePatches(t, saved, fresh));
|
||||
setUpdate(null);
|
||||
setNote(`Updated the game patches saved in ${props.fileName}: ${n} ${n === 1 ? 'patch' : 'patches'} changed. Your own cheats were not touched.`);
|
||||
};
|
||||
|
||||
const looked = ready !== null || saved !== null;
|
||||
|
||||
return (
|
||||
<Card aria-labelledby="game-patches-title">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="game-patches-title" className="font-semibold">
|
||||
Game patches
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changes like a higher frame rate or resolution, each made for one exact version of a game.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(phase.kind === 'looking' || (phase.kind === 'waiting' && !saved)) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" />
|
||||
<span className="min-w-0 flex-1">
|
||||
{phase.kind === 'looking' && phase.total > 0
|
||||
? `Looking for game patches for ${game.titleId}: checked ${phase.done} of ${phase.total} patch files…`
|
||||
: `Looking for game patches for ${game.titleId}…`}
|
||||
</span>
|
||||
{phase.kind === 'looking' && (
|
||||
<Button variant="outline" size="sm" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'ask' && (
|
||||
<div className="flex flex-col items-start gap-2 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Finding this game's patches means checking {phase.unread} patch files the first time (about a megabyte). After that it is quick.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setReadAll(true)}>
|
||||
Look for game patches
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'canceled' && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Stopped looking for game patches.</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Look again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'failed' && (
|
||||
<Notice kind="warn">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{/limiting/.test(phase.reason)
|
||||
? `Game patches could not be checked: ${phase.reason}.`
|
||||
: `Game patches could not be checked (${phase.reason}). The phone or computer showing this page downloads them, so it needs to be online.`}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setAttempt((n) => n + 1)}>
|
||||
<RotateCcwIcon /> Try again
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{looked && (
|
||||
<>
|
||||
{!saved && ready?.result.stale && <Notice>Offline: showing the copy of the game patches kept in this browser.</Notice>}
|
||||
{!saved && ready && !ready.result.source && <p className="text-sm text-muted-foreground">There are no game patches for {game.titleId} yet.</p>}
|
||||
{!saved && ready?.result.source && entries.length === 0 && refused.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{otherVersions.length > 0
|
||||
? `There are game patches for ${versionList(otherVersions)} of this game, but none for v${game.version || '?'}, the version you are running. They change fixed places in the game, so they are only offered for the version they were made for.`
|
||||
: `There are no game patches for v${game.version || '?'} of this game.`}
|
||||
</p>
|
||||
)}
|
||||
{(shown.length > 0 || refused.length > 0) && (
|
||||
<ul className="divide-y">
|
||||
{shown.map((e) => (
|
||||
<PatchRow
|
||||
key={e.id}
|
||||
entry={e}
|
||||
on={runner.isOn(e.id) || already.has(e.id)}
|
||||
already={already.has(e.id)}
|
||||
busy={wb.busy === e.id}
|
||||
locked={wb.busy !== null && wb.busy !== e.id}
|
||||
error={wb.errors[e.id] ?? null}
|
||||
onToggle={(on) => toggle(e, on)}
|
||||
onAdd={() => addToCheats(e)}
|
||||
/>
|
||||
))}
|
||||
{refused.map((r, i) => (
|
||||
<li key={`refused-${i}`} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium break-words text-muted-foreground">{r.name || 'Game patch'}</div>
|
||||
<div className="text-sm text-muted-foreground">{r.reason}</div>
|
||||
</div>
|
||||
<Switch aria-label={r.name || 'Game patch'} checked={false} disabled />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{mineCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mineCount === 1
|
||||
? `"${entries.find(inMine)?.name}" is in your cheats above, so it is not listed here.`
|
||||
: mineCount === entries.length
|
||||
? `All ${mineCount} game patches for this version are in your cheats above.`
|
||||
: `${mineCount} more are in your cheats above, so they are not listed here.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Where the list stands with the game's cheat file. */}
|
||||
{saved ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="break-words">
|
||||
Saved in {props.fileName} on your PS5{savedDate(saved.savedAt)}; opens with no download.
|
||||
</span>
|
||||
<Button variant="outline" size="xs" disabled={update?.checking} onClick={() => void checkNow()}>
|
||||
{update?.checking ? <Spinner className="size-3" /> : <RefreshCwIcon />} {update?.checking ? 'Checking…' : 'Check for updates'}
|
||||
</Button>
|
||||
{update?.asked && !update.checking && update.check?.same && <span role="status">Up to date.</span>}
|
||||
</div>
|
||||
) : (
|
||||
ready &&
|
||||
entries.length > 0 &&
|
||||
props.canSave && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Not saved on your PS5, so they are looked up again each time.</span>
|
||||
<Button variant="outline" size="xs" onClick={saveNow}>
|
||||
Save them into {props.fileName}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{update?.asked && update.error && (
|
||||
<Notice kind="warn">
|
||||
{/limiting/.test(update.error)
|
||||
? `Updates could not be checked: ${update.error}.`
|
||||
: `This device could not reach GitHub, so updates could not be checked (${update.error}). Check that it is online, then try again.`}{' '}
|
||||
The copy saved on your PS5 keeps working.
|
||||
</Notice>
|
||||
)}
|
||||
{update?.asked && update.check && !update.check.same && !update.check.fresh && (
|
||||
<Notice kind="warn">
|
||||
The game patches collection no longer has patches for v{game.version || '?'} of this game. The copy saved on your PS5 keeps working.
|
||||
</Notice>
|
||||
)}
|
||||
{offer?.fresh && !reviewing && (
|
||||
<Notice>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>The game patches for this game have changed since they were saved.</span>
|
||||
<Button size="sm" onClick={() => setReviewing(true)}>
|
||||
Review update
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
{note && <Notice>{note}</Notice>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From{' '}
|
||||
<a className="text-primary underline underline-offset-2" href={GAME_PATCH_SOURCE.home} target="_blank" rel="noreferrer">
|
||||
{GAME_PATCH_SOURCE.name}
|
||||
</a>
|
||||
{file && file.url && (
|
||||
<>
|
||||
{' '}
|
||||
(
|
||||
<a className="text-primary underline underline-offset-2 break-all" href={file.url} target="_blank" rel="noreferrer">
|
||||
{baseName(file.path)}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
, downloaded by this device.
|
||||
</p>
|
||||
</CardContent>
|
||||
<UpdateDialog
|
||||
check={reviewing && offer?.fresh ? { fresh: { file: baseName(offer.fresh.file), sourceName: offer.fresh.sourceName }, changes: offer.changes } : null}
|
||||
onCancel={() => setReviewing(false)}
|
||||
onUpdate={applyUpdate}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PatchRow(props: {
|
||||
entry: Entry;
|
||||
on: boolean;
|
||||
/** Applied before this page saw the game, with nothing known to put back. */
|
||||
already: boolean;
|
||||
busy: boolean;
|
||||
locked: boolean;
|
||||
error: string | null;
|
||||
onToggle: (on: boolean) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const { entry } = props;
|
||||
const anyVersion = /^(mask|all)/i.test(gamePatchOrigin(entry)?.version ?? '');
|
||||
return (
|
||||
<li className="flex flex-col gap-1.5 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 font-medium break-words">{entry.name}</span>
|
||||
{anyVersion && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Any version
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{entry.note && <div className="text-sm break-words text-muted-foreground">{entry.note}</div>}
|
||||
</div>
|
||||
{props.busy && <Spinner className="size-4 text-muted-foreground" />}
|
||||
<Switch
|
||||
aria-label={entry.name}
|
||||
checked={props.on}
|
||||
disabled={props.already || props.busy || props.locked}
|
||||
onCheckedChange={(on) => props.onToggle(on)}
|
||||
/>
|
||||
</div>
|
||||
{props.already && (
|
||||
<div className="text-sm text-primary" role="status">
|
||||
Already in the game: it was applied when the game started, most likely by etaHEN's own game patch setting. Switch it off there; CheatSpecter does not know what to put back.
|
||||
</div>
|
||||
)}
|
||||
<CrashMarkNote name={entry.name} />
|
||||
{props.error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{props.error}
|
||||
</div>
|
||||
)}
|
||||
{!props.already && (
|
||||
<Button className="self-start" variant="outline" size="sm" disabled={props.busy} onClick={props.onAdd}>
|
||||
Add to your cheats
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+51
-3
@@ -119,6 +119,15 @@ export interface LearnPageProps {
|
||||
onConsole: boolean;
|
||||
/** A lesson to open right away, when another screen sent the user here for it. */
|
||||
lesson?: string | null;
|
||||
/** The step of `lesson` to show, counting from 0 (from the address); null for where the learner left off. */
|
||||
step?: number | null;
|
||||
/**
|
||||
* Given, the lesson and step shown are kept in the address: the page reports
|
||||
* every move here and follows `lesson` and `step` when they change (Back,
|
||||
* Forward, a link). `fill` marks a correction nobody asked for (the step a
|
||||
* lesson resumes at), which should not add a history entry.
|
||||
*/
|
||||
onPlace?: (lesson: string | null, step: number | null, fill: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -128,7 +137,10 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
const library = libraryFor(props.preferLocal);
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [index, setIndex] = useState<Loaded>({ state: 'loading' });
|
||||
const [open, setOpen] = useState<string | null>(props.lesson ?? null);
|
||||
const [openHere, setOpenHere] = useState<string | null>(props.lesson ?? null);
|
||||
const { onPlace } = props;
|
||||
const open = onPlace ? (props.lesson ?? null) : openHere;
|
||||
const setOpen = useCallback((id: string | null) => (onPlace ? onPlace(id, null, false) : setOpenHere(id)), [onPlace]);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -183,6 +195,14 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
body = <Waiting>Downloading the lessons…</Waiting>;
|
||||
} else if (index.state === 'failed') {
|
||||
body = <DownloadFailed error={index.error} onRetry={() => setAttempt((n) => n + 1)} onSwitchOff={() => change((p) => ({ ...p, lessons: 'off' }))} />;
|
||||
} else if (open && !index.data.index.courses.some((c) => c.lessons.some((l) => l.id === open))) {
|
||||
// An address naming a lesson that is not (or no longer) in the list.
|
||||
body = (
|
||||
<>
|
||||
<Notice kind="warn">There is no lesson called "{open}". It may have been renamed. Pick one from the list.</Notice>
|
||||
<LessonList loaded={index.data} progress={progress} onOpen={setOpen} onSwitchOff={() => change((p) => ({ ...p, lessons: 'off' }))} />
|
||||
</>
|
||||
);
|
||||
} else if (open) {
|
||||
const all = index.data.index.courses.flatMap((c) => c.lessons);
|
||||
const at = all.findIndex((l) => l.id === open);
|
||||
@@ -198,6 +218,8 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
onOpen={setOpen}
|
||||
onList={() => setOpen(null)}
|
||||
onLeave={props.connected ? props.onClose : undefined}
|
||||
step={props.step ?? null}
|
||||
onStep={onPlace ? (at, fill) => onPlace(open, at, fill) : undefined}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -471,6 +493,10 @@ function LessonView(props: {
|
||||
onList: () => void;
|
||||
/** Goes back to the connected console, when there is one, to try it for real. */
|
||||
onLeave?: () => void;
|
||||
/** The step the address names (from 0), or null. */
|
||||
step: number | null;
|
||||
/** Reports the step on show; `fill` when it was not the learner's move. */
|
||||
onStep?: (at: number, fill: boolean) => void;
|
||||
}) {
|
||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||
const [error, setError] = useState<LearnError | null>(null);
|
||||
@@ -490,9 +516,10 @@ function LessonView(props: {
|
||||
(l) => {
|
||||
if (canceled) return;
|
||||
setLesson(l);
|
||||
// Pick up where the learner left off; a finished lesson starts over.
|
||||
// The step the address names; otherwise pick up where the learner left off. A finished lesson starts over.
|
||||
const reached = saved.current;
|
||||
setAt(reached && !reached.finished ? Math.min(reached.steps, l.steps.length - 1) : 0);
|
||||
const asked = stepRef.current;
|
||||
setAt(asked !== null ? Math.min(asked, l.steps.length) : reached && !reached.finished ? Math.min(reached.steps, l.steps.length - 1) : 0);
|
||||
},
|
||||
(e) => !canceled && setError(e instanceof LearnError ? e : new LearnError('offline', String(e))),
|
||||
);
|
||||
@@ -501,6 +528,26 @@ function LessonView(props: {
|
||||
};
|
||||
}, [props.library, props.id, attempt]);
|
||||
|
||||
const stepRef = useRef(props.step);
|
||||
stepRef.current = props.step;
|
||||
/** The last step on show was reached by the learner's own move (Next, Back, a dot), not by the address. */
|
||||
const moved = useRef(false);
|
||||
// The address changed (Back, Forward, a link): show the step it names.
|
||||
useEffect(() => {
|
||||
if (!lesson || props.step === null) return;
|
||||
const want = Math.min(props.step, lesson.steps.length);
|
||||
setAt((now) => (now === want ? now : want));
|
||||
}, [props.step, lesson]);
|
||||
// And the other way: every step shown goes into the address.
|
||||
// Only a new step counts, not a new callback: after Back the address is already right.
|
||||
const onStep = useRef(props.onStep);
|
||||
onStep.current = props.onStep;
|
||||
useEffect(() => {
|
||||
if (!lesson) return;
|
||||
onStep.current?.(at, !moved.current);
|
||||
moved.current = false;
|
||||
}, [at, lesson]);
|
||||
|
||||
const heading = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
@@ -553,6 +600,7 @@ function LessonView(props: {
|
||||
const total = lesson.steps.length;
|
||||
const go = (index: number) => {
|
||||
reach(Math.min(index, total), index >= total);
|
||||
moved.current = true;
|
||||
setAt(index);
|
||||
};
|
||||
|
||||
|
||||
+77
-45
@@ -23,14 +23,15 @@ import { codeListing, isCodeEntry } from '../lib/codecheat.ts';
|
||||
import { patchLocating, refindTable } from '../lib/proof.ts';
|
||||
import type { RefindOutcome, RefindResult } from '../lib/proof.ts';
|
||||
import { loadJson, saveJson } from '../lib/storage.ts';
|
||||
import { describeEntry, exportCommunityTable, groupNames, isAction, newId } from '../lib/table.ts';
|
||||
import { Ps5StorageUnavailable, deviceFileText, saveToDevice, tableFileName } from '../lib/tablestore.ts';
|
||||
import { atRawAddress, describeEntry, exportCommunityTable, groupNames, isAction, newId } from '../lib/table.ts';
|
||||
import { deviceFileText, saveToDevice, tableFileName } from '../lib/tablestore.ts';
|
||||
import { exportMc4, exportShn } from '../lib/trainer.ts';
|
||||
import type { Entry, PatchEntry } from '../lib/table.ts';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { EntryExtras } from './CodeCheatEditor.tsx';
|
||||
import { FirstCheatWizard } from './Wizard.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
import { CrashMarkNote } from './CrashMark.tsx';
|
||||
|
||||
const SCRIPT_TEMPLATE = `// Keep health topped up. Change the address to your own.
|
||||
const health = await cs.pointer("game+10100", 0x340);
|
||||
@@ -58,6 +59,12 @@ function blank(type: Entry['type']): Entry {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a saved copy left out, for the message after saving; "" when nothing was. */
|
||||
export function leftOutNote(left: number): string {
|
||||
if (!left) return '';
|
||||
return ` ${left} ${left === 1 ? 'cheat uses' : 'cheats use'} a raw memory address, which only means something until the game restarts, so ${left === 1 ? 'it was' : 'they were'} left out.`;
|
||||
}
|
||||
|
||||
/** A question about a group that needs a name typed, or a group picked. */
|
||||
type GroupTask = { kind: 'move'; entry: Entry } | { kind: 'rename'; group: string };
|
||||
|
||||
@@ -73,7 +80,12 @@ export function Keycap(props: { hotkey: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OwnCheats() {
|
||||
/**
|
||||
* `fileBar`: the page shows the game's cheat file (with its own Save) above
|
||||
* this card, as the Cheats tab does; without one (the practice game) this
|
||||
* card has the Save button itself.
|
||||
*/
|
||||
export function OwnCheats(props: { fileBar?: boolean } = {}) {
|
||||
const wb = useWorkbench();
|
||||
const { table, runner } = wb;
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -99,36 +111,12 @@ export function OwnCheats() {
|
||||
setWarning(warn);
|
||||
};
|
||||
|
||||
const saveOnPs5 = async () => {
|
||||
say(null);
|
||||
try {
|
||||
await wb.saveToPs5();
|
||||
say(`Saved on your PS5 as ${fileName}. It loads by itself whenever this game is running, and your changes are saved to it as you make them.`);
|
||||
} catch (e) {
|
||||
say(
|
||||
null,
|
||||
e instanceof Ps5StorageUnavailable && wb.session.target?.label === 'Practice game'
|
||||
? 'The practice game is not on a PS5, so there is nowhere on a console to save its cheats. "Save to this device" works here.'
|
||||
: e instanceof Ps5StorageUnavailable
|
||||
? 'Saving on the PS5 needs the CheatSpecter payload running on the console. Send payload/cheatspecter.elf to the PS5 (the same way you load ps5debug), then save again. "Save to this device" works without it.'
|
||||
: `The PS5 did not save the file. ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const saveOnDevice = async () => {
|
||||
say(null);
|
||||
const full = wb.fileTable();
|
||||
const text = deviceFileText(full);
|
||||
const left = full.entries.length - (JSON.parse(text) as { cheats: unknown[] }).cheats.length;
|
||||
try {
|
||||
const name = await saveToDevice(fileName, text);
|
||||
if (name) {
|
||||
say(
|
||||
`Saved as ${name}. Drop that file onto this page to load it again.` +
|
||||
(left ? ` ${left} ${left === 1 ? 'cheat uses' : 'cheats use'} a raw memory address, which only means something until the game restarts, so ${left === 1 ? 'it was' : 'they were'} left out.` : ''),
|
||||
);
|
||||
}
|
||||
const full = wb.fileTable();
|
||||
const name = await saveToDevice(fileName, deviceFileText(full));
|
||||
if (name) say(`Saved as ${name}. Drop that file onto this page to load it again.` + leftOutNote(full.entries.filter(atRawAddress).length));
|
||||
} catch (e) {
|
||||
say(null, `The file could not be saved. ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
@@ -166,10 +154,14 @@ export function OwnCheats() {
|
||||
|
||||
const where =
|
||||
ps5.status === 'saved'
|
||||
? `Saved on your PS5 as ${fileName}.`
|
||||
? `Saved on your PS5 as ${fileName}, as you make changes.`
|
||||
: ps5.status === 'saving'
|
||||
? 'Saving to your PS5…'
|
||||
: 'Kept in this browser for now. Use Save to put them in a file on your PS5 or this device.';
|
||||
: ps5.status === 'none'
|
||||
? `Saved on your PS5 as ${fileName} with your next change.`
|
||||
: wb.session.target?.label === 'Practice game'
|
||||
? 'Kept in this browser. Use Save to keep a copy on this device.'
|
||||
: 'Kept in this browser for now: your PS5 is not answering about files. Use Save to keep a copy on this device.';
|
||||
|
||||
const groups = groupNames(table);
|
||||
const loose = table.entries.filter((e) => !e.group);
|
||||
@@ -196,17 +188,19 @@ export function OwnCheats() {
|
||||
: where}
|
||||
</CardDescription>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{table.entries.length > 0 && !props.fileBar && (
|
||||
<Button variant="outline" size="sm" onClick={() => void saveOnDevice()} title={`Download ${fileName} to this device`}>
|
||||
<SaveIcon /> Save
|
||||
</Button>
|
||||
)}
|
||||
{table.entries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<SaveIcon /> Save
|
||||
<Button variant="outline" size="icon-sm" aria-label="More for your cheats">
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => void saveOnPs5()}>Save to PS5</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void saveOnDevice()}>Save to this device…</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void exportCommunity()}>Export for GoldHEN / etaHEN (simple patches)</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void exportTrainer('shn')}>Same, as an .shn file</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void exportTrainer('mc4')}>Same, as an .mc4 file</DropdownMenuItem>
|
||||
@@ -248,13 +242,13 @@ export function OwnCheats() {
|
||||
{wb.saveFailed && ps5.status !== 'saved' && ps5.status !== 'saving' && (
|
||||
<Notice kind="warn">This browser would not keep your cheats (its storage is full or blocked). Use Save before closing the page.</Notice>
|
||||
)}
|
||||
{ps5.status === 'failed' && !warning && (
|
||||
<Notice kind="warn">Your last changes did not reach the file on the PS5{ps5.error ? ` (${ps5.error})` : ''}. Use Save to try again.</Notice>
|
||||
{ps5.status === 'failed' && !warning && !props.fileBar && (
|
||||
<Notice kind="warn">Your last changes did not reach the file on the PS5{ps5.error ? ` (${ps5.error})` : ''}. Your next change tries again.</Notice>
|
||||
)}
|
||||
{ps5.loadedFrom && (
|
||||
<Notice>
|
||||
These were loaded from the file you saved for v{ps5.loadedFrom} of this game. Check they still work, then use Save to PS5 to keep a
|
||||
file for this version.
|
||||
These were loaded from the file saved for v{ps5.loadedFrom} of this game. Check they still work; your next change saves them as this
|
||||
version's own file.
|
||||
</Notice>
|
||||
)}
|
||||
{otherVersion && (
|
||||
@@ -487,6 +481,7 @@ const KEPT_WORDS: Record<string, string> = {
|
||||
waiting: 'waiting',
|
||||
mismatch: 'does not fit',
|
||||
error: 'problem',
|
||||
held: 'held back',
|
||||
};
|
||||
|
||||
const KEPT_FALLBACK: Record<string, string> = {
|
||||
@@ -526,6 +521,41 @@ function EntryRow(props: { entry: Entry; onGroup: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Deleting cannot be undone (the file on the PS5 is saved straight after), so it asks first, saying what goes with it.
|
||||
const confirmDelete = async () => {
|
||||
const consoleFile = wb.ps5.status !== 'unavailable';
|
||||
const inStart = (wb.table.startSet ?? []).some((s) => s.from === undefined && s.name === entry.name);
|
||||
const parts = [
|
||||
consoleFile
|
||||
? `It is removed from your cheats and from ${tableFileName(wb.game.titleId, wb.game.version)} on your PS5.`
|
||||
: 'It is removed from your cheats.',
|
||||
on && !isAction(entry) ? 'It is switched off first.' : '',
|
||||
entry.keep ? 'Your PS5 stops keeping it on.' : '',
|
||||
inStart ? 'It also leaves the start set.' : '',
|
||||
'This cannot be undone; to keep a copy, use Save first.',
|
||||
];
|
||||
const choice = await wb.ask({
|
||||
title: `Delete "${entry.name}"?`,
|
||||
body: parts.filter(Boolean).join(' '),
|
||||
actions: [{ id: 'delete', label: 'Delete cheat', variant: 'destructive' }],
|
||||
});
|
||||
if (choice !== 'delete') return;
|
||||
await wb.removeEntry(entry);
|
||||
// Its row, and the button the dialog was opened from, are gone: the focus goes to the list's own menu button (or New, when the list is empty now).
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== document.body) return;
|
||||
const more = document.querySelector<HTMLElement>('[aria-label="More for your cheats"]');
|
||||
(more ?? [...document.querySelectorAll<HTMLElement>('button')].find((b) => b.textContent?.trim() === 'New'))?.focus();
|
||||
}, 50);
|
||||
if (!inStart) return;
|
||||
// Unless another of your cheats has the same name, which the start set then means.
|
||||
wb.setTable((t) => {
|
||||
if (t.entries.some((e) => e.name === entry.name)) return t;
|
||||
const startSet = (t.startSet ?? []).filter((s) => !(s.from === undefined && s.name === entry.name));
|
||||
return { ...t, startSet: startSet.length ? startSet : undefined };
|
||||
});
|
||||
};
|
||||
|
||||
let how = describeEntry(entry);
|
||||
let plain = false;
|
||||
if (entry.type === 'patch') {
|
||||
@@ -626,8 +656,8 @@ function EntryRow(props: { entry: Entry; onGroup: () => void }) {
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void wb.removeEntry(entry)}>
|
||||
Delete
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void confirmDelete()}>
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -649,7 +679,9 @@ function EntryRow(props: { entry: Entry; onGroup: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kept && kept.state !== 'on' && (
|
||||
{/* A cheat the PS5 held back for crash protection says why here, once. */}
|
||||
<CrashMarkNote name={entry.name} heldDetail={kept?.state === 'held' ? kept.detail : undefined} />
|
||||
{kept && kept.state !== 'on' && kept.state !== 'held' && (
|
||||
<div className="text-sm text-primary" role="status">
|
||||
{kept.detail || KEPT_FALLBACK[kept.state]}
|
||||
</div>
|
||||
@@ -724,7 +756,7 @@ function RefindDialog(props: { onClose: () => void; onSaved: (text: string) => v
|
||||
(found.length > 0 ? `${found.length} ${found.length === 1 ? 'cheat now points' : 'cheats now point'} at ${found.length === 1 ? 'its' : 'their'} new place. ` : '') +
|
||||
(version ? `Your cheats are now marked as made for v${version}. ` : '') +
|
||||
(lost.length > 0 ? `${lost.length} could not be found and ${lost.length === 1 ? 'needs' : 'need'} making again. ` : '') +
|
||||
(wb.ps5.status === 'saved' ? '' : 'Use Save to keep this in a file.'),
|
||||
(wb.ps5.status === 'saved' || wb.ps5.status === 'none' ? '' : 'Use Save to keep a copy on this device.'),
|
||||
);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// The controller shortcut setting: switch it on, pick the buttons and how long
|
||||
// to hold them, and see what the PS5 is doing about it. Self-contained, so the
|
||||
// Settings screen only has to render <PadShortcutSetting target={...} />.
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import {
|
||||
PAD_BUTTONS,
|
||||
PAD_HOLD_CHOICES,
|
||||
PAD_MAX_BUTTONS,
|
||||
PAD_MIN_BUTTONS,
|
||||
describeCombo,
|
||||
describeHold,
|
||||
loadPadShortcut,
|
||||
loadPadStatus,
|
||||
savePadShortcut,
|
||||
sortButtons,
|
||||
validCombo,
|
||||
} from '../lib/padshortcut.ts';
|
||||
import type { PadShortcut, PadStatus } from '../lib/padshortcut.ts';
|
||||
import type { Target } from '../lib/session.ts';
|
||||
import { Notice } from './bits.tsx';
|
||||
|
||||
const SAVE_FAILED = 'Could not save this on the PS5. Check that CheatSpecter is still running on it, then try again.';
|
||||
|
||||
export function PadShortcutSetting(props: { target: Target | null }) {
|
||||
const { target } = props;
|
||||
const switchId = useId();
|
||||
const holdId = useId();
|
||||
const [saved, setSaved] = useState<PadShortcut | null>(null);
|
||||
/** The buttons picked on screen; only saved once they make a combination the PS5 accepts. */
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<PadStatus | null>(null);
|
||||
const alive = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
let canceled = false;
|
||||
void loadPadShortcut(target).then((s) => {
|
||||
if (canceled) return;
|
||||
setSaved(s);
|
||||
setPicked(s.buttons);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
alive.current = false;
|
||||
};
|
||||
}, [target]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
const s = await loadPadStatus(target);
|
||||
if (alive.current) setStatus(s);
|
||||
return s;
|
||||
}, [target]);
|
||||
|
||||
// The PS5's side changes by itself (a controller turned on, the browser opened), so it is asked again every few seconds.
|
||||
// Not when the first answer was "cannot say" (a computer bridge or an older payload): asking again would only fill the browser's log with failures.
|
||||
useEffect(() => {
|
||||
let timer = 0;
|
||||
let canceled = false;
|
||||
void refreshStatus().then((s) => {
|
||||
if (canceled || !s) return;
|
||||
timer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void refreshStatus();
|
||||
}, 3000);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
const save = useCallback(
|
||||
async (next: PadShortcut) => {
|
||||
const before = saved;
|
||||
setSaved(next);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const ok = await savePadShortcut(target, next).catch(() => false);
|
||||
if (!alive.current) return;
|
||||
setSaving(false);
|
||||
if (!ok) {
|
||||
setSaved(before);
|
||||
if (before) setPicked(before.buttons);
|
||||
setError(SAVE_FAILED);
|
||||
return;
|
||||
}
|
||||
if (status) void refreshStatus();
|
||||
},
|
||||
[saved, status, target, refreshStatus],
|
||||
);
|
||||
|
||||
if (!saved) {
|
||||
return (
|
||||
<section aria-labelledby={switchId} className="flex flex-col gap-3">
|
||||
<h3 id={switchId} className="text-base font-semibold">
|
||||
Controller shortcut
|
||||
</h3>
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Reading the setting from the PS5…
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const combo = describeCombo(saved.buttons);
|
||||
const pickedOk = validCombo(picked);
|
||||
const full = picked.length >= PAD_MAX_BUTTONS;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = picked.includes(id) ? picked.filter((b) => b !== id) : sortButtons([...picked, id]);
|
||||
setPicked(next);
|
||||
setError(null);
|
||||
if (validCombo(next) && describeCombo(next) !== combo) void save({ ...saved, buttons: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h3 className="text-base font-semibold">
|
||||
<Label htmlFor={switchId} className="text-base font-semibold">
|
||||
Controller shortcut
|
||||
</Label>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hold <strong className="text-foreground">{combo}</strong> for {describeHold(saved.holdMs)} to open CheatSpecter in the PS5's web
|
||||
browser, on the Cheats screen of the game you are playing.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
className="mt-1 shrink-0"
|
||||
checked={saved.enabled}
|
||||
disabled={saving}
|
||||
onCheckedChange={(enabled) => void save({ ...saved, enabled })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Notice kind="warn">
|
||||
Use at your own risk. CheatRunner, a similar tool, reports that watching the controller like this can make the PS5's home
|
||||
screen crash on some system software (4.xx). If the home screen acts up after you switch this on, switch it off here. If the PS5
|
||||
stops while the shortcut is starting, CheatSpecter pauses the shortcut until you choose to try again here.
|
||||
</Notice>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-2 text-sm font-medium">Buttons to hold together</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PAD_BUTTONS.map((b) => {
|
||||
const on = picked.includes(b.id);
|
||||
return (
|
||||
<Button
|
||||
key={b.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={on ? 'default' : 'outline'}
|
||||
className="h-10 min-w-12"
|
||||
aria-pressed={on}
|
||||
disabled={saving || (!on && full)}
|
||||
onClick={() => toggle(b.id)}
|
||||
>
|
||||
{b.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{pickedOk && full
|
||||
? `${PAD_MAX_BUTTONS} buttons is the most. To use a different one, first tap a picked button to remove it.`
|
||||
: pickedOk
|
||||
? 'The game still gets every press, so pick buttons you do not hold together while playing.'
|
||||
: picked.length < PAD_MIN_BUTTONS
|
||||
? `Pick at least ${PAD_MIN_BUTTONS} buttons. Until then the shortcut stays ${combo}.`
|
||||
: `Pick at most ${PAD_MAX_BUTTONS} buttons.`}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:max-w-xs">
|
||||
<Label htmlFor={holdId}>How long to hold them</Label>
|
||||
<Select value={String(saved.holdMs)} disabled={saving} onValueChange={(v) => void save({ ...saved, holdMs: Number(v) })}>
|
||||
<SelectTrigger id={holdId} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...new Set([...PAD_HOLD_CHOICES, saved.holdMs])]
|
||||
.sort((a, b) => a - b)
|
||||
.map((ms) => (
|
||||
<SelectItem key={ms} value={String(ms)}>
|
||||
{describeHold(ms)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{saving && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" /> Saving on the PS5…
|
||||
</p>
|
||||
)}
|
||||
{!saving && <PadStatusLine status={status} saved={saved} onRetry={() => void save({ ...saved, enabled: true })} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the PS5 says it is doing, in a sentence. Nothing when it cannot be asked (an older payload, or a computer bridge). */
|
||||
function PadStatusLine(props: { status: PadStatus | null; saved: PadShortcut; onRetry: () => void }) {
|
||||
const { status, saved } = props;
|
||||
if (!status || !saved.enabled) return null;
|
||||
const combo = describeCombo(saved.buttons);
|
||||
switch (status.state) {
|
||||
case 'listening':
|
||||
return (
|
||||
<Notice kind="info">
|
||||
Ready. Hold {combo} for {describeHold(saved.holdMs)} on the controller to open CheatSpecter on the PS5.
|
||||
{status.fired > 0 && ` Used ${status.fired} ${status.fired === 1 ? 'time' : 'times'} since CheatSpecter started.`}
|
||||
</Notice>
|
||||
);
|
||||
case 'starting':
|
||||
case 'off':
|
||||
// "off" right after switching on: the PS5 has not read the new setting yet.
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" /> Starting the shortcut on the PS5…
|
||||
</p>
|
||||
);
|
||||
case 'no-controller':
|
||||
return <Notice kind="warn">{status.detail || 'Waiting for a controller. Turn one on and sign in.'}</Notice>;
|
||||
case 'paused':
|
||||
return (
|
||||
<Notice kind="warn">
|
||||
<span className="flex flex-col items-start gap-2">
|
||||
<span>The shortcut is paused: the PS5 stopped while it was starting last time. Try again only if the home screen has been fine since.</span>
|
||||
<Button type="button" size="sm" variant="outline" onClick={props.onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</span>
|
||||
</Notice>
|
||||
);
|
||||
case 'unavailable':
|
||||
return <Notice kind="warn">{status.detail || 'CheatSpecter on this PS5 cannot read the controller.'}</Notice>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Settings: reached from the gear button at the top of every screen
|
||||
// (#/settings). Holds the diagnostics log (its own screen,
|
||||
// #/settings/diagnostics) and the way to stop CheatSpecter on the PS5.
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { ArrowLeftIcon, ChevronRightIcon, PowerOffIcon, ScrollTextIcon, UnplugIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { navigate } from '../lib/route.ts';
|
||||
import type { SettingsSection } from '../lib/route.ts';
|
||||
import type { ServerInfo, Target } from '../lib/session.ts';
|
||||
import { Notice, Safe, Wordmark, errorText } from './bits.tsx';
|
||||
import { Diagnostics } from './Diagnostics.tsx';
|
||||
|
||||
// Settings other parts of the app add as their own small component, picked up
|
||||
// here when the file exists (the controller shortcut lives in
|
||||
// PadShortcutSetting.tsx). A missing file is simply nothing to show.
|
||||
const extras = import.meta.glob<{ default?: ComponentType<SettingProps>; PadShortcutSetting?: ComponentType<SettingProps> }>('./PadShortcutSetting.tsx', {
|
||||
eager: true,
|
||||
});
|
||||
const PadShortcut = Object.values(extras).map((m) => m.PadShortcutSetting ?? m.default)[0] ?? null;
|
||||
|
||||
/** What a setting component placed on this screen is given. */
|
||||
export interface SettingProps {
|
||||
/** The console being talked to through the computer's bridge; null when the PS5 serves the page. */
|
||||
target: Target | null;
|
||||
/** Served by the PS5 itself (so its payload is right there to configure). */
|
||||
onConsole: boolean;
|
||||
}
|
||||
|
||||
export function Settings(props: {
|
||||
section: SettingsSection;
|
||||
info: ServerInfo | null;
|
||||
target: Target | null;
|
||||
connected: boolean;
|
||||
/** CheatSpecter was stopped on the PS5 from here. */
|
||||
stopped: boolean;
|
||||
/** Stops the payload on the PS5. Throws with words for the person when it does not. */
|
||||
onShutDown: () => Promise<void>;
|
||||
/** Through a computer's bridge: leaves this console, back to the list of consoles. */
|
||||
onDisconnect?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const back = props.connected ? 'Back to your PS5' : 'Back';
|
||||
if (props.section === 'diagnostics') {
|
||||
return <Diagnostics target={props.target} onConsole={props.info?.mode === 'console'} />;
|
||||
}
|
||||
const onConsole = props.info?.mode === 'console';
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-center py-1">
|
||||
<Wordmark />
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={props.onClose}>
|
||||
<ArrowLeftIcon /> {back}
|
||||
</Button>
|
||||
</header>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Diagnostics</CardTitle>
|
||||
<CardDescription>
|
||||
A record of what CheatSpecter did on this page: connections, cheats switched, cheat files loaded, and anything that went wrong.
|
||||
Copy or save it to send along when you report a problem.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => navigate({ screen: 'settings', section: 'diagnostics' })}>
|
||||
<ScrollTextIcon /> Open diagnostics <ChevronRightIcon />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{PadShortcut && (
|
||||
<Safe what="the controller shortcut setting">
|
||||
<PadShortcut target={props.target} onConsole={onConsole} />
|
||||
</Safe>
|
||||
)}
|
||||
|
||||
{props.connected && !onConsole && props.onDisconnect && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connection</CardTitle>
|
||||
<CardDescription>
|
||||
Connected to {props.target ? `${props.target.label ?? 'a PS5'} (${props.target.host})` : 'a PS5'} through this computer. Disconnect to pick another
|
||||
console.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={props.onDisconnect}>
|
||||
<UnplugIcon /> Disconnect
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ShutDownCard onConsole={onConsole} stopped={props.stopped} onShutDown={props.onShutDown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShutDownCard(props: { onConsole: boolean; stopped: boolean; onShutDown: () => Promise<void> }) {
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cancel = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const stop = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await props.onShutDown();
|
||||
setAsking(false);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CheatSpecter on the PS5</CardTitle>
|
||||
<CardDescription>
|
||||
{props.onConsole
|
||||
? 'Stops the CheatSpecter payload on your PS5, for example before loading another payload. You load it again to use CheatSpecter.'
|
||||
: 'This page is served by your computer, not by the PS5, so it cannot stop CheatSpecter there. Open the page from the PS5 address to do that.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{props.onConsole && (
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
{props.stopped ? (
|
||||
<p className="text-sm" role="status">
|
||||
CheatSpecter has stopped on the PS5. Load the payload again to reconnect.
|
||||
</p>
|
||||
) : (
|
||||
<Button variant="destructive" onClick={() => setAsking(true)}>
|
||||
<PowerOffIcon /> Shut down CheatSpecter on the PS5
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<Dialog open={asking} onOpenChange={(open) => !busy && setAsking(open)}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
// The safe choice has the focus, so Enter or a stray tap does not stop anything.
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
cancel.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Shut down CheatSpecter on the PS5?</DialogTitle>
|
||||
<DialogDescription>This stops the CheatSpecter payload on your PS5. Until you load it again:</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="ml-5 list-disc text-sm text-muted-foreground">
|
||||
<li>this page, and any other device showing it, loses its connection</li>
|
||||
<li>cheats set to "Keep on" are no longer kept on, and values this page holds stop being held</li>
|
||||
<li>changes already made in the game stay until the game restarts</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">The game keeps running, and ps5debug stays loaded.</p>
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
<DialogFooter>
|
||||
<Button ref={cancel} variant="outline" disabled={busy} onClick={() => setAsking(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={busy} onClick={() => void stop()}>
|
||||
{busy ? <Spinner className="size-4" /> : <PowerOffIcon />} {busy ? 'Shutting down…' : 'Shut down'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Asks before replacing a table saved in the game's cheat file with a newer
|
||||
// copy from where it came from (a community table, a file on the console,
|
||||
// the game patches), listing what changes.
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
/** What the dialog needs to know about an update: the new copy's file and source, and what changes. */
|
||||
export interface UpdateOffer {
|
||||
fresh: { file: string; sourceName: string };
|
||||
changes: { added: string[]; changed: string[]; removed: string[] };
|
||||
}
|
||||
|
||||
/** Shows what an update to a saved table changes, and asks before replacing it. */
|
||||
export function UpdateDialog(props: { check: UpdateOffer | null; onCancel: () => void; onUpdate: () => void }) {
|
||||
const c = props.check?.changes;
|
||||
const list = (title: string, names: string[]) =>
|
||||
names.length > 0 && (
|
||||
<section className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{title} ({names.length})
|
||||
</h3>
|
||||
<ul className="ml-4 list-disc text-sm">
|
||||
{names.map((n, i) => (
|
||||
<li key={`${n}-${i}`} className="break-words">
|
||||
{n}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<Dialog open={props.check !== null} onOpenChange={(open) => !open && props.onCancel()}>
|
||||
{props.check && c && (
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update these cheats?</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
"{props.check.fresh.file}" from {props.check.fresh.sourceName} has changed since it was saved on your PS5. Updating replaces the saved
|
||||
copy with the new one. Your own cheats are not touched.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{list('New', c.added)}
|
||||
{list('Changed', c.changed)}
|
||||
{list('Removed', c.removed)}
|
||||
{c.added.length + c.changed.length + c.removed.length === 0 && <p className="text-sm">The same cheats, in a different order.</p>}
|
||||
<p className="text-sm text-muted-foreground">Cheats that are on in the game stay as they are; the list is read again after updating.</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={props.onCancel}>
|
||||
Not now
|
||||
</Button>
|
||||
<Button onClick={props.onUpdate}>Update</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+8
-4
@@ -122,9 +122,11 @@ function WizardBody(props: { onClose: () => void }) {
|
||||
aria-describedby={undefined}
|
||||
// A stray tap beside the sheet must not throw away a search that took minutes.
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
// The dialog's own height cap and scrolling are for ordinary dialogs; this sheet fills the screen and scrolls its middle part only.
|
||||
// It becomes a centered card only on a screen that is both wide and tall: a phone on its side is wide but far too short for one.
|
||||
className="top-0 left-0 flex h-dvh max-h-none max-w-none overflow-hidden sm:max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 [@media(min-width:40rem)_and_(min-height:34rem)]:top-[50%] [@media(min-width:40rem)_and_(min-height:34rem)]:left-[50%] [@media(min-width:40rem)_and_(min-height:34rem)]:h-[min(46rem,92dvh)] [@media(min-width:40rem)_and_(min-height:34rem)]:max-w-md [@media(min-width:40rem)_and_(min-height:34rem)]:translate-x-[-50%] [@media(min-width:40rem)_and_(min-height:34rem)]:translate-y-[-50%] [@media(min-width:40rem)_and_(min-height:34rem)]:rounded-lg [@media(min-width:40rem)_and_(min-height:34rem)]:border"
|
||||
// Ordinary dialogs scroll as a whole; this sheet fills the screen (below the paused-game bar, when there is one) and scrolls its
|
||||
// middle part only, so its buttons stay put. Pinned to the window's edges rather than given a height, so it fits even where `dvh`
|
||||
// is not understood. It becomes a centered card only on a screen that is both wide and tall: a phone on its side is wide but far
|
||||
// too short for one.
|
||||
className="fixed inset-x-0 bottom-0 top-[var(--cs-dialog-top,0px)] m-0 flex max-w-none flex-col gap-0 overflow-hidden rounded-none border-0 p-0 sm:max-w-none [@media(min-width:40rem)_and_(min-height:34rem)]:relative [@media(min-width:40rem)_and_(min-height:34rem)]:inset-auto [@media(min-width:40rem)_and_(min-height:34rem)]:m-auto [@media(min-width:40rem)_and_(min-height:34rem)]:h-[min(46rem,calc(100vh-2rem-var(--cs-dialog-top,0px)))] [@media(min-width:40rem)_and_(min-height:34rem)]:max-w-md [@media(min-width:40rem)_and_(min-height:34rem)]:rounded-lg [@media(min-width:40rem)_and_(min-height:34rem)]:border"
|
||||
>
|
||||
{!session.extended ? (
|
||||
<Unsupported onClose={props.onClose} />
|
||||
@@ -841,7 +843,9 @@ function Screens(props: { flow: FirstCheatFlow; state: WizardState; onClose: ()
|
||||
<Para className="text-sm text-muted-foreground">
|
||||
{onPs5
|
||||
? `It is stored on your PS5 as ${tableFileName(wb.game.titleId, wb.game.version)}.`
|
||||
: 'For now it is kept in this browser. Use Save in "Your cheats" to put it in a file on your PS5.'}
|
||||
: wb.ps5.status === 'none'
|
||||
? `It is being saved on your PS5 as ${tableFileName(wb.game.titleId, wb.game.version)}.`
|
||||
: 'For now it is kept in this browser, because your PS5 is not keeping cheat files right now. Use Save to keep a copy on this device.'}
|
||||
</Para>
|
||||
)}
|
||||
</Frame>
|
||||
|
||||
+48
-10
@@ -10,9 +10,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { hotkeyFromEvent } from '../lib/hotkeys.ts';
|
||||
import { diag } from '../lib/diag.ts';
|
||||
import { WriteJournal } from '../lib/journal.ts';
|
||||
import type { MemoryAccess } from '../lib/journal.ts';
|
||||
import { fetchEngineStatus, keptStatus, reloadEngine } from '../lib/keep.ts';
|
||||
import { noteSwitchedOn } from '../lib/crashguard.ts';
|
||||
import type { EngineStatus, KeptCheat } from '../lib/keep.ts';
|
||||
import { createGameContext } from '../lib/locations.ts';
|
||||
import type { GameContext } from '../lib/locations.ts';
|
||||
@@ -100,12 +102,20 @@ export interface Workbench extends EngineAccess {
|
||||
/** The table as it should be written to a file: current game version, and the game's name. */
|
||||
fileTable: () => OwnTable;
|
||||
setTable: (update: (t: OwnTable) => OwnTable) => void;
|
||||
/** Puts `next` in place of the table without writing it to the console, dropping a write still waiting. For after the file there was deleted. */
|
||||
dropTable: (next: OwnTable) => void;
|
||||
/** Adds an entry and, unless told otherwise, switches it on. */
|
||||
addEntry: (entry: Entry, opts?: { enable?: boolean }) => Promise<void>;
|
||||
/** Replaces the entry with the same id, undoing the old version first if it is on. */
|
||||
replaceEntry: (entry: Entry) => Promise<void>;
|
||||
/** Switches an entry as is. The row switch and hotkeys use `flip`, which also looks after "keep on". */
|
||||
toggle: (entry: Entry, on: boolean, force?: boolean) => Promise<void>;
|
||||
/**
|
||||
* Switches an entry as is. The row switch and hotkeys use `flip`, which also
|
||||
* looks after "keep on". `auto` is for switching on that the person did not
|
||||
* ask for just now (the start set): crash protection then keeps a mark the
|
||||
* cheat has, instead of taking it away as a switch by hand does. Whoever
|
||||
* passes it checks mayAutoSwitch() (crashguard.ts) first.
|
||||
*/
|
||||
toggle: (entry: Entry, on: boolean, force?: boolean, opts?: { auto?: boolean }) => Promise<void>;
|
||||
/**
|
||||
* What the row switch does. Switching off a cheat the PS5 keeps on also
|
||||
* turns "keep on" off for it, after asking once; otherwise the console
|
||||
@@ -427,6 +437,8 @@ export function WorkbenchProvider(props: {
|
||||
const version = pickVersion(found.versions, game.version);
|
||||
const saved = version ? await loadPs5Table(session.target, game.titleId, version) : null;
|
||||
if (canceled) return;
|
||||
// The console keeps the files: from here on every change is saved to this version's file, made when first needed.
|
||||
linked.current = true;
|
||||
if (!saved || !version) {
|
||||
setPs5({ status: 'none', folder: found.folder });
|
||||
return;
|
||||
@@ -434,12 +446,10 @@ export function WorkbenchProvider(props: {
|
||||
const exact = version === fileVersion(game.version);
|
||||
if (touched.current) {
|
||||
// The user got there first; their edits stand, and go to the file if it is this version's.
|
||||
linked.current = exact;
|
||||
setPs5({ status: exact ? 'saved' : 'none', folder: found.folder });
|
||||
if (exact) void writeToPs5().catch(() => {});
|
||||
return;
|
||||
}
|
||||
linked.current = exact;
|
||||
const next: OwnTable = { ...saved, titleId: game.titleId };
|
||||
tableRef.current = next;
|
||||
setTableState(next);
|
||||
@@ -458,6 +468,15 @@ export function WorkbenchProvider(props: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session, game.titleId, game.version]);
|
||||
|
||||
const dropTable = useCallback((next: OwnTable) => {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = undefined;
|
||||
tableRef.current = next;
|
||||
setTableState(next);
|
||||
saveOwnTable(next);
|
||||
setPs5((p) => ({ folder: p.folder, status: p.status === 'unavailable' ? p.status : 'none' }));
|
||||
}, []);
|
||||
|
||||
const setTable = useCallback(
|
||||
(update: (t: OwnTable) => OwnTable) => {
|
||||
touched.current = true;
|
||||
@@ -491,7 +510,7 @@ export function WorkbenchProvider(props: {
|
||||
const tellTv = useCallback((name: string, change: TvChange) => tv.add(name, change), [tv]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (entry: Entry, on: boolean, force = false) => {
|
||||
async (entry: Entry, on: boolean, force = false, opts: { auto?: boolean } = {}) => {
|
||||
touched.current = true;
|
||||
setBusy(entry.id);
|
||||
setErrors((prev) => {
|
||||
@@ -502,6 +521,8 @@ export function WorkbenchProvider(props: {
|
||||
if (on) {
|
||||
if (entry.type === 'script') setLogs((prev) => ({ ...prev, [entry.id]: [] }));
|
||||
await runner.enable(entry, { force });
|
||||
// Noted for crash protection; switched on by hand, that also clears a mark it may have.
|
||||
if (!practice && !isAction(entry) && entry.type !== 'shared') void noteSwitchedOn(session.target, game, entry.name, { byHand: !opts.auto });
|
||||
} else {
|
||||
// On in the game without this page having switched it (the console did): find it first, so there is something to undo.
|
||||
if (entry.type === 'patch' && !runner.isOn(entry.id)) await runner.adopt([entry]);
|
||||
@@ -512,12 +533,13 @@ export function WorkbenchProvider(props: {
|
||||
if (entry.type !== 'shared') tv.add(entry.name, on ? (isAction(entry) ? 'ran' : 'on') : 'off');
|
||||
} catch (e) {
|
||||
setErrors((prev) => ({ ...prev, [entry.id]: errorText(e) }));
|
||||
diag.warn('cheats', `Could not switch ${on ? 'on' : 'off'} "${entry.name}": ${errorText(e)}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
bump();
|
||||
}
|
||||
},
|
||||
[runner, bump, tv],
|
||||
[runner, bump, tv, session, game, practice],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -579,6 +601,7 @@ export function WorkbenchProvider(props: {
|
||||
(q: { title: string; body: string; actions: AskAction[] }) => new Promise<string | null>((resolve) => setQuestion({ ...q, resolve })),
|
||||
[],
|
||||
);
|
||||
const askCancel = useRef<HTMLButtonElement>(null);
|
||||
const answer = (id: string | null) => {
|
||||
question?.resolve(id);
|
||||
setQuestion(null);
|
||||
@@ -859,6 +882,12 @@ export function WorkbenchProvider(props: {
|
||||
return prev && prev.pid === game.pid && prev.titleId === game.titleId ? [...new Set([...prev.earlier, ...prev.items])] : [];
|
||||
});
|
||||
const activity = JSON.stringify([onNow, lastChange.current?.time ?? 0, props.gameName]);
|
||||
// Every change made to the game also goes in the diagnostics log.
|
||||
const changedAt = lastChange.current?.time ?? 0;
|
||||
useEffect(() => {
|
||||
const c = lastChange.current;
|
||||
if (c && !practice) diag.info('cheats', `${game.titleId}: ${c.text}`);
|
||||
}, [changedAt, practice, game.titleId]);
|
||||
useEffect(() => {
|
||||
if (practice) return;
|
||||
const before = earlier.filter((x) => !onNow.includes(x));
|
||||
@@ -895,6 +924,7 @@ export function WorkbenchProvider(props: {
|
||||
saveToPs5: writeToPs5,
|
||||
fileTable,
|
||||
setTable,
|
||||
dropTable,
|
||||
addEntry,
|
||||
replaceEntry,
|
||||
toggle,
|
||||
@@ -945,13 +975,21 @@ export function WorkbenchProvider(props: {
|
||||
{props.children}
|
||||
<Dialog open={question !== null} onOpenChange={(open) => !open && answer(null)}>
|
||||
{question && (
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onOpenAutoFocus={(e) => {
|
||||
// A question that can destroy something starts on Cancel: Enter alone must never do it.
|
||||
if (!question.actions.some((a) => a.variant === 'destructive')) return;
|
||||
e.preventDefault();
|
||||
askCancel.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{question.title}</DialogTitle>
|
||||
<DialogDescription>{question.body}</DialogDescription>
|
||||
<DialogTitle className="break-words">{question.title}</DialogTitle>
|
||||
<DialogDescription className="break-words">{question.body}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => answer(null)}>
|
||||
<Button ref={askCancel} variant={question.actions.some((a) => a.variant === 'destructive') ? 'outline' : 'ghost'} onClick={() => answer(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{question.actions.map((a) => (
|
||||
|
||||
@@ -1251,3 +1251,31 @@ test('without Keystone installed, rules and hand-written cheats still work on th
|
||||
runner.dispose();
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('scripts: searching memory for a value and reading text', async () => {
|
||||
const { ctx, m, done } = await setup([]);
|
||||
try {
|
||||
const api = { setEntry: async () => undefined };
|
||||
const call = (method, ...args) => handleScriptCall(ctx, api, method, args);
|
||||
const heap = BigInt(GAME.heapBase ?? 0);
|
||||
const region = (await ctx.maps(true)).find((r) => r.start === heap) ?? null;
|
||||
assert.ok(region, 'the mock game has a heap block');
|
||||
// A value written twice, far enough apart to tell the limit and the range apart.
|
||||
const marker = 0x5a17c0de0badf00dn;
|
||||
m.game.heap.writeBigUInt64LE(marker, 0x3E000);
|
||||
m.game.heap.writeBigUInt64LE(marker, 0x3F000);
|
||||
m.game.heap.write('item_craftingmaterial_fiber_007_c\0junk', 0x3F800, 'utf8');
|
||||
const all = await call('search', 'u64', marker.toString());
|
||||
assert.deepEqual(all, [heap + 0x3E000n, heap + 0x3F000n]);
|
||||
assert.deepEqual(await call('search', 'u64', marker.toString(), { limit: 1 }), [heap + 0x3E000n]);
|
||||
assert.deepEqual(await call('search', 'u64', marker.toString(), { from: heap + 0x3E800n }), [heap + 0x3F000n]);
|
||||
assert.deepEqual(await call('search', 'u64', marker.toString(), { from: heap + 0x3E800n, to: heap + 0x3F000n }), []);
|
||||
await assert.rejects(call('search', 'u64', '1', { from: heap + 0x3E010n, to: heap + 0x3E000n }), /"to" must be above "from"/);
|
||||
await assert.rejects(call('search', 'u64', '1', { limit: 0 }), /"limit" must be between 1/);
|
||||
assert.equal(await call('readText', heap + 0x3F800n), 'item_craftingmaterial_fiber_007_c');
|
||||
assert.equal(await call('readText', heap + 0x3F800n, 4), 'item');
|
||||
await assert.rejects(call('readText', heap + 0x3F800n, 0), /between 1 and 65536/);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
// Crash protection: a cheat switched on less than a minute before the game
|
||||
// ended is marked, and is not switched on by itself again until the person
|
||||
// switches it on by hand. The record rules (src/lib/crashguard.ts) are tested
|
||||
// on their own; then the keep-on engine in the payload, built for this
|
||||
// computer and pointed at the mock console, together with the page's client,
|
||||
// talking to the payload through /api/prefs exactly as the web app does.
|
||||
// The payload parts are skipped when there is no C compiler or the web app is
|
||||
// not built.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startMock, GAME } from '../mock/mock-ps5.mjs';
|
||||
import {
|
||||
CRASH_WINDOW_MS,
|
||||
WATCH_GAP_MS,
|
||||
clearCrashMark,
|
||||
crashMarkText,
|
||||
crashPrefName,
|
||||
crashRecord,
|
||||
emptyRecord,
|
||||
heldBackText,
|
||||
loadCrashRecord,
|
||||
markOf,
|
||||
mayAutoSwitch,
|
||||
noteGameEnded,
|
||||
noteSwitchedOn,
|
||||
parseCrashRecord,
|
||||
withGameEnded,
|
||||
withSwitchedOn,
|
||||
} from '../src/lib/crashguard.ts';
|
||||
import { keptOverview, keptStatus, parseEngineStatus } from '../src/lib/keep.ts';
|
||||
import { tableFileName } from '../src/lib/tablestore.ts';
|
||||
|
||||
// ---- the record --------------------------------------------------------------------
|
||||
|
||||
test('the settings name is per game, and only for a real title ID', () => {
|
||||
assert.equal(crashPrefName('PPSA01575'), 'crash-ppsa01575');
|
||||
assert.equal(crashPrefName('cusa00001'), 'crash-cusa00001');
|
||||
assert.throws(() => crashPrefName('../etc'), /not a title ID/);
|
||||
assert.throws(() => crashPrefName(''), /not a title ID/);
|
||||
});
|
||||
|
||||
test('a record from the console is read leniently: only well-formed parts are kept', () => {
|
||||
assert.deepEqual(parseCrashRecord(null), emptyRecord());
|
||||
assert.deepEqual(parseCrashRecord('text'), emptyRecord());
|
||||
assert.deepEqual(parseCrashRecord({ marks: 'x', armed: 5 }), emptyRecord());
|
||||
const r = parseCrashRecord({
|
||||
marks: [
|
||||
{ name: 'A', at: 5, by: 'ps5', version: '01.000.000' },
|
||||
{ name: 'A', at: 9 }, // the same cheat twice: the first one counts
|
||||
{ name: 'B', at: 'soon', by: 'someone' },
|
||||
{ at: 1 },
|
||||
{ name: '' },
|
||||
null,
|
||||
],
|
||||
armed: [{ name: 'C', pid: 7, at: 3 }, { name: 'D', pid: 'x' }, { name: 'E', pid: 1.5 }, { pid: 2 }],
|
||||
extra: true,
|
||||
});
|
||||
assert.deepEqual(r, {
|
||||
marks: [
|
||||
{ name: 'A', at: 5, by: 'ps5', version: '01.000.000' },
|
||||
{ name: 'B', at: 0, by: 'page' },
|
||||
],
|
||||
armed: [{ name: 'C', pid: 7, at: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('switching on by hand clears the mark; switching on by itself does not', () => {
|
||||
let r = { marks: [{ name: 'Ammo', at: 1, by: 'ps5' }], armed: [{ name: 'Old launch', pid: 1, at: 1 }] };
|
||||
assert.equal(mayAutoSwitch(r, 'Ammo'), false);
|
||||
assert.equal(mayAutoSwitch(r, 'Other'), true);
|
||||
const auto = withSwitchedOn(r, 'Ammo', 2, 100, false);
|
||||
assert.ok(markOf(auto, 'Ammo'), 'a start set switching it on would not clear the mark');
|
||||
r = withSwitchedOn(r, 'Ammo', 2, 100, true);
|
||||
assert.equal(markOf(r, 'Ammo'), null);
|
||||
// Only this launch's switches are kept, one per cheat, the latest.
|
||||
r = withSwitchedOn(r, 'Ammo', 2, 150, true);
|
||||
assert.deepEqual(r.armed, [{ name: 'Ammo', pid: 2, at: 150 }]);
|
||||
});
|
||||
|
||||
test('the page marks what it switched on within a minute of the game ending, if it was watching', () => {
|
||||
const t0 = 1_000_000;
|
||||
let r = emptyRecord();
|
||||
r = withSwitchedOn(r, 'Early', 9, t0, true);
|
||||
r = withSwitchedOn(r, 'Late', 9, t0 + 120_000, true);
|
||||
// Last seen running 30 s after "Late" went on, noticed gone 3 s later.
|
||||
const lastSeen = t0 + 150_000;
|
||||
const out = withGameEnded(r, { pid: 9, version: '01.000.000' }, lastSeen, lastSeen + 3000);
|
||||
assert.deepEqual(out.marked, ['Late']);
|
||||
assert.deepEqual(out.record.marks, [{ name: 'Late', at: lastSeen + 3000, by: 'page', version: '01.000.000' }]);
|
||||
assert.deepEqual(out.record.armed, [], 'that launch is over');
|
||||
|
||||
// Exactly at the edge of the window still counts; a moment later does not.
|
||||
r = withSwitchedOn(emptyRecord(), 'Edge', 9, t0, true);
|
||||
assert.deepEqual(withGameEnded(r, { pid: 9 }, t0 + CRASH_WINDOW_MS, t0 + CRASH_WINDOW_MS).marked, ['Edge']);
|
||||
assert.deepEqual(withGameEnded(r, { pid: 9 }, t0 + CRASH_WINDOW_MS + 1, t0 + CRASH_WINDOW_MS + 1).marked, []);
|
||||
|
||||
// A page that was asleep cannot say when the game ended, so it marks nothing (and still drops the launch).
|
||||
r = withSwitchedOn(emptyRecord(), 'Asleep', 9, t0, true);
|
||||
const asleep = withGameEnded(r, { pid: 9 }, t0 + 1000, t0 + 1000 + WATCH_GAP_MS + 1);
|
||||
assert.deepEqual(asleep.marked, []);
|
||||
assert.deepEqual(asleep.record, emptyRecord());
|
||||
|
||||
// Another launch's switches are not this one's.
|
||||
r = withSwitchedOn(emptyRecord(), 'Other launch', 8, t0, true);
|
||||
assert.deepEqual(withGameEnded(r, { pid: 9 }, t0 + 1000, t0 + 2000).marked, []);
|
||||
});
|
||||
|
||||
test('the words on a marked row and in the notice', () => {
|
||||
assert.match(crashMarkText({ name: 'A', at: Date.UTC(2026, 9, 11, 15, 2), by: 'ps5' }), /^May have crashed the game: your PS5 switched it on less than a minute before the game closed \(.+\)\. It will not be switched on by itself again until you switch it on here\.$/);
|
||||
assert.match(crashMarkText({ name: 'A', at: 0, by: 'page' }), /^May have crashed the game: it was switched on less than a minute before the game closed\. /);
|
||||
assert.equal(heldBackText([]), '');
|
||||
assert.match(heldBackText(['Infinite ammo']), /^"Infinite ammo" was not switched on by itself because .* Switch it on by hand to try again\.$/);
|
||||
assert.match(heldBackText(['A', 'B']), /^2 cheats were not switched on by itself .* Switch them on by hand/);
|
||||
});
|
||||
|
||||
test('the summary above the cheats says when the PS5 held one back', () => {
|
||||
const GAME_ID = { titleId: 'PPSA99999', version: '01.000.000' };
|
||||
const st = parseEngineStatus({
|
||||
running: true,
|
||||
crashGuard: true,
|
||||
game: { ...GAME_ID, pid: 3 },
|
||||
file: 'PPSA99999-01.000.000.cspector',
|
||||
exact: true,
|
||||
cheats: [
|
||||
{ name: 'A', state: 'on', detail: '' },
|
||||
{ name: 'B', state: 'held', detail: 'Held back' },
|
||||
],
|
||||
});
|
||||
assert.equal(st.crashGuard, true);
|
||||
assert.equal(keptStatus(st, 'PPSA99999', 'B').state, 'held');
|
||||
const o = keptOverview(st, GAME_ID, 2);
|
||||
assert.equal(o.kind, 'warn');
|
||||
assert.match(o.text, /keeping 1 cheat on by itself.*It did not switch "B" on, because the game closed less than a minute after it was switched on last time\. Switch it on by hand/);
|
||||
// An older engine knows nothing of this.
|
||||
assert.equal(parseEngineStatus({ running: true, game: null, file: '', cheats: [] }).crashGuard, false);
|
||||
});
|
||||
|
||||
// ---- the engine on the console, and the page's client -------------------------------
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const PORT = 19163 + PORT_OFFSET;
|
||||
const MOCK = 17863 + PORT_OFFSET;
|
||||
const EVENTS = 17983 + PORT_OFFSET;
|
||||
// The engine's waits run this many times shorter, crash protection's minute included.
|
||||
const SPEED = 10;
|
||||
const WINDOW = 60000 / SPEED;
|
||||
|
||||
let skip = false;
|
||||
try {
|
||||
if (!fs.existsSync(path.join(ROOT, 'dist/index.html'))) throw new Error('dist missing');
|
||||
execFileSync(process.execPath, [path.join(ROOT, 'scripts/build-payload.mjs'), 'host'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
skip = 'needs a C compiler and a built web app (npm run build)';
|
||||
}
|
||||
|
||||
const FAKE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-crash-'));
|
||||
const CHEATS = path.join(FAKE_ROOT, 'data/cheatspecter/cheats');
|
||||
const PREFS = path.join(FAKE_ROOT, 'data/cheatspecter/prefs');
|
||||
const CRASH_FILE = path.join(PREFS, `${crashPrefName(GAME.titleId)}.json`);
|
||||
const OWN_FILE = tableFileName(GAME.titleId, GAME.version);
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
const status = async () => (await fetch(`${base}/api/engine`)).json();
|
||||
async function until(what, check, ms = 10000) {
|
||||
const deadline = Date.now() + ms;
|
||||
let last;
|
||||
for (;;) {
|
||||
last = await status();
|
||||
if (check(last)) return last;
|
||||
if (Date.now() > deadline) assert.fail(`timed out waiting for ${what}; the engine says ${JSON.stringify(last)}`);
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
const stateOf = (st, name) => st.cheats.find((c) => c.name === name)?.state;
|
||||
const readRecord = () => (fs.existsSync(CRASH_FILE) ? JSON.parse(fs.readFileSync(CRASH_FILE, 'utf8')) : null);
|
||||
|
||||
const hex = (buf) => Buffer.from(buf).toString('hex').toUpperCase();
|
||||
const code = (name) => mock.game.image.subarray(GAME.code[name].offset, GAME.code[name].offset + GAME.code[name].bytes.length);
|
||||
const nopPatch = (name, codeName) => ({
|
||||
name,
|
||||
type: 'patch',
|
||||
keep: true,
|
||||
at: { kind: 'module', offset: `0x${GAME.code[codeName].offset.toString(16)}` },
|
||||
on: '90'.repeat(GAME.code[codeName].bytes.length),
|
||||
off: hex(GAME.code[codeName].bytes),
|
||||
});
|
||||
const heldHealth = {
|
||||
name: 'Health',
|
||||
type: 'value',
|
||||
keep: true,
|
||||
freeze: true,
|
||||
at: { kind: 'pointer', base: `0x${(0x10000 + GAME.statics.player).toString(16)}`, offsets: [`0x${(GAME.vars.health - GAME.player).toString(16)}`] },
|
||||
valueType: 'u32',
|
||||
value: '999',
|
||||
};
|
||||
const fileText = (cheats) => JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: { name: 'Specter Run', titleId: GAME.titleId, version: GAME.version }, cheats });
|
||||
|
||||
let mock, proc;
|
||||
let stderr = '';
|
||||
const notifications = (from = 0) =>
|
||||
stderr
|
||||
.slice(from)
|
||||
.split('[cheatspecter] ')
|
||||
.slice(1)
|
||||
.map((s) => s.trimEnd());
|
||||
|
||||
function resetGame() {
|
||||
const { image, heap, data } = mock.game;
|
||||
image.fill(0xcc);
|
||||
for (const c of Object.values(GAME.code)) {
|
||||
c.bytes.copy(image, c.offset);
|
||||
image[c.offset + c.bytes.length] = 0xc3;
|
||||
}
|
||||
heap.fill(0);
|
||||
heap.writeUInt32LE(100, GAME.vars.health);
|
||||
heap.writeUInt32LE(30, GAME.vars.ammo);
|
||||
data.fill(0);
|
||||
data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.player), GAME.statics.player);
|
||||
data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.world), GAME.statics.world);
|
||||
}
|
||||
|
||||
/** Closes the game (if running) and starts it again under a new process id with `cheats` in its file. */
|
||||
async function launch(cheats, { clearMarks = false, text = null } = {}) {
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.running && st.game === null);
|
||||
fs.mkdirSync(CHEATS, { recursive: true });
|
||||
fs.writeFileSync(path.join(CHEATS, OWN_FILE), text ?? fileText(cheats));
|
||||
if (clearMarks) fs.rmSync(CRASH_FILE, { force: true });
|
||||
resetGame();
|
||||
GAME.pid += 1;
|
||||
mock.state.foreground = true;
|
||||
return until('the new game to be seen', (st) => st.game?.pid === GAME.pid);
|
||||
}
|
||||
|
||||
/** Makes the page's client talk to the payload, as the web app served by it does. `engine: false` hides the engine. */
|
||||
function pageFetch({ engine = true } = {}) {
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith('http')) return real(input, init);
|
||||
if (!engine && url.startsWith('api/engine')) return Promise.resolve(new Response('', { status: 404 }));
|
||||
return real(`${base}/${url}`, init);
|
||||
};
|
||||
return () => (globalThis.fetch = real);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: false, eventPort: EVENTS });
|
||||
await mock.ready;
|
||||
mock.state.foreground = false;
|
||||
proc = spawn(BIN, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
CS_PORT: String(PORT),
|
||||
CS_DEBUGGER_PORT: String(MOCK),
|
||||
CS_EVENT_PORT: String(EVENTS),
|
||||
CS_ROOT: FAKE_ROOT,
|
||||
CS_SHADOWMOUNT_PORT: '1',
|
||||
CS_ENGINE_SPEED: String(SPEED),
|
||||
CS_IDLE_SECONDS: '2',
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr.on('data', (d) => (stderr += d));
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const s = net.connect(PORT, '127.0.0.1');
|
||||
s.once('connect', () => (s.destroy(), resolve(true)));
|
||||
s.once('error', () => resolve(false));
|
||||
});
|
||||
if (ok) break;
|
||||
await sleep(30);
|
||||
}
|
||||
await until('the debugger to be found', (st) => st.running);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (proc) proc.kill();
|
||||
if (mock) await mock.close();
|
||||
fs.rmSync(FAKE_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('the engine says it guards against crashes, even with no game running', { skip }, async () => {
|
||||
const st = await status();
|
||||
assert.equal(st.crashGuard, true);
|
||||
assert.equal(parseEngineStatus(st).crashGuard, true);
|
||||
});
|
||||
|
||||
test('a game that ends soon after the PS5 switched cheats on: they are marked, then held back next time', { skip }, async () => {
|
||||
await launch([nopPatch('Infinite ammo', 'ammoSpend'), heldHealth], { clearMarks: true });
|
||||
await until('both to be on', (st) => stateOf(st, 'Infinite ammo') === 'on' && stateOf(st, 'Health') === 'on');
|
||||
// It crashes a moment later.
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
const record = readRecord();
|
||||
assert.ok(record, 'the engine wrote the record');
|
||||
const parsed = parseCrashRecord(record);
|
||||
assert.deepEqual(parsed.marks.map((m) => [m.name, m.by, m.version]), [
|
||||
['Infinite ammo', 'ps5', GAME.version],
|
||||
['Health', 'ps5', GAME.version],
|
||||
]);
|
||||
for (const m of parsed.marks) assert.ok(Math.abs(m.at - Date.now()) < 60_000, 'marked with the time it happened');
|
||||
|
||||
// Started again: neither is switched on, and the game is left exactly as it starts.
|
||||
const mark = stderr.length;
|
||||
resetGame();
|
||||
GAME.pid += 1;
|
||||
mock.state.foreground = true;
|
||||
const st = await until('both to be held back', (s) => s.game?.pid === GAME.pid && stateOf(s, 'Infinite ammo') === 'held' && stateOf(s, 'Health') === 'held');
|
||||
await sleep(1000);
|
||||
assert.equal(hex(code('ammoSpend')), hex(GAME.code.ammoSpend.bytes));
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
||||
assert.match(st.cheats[0].detail, /^Held back: the game closed less than a minute after this was switched on last time/);
|
||||
for (let i = 0; i < 200 && notifications(mark).length === 0; i++) await sleep(20);
|
||||
assert.deepEqual(notifications(mark), ['CheatSpecter: 2 cheats held back: the game closed soon after they were switched on last time']);
|
||||
const overview = keptOverview(parseEngineStatus(st), GAME, 2);
|
||||
assert.match(overview.text, /It did not switch 2 cheats on/);
|
||||
});
|
||||
|
||||
test('switching a held cheat on by hand clears its mark, and the PS5 takes it back on; a quick crash then marks it as the page\'s', { skip }, async () => {
|
||||
const restore = pageFetch();
|
||||
try {
|
||||
// Carries on from the test above: both are held.
|
||||
await until('both to be held back', (s) => stateOf(s, 'Infinite ammo') === 'held');
|
||||
await loadCrashRecord(null, GAME.titleId);
|
||||
assert.ok(markOf(crashRecord(GAME.titleId), 'Infinite ammo'));
|
||||
|
||||
// The person switches "Infinite ammo" on in the page: the page writes the patch and notes it.
|
||||
code('ammoSpend').fill(0x90);
|
||||
await noteSwitchedOn(null, { titleId: GAME.titleId, pid: GAME.pid }, 'Infinite ammo', { byHand: true });
|
||||
assert.equal(markOf(crashRecord(GAME.titleId), 'Infinite ammo'), null, 'the page sees the mark gone at once');
|
||||
let rec = parseCrashRecord(readRecord());
|
||||
assert.deepEqual(rec.marks.map((m) => m.name), ['Health']);
|
||||
assert.deepEqual(rec.armed.map((a) => [a.name, a.pid]), [['Infinite ammo', GAME.pid]]);
|
||||
// The engine lets go of it and finds it on; "Health" stays held.
|
||||
await until('the patch to be on again', (s) => stateOf(s, 'Infinite ammo') === 'on');
|
||||
assert.equal(stateOf(await status(), 'Health'), 'held');
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
||||
|
||||
// A start set (switching on by itself) must not clear a mark.
|
||||
await noteSwitchedOn(null, { titleId: GAME.titleId, pid: GAME.pid }, 'Health', { byHand: false });
|
||||
assert.ok(markOf(parseCrashRecord(readRecord()), 'Health'));
|
||||
assert.equal(mayAutoSwitch(crashRecord(GAME.titleId), 'Health'), false);
|
||||
|
||||
// The game ends a few seconds after the page switched "Infinite ammo" on. The engine
|
||||
// marks it (as switched on by the page); the page itself writes nothing.
|
||||
await sleep(500);
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
const marked = await noteGameEnded(null, { titleId: GAME.titleId, pid: GAME.pid, version: GAME.version }, Date.now() - 1000);
|
||||
assert.deepEqual(marked, [], 'the engine does the marking where there is one');
|
||||
rec = parseCrashRecord(readRecord());
|
||||
assert.deepEqual(rec.marks.map((m) => [m.name, m.by]).sort(), [['Health', 'ps5'], ['Infinite ammo', 'page']]);
|
||||
assert.deepEqual(rec.armed, [], 'the ended launch\'s list is dropped');
|
||||
assert.ok(markOf(crashRecord(GAME.titleId), 'Infinite ammo'), 'the page read the record again');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('start set cheats the PS5 switches on are guarded the same way: marked after a quick crash, then held back', { skip }, async () => {
|
||||
// Not marked to keep: here only because the start set names them, one of the person's own and one from a saved table (a game patch).
|
||||
const own = { ...nopPatch('Infinite ammo', 'ammoSpend'), keep: undefined };
|
||||
const patch = { ...nopPatch('Free shopping', 'goldSpend'), keep: undefined };
|
||||
const text = JSON.stringify({
|
||||
format: 'cheatspecter',
|
||||
formatVersion: 1,
|
||||
game: { name: 'Specter Run', titleId: GAME.titleId, version: GAME.version },
|
||||
startSet: [{ name: 'Infinite ammo' }, { name: 'Free shopping', from: 'gamepatch/patches/xml_prospero/SpecterRun.xml' }],
|
||||
cheats: [own, heldHealth],
|
||||
sources: [{ id: 'gamepatch/patches/xml_prospero/SpecterRun.xml', kind: 'gamepatch', source: 'gamepatch', file: 'patches/xml_prospero/SpecterRun.xml', format: 'xml', cheats: [patch] }],
|
||||
});
|
||||
await launch(null, { clearMarks: true, text });
|
||||
let st = await until('the start set to be on', (s) => stateOf(s, 'Infinite ammo') === 'on' && stateOf(s, 'Free shopping') === 'on' && stateOf(s, 'Health') === 'on');
|
||||
assert.deepEqual(st.cheats.map((c) => [c.name, !!c.start]), [['Infinite ammo', true], ['Health', false], ['Free shopping', true]]);
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (s) => s.game === null);
|
||||
assert.deepEqual(parseCrashRecord(readRecord()).marks.map((m) => [m.name, m.by]).sort(), [['Free shopping', 'ps5'], ['Health', 'ps5'], ['Infinite ammo', 'ps5']]);
|
||||
|
||||
// Next start: the start set is held back like the kept cheat, and the game is left as it starts.
|
||||
resetGame();
|
||||
GAME.pid += 1;
|
||||
mock.state.foreground = true;
|
||||
st = await until('all three to be held back', (s) => s.game?.pid === GAME.pid && ['Infinite ammo', 'Free shopping', 'Health'].every((n) => stateOf(s, n) === 'held'));
|
||||
await sleep(1000);
|
||||
assert.equal(hex(code('ammoSpend')), hex(GAME.code.ammoSpend.bytes));
|
||||
assert.equal(hex(code('goldSpend')), hex(GAME.code.goldSpend.bytes));
|
||||
// The page tells kept and start set apart by "start", whatever their names.
|
||||
const parsed = parseEngineStatus(st);
|
||||
assert.equal(keptStatus(parsed, GAME.titleId, 'Health').state, 'held');
|
||||
assert.equal(keptStatus(parsed, GAME.titleId, 'Infinite ammo'), null, 'not a kept cheat');
|
||||
fs.rmSync(CRASH_FILE, { force: true });
|
||||
});
|
||||
|
||||
test('a game that ends after a long session marks nothing', { skip }, async () => {
|
||||
await launch([nopPatch('Infinite ammo', 'ammoSpend'), nopPatch('God mode', 'healthDrain')], { clearMarks: true });
|
||||
const restore = pageFetch();
|
||||
try {
|
||||
await until('both to be on', (st) => stateOf(st, 'Infinite ammo') === 'on' && stateOf(st, 'God mode') === 'on');
|
||||
await sleep(WINDOW + 800);
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
await sleep(200);
|
||||
const rec = readRecord();
|
||||
assert.ok(rec === null || parseCrashRecord(rec).marks.length === 0, `nothing marked: ${JSON.stringify(rec)}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('what the page listed before the engine was watching does not count; another launch\'s list neither', { skip }, async () => {
|
||||
const restore = pageFetch();
|
||||
try {
|
||||
await launch([], { clearMarks: true });
|
||||
// Listed for an earlier launch of the game.
|
||||
await noteSwitchedOn(null, { titleId: GAME.titleId, pid: GAME.pid - 1 }, 'From before', { byHand: true });
|
||||
await sleep(400);
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
await sleep(200);
|
||||
assert.deepEqual(parseCrashRecord(readRecord()).marks, []);
|
||||
|
||||
// Already listed for this very launch when the engine first sees it (the payload was started late):
|
||||
// when it was switched on is unknown, so it does not count.
|
||||
fs.writeFileSync(CRASH_FILE, JSON.stringify({ marks: [], armed: [{ name: 'Unknown when', pid: GAME.pid + 1, at: Date.now() }] }));
|
||||
GAME.pid += 1;
|
||||
mock.state.foreground = true;
|
||||
await until('the game to be seen', (st) => st.game?.pid === GAME.pid);
|
||||
await sleep(300);
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
await sleep(200);
|
||||
const rec = parseCrashRecord(readRecord());
|
||||
assert.deepEqual(rec.marks, []);
|
||||
assert.deepEqual(rec.armed, [], 'that launch is over, so its list is dropped');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('where there is no engine that guards, the page marks by itself', { skip }, async () => {
|
||||
const restore = pageFetch({ engine: false });
|
||||
try {
|
||||
fs.rmSync(CRASH_FILE, { force: true });
|
||||
const pid = 4242;
|
||||
await noteSwitchedOn(null, { titleId: GAME.titleId, pid }, 'Fly', { byHand: true });
|
||||
await noteSwitchedOn(null, { titleId: GAME.titleId, pid }, 'Speed', { byHand: true });
|
||||
const lastSeen = Date.now();
|
||||
const marked = await noteGameEnded(null, { titleId: GAME.titleId, pid, version: '01.000.000' }, lastSeen, lastSeen + 2000);
|
||||
assert.deepEqual(marked.sort(), ['Fly', 'Speed']);
|
||||
const rec = parseCrashRecord(readRecord());
|
||||
assert.deepEqual(rec.marks.map((m) => [m.name, m.by, m.version]).sort(), [['Fly', 'page', '01.000.000'], ['Speed', 'page', '01.000.000']]);
|
||||
assert.deepEqual(rec.armed, []);
|
||||
// Taken off by hand without switching on.
|
||||
await clearCrashMark(null, GAME.titleId, 'Fly');
|
||||
assert.deepEqual(parseCrashRecord(readRecord()).marks.map((m) => m.name), ['Speed']);
|
||||
// Quick changes in a row do not overwrite each other.
|
||||
await Promise.all(['A', 'B', 'C', 'D'].map((n) => noteSwitchedOn(null, { titleId: GAME.titleId, pid: 7 }, n, { byHand: true })));
|
||||
assert.deepEqual(parseCrashRecord(readRecord()).armed.map((a) => a.name).sort(), ['A', 'B', 'C', 'D']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a console that cannot be asked: nothing is marked and nothing breaks', async () => {
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = () => Promise.reject(new Error('offline'));
|
||||
try {
|
||||
await noteSwitchedOn(null, { titleId: 'PPSA00001', pid: 1 }, 'X', { byHand: true });
|
||||
assert.deepEqual(await noteGameEnded(null, { titleId: 'PPSA00001', pid: 1 }, Date.now()), []);
|
||||
assert.deepEqual(await loadCrashRecord(null, 'PPSA00001'), emptyRecord());
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
// The diagnostics log (src/lib/diag.ts): levels, the size limit, the report's
|
||||
// head, what requests are worth a line, and the console and error hooks.
|
||||
//
|
||||
// node --test test/diag.test.mjs
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DiagLog, browserName, classifyRequest, describeArgs, describeValue, formatLine, installDiagHooks, keeps } from '../src/lib/diag.ts';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
test('the level decides what is kept', () => {
|
||||
assert.ok(keeps('errors', 'error') && keeps('errors', 'warn'));
|
||||
assert.ok(!keeps('errors', 'info') && !keeps('errors', 'debug'));
|
||||
assert.ok(keeps('normal', 'info') && !keeps('normal', 'debug'));
|
||||
assert.ok(keeps('everything', 'debug'));
|
||||
|
||||
const log = new DiagLog();
|
||||
log.setSetting('errors');
|
||||
log.info('cheats', 'switched on Infinite ammo');
|
||||
log.debug('payload', 'GET api/engine: 200');
|
||||
log.warn('connection', 'lost');
|
||||
log.error('page', 'broke');
|
||||
assert.deepEqual(log.lines().map((l) => l.level), ['warn', 'error']);
|
||||
log.setSetting('everything');
|
||||
log.debug('payload', 'GET api/engine: 200');
|
||||
assert.equal(log.size, 3);
|
||||
});
|
||||
|
||||
test('only the newest lines are kept, and the report says some were dropped', () => {
|
||||
const log = new DiagLog(100);
|
||||
for (let i = 0; i < 1000; i++) log.info('t', `line ${i}`);
|
||||
const lines = log.lines();
|
||||
assert.equal(lines.length, 100);
|
||||
assert.equal(log.size, 100);
|
||||
assert.equal(lines[0].text, 'line 900');
|
||||
assert.equal(lines.at(-1).text, 'line 999');
|
||||
assert.equal(log.dropped, 900);
|
||||
assert.match(log.report(), /900 older lines were dropped/);
|
||||
log.clear();
|
||||
assert.equal(log.size, 0);
|
||||
assert.equal(log.dropped, 0);
|
||||
});
|
||||
|
||||
test('a very long line is cut', () => {
|
||||
const log = new DiagLog();
|
||||
log.error('page', 'x'.repeat(10000));
|
||||
assert.ok(log.lines()[0].text.length < 2100);
|
||||
assert.match(log.lines()[0].text, /8000 more characters/);
|
||||
});
|
||||
|
||||
test('the report starts with what is known about the setup', () => {
|
||||
const log = new DiagLog();
|
||||
log.setContext('CheatSpecter', '0.4.3 (served by the PS5)');
|
||||
log.setContext('Browser', 'Chrome 150 on macOS');
|
||||
log.setContext('Console', 'ps5debug-NG, firmware 13.60');
|
||||
log.setContext('Game', 'gone');
|
||||
log.setContext('Game', null);
|
||||
log.add('warn', 'connection', 'Lost the connection: closed\nsecond line', new Date(2026, 9, 11, 14, 3, 22, 7).getTime());
|
||||
const text = log.report([['Screen', '844x390 at 3x']], new Date(2026, 9, 11, 15, 0, 0).getTime());
|
||||
const lines = text.split('\n');
|
||||
assert.equal(lines[0], 'CheatSpecter diagnostics');
|
||||
assert.match(text, /^Written: +2026-10-11 15:00:00\.000$/m);
|
||||
assert.match(text, /^CheatSpecter: +0\.4\.3 \(served by the PS5\)$/m);
|
||||
assert.match(text, /^Browser: +Chrome 150 on macOS$/m);
|
||||
assert.match(text, /^Console: +ps5debug-NG, firmware 13\.60$/m);
|
||||
assert.match(text, /^Screen: +844x390 at 3x$/m);
|
||||
assert.match(text, /^Log level: +normal$/m);
|
||||
assert.doesNotMatch(text, /^Game:/m);
|
||||
assert.match(text, /^2026-10-11 14:03:22\.007 WARN connection: Lost the connection: closed\n {4}second line$/m);
|
||||
});
|
||||
|
||||
test('listeners hear of changes after the fact, once per burst', async () => {
|
||||
const log = new DiagLog();
|
||||
let calls = 0;
|
||||
const stop = log.subscribe(() => calls++);
|
||||
const before = log.revision;
|
||||
log.info('a', '1');
|
||||
log.info('a', '2');
|
||||
// Not while the line is being written: React logs its warnings in the middle of drawing.
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(log.revision, before);
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(log.revision, before + 1);
|
||||
stop();
|
||||
log.info('a', '3');
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('describing what was logged or thrown', () => {
|
||||
const e = new Error('boom');
|
||||
assert.match(describeValue(e), /^Error: boom\n/);
|
||||
assert.equal(describeValue(5n * 16n), '0x50');
|
||||
assert.equal(describeValue({ a: 1, b: 255n }), '{"a":1,"b":"0xff"}');
|
||||
const loop = {};
|
||||
loop.self = loop;
|
||||
assert.equal(describeValue(loop), '[object Object]');
|
||||
assert.equal(describeArgs(['%s stopped working: %s', 'the Cheats tab', 'oops', 42]), 'the Cheats tab stopped working: oops 42');
|
||||
assert.equal(describeArgs(['plain', 1, null]), 'plain 1 null');
|
||||
});
|
||||
|
||||
test('which requests are worth a line, and at what level', () => {
|
||||
const c = (m, u, s, e) => classifyRequest(m, u, s, e);
|
||||
assert.equal(c('GET', 'https://example.com/x', 500), null);
|
||||
assert.equal(c('GET', 'assets/index.js', 200), null);
|
||||
assert.deepEqual(c('GET', 'api/tables/PPSA01575/01.020.000', 200), { level: 'info', area: 'cheats', text: 'Loaded the cheat file for PPSA01575 01.020.000 from the PS5' });
|
||||
assert.equal(c('PUT', 'api/tables/PPSA01575/01.020.000?host=1.2.3.4', 204).text, 'Saved the cheat file for PPSA01575 01.020.000 on the PS5');
|
||||
// A file or setting never saved is routine; a refused save is not.
|
||||
assert.equal(c('GET', 'api/tables/PPSA01575/01.020.000', 404).level, 'debug');
|
||||
assert.equal(c('GET', 'api/prefs/learn', 404).level, 'debug');
|
||||
assert.equal(c('PUT', 'api/tables/PPSA01575/01.020.000', 403).level, 'warn');
|
||||
assert.equal(c('GET', 'api/library', 500).level, 'error');
|
||||
assert.equal(c('POST', 'api/launch/PPSA01575', 0, 'Failed to fetch').level, 'warn');
|
||||
assert.match(c('POST', 'api/launch/PPSA01575', 0, 'Failed to fetch').text, /no answer \(Failed to fetch\)/);
|
||||
assert.equal(c('GET', 'api/engine', 200).level, 'debug');
|
||||
assert.equal(c('POST', 'api/shutdown', 200).level, 'info');
|
||||
assert.equal(c('GET', 'https://raw.githubusercontent.com/o/r/main/json/PPSA01575_01.020.000.json', 200).text, 'Downloaded json/PPSA01575_01.020.000.json');
|
||||
assert.equal(c('GET', 'https://raw.githubusercontent.com/o/r/main/x.json', 0, 'offline').level, 'warn');
|
||||
assert.equal(c('GET', 'https://raw.githubusercontent.com/o/r/main/x.json', 404).level, 'debug');
|
||||
assert.equal(c('GET', 'http://192.168.1.5:8744/api/info', 200).level, 'debug');
|
||||
});
|
||||
|
||||
test('the hooks copy console errors, uncaught errors, rejections and requests into the log', async () => {
|
||||
const seen = [];
|
||||
const listeners = new Map();
|
||||
const target = {
|
||||
console: { error: (...a) => seen.push(['error', ...a]), warn: (...a) => seen.push(['warn', ...a]) },
|
||||
addEventListener: (t, f) => listeners.set(t, f),
|
||||
removeEventListener: (t, f) => listeners.get(t) === f && listeners.delete(t),
|
||||
fetch: async (url) => {
|
||||
if (String(url).includes('down')) throw new TypeError('Failed to fetch');
|
||||
return { status: String(url).includes('missing') ? 404 : 200, ok: true };
|
||||
},
|
||||
};
|
||||
const log = new DiagLog();
|
||||
log.setSetting('everything');
|
||||
const undo = installDiagHooks(target, log);
|
||||
// A second install is harmless.
|
||||
installDiagHooks(target, log)();
|
||||
|
||||
target.console.error('Warning: %s is bad', 'this');
|
||||
target.console.warn('careful');
|
||||
assert.deepEqual(seen, [['error', 'Warning: %s is bad', 'this'], ['warn', 'careful']], 'the console still gets everything');
|
||||
listeners.get('error')({ message: 'x is not a function', error: new TypeError('x is not a function'), filename: 'http://h/assets/index.js', lineno: 10 });
|
||||
listeners.get('error')({ message: '', error: null });
|
||||
listeners.get('unhandledrejection')({ reason: new Error('nope') });
|
||||
await target.fetch('api/tables/PPSA01575/01.020.000');
|
||||
await target.fetch('api/tables/PPSA01575/missing');
|
||||
await assert.rejects(target.fetch('api/down'), /Failed to fetch/);
|
||||
|
||||
const texts = log.lines().map((l) => `${l.level} ${l.area}: ${l.text.split('\n')[0]}`);
|
||||
assert.deepEqual(texts, [
|
||||
'error page: Warning: this is bad',
|
||||
'warn page: careful',
|
||||
'error page: Uncaught in index.js:10: TypeError: x is not a function',
|
||||
'error page: Unhandled rejection: Error: nope',
|
||||
'info cheats: Loaded the cheat file for PPSA01575 01.020.000 from the PS5',
|
||||
'debug payload: GET api/tables/PPSA01575/missing was refused: 404',
|
||||
'warn payload: GET api/down: no answer (Failed to fetch)',
|
||||
]);
|
||||
|
||||
undo();
|
||||
target.console.error('after');
|
||||
assert.equal(log.size, 7);
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('formatLine and browserName', () => {
|
||||
assert.match(formatLine({ time: 0, level: 'info', area: 'cheats', text: 'x' }), /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.000 INFO cheats: x$/);
|
||||
assert.equal(
|
||||
browserName('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'),
|
||||
'Chrome 150.0.0.0 on macOS',
|
||||
);
|
||||
assert.equal(browserName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1'), 'Safari 18.0 on iOS');
|
||||
assert.equal(browserName('Mozilla/5.0 (PlayStation; PlayStation 5/13.60) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15'), 'PS5 browser, system 13.60 on PS5');
|
||||
assert.equal(browserName('curl/8'), 'Unknown browser');
|
||||
});
|
||||
+20
-8
@@ -35,6 +35,8 @@ const MOCK = 17650 + PORT_OFFSET;
|
||||
const EVENTS = 17966 + PORT_OFFSET;
|
||||
// The engine's waits (2 s between looks at the running game, and so on) run this many times shorter.
|
||||
const SPEED = 10;
|
||||
/** Crash protection's look back (60 s on a console), at that speed. */
|
||||
const CRASH_WINDOW_MS = 60000 / SPEED;
|
||||
|
||||
let skip = false;
|
||||
try {
|
||||
@@ -162,6 +164,9 @@ async function launch(files, prepare) {
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.running && st.game === null);
|
||||
fs.rmSync(CHEATS, { recursive: true, force: true });
|
||||
// Games here close seconds after their cheats go on, which crash protection
|
||||
// marks. Each launch starts without those marks; test/crashguard.test.mjs covers them.
|
||||
fs.rmSync(path.join(FAKE_ROOT, 'data/cheatspecter/prefs'), { recursive: true, force: true });
|
||||
for (const [name, text] of Object.entries(files)) putFile(name, text);
|
||||
resetGame();
|
||||
prepare?.();
|
||||
@@ -197,20 +202,20 @@ after(async () => {
|
||||
|
||||
test('says so when the debugger is not there, and picks up when it appears', { skip }, async () => {
|
||||
// Nothing is listening where the debugger should be.
|
||||
assert.deepEqual(await status(), { running: false, game: null, file: '', cheats: [] });
|
||||
assert.deepEqual(await status(), { running: false, crashGuard: true, game: null, file: '', cheats: [] });
|
||||
await sleep(400);
|
||||
assert.deepEqual(await status(), { running: false, game: null, file: '', cheats: [] });
|
||||
assert.deepEqual(await status(), { running: false, crashGuard: true, game: null, file: '', cheats: [] });
|
||||
|
||||
await startTheMock();
|
||||
mock.state.foreground = false;
|
||||
await until('the debugger to be found', (st) => st.running);
|
||||
assert.deepEqual(await status(), { running: true, game: null, file: '', cheats: [] });
|
||||
assert.deepEqual(await status(), { running: true, crashGuard: true, game: null, file: '', cheats: [] });
|
||||
|
||||
// A game with no cheat file: seen, nothing to do, nothing said.
|
||||
const mark = stderr.length;
|
||||
mock.state.foreground = true;
|
||||
const st = await until('the game to be seen', (s) => s.game !== null);
|
||||
assert.deepEqual(st, { running: true, game: { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid }, file: '', cheats: [] });
|
||||
assert.deepEqual(st, { running: true, crashGuard: true, game: { titleId: GAME.titleId, version: GAME.version, pid: GAME.pid }, file: '', cheats: [] });
|
||||
await sleep(600);
|
||||
assert.deepEqual(notifications(mark), []);
|
||||
});
|
||||
@@ -271,7 +276,10 @@ test('switches a kept patch on when the game starts, and says why for everything
|
||||
// The stand-in the demo console uses lists the same cheats for the same file.
|
||||
const stand = demoEngineStatus({ titleId: GAME.titleId, version: GAME.version, pid: GAME.pid }, new Map([[OWN_FILE.replace('.cspector', ''), Buffer.from(fileText(cheats))]]));
|
||||
assert.deepEqual(stand.cheats.map((c) => [c.name, c.state === 'refused' ? c.detail : '']), st.cheats.map((c) => [c.name, c.state === 'refused' ? c.detail : '']));
|
||||
assert.deepEqual({ ...stand, cheats: [] }, { ...st, cheats: [] });
|
||||
// Except "crashGuard": the stand-in marks nothing, so the page does that itself there.
|
||||
const { crashGuard, ...shape } = st;
|
||||
assert.equal(crashGuard, true);
|
||||
assert.deepEqual({ ...stand, cheats: [] }, { ...shape, cheats: [] });
|
||||
assert.equal(hex(code('ammoSpend')), '909090');
|
||||
for (const untouched of ['healthDrain', 'goldSpend', 'scoreAdd']) assert.equal(hex(code(untouched)), ORIGINAL(untouched), untouched);
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
||||
@@ -623,6 +631,7 @@ test('tells the user once, then once more for cheats that only became possible l
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
fs.rmSync(CHEATS, { recursive: true, force: true });
|
||||
fs.rmSync(path.join(FAKE_ROOT, 'data/cheatspecter/prefs'), { recursive: true, force: true });
|
||||
putFile(OWN_FILE, fileText([nopPatch('Infinite ammo', 'ammoSpend'), held('Health', GAME.vars.health - GAME.player, 'u32', '999')]));
|
||||
resetGame();
|
||||
// At the title screen there is no player yet.
|
||||
@@ -654,12 +663,14 @@ test('tells the user once, then once more for cheats that only became possible l
|
||||
test('a new launch of the game gets its cheats again; a closed game gets nothing', { skip }, async () => {
|
||||
await launch({ [OWN_FILE]: fileText([nopPatch('Infinite ammo', 'ammoSpend'), held('Health', GAME.vars.health - GAME.player, 'u32', '999')]) });
|
||||
await until('both to be on', (st) => st.cheats.length === 2 && st.cheats.every((c) => c.state === 'on'));
|
||||
// Played for longer than crash protection looks back (a minute on a console), so closing marks nothing.
|
||||
await sleep(CRASH_WINDOW_MS + 500);
|
||||
|
||||
// The game is closed. Its memory is gone; nothing may be written to whatever has that address next.
|
||||
const mark = stderr.length;
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.game === null);
|
||||
assert.deepEqual(await status(), { running: true, game: null, file: '', cheats: [] });
|
||||
assert.deepEqual(await status(), { running: true, crashGuard: true, game: null, file: '', cheats: [] });
|
||||
resetGame();
|
||||
await sleep(400);
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 100);
|
||||
@@ -675,7 +686,8 @@ test('a new launch of the game gets its cheats again; a closed game gets nothing
|
||||
await sleep(300);
|
||||
assert.deepEqual(notifications(mark), ['CheatSpecter: 2 cheats on for Specter Run']);
|
||||
|
||||
// The process id changes without the game ever being seen closed (a quick restart).
|
||||
// The process id changes without the game ever being seen closed (a restart, after a while).
|
||||
await sleep(CRASH_WINDOW_MS + 500);
|
||||
resetGame();
|
||||
GAME.pid += 1;
|
||||
await until('the restart to be noticed', (s) => s.game?.pid === GAME.pid && s.cheats.length === 2 && s.cheats.every((c) => c.state === 'on'));
|
||||
@@ -690,7 +702,7 @@ test('carries on when the debugger goes away and comes back', { skip }, async ()
|
||||
const keptMemory = mock.game;
|
||||
await mock.close();
|
||||
await until('the engine to notice', (st) => !st.running);
|
||||
assert.deepEqual(await status(), { running: false, game: null, file: '', cheats: [] });
|
||||
assert.deepEqual(await status(), { running: false, crashGuard: true, game: null, file: '', cheats: [] });
|
||||
// The rest of the payload is unaffected.
|
||||
assert.equal((await api('/api/info')).status, 200);
|
||||
await sleep(500);
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Patch>
|
||||
<TitleID>
|
||||
<ID>PPSA22222</ID>
|
||||
</TitleID>
|
||||
<Metadata Title="Broken" Name="Cut short" AppVer="01.000.000">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x401000" Value="90"/>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Hand-made for the tests: another PS5 game's file. -->
|
||||
<Patch>
|
||||
<TitleID>
|
||||
<ID>PPSA11111</ID>
|
||||
</TitleID>
|
||||
<Metadata Title="Other Game" Name="Something" Author="tester" PatchVer="1.0" AppVer="01.000.000" ImageBase="0x400000" AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x401000" Value="90"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
</Patch>
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Hand-made for the tests, in the format of github.com/illusion0001/PS4-PS5-Game-Patch (patches/xml_prospero).
|
||||
The addresses are for the mock game (mock/mock-ps5.mjs) with its executable at 0x10000000. -->
|
||||
<Patch>
|
||||
<TitleID>
|
||||
<ID>PPSA99999</ID>
|
||||
<ID>PPSA99998</ID>
|
||||
</TitleID>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Infinite ammo"
|
||||
Note="Shots no longer use ammo."
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.000.000"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x10002040" Value="909090"/> <!-- dec dword [rbx+0x28] -->
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="60 FPS & no motion blur"
|
||||
Note="Unlocks the frame rate."
|
||||
Author="tester, helper"
|
||||
PatchVer="1.1"
|
||||
AppVer="1.0"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes32" Address="0x10010800" Value="0x3c"/>
|
||||
<Line Type="float32" Address="0x10010810" Value="100.0"/>
|
||||
<Line Type="byte" Address="0x10010820" Value="0"/>
|
||||
<Line Type="bytes16" Address="0x10010822" Value="-2"/>
|
||||
<Line Type="bytes64" Address="0x10010828" Value="$0102030405060708"/>
|
||||
<Line Type="float64" Address="0x10010830" Value="-0.5"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Free shopping"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="mask"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="mask" Address="f3 0f 5c c1" Offset="+0" Value="90909090"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Rename the debug flag"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.000.000"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="utf8" Address="0x1000e000" Value="debug_menu_shown"/>
|
||||
<Line Type="utf16" Address="0x1000e100" Value="Hi\x21"/>
|
||||
<Line Type="mask_bytes32" Address="FF 4B ??" Offset="-3" Value="1"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Infinite ammo"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="00.900.000"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x10001f00" Value="9090"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Bigger draw distance"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.000.000"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x10003200" Value="90"/>
|
||||
<Line Type="mask_jump32" Address="83 87 80 88" Target="cc cc cc cc cc cc cc cc" Size="7" Offset="+0" Value="c3"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Photo mode in the launcher"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.000.000"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="launcher.elf">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x10001000" Value="90"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Specter Run"
|
||||
Name="Too big"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.000.000"
|
||||
ImageBase="0x10000000"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="byte" Address="0x10001000" Value="0x1ff"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
</Patch>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Hand-made for the tests: a PS4 game's file (patches/xml), with addresses for an executable at 0x400000 (no ImageBase). -->
|
||||
<Patch>
|
||||
<TitleID>
|
||||
<ID>CUSA00001</ID>
|
||||
</TitleID>
|
||||
<Metadata Title="Test Game"
|
||||
Name="Skip intro"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.02"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x00402040" Value="eb"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Test Game"
|
||||
Name="Relative"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.02"
|
||||
ImageBase="0x0"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x2040" Value="eb"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
<Metadata Title="Test Game"
|
||||
Name="Below the base"
|
||||
Author="tester"
|
||||
PatchVer="1.0"
|
||||
AppVer="01.02"
|
||||
AppElf="eboot.bin">
|
||||
<PatchList>
|
||||
<Line Type="bytes" Address="0x00300000" Value="eb"/>
|
||||
</PatchList>
|
||||
</Metadata>
|
||||
</Patch>
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"sha": "0000000000000000000000000000000000000000",
|
||||
"url": "https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main",
|
||||
"tree": [
|
||||
{ "path": ".github/README.md", "mode": "100644", "type": "blob", "sha": "1111111111111111111111111111111111111111", "size": 9000 },
|
||||
{ "path": "patches", "mode": "040000", "type": "tree", "sha": "2222222222222222222222222222222222222222" },
|
||||
{ "path": "patches/xml", "mode": "040000", "type": "tree", "sha": "3333333333333333333333333333333333333333" },
|
||||
{ "path": "patches/xml/TestGame-Orbis.xml", "mode": "100644", "type": "blob", "sha": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "size": 1200 },
|
||||
{ "path": "patches/xml/sub/Nested.xml", "mode": "100644", "type": "blob", "sha": "a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2", "size": 100 },
|
||||
{ "path": "patches/xml_prospero", "mode": "040000", "type": "tree", "sha": "4444444444444444444444444444444444444444" },
|
||||
{ "path": "patches/xml_prospero/Broken.xml", "mode": "100644", "type": "blob", "sha": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", "size": 300 },
|
||||
{ "path": "patches/xml_prospero/OtherGame.xml", "mode": "100644", "type": "blob", "sha": "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", "size": 500 },
|
||||
{ "path": "patches/xml_prospero/SpecterRun.xml", "mode": "100644", "type": "blob", "sha": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "size": 4000 },
|
||||
{ "path": "patches_old/json/SpecterRun.json", "mode": "100644", "type": "blob", "sha": "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "size": 100 }
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// Game patches in the PS4-PS5-Game-Patch XML format (src/lib/gamepatch.ts):
|
||||
// reading files, what each kind of line writes, which patches fit the running
|
||||
// version, finding a game's file in the repository with as few downloads as
|
||||
// possible, and turning patches into .cspector entries against the mock game.
|
||||
// The fixtures under test/fixtures/gamepatch/ are small hand-made files in
|
||||
// the real format.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Browser storage, for what the lookups remember between visits.
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
import { startMock, GAME } from '../mock/mock-ps5.mjs';
|
||||
import { startBridge } from '../bridge/server.mjs';
|
||||
import {
|
||||
DEFAULT_IMAGE_BASE,
|
||||
GAME_PATCH_SOURCE_ID,
|
||||
PatchRefused,
|
||||
convertPatch,
|
||||
findGamePatches,
|
||||
folderFor,
|
||||
checkGamePatches,
|
||||
gamePatchOrigin,
|
||||
gamePatchSource,
|
||||
lineBytes,
|
||||
nameScore,
|
||||
otherVersions,
|
||||
parseGamePatchFile,
|
||||
parseTree,
|
||||
patchesForGame,
|
||||
patchesForVersion,
|
||||
rawUrl,
|
||||
samePatch,
|
||||
savedGamePatches,
|
||||
versionMatch,
|
||||
} from '../src/lib/gamepatch.ts';
|
||||
import { bytesToHex } from '../src/lib/bytes.ts';
|
||||
import { createGameContext } from '../src/lib/locations.ts';
|
||||
import { Ps5Debug } from '../src/lib/ps5debug.ts';
|
||||
import { TableRunner, emptyTable, keepProblem, parseOwnTable, saveFileText, withSource } from '../src/lib/table.ts';
|
||||
import { openWebSocketStream } from '../src/lib/transport.ts';
|
||||
import { asAsync, createX86 } from '../src/lib/x86.ts';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const FIXTURES = path.join(ROOT, 'test/fixtures/gamepatch');
|
||||
const fixture = (name) => fs.readFileSync(path.join(FIXTURES, name), 'utf8');
|
||||
const SPECTER = parseGamePatchFile(fixture('SpecterRun.xml'));
|
||||
const byName = (file, name, appVer) => file.patches.find((p) => p.name === name && (!appVer || p.appVer === appVer));
|
||||
const hex = (bytes) => bytesToHex(bytes);
|
||||
const line = (type, value, extra = {}) => ({ type, address: '0x400000', value, offset: '', target: '', size: '', ...extra });
|
||||
|
||||
// ---- reading files -----------------------------------------------------------------
|
||||
|
||||
test('a file: its title IDs, and every patch with what it says about itself', () => {
|
||||
assert.deepEqual(SPECTER.titleIds, ['PPSA99999', 'PPSA99998']);
|
||||
assert.equal(SPECTER.patches.length, 8);
|
||||
const ammo = byName(SPECTER, 'Infinite ammo', '01.000.000');
|
||||
assert.deepEqual(
|
||||
{ ...ammo, key: undefined },
|
||||
{
|
||||
title: 'Specter Run',
|
||||
name: 'Infinite ammo',
|
||||
note: 'Shots no longer use ammo.',
|
||||
author: 'tester',
|
||||
patchVer: '1.0',
|
||||
appVer: '01.000.000',
|
||||
appElf: 'eboot.bin',
|
||||
imageBase: '0x10000000',
|
||||
lines: [{ type: 'bytes', address: '0x10002040', value: '909090', offset: '', target: '', size: '' }],
|
||||
key: undefined,
|
||||
},
|
||||
);
|
||||
// Entities are decoded; comments inside and after lines are ignored.
|
||||
assert.equal(SPECTER.patches[1].name, '60 FPS & no motion blur');
|
||||
assert.equal(SPECTER.patches[1].lines.length, 6);
|
||||
// Keys tell the patches apart, including the two "Infinite ammo" for different versions, and do not change between reads.
|
||||
assert.equal(new Set(SPECTER.patches.map((p) => p.key)).size, SPECTER.patches.length);
|
||||
assert.deepEqual(parseGamePatchFile(fixture('SpecterRun.xml')).patches.map((p) => p.key), SPECTER.patches.map((p) => p.key));
|
||||
assert.match(ammo.key, /^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
test('damaged and foreign files are refused with a reason, not guessed at', () => {
|
||||
assert.throws(() => parseGamePatchFile(fixture('Broken.xml')), /cut short \(<PatchList> is never closed\)/);
|
||||
assert.throws(() => parseGamePatchFile('<Cheats><ID>PPSA99999</ID></Cheats>'), /not a game patch file/);
|
||||
assert.throws(() => parseGamePatchFile('<Patch></Metadata></Patch>'), /not valid XML/);
|
||||
assert.throws(() => parseGamePatchFile('<Patch><!-- never closed'), /cut short/);
|
||||
assert.throws(() => parseGamePatchFile('{"name":"json"}'), /not a game patch file/);
|
||||
// A byte-order mark, CDATA, single quotes and character references are fine.
|
||||
const ok = parseGamePatchFile("<?xml version='1.0'?><Patch><TitleID><ID>cusa00001</ID><ID>junk</ID></TitleID><Metadata Name='A & B' AppVer='01.00'><PatchList><Line Type='bytes' Address='0x400000' Value='90'/></PatchList></Metadata><![CDATA[<ignored/>]]></Patch>");
|
||||
assert.deepEqual(ok.titleIds, ['CUSA00001']);
|
||||
assert.equal(ok.patches[0].name, 'A & B');
|
||||
});
|
||||
|
||||
// ---- what each line writes ---------------------------------------------------------
|
||||
|
||||
test('whole numbers: hex with 0x, # or $, decimal (negative too), little-endian, and nothing that does not fit', () => {
|
||||
assert.equal(hex(lineBytes(line('byte', '0x7f'))), '7F');
|
||||
assert.equal(hex(lineBytes(line('byte', '255'))), 'FF');
|
||||
assert.equal(hex(lineBytes(line('byte', '-1'))), 'FF');
|
||||
assert.equal(hex(lineBytes(line('bytes16', '#1234'))), '3412');
|
||||
assert.equal(hex(lineBytes(line('bytes32', '0x3c'))), '3C000000');
|
||||
assert.equal(hex(lineBytes(line('mask_bytes32', '60'))), '3C000000');
|
||||
assert.equal(hex(lineBytes(line('bytes64', '$0102030405060708'))), '0807060504030201');
|
||||
assert.equal(hex(lineBytes(line('bytes64', '0xffffffffffffffff'))), 'FFFFFFFFFFFFFFFF');
|
||||
assert.throws(() => lineBytes(line('byte', '0x1ff')), (e) => e instanceof PatchRefused && /0x1ff does not fit in 1 byte/.test(e.message));
|
||||
assert.throws(() => lineBytes(line('bytes16', '-40000')), /does not fit in 2 bytes/);
|
||||
assert.throws(() => lineBytes(line('bytes32', 'lots')), /"lots" is not a number/);
|
||||
assert.throws(() => lineBytes(line('bytes32', '')), /no value/);
|
||||
});
|
||||
|
||||
test('bytes, floats and strings', () => {
|
||||
assert.equal(hex(lineBytes(line('bytes', 'e937010000'))), 'E937010000');
|
||||
assert.equal(hex(lineBytes(line('mask', '90 90'))), '9090', 'spaces are let through');
|
||||
assert.equal(hex(lineBytes(line('bytes', 'abc'))), '0ABC', 'an odd count gets a leading zero, as the plugins read it');
|
||||
assert.throws(() => lineBytes(line('bytes', '90zz')), /not written as hex bytes/);
|
||||
assert.equal(hex(lineBytes(line('float32', '1.0'))), '0000803F');
|
||||
assert.equal(hex(lineBytes(line('mask_float64', '-0.5'))), '000000000000E0BF');
|
||||
assert.throws(() => lineBytes(line('float32', 'fast')), /not a number/);
|
||||
// Strings end with a zero, as the plugins write them; escapes are theirs.
|
||||
assert.equal(hex(lineBytes(line('utf8', 'Hi'))), '486900');
|
||||
assert.equal(hex(lineBytes(line('utf8', 'a\\nb\\x21\\\\'))), '610A62215C00');
|
||||
assert.equal(hex(lineBytes(line('utf8', 'é😀'))), 'C3A9F09F988000');
|
||||
assert.equal(hex(lineBytes(line('utf16', 'Hi\\x21'))), '4800690021000000');
|
||||
});
|
||||
|
||||
test('lines that cannot be applied safely are refused with a plain reason', () => {
|
||||
assert.throws(() => lineBytes(line('mask_jump32', 'c3')), /writes new code into free space/);
|
||||
assert.throws(() => lineBytes(line('patchCall', 'c3')), /changes the function a call in the game leads to/);
|
||||
assert.throws(() => lineBytes(line('mask_patchCall', 'c3')), /changes the function a call/);
|
||||
assert.throws(() => lineBytes(line('teleport', '1')), /uses a kind of line \("teleport"\) CheatSpecter does not know/);
|
||||
assert.throws(() => lineBytes(line('', '1')), /does not say what kind it is/);
|
||||
});
|
||||
|
||||
// ---- places ------------------------------------------------------------------------
|
||||
|
||||
test('addresses become offsets into the game, by the patch\'s ImageBase, 0x400000 when it has none, as is when it is 0', () => {
|
||||
assert.equal(DEFAULT_IMAGE_BASE, 0x400000n);
|
||||
assert.deepEqual(convertPatch(byName(SPECTER, 'Infinite ammo', '01.000.000')), [{ at: { kind: 'module', offset: '2040' }, on: '909090' }]);
|
||||
const ps4 = parseGamePatchFile(fixture('TestGame-Orbis.xml'));
|
||||
assert.deepEqual(convertPatch(byName(ps4, 'Skip intro')), [{ at: { kind: 'module', offset: '2040' }, on: 'EB' }]);
|
||||
assert.deepEqual(convertPatch(byName(ps4, 'Relative')), [{ at: { kind: 'module', offset: '2040' }, on: 'EB' }]);
|
||||
assert.throws(() => convertPatch(byName(ps4, 'Below the base')), /the address 0x00300000 is before the start of the game's code/);
|
||||
// Every line is one place, in order.
|
||||
const fps = convertPatch(byName(SPECTER, '60 FPS & no motion blur'));
|
||||
assert.deepEqual(fps.map((s) => [s.at.offset, s.on]), [
|
||||
['10800', '3C000000'],
|
||||
['10810', '0000C842'],
|
||||
['10820', '00'],
|
||||
['10822', 'FEFF'],
|
||||
['10828', '0807060504030201'],
|
||||
['10830', '000000000000E0BF'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('"mask" lines become byte patterns with the distance from the match', () => {
|
||||
assert.deepEqual(convertPatch(byName(SPECTER, 'Free shopping')), [{ at: { kind: 'signature', pattern: 'F3 0F 5C C1', offset: 0 }, on: '90909090' }]);
|
||||
const sites = convertPatch(byName(SPECTER, 'Rename the debug flag'));
|
||||
assert.deepEqual(sites[2], { at: { kind: 'signature', pattern: 'FF 4B ??', offset: -3 }, on: '01000000' });
|
||||
assert.equal(sites[0].on, hex(new TextEncoder().encode('debug_menu_shown\0')));
|
||||
const bad = { ...byName(SPECTER, 'Free shopping'), lines: [line('mask', '90', { address: 'not a pattern' })] };
|
||||
assert.throws(() => convertPatch(bad), /"not a pattern" is not a byte pattern/);
|
||||
const far = { ...byName(SPECTER, 'Free shopping'), lines: [line('mask', '90', { address: 'F3 0F', offset: 'later' })] };
|
||||
assert.throws(() => convertPatch(far), /"later" is not a distance/);
|
||||
});
|
||||
|
||||
test('a patch is used whole or not at all: one bad line refuses all of it, and so does another program', () => {
|
||||
assert.throws(() => convertPatch(byName(SPECTER, 'Bigger draw distance')), /writes new code/);
|
||||
assert.throws(() => convertPatch(byName(SPECTER, 'Photo mode in the launcher')), /it is for launcher\.elf, a different program of the game/);
|
||||
// Unless that program is the one running.
|
||||
assert.equal(convertPatch(byName(SPECTER, 'Photo mode in the launcher'), 'launcher.elf').length, 1);
|
||||
});
|
||||
|
||||
test('which patches fit the running version: exactly its own, and the ones made for any version', () => {
|
||||
assert.equal(versionMatch('01.000.000', '01.000.000'), 'exact');
|
||||
assert.equal(versionMatch('1.0', '01.000.000'), 'exact', 'the same version written with less padding');
|
||||
assert.equal(versionMatch('01.000.001', '01.000.000'), null);
|
||||
assert.equal(versionMatch('mask', '01.000.000'), 'any');
|
||||
assert.equal(versionMatch('All', '9'), 'any');
|
||||
assert.equal(versionMatch('01.000.000', ''), null, 'with no running version known, nothing is exact');
|
||||
|
||||
const lookup = { titleId: 'PPSA99999', file: { path: 'x', sha: 's', url: 'u' }, patches: SPECTER.patches, stale: false, read: 1, unread: 0 };
|
||||
const choices = patchesForVersion(lookup, '01.000.000');
|
||||
assert.deepEqual(
|
||||
choices.map((c) => [c.patch.name, c.match, c.problem && c.problem.replace(/:.*/, '')]),
|
||||
[
|
||||
['Infinite ammo', 'exact', null],
|
||||
['60 FPS & no motion blur', 'exact', null],
|
||||
['Rename the debug flag', 'exact', null],
|
||||
['Bigger draw distance', 'exact', 'This patch cannot be used'],
|
||||
['Photo mode in the launcher', 'exact', 'This patch cannot be used'],
|
||||
['Too big', 'exact', 'This patch cannot be used'],
|
||||
['Free shopping', 'any', null],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(otherVersions(lookup, '01.000.000'), ['00.900.000']);
|
||||
assert.deepEqual(otherVersions(lookup, '00.900.000'), ['01.000.000'], '"1.0" is the same version, written shorter');
|
||||
assert.deepEqual(patchesForVersion(lookup, '02.000.000').map((c) => c.patch.name), ['Free shopping']);
|
||||
});
|
||||
|
||||
// ---- finding a game's file ----------------------------------------------------------
|
||||
|
||||
/** Stands in for GitHub: the listing and the fixture files, counting what is asked for. */
|
||||
function fakeGitHub(overrides = {}) {
|
||||
const asked = [];
|
||||
const fetchText = async (url) => {
|
||||
asked.push(url);
|
||||
if (overrides[url]) return overrides[url]();
|
||||
if (url.startsWith('https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main')) return fixture('tree.json');
|
||||
const m = /^https:\/\/raw\.githubusercontent\.com\/illusion0001\/PS4-PS5-Game-Patch\/main\/patches\/xml(?:_prospero)?\/(.+)$/.exec(url);
|
||||
if (m && fs.existsSync(path.join(FIXTURES, decodeURIComponent(m[1])))) return fixture(decodeURIComponent(m[1]));
|
||||
throw new Error('HTTP 404');
|
||||
};
|
||||
return { fetchText, asked, files: () => asked.filter((u) => u.startsWith('https://raw.')).map((u) => u.slice(u.lastIndexOf('/') + 1)) };
|
||||
}
|
||||
|
||||
test('the listing: only patch files directly in the PS4 and PS5 folders', () => {
|
||||
assert.deepEqual(parseTree(fixture('tree.json')).map((f) => f.path), [
|
||||
'patches/xml/TestGame-Orbis.xml',
|
||||
'patches/xml_prospero/Broken.xml',
|
||||
'patches/xml_prospero/OtherGame.xml',
|
||||
'patches/xml_prospero/SpecterRun.xml',
|
||||
]);
|
||||
assert.throws(() => parseTree('<html>rate limited</html>'), /not a file list/);
|
||||
assert.throws(() => parseTree('{"message":"API rate limit exceeded"}'), /not a file list/);
|
||||
assert.equal(folderFor('PPSA01575'), 'patches/xml_prospero/');
|
||||
assert.equal(folderFor('CUSA00001'), 'patches/xml/');
|
||||
assert.equal(rawUrl('patches/xml/Marvel\'s Spider-Man.xml'), "https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/patches/xml/Marvel's%20Spider-Man.xml");
|
||||
// The likeliest files by name are read first.
|
||||
assert.ok(nameScore('patches/xml_prospero/SpecterRun.xml', 'Specter Run') > nameScore('patches/xml_prospero/OtherGame.xml', 'Specter Run'));
|
||||
assert.ok(nameScore('patches/xml/DemonSouls-Orbis.xml', "Demon's Souls") >= 50);
|
||||
assert.equal(nameScore('patches/xml/X.xml', ''), 0);
|
||||
});
|
||||
|
||||
test('finds the game\'s file reading the likeliest first, then remembers it: the next look downloads nothing', async () => {
|
||||
store.clear();
|
||||
const gh = fakeGitHub();
|
||||
const progress = [];
|
||||
const found = await findGamePatches('ppsa99999', { gameName: 'Specter Run', fetchText: gh.fetchText, onProgress: (d, t) => progress.push([d, t]) });
|
||||
assert.equal(found.titleId, 'PPSA99999');
|
||||
assert.equal(found.file.path, 'patches/xml_prospero/SpecterRun.xml');
|
||||
assert.equal(found.file.url, 'https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/patches/xml_prospero/SpecterRun.xml');
|
||||
assert.equal(found.patches.length, 8);
|
||||
assert.equal(found.stale, false);
|
||||
// The PS5 folder only, SpecterRun.xml first by its name; the others may be read alongside it, at most.
|
||||
assert.equal(gh.files()[0], 'SpecterRun.xml');
|
||||
assert.ok(!gh.files().includes('TestGame-Orbis.xml'));
|
||||
assert.deepEqual(progress[0], [0, 3]);
|
||||
|
||||
const again = fakeGitHub();
|
||||
const second = await findGamePatches('PPSA99999', { fetchText: again.fetchText });
|
||||
assert.equal(second.file.path, 'patches/xml_prospero/SpecterRun.xml');
|
||||
assert.deepEqual(again.asked, [], 'the listing is trusted for a while, and the file was kept');
|
||||
assert.equal(second.read, 0);
|
||||
});
|
||||
|
||||
test('a game no file covers: every unseen file of its folder is read once, then the answer comes without downloads', async () => {
|
||||
store.clear();
|
||||
const gh = fakeGitHub();
|
||||
const none = await findGamePatches('PPSA55555', { fetchText: gh.fetchText });
|
||||
assert.equal(none.file, null);
|
||||
assert.deepEqual(none.patches, []);
|
||||
assert.deepEqual(gh.files().sort(), ['Broken.xml', 'OtherGame.xml', 'SpecterRun.xml']);
|
||||
const again = fakeGitHub();
|
||||
assert.equal((await findGamePatches('PPSA55555', { fetchText: again.fetchText })).file, null);
|
||||
assert.deepEqual(again.asked, []);
|
||||
// Another game in the same folder is now known without reading anything either.
|
||||
const other = await findGamePatches('PPSA11111', { fetchText: again.fetchText });
|
||||
assert.equal(other.file.path, 'patches/xml_prospero/OtherGame.xml');
|
||||
assert.deepEqual(again.files(), ['OtherGame.xml'], 'only the file itself, to have its patches');
|
||||
// A PS4 game looks in the PS4 folder.
|
||||
const ps4 = await findGamePatches('CUSA00001', { fetchText: again.fetchText });
|
||||
assert.equal(ps4.file.path, 'patches/xml/TestGame-Orbis.xml');
|
||||
});
|
||||
|
||||
test('a look can be told not to read a whole folder without asking: it says how many files are left', async () => {
|
||||
store.clear();
|
||||
const gh = fakeGitHub();
|
||||
const ask = await findGamePatches('PPSA55555', { fetchText: gh.fetchText, maxReads: 2 });
|
||||
assert.deepEqual([ask.file, ask.unread], [null, 3]);
|
||||
assert.deepEqual(gh.files(), []);
|
||||
// Once the person agrees.
|
||||
const all = await findGamePatches('PPSA55555', { fetchText: gh.fetchText });
|
||||
assert.deepEqual([all.file, all.unread], [null, 0]);
|
||||
// From then on nothing is left to read, so the limit no longer stops it.
|
||||
assert.equal((await findGamePatches('PPSA11111', { fetchText: gh.fetchText, maxReads: 0 })).file.path, 'patches/xml_prospero/OtherGame.xml');
|
||||
});
|
||||
|
||||
test('a file that changed online is read again; one that is gone is forgotten', async () => {
|
||||
store.clear();
|
||||
await findGamePatches('PPSA99999', { fetchText: fakeGitHub().fetchText });
|
||||
// Twelve hours on, the listing is asked for again, and SpecterRun.xml has a new hash.
|
||||
const tree = JSON.parse(fixture('tree.json'));
|
||||
tree.tree.find((t) => t.path.endsWith('SpecterRun.xml')).sha = 'f'.repeat(40);
|
||||
tree.tree = tree.tree.filter((t) => !t.path.endsWith('OtherGame.xml'));
|
||||
const saved = JSON.parse(store.get('cheatspecter.gamepatch.tree'));
|
||||
store.set('cheatspecter.gamepatch.tree', JSON.stringify({ ...saved, at: 0 }));
|
||||
const gh = fakeGitHub({ 'https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main?recursive=1': async () => JSON.stringify(tree) });
|
||||
const found = await findGamePatches('PPSA99999', { fetchText: gh.fetchText });
|
||||
assert.equal(found.file.sha, 'f'.repeat(40));
|
||||
assert.deepEqual(gh.files(), ['SpecterRun.xml']);
|
||||
const ids = JSON.parse(store.get('cheatspecter.gamepatch.ids'));
|
||||
assert.equal(ids['patches/xml_prospero/OtherGame.xml'], undefined);
|
||||
});
|
||||
|
||||
test('offline: the saved copy is used and said to be old; with nothing saved, the reason comes through', async () => {
|
||||
store.clear();
|
||||
await findGamePatches('PPSA99999', { fetchText: fakeGitHub().fetchText });
|
||||
const saved = JSON.parse(store.get('cheatspecter.gamepatch.tree'));
|
||||
store.set('cheatspecter.gamepatch.tree', JSON.stringify({ ...saved, at: 0 }));
|
||||
const offline = async () => {
|
||||
throw new Error('the download timed out');
|
||||
};
|
||||
const stale = await findGamePatches('PPSA99999', { fetchText: offline });
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.patches.length, 8);
|
||||
store.clear();
|
||||
await assert.rejects(findGamePatches('PPSA99999', { fetchText: offline }), /timed out/);
|
||||
// GitHub refusing to list (rate limited) is a failure like any other, said in its words.
|
||||
await assert.rejects(findGamePatches('PPSA99999', { fetchText: async () => '{"message":"API rate limit exceeded"}' }), /not a file list/);
|
||||
});
|
||||
|
||||
test('a look can be canceled; what was learned up to then is kept', async () => {
|
||||
store.clear();
|
||||
const stop = new AbortController();
|
||||
const gh = fakeGitHub();
|
||||
const slow = async (url, signal) => {
|
||||
if (url.startsWith('https://raw.')) {
|
||||
stop.abort();
|
||||
if (signal?.aborted) throw new DOMException('Canceled', 'AbortError');
|
||||
}
|
||||
return gh.fetchText(url);
|
||||
};
|
||||
await assert.rejects(findGamePatches('PPSA55555', { fetchText: slow, signal: stop.signal }), (e) => e.name === 'AbortError');
|
||||
});
|
||||
|
||||
// ---- entries, against the mock game ------------------------------------------------------
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const x86 = asAsync(createX86(require('iced-x86')));
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const TCP = 17891 + PORT_OFFSET;
|
||||
let mock, bridge, port;
|
||||
|
||||
before(async () => {
|
||||
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
|
||||
await mock.ready;
|
||||
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 't' } });
|
||||
port = await bridge.ready;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await bridge?.close();
|
||||
await mock?.close();
|
||||
});
|
||||
|
||||
const context = async () => {
|
||||
const dbg = new Ps5Debug(await openWebSocketStream(`ws://127.0.0.1:${port}/ws?host=127.0.0.1&port=${TCP}`), { timeoutMs: 4000 });
|
||||
return { dbg, ctx: createGameContext({ pid: GAME.pid, dbg: () => dbg, x86 }) };
|
||||
};
|
||||
const imageHex = (offset, length) => bytesToHex(mock.game.image.subarray(offset, offset + length));
|
||||
|
||||
test('patchesForGame: exact-version patches as entries, with what the game holds now as "off", and where they came from', async () => {
|
||||
store.clear();
|
||||
const { dbg, ctx } = await context();
|
||||
try {
|
||||
const res = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { ctx, fetchText: fakeGitHub().fetchText, gameName: 'Specter Run' });
|
||||
assert.equal(res.source.path, 'patches/xml_prospero/SpecterRun.xml');
|
||||
assert.deepEqual(res.otherVersions, ['00.900.000']);
|
||||
assert.deepEqual(res.entries.map((e) => e.name), ['Infinite ammo', '60 FPS & no motion blur', 'Rename the debug flag', 'Free shopping']);
|
||||
assert.deepEqual(res.refused.map((r) => r.name), ['Bigger draw distance', 'Photo mode in the launcher', 'Too big']);
|
||||
assert.match(res.refused[0].reason, /^This patch cannot be used: it writes new code into free space/);
|
||||
assert.match(res.refused[2].reason, /0x1ff does not fit in 1 byte/);
|
||||
|
||||
const ammo = res.entries[0];
|
||||
assert.equal(ammo.type, 'patch');
|
||||
assert.deepEqual(ammo.at, { kind: 'module', offset: '2040' });
|
||||
assert.equal(ammo.on, '909090');
|
||||
assert.equal(ammo.off, 'FF4B28', 'read from the game');
|
||||
assert.equal(ammo.more, undefined);
|
||||
assert.equal(ammo.note, 'Shots no longer use ammo. By tester.');
|
||||
// The same origin every cheat saved from elsewhere has (EntryOrigin in table.ts), plus the patch's own key, version and author.
|
||||
assert.deepEqual(ammo.origin, {
|
||||
source: GAME_PATCH_SOURCE_ID,
|
||||
file: 'patches/xml_prospero/SpecterRun.xml',
|
||||
format: 'xml',
|
||||
key: byName(SPECTER, 'Infinite ammo', '01.000.000').key,
|
||||
version: '01.000.000',
|
||||
author: 'tester',
|
||||
});
|
||||
assert.equal(gamePatchOrigin(ammo), ammo.origin);
|
||||
assert.equal(gamePatchOrigin({ origin: { source: 'etahen', file: 'x', format: 'json' } }), null);
|
||||
// What is true of the whole file is said once, on the source the entries are saved under.
|
||||
assert.equal(res.gameName, 'Specter Run');
|
||||
const src = gamePatchSource(res, GAME, new Date('2026-10-12T00:00:00Z'));
|
||||
assert.deepEqual({ ...src, cheats: undefined, digest: undefined }, {
|
||||
id: 'gamepatch/patches/xml_prospero/SpecterRun.xml',
|
||||
kind: 'gamepatch',
|
||||
source: 'gamepatch',
|
||||
sourceName: 'PS4-PS5-Game-Patch',
|
||||
file: 'patches/xml_prospero/SpecterRun.xml',
|
||||
format: 'xml',
|
||||
url: 'https://raw.githubusercontent.com/illusion0001/PS4-PS5-Game-Patch/main/patches/xml_prospero/SpecterRun.xml',
|
||||
sha: 'b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2',
|
||||
version: GAME.version,
|
||||
savedAt: '2026-10-12T00:00:00.000Z',
|
||||
name: 'Specter Run',
|
||||
credits: ['tester', 'tester, helper'],
|
||||
cheats: undefined,
|
||||
digest: undefined,
|
||||
});
|
||||
assert.equal(src.cheats, res.entries);
|
||||
|
||||
// Several lines: the first place, then the rest.
|
||||
const fps = res.entries[1];
|
||||
assert.equal(fps.more.length, 5);
|
||||
// These places are in the game's data, which starts 0x10000 into it.
|
||||
const dataHex = (offset, length) => bytesToHex(mock.game.data.subarray(offset - 0x10000, offset - 0x10000 + length));
|
||||
assert.equal(fps.off, dataHex(0x10800, 4));
|
||||
assert.deepEqual(fps.more.map((m) => m.off), [[0x10810, 4], [0x10820, 1], [0x10822, 2], [0x10828, 8], [0x10830, 8]].map(([o, n]) => dataHex(o, n)));
|
||||
// A pattern gets the fixed position it was found at.
|
||||
const shop = res.entries[3];
|
||||
assert.deepEqual(shop.at, { kind: 'signature', pattern: 'F3 0F 5C C1', offset: 0 });
|
||||
assert.deepEqual(shop.fallback, { kind: 'module', offset: '30A0' });
|
||||
assert.equal(shop.off, 'F30F5CC1');
|
||||
|
||||
// Saved into a .cspector file as a source they come back the same, origin included, and so does the source.
|
||||
const table = withSource(emptyTable(GAME.titleId, GAME.version, 'Specter Run'), src);
|
||||
const back = parseOwnTable(saveFileText(table), { own: true });
|
||||
const plain = (list) => JSON.parse(JSON.stringify(list.map((e) => ({ ...e, id: undefined }))));
|
||||
assert.equal(back.entries.length, 0, 'not among the person\'s own cheats');
|
||||
assert.deepEqual(plain(back.sources[0].cheats), plain(res.entries));
|
||||
assert.deepEqual({ ...back.sources[0], cheats: undefined }, { ...src, cheats: undefined });
|
||||
assert.equal(savedGamePatches(back.sources, GAME.version).id, src.id);
|
||||
assert.equal(savedGamePatches(back.sources, '02.000.000'), null, 'only for the version they were saved for');
|
||||
assert.ok(back.sources[0].cheats.every((e) => gamePatchOrigin(e)), 'still told apart as game patches');
|
||||
// Copied into the person's own cheats ("Add to your cheats") a patch is theirs: it loses its origin, can be kept on, and is still known as the same patch.
|
||||
const mine = parseOwnTable(saveFileText({ ...emptyTable(GAME.titleId, GAME.version), entries: res.entries }), { own: true });
|
||||
assert.ok(mine.entries.every((e) => e.origin === undefined));
|
||||
for (const e of mine.entries) assert.equal(keepProblem(e), null, e.name);
|
||||
assert.ok(samePatch(mine.entries[0], back.sources[0].cheats[0]));
|
||||
assert.ok(!samePatch(mine.entries[0], back.sources[0].cheats[1]));
|
||||
|
||||
// Switched on and off like any patch.
|
||||
const runner = new TableRunner(ctx);
|
||||
await runner.enable(ammo);
|
||||
assert.equal(imageHex(0x2040, 3), '909090');
|
||||
await runner.disable(ammo);
|
||||
assert.equal(imageHex(0x2040, 3), 'FF4B28');
|
||||
await runner.enable(shop);
|
||||
assert.equal(imageHex(0x30a0, 4), '90909090');
|
||||
await runner.disable(shop);
|
||||
assert.equal(imageHex(0x30a0, 4), 'F30F5CC1');
|
||||
|
||||
// A patch already in the game (applied at boot by another tool): "off" cannot be known, so it is left out.
|
||||
mock.game.image.fill(0x90, 0x2040, 0x2043);
|
||||
const again = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { ctx, fetchText: fakeGitHub().fetchText });
|
||||
assert.equal(again.entries[0].off, undefined);
|
||||
GAME.code.ammoSpend.bytes.copy(mock.game.image, 0x2040);
|
||||
} finally {
|
||||
dbg.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('without the game, entries are made all the same, just without "off"; another version gets none', async () => {
|
||||
store.clear();
|
||||
const res = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { fetchText: fakeGitHub().fetchText });
|
||||
assert.equal(res.entries.length, 4);
|
||||
assert.ok(res.entries.every((e) => e.off === undefined && (e.more ?? []).every((m) => m.off === undefined)));
|
||||
const other = await patchesForGame({ titleId: GAME.titleId, version: '02.000.000' }, { fetchText: fakeGitHub().fetchText });
|
||||
assert.deepEqual(other.entries.map((e) => e.name), ['Free shopping']);
|
||||
assert.deepEqual(other.otherVersions, ['00.900.000', '01.000.000']);
|
||||
const none = await patchesForGame({ titleId: 'PPSA55555', version: '01.000.000' }, { fetchText: fakeGitHub().fetchText });
|
||||
assert.deepEqual(none, { source: null, gameName: '', entries: [], refused: [], otherVersions: [], stale: false });
|
||||
assert.equal(gamePatchSource(none, GAME), null, 'nothing to save');
|
||||
assert.deepEqual((await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { lookup: { titleId: GAME.titleId, file: null, patches: [], stale: false, read: 0, unread: 9 } })).entries, []);
|
||||
});
|
||||
|
||||
test('Check for updates on saved game patches: an unchanged file is not read again; a changed one says what changed', async () => {
|
||||
store.clear();
|
||||
const first = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { fetchText: fakeGitHub().fetchText });
|
||||
const src = gamePatchSource(first, GAME);
|
||||
|
||||
// The same file (same hash in the listing): nothing downloaded but the listing, asked for afresh when the person presses the button.
|
||||
const gh = fakeGitHub();
|
||||
const same = await checkGamePatches(src, GAME, { fetchText: gh.fetchText, fresh: true });
|
||||
assert.equal(same.same, true);
|
||||
assert.equal(same.fresh, null);
|
||||
assert.deepEqual(gh.files(), []);
|
||||
assert.equal(gh.asked.length, 1, 'only the listing');
|
||||
assert.deepEqual(same.result.refused.map((r) => r.name), ['Bigger draw distance', 'Photo mode in the launcher', 'Too big'], 'what cannot be used is still said');
|
||||
|
||||
// The file changed online: a new hash, and one patch writes something else.
|
||||
const tree = JSON.parse(fixture('tree.json'));
|
||||
tree.tree.find((t) => t.path.endsWith('SpecterRun.xml')).sha = 'e'.repeat(40);
|
||||
const changedXml = fixture('SpecterRun.xml').replace('Value="909090"', 'Value="909091"');
|
||||
const gh2 = fakeGitHub({
|
||||
'https://api.github.com/repos/illusion0001/PS4-PS5-Game-Patch/git/trees/main?recursive=1': async () => JSON.stringify(tree),
|
||||
[rawUrl('patches/xml_prospero/SpecterRun.xml')]: async () => changedXml,
|
||||
});
|
||||
const changed = await checkGamePatches(src, GAME, { fetchText: gh2.fetchText, fresh: true });
|
||||
assert.equal(changed.same, false);
|
||||
assert.equal(changed.fresh.sha, 'e'.repeat(40));
|
||||
assert.deepEqual(changed.changes, { added: [], removed: [], changed: ['Infinite ammo'] });
|
||||
|
||||
// GitHub out of reach: the check fails with the reason, rather than answer from the copy kept in this browser.
|
||||
const saved = JSON.parse(store.get('cheatspecter.gamepatch.tree'));
|
||||
store.set('cheatspecter.gamepatch.tree', JSON.stringify({ ...saved, at: 0 }));
|
||||
await assert.rejects(
|
||||
checkGamePatches(src, GAME, {
|
||||
fetchText: async () => {
|
||||
throw new Error('the download timed out');
|
||||
},
|
||||
}),
|
||||
/could not be reached|timed out/,
|
||||
);
|
||||
});
|
||||
|
||||
// ---- kept on by the PS5 ------------------------------------------------------------
|
||||
|
||||
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
||||
let skipPayload = false;
|
||||
try {
|
||||
if (!fs.existsSync(path.join(ROOT, 'dist/index.html'))) throw new Error('dist missing');
|
||||
execFileSync(process.execPath, [path.join(ROOT, 'scripts/build-payload.mjs'), 'host'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
skipPayload = 'needs a C compiler and a built web app (npm run build)';
|
||||
}
|
||||
|
||||
test('converted patches marked "Keep on" are switched on by the PS5 itself', { skip: skipPayload }, async () => {
|
||||
store.clear();
|
||||
const res = await patchesForGame({ titleId: GAME.titleId, version: GAME.version }, { fetchText: fakeGitHub().fetchText });
|
||||
const kept = res.entries.filter((e) => ['Infinite ammo', 'Free shopping'].includes(e.name)).map((e) => ({ ...e, keep: true }));
|
||||
const fake = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-gamepatch-'));
|
||||
fs.mkdirSync(path.join(fake, 'data/cheatspecter/cheats'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fake, `data/cheatspecter/cheats/${GAME.titleId}-${GAME.version}.cspector`), saveFileText({ ...emptyTable(GAME.titleId, GAME.version, 'Specter Run'), entries: kept }));
|
||||
const PORT = 19171 + PORT_OFFSET;
|
||||
const proc = spawn(BIN, [], {
|
||||
env: { ...process.env, CS_PORT: String(PORT), CS_DEBUGGER_PORT: String(TCP), CS_EVENT_PORT: String(17991 + PORT_OFFSET), CS_ROOT: fake, CS_SHADOWMOUNT_PORT: '1', CS_ENGINE_SPEED: '10' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
try {
|
||||
let st;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
st = await fetch(`http://127.0.0.1:${PORT}/api/engine`).then((r) => r.json()).catch(() => null);
|
||||
if (st?.cheats?.length === 2 && st.cheats.every((c) => c.state === 'on')) break;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
assert.deepEqual(st.cheats.map((c) => [c.name, c.state]), [['Infinite ammo', 'on'], ['Free shopping', 'on']]);
|
||||
assert.equal(imageHex(0x2040, 3), '909090');
|
||||
assert.equal(imageHex(0x30a0, 4), '90909090');
|
||||
} finally {
|
||||
proc.kill();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
GAME.code.ammoSpend.bytes.copy(mock.game.image, 0x2040);
|
||||
GAME.code.goldSpend.bytes.copy(mock.game.image, 0x30a0);
|
||||
fs.rmSync(fake, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+26
-2
@@ -23,6 +23,7 @@ test('every field of the engine\'s answer is read, and answers from before a fie
|
||||
file: FILE,
|
||||
exact: false,
|
||||
busy: true,
|
||||
crashGuard: false,
|
||||
error: 'Only the first 256 cheats marked to keep on are used.',
|
||||
cheats: [cheat('A', 'on'), cheat('B', 'refused', 'why')],
|
||||
});
|
||||
@@ -40,6 +41,28 @@ test('a refused cheat is never taken for a kept one of the same name', () => {
|
||||
assert.equal(keptStatus(status, GAME.titleId, 'Twin', 2), null);
|
||||
});
|
||||
|
||||
test('a cheat on only for the start set is never taken for a kept one of the same name', () => {
|
||||
// The engine lists the person's own cheats in file order, kept and start set alike, then the saved tables' start set cheats.
|
||||
const doc = answer({ cheats: [{ ...cheat('Twin', 'held', 'Held back'), start: true }, cheat('Twin', 'on'), { ...cheat('Twin', 'on'), start: true }, cheat('Twin', 'mismatch', 'x')] });
|
||||
const status = parseEngineStatus(doc);
|
||||
assert.equal(status.cheats[0].start, true);
|
||||
assert.equal(status.cheats[1].start, undefined);
|
||||
assert.equal(keptStatus(status, GAME.titleId, 'Twin', 0).state, 'on');
|
||||
assert.equal(keptStatus(status, GAME.titleId, 'Twin', 1).state, 'mismatch');
|
||||
assert.equal(keptStatus(status, GAME.titleId, 'Twin', 2), null);
|
||||
// In the summary, start set cheats are not counted as kept on; one held back is still said.
|
||||
const o = keptOverview(status, GAME, 2);
|
||||
assert.match(o.text, /keeping 1 cheat on by itself/);
|
||||
assert.match(o.text, /1 does not fit this version/);
|
||||
assert.match(o.text, /switched on 1 cheat of this game's start set/);
|
||||
assert.match(o.text, /did not switch "Twin" on/);
|
||||
assert.equal(o.kind, 'warn');
|
||||
// Only a start set, nothing kept.
|
||||
const only = keptOverview(parseEngineStatus(answer({ cheats: [{ ...cheat('Fly', 'on'), start: true }] })), GAME, 0);
|
||||
assert.equal(only.kind, 'info');
|
||||
assert.match(only.text, /has no cheats to keep on for this game\. It switched on 1 cheat of this game's start set/);
|
||||
});
|
||||
|
||||
test('the summary above the cheats: nothing to say, all well, and each thing that can be wrong', () => {
|
||||
const over = (doc, kept = 1) => keptOverview(doc === null ? null : parseEngineStatus(doc), GAME, kept);
|
||||
|
||||
@@ -86,10 +109,11 @@ test('the summary above the cheats: nothing to say, all well, and each thing tha
|
||||
// The file: none, another version's, damaged, too long.
|
||||
o = over(answer({ file: '', exact: undefined }));
|
||||
assert.equal(o.kind, 'warn');
|
||||
assert.match(o.lines[0], /no cheat file for this game on your PS5 yet.*Save to PS5/);
|
||||
assert.match(o.lines[0], /no cheat file for this game on your PS5 yet.*made by itself as soon as you change one of your cheats/);
|
||||
assert.doesNotMatch(o.lines[0], /Save to PS5/, 'saving to the PS5 is automatic now; there is no such button');
|
||||
o = over(answer({ file: 'PPSA99999-00.900.000.cspector', exact: false, cheats: [cheat('A', 'on'), cheat('B', 'mismatch', 'saved for another version')] }), 2);
|
||||
assert.equal(o.kind, 'warn');
|
||||
assert.match(o.lines[0], /using the file saved for another version of this game \(PPSA99999-00\.900\.000\.cspector\).*Save to PS5/);
|
||||
assert.match(o.lines[0], /using the file saved for another version of this game \(PPSA99999-00\.900\.000\.cspector\).*Change any of your cheats here to give this version a file of its own/);
|
||||
o = over(answer({ exact: false, cheats: [cheat('A', 'on')] }));
|
||||
assert.match(o.lines[0], /The file on your PS5 \(PPSA99999-01\.000\.000\.cspector\) says it was made for another version/);
|
||||
o = over(answer({ error: 'The cheat file for this game is damaged (it is not valid JSON), so no cheats were switched on. Save your cheats again from CheatSpecter.' }));
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
// Local-first cheat files: community tables saved into the game's .cspector
|
||||
// file on the PS5, the start set, deleting the file, and reading the cheat
|
||||
// files other tools keep on the console.
|
||||
//
|
||||
// The format and the conversion are checked in plain code; the payload
|
||||
// (built for this computer, in front of the mock console) for its endpoints
|
||||
// and for the engine's side of the start set; the bridge for passing the new
|
||||
// endpoints through and for its demo console. Payload parts are skipped when
|
||||
// there is no C compiler or the web app is not built.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/localfirst.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startMock, GAME } from '../mock/mock-ps5.mjs';
|
||||
import { demoEngineStatus, startBridge } from '../bridge/server.mjs';
|
||||
import {
|
||||
CONSOLE_SOURCE_ID,
|
||||
checkSource,
|
||||
communitySource,
|
||||
entryInfo,
|
||||
findTables,
|
||||
listConsoleFiles,
|
||||
sourceChanges,
|
||||
sourceTable,
|
||||
storedEntry,
|
||||
} from '../src/lib/catalog.ts';
|
||||
import { parseCheatTable } from '../src/lib/cheats.ts';
|
||||
import {
|
||||
canKeep,
|
||||
emptyTable,
|
||||
exportOwnTable,
|
||||
keepProblem,
|
||||
parseOwnTable,
|
||||
saveFileText,
|
||||
sourceDigest,
|
||||
startEntry,
|
||||
startsOnPs5,
|
||||
withSource,
|
||||
withoutSource,
|
||||
} from '../src/lib/table.ts';
|
||||
import { deletePs5Table, deviceFileText, loadPs5Table, savePs5Table, tableFileName } from '../src/lib/tablestore.ts';
|
||||
import { encodeMc4, parseAnyTable, toTrainerXml } from '../src/lib/trainer.ts';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const PORT = 19310 + PORT_OFFSET;
|
||||
const MOCK = 19410 + PORT_OFFSET;
|
||||
const EVENTS = 19510 + PORT_OFFSET;
|
||||
const SPEED = 10;
|
||||
|
||||
// ---- a community table with everything the format can say ---------------------------
|
||||
|
||||
const COMMUNITY = {
|
||||
name: 'Specter Run',
|
||||
id: 'PPSA99999',
|
||||
version: '01.000.000',
|
||||
process: 'eboot.bin',
|
||||
credits: ['Someone', 'Someone else'],
|
||||
mods: [
|
||||
{ name: 'Infinite Ammo', description: 'Shots are free.', type: 'checkbox', memory: [{ offset: '2040', on: '909090', off: 'FF4B28' }] },
|
||||
{ name: 'Two places', type: 'checkbox', memory: [{ offset: '1000', on: '909090909090', off: '898190030000' }, { offset: '30A0', on: '90909090', off: 'F30F5CC1' }] },
|
||||
{ name: 'Max gold', type: 'button', memory: [{ offset: '5000', on: '0F27', off: '' }] },
|
||||
{ name: 'In a library', type: 'checkbox', memory: [{ offset: '40', on: 'C3', off: '90', section: 3, sectionName: 'libgame.prx', sectionOffset: '40', sectionProtection: 5 }] },
|
||||
{ name: 'Section only', type: 'checkbox', memory: [{ offset: '80', on: 'C3', off: '90', section: 2 }] },
|
||||
{ name: 'Fixed address', type: 'checkbox', memory: [{ offset: '880001000', on: '01', off: '00', absolute: true }] },
|
||||
{ name: 'Nothing in it', type: 'checkbox', memory: [] },
|
||||
{ name: 'Infinite Ammo', type: 'checkbox', memory: [{ offset: '2041', on: '90', off: '4B' }] },
|
||||
],
|
||||
};
|
||||
const INFO = { kind: 'community', source: 'etahen', sourceName: 'etaHEN PS5 Cheats', file: 'PPSA99999_01.000.000.json', format: 'json', url: 'https://example.invalid/PPSA99999_01.000.000.json' };
|
||||
const ID = 'etahen/PPSA99999_01.000.000.json';
|
||||
|
||||
/** Bigints and bytes as text, so two tables can be compared whole. */
|
||||
const plain = (t) => JSON.parse(JSON.stringify(t, (_k, v) => (typeof v === 'bigint' ? `n${v}` : v instanceof Uint8Array ? Buffer.from(v).toString('hex') : v)));
|
||||
|
||||
test('a community table saved into the file opens as exactly the same table', () => {
|
||||
const table = parseCheatTable(JSON.stringify(COMMUNITY));
|
||||
const src = communitySource(table, INFO, new Date('2026-10-11T12:00:00Z'));
|
||||
assert.equal(src.id, ID);
|
||||
assert.equal(src.version, '01.000.000');
|
||||
assert.equal(src.savedAt, '2026-10-11T12:00:00.000Z');
|
||||
assert.deepEqual(src.credits, ['Someone', 'Someone else']);
|
||||
assert.equal(src.process, undefined, 'eboot.bin is what a table counts from anyway');
|
||||
// Each cheat keeps where it came from.
|
||||
for (const e of src.cheats) assert.deepEqual({ source: e.origin.source, file: e.origin.file, format: e.origin.format }, { source: 'etahen', file: INFO.file, format: 'json' });
|
||||
// Through the file and back.
|
||||
const own = withSource(emptyTable('PPSA99999', '01.000.000', 'Specter Run'), src);
|
||||
const back = parseOwnTable(saveFileText(own), { own: true });
|
||||
assert.equal(back.sources.length, 1);
|
||||
assert.equal(sourceDigest(back.sources[0].cheats), src.digest, 'the same cheats give the same digest after a save');
|
||||
assert.deepEqual(sourceChanges(src, back.sources[0]), { added: [], removed: [], changed: [] });
|
||||
assert.deepEqual(plain(sourceTable(back.sources[0], 'PPSA99999')), plain(table));
|
||||
// A file someone else made opens the same way.
|
||||
assert.deepEqual(plain(sourceTable(parseOwnTable(saveFileText(own)).sources[0], 'PPSA99999')), plain(table));
|
||||
});
|
||||
|
||||
test('SHN and MC4 tables convert the same way, a second executable included', () => {
|
||||
const table = parseCheatTable(JSON.stringify({ ...COMMUNITY, process: 'other.elf', mods: COMMUNITY.mods.slice(0, 3) }));
|
||||
for (const [format, text] of [['shn', toTrainerXml(table)], ['mc4', encodeMc4(toTrainerXml(table))]]) {
|
||||
const parsed = parseAnyTable(text);
|
||||
const src = communitySource(parsed, { ...INFO, format, file: `PPSA99999_01.000.000.${format}` });
|
||||
const back = parseOwnTable(saveFileText(withSource(emptyTable('PPSA99999', '01.000.000'), src)), { own: true }).sources[0];
|
||||
assert.equal(back.process, 'other.elf');
|
||||
assert.deepEqual(plain(sourceTable(back, 'PPSA99999')), plain(parsed), format);
|
||||
}
|
||||
});
|
||||
|
||||
test('cheats from a table never turn "Keep on" on, and the ones only the page can place say so', () => {
|
||||
const src = communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO);
|
||||
const byName = (n) => src.cheats.find((e) => e.name === n);
|
||||
for (const n of ['In a library', 'Section only', 'Fixed address', 'Nothing in it']) {
|
||||
assert.equal(byName(n).pageOnly, true, n);
|
||||
assert.equal(startsOnPs5(byName(n)), false, n);
|
||||
assert.match(keepProblem(byName(n)), /Only CheatSpecter in the browser can place this cheat/);
|
||||
}
|
||||
for (const n of ['Infinite Ammo', 'Two places', 'Max gold']) {
|
||||
assert.equal(byName(n).pageOnly, undefined, n);
|
||||
assert.equal(startsOnPs5(byName(n)), true, n);
|
||||
}
|
||||
// A file asking for "keep" on a source's cheat, or a "page only" mark taken away by hand, changes nothing.
|
||||
const own = withSource(emptyTable('PPSA99999', '01.000.000'), src);
|
||||
const doc = JSON.parse(saveFileText(own));
|
||||
for (const c of doc.sources[0].cheats) {
|
||||
c.keep = true;
|
||||
delete c.pageOnly;
|
||||
}
|
||||
const back = parseOwnTable(JSON.stringify(doc), { own: true }).sources[0];
|
||||
assert.equal(back.cheats.some((e) => e.keep), false);
|
||||
assert.equal(back.cheats.find((e) => e.name === 'Section only').pageOnly, true);
|
||||
// The person's own cheats never carry an origin.
|
||||
const mine = parseOwnTable(JSON.stringify({ format: 'cheatspecter', formatVersion: 1, game: {}, cheats: [{ name: 'X', type: 'patch', at: { kind: 'module', offset: '0x10' }, on: '90', origin: { source: 'x', file: 'y', format: 'json' } }] }), { own: true });
|
||||
assert.equal(mine.entries[0].origin, undefined);
|
||||
assert.equal(canKeep(mine.entries[0]), true);
|
||||
});
|
||||
|
||||
test('the start set: read, written before the cheats, and pointing at the right cheat', () => {
|
||||
const src = communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO);
|
||||
const own = {
|
||||
...withSource(emptyTable('PPSA99999', '01.000.000'), src),
|
||||
entries: [
|
||||
{ id: 'a', name: 'Mine', type: 'patch', at: { kind: 'module', offset: '0x10' }, on: '90', off: 'CC' },
|
||||
{ id: 'b', name: 'Held', type: 'value', at: { kind: 'pointer', base: '0x100', offsets: ['0x8'] }, valueType: 'u32', value: '5', freeze: true },
|
||||
{ id: 'c', name: 'Script', type: 'script', code: 'cs.log(1)' },
|
||||
],
|
||||
startSet: [{ name: 'Mine' }, { name: 'Held' }, { name: 'Script' }, { name: 'Infinite Ammo', from: ID }, { name: 'Gone', from: 'nowhere/x.json' }],
|
||||
};
|
||||
const text = saveFileText(own);
|
||||
// The engine on the console reads the file once from start to end: it needs the start set before the cheats.
|
||||
assert.ok(text.indexOf('"startSet"') < text.indexOf('"cheats"'));
|
||||
assert.ok(text.indexOf('"id"', text.indexOf('"sources"')) < text.indexOf('"cheats"', text.indexOf('"sources"')));
|
||||
const back = parseOwnTable(text, { own: true });
|
||||
assert.deepEqual(back.startSet, own.startSet);
|
||||
assert.equal(startEntry(back, back.startSet[0]).name, 'Mine');
|
||||
// Two cheats of one name in a table: the first one is meant.
|
||||
assert.equal(startEntry(back, back.startSet[3]).at.offset, '0x2040');
|
||||
assert.equal(startEntry(back, back.startSet[4]), null);
|
||||
assert.deepEqual(back.entries.map(startsOnPs5), [true, true, false]);
|
||||
// Odd entries are passed over; a plain string is one of the person's own.
|
||||
const odd = parseOwnTable(JSON.stringify({ ...JSON.parse(text), startSet: ['Mine', { name: '' }, { from: 'x' }, 5, { name: 'Mine' }, { name: 'Mine', from: '' }] }));
|
||||
assert.deepEqual(odd.startSet, [{ name: 'Mine' }]);
|
||||
// Removing a source takes its start set entries with it.
|
||||
const without = withoutSource(back, ID);
|
||||
assert.equal(without.sources, undefined);
|
||||
assert.deepEqual(without.startSet.map((s) => s.name), ['Mine', 'Held', 'Script', 'Gone']);
|
||||
// Replacing one keeps it in place.
|
||||
const again = withSource(back, { ...back.sources[0], savedAt: 'later' });
|
||||
assert.equal(again.sources.length, 1);
|
||||
assert.equal(again.sources[0].savedAt, 'later');
|
||||
});
|
||||
|
||||
test('older files still load, and the new parts stay out of the way of older apps and payloads', () => {
|
||||
// A file from before sources and start sets, and one in the older flat layout.
|
||||
const before = { format: 'cheatspecter', formatVersion: 1, game: { titleId: 'PPSA99999', version: '01.000.000', name: 'S' }, cheats: [{ name: 'A', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', keep: true }] };
|
||||
const t = parseOwnTable(JSON.stringify(before), { own: true });
|
||||
assert.equal(t.sources, undefined);
|
||||
assert.equal(t.startSet, undefined);
|
||||
assert.equal(t.entries[0].keep, true);
|
||||
assert.equal(JSON.parse(saveFileText(t)).sources, undefined, 'nothing new is written for a table that does not use it');
|
||||
const flat = parseOwnTable(JSON.stringify({ format: 'cheatspecter/1', titleId: 'PPSA99999', version: '1', name: 'S', entries: before.cheats, sources: [{ id: 'x/y', source: 'x', file: 'y', cheats: [] }] }));
|
||||
assert.equal(flat.entries.length, 1);
|
||||
assert.equal(flat.sources, undefined, 'the older layout never had sources');
|
||||
// The version stays 1, so an older app opens a new file; the new parts are top-level keys it never looks at.
|
||||
const doc = JSON.parse(saveFileText({ ...withSource(t, communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO)), startSet: [{ name: 'A' }] }));
|
||||
assert.equal(doc.formatVersion, 1);
|
||||
assert.deepEqual(Object.keys(doc), ['format', 'formatVersion', 'game', 'startSet', 'cheats', 'sources']);
|
||||
assert.deepEqual(doc.cheats, JSON.parse(saveFileText(t)).cheats);
|
||||
// Damaged sources are dropped one by one, not the file.
|
||||
const damaged = parseOwnTable(JSON.stringify({ ...doc, sources: [null, 5, { id: 'a' }, { source: 'x', file: 'y', cheats: 'no' }, { source: 'x', file: 'y', cheats: [{ name: 'Bad', type: 'patch' }, null] }, doc.sources[0], doc.sources[0]] }));
|
||||
assert.deepEqual(damaged.sources.map((s) => [s.id, s.cheats.length]), [['x/y', 0], [ID, 8]]);
|
||||
});
|
||||
|
||||
test('the copy saved to a device carries the tables and the start set, and none of what only means something on this console', () => {
|
||||
const src = communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO);
|
||||
const own = {
|
||||
...withSource(emptyTable('PPSA99999', '01.000.000'), src),
|
||||
entries: [
|
||||
{ id: 'a', name: 'Kept', type: 'patch', at: { kind: 'module', offset: '0x10' }, on: '90', keep: true },
|
||||
{ id: 'b', name: 'Raw', type: 'patch', at: { kind: 'address', address: '0x880001000' }, on: '90' },
|
||||
],
|
||||
startSet: [{ name: 'Kept' }],
|
||||
};
|
||||
const doc = JSON.parse(deviceFileText(own));
|
||||
assert.deepEqual(doc.cheats.map((c) => [c.name, c.keep]), [['Kept', undefined]]);
|
||||
assert.deepEqual(doc.startSet, [{ name: 'Kept' }]);
|
||||
// A community table's fixed addresses are what its makers wrote; they stay.
|
||||
assert.equal(doc.sources[0].cheats.length, 8);
|
||||
assert.ok(doc.sources[0].cheats.some((c) => c.at.kind === 'address'));
|
||||
assert.equal(exportOwnTable(own), deviceFileText(own));
|
||||
});
|
||||
|
||||
test('a saved table shows in the list as saved, matched against the running version', () => {
|
||||
const src = communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO);
|
||||
assert.deepEqual(storedEntry(src, 'ppsa99999', '1.0.0'), {
|
||||
sourceId: 'etahen',
|
||||
sourceName: 'etaHEN PS5 Cheats',
|
||||
file: INFO.file,
|
||||
url: INFO.url,
|
||||
titleId: 'PPSA99999',
|
||||
version: '01.000.000',
|
||||
gameName: 'Specter Run',
|
||||
format: 'json',
|
||||
exact: true,
|
||||
stored: ID,
|
||||
kind: 'community',
|
||||
label: 'JSON',
|
||||
});
|
||||
assert.equal(storedEntry(src, 'PPSA99999', '01.001.000').exact, false);
|
||||
// A file found on the console is read from the console again next time, whatever address it is reached at.
|
||||
assert.equal(entryInfo({ sourceId: CONSOLE_SOURCE_ID, sourceName: 'On this PS5', file: 'etahen/json/x.json', url: 'api/x', format: 'json', version: '1' }).url, undefined);
|
||||
});
|
||||
|
||||
test('another source of converted cheats (game patches) plugs in the same way', () => {
|
||||
// What a source such as src/lib/gamepatch.ts hands over: patch entries at fixed offsets in the game's main program.
|
||||
const cheats = [
|
||||
{ id: 'x', name: '60 FPS', note: 'By someone', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'FF4B28', origin: { source: 'gamepatch', file: 'PPSA99999.xml', format: 'xml' }, more: [{ at: { kind: 'module', offset: '0x2041' }, on: '90', off: '4B' }] },
|
||||
{ id: 'y', name: 'Found by pattern', type: 'patch', at: { kind: 'signature', pattern: 'FF 4B 28', offset: 0 }, on: '909090', origin: { source: 'gamepatch', file: 'PPSA99999.xml', format: 'xml' } },
|
||||
];
|
||||
const src = { id: 'gamepatch/PPSA99999.xml', kind: 'gamepatch', source: 'gamepatch', sourceName: 'Game patches', file: 'PPSA99999.xml', format: 'xml', version: '01.000.000', savedAt: '2026-10-11T00:00:00Z', digest: sourceDigest(cheats), cheats };
|
||||
const own = withSource(withSource(emptyTable('PPSA99999', '01.000.000'), communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO)), src);
|
||||
const back = parseOwnTable(saveFileText(own), { own: true });
|
||||
assert.deepEqual(back.sources.map((s) => s.kind), ['community', 'gamepatch']);
|
||||
const patches = back.sources[1];
|
||||
assert.equal(sourceDigest(patches.cheats), src.digest);
|
||||
assert.equal(storedEntry(patches, 'PPSA99999', '01.000.000').label, 'XML');
|
||||
const shown = sourceTable(patches, 'PPSA99999');
|
||||
assert.deepEqual(shown.mods.map((m) => [m.name, m.type, m.description, m.patches.map((p) => p.offset), m.unsupported]), [
|
||||
['60 FPS', 'checkbox', 'By someone', [0x2040n, 0x2041n], null],
|
||||
['Found by pattern', 'checkbox', '', [], 'This cheat is found by a byte pattern or pointer, which only "Your cheats" can do'],
|
||||
]);
|
||||
// The PS5 can switch on a plain patch from it as part of the start set.
|
||||
assert.equal(startsOnPs5(patches.cheats[0]), true);
|
||||
// Replacing it leaves the community table and the person's own cheats alone.
|
||||
const updated = withSource(back, { ...patches, cheats: patches.cheats.slice(0, 1) });
|
||||
assert.equal(updated.sources[0].cheats.length, back.sources[0].cheats.length);
|
||||
assert.equal(updated.sources[1].cheats.length, 1);
|
||||
});
|
||||
|
||||
test('what an update changes is told cheat by cheat', () => {
|
||||
const old = communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO);
|
||||
const mods = structuredClone(COMMUNITY.mods);
|
||||
mods[0].memory[0].on = '90C3C3';
|
||||
mods.splice(1, 1);
|
||||
mods.push({ name: 'Brand new', type: 'checkbox', memory: [{ offset: '10', on: '90', off: 'CC' }] });
|
||||
const fresh = communitySource(parseCheatTable(JSON.stringify({ ...COMMUNITY, mods })), INFO);
|
||||
assert.notEqual(fresh.digest, old.digest);
|
||||
assert.deepEqual(sourceChanges(old, fresh), { added: ['Brand new'], removed: ['Two places'], changed: ['Infinite Ammo'] });
|
||||
// The same cheats saved at another time are the same.
|
||||
assert.equal(communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO, new Date(0)).digest, old.digest);
|
||||
});
|
||||
|
||||
test('an update check reads the source itself, never a copy kept in the browser', async () => {
|
||||
const src = communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO);
|
||||
const real = globalThis.fetch;
|
||||
let answer = { status: 200, body: JSON.stringify(COMMUNITY) };
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.equal(String(url), INFO.url);
|
||||
return new Response(answer.body, { status: answer.status });
|
||||
};
|
||||
try {
|
||||
let check = await checkSource(src, null, 'PPSA99999');
|
||||
assert.equal(check.same, true);
|
||||
answer = { status: 200, body: JSON.stringify({ ...COMMUNITY, mods: COMMUNITY.mods.slice(1) }) };
|
||||
check = await checkSource(src, null, 'PPSA99999');
|
||||
assert.equal(check.same, false);
|
||||
assert.deepEqual(check.changes.removed, []);
|
||||
assert.deepEqual(check.changes.changed, ['Infinite Ammo'], 'two of one name: the second is what is left');
|
||||
answer = { status: 404, body: '' };
|
||||
await assert.rejects(checkSource(src, null, 'PPSA99999'), /HTTP 404/);
|
||||
await assert.rejects(checkSource({ ...src, url: undefined }, null, 'PPSA99999'), /does not say where it came from/);
|
||||
} finally {
|
||||
globalThis.fetch = real;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the payload ---------------------------------------------------------------------
|
||||
|
||||
let skip = false;
|
||||
try {
|
||||
if (!fs.existsSync(path.join(ROOT, 'dist/index.html'))) throw new Error('dist missing');
|
||||
execFileSync(process.execPath, [path.join(ROOT, 'scripts/build-payload.mjs'), 'host'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
skip = 'needs a C compiler and a built web app (npm run build)';
|
||||
}
|
||||
|
||||
const FAKE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-localfirst-'));
|
||||
const CHEATS = path.join(FAKE_ROOT, 'data/cheatspecter/cheats');
|
||||
const OWN_FILE = tableFileName(GAME.titleId, GAME.version);
|
||||
const put = (rel, data) => {
|
||||
const file = path.join(FAKE_ROOT, rel);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, data);
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const api = (p, init) => fetch(`${BASE}${p}`, init);
|
||||
const engine = async () => (await api('/api/engine')).json();
|
||||
|
||||
async function until(what, check, ms = 10000) {
|
||||
const deadline = Date.now() + ms;
|
||||
let last;
|
||||
for (;;) {
|
||||
last = await engine();
|
||||
if (check(last)) return last;
|
||||
if (Date.now() > deadline) assert.fail(`timed out waiting for ${what}; the engine says ${JSON.stringify(last)}`);
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
let mock, proc;
|
||||
|
||||
/** Puts the mock game's code and values back to how a freshly started game has them. */
|
||||
function resetGame() {
|
||||
const { image, heap, data } = mock.game;
|
||||
for (const c of Object.values(GAME.code)) c.bytes.copy(image, c.offset);
|
||||
heap.writeUInt32LE(100, GAME.vars.health);
|
||||
heap.writeUInt32LE(30, GAME.vars.ammo);
|
||||
data.writeBigUInt64LE(GAME.heapBase + BigInt(GAME.player), GAME.statics.player);
|
||||
}
|
||||
|
||||
/** Closes the game, puts `text` in place as its file, and starts it again under a new process id. */
|
||||
async function launch(text) {
|
||||
mock.state.foreground = false;
|
||||
await until('the game to be gone', (st) => st.running && st.game === null);
|
||||
// Closing the game moments after the start set came on looks like a crash to crash protection
|
||||
// (crashguard.ts), which would hold those cheats back next time; these tests are not about that.
|
||||
fs.rmSync(path.join(FAKE_ROOT, 'data/cheatspecter/prefs'), { recursive: true, force: true });
|
||||
fs.rmSync(CHEATS, { recursive: true, force: true });
|
||||
if (text !== null) put(`data/cheatspecter/cheats/${OWN_FILE}`, text);
|
||||
resetGame();
|
||||
GAME.pid += 1;
|
||||
mock.state.foreground = true;
|
||||
return until('the new game to be seen', (st) => st.game?.pid === GAME.pid);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: false, eventPort: EVENTS });
|
||||
await mock.ready;
|
||||
proc = spawn(BIN, [], {
|
||||
env: { ...process.env, CS_PORT: String(PORT), CS_DEBUGGER_PORT: String(MOCK), CS_EVENT_PORT: String(EVENTS), CS_ROOT: FAKE_ROOT, CS_SHADOWMOUNT_PORT: '1', CS_ENGINE_SPEED: String(SPEED) },
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const s = net.connect(PORT, '127.0.0.1');
|
||||
s.once('connect', () => (s.destroy(), resolve(true)));
|
||||
s.once('error', () => resolve(false));
|
||||
});
|
||||
if (ok) break;
|
||||
await sleep(30);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
proc?.kill();
|
||||
if (mock) await mock.close();
|
||||
fs.rmSync(FAKE_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** fetch for the app's own code, which asks for paths relative to the page. */
|
||||
function pageFetch(base) {
|
||||
const real = globalThis.fetch;
|
||||
globalThis.fetch = (url, init) => real(/^https?:/.test(String(url)) ? url : `${base}/${url}`, init);
|
||||
return () => (globalThis.fetch = real);
|
||||
}
|
||||
|
||||
test('the file on the PS5 can be deleted, and nothing else can', { skip }, async () => {
|
||||
const restore = pageFetch(BASE);
|
||||
try {
|
||||
const table = withSource({ ...emptyTable(GAME.titleId, GAME.version, 'Specter Run') }, communitySource(parseCheatTable(JSON.stringify(COMMUNITY)), INFO));
|
||||
await savePs5Table(null, table);
|
||||
const loaded = await loadPs5Table(null, GAME.titleId, GAME.version);
|
||||
assert.equal(loaded.sources[0].cheats.length, 8);
|
||||
assert.equal(await deletePs5Table(null, GAME.titleId, GAME.version), true);
|
||||
assert.equal(await loadPs5Table(null, GAME.titleId, GAME.version), null);
|
||||
assert.equal(await deletePs5Table(null, GAME.titleId, GAME.version), false, 'deleting what is not there says so');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
// Only a .cspector file in the cheats folder, named by a real title ID and version, and asked for by this page.
|
||||
put('data/cheatspecter/cheats/PPSA99999-01.000.000.cspector', '{}');
|
||||
put('data/cheatspecter/secret.json', 'keep me');
|
||||
fs.mkdirSync(path.join(CHEATS, 'PPSA99999-folder.cspector'), { recursive: true });
|
||||
fs.symlinkSync(path.join(FAKE_ROOT, 'data/cheatspecter/secret.json'), path.join(CHEATS, 'PPSA99999-link.cspector'));
|
||||
const del = (p, headers = {}) => api(p, { method: 'DELETE', headers }).then((r) => r.status);
|
||||
assert.equal(await del('/api/tables/PPSA99999/01.000.000', { origin: 'http://evil.example' }), 403);
|
||||
assert.equal(fs.existsSync(path.join(CHEATS, 'PPSA99999-01.000.000.cspector')), true);
|
||||
for (const p of ['/api/tables/PPSA99999', '/api/tables/PPSA99999/..%2F..%2Fsecret', '/api/tables/PPSA99999/../secret.json', '/api/tables/ppsa99999/01.000.000', '/api/tables/PPSA99999/01.000.000/x']) {
|
||||
assert.ok([400, 404].includes(await del(p)), p);
|
||||
}
|
||||
assert.equal(await del('/api/tables/PPSA99999/folder'), 404);
|
||||
assert.equal(await del('/api/tables/PPSA99999/link'), 404, 'a link is not deleted, and neither is what it points at');
|
||||
assert.equal(fs.readFileSync(path.join(FAKE_ROOT, 'data/cheatspecter/secret.json'), 'utf8'), 'keep me');
|
||||
assert.equal(await del('/api/tables/PPSA99999/01.000.000'), 204);
|
||||
assert.equal(fs.existsSync(path.join(CHEATS, 'PPSA99999-01.000.000.cspector')), false);
|
||||
assert.equal((await api('/api/library', { method: 'DELETE' })).status, 405);
|
||||
fs.rmSync(CHEATS, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('cheat files other tools keep on the console are listed and read, and nothing outside their folders is', { skip }, async () => {
|
||||
const json = JSON.stringify(COMMUNITY);
|
||||
put('data/etaHEN/cheats/json/PPSA99999_01.000.000.json', json);
|
||||
put('data/etaHEN/cheats/shn/PPSA99999_01.000.000.shn', toTrainerXml(parseCheatTable(json)));
|
||||
put('data/etaHEN/cheats/mc4/PPSA99999_00.900.000.mc4', encodeMc4(toTrainerXml(parseCheatTable(json))));
|
||||
put('data/etaHEN/cheats/mc4/PPSA99999_00.900.000.mc4.xml', '<names only/>');
|
||||
put('data/etaHEN/cheats/PPSA99999 (my copy).json', json);
|
||||
put('data/etaHEN/cheats/PPSA99999_01.000.000 (backup).json', json);
|
||||
put('data/elf-arsenal/cheats/PPSA99999_01.000.000.json', json);
|
||||
put('data/GoldHEN/cheats/json/PPSA99999_01.000.000.json', json);
|
||||
put('data/GoldHEN/cheats/json/PPSA11111_01.000.000.json', json);
|
||||
put('data/GoldHEN/cheats/readme.txt', 'x');
|
||||
put('data/etaHEN/cheats/other/PPSA99999_01.000.000.json', json);
|
||||
put('data/etaHEN/secret.json', 'no');
|
||||
fs.symlinkSync(path.join(FAKE_ROOT, 'data/etaHEN/secret.json'), path.join(FAKE_ROOT, 'data/etaHEN/cheats/PPSA99999_link.json'));
|
||||
|
||||
const list = await (await api('/api/console-cheats/PPSA99999')).json();
|
||||
assert.deepEqual(list.files.map((f) => f.path).sort(), [
|
||||
'elf-arsenal/PPSA99999_01.000.000.json',
|
||||
'etahen/PPSA99999 (my copy).json',
|
||||
'etahen/PPSA99999_01.000.000 (backup).json',
|
||||
'etahen/json/PPSA99999_01.000.000.json',
|
||||
'etahen/mc4/PPSA99999_00.900.000.mc4',
|
||||
'etahen/shn/PPSA99999_01.000.000.shn',
|
||||
'goldhen/json/PPSA99999_01.000.000.json',
|
||||
]);
|
||||
const one = list.files.find((f) => f.path === 'etahen/json/PPSA99999_01.000.000.json');
|
||||
assert.deepEqual(one, { path: one.path, folder: '/data/etaHEN/cheats/json', name: 'PPSA99999_01.000.000.json', size: Buffer.byteLength(json) });
|
||||
assert.deepEqual(await (await api('/api/console-cheats/PPSA12345')).json(), { files: [] });
|
||||
assert.equal((await api('/api/console-cheats/nonsense')).status, 404);
|
||||
|
||||
const got = await api('/api/console-cheats/PPSA99999/etahen/json/PPSA99999_01.000.000.json');
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(await got.text(), json);
|
||||
assert.equal((await api(`/api/console-cheats/PPSA99999/etahen/${encodeURIComponent('PPSA99999 (my copy).json')}`)).status, 200);
|
||||
for (const p of [
|
||||
'etahen/PPSA99999_link.json',
|
||||
'etahen/other/PPSA99999_01.000.000.json',
|
||||
'etahen/..%2Fsecret.json',
|
||||
'etahen/json/..%2F..%2Fsecret.json',
|
||||
'etahen/%2E%2E/secret.json',
|
||||
'nowhere/PPSA99999_01.000.000.json',
|
||||
'goldhen/json/PPSA11111_01.000.000.json',
|
||||
'goldhen/readme.txt',
|
||||
'etahen/mc4/PPSA99999_00.900.000.mc4.xml',
|
||||
'etahen/json/PPSA99999_01.000.000.json/more',
|
||||
'etahen/json/PPSA99999_01.000.000.json%00.txt',
|
||||
]) {
|
||||
assert.equal((await api(`/api/console-cheats/PPSA99999/${p}`)).status, 404, p);
|
||||
}
|
||||
// Read only.
|
||||
assert.equal((await api('/api/console-cheats/PPSA99999/etahen/json/PPSA99999_01.000.000.json', { method: 'PUT', body: '{}' })).status, 405);
|
||||
assert.equal((await api('/api/console-cheats/PPSA99999/etahen/json/PPSA99999_01.000.000.json', { method: 'DELETE' })).status, 405);
|
||||
|
||||
// The app's side: listed as "On this PS5", the version taken from the name where it follows the usual naming.
|
||||
const restore = pageFetch(BASE);
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (url, init) => (/^https:/.test(String(url)) ? Promise.resolve(new Response('', { status: 404 })) : realFetch(url, init));
|
||||
try {
|
||||
assert.equal((await listConsoleFiles(null, 'ppsa99999')).length, 7);
|
||||
const found = await findTables(GAME.titleId, GAME.version, { includeLocal: false, console: { target: null } });
|
||||
const mine = found.entries.filter((e) => e.sourceId === CONSOLE_SOURCE_ID);
|
||||
assert.equal(mine.length, 7);
|
||||
assert.equal(mine.find((e) => e.file.endsWith('(my copy).json')).version, '', 'a name that does not say is learned on opening');
|
||||
assert.deepEqual(mine.filter((e) => e.exact).map((e) => e.file).sort(), ['elf-arsenal/PPSA99999_01.000.000.json', 'etahen/PPSA99999_01.000.000 (backup).json', 'etahen/json/PPSA99999_01.000.000.json', 'etahen/shn/PPSA99999_01.000.000.shn', 'goldhen/json/PPSA99999_01.000.000.json']);
|
||||
assert.equal(mine.find((e) => e.file.endsWith('.mc4')).format, 'mc4');
|
||||
// Read back and converted like an online table.
|
||||
const shn = mine.find((e) => e.format === 'shn');
|
||||
const res = await globalThis.fetch(shn.url);
|
||||
const table = parseAnyTable(await res.text());
|
||||
assert.deepEqual(table.mods.map((m) => m.name), COMMUNITY.mods.map((m) => m.name));
|
||||
const src = communitySource(table, entryInfo(shn));
|
||||
assert.equal(src.id, 'ps5/etahen/shn/PPSA99999_01.000.000.shn');
|
||||
assert.equal(src.kind, 'console');
|
||||
// "Check for updates" reads the file on the console again.
|
||||
assert.equal((await checkSource(src, null, GAME.titleId)).same, true);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
restore();
|
||||
}
|
||||
// With no payload to ask, there are simply none.
|
||||
assert.deepEqual(await listConsoleFiles({ host: '127.0.0.1', label: 'x', port: 1 }, 'PPSA99999'), []);
|
||||
});
|
||||
|
||||
/** The mock game's code at one of its known places. */
|
||||
const code = (name) => Buffer.from(mock.game.image.subarray(GAME.code[name].offset, GAME.code[name].offset + GAME.code[name].bytes.length)).toString('hex').toUpperCase();
|
||||
const ORIGINAL = (name) => GAME.code[name].bytes.toString('hex').toUpperCase();
|
||||
const NOPS = (name) => '90'.repeat(GAME.code[name].bytes.length);
|
||||
const patch = (name, codeName, extra = {}) => ({ name, type: 'patch', at: { kind: 'module', offset: `0x${GAME.code[codeName].offset.toString(16)}` }, on: NOPS(codeName), off: ORIGINAL(codeName), ...extra });
|
||||
const healthPointer = { kind: 'pointer', base: `0x${(0x10000 + GAME.statics.player).toString(16)}`, offsets: [`0x${(GAME.vars.health - GAME.player).toString(16)}`] };
|
||||
|
||||
/** A file as the app writes it, with a start set and a saved community table. */
|
||||
function startFile(startSet, extraOwn = []) {
|
||||
const community = { ...COMMUNITY, mods: [COMMUNITY.mods[0], { name: 'Free gold', type: 'checkbox', memory: [{ offset: '30A0', on: '90909090', off: 'F30F5CC1' }] }, COMMUNITY.mods[4]] };
|
||||
const table = withSource(emptyTable(GAME.titleId, GAME.version, 'Specter Run'), communitySource(parseCheatTable(JSON.stringify(community)), INFO));
|
||||
table.entries = [
|
||||
{ id: '1', ...patch('God mode', 'healthDrain') },
|
||||
{ id: '2', name: 'Health 777', type: 'value', at: healthPointer, valueType: 'u32', value: '777', freeze: true },
|
||||
{ id: '3', name: 'Logger', type: 'script', code: 'cs.log(1)' },
|
||||
...extraOwn,
|
||||
];
|
||||
table.startSet = startSet;
|
||||
return saveFileText(table);
|
||||
}
|
||||
|
||||
test('when the game starts, the PS5 switches on the start set it can, and leaves the rest to the page', { skip }, async () => {
|
||||
await launch(
|
||||
startFile([{ name: 'God mode' }, { name: 'Health 777' }, { name: 'Logger' }, { name: 'Infinite Ammo', from: ID }, { name: 'Section only', from: ID }, { name: 'Free gold', from: 'other/x.json' }]),
|
||||
);
|
||||
const st = await until('the start set to settle', (s) => s.cheats.length === 3 && s.cheats.every((c) => c.state !== 'waiting'));
|
||||
assert.deepEqual(st.cheats, [
|
||||
{ name: 'God mode', state: 'on', detail: '', start: true },
|
||||
{ name: 'Health 777', state: 'on', detail: 'Set when the game started. Open CheatSpecter to hold it there.', start: true },
|
||||
{ name: 'Infinite Ammo', state: 'on', detail: '', start: true },
|
||||
]);
|
||||
assert.equal(code('healthDrain'), NOPS('healthDrain'));
|
||||
assert.equal(code('ammoSpend'), NOPS('ammoSpend'));
|
||||
// A cheat of that name in another source, or one not in the start set, is left alone.
|
||||
assert.equal(code('goldSpend'), ORIGINAL('goldSpend'));
|
||||
// The held value was set once and is not held: the game can change it, and the PS5 does not put it back.
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 777);
|
||||
mock.game.heap.writeUInt32LE(5, GAME.vars.health);
|
||||
await sleep(300);
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 5);
|
||||
// The app makes the same choices.
|
||||
const table = parseOwnTable(fs.readFileSync(path.join(CHEATS, OWN_FILE), 'utf8'), { own: true });
|
||||
const ps5 = table.startSet.map((i) => startEntry(table, i)).filter((e) => e && startsOnPs5(e)).map((e) => e.name);
|
||||
assert.deepEqual(ps5, st.cheats.map((c) => c.name));
|
||||
});
|
||||
|
||||
test('clearing the start set leaves the game as it is; taking "keep" away still puts it back', { skip }, async () => {
|
||||
const kept = { id: '9', ...patch('Kept gold', 'goldSpend'), keep: true };
|
||||
await launch(startFile([{ name: 'God mode' }, { name: 'Infinite Ammo', from: ID }], [kept]));
|
||||
await until('all on', (s) => s.cheats.length === 3 && s.cheats.every((c) => c.state === 'on'));
|
||||
assert.deepEqual((await engine()).cheats.map((c) => [c.name, !!c.start]), [['God mode', true], ['Kept gold', false], ['Infinite Ammo', true]]);
|
||||
// The app saves the file without a start set, and without "keep".
|
||||
const doc = JSON.parse(startFile([], [{ ...kept, keep: undefined }]));
|
||||
delete doc.startSet;
|
||||
assert.equal((await api(`/api/tables/${GAME.titleId}/${GAME.version}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(doc) })).status, 204);
|
||||
await until('nothing to keep', (s) => s.cheats.length === 0);
|
||||
assert.equal(code('healthDrain'), NOPS('healthDrain'), 'the start set only says what to switch on when the game starts');
|
||||
assert.equal(code('ammoSpend'), NOPS('ammoSpend'));
|
||||
assert.equal(code('goldSpend'), ORIGINAL('goldSpend'), 'a cheat no longer kept on is put back');
|
||||
});
|
||||
|
||||
test('a cheat both kept on and in the start set is held, and turning "keep" off makes it a start set cheat', { skip }, async () => {
|
||||
const keptValue = { id: '8', name: 'Held health', type: 'value', at: healthPointer, valueType: 'u32', value: '321', freeze: true, keep: true };
|
||||
await launch(startFile([{ name: 'Held health' }], [keptValue]));
|
||||
await until('held', (s) => s.cheats[0]?.state === 'on');
|
||||
assert.deepEqual((await engine()).cheats.map((c) => [c.name, !!c.start]), [['Held health', false]]);
|
||||
mock.game.heap.writeUInt32LE(1, GAME.vars.health);
|
||||
await sleep(200);
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 321, 'kept on: held');
|
||||
// Saved again with "keep" off: only the start set wants it now, so it is no longer held.
|
||||
put(`data/cheatspecter/cheats/${OWN_FILE}`, startFile([{ name: 'Held health' }], [{ ...keptValue, keep: undefined }]));
|
||||
await api('/api/engine/reload', { method: 'POST' });
|
||||
await until('a start set cheat now', (s) => s.cheats[0]?.start === true);
|
||||
mock.game.heap.writeUInt32LE(2, GAME.vars.health);
|
||||
await sleep(200);
|
||||
assert.equal(mock.game.heap.readUInt32LE(GAME.vars.health), 2);
|
||||
});
|
||||
|
||||
test('start sets in damaged or unusual files do no harm', { skip }, async () => {
|
||||
const doc = JSON.parse(startFile([{ name: 'God mode' }]));
|
||||
const variants = [
|
||||
// The start set after the cheats (a file edited by hand): it still counts.
|
||||
{ ...Object.fromEntries(Object.entries(doc).filter(([k]) => k !== 'startSet')), startSet: [{ name: 'God mode' }] },
|
||||
// Plain names, odd items, a name too long to be anything, and far too many.
|
||||
{ ...doc, startSet: ['God mode', 5, null, { name: 7 }, { from: 'x' }, { name: 'x'.repeat(5000) }, ...Array.from({ length: 600 }, (_, i) => ({ name: `n${i}` }))] },
|
||||
// Sources that are not what they should be; a source's cheats before its id.
|
||||
{ ...doc, startSet: [{ name: 'Infinite Ammo', from: ID }], sources: [5, null, { cheats: 'x' }, { cheats: doc.sources[0].cheats, id: ID }] },
|
||||
{ ...doc, startSet: 'God mode' },
|
||||
];
|
||||
const expected = [['God mode'], ['God mode'], [], []];
|
||||
for (const [i, v] of variants.entries()) {
|
||||
await launch(JSON.stringify(v));
|
||||
const st = await until(`variant ${i}`, (s) => s.cheats.length === expected[i].length && s.cheats.every((c) => c.state !== 'waiting'), 5000);
|
||||
assert.deepEqual(st.cheats.map((c) => c.name), expected[i], `variant ${i}`);
|
||||
assert.equal(st.error ?? '', '', `variant ${i}`);
|
||||
}
|
||||
// The app reads the same files without trouble.
|
||||
for (const v of variants) parseOwnTable(JSON.stringify(v), { own: true });
|
||||
});
|
||||
|
||||
test('a page-only cheat marked to keep by hand is turned down, by the engine and the demo stand-in alike', { skip }, async () => {
|
||||
const doc = { format: 'cheatspecter', formatVersion: 1, game: { titleId: GAME.titleId, version: GAME.version }, cheats: [{ ...patch('Hand made', 'ammoSpend'), keep: true, pageOnly: true }] };
|
||||
await launch(JSON.stringify(doc));
|
||||
const st = await until('refused', (s) => s.cheats.length === 1 && s.cheats[0].state === 'refused');
|
||||
assert.equal(st.cheats[0].detail, 'Only CheatSpecter in the browser can place this cheat, so the PS5 cannot keep it on.');
|
||||
assert.equal(code('ammoSpend'), ORIGINAL('ammoSpend'));
|
||||
const stand = demoEngineStatus({ titleId: GAME.titleId, version: GAME.version, pid: GAME.pid }, new Map([[OWN_FILE.replace('.cspector', ''), Buffer.from(JSON.stringify(doc))]]));
|
||||
assert.deepEqual(stand.cheats.map((c) => c.detail), [st.cheats[0].detail]);
|
||||
// The app agrees: it would never have let it be kept.
|
||||
assert.equal(parseOwnTable(JSON.stringify(doc), { own: true }).entries[0].keep, undefined);
|
||||
});
|
||||
|
||||
test('the bridge passes deleting and the console\'s cheat folders through, and its demo console stands in for both', { skip }, async () => {
|
||||
const bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, payloadPort: PORT });
|
||||
const demo = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, payloadPort: 9, demo: { host: '127.0.0.1', port: MOCK, udpPort: 0, label: 'demo' } });
|
||||
await bridge.ready;
|
||||
await demo.ready;
|
||||
try {
|
||||
const b = `http://127.0.0.1:${bridge.server.address().port}`;
|
||||
put(`data/cheatspecter/cheats/${OWN_FILE}`, '{}');
|
||||
assert.equal((await fetch(`${b}/api/tables/PPSA99999/01.000.000?host=127.0.0.1`, { method: 'DELETE', headers: { origin: 'http://evil.example' } })).status, 403);
|
||||
assert.equal((await fetch(`${b}/api/tables/PPSA99999?host=127.0.0.1`, { method: 'DELETE' })).status, 405);
|
||||
assert.equal((await fetch(`${b}/api/tables/PPSA99999/01.000.000?host=127.0.0.1`, { method: 'DELETE' })).status, 204);
|
||||
assert.equal(fs.existsSync(path.join(CHEATS, OWN_FILE)), false);
|
||||
assert.equal((await fetch(`${b}/api/tables/PPSA99999/01.000.000?host=127.0.0.1`, { method: 'DELETE' })).status, 404);
|
||||
const list = await (await fetch(`${b}/api/console-cheats/PPSA99999?host=127.0.0.1`)).json();
|
||||
assert.ok(list.files.length >= 1);
|
||||
const one = await fetch(`${b}/api/console-cheats/PPSA99999/${list.files[0].path.split('/').map(encodeURIComponent).join('/')}?host=127.0.0.1`);
|
||||
assert.equal(one.status, 200);
|
||||
assert.equal((await fetch(`${b}/api/console-cheats/PPSA99999?host=8.8.8.8`)).status, 400);
|
||||
assert.equal((await fetch(`${b}/api/console-cheats/PPSA99999/etahen/x.json?host=127.0.0.1`, { method: 'DELETE' })).status, 405);
|
||||
|
||||
const d = `http://127.0.0.1:${demo.server.address().port}`;
|
||||
const at = (p) => `${d}${p}?host=127.0.0.1`;
|
||||
assert.equal((await fetch(at('/api/tables/PPSA99999/01.000.000'), { method: 'PUT', body: '{"format":"cheatspecter","cheats":[]}' })).status, 204);
|
||||
assert.deepEqual((await (await fetch(at('/api/tables/PPSA99999'))).json()).versions, ['01.000.000']);
|
||||
assert.equal((await fetch(at('/api/tables/PPSA99999/01.000.000'), { method: 'DELETE' })).status, 204);
|
||||
assert.deepEqual((await (await fetch(at('/api/tables/PPSA99999'))).json()).versions, []);
|
||||
assert.equal((await fetch(at('/api/tables/PPSA99999/01.000.000'), { method: 'DELETE' })).status, 404);
|
||||
assert.deepEqual(await (await fetch(at('/api/console-cheats/PPSA99999'))).json(), { files: [] });
|
||||
assert.equal((await fetch(at('/api/console-cheats/PPSA99999/etahen/x.json'))).status, 404);
|
||||
} finally {
|
||||
await new Promise((r) => bridge.server.close(r));
|
||||
await new Promise((r) => demo.server.close(r));
|
||||
}
|
||||
});
|
||||
@@ -169,6 +169,19 @@ test('finds code, disassembles it, and turns it into a saved switch', { skip },
|
||||
assert.equal(await call('set_cheat', { name: 'infinite AMMO', on: false }), '"Infinite ammo" is off.');
|
||||
assert.match(await call('read_memory', { address: 'game+0x2040', length: 3 }), /FF 4B 28/);
|
||||
|
||||
// A script runs to its end here and hands back what it logged.
|
||||
await call('add_cheat', { cheat: { name: 'Count to two', type: 'script', code: "cs.log('one', 1 + 1);\nconst b = await cs.readBytes('game+0x2040', 3);\ncs.log(b);" } });
|
||||
assert.equal(await call('set_cheat', { name: 'Count to two', on: true }), 'Ran "Count to two".\none 2\nFF 4B 28');
|
||||
await call('add_cheat', { cheat: { name: 'Broken script', type: 'script', code: "throw new Error('nope')" } });
|
||||
assert.match(await call('set_cheat', { name: 'Broken script', on: true }), /stopped with an error: nope/);
|
||||
await call('add_cheat', { cheat: { name: 'Waits for a button', type: 'script', code: "cs.command('Go', async () => {});" } });
|
||||
assert.match(await call('set_cheat', { name: 'Waits for a button', on: true }), /only work in the CheatSpecter web app/);
|
||||
// A code cheat saved here keeps the game's own bytes, so any app can switch it off later.
|
||||
await call('add_cheat', { cheat: { name: 'Hooked ammo', type: 'inject', at: { kind: 'module', offset: '0x2040' }, code: 'original' } });
|
||||
const hooked = JSON.parse(fs.readFileSync(file, 'utf8')).cheats.find((c) => c.name === 'Hooked ammo');
|
||||
assert.match(hooked.off ?? '', /^FF 4B 28/i);
|
||||
for (const name of ['Count to two', 'Broken script', 'Waits for a button', 'Hooked ammo']) await call('remove_cheat', { name });
|
||||
|
||||
await call('add_cheat', { cheat: { name: 'Wrong build', type: 'patch', at: { kind: 'module', offset: '0x2040' }, on: '909090', off: 'AABBCC' } });
|
||||
await assert.rejects(call('set_cheat', { name: 'Wrong build', on: true }), /does not match this version of the game/);
|
||||
assert.equal(await call('remove_cheat', { name: 'Wrong build' }), 'Removed "Wrong build".');
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
// The controller shortcut (payload/src/pad.c, src/lib/padshortcut.ts).
|
||||
//
|
||||
// Three layers, none of which needs a console:
|
||||
// - the web app's side of the setting (pure functions);
|
||||
// - the payload's deciding functions, compiled with a tiny C harness and
|
||||
// fed the same inputs, so both sides are held to one table of answers;
|
||||
// - the payload built for this computer, where a text file stands in for
|
||||
// the controller (CS_PAD_FILE) and opening the browser writes the address
|
||||
// to a log (CS_BROWSER_LOG): the thread starts, follows the setting saved
|
||||
// through /api/prefs, survives the controller going away, and stops with
|
||||
// the payload.
|
||||
// The C parts are skipped when there is no C compiler or the web app is not built.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startMock } from '../mock/mock-ps5.mjs';
|
||||
import {
|
||||
PAD_DEFAULT,
|
||||
describeCombo,
|
||||
describeHold,
|
||||
loadPadShortcut,
|
||||
loadPadStatus,
|
||||
normalizePadShortcut,
|
||||
savePadShortcut,
|
||||
sortButtons,
|
||||
validCombo,
|
||||
} from '../src/lib/padshortcut.ts';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const PORT = 18960 + PORT_OFFSET;
|
||||
const MOCK = 17480 + PORT_OFFSET;
|
||||
const EVENTS = 17990 + PORT_OFFSET;
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-pad-'));
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ---- the web app's side ---------------------------------------------------------------
|
||||
|
||||
test('a missing or damaged setting is the default: off, L2 + R3, 0.8 seconds', () => {
|
||||
for (const v of [null, undefined, 'x', 3, [], {}]) assert.deepEqual(normalizePadShortcut(v), { ...PAD_DEFAULT });
|
||||
assert.equal(PAD_DEFAULT.enabled, false);
|
||||
assert.equal(describeCombo(PAD_DEFAULT.buttons), 'L2 + R3');
|
||||
assert.equal(describeHold(800), '0.8 seconds');
|
||||
assert.equal(describeHold(1000), '1 second');
|
||||
});
|
||||
|
||||
test('button names are put in one order, without repeats, in any case', () => {
|
||||
assert.deepEqual(sortButtons(['r3', 'L2', 'L2']), ['L2', 'R3']);
|
||||
assert.deepEqual(sortButtons(['Touchpad', 'cross', 'L1']), ['L1', 'Cross', 'Touchpad']);
|
||||
assert.equal(validCombo(['L2']), false);
|
||||
assert.equal(validCombo(['L2', 'R3']), true);
|
||||
assert.equal(validCombo(['L1', 'R1', 'L2', 'R2']), true);
|
||||
assert.equal(validCombo(['L1', 'R1', 'L2', 'R2', 'L3']), false);
|
||||
assert.equal(validCombo(['L2', 'Nope']), false);
|
||||
});
|
||||
|
||||
// The same inputs go to the payload's reader below; both must give these answers.
|
||||
const SETTINGS_CASES = [
|
||||
['{"enabled":true,"buttons":["L2","R3"],"holdMs":800}', { enabled: true, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
['{"enabled":true,"buttons":["triangle","L1"],"holdMs":1500}', { enabled: true, buttons: ['L1', 'Triangle'], holdMs: 1500 }],
|
||||
['{"enabled":true,"buttons":["L2"],"holdMs":800}', { enabled: true, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
['{"enabled":true,"buttons":["L2","Jump"],"holdMs":800}', { enabled: true, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
['{"enabled":true,"buttons":["L1","R1","L2","R2","L3"]}', { enabled: true, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
['{"enabled":true,"buttons":["Up","Down"],"holdMs":50}', { enabled: true, buttons: ['Up', 'Down'], holdMs: 800 }],
|
||||
['{"enabled":true,"holdMs":99999}', { enabled: true, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
['{"enabled":"yes","buttons":["L2","R3"]}', { enabled: false, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
['{"enabled":true,"holdMs":2500.7,"extra":{"a":[1,2]}}', { enabled: true, buttons: ['L2', 'R3'], holdMs: 2500 }],
|
||||
['[1,2]', { enabled: false, buttons: ['L2', 'R3'], holdMs: 800 }],
|
||||
];
|
||||
|
||||
test('the web app reads a setting the way the payload does', () => {
|
||||
for (const [text, want] of SETTINGS_CASES) assert.deepEqual(normalizePadShortcut(JSON.parse(text)), want, text);
|
||||
});
|
||||
|
||||
// ---- the payload's deciding functions ---------------------------------------------------
|
||||
|
||||
let skipC = false;
|
||||
let skipPayload = false;
|
||||
try {
|
||||
execFileSync(process.env.CC ?? 'cc', ['--version'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
skipC = 'needs a C compiler';
|
||||
}
|
||||
try {
|
||||
if (skipC) throw new Error(skipC);
|
||||
if (!fs.existsSync(path.join(ROOT, 'dist/index.html'))) throw new Error('dist missing');
|
||||
execFileSync(process.execPath, [path.join(ROOT, 'scripts/build-payload.mjs'), 'host'], { stdio: 'pipe' });
|
||||
} catch {
|
||||
skipPayload = 'needs a C compiler and a built web app (npm run build)';
|
||||
}
|
||||
|
||||
// Reads commands on stdin and answers one line each.
|
||||
const HARNESS = String.raw`
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "pad.h"
|
||||
|
||||
static uint32_t names(char *list) {
|
||||
uint32_t b = 0;
|
||||
for (char *w = strtok(list, " \n"); w; w = strtok(NULL, " \n")) b |= pad_button_bit(w);
|
||||
return b;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char line[4096];
|
||||
struct pad_settings s = {1, PAD_L2 | PAD_R3, 800};
|
||||
struct pad_tracker t;
|
||||
memset(&t, 0, sizeof t);
|
||||
pad_tracker_reset(&t);
|
||||
while (fgets(line, sizeof line, stdin)) {
|
||||
line[strcspn(line, "\n")] = 0;
|
||||
char out[256];
|
||||
if (strncmp(line, "parse ", 6) == 0) {
|
||||
struct pad_settings p;
|
||||
int rc = pad_parse_settings(line + 6, strlen(line + 6), &p);
|
||||
pad_describe(p.buttons, out, sizeof out);
|
||||
printf("%d|%d|%s|%d\n", rc, p.enabled, out, p.hold_ms);
|
||||
} else if (strncmp(line, "use ", 4) == 0) {
|
||||
/* use <hold ms> <buttons...> */
|
||||
char *rest;
|
||||
s.hold_ms = (int)strtol(line + 4, &rest, 10);
|
||||
s.buttons = names(rest);
|
||||
memset(&t, 0, sizeof t);
|
||||
pad_tracker_reset(&t);
|
||||
printf("ok\n");
|
||||
} else if (strncmp(line, "at ", 3) == 0) {
|
||||
/* at <ms> <connected 0|1> <buttons...> */
|
||||
char *rest;
|
||||
long long now = strtoll(line + 3, &rest, 10);
|
||||
int connected = (int)strtol(rest, &rest, 10);
|
||||
printf("%d\n", pad_track(&t, &s, now, connected, names(rest)));
|
||||
} else if (strncmp(line, "url ", 4) == 0) {
|
||||
char tid[16] = "";
|
||||
int port = 0;
|
||||
sscanf(line + 4, "%d %15s", &port, tid);
|
||||
pad_page_url(port, tid, out, sizeof out);
|
||||
printf("%s\n", out);
|
||||
} else {
|
||||
printf("?\n");
|
||||
}
|
||||
fflush(stdout);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
|
||||
let harness = null;
|
||||
function runHarness(lines) {
|
||||
const r = spawnSync(harness, [], { input: lines.join('\n') + '\n', encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trimEnd().split('\n');
|
||||
}
|
||||
|
||||
before(() => {
|
||||
if (skipC) return;
|
||||
const src = path.join(TMP, 'harness.c');
|
||||
fs.writeFileSync(src, HARNESS);
|
||||
harness = path.join(TMP, 'harness');
|
||||
const dir = path.join(ROOT, 'payload/src');
|
||||
execFileSync(process.env.CC ?? 'cc', ['-O2', '-Wall', '-Wextra', '-Werror', `-I${dir}`, '-o', harness, src, path.join(dir, 'pad.c'), path.join(dir, 'json.c'), '-lpthread'], { stdio: 'pipe' });
|
||||
});
|
||||
|
||||
test('the payload reads a setting the way the web app does', { skip: skipC }, () => {
|
||||
const answers = runHarness(SETTINGS_CASES.map(([text]) => `parse ${text}`));
|
||||
SETTINGS_CASES.forEach(([text, want], i) => {
|
||||
const [rc, enabled, combo, hold] = answers[i].split('|');
|
||||
assert.equal(rc, text.startsWith('{') ? '0' : '-1', text);
|
||||
assert.deepEqual({ enabled: enabled === '1', buttons: combo.split(' + '), holdMs: Number(hold) }, want, text);
|
||||
});
|
||||
// Not JSON at all, or cut short: off.
|
||||
for (const text of ['', 'nope', '{"enabled":true', '{"enabled":true}}']) {
|
||||
const [rc, enabled] = runHarness([`parse ${text}`])[0].split('|');
|
||||
assert.equal(rc, '-1', text);
|
||||
assert.equal(enabled, '0', text);
|
||||
}
|
||||
});
|
||||
|
||||
/** Feeds readings ("<ms> <connected> <buttons>") and returns the times the shortcut went off. */
|
||||
function fires(hold, combo, readings) {
|
||||
const out = runHarness([`use ${hold} ${combo}`, ...readings.map((r) => `at ${r}`)]);
|
||||
assert.equal(out[0], 'ok');
|
||||
return readings.filter((_, i) => out[i + 1] === '1').map((r) => Number(r.split(' ')[0]));
|
||||
}
|
||||
|
||||
/** Readings every `step` ms from `from` to `to` (inclusive) with the same state. */
|
||||
const span = (from, to, step, state) => Array.from({ length: Math.floor((to - from) / step) + 1 }, (_, i) => `${from + i * step} ${state}`);
|
||||
|
||||
test('goes off once the buttons have been held together long enough', { skip: skipC }, () => {
|
||||
// Released first, then held from 1000 ms: goes off at 1800 ms, once.
|
||||
assert.deepEqual(fires(800, 'L2 R3', [...span(900, 900, 100, '1'), ...span(1000, 2500, 100, '1 L2 R3')]), [1800]);
|
||||
// Other buttons held as well do not matter.
|
||||
assert.deepEqual(fires(800, 'L2 R3', ['900 1', ...span(1000, 2000, 100, '1 L2 R3 Cross Up')]), [1800]);
|
||||
// One of the two alone never does.
|
||||
assert.deepEqual(fires(800, 'L2 R3', ['900 1', ...span(1000, 4000, 100, '1 L2 Cross')]), []);
|
||||
// Let go before the time is up: the hold starts again.
|
||||
assert.deepEqual(fires(800, 'L2 R3', ['900 1', ...span(1000, 1700, 100, '1 L2 R3'), '1800 1 L2', ...span(1900, 2800, 100, '1 L2 R3')]), [2700]);
|
||||
});
|
||||
|
||||
test('does not go off for buttons already down when watching starts', { skip: skipC }, () => {
|
||||
assert.deepEqual(fires(800, 'L2 R3', span(0, 3000, 100, '1 L2 R3')), []);
|
||||
// ...until they are let go and held again.
|
||||
assert.deepEqual(fires(800, 'L2 R3', [...span(0, 1000, 100, '1 L2 R3'), '1100 1', ...span(1200, 2100, 100, '1 L2 R3')]), [2000]);
|
||||
});
|
||||
|
||||
test('needs the buttons let go, and a pause, before going off again', { skip: skipC }, () => {
|
||||
// Kept held: once only.
|
||||
assert.deepEqual(fires(500, 'L2 R3', ['0 1', ...span(100, 5000, 100, '1 L2 R3')]), [600]);
|
||||
// Let go and held again at once: the cooldown keeps it quiet...
|
||||
assert.deepEqual(fires(500, 'L2 R3', ['0 1', ...span(100, 700, 100, '1 L2 R3'), '800 1', ...span(900, 1600, 100, '1 L2 R3')]), [600]);
|
||||
// ...but not after it.
|
||||
assert.deepEqual(fires(500, 'L2 R3', ['0 1', ...span(100, 700, 100, '1 L2 R3'), '800 1', '3500 1', ...span(3600, 4200, 100, '1 L2 R3')]), [600, 4100]);
|
||||
});
|
||||
|
||||
test('a controller that goes away, or a gap in the readings, starts the hold over', { skip: skipC }, () => {
|
||||
// Disconnected half way: the hold is lost, and the buttons must be let go after it comes back.
|
||||
assert.deepEqual(fires(800, 'L2 R3', ['0 1', ...span(100, 500, 100, '1 L2 R3'), '600 0', ...span(700, 2000, 100, '1 L2 R3')]), []);
|
||||
// The console slept (no readings for a while): the same.
|
||||
assert.deepEqual(fires(800, 'L2 R3', ['0 1', ...span(100, 500, 100, '1 L2 R3'), ...span(60000, 61000, 100, '1 L2 R3')]), []);
|
||||
// The clock going backwards is treated as a gap too.
|
||||
assert.deepEqual(fires(800, 'L2 R3', ['5000 1', ...span(5100, 5500, 100, '1 L2 R3'), ...span(100, 1500, 100, '1 L2 R3')]), []);
|
||||
});
|
||||
|
||||
test('other combinations and hold times are followed', { skip: skipC }, () => {
|
||||
assert.deepEqual(fires(1500, 'L1 R1 Triangle', ['0 1', ...span(100, 2000, 100, '1 L1 R1 Triangle')]), [1600]);
|
||||
assert.deepEqual(fires(1500, 'L1 R1 Triangle', ['0 1', ...span(100, 2000, 100, '1 L1 R1')]), []);
|
||||
});
|
||||
|
||||
test('opens the game\'s Cheats screen, or the start when no game is known', { skip: skipC }, () => {
|
||||
assert.deepEqual(runHarness(['url 8744 PPSA01575', 'url 8744']), ['http://127.0.0.1:8744/#/game/PPSA01575/cheats', 'http://127.0.0.1:8744/']);
|
||||
});
|
||||
|
||||
// ---- the payload, built for this computer ---------------------------------------------------
|
||||
|
||||
const FAKE_ROOT = path.join(TMP, 'root');
|
||||
const PAD_FILE = path.join(TMP, 'pad.txt');
|
||||
const BROWSER_LOG = path.join(TMP, 'browser.txt');
|
||||
const GUARD = path.join(FAKE_ROOT, 'data/cheatspecter/pad-starting');
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
let mock;
|
||||
let proc = null;
|
||||
let stderr = '';
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
const press = (text) => fs.writeFileSync(PAD_FILE, text);
|
||||
const opened = () => (fs.existsSync(BROWSER_LOG) ? fs.readFileSync(BROWSER_LOG, 'utf8').trim().split('\n').filter(Boolean) : []);
|
||||
|
||||
async function startPayload() {
|
||||
stderr = '';
|
||||
proc = spawn(BIN, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
CS_PORT: String(PORT),
|
||||
CS_DEBUGGER_PORT: String(MOCK),
|
||||
CS_EVENT_PORT: String(EVENTS),
|
||||
CS_ROOT: FAKE_ROOT,
|
||||
CS_SHADOWMOUNT_PORT: '1',
|
||||
// Every slow wait (setting checks, retries, the marker) ten times quicker.
|
||||
CS_ENGINE_SPEED: '10',
|
||||
CS_PAD_FILE: PAD_FILE,
|
||||
CS_BROWSER_LOG: BROWSER_LOG,
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr.on('data', (d) => (stderr += d));
|
||||
for (let i = 0; ; i++) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const s = net.connect(PORT, '127.0.0.1');
|
||||
s.once('connect', () => (s.destroy(), resolve(true)));
|
||||
s.once('error', () => resolve(false));
|
||||
});
|
||||
if (ok) break;
|
||||
if (i > 150) throw new Error('payload did not start');
|
||||
await sleep(30);
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks the payload to stop the way a newer copy does, and waits for it to be gone. Returns its exit code and how long it took. */
|
||||
async function stopPayload() {
|
||||
const started = Date.now();
|
||||
const exited = new Promise((resolve) => proc.once('exit', (code) => resolve(code)));
|
||||
await realFetch(`${base}/api/shutdown`, { method: 'POST' }).catch(() => null);
|
||||
const code = await Promise.race([exited, sleep(5000).then(() => 'timeout')]);
|
||||
if (code === 'timeout') proc.kill('SIGKILL');
|
||||
proc = null;
|
||||
return { code, ms: Date.now() - started };
|
||||
}
|
||||
|
||||
/** Polls GET /api/pad until `check` passes. */
|
||||
async function waitStatus(check, what, ms = 4000) {
|
||||
let last = null;
|
||||
for (const end = Date.now() + ms; Date.now() < end; await sleep(50)) {
|
||||
last = await loadPadStatus(null);
|
||||
if (last && check(last)) return last;
|
||||
}
|
||||
assert.fail(`timed out waiting for ${what}; last status ${JSON.stringify(last)}`);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (skipPayload) return;
|
||||
fs.mkdirSync(path.join(FAKE_ROOT, 'data'), { recursive: true });
|
||||
mock = startMock({ tcpPort: MOCK, udpPort: 0, tick: true });
|
||||
await mock.ready;
|
||||
// The web app's client asks relative addresses, as it does when the PS5 serves it.
|
||||
globalThis.fetch = (input, init) => realFetch(`${base}/${input}`, init);
|
||||
press('');
|
||||
await startPayload();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
globalThis.fetch = realFetch;
|
||||
if (proc) proc.kill('SIGKILL');
|
||||
await mock?.close?.();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('off until switched on, and the controller is not opened while off', { skip: skipPayload }, async () => {
|
||||
const s = await loadPadStatus(null);
|
||||
assert.deepEqual(s, { enabled: false, buttons: ['L2', 'R3'], holdMs: 800, state: 'off', detail: '', fired: 0 });
|
||||
assert.deepEqual(await loadPadShortcut(null), PAD_DEFAULT);
|
||||
press('L2 R3');
|
||||
await sleep(1200);
|
||||
assert.deepEqual(opened(), []);
|
||||
assert.equal(fs.existsSync(GUARD), false, 'nothing was started, so no marker');
|
||||
});
|
||||
|
||||
test('the setting round-trips through /api/prefs and the payload follows it at once', { skip: skipPayload }, async () => {
|
||||
const want = { enabled: true, buttons: ['L2', 'R3'], holdMs: 300 };
|
||||
assert.equal(await savePadShortcut(null, want), true);
|
||||
assert.deepEqual(await loadPadShortcut(null), want);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(FAKE_ROOT, 'data/cheatspecter/prefs/pad-shortcut.json'), 'utf8')), want);
|
||||
const s = await waitStatus((x) => x.state === 'listening', 'listening', 1500);
|
||||
assert.equal(s.enabled, true);
|
||||
assert.equal(s.holdMs, 300);
|
||||
assert.equal(fs.existsSync(GUARD), true, 'the marker is there while the controller is new');
|
||||
// Held since before it was switched on: that does not count.
|
||||
await sleep(800);
|
||||
assert.deepEqual(opened(), []);
|
||||
});
|
||||
|
||||
test('holding the buttons opens the running game\'s Cheats screen, once per hold', { skip: skipPayload }, async () => {
|
||||
// The cheat engine has to have seen the game for the address to name it.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const engine = await (await realFetch(`${base}/api/engine`)).json();
|
||||
if (engine.game) break;
|
||||
await sleep(50);
|
||||
}
|
||||
press('');
|
||||
await sleep(300);
|
||||
press('L2 R3 Cross');
|
||||
await sleep(1500);
|
||||
assert.deepEqual(opened(), ['http://127.0.0.1:' + PORT + '/#/game/PPSA99999/cheats']);
|
||||
assert.match(stderr, /Opening CheatSpecter \(L2 \+ R3 held\)/, 'a notification the first time');
|
||||
const s = await loadPadStatus(null);
|
||||
assert.equal(s.fired, 1);
|
||||
|
||||
// Again after letting go and waiting out the cooldown: no second notification.
|
||||
press('');
|
||||
await sleep(3200);
|
||||
press('L2 R3');
|
||||
await sleep(800);
|
||||
assert.equal(opened().length, 2);
|
||||
assert.equal(stderr.match(/Opening CheatSpecter/g).length, 1);
|
||||
});
|
||||
|
||||
test('the marker goes once the controller has been fine for a while', { skip: skipPayload }, async () => {
|
||||
// 20 seconds on the console; 2 here.
|
||||
for (let i = 0; i < 60 && fs.existsSync(GUARD); i++) await sleep(100);
|
||||
assert.equal(fs.existsSync(GUARD), false);
|
||||
});
|
||||
|
||||
test('survives the controller going away, read errors and another user signing in', { skip: skipPayload }, async () => {
|
||||
press('disconnected');
|
||||
await waitStatus((x) => x.state === 'no-controller' && /No controller/.test(x.detail), 'no controller');
|
||||
press('fail');
|
||||
await waitStatus((x) => x.state === 'no-controller' && /Lost the controller/.test(x.detail), 'lost');
|
||||
press('nouser');
|
||||
await waitStatus((x) => x.state === 'no-controller' && /Nobody is signed in/.test(x.detail), 'nobody signed in');
|
||||
press('user 2');
|
||||
await waitStatus((x) => x.state === 'listening', 'listening again');
|
||||
const before = opened().length;
|
||||
await sleep(3000); // past the cooldown of the last time it went off
|
||||
press('user 2 L2 R3');
|
||||
await sleep(900);
|
||||
assert.equal(opened().length, before + 1, 'works for the new user');
|
||||
// The HTTP server kept answering throughout.
|
||||
assert.equal((await realFetch(`${base}/api/info`)).status, 200);
|
||||
});
|
||||
|
||||
test('a new combination and hold time apply without restarting', { skip: skipPayload }, async () => {
|
||||
press('');
|
||||
assert.equal(await savePadShortcut(null, { enabled: true, buttons: ['Triangle', 'L1'], holdMs: 500 }), true);
|
||||
await waitStatus((x) => x.state === 'listening' && x.buttons.join() === 'L1,Triangle', 'new combination');
|
||||
await sleep(3000); // past the cooldown of the last test
|
||||
const before = opened().length;
|
||||
press('L2 R3');
|
||||
await sleep(900);
|
||||
assert.equal(opened().length, before, 'the old combination no longer works');
|
||||
press('');
|
||||
await sleep(200);
|
||||
press('L1 Triangle');
|
||||
await sleep(900);
|
||||
assert.equal(opened().length, before + 1);
|
||||
});
|
||||
|
||||
test('switching it off lets go of the controller', { skip: skipPayload }, async () => {
|
||||
assert.equal(await savePadShortcut(null, { enabled: false, buttons: ['L1', 'Triangle'], holdMs: 500 }), true);
|
||||
await waitStatus((x) => x.state === 'off' && !x.enabled, 'off');
|
||||
await sleep(3000);
|
||||
const before = opened().length;
|
||||
press('');
|
||||
await sleep(200);
|
||||
press('L1 Triangle');
|
||||
await sleep(900);
|
||||
assert.equal(opened().length, before);
|
||||
});
|
||||
|
||||
test('stops cleanly with the payload, and takes its marker with it', { skip: skipPayload }, async () => {
|
||||
press('');
|
||||
assert.equal(await savePadShortcut(null, { enabled: true, buttons: ['L2', 'R3'], holdMs: 800 }), true);
|
||||
await waitStatus((x) => x.state === 'listening', 'listening');
|
||||
assert.equal(fs.existsSync(GUARD), true);
|
||||
const { code, ms } = await stopPayload();
|
||||
assert.equal(code, 0);
|
||||
assert.ok(ms < 2000, `took ${ms} ms`);
|
||||
assert.equal(fs.existsSync(GUARD), false);
|
||||
});
|
||||
|
||||
test('pauses after a start the console did not get through, until saved again', { skip: skipPayload }, async () => {
|
||||
// What the console looks like after stopping while the controller was being opened.
|
||||
fs.writeFileSync(GUARD, 'left over');
|
||||
press('');
|
||||
await startPayload();
|
||||
const s = await waitStatus((x) => x.state === 'paused', 'paused');
|
||||
assert.equal(s.enabled, true);
|
||||
assert.match(stderr, /controller shortcut is paused/);
|
||||
assert.equal(fs.existsSync(GUARD), true);
|
||||
const before = opened().length;
|
||||
press('L2 R3');
|
||||
await sleep(1200);
|
||||
assert.equal(opened().length, before, 'nothing while paused');
|
||||
|
||||
// Saving the setting again (the "Try again" button) is the go-ahead.
|
||||
press('');
|
||||
assert.equal(await savePadShortcut(null, { enabled: true, buttons: ['L2', 'R3'], holdMs: 300 }), true);
|
||||
await waitStatus((x) => x.state === 'listening', 'listening after the go-ahead');
|
||||
press('L2 R3');
|
||||
await sleep(800);
|
||||
assert.equal(opened().length, before + 1);
|
||||
await stopPayload();
|
||||
});
|
||||
|
||||
test('a payload without a controller says so instead of pretending', { skip: skipPayload }, async () => {
|
||||
// The computer build without a stand-in file is like a console that cannot load the controller library.
|
||||
const child = spawn(BIN, [], {
|
||||
env: { ...process.env, CS_PORT: String(PORT), CS_DEBUGGER_PORT: '9', CS_EVENT_PORT: String(EVENTS), CS_ROOT: FAKE_ROOT, CS_SHADOWMOUNT_PORT: '1', CS_ENGINE_SPEED: '10' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
try {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const ok = await realFetch(`${base}/api/info`).then(() => true, () => false);
|
||||
if (ok) break;
|
||||
await sleep(30);
|
||||
}
|
||||
const s = await waitStatus((x) => x.state === 'unavailable', 'unavailable');
|
||||
assert.match(s.detail, /cannot read a controller/);
|
||||
} finally {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
@@ -62,12 +62,14 @@ test('small slips in a hand-written file are put right rather than refused', ()
|
||||
test('every dialog keeps its buttons in reach and hands focus back', () => {
|
||||
const dialog = src('src/components/ui/dialog.tsx');
|
||||
assert.match(dialog, /data-slot="dialog-footer"[\s\S]*?sticky/, 'the footer stays at the bottom of a scrolling dialog');
|
||||
assert.match(dialog, /max-h-\[calc\(100dvh-2rem\)\]/, 'a dialog is never taller than the screen');
|
||||
// The backdrop is the scroll container (see the comment in dialog.tsx), and no dialog's own classes reach it.
|
||||
assert.match(dialog, /<DialogOverlay[\s\S]*?className="flex flex-col overflow-y-auto overscroll-contain[^"]*"\s*>/, 'a dialog taller than the screen scrolls with its backdrop');
|
||||
assert.match(dialog, /onCloseAutoFocus/, 'focus returns to what opened the dialog');
|
||||
});
|
||||
|
||||
test('the guide is not held to the ordinary dialog height', () => {
|
||||
assert.match(src('src/ui/Wizard.tsx'), /h-dvh max-h-none/);
|
||||
// Pinned to the window's edges rather than given a height, so it fits even where `dvh` is not understood.
|
||||
assert.match(src('src/ui/Wizard.tsx'), /fixed inset-x-0 bottom-0 top-\[var\(--cs-dialog-top,0px\)\][^"]*overflow-hidden/);
|
||||
});
|
||||
|
||||
test('editors are forms that Enter submits, and are not closed by a stray click', () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Screen addresses (src/lib/route.ts): what each address means, and that every
|
||||
// screen's address reads back as the same screen.
|
||||
//
|
||||
// node --test test/route.test.mjs
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { GAME_TABS, HOME, formatRoute, gameAddress, parseRoute, sameRoute } from '../src/lib/route.ts';
|
||||
|
||||
test('the forms the app writes', () => {
|
||||
assert.equal(formatRoute(HOME), '#/');
|
||||
assert.equal(formatRoute({ screen: 'game', titleId: 'PPSA01575', tab: 'cheats' }), '#/game/PPSA01575/cheats');
|
||||
assert.equal(formatRoute({ screen: 'game', titleId: 'PPSA01575', tab: 'find' }), '#/game/PPSA01575/find');
|
||||
assert.equal(formatRoute({ screen: 'library' }), '#/library');
|
||||
assert.equal(formatRoute({ screen: 'learn', lesson: null, step: null }), '#/learn');
|
||||
assert.equal(formatRoute({ screen: 'learn', lesson: 'find-a-number', step: null }), '#/learn/find-a-number');
|
||||
assert.equal(formatRoute({ screen: 'learn', lesson: 'find-a-number', step: 3 }), '#/learn/find-a-number/3');
|
||||
assert.equal(formatRoute({ screen: 'settings', section: 'main' }), '#/settings');
|
||||
assert.equal(formatRoute({ screen: 'settings', section: 'diagnostics' }), '#/settings/diagnostics');
|
||||
});
|
||||
|
||||
test('every screen survives a round trip through the address', () => {
|
||||
const routes = [
|
||||
HOME,
|
||||
...GAME_TABS.map((tab) => ({ screen: 'game', titleId: 'CUSA12345', tab })),
|
||||
{ screen: 'library' },
|
||||
{ screen: 'learn', lesson: null, step: null },
|
||||
{ screen: 'learn', lesson: 'how-games-remember', step: null },
|
||||
{ screen: 'learn', lesson: 'how-games-remember', step: 12 },
|
||||
{ screen: 'settings', section: 'main' },
|
||||
{ screen: 'settings', section: 'diagnostics' },
|
||||
];
|
||||
for (const r of routes) {
|
||||
assert.deepEqual(parseRoute(formatRoute(r)), r, formatRoute(r));
|
||||
assert.ok(sameRoute(parseRoute(formatRoute(r)), r));
|
||||
}
|
||||
});
|
||||
|
||||
test('reading addresses people type or paste', () => {
|
||||
// No hash at all, or just the #, is the start.
|
||||
assert.deepEqual(parseRoute(''), HOME);
|
||||
assert.deepEqual(parseRoute('#'), HOME);
|
||||
assert.deepEqual(parseRoute('#/'), HOME);
|
||||
// Lower-case title IDs, a missing or unknown tab, a trailing slash, a query.
|
||||
assert.deepEqual(parseRoute('#/game/ppsa01575'), { screen: 'game', titleId: 'PPSA01575', tab: 'cheats' });
|
||||
assert.deepEqual(parseRoute('#/game/PPSA01575/'), { screen: 'game', titleId: 'PPSA01575', tab: 'cheats' });
|
||||
assert.deepEqual(parseRoute('#/game/PPSA01575/nonsense'), { screen: 'game', titleId: 'PPSA01575', tab: 'cheats' });
|
||||
assert.deepEqual(parseRoute('#/game/PPSA01575/memory?x=1'), { screen: 'game', titleId: 'PPSA01575', tab: 'memory' });
|
||||
assert.deepEqual(parseRoute('game/PPSA01575/code'), { screen: 'game', titleId: 'PPSA01575', tab: 'code' });
|
||||
// Not a title ID: the start, not a screen for a game that cannot exist.
|
||||
assert.deepEqual(parseRoute('#/game/../../etc'), HOME);
|
||||
assert.deepEqual(parseRoute('#/game/PPSA0157'), HOME);
|
||||
assert.deepEqual(parseRoute('#/game/'), HOME);
|
||||
// The tab the old in-page state allowed but the address does not.
|
||||
assert.deepEqual(parseRoute('#/game/PPSA01575/learn'), { screen: 'game', titleId: 'PPSA01575', tab: 'cheats' });
|
||||
// Learn: bad lesson names and step numbers are dropped, not trusted.
|
||||
assert.deepEqual(parseRoute('#/learn/Find A Number/2'), { screen: 'learn', lesson: null, step: null });
|
||||
assert.deepEqual(parseRoute('#/learn/find-a-number/0'), { screen: 'learn', lesson: 'find-a-number', step: null });
|
||||
assert.deepEqual(parseRoute('#/learn/find-a-number/two'), { screen: 'learn', lesson: 'find-a-number', step: null });
|
||||
assert.deepEqual(parseRoute('#/learn/find-a-number/-1'), { screen: 'learn', lesson: 'find-a-number', step: null });
|
||||
// Short forms.
|
||||
assert.deepEqual(parseRoute('#/games'), { screen: 'library' });
|
||||
assert.deepEqual(parseRoute('#/diagnostics'), { screen: 'settings', section: 'diagnostics' });
|
||||
assert.deepEqual(parseRoute('#/settings/whatever'), { screen: 'settings', section: 'main' });
|
||||
// Anything else, including broken escapes, is the start and never throws.
|
||||
assert.deepEqual(parseRoute('#/%E0%A4%A'), HOME);
|
||||
assert.deepEqual(parseRoute('#/somewhere/else'), HOME);
|
||||
assert.deepEqual(parseRoute('#/game%2FPPSA01575%2Ffind'), { screen: 'game', titleId: 'PPSA01575', tab: 'find' });
|
||||
});
|
||||
|
||||
test('the address the PS5 browser opens for a running game', () => {
|
||||
assert.equal(gameAddress('PPSA01575'), 'http://127.0.0.1:8744/#/game/PPSA01575/cheats');
|
||||
assert.equal(gameAddress('ppsa01575', 'find', 'http://192.168.1.20:8744/'), 'http://192.168.1.20:8744/#/game/PPSA01575/find');
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Stopping the payload from the app's Settings screen (POST /api/shutdown):
|
||||
// the app may, a page from another site may not, and nothing that is refused
|
||||
// stops anything. Runs the payload's computer build against the mock console.
|
||||
// Skipped when that build or a non-loopback network address is missing.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/shutdown.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startMock } from '../mock/mock-ps5.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BIN = path.join(ROOT, 'payload/build/cheatspecter-host');
|
||||
const OFF = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const PORT = 18910 + OFF;
|
||||
const MOCK = 17610 + OFF;
|
||||
const EVENTS = 17910 + OFF;
|
||||
|
||||
// The loopback address may always ask (a newer copy of the payload does), so
|
||||
// the checks that matter are made from this machine's network address.
|
||||
const lan = Object.values(os.networkInterfaces())
|
||||
.flat()
|
||||
.find((a) => a && a.family === 'IPv4' && !a.internal)?.address;
|
||||
const skip = !fs.existsSync(BIN) ? 'needs the payload built for this computer (node scripts/build-payload.mjs host)' : !lan ? 'needs a network address other than 127.0.0.1' : false;
|
||||
|
||||
let mock, payload, exited;
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
mock = startMock({ tcpPort: MOCK, udpPort: 0, eventPort: EVENTS });
|
||||
await mock.ready;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-shutdown-'));
|
||||
fs.mkdirSync(path.join(root, 'data'), { recursive: true });
|
||||
const env = { ...process.env, CS_PORT: String(PORT), CS_DEBUGGER_PORT: String(MOCK), CS_EVENT_PORT: String(EVENTS), CS_ROOT: root };
|
||||
payload = spawn(BIN, [], { env, stdio: 'ignore' });
|
||||
exited = new Promise((resolve) => payload.on('exit', (code) => resolve(code)));
|
||||
// It says it is ready on one stream or the other; asking is surer.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (payload.exitCode !== null) throw new Error('the payload did not start');
|
||||
if (await stillServing().catch(() => false)) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (payload && payload.exitCode === null) payload.kill('SIGKILL');
|
||||
mock?.close?.();
|
||||
});
|
||||
|
||||
/** POST /api/shutdown at `host` with the given headers. Resolves to the status and body. */
|
||||
function post(host, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host, port: PORT, path: '/api/shutdown', method: 'POST', headers: { 'content-length': 0, ...headers } }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (d) => (body += d));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function stillServing() {
|
||||
const res = await fetch(`http://127.0.0.1:${PORT}/api/info`);
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
test('a request without the app\'s header is refused and stops nothing', { skip }, async () => {
|
||||
const r = await post(lan, {});
|
||||
assert.equal(r.status, 403);
|
||||
assert.ok(await stillServing());
|
||||
});
|
||||
|
||||
test('a page from another site is refused even with the header', { skip }, async () => {
|
||||
const r = await post(lan, { 'X-CheatSpecter': 'shutdown', Origin: 'http://evil.example' });
|
||||
assert.equal(r.status, 403);
|
||||
assert.ok(await stillServing());
|
||||
});
|
||||
|
||||
test('a wrong header value is refused', { skip }, async () => {
|
||||
const r = await post(lan, { 'X-CheatSpecter': 'yes', Origin: `http://${lan}:${PORT}` });
|
||||
assert.equal(r.status, 403);
|
||||
assert.ok(await stillServing());
|
||||
});
|
||||
|
||||
test('the app itself, from another device, stops the payload', { skip }, async () => {
|
||||
const r = await post(lan, { 'X-CheatSpecter': 'shutdown', Origin: `http://${lan}:${PORT}` });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body, 'bye');
|
||||
assert.equal(await exited, 0);
|
||||
await assert.rejects(stillServing());
|
||||
});
|
||||
@@ -78,7 +78,8 @@ test('on a phone held sideways an editor keeps Save in reach, and closing it is
|
||||
await page.getByRole('menuitem', { name: 'Patch' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(box.y >= 0 && box.y + box.height <= 390, 'the dialog fits on the screen');
|
||||
// Taller than the screen: its top is on screen, and the rest scrolls into view (test/ui/dialogs.ui.test.mjs).
|
||||
assert.ok(box.y >= 0, 'the dialog starts on the screen');
|
||||
const save = await dialog.getByRole('button', { name: 'Save' }).boundingBox();
|
||||
assert.ok(save.y + save.height <= 390, 'Save is on screen without scrolling');
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Tall dialogs on a phone held sideways (844x390): every part of them can be
|
||||
// reached by scrolling, with a mouse wheel and with a finger, and the page
|
||||
// behind stays put. Also with the paused-game bar at the top, which used to
|
||||
// push dialogs down out of reach. Skipped when Playwright or the built web
|
||||
// app is not there.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/dialogs.ui.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const PORT = 18820 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const URL = `http://127.0.0.1:${PORT}/`;
|
||||
|
||||
let chromium = null;
|
||||
for (const from of [import.meta.url, '/opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
chromium = createRequire(from)('playwright').chromium;
|
||||
break;
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
const skip = !chromium ? 'needs Playwright' : !fs.existsSync(path.join(ROOT, 'dist/index.html')) ? 'needs a built web app (npm run build)' : false;
|
||||
|
||||
let server, browser;
|
||||
const problems = [];
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) delete env[k];
|
||||
server = spawn(process.execPath, [path.join(ROOT, 'bridge/server.mjs'), '--demo', '--port', String(PORT)], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.stdout.on('data', (d) => String(d).includes('On this computer') && resolve());
|
||||
server.on('exit', () => reject(new Error('the demo bridge did not start')));
|
||||
});
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
server?.kill('SIGKILL');
|
||||
assert.deepEqual(problems, [], 'the page reported errors or warnings');
|
||||
});
|
||||
|
||||
/** The demo console's Cheats tab on a phone held sideways. `touch` makes it a touch screen. */
|
||||
async function openCheats(touch) {
|
||||
const context = await browser.newContext({ viewport: { width: 844, height: 390 }, ...(touch ? { hasTouch: true, isMobile: true } : {}) });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => problems.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) problems.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
await page.goto(URL);
|
||||
await page.getByRole('button', { name: /Demo console/ }).click();
|
||||
await page.getByText('Your cheats').waitFor();
|
||||
return page;
|
||||
}
|
||||
|
||||
async function newEditor(page, kind) {
|
||||
await page.getByRole('button', { name: 'New' }).click();
|
||||
await page.getByRole('menuitem', { name: kind }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
// Let the opening animation finish, so positions are final.
|
||||
await page.waitForTimeout(300);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/** True when the element's middle is on screen and nothing covers it there. */
|
||||
function reachable(locator) {
|
||||
return locator.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const x = r.left + r.width / 2;
|
||||
const y = r.top + r.height / 2;
|
||||
if (y < 0 || y > innerHeight || x < 0 || x > innerWidth) return false;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
return !!hit && (hit === el || el.contains(hit));
|
||||
});
|
||||
}
|
||||
|
||||
/** One finger dragging up the screen by `dy` pixels at (x, y), the way a phone reports it. */
|
||||
async function swipe(page, x, y, dy) {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y + (dy * i) / 10 }] });
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
/** The controls of the dialog's body, in order: not the footer's buttons, not the close button. */
|
||||
function bodyControls(dialog) {
|
||||
return dialog.locator('input:not([type=hidden]), textarea, button, [role=switch], [role=combobox]');
|
||||
}
|
||||
|
||||
async function lastBodyControl(dialog) {
|
||||
const all = bodyControls(dialog);
|
||||
const n = await all.count();
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const el = all.nth(i);
|
||||
// Not the footer or the close button, and not the invisible copies of switches and lists that forms read.
|
||||
const skipIt = await el.evaluate(
|
||||
(e) => !!e.closest('[data-slot=dialog-footer],[aria-hidden=true]') || e.getAttribute('data-slot') === 'dialog-close' || e.getClientRects().length === 0,
|
||||
);
|
||||
if (!skipIt) return el;
|
||||
}
|
||||
throw new Error('the dialog has no controls');
|
||||
}
|
||||
|
||||
/** Scrolls the dialog down (wheel or finger) until `target` is reachable. Returns how many moves it took, or -1. */
|
||||
async function scrollTo(page, target, touch) {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
if (await reachable(target)) return i;
|
||||
if (touch) await swipe(page, 420, 300, -200);
|
||||
else {
|
||||
await page.mouse.move(420, 200);
|
||||
await page.mouse.wheel(0, 250);
|
||||
}
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
return (await reachable(target)) ? 25 : -1;
|
||||
}
|
||||
|
||||
for (const [kind, touch, capLost] of [
|
||||
['Code cheat (if this, then that)', false, false],
|
||||
['Patch', true, false],
|
||||
// What a browser without `dvh` does to a height cap written with it, or a dialog that sets its own height or overflow:
|
||||
// the dialog's own cap and scrolling are gone. It must still scroll.
|
||||
['Value', false, true],
|
||||
]) {
|
||||
test(`${kind}: taller than the screen, and every part can be reached ${touch ? 'with a finger' : 'with the wheel'}${capLost ? ', even without its own height cap' : ''}`, { skip }, async () => {
|
||||
const page = await openCheats(touch);
|
||||
if (capLost) await page.addStyleTag({ content: '[data-slot=dialog-content]{max-height:none !important;overflow:visible !important}' });
|
||||
const dialog = await newEditor(page, kind);
|
||||
// Measured once the dialog is open: reaching the New button may have scrolled the page a little first.
|
||||
const pageScroll = await page.evaluate(() => scrollY);
|
||||
|
||||
// The dialog does not fit, so this is a real test of scrolling.
|
||||
const box = await dialog.boundingBox();
|
||||
const tall = await dialog.evaluate((el) => Math.max(el.scrollHeight, el.getBoundingClientRect().height));
|
||||
assert.ok(tall > 390 * 1.5, `the dialog is only ${tall}px tall`);
|
||||
// Its top is on screen, with the title readable and the close button in reach.
|
||||
assert.ok(box.y >= 0, `the dialog starts ${box.y}px off the top`);
|
||||
assert.ok(await reachable(dialog.getByRole('heading').first()), 'the title is covered or off screen');
|
||||
// Save and Cancel are in reach from the start, and stay so.
|
||||
assert.ok(await reachable(dialog.getByRole('button', { name: 'Save' })), 'Save is out of reach before scrolling');
|
||||
|
||||
const last = await lastBodyControl(dialog);
|
||||
const moves = await scrollTo(page, last, touch);
|
||||
assert.ok(moves > 0, moves === 0 ? 'the last control was in reach without scrolling: not a tall dialog' : 'the last control could not be reached by scrolling');
|
||||
await last.click();
|
||||
assert.ok(await last.evaluate((el) => el === document.activeElement || el.contains(document.activeElement) || el.matches('[role=switch],button')), 'the click did not land on the last control');
|
||||
assert.ok(await reachable(dialog.getByRole('button', { name: 'Save' })), 'Save went out of reach after scrolling');
|
||||
assert.equal(await page.evaluate(() => scrollY), pageScroll, 'the page behind the dialog scrolled');
|
||||
|
||||
// And back up to the top, to the name field.
|
||||
const first = dialog.getByRole('textbox').first();
|
||||
for (let i = 0; i < 25 && !(await reachable(first)); i++) {
|
||||
if (touch) await swipe(page, 420, 100, 200);
|
||||
else {
|
||||
await page.mouse.move(420, 200);
|
||||
await page.mouse.wheel(0, -250);
|
||||
}
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
assert.ok(await reachable(first), 'the top of the dialog could not be reached again');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
if (await page.getByRole('dialog').count()) await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.context().close();
|
||||
});
|
||||
}
|
||||
|
||||
test('with the game paused, the bar at the top covers no dialog, and the guide still fits', { skip }, async () => {
|
||||
const page = await openCheats(false);
|
||||
await page.getByRole('tab', { name: 'Debug' }).click();
|
||||
await page.getByRole('button', { name: 'Pause the game' }).click();
|
||||
const bar = page.getByText('The game is paused.');
|
||||
await bar.waitFor();
|
||||
await page.getByRole('tab', { name: 'Cheats' }).click();
|
||||
const barBottom = await bar.evaluate((el) => el.closest('[role=status],[role=alert],div[class*=fixed]')?.getBoundingClientRect().bottom ?? el.getBoundingClientRect().bottom);
|
||||
|
||||
const dialog = await newEditor(page, 'Patch');
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(box.y >= barBottom - 1, `the dialog starts at ${box.y}, under the bar that ends at ${barBottom}`);
|
||||
assert.ok(await reachable(dialog.getByRole('heading').first()), 'the title is under the bar');
|
||||
const last = await lastBodyControl(dialog);
|
||||
assert.ok((await scrollTo(page, last, false)) >= 0, 'the last control could not be reached with the bar showing');
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
if (await page.getByRole('dialog').count()) await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// The guide is a full-screen sheet with its own buttons at the bottom: all of it on screen, below the bar.
|
||||
await page.getByRole('button', { name: 'Guide me through my first cheat' }).click();
|
||||
const sheet = page.getByRole('dialog');
|
||||
await sheet.waitFor();
|
||||
await page.waitForTimeout(300);
|
||||
const s = await sheet.boundingBox();
|
||||
assert.ok(s.y >= barBottom - 1 && s.y + s.height <= 390 + 1, `the guide spans ${s.y} to ${s.y + s.height}`);
|
||||
const buttons = sheet.getByRole('button');
|
||||
assert.ok(await reachable(buttons.last()), 'the guide\'s last button is out of reach');
|
||||
await page.context().close();
|
||||
});
|
||||
@@ -46,7 +46,10 @@ before(async () => {
|
||||
// A made-up name for this computer: unlike 127.0.0.1 it is not a "secure" page, so the browser
|
||||
// gives it no clipboard, exactly as when the app is opened from the PS5's address.
|
||||
url = `http://ps5.test:${port}/`;
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server', '--host-resolver-rules=MAP ps5.test 127.0.0.1'] });
|
||||
// The browser gets no proxy settings at all: a proxy named in the environment (HTTPS_PROXY and the like) still carries
|
||||
// its WebSocket to ps5.test despite --no-proxy-server, and the proxy, which cannot reach this computer, answers 502.
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(https?|all|no)_proxy$/i.test(k)));
|
||||
browser = await chromium.launch({ env, args: ['--no-proxy-server', '--host-resolver-rules=MAP ps5.test 127.0.0.1'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
||||
@@ -129,7 +129,8 @@ test('Debug: on a phone held sideways the paused banner leaves a dialog whole an
|
||||
const bar = await banner.boundingBox();
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(box.y >= bar.height, `the dialog starts at ${box.y}px, under a ${bar.height}px banner`);
|
||||
assert.ok(box.y + box.height <= 390, 'and ends inside the screen');
|
||||
const save = await dialog.getByRole('button', { name: 'Save' }).boundingBox();
|
||||
assert.ok(save.y + save.height <= 390, 'and Save is on the screen (the rest of a tall dialog scrolls)');
|
||||
|
||||
// An open dialog switches off clicks outside itself; Resume must work all the same, and not close the dialog.
|
||||
await banner.locator('button', { hasText: 'Resume' }).click({ timeout: 3000 });
|
||||
@@ -147,10 +148,12 @@ test('Debug: a mistake in "Inside an object" stays on screen, and adding a break
|
||||
await add.fill('game+3600');
|
||||
await add.press('Enter');
|
||||
await panel.getByText('There is already a breakpoint on that instruction').waitFor();
|
||||
// The game stops there at once; let it go, and take the breakpoint out.
|
||||
// The game stops there at once; let it go, and take the breakpoint out. The "paused" bar arrives a moment
|
||||
// later and moves the page down, so it is waited for first: a menu opened just before would move with it.
|
||||
const banner = page.locator('body > [role=alert]');
|
||||
await banner.first().waitFor({ timeout: 2000 }).catch(() => undefined);
|
||||
await panel.getByRole('button', { name: /^Actions for the breakpoint/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
const banner = page.locator('body > [role=alert]');
|
||||
if (await banner.count()) await banner.locator('button', { hasText: 'Resume' }).click();
|
||||
|
||||
await panel.getByRole('tab').nth(2).click();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// The game's cheat file in a real browser, against the demo console: a
|
||||
// table for exactly the running version is saved into it and opens from it
|
||||
// next time; the start set is recorded; deleting asks first, with Cancel
|
||||
// in focus, and the dialog works on a phone held sideways.
|
||||
// Skipped when Playwright or the built web app is not there.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/localfirst.ui.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const PORT = 19396 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const URL = `http://127.0.0.1:${PORT}/`;
|
||||
const FILE_API = `${URL}api/tables/PPSA99999/01.000.000?host=127.0.0.1`;
|
||||
|
||||
let chromium = null;
|
||||
for (const from of [import.meta.url, '/opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
chromium = createRequire(from)('playwright').chromium;
|
||||
break;
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
const skip = !chromium ? 'needs Playwright' : !fs.existsSync(path.join(ROOT, 'dist/index.html')) ? 'needs a built web app (npm run build)' : false;
|
||||
|
||||
let server, browser;
|
||||
const problems = [];
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) delete env[k];
|
||||
server = spawn(process.execPath, [path.join(ROOT, 'bridge/server.mjs'), '--demo', '--port', String(PORT)], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.stdout.on('data', (d) => String(d).includes('On this computer') && resolve());
|
||||
server.on('exit', () => reject(new Error('the demo bridge did not start')));
|
||||
});
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
server?.kill('SIGKILL');
|
||||
});
|
||||
|
||||
/** The demo console's Cheats tab. With `offline`, nothing on the internet answers at all. */
|
||||
async function openCheats(width, height) {
|
||||
const context = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => problems.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) problems.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.abort());
|
||||
await page.goto(URL);
|
||||
await page.getByRole('button', { name: /Demo console/ }).click();
|
||||
return page;
|
||||
}
|
||||
|
||||
const savedFile = async () => {
|
||||
const res = await fetch(FILE_API);
|
||||
return res.status === 200 ? res.json() : null;
|
||||
};
|
||||
|
||||
/** The file once `check` likes it: the page writes it a moment after a change, so changes made close together go in one write. */
|
||||
async function fileWhen(check) {
|
||||
const deadline = Date.now() + 5000;
|
||||
for (;;) {
|
||||
const doc = await savedFile();
|
||||
if (check(doc)) return doc;
|
||||
if (Date.now() > deadline) assert.fail(`the file on the console did not change as expected: ${JSON.stringify(doc)?.slice(0, 300)}`);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
test('a table for exactly this version is saved into the file on the PS5, and opens from it', { skip }, async () => {
|
||||
assert.equal(await savedFile(), null);
|
||||
let page = await openCheats(1280, 860);
|
||||
await page.getByRole('switch', { name: 'Infinite Ammo' }).waitFor();
|
||||
await page.getByText(/Saved on your PS5 .*opens with no download/).waitFor();
|
||||
const doc = await fileWhen((d) => d?.sources);
|
||||
assert.deepEqual(doc.sources.map((s) => [s.kind, s.source, s.version, s.cheats.length]), [['community', 'local', '01.000.000', 4]]);
|
||||
assert.deepEqual(doc.cheats, []);
|
||||
await page.context().close();
|
||||
|
||||
// Next time it is the saved one that opens, and the table list says so.
|
||||
page = await openCheats(390, 844);
|
||||
await page.getByRole('switch', { name: 'Infinite Ammo' }).waitFor();
|
||||
assert.match(await page.getByLabel('Cheat table').innerText(), /saved on your PS5 · matches your game/);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the start set records what is on, and says what it did', { skip }, async () => {
|
||||
const page = await openCheats(390, 844);
|
||||
const ammo = page.getByRole('switch', { name: 'Infinite Ammo' });
|
||||
await ammo.click();
|
||||
await page.locator('[role=switch][aria-label="Infinite Ammo"][aria-checked=true]').waitFor();
|
||||
await page.getByRole('button', { name: 'More for the cheat file' }).click();
|
||||
await page.getByRole('menuitem', { name: "Save what's on as this game's start set" }).click();
|
||||
await page.getByText(/Start set saved: "Infinite Ammo"/).waitFor();
|
||||
assert.deepEqual((await fileWhen((d) => d?.startSet)).startSet, [{ name: 'Infinite Ammo', from: 'local/PPSA99999_01.000.000.json' }]);
|
||||
await page.getByRole('region', { name: 'Cheat file' }).getByText(/Start set:/).waitFor();
|
||||
// Nothing on: it says so rather than saving an empty start set.
|
||||
await ammo.click();
|
||||
await page.locator('[role=switch][aria-label="Infinite Ammo"][aria-checked=false]').waitFor();
|
||||
await page.getByRole('button', { name: 'More for the cheat file' }).click();
|
||||
await page.getByRole('menuitem', { name: "Save what's on as this game's start set" }).click();
|
||||
await page.getByText(/Nothing is switched on right now/).waitFor();
|
||||
assert.equal((await savedFile()).startSet.length, 1);
|
||||
// Clearing it.
|
||||
await page.getByRole('button', { name: 'More for the cheat file' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Clear start set' }).click();
|
||||
await page.getByText(/Start set cleared/).waitFor();
|
||||
await fileWhen((d) => d && d.startSet === undefined);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('deleting the file asks first, keeps Cancel in focus, and fits a phone held sideways', { skip }, async () => {
|
||||
const page = await openCheats(844, 390);
|
||||
await page.getByRole('switch', { name: 'Infinite Ammo' }).waitFor();
|
||||
const more = page.getByRole('button', { name: 'More for the cheat file' });
|
||||
await more.click();
|
||||
await page.getByRole('menuitem', { name: /Delete from PS5/ }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByText('Delete PPSA99999-01.000.000.cspector from your PS5?').waitFor();
|
||||
await dialog.getByText(/The 4 cheats saved from cheat tables/).waitFor();
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.textContent?.trim()), 'Cancel');
|
||||
// Enter alone never deletes anything.
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
assert.notEqual(await savedFile(), null);
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute('aria-label')), 'More for the cheat file', 'focus goes back to the menu button');
|
||||
|
||||
await more.click();
|
||||
await page.getByRole('menuitem', { name: /Delete from PS5/ }).click();
|
||||
const box = await dialog.boundingBox();
|
||||
assert.ok(box.y >= 0 && box.y + box.height <= 390, 'the dialog fits on the screen');
|
||||
const del = dialog.getByRole('button', { name: 'Delete file' });
|
||||
await del.scrollIntoViewIfNeeded();
|
||||
await del.click();
|
||||
await page.getByText(/Deleted PPSA99999-01.000.000.cspector from your PS5/).waitFor();
|
||||
assert.equal(await savedFile(), null);
|
||||
// With nothing left to delete, the menu says no more.
|
||||
await more.click();
|
||||
assert.equal(await page.getByRole('menuitem', { name: /Delete from PS5/ }).getAttribute('aria-disabled'), 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
// Not saved again by itself in this visit, even though the table matches.
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(await savedFile(), null);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('none of that put an error or a warning in the browser console', { skip }, () => {
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// Every screen has an address: reload, Back and Forward land on the same
|
||||
// screen. Skipped when Playwright or the built web app is not there.
|
||||
//
|
||||
// CS_PORT_OFFSET=<n> node --test test/ui/route.ui.test.mjs
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const PORT = 18830 + Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const URL = `http://127.0.0.1:${PORT}/`;
|
||||
|
||||
let chromium = null;
|
||||
for (const from of [import.meta.url, '/opt/npm-tools/node_modules/']) {
|
||||
try {
|
||||
chromium = createRequire(from)('playwright').chromium;
|
||||
break;
|
||||
} catch {
|
||||
// try the next place
|
||||
}
|
||||
}
|
||||
const skip = !chromium ? 'needs Playwright' : !fs.existsSync(path.join(ROOT, 'dist/index.html')) ? 'needs a built web app (npm run build)' : false;
|
||||
|
||||
let server, browser;
|
||||
const problems = [];
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
const env = { ...process.env };
|
||||
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) delete env[k];
|
||||
server = spawn(process.execPath, [path.join(ROOT, 'bridge/server.mjs'), '--demo', '--port', String(PORT)], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.stdout.on('data', (d) => String(d).includes('On this computer') && resolve());
|
||||
server.on('exit', () => reject(new Error('the demo bridge did not start')));
|
||||
});
|
||||
browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
server?.kill('SIGKILL');
|
||||
assert.deepEqual(problems, [], 'the page reported errors or warnings');
|
||||
});
|
||||
|
||||
test('reload, Back and Forward keep the screen', { skip }, async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 } });
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (e) => problems.push(`error: ${e.message}`));
|
||||
page.on('console', (m) => {
|
||||
if ((m.type() === 'error' || m.type() === 'warning') && !/Failed to load resource/.test(m.text())) problems.push(`${m.type()}: ${m.text()}`);
|
||||
});
|
||||
await page.route('https://raw.githubusercontent.com/**', (route) => route.fulfill({ status: 404, body: '' }));
|
||||
await page.goto(URL);
|
||||
await page.getByRole('button', { name: /Demo console/ }).click();
|
||||
await page.getByText('Your cheats').waitFor();
|
||||
const hash = () => page.evaluate(() => location.hash);
|
||||
const tab = () => page.locator('[data-slot=tabs-trigger][aria-selected=true]').first().innerText();
|
||||
// The start becomes the running game's address without adding a history entry.
|
||||
await page.waitForFunction(() => location.hash === '#/game/PPSA99999/cheats');
|
||||
|
||||
await page.getByRole('tab', { name: 'Find' }).click();
|
||||
assert.equal(await hash(), '#/game/PPSA99999/find');
|
||||
// The demo console is remembered, so a reload reconnects to it by itself.
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'Find', selected: true }).waitFor();
|
||||
|
||||
await page.getByRole('tab', { name: 'Memory' }).click();
|
||||
await page.goBack();
|
||||
await page.waitForFunction(() => location.hash.endsWith('/find'));
|
||||
assert.equal(await tab(), 'Find');
|
||||
await page.goForward();
|
||||
await page.waitForFunction(() => location.hash.endsWith('/memory'));
|
||||
assert.equal(await tab(), 'Memory');
|
||||
|
||||
// The library is a screen of its own, and closing it returns to the tab.
|
||||
await page.getByRole('button', { name: 'Games' }).click();
|
||||
assert.equal(await hash(), '#/library');
|
||||
await page.reload();
|
||||
await page.getByRole('dialog', { name: 'Your games' }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => location.hash === '#/game/PPSA99999/memory');
|
||||
|
||||
// Settings and the diagnostics log survive a reload too.
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: /Open diagnostics/ }).click();
|
||||
assert.equal(await hash(), '#/settings/diagnostics');
|
||||
await page.reload();
|
||||
await page.getByRole('heading', { name: 'Diagnostics' }).waitFor();
|
||||
// The page reconnects by itself behind the diagnostics screen, and the log says so.
|
||||
await page.getByRole('log').getByText(/Connected to Demo console/).waitFor({ timeout: 10000 });
|
||||
|
||||
// A link to a game that is not running says so, and offers the way back.
|
||||
await page.goto(`${URL}#/game/PPSA01575/find`);
|
||||
await page.getByRole('heading', { name: 'PPSA01575 is not running' }).waitFor();
|
||||
await page.getByRole('button', { name: /^Show / }).click();
|
||||
await page.waitForFunction(() => location.hash === '#/game/PPSA99999/cheats');
|
||||
await context.close();
|
||||
});
|
||||
Reference in new issue
Block a user