mirror of
https://github.com/TiagoVal97/cheatspecter.git
synced 2026-10-18 01:47:02 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bda44ebec | ||
|
|
eda8b9778a | ||
|
|
08c9abb573 |
No files matched your search
@@ -1,12 +1,25 @@
|
||||
## What this changes
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences. Link the issue if there is one. -->
|
||||
<!-- What this changes and why, in a sentence or two. Link the issue: "Closes #123". -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Lesson or documentation
|
||||
- [ ] Refactor or tests only
|
||||
|
||||
## How it was tested
|
||||
|
||||
- [ ] `npm run typecheck` and `npm test` pass
|
||||
- [ ] New behavior has a test
|
||||
- [ ] Checked in a browser at phone and desktop widths (for interface changes)
|
||||
- [ ] Tested on a real PS5 (say which firmware and game), or: not tested on hardware
|
||||
- [ ] Interface changes checked in a browser at phone and desktop widths
|
||||
- [ ] Tried on a real PS5 (firmware and game: ...), or: not tried on hardware
|
||||
|
||||
## Anything reviewers should know
|
||||
## Screenshots
|
||||
|
||||
<!-- For interface changes: before and after. Delete this section otherwise. -->
|
||||
|
||||
## Notes for the reviewer
|
||||
|
||||
<!-- Anything risky, unfinished or worth a closer look. -->
|
||||
+177
@@ -1,5 +1,182 @@
|
||||
# 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
|
||||
the page, a hotkey or the MCP server ("Infinite ammo: ON"). Several cheats
|
||||
switched together, such as a group, share one notification, and "Restore
|
||||
everything" announces itself once.
|
||||
|
||||
## 0.4.1
|
||||
|
||||
A quality pass over 0.4.0 after it was tried on a real console. Like 0.4.0,
|
||||
most of this has been tested against the simulated console only.
|
||||
|
||||
### Fixed
|
||||
- The Learn page crashed in current browsers ("l is not a function") when
|
||||
moving between lesson steps.
|
||||
- A first search that found nothing gave no useful feedback. Every search now
|
||||
ends with what was searched, what came back and what to try next.
|
||||
- Dialogs taller than the screen: they now scroll inside, and Save and Cancel
|
||||
stay in view. Editors no longer lose what you typed to a stray click.
|
||||
- A browser console error on every closed connection to the console
|
||||
("Close received after close").
|
||||
- Copy buttons did nothing when the page is served from the PS5.
|
||||
- The practice game covered the lesson's tools; it now sits beside them on
|
||||
wide screens and shrinks on short ones. A lesson's practice game no longer
|
||||
restarts between steps.
|
||||
- Importing a damaged `.cspector` file could show a raw error; damaged
|
||||
entries are now left out and counted.
|
||||
- A patch whose original bytes were shorter than its new bytes could be saved
|
||||
and would restore only part of the code.
|
||||
- Error messages that vanished within a second on the Memory and Debug tabs.
|
||||
- Memory that is not there was shown as zeros with no warning.
|
||||
- The paused-game banner covered dialogs and could not be pressed over them.
|
||||
- The header wrapped onto two lines on a phone; switches were hard to tap.
|
||||
|
||||
### Improved
|
||||
- Search progress is real: percentage, memory covered, matches so far, time
|
||||
elapsed and time left, with a Cancel button that works at any point.
|
||||
- Searches run in smaller pieces, so progress moves steadily.
|
||||
- "Ignore capitals" in text search is exact for any length of text.
|
||||
- The page reconnects by itself when the debugger comes up.
|
||||
- An error on one screen no longer blanks the whole page.
|
||||
- The pointer finder can be canceled.
|
||||
- Lessons: clearer text, a notice when an earlier step has not been done, and
|
||||
progress shown on "do it three times" goals.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
Everything new in this version passes the automated tests against the
|
||||
|
||||
@@ -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>
|
||||
|
||||
+30
-4
@@ -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.
|
||||
|
||||
@@ -114,9 +114,10 @@ capitals, spacing and a final period are ignored), `explain`, optionally `text`.
|
||||
}
|
||||
```
|
||||
|
||||
- `game`: which of the lesson's games. Steps that follow each other in the same game carry on
|
||||
in it (the learner's search and cheats are still there), unless a step has `"fresh": true`,
|
||||
which starts a new game with no cheats.
|
||||
- `game`: which of the lesson's games. Steps in the same game carry on in it (the learner's
|
||||
search and cheats are still there), also when a reading or a question sits between them,
|
||||
unless a step has `"fresh": true`, which starts a new game with no cheats. Mark a step
|
||||
`fresh` when it does not use anything from the steps before it.
|
||||
- `tool`: the tab shown when the step opens: `find`, `memory`, `code` or `cheats`.
|
||||
- `hints`: one to six, revealed one at a time, weakest first.
|
||||
- `"challenge": true` marks a step the learner does alone. Give the goal and no steps. Every
|
||||
@@ -125,6 +126,31 @@ capitals, spacing and a final period are ignored), `explain`, optionally `text`.
|
||||
The learner also gets a **Show me** button. It reaches the goal with the same tools and writes
|
||||
down what it did; you do not script it.
|
||||
|
||||
### Writing practice steps that hold up
|
||||
|
||||
A learner does not always arrive at a step the way you wrote it: they skip, come back the next
|
||||
day (the game starts over), restart the game when not asked, or type the wrong number. The app
|
||||
helps: when a step builds on earlier ones that were not done in the running game, it says so
|
||||
and offers the way back; and when a search has dropped the value a goal is about, it says that
|
||||
too. The text has to do its part:
|
||||
|
||||
- **Call things what the app calls them.** Bold in a practice step is for what the learner
|
||||
presses or looks at, and the checker refuses a bold name that is neither in the app nor in
|
||||
the game. (Examples to type, such as a cheat's name, go in `EXAMPLES` in the checker.)
|
||||
- **Do not say where on the screen something is.** The practice game is at the bottom of a
|
||||
phone and beside the lesson in a wide window; a table has column headings on one and not on
|
||||
the other. Say "the practice game's panel", "the box on the row".
|
||||
- **Do not assume a button that only exists in one state.** "Press New search" is wrong when
|
||||
no search is going; write "if the Find tab shows a search, press New search".
|
||||
- **Follow your own hints in the app**, literally, in the game as it starts. A game is laid out
|
||||
the same way for every learner, so what you see is what they get: if the pointer search
|
||||
offers two paths, the hint has to say which one.
|
||||
- **Order a goal's parts so that only the real thing passes.** "Make a cheat that survives the
|
||||
update" is `off`, then `updated`, then `off` again; with `updated` first, a cheat made after
|
||||
the update would pass.
|
||||
- An older CheatSpecter reads these same files. Stay within the format, and keep text true for
|
||||
it as well: in 0.4.0 the game started over after every reading or question.
|
||||
|
||||
**`soon`**: `text` explaining practice that the app cannot offer yet.
|
||||
|
||||
## Goals
|
||||
|
||||
+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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "A number that is not where you look",
|
||||
"text": "The rally car shows **Fuel** with a decimal point, and **Use boost** burns some.\n\nTry it the old way first if you like: search for the fuel as **4 bytes**, boost, and narrow down. The list runs dry, because the fuel was never there.\n\nThen do it properly:\n\n1. Press **New search**.\n2. Set **Type** to **Auto**.\n3. Search for the fuel number, boost, type the new number, narrow down.\n\nEach match says which type it is. Set the fuel to **500**.",
|
||||
"text": "The rally car shows **Fuel** with a decimal point, and **Use boost** burns some.\n\nTry it the old way first if you like: search for 50 as **4 bytes**, then boost. The new number, 42.5, is not even accepted as a whole number, and whatever you narrow down with instead, the list runs dry. The fuel was never in it.\n\nThen do it properly:\n\n1. Press **New search**, if you tried the old way.\n2. Set **Type** to **Auto**.\n3. Search for the fuel number, boost, type the new number, narrow down.\n\nEach match says which type it is. Set the fuel to **500**.",
|
||||
"game": "race",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -173,7 +173,7 @@
|
||||
"value": 500
|
||||
},
|
||||
"hints": [
|
||||
"Type is the first dropdown in the Find tab. It can only be changed before a search starts, so press New search first.",
|
||||
"Type is the first dropdown in the Find tab. It can only be changed before a search starts, so if one is going, press New search first.",
|
||||
"With Auto, type the fuel exactly as shown, for example 50 or 42.5.",
|
||||
"One boost burns 7.5. After one boost from 50, narrow down with 42.5.",
|
||||
"The match that says \"Float\" is the fuel. Type 500 in its box and press Set."
|
||||
@@ -201,7 +201,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Stop the clock",
|
||||
"text": "Find the **Time left** clock and freeze it.\n\n1. Press **New search**. Set **Type** to **Float** and **Value is** to **Between**.\n2. Type **0** in the first box and **100** in the second, and press **Search**.\n3. Set **Value now is** to **Went down** and press **Narrow down**. Wait a second or two and press it again. Repeat until a few are left.\n4. Freeze the one that is counting down, with its **padlock**.",
|
||||
"text": "Find the **Time left** clock and freeze it.\n\n1. If the Find tab shows a search, press **New search**. Then set **Type** to **Float** and **Value is** to **Between**.\n2. Type **0** in the first box and **100** in the second, and press **Search**.\n3. Set **Value now is** to **Went down** and press **Narrow down**. Wait a second or two and press it again. Repeat until a few are left.\n4. Freeze the one that is counting down, with its **padlock**.",
|
||||
"game": "race",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -215,7 +215,8 @@
|
||||
"After the Between search, do not type anything more. Just choose \"Went down\" and press Narrow down every couple of seconds.",
|
||||
"In the list, the right row is the one whose Value keeps dropping by one each second. Press its padlock."
|
||||
],
|
||||
"done": "Time stands still. You searched without typing the value once, and froze a number the game was changing on its own."
|
||||
"done": "Time stands still. You searched without typing the value once, and froze a number the game was changing on its own.",
|
||||
"fresh": true
|
||||
},
|
||||
{
|
||||
"kind": "practice",
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"text": "",
|
||||
"question": "A line reads \"jne 0x401200\". You want it to jump always, whatever was compared. What should its first word be?",
|
||||
"accept": [
|
||||
"jmp"
|
||||
"jmp",
|
||||
"jmp 0x401200"
|
||||
],
|
||||
"explain": "`jmp` jumps without asking. Swapping a conditional jump for `jmp`, or for no-ops, are the two smallest edits that flip a decision."
|
||||
},
|
||||
@@ -74,7 +75,7 @@
|
||||
{
|
||||
"kind": "soon",
|
||||
"title": "Practice",
|
||||
"text": "An editor for writing detours like the one described here is being built into CheatSpecter. When it is ready, this lesson will get a practice game where you add the \"only for the player\" check yourself.\n\nUntil then, lesson 10's practice game is a good place to read real instructions: use **Find what writes to this** on your health and press **Show** to see the code around it."
|
||||
"text": "The practice games in these lessons cannot run code that you add yet, so this lesson has no practice step of its own. When they can, you will add the \"only for the player\" check here yourself.\n\nUntil then, lesson 10's practice game is a good place to read real instructions: use **Find what writes to this** on your health and press **Show** to see the code around it."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -157,7 +157,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "The first search",
|
||||
"text": "The practice game at the bottom of the window shows your **Ammo**, and **Fire** is you pulling the trigger.\n\nIn the **Find** tab:\n\n1. Leave **Type** on 4 bytes and **Value is** on Exactly. That is how most whole numbers in games are kept.\n2. Type the ammo number you see into **Value**.\n3. Press **Search**.\n\nYou get a list of every place that holds that number right now. Do not fire yet.",
|
||||
"text": "The practice game shows your **Ammo**, and **Fire** is you pulling the trigger.\n\nIn the **Find** tab:\n\n1. Leave **Type** on 4 bytes and **Value is** on Exactly. That is how most whole numbers in games are kept.\n2. Type the ammo number you see into **Value**.\n3. Press **Search**.\n\nYou get a list of every place that holds that number right now. Do not fire yet.",
|
||||
"game": "range",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -167,7 +167,7 @@
|
||||
"atMost": 2000
|
||||
},
|
||||
"hints": [
|
||||
"The number to search for is the one under \"Ammo\" at the bottom of the window. It starts at 30.",
|
||||
"The number to search for is the one under \"Ammo\" in the practice game. It starts at 30.",
|
||||
"In the Find tab, type 30 into the Value box and press Search. If you already fired, type the number shown now instead."
|
||||
],
|
||||
"done": "That list is every place holding the number, your real ammo among them. Next, you thin it out."
|
||||
@@ -194,7 +194,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Change it",
|
||||
"text": "The match has a box next to it under **Change to**. Whatever you put there is written straight into the game's memory.\n\n1. Type **999** into the box on that row.\n2. Press **Set**.\n\nThen look at the game at the bottom of the window.",
|
||||
"text": "The match has an empty box on its row, followed by a **Set** button. Whatever you put there is written straight into the game's memory.\n\n1. Type **999** into the box on that row.\n2. Press **Set**.\n\nThen look at the Ammo number in the practice game.",
|
||||
"game": "range",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Switch it off",
|
||||
"text": "Next to the instruction is a button: **Disable it**. Press it.\n\nThe tool stops watching, overwrites the instruction with no-ops, and saves the whole thing as a cheat with an on/off switch under **Your cheats**.",
|
||||
"text": "Next to the instruction is a button: **Disable it**. Press it.\n\nThe tool stops watching, overwrites the instruction with no-ops, saves the whole thing as a cheat with an on/off switch under **Your cheats**, and takes you to that tab.",
|
||||
"game": "range",
|
||||
"tool": "code",
|
||||
"goal": {
|
||||
|
||||
@@ -216,17 +216,22 @@
|
||||
{
|
||||
"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",
|
||||
"title": "On your own: free potions, any version",
|
||||
"text": "Make potions free, install the shop's update, and have potions still be free afterward.",
|
||||
"text": "Make potions free. Then install the shop's update, and have potions still be free afterward.\n\nThe order matters: the cheat has to be one you made before the update.",
|
||||
"game": "shop",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
"check": "all",
|
||||
"of": [
|
||||
{
|
||||
"check": "off",
|
||||
"label": "Switch off the code that takes your coins",
|
||||
"action": "buy"
|
||||
},
|
||||
{
|
||||
"check": "updated",
|
||||
"label": "Install the game update"
|
||||
@@ -247,8 +252,9 @@
|
||||
},
|
||||
"hints": [
|
||||
"Make the cheat first, the lesson 6 way: find the coins, find what writes to them, disable it.",
|
||||
"Then press \"Install game update\" and switch your cheat back on in Your cheats.",
|
||||
"Buy three potions to prove it."
|
||||
"Then press \"Install game update\". The update switches the cheat off, so switch it back on in Your cheats.",
|
||||
"Buy three potions to prove it.",
|
||||
"If you installed the update before making the cheat, press \"Start this step again\" to get the first version of the game back."
|
||||
],
|
||||
"done": "A cheat that outlives a restart and an update. Most cheats people share are exactly this: a named switch, a pattern, and a few no-ops.",
|
||||
"fresh": true,
|
||||
|
||||
@@ -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- **Accounts.** Games that talk to online services can notice changed values, and the penalty can be a ban, sometimes for the console and not only the game.\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",
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Freeze your ammo",
|
||||
"text": "First find your ammo, the way you did in lesson 2: search for the number, fire, narrow down, until one match is left.\n\nThen press the **padlock** at the end of that row. It turns yellow: the value is frozen at what it is now. The row moves to the top of the list and stays there.\n\nFire three times, slowly, and watch the Ammo number.",
|
||||
"text": "First find your ammo, the way you did in lesson 2: search for the number, fire, narrow down, until one match is left.\n\nThen press the **padlock** at the end of that row. It turns yellow: the value is frozen at what it is now. The row also stays in the list from now on, even if a later search would drop it.\n\nFire three times, slowly, and watch the Ammo number.",
|
||||
"game": "range",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -191,7 +191,7 @@
|
||||
]
|
||||
},
|
||||
"hints": [
|
||||
"\"Restart game\" is on the practice game's bar at the bottom of the window, top right."
|
||||
"\"Restart game\" is on the practice game's own panel, next to its name."
|
||||
],
|
||||
"done": "The ammo went down, and your frozen row is gone. The freeze belonged to an address in the game that closed. In the new one, the ammo lives somewhere else, and the tool will not keep writing to an address that may now hold something unrelated."
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "See how crowded memory is",
|
||||
"text": "Time to look inside a game. A small practice game is running at the bottom of the window. It shows **Lives** and **Steps**. The tools above it are the real ones, pointed at that game.\n\nIn the **Find** tab, type the number of lives you see into **Value** and press **Search**.\n\nLook at how many matches come back. Every one of them is a place in this tiny game's memory that holds the same number as your lives. A real game has thousands of times more memory than this.",
|
||||
"text": "Time to look inside a game. A small practice game is running in its own panel on this page, marked **Practice game**. It shows **Lives** and **Steps**. The tools on this page are the real ones, pointed at that game.\n\nIn the **Find** tab, type the number of lives you see into **Value** and press **Search**.\n\nLook at how many matches come back. Every one of them is a place in this tiny game's memory that holds the same number as your lives. A real game has thousands of times more memory than this.",
|
||||
"game": "lives",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -104,7 +104,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Restart, and watch it move",
|
||||
"text": "Look at the first few addresses in your list and remember roughly what they look like.\n\nNow press **Restart game** at the top right of the practice game, as if you had closed the game and started it again. Then search for the lives once more.\n\nCompare the addresses with the ones from before.",
|
||||
"text": "Look at the first few addresses in your list and remember roughly what they look like.\n\nNow press **Restart game** on the practice game's panel, as if you had closed the game and started it again. Then search for the lives once more.\n\nCompare the addresses with the ones from before.",
|
||||
"game": "lives",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -123,7 +123,7 @@
|
||||
]
|
||||
},
|
||||
"hints": [
|
||||
"\"Restart game\" is the small button on the practice game's own bar at the bottom of the window.",
|
||||
"\"Restart game\" is the small button on the practice game's own panel, next to its name.",
|
||||
"After the restart the Find tab is empty again, because everything it knew was about the game that just closed. Type 3 and press Search."
|
||||
],
|
||||
"done": "Different addresses, every one of them. Nothing you find by searching outlives the game it was found in. Keep that in mind; lessons 6 and 9 are about getting around it."
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Fill it and freeze it",
|
||||
"text": "Look at the **Value** column of what is left. One of them is your health, and you can now see what \"full\" is in this game.\n\nPress the **padlock** on its row to freeze it, then take three hits.\n\n> tip: If more than one row is left, take a hit and see which value drops by the same amount as the bar. That one is your health.",
|
||||
"text": "Look at the value on each row that is left. One of them is your health, and you can now see what \"full\" is in this game.\n\nPress the **padlock** on its row to freeze it, then take three hits.\n\n> tip: If more than one row is left, take a hit and see which value drops by the same amount as the bar. That one is your health.",
|
||||
"game": "arena",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "On your own: a longer way round",
|
||||
"text": "In this game the lives are two notes away from the fixed spot. Make a cheat that holds **99 lives**, then restart the game and show that it still works.",
|
||||
"text": "In this game the lives are two notes away from the fixed spot, and the search may offer you more than one path. Remember the last step: not every path is real. Make a cheat that holds **99 lives**, then restart the game and show that it still works.",
|
||||
"game": "tower",
|
||||
"tool": "find",
|
||||
"goal": {
|
||||
@@ -286,8 +286,9 @@
|
||||
},
|
||||
"hints": [
|
||||
"Find the Lives number. Falling off changes it. There are many small numbers in memory, so it may take a few rounds.",
|
||||
"… then \"Make it survive restarts…\", Search, and Use on the first path. Set the value to 99 in the form and save.",
|
||||
"Restart the game, then switch the cheat on in Your cheats."
|
||||
"… then \"Make it survive restarts…\" and Search. If more than one path comes back, do not take the shortest on trust: a path with 1 pointer here is a coincidence, something that happens to sit near the lives in this run. Press Use on the one with 2 pointers, set the value to 99 in the form and save.",
|
||||
"Restart the game, then switch the cheat on in Your cheats.",
|
||||
"If the lives do not change after the restart, the path you used was the coincidence. Find the lives again, make a new cheat from the other path, and switch that one on."
|
||||
],
|
||||
"done": "Two pointers deep, and it held. Between switching off instructions and following pointers, you can now make a cheat for almost anything that survives a restart.",
|
||||
"fresh": true,
|
||||
|
||||
@@ -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",
|
||||
@@ -122,7 +122,7 @@
|
||||
{
|
||||
"kind": "practice",
|
||||
"title": "Put it in a group",
|
||||
"text": "Open the **…** menu on the cheat again and choose **Put in a group…**. Type a name for a new group, such as **Weapons**.",
|
||||
"text": "Open the **…** menu on the cheat again and choose **Put in a group…**. Type a name for a new group, such as **Weapons**, and press **Create**.",
|
||||
"game": "range",
|
||||
"tool": "cheats",
|
||||
"goal": {
|
||||
@@ -135,14 +135,32 @@
|
||||
},
|
||||
"hints": [
|
||||
"Your cheats tab, … on the cheat, \"Put in a group…\".",
|
||||
"Type a group name in the box and confirm."
|
||||
"Type a group name in the box and press Create."
|
||||
],
|
||||
"done": "Groups get their own heading and one switch for everything inside. With thirty cheats for one game, you will be glad of them."
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
]
|
||||
},
|
||||
"hints": [
|
||||
"First switch your \"take damage\" cheat off in Your cheats, so the guard can be hurt again.",
|
||||
"If your \"take damage\" cheat from before is still on in Your cheats, switch it off first, so the guard can be hurt again.",
|
||||
"Freezing works on one address: yours. Find your health and press its padlock.",
|
||||
"With your health frozen, let the guard hit you three times, then hit him until his health reaches 0."
|
||||
],
|
||||
|
||||
+90
-1
@@ -13,6 +13,8 @@
|
||||
// The protocol (JSON-RPC over stdin/stdout, one message per line) is small
|
||||
// enough to speak directly, which keeps this free of dependencies.
|
||||
|
||||
import { tvMessage } from '../src/lib/tvnotice.ts';
|
||||
import type { TvChange } from '../src/lib/tvnotice.ts';
|
||||
import { createRequire } from 'node:module';
|
||||
import readline from 'node:readline';
|
||||
|
||||
@@ -34,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';
|
||||
@@ -327,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> {
|
||||
@@ -387,6 +449,15 @@ A patch, or a value with "freeze":true, can also carry "keep":true: the PS5 then
|
||||
|
||||
// ---- tools ---------------------------------------------------------------------
|
||||
|
||||
/** Says on the TV what was switched, as the web app does. A notification that cannot be shown is not an error. */
|
||||
async function tellTv(st: GameState, name: string, change: TvChange): Promise<void> {
|
||||
try {
|
||||
await st.ctx.dbg().notify(tvMessage([{ name, change }]));
|
||||
} catch {
|
||||
// The switch itself worked; that is what matters.
|
||||
}
|
||||
}
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
interface Tool {
|
||||
name: string;
|
||||
@@ -762,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);
|
||||
@@ -800,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(
|
||||
@@ -809,6 +896,7 @@ const tools: Tool[] = [
|
||||
}
|
||||
if (!st.runner.isOn(entry.id)) return `"${entry.name}" is already off.`;
|
||||
await st.runner.disable(entry);
|
||||
await tellTv(st, entry.name, 'off');
|
||||
return `"${entry.name}" is off.`;
|
||||
}
|
||||
try {
|
||||
@@ -820,6 +908,7 @@ const tools: Tool[] = [
|
||||
throw e;
|
||||
}
|
||||
const result = st.runner.result(entry.id);
|
||||
await tellTv(st, entry.name, isAction(entry) ? 'ran' : 'on');
|
||||
return isAction(entry) ? `Ran "${entry.name}".${result ? ' ' + result : ''}` : `"${entry.name}" is on.`;
|
||||
},
|
||||
},
|
||||
|
||||
+51
-19
@@ -115,6 +115,15 @@ function createThread(info, regs) {
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* A region of any size that takes no memory here: it reads as zeros and
|
||||
* forgets writes. Push some onto `mock.game.regions` to give the game a
|
||||
* real-sized memory map (gigabytes, thousands of blocks).
|
||||
*/
|
||||
export function blankRegion(name, start, length, prot = 3) {
|
||||
return { name, start, prot, blank: true, buf: { length, copy: () => 0 } };
|
||||
}
|
||||
|
||||
function createGame() {
|
||||
// int3 padding with real instructions at the interesting offsets, each
|
||||
// followed by a ret, so it disassembles cleanly.
|
||||
@@ -222,6 +231,7 @@ function createGame() {
|
||||
function write(addr, data) {
|
||||
const hit = locate(addr, data.length);
|
||||
if (!hit) return false;
|
||||
if (hit.r.blank) return true;
|
||||
data.copy(hit.r.buf, hit.off);
|
||||
return true;
|
||||
}
|
||||
@@ -561,6 +571,14 @@ export function startMock(opts = {}) {
|
||||
const game = createGame();
|
||||
if (opts.rtti) addTypeInfo(game);
|
||||
const state = { authed: false, notifications: [], foreground: true };
|
||||
// Tests change these while the mock runs: a pause per scanned range, and a console that stops answering mid-scan.
|
||||
state.scanDelayMs = opts.scanDelayMs ?? 0;
|
||||
state.scanHang = false;
|
||||
state.countDelayMs = 0; // the same pause for each range of a "narrow down"
|
||||
const scanPause = async () => {
|
||||
if (state.scanHang) await new Promise(() => {});
|
||||
if (state.scanDelayMs) await new Promise((r) => setTimeout(r, state.scanDelayMs));
|
||||
};
|
||||
const authLfsr = makeLfsr(0x12345678, 0x9abcdef0, 0x0f1e2d3c, 0x4b5a6978);
|
||||
|
||||
state.hazards = [];
|
||||
@@ -986,7 +1004,7 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const pattern = needsValue ? await rd.read(lenData) : null;
|
||||
status(ST.SUCCESS);
|
||||
if (opts.scanDelayMs) await new Promise((r) => setTimeout(r, opts.scanDelayMs));
|
||||
await scanPause();
|
||||
const step = alignment || size;
|
||||
const mem = game.read(address, length);
|
||||
const upper = cmp === 4 ? pattern : null;
|
||||
@@ -1027,6 +1045,8 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const pattern = needsValue && lenData ? await rd.read(lenData) : null;
|
||||
const entrySize = withPrev ? 4 + size : 4;
|
||||
if (state.scanHang) await new Promise(() => {});
|
||||
if (state.countDelayMs) await new Promise((r) => setTimeout(r, state.countDelayMs));
|
||||
for (;;) {
|
||||
const chunkLen = (await rd.read(4)).readUInt32LE(0);
|
||||
if (chunkLen === 0xffffffff) break;
|
||||
@@ -1185,7 +1205,7 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const blob = await rd.read(d.readUInt32LE(17));
|
||||
status(ST.SUCCESS);
|
||||
if (opts.scanDelayMs) await new Promise((r) => setTimeout(r, opts.scanDelayMs));
|
||||
await scanPause();
|
||||
const mem = game.read(address, length);
|
||||
const results = [];
|
||||
for (let cursor = 0; cursor + 5 <= blob.length; ) {
|
||||
@@ -1252,32 +1272,41 @@ export function startMock(opts = {}) {
|
||||
status(ST.SUCCESS);
|
||||
const nseg = (await rd.read(4)).readUInt32LE(0);
|
||||
const segs = await rd.read(nseg * 12);
|
||||
if (opts.scanDelayMs) await new Promise((r) => setTimeout(r, opts.scanDelayMs));
|
||||
const records = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < nseg; i++) {
|
||||
const address = segs.readBigUInt64LE(i * 12);
|
||||
const length = segs.readUInt32LE(i * 12 + 8);
|
||||
total += length;
|
||||
const mem = game.read(address, length);
|
||||
for (let j = 0; j + size <= length; j += size) {
|
||||
const v = mem.subarray(j, j + size);
|
||||
if (!(flags & 8) && v.every((b) => b === 0)) continue; // zero slots are dropped unless asked for
|
||||
records.push({ address: address + BigInt(j), value: Buffer.from(v) });
|
||||
}
|
||||
}
|
||||
turbo = { valType, size, records };
|
||||
for (let i = 0; i < nseg; i++) total += segs.readUInt32LE(i * 12 + 8);
|
||||
// The plan first, then progress as each segment is done, like the console.
|
||||
const plan = Buffer.alloc(16);
|
||||
plan.writeBigUInt64LE(BigInt(Math.floor(total / size)), 0);
|
||||
plan.writeBigUInt64LE(BigInt(total), 8);
|
||||
sock.write(plan);
|
||||
const prog = Buffer.alloc(8);
|
||||
prog.writeBigUInt64LE(BigInt(total));
|
||||
sock.write(prog);
|
||||
let blankSlots = 0;
|
||||
let doneBytes = 0;
|
||||
for (let i = 0; i < nseg; i++) {
|
||||
const address = segs.readBigUInt64LE(i * 12);
|
||||
const length = segs.readUInt32LE(i * 12 + 8);
|
||||
await scanPause();
|
||||
if (game.regions.some((r) => r.blank && address >= r.start && address < r.start + BigInt(r.buf.length))) {
|
||||
// Blank regions are counted but not kept one by one: there can be gigabytes of them.
|
||||
blankSlots += Math.floor(length / size);
|
||||
} else {
|
||||
const mem = game.read(address, length);
|
||||
for (let j = 0; j + size <= length; j += size) {
|
||||
const v = mem.subarray(j, j + size);
|
||||
if (!(flags & 8) && v.every((b) => b === 0)) continue; // zero slots are dropped unless asked for
|
||||
records.push({ address: address + BigInt(j), value: Buffer.from(v) });
|
||||
}
|
||||
}
|
||||
doneBytes += length;
|
||||
const prog = Buffer.alloc(8);
|
||||
prog.writeBigUInt64LE(BigInt(doneBytes));
|
||||
sock.write(prog);
|
||||
}
|
||||
turbo = { valType, size, records, blankSlots: flags & 8 ? blankSlots : 0 };
|
||||
sentinel();
|
||||
const sum = Buffer.alloc(12);
|
||||
sum.writeUInt32LE(1, 0);
|
||||
sum.writeBigUInt64LE(BigInt(records.length), 4);
|
||||
sum.writeBigUInt64LE(BigInt(records.length + turbo.blankSlots), 4);
|
||||
sock.write(sum);
|
||||
status(ST.SUCCESS);
|
||||
break;
|
||||
@@ -1301,7 +1330,10 @@ export function startMock(opts = {}) {
|
||||
}
|
||||
}
|
||||
turbo.records = kept;
|
||||
turbo.blankSlots = 0; // blank memory never changes; the mock simply stops counting it
|
||||
}
|
||||
if (state.scanHang) await new Promise(() => {});
|
||||
if (state.countDelayMs) await new Promise((r) => setTimeout(r, state.countDelayMs));
|
||||
sentinel();
|
||||
const n = Buffer.alloc(8);
|
||||
n.writeBigUInt64LE(BigInt(kept.length));
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cheatspecter",
|
||||
"version": "0.4.0",
|
||||
"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.0",
|
||||
"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) {
|
||||
|
||||
+235
-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
|
||||
@@ -307,6 +309,7 @@ struct relay {
|
||||
int ws;
|
||||
int tcp;
|
||||
pthread_mutex_t wlock;
|
||||
int closed; /* a close frame has gone out; nothing may follow it. Guarded by wlock. */
|
||||
/* For browser_busy(); guarded by g_relays_lock. */
|
||||
int awaiting; /* the browser has sent something the debugger has not answered yet */
|
||||
int slow; /* the answer now arriving took long to start: a search streaming its results */
|
||||
@@ -397,6 +400,13 @@ static int ws_send(struct relay *r, int opcode, const unsigned char *data, size_
|
||||
hlen = 10;
|
||||
}
|
||||
pthread_mutex_lock(&r->wlock);
|
||||
/* When the browser closes first, its close is echoed and the debugger side is then
|
||||
shut, which would send a second close frame. Browsers report that as an error. */
|
||||
if (r->closed) {
|
||||
pthread_mutex_unlock(&r->wlock);
|
||||
return -1;
|
||||
}
|
||||
if (opcode == 0x8) r->closed = 1;
|
||||
int rc = write_all(r->ws, hdr, hlen);
|
||||
if (rc == 0 && len) rc = write_all(r->ws, data, len);
|
||||
pthread_mutex_unlock(&r->wlock);
|
||||
@@ -1314,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 */
|
||||
@@ -1410,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");
|
||||
}
|
||||
@@ -1435,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) {
|
||||
@@ -1458,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");
|
||||
@@ -1477,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) {
|
||||
@@ -1497,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) {
|
||||
@@ -1642,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' });
|
||||
|
||||
@@ -12,7 +12,79 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { checkIndex, checkLesson } from '../src/lib/learn.ts';
|
||||
import { PracticeGame } from '../src/lib/practice/game.ts';
|
||||
|
||||
const DEFAULT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'learn');
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_DIR = path.join(ROOT, 'learn');
|
||||
|
||||
// ---- what the words say ------------------------------------------------------------
|
||||
//
|
||||
// The format check cannot tell whether a lesson is true. These catch the ways
|
||||
// lesson text has gone wrong before: naming a button that the app calls
|
||||
// something else, saying where on the screen the practice game is (it is at the
|
||||
// bottom of a phone and beside the lesson on a wide screen), and wording the
|
||||
// project does not use.
|
||||
|
||||
/** Spellings and subjects the lessons stay away from, each with what to write instead. */
|
||||
const WORDING = [
|
||||
[/\b(colour|behaviour|favour|cancelled|cancelling|grey|centre|labelled|untick|learnt|whilst|afterwards|towards)/i, 'use US spelling'],
|
||||
[/\b(online|multiplayer|multi-player|single-player|singleplayer)\b/i, 'the lessons do not go into online or single-player play; the standing note is "use at your own risk"'],
|
||||
[/\b(at|on) the (bottom|top|left|right|side) of the (window|screen|page)\b/i, 'do not say where on the screen something is; the layout differs between a phone and a wide window. Name the thing instead ("the practice game\'s panel")'],
|
||||
];
|
||||
|
||||
/** Bold words in practice steps that are examples to type, not names of things on screen. */
|
||||
const EXAMPLES = ['Lots of gold', 'padlock'];
|
||||
|
||||
/** Everything the app can put on screen, as one long string: its source. */
|
||||
function appText() {
|
||||
let all = '';
|
||||
for (const dir of ['src/ui', 'src/lib']) {
|
||||
const full = path.join(ROOT, dir);
|
||||
for (const file of fs.readdirSync(full)) if (/\.tsx?$/.test(file)) all += fs.readFileSync(path.join(full, file), 'utf8') + '\n';
|
||||
}
|
||||
return all.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** Every piece of text a lesson shows, with where it is. */
|
||||
function textsOf(lesson) {
|
||||
const out = [
|
||||
['title', lesson.title],
|
||||
['summary', lesson.summary],
|
||||
];
|
||||
lesson.steps.forEach((s, i) => {
|
||||
const at = `steps[${i}]`;
|
||||
for (const key of ['title', 'text', 'question', 'explain', 'done']) if (typeof s[key] === 'string') out.push([`${at}.${key}`, s[key]]);
|
||||
for (const key of ['options', 'hints']) (s[key] ?? []).forEach((t, j) => out.push([`${at}.${key}[${j}]`, t]));
|
||||
if (s.goal) for (const g of s.goal.check === 'all' ? s.goal.of : [s.goal]) out.push([`${at}.goal.label`, g.label]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../src/lib/learn.ts').Lesson} lesson
|
||||
* @param {string} app
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function wordProblems(lesson, app) {
|
||||
const problems = [];
|
||||
for (const [where, text] of textsOf(lesson)) {
|
||||
for (const [pattern, instead] of WORDING) {
|
||||
const found = pattern.exec(text);
|
||||
if (found) problems.push(`${where}: "${found[0]}": ${instead}`);
|
||||
}
|
||||
}
|
||||
// In a practice step, bold is for what the learner presses or looks at. It has to be called that in the app or in the game.
|
||||
const games = JSON.stringify(lesson.games);
|
||||
lesson.steps.forEach((s, i) => {
|
||||
if (s.kind !== 'practice') return;
|
||||
for (const text of [s.text, s.done, ...s.hints]) {
|
||||
for (const [, name] of text.matchAll(/\*\*([^*]+)\*\*/g)) {
|
||||
if (/^\d/.test(name) || EXAMPLES.includes(name)) continue;
|
||||
if (app.includes(name) || games.includes(JSON.stringify(name).slice(0, -1))) continue;
|
||||
problems.push(`steps[${i}]: "${name}" is in bold like a button or a label, but nothing in the app or in the game is called that. Use the app's own words, or add it to EXAMPLES in scripts/check-lessons.mjs if it is something to type`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [dir] the learn/ folder
|
||||
@@ -30,6 +102,7 @@ export function checkLessons(dir = DEFAULT_DIR) {
|
||||
return { problems, lessons, index };
|
||||
}
|
||||
const listed = new Set();
|
||||
const app = appText();
|
||||
for (const course of index.courses) {
|
||||
for (const ref of course.lessons) {
|
||||
const file = `lessons/${ref.id}.json`;
|
||||
@@ -42,6 +115,7 @@ export function checkLessons(dir = DEFAULT_DIR) {
|
||||
continue;
|
||||
}
|
||||
lessons.push(lesson);
|
||||
for (const p of wordProblems(lesson, app)) problems.push(`${file}: ${p}`);
|
||||
if (lesson.id !== ref.id) problems.push(`${file}: its id is "${lesson.id}", which should match its file name`);
|
||||
// The list of lessons is drawn from the index alone, so it has to say the same as the lesson.
|
||||
for (const key of ['title', 'summary', 'minutes']) {
|
||||
|
||||
+113
-20
@@ -5,6 +5,44 @@ import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
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
|
||||
// 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"]')) 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>) {
|
||||
@@ -29,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
|
||||
@@ -53,28 +107,64 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
// 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 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] 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 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -101,7 +191,10 @@ function DialogFooter({
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
// Stays at the bottom edge of a dialog that scrolls, so Save and Cancel are
|
||||
// always in reach however long the form is. The margins take it out to the
|
||||
// dialog's own edges, over the padding, so nothing shows through beside it.
|
||||
"sticky -bottom-6 z-10 -mx-6 -mb-6 flex flex-col-reverse gap-2 bg-background px-6 pt-3 pb-6 before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-linear-to-t before:from-background before:to-transparent sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -34,7 +34,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:block *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:truncate dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,7 +14,8 @@ function Switch({
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
// The switch is drawn small; the invisible ::after makes the area that takes a tap finger-sized.
|
||||
"peer group/switch relative inline-flex shrink-0 after:absolute after:-inset-x-1.5 after:-inset-y-3 after:content-[''] items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,6 +9,27 @@ export function hexToBytes(hex: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes as people type them: "90 90", "90,90", "0x90 0x90", "9090". Says
|
||||
* what is wrong in plain words; the shared hexToBytes drops anything it does
|
||||
* not know, which turns a typo into different bytes.
|
||||
*/
|
||||
export function parseTypedBytes(text: string): Uint8Array {
|
||||
const parts = text
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.filter((p) => p !== '')
|
||||
.map((p) => p.replace(/^0x/i, ''));
|
||||
if (parts.length === 0) throw new Error('Enter some bytes, for example: 90 90 90');
|
||||
const out: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^[0-9a-fA-F]+$/.test(part)) throw new Error(`"${part}" is not a byte. Bytes are written as two hex digits each (0 to 9 and A to F), for example: 90 90 90`);
|
||||
if (part.length % 2 !== 0) throw new Error(`"${part}" is half a byte. Each byte is two hex digits; write a single digit with a zero in front, like 0${part.slice(-1)}.`);
|
||||
for (let i = 0; i < part.length; i += 2) out.push(parseInt(part.slice(i, i + 2), 16));
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array, sep = ''): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
|
||||
+336
-6
@@ -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 {
|
||||
@@ -90,6 +113,8 @@ interface CachedTable {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** How long one download may take before it is given up on. The indexes are a few hundred KB. */
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
const INDEX_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const FORMAT_ORDER: TableFormat[] = ['json', 'shn', 'mc4'];
|
||||
const FILE_RE = /^([A-Z]{4}\d{5})_([0-9.]+?)(?:_.*)?\.(json|shn|mc4)(\.xml)?$/i;
|
||||
@@ -111,9 +136,20 @@ export function parseIndexLine(
|
||||
|
||||
/** Fetches a file as text. Trainer files are not always UTF-8, so the bytes are decoded here. */
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return decodeText(new Uint8Array(await res.arrayBuffer()));
|
||||
// A network that accepts the connection and then says nothing (a captive
|
||||
// Wi-Fi page, a firewall) would otherwise leave "Looking for cheats" up forever.
|
||||
const stop = new AbortController();
|
||||
const timer = setTimeout(() => stop.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-cache', signal: stop.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return decodeText(new Uint8Array(await res.arrayBuffer()));
|
||||
} catch (e) {
|
||||
if (stop.signal.aborted) throw new Error('the download timed out');
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIndex(source: CatalogSource, index: CatalogIndex): Promise<{ lines: string[]; stale: boolean }> {
|
||||
@@ -141,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,
|
||||
@@ -219,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;
|
||||
@@ -261,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 };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copying text to the clipboard on any page.
|
||||
//
|
||||
// `navigator.clipboard` only exists on secure pages (https, or localhost). The
|
||||
// app is normally opened from the PS5 over plain http, where it is simply
|
||||
// missing, so the older way is the one that usually runs: put the text in the
|
||||
// page, select it and ask the browser to copy the selection.
|
||||
|
||||
/** Returns true when the text is on the clipboard. Call it from a click or key press. */
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
// The old way only works while the click is being handled, so it goes first wherever the new one cannot be used.
|
||||
const modern = typeof navigator !== 'undefined' && !!navigator.clipboard && window.isSecureContext !== false;
|
||||
if (!modern) return copyBySelection(text);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Refused (no permission, or the page lost focus): the old way may still be allowed.
|
||||
return copyBySelection(text);
|
||||
}
|
||||
}
|
||||
|
||||
function copyBySelection(text: string): boolean {
|
||||
if (typeof document === 'undefined' || typeof document.execCommand !== 'function') return false;
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return false;
|
||||
// A plain element rather than a text box: selecting it does not move the keyboard
|
||||
// focus, which an open menu or dialog would grab straight back.
|
||||
const holder = document.createElement('span');
|
||||
holder.textContent = text;
|
||||
holder.setAttribute('aria-hidden', 'true');
|
||||
holder.style.cssText = 'position:fixed;top:0;left:0;white-space:pre;opacity:0;pointer-events:none;user-select:text;-webkit-user-select:text';
|
||||
const saved: Range[] = [];
|
||||
for (let i = 0; i < selection.rangeCount; i++) saved.push(selection.getRangeAt(i));
|
||||
document.body.appendChild(holder);
|
||||
let ok = false;
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(holder);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ok = document.execCommand('copy');
|
||||
} catch {
|
||||
ok = false;
|
||||
} finally {
|
||||
selection.removeAllRanges();
|
||||
for (const r of saved) selection.addRange(r);
|
||||
holder.remove();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+11
-2
@@ -35,6 +35,12 @@ export interface EngineReport {
|
||||
/** What was tried and how it went, one line each. */
|
||||
steps: string[];
|
||||
ms: number;
|
||||
/**
|
||||
* True when nothing was found and at least one of the looks stopped with an
|
||||
* error (the connection dropped, a read failed), so "not found" is not a
|
||||
* verdict on the game and looking again may well give another answer.
|
||||
*/
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface DetectOptions {
|
||||
@@ -95,12 +101,14 @@ async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport>
|
||||
say('Looking at what the game has loaded');
|
||||
let guess: EngineGuess;
|
||||
let unfinished = false;
|
||||
let errors = 0;
|
||||
try {
|
||||
guess = await (opts.identify ?? ((c) => identifyEngine(c, { budgetMs: 8000 })))(ctx);
|
||||
unfinished = guess.engine === 'unknown' && guess.evidence.some((line) => line.startsWith('Stopped looking'));
|
||||
} catch (e) {
|
||||
guess = { engine: 'unknown', evidence: [`The first look at the game did not finish: ${reason(e)}`] };
|
||||
unfinished = true;
|
||||
errors++;
|
||||
}
|
||||
|
||||
const attempt = async (id: EngineId): Promise<EngineHelper | null> => {
|
||||
@@ -114,6 +122,7 @@ async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport>
|
||||
return helper;
|
||||
} catch (e) {
|
||||
steps.push(`${title}: stopped with an error (${reason(e)}).`);
|
||||
errors++;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -132,7 +141,7 @@ async function run(ctx: GameContext, opts: DetectOptions): Promise<EngineReport>
|
||||
}
|
||||
if (!helper) helper = await attempt('cpp');
|
||||
if (helper && guess.engine === 'unknown' && helper.engine !== 'cpp') guess = { engine: helper.engine, evidence: guess.evidence };
|
||||
return { helper, guess, steps, ms: Date.now() - started };
|
||||
return helper || errors === 0 ? { helper, guess, steps, ms: Date.now() - started } : { helper, guess, steps, ms: Date.now() - started, failed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +153,7 @@ export function detectEngine(ctx: GameContext, opts: DetectOptions = {}): Promis
|
||||
let job = opts.refresh ? undefined : pending.get(ctx);
|
||||
if (!job) {
|
||||
job = run(ctx, opts)
|
||||
.catch((e): EngineReport => ({ helper: null, guess: { engine: 'unknown', evidence: [] }, steps: [`Stopped with an error (${reason(e)}).`], ms: 0 }))
|
||||
.catch((e): EngineReport => ({ helper: null, guess: { engine: 'unknown', evidence: [] }, steps: [`Stopped with an error (${reason(e)}).`], ms: 0, failed: true }))
|
||||
.then((report) => {
|
||||
settled.set(ctx, report);
|
||||
return report;
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// The words the Find tab uses to say how a search ended: what was looked for,
|
||||
// in how much memory, what came back and what to try next. Kept apart from the
|
||||
// screens so every outcome can be checked by a test.
|
||||
|
||||
import { fmtBytes } from './bytes.ts';
|
||||
import { AUTO_KIND, roundedRange } from './scanner.ts';
|
||||
import type { CompareKind, ScanProgress, ValueKind } from './scanner.ts';
|
||||
|
||||
export interface Feedback {
|
||||
tone: 'info' | 'warn' | 'error';
|
||||
title: string;
|
||||
/** What to try next, most useful first. */
|
||||
tips: string[];
|
||||
}
|
||||
|
||||
/** What was typed into the number search. */
|
||||
export interface NumberQuery {
|
||||
kind: ValueKind;
|
||||
compare: CompareKind;
|
||||
value: string;
|
||||
value2: string;
|
||||
}
|
||||
|
||||
/** How much memory a search covered, and how much there was. */
|
||||
export interface Coverage {
|
||||
bytes: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const n = (x: number) => x.toLocaleString('en-US');
|
||||
const plural = (count: number, one: string, many: string) => `${n(count)} ${count === 1 ? one : many}`;
|
||||
|
||||
/** "3.1 of 9.6 GB": both numbers in the unit of the bigger one. */
|
||||
export function ofBytes(done: number, total: number): string {
|
||||
const units: [number, string][] = [
|
||||
[1024 ** 3, 'GB'],
|
||||
[1024 ** 2, 'MB'],
|
||||
[1024, 'KB'],
|
||||
];
|
||||
for (const [size, name] of units) {
|
||||
if (total >= size) {
|
||||
// Enough digits that the number visibly moves: 0.31 of 1.04 GB, 3.1 of 19.6 GB, 120 of 480 MB.
|
||||
const digits = name !== 'GB' ? (total < 10 * size ? 1 : 0) : total < 10 * size ? 2 : 1;
|
||||
const whole = (total / size).toFixed(digits);
|
||||
// Never shows "9.6 of 9.6" before the end: the part done is rounded down.
|
||||
const part = Math.min(Number(whole), Math.floor((done / size) * 10 ** digits) / 10 ** digits).toFixed(digits);
|
||||
return `${done >= total ? whole : part} of ${whole} ${name}`;
|
||||
}
|
||||
}
|
||||
return `${done} of ${total} bytes`;
|
||||
}
|
||||
|
||||
/** "9.6 GB of memory (everything)" or "1.2 GB of the game's 9.6 GB". */
|
||||
export function coverageWords(c: Coverage): string {
|
||||
return c.bytes >= c.total ? `all ${fmtBytes(c.total)} of the game's memory` : `${fmtBytes(c.bytes)} of the game's ${fmtBytes(c.total)}`;
|
||||
}
|
||||
|
||||
const typeWords = (kind: ValueKind) => (kind.id === AUTO_KIND.id ? '4 bytes, Float or Double' : kind.label);
|
||||
|
||||
/** "90 and 200" whichever way round the two were typed. */
|
||||
function rangeWords(q: NumberQuery): string {
|
||||
const a = q.value.trim();
|
||||
const b = q.value2.trim();
|
||||
return Number(a) <= Number(b) ? `${a} and ${b}` : `${b} and ${a}`;
|
||||
}
|
||||
|
||||
/** Completes "places that hold ...". */
|
||||
export function holdsWords(q: NumberQuery): string {
|
||||
const type = typeWords(q.kind);
|
||||
const v = q.value.trim();
|
||||
switch (q.compare.id) {
|
||||
case 'exact':
|
||||
return `${v} as ${type}`;
|
||||
case 'rounded': {
|
||||
let range = '';
|
||||
try {
|
||||
const [lo, hi] = roundedRange(v);
|
||||
range = ` (${Number(lo.toPrecision(12))} to ${Number(hi.toPrecision(12))})`;
|
||||
} catch {
|
||||
// The text was checked before the search ran; without a range the sentence still reads.
|
||||
}
|
||||
return `about ${v}${range} as ${type}`;
|
||||
}
|
||||
case 'bigger':
|
||||
return `a value bigger than ${v} as ${type}`;
|
||||
case 'smaller':
|
||||
return `a value smaller than ${v} as ${type}`;
|
||||
case 'between':
|
||||
return `a value between ${rangeWords(q)} as ${type}`;
|
||||
default:
|
||||
return `any value as ${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The check a "narrow down" applied, as in: kept the ones whose "value went up". */
|
||||
export function checkWords(q: NumberQuery): string {
|
||||
const v = q.value.trim();
|
||||
switch (q.compare.id) {
|
||||
case 'exact':
|
||||
return `value is now ${v}`;
|
||||
case 'rounded':
|
||||
return `value is now about ${v}`;
|
||||
case 'bigger':
|
||||
return `value is now bigger than ${v}`;
|
||||
case 'smaller':
|
||||
return `value is now smaller than ${v}`;
|
||||
case 'between':
|
||||
return `value is now between ${rangeWords(q)}`;
|
||||
case 'increased':
|
||||
return 'value went up';
|
||||
case 'decreased':
|
||||
return 'value went down';
|
||||
case 'increasedBy':
|
||||
return `value went up by exactly ${v}`;
|
||||
case 'decreasedBy':
|
||||
return `value went down by exactly ${v}`;
|
||||
case 'changed':
|
||||
return 'value changed';
|
||||
case 'unchanged':
|
||||
return 'value stayed the same';
|
||||
default:
|
||||
return q.compare.label.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = (ms: number) => (ms < 950 ? 'under a second' : ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`);
|
||||
|
||||
const NARROW_HOW = 'Change the value in the game (take damage, spend money), type the new number above and press "Narrow down". Repeat until only a few are left.';
|
||||
|
||||
/** A first search that finished. */
|
||||
export function firstFeedback(q: NumberQuery, where: Coverage, matches: number, tookMs: number, dropped: string[] = []): Feedback {
|
||||
const mem = coverageWords(where);
|
||||
if (q.compare.id === 'unknown') {
|
||||
return {
|
||||
tone: 'info',
|
||||
title: `Took note of every value in ${mem}, read as ${typeWords(q.kind)}: ${plural(matches, 'match', 'matches')}. Took ${seconds(tookMs)}.`,
|
||||
tips: [
|
||||
'Now make the value change in the game. Then choose "Went up", "Went down" or "Changed" above and press "Narrow down".',
|
||||
'If it has not changed, choose "Stayed the same" to rule out everything that did.',
|
||||
],
|
||||
};
|
||||
}
|
||||
const what = holdsWords(q);
|
||||
if (matches === 0) {
|
||||
const tips = ['Check that the number is exactly what the game shows right now. If it changed while the search ran, search for the new number.'];
|
||||
if (dropped.length) tips.push(`Not counted here: ${dropped.join(', ')}. To search one of those, pick that type.`);
|
||||
if (q.kind.id !== AUTO_KIND.id) {
|
||||
tips.push(
|
||||
q.kind.float
|
||||
? q.compare.id === 'exact'
|
||||
? 'A decimal value is rarely exactly what the screen shows. Set "Value is" to "About (shown rounded)", or set Type to Auto.'
|
||||
: 'The game may keep it as a whole number. Set Type to Auto to try 4 bytes, Float and Double in one go.'
|
||||
: 'The game may store it as another type. Set Type to Auto to try 4 bytes, Float and Double in one go.',
|
||||
);
|
||||
} else if (q.compare.id === 'exact') {
|
||||
tips.push('If the game rounds what it shows, set "Value is" to "About (shown rounded)".');
|
||||
}
|
||||
if (where.bytes < where.total) tips.push(`Only ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)} was searched. Open "Where to search" and choose "Everything".`);
|
||||
tips.push(
|
||||
'Some games store a different number than they show (100 health kept as 1.0, or as 1000). Set "Value is" to "Unknown" and narrow down with "Went up" and "Went down" instead.',
|
||||
);
|
||||
return { tone: 'warn', title: `Nothing found. No place in ${mem} holds ${what}. Took ${seconds(tookMs)}.`, tips };
|
||||
}
|
||||
const tips: string[] = [];
|
||||
if (matches === 1) tips.push('Only one place, so this is very likely it. Type a new value next to it, press "Set" and check the game.');
|
||||
else if (matches <= 10) tips.push('Few enough to try: change them one at a time and watch the game. To be sure first, change the value in the game and narrow down.');
|
||||
else tips.push(`Too many to try one by one. ${NARROW_HOW}`);
|
||||
if (dropped.length) tips.push(`Left out of this search: ${dropped.join(', ')}. To search one of those, start again and pick that type.`);
|
||||
return { tone: 'info', title: `Found ${plural(matches, 'place', 'places')} holding ${what}, in ${mem}. Took ${seconds(tookMs)}.`, tips };
|
||||
}
|
||||
|
||||
/** A "narrow down" that finished. */
|
||||
export function narrowFeedback(q: NumberQuery, before: number, matches: number, canUndo: boolean): Feedback {
|
||||
const check = checkWords(q);
|
||||
if (matches === 0) {
|
||||
const tips: string[] = [];
|
||||
if (canUndo) tips.push(`Press "Undo last step" to get the ${plural(before, 'match', 'matches')} back and try a different check.`);
|
||||
if (q.compare.inputs > 0) {
|
||||
tips.push('Check that the number is what the game shows right now.');
|
||||
tips.push('If the game shows the value differently than it stores it, use "Went up", "Went down" or "Changed" instead of a number.');
|
||||
} else {
|
||||
tips.push('Make sure the value really changed that way in the game since the last step. Some games only update it when a menu closes.');
|
||||
}
|
||||
tips.push(canUndo ? 'Or press "New search" and try another type, such as Auto.' : 'Press "New search" to start again; try another type, such as Auto.');
|
||||
return { tone: 'warn', title: `Nothing left. None of the ${plural(before, 'match', 'matches')} passed the check "${check}".`, tips };
|
||||
}
|
||||
const tips: string[] = [];
|
||||
if (matches === before) tips.push('Nothing was ruled out. Make the value change in the game first, then narrow down with the new number.');
|
||||
if (matches === 1) tips.push('One place left, so this is very likely it. Type a new value next to it, press "Set" and check the game.');
|
||||
else if (matches <= 10) tips.push('Few enough to try: change them one at a time and watch the game.');
|
||||
else tips.push(`Still too many to try one by one. ${NARROW_HOW}`);
|
||||
return { tone: 'info', title: `${n(matches)} of ${plural(before, 'match', 'matches')} left: the ones whose ${check}.`, tips };
|
||||
}
|
||||
|
||||
/** A search the person canceled. `kept` is the candidate list that is still there, if any. */
|
||||
export function canceledFeedback(first: boolean, progress: ScanProgress | null, where: Coverage | null, kept: number | null): Feedback {
|
||||
if (!first) {
|
||||
return kept === null
|
||||
? {
|
||||
tone: 'warn',
|
||||
title: 'Canceled. This search is gone.',
|
||||
tips: ['A search that started from "Unknown" keeps its list on the console, and stopping it halfway drops that list. Press "Search" to start again.'],
|
||||
}
|
||||
: { tone: 'warn', title: `Canceled. Nothing changed: your list still has ${plural(kept, 'match', 'matches')}.`, tips: [] };
|
||||
}
|
||||
const total = progress?.total ?? where?.bytes ?? 0;
|
||||
const done = progress?.done ?? 0;
|
||||
const pass = progress?.part && progress.part.count > 1 ? ` on round ${progress.part.index} of ${progress.part.count}` : '';
|
||||
const far = total > 0 ? ` after searching ${ofBytes(done, total)}${pass}` : '';
|
||||
const tips = ['Nothing was kept, because a list from part of the memory would miss the value if it is in the rest.'];
|
||||
if (progress && progress.matches > 0) tips.push(`${plural(progress.matches, 'match', 'matches')} had been found up to then.`);
|
||||
tips.push('To make a search quicker, open "Where to search" and uncheck the biggest blocks.');
|
||||
return { tone: 'warn', title: `Canceled${far}.`, tips };
|
||||
}
|
||||
|
||||
/** A search that stopped on an error. `gameGone` when the game is no longer the one that was searched. */
|
||||
export function failedFeedback(first: boolean, message: string, kept: number | null, gameGone: boolean): Feedback {
|
||||
const text = /[.!?]$/.test(message) ? message : `${message}.`;
|
||||
const tips: string[] = [];
|
||||
if (first || kept === null) tips.push('Nothing was kept.');
|
||||
else tips.push(`Your list is unchanged: it still has ${plural(kept, 'match', 'matches')}.`);
|
||||
if (gameGone) {
|
||||
tips.push(
|
||||
'The game is not running any more (or was restarted). A game puts its values in new places each time it starts, so start a new search once it is running again.',
|
||||
);
|
||||
} else {
|
||||
tips.push('Check that the game is still running and the console is on, then try again.');
|
||||
}
|
||||
return { tone: 'error', title: `${first ? 'The search' : 'Narrowing down'} stopped before it finished. ${text}`, tips };
|
||||
}
|
||||
|
||||
/** More matches than can be kept. */
|
||||
export function tooManyFeedback(q: NumberQuery, limit: number): Feedback {
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `More than ${n(limit)} places hold ${holdsWords(q)}. That is too many to keep, so nothing was kept.`,
|
||||
tips: [
|
||||
'Search for a less common number: 0 and 1 are everywhere.',
|
||||
'Or change the value in the game to something unusual and search for that.',
|
||||
'Or open "Where to search" and search less memory.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- time ------------------------------------------------------------------------
|
||||
|
||||
export interface ProgressSample {
|
||||
/** Milliseconds, any clock. */
|
||||
at: number;
|
||||
/** 0..1 */
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds a search still needs, from how fast it has really been going,
|
||||
* or null while that is not known well enough to say. Speed is taken over the
|
||||
* last 20 seconds so it follows a search that slows down or speeds up.
|
||||
*/
|
||||
export function timeLeft(samples: ProgressSample[], now: number): number | null {
|
||||
if (samples.length < 4) return null;
|
||||
const first = samples[0];
|
||||
const last = samples[samples.length - 1];
|
||||
if (last.at - first.at < 3000 || last.fraction >= 1) return null;
|
||||
let from = first;
|
||||
for (const s of samples) {
|
||||
if (last.at - s.at <= 20_000) {
|
||||
// A window shorter than two seconds is too jumpy; fall back to the whole run.
|
||||
if (last.at - s.at >= 2000) from = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const gained = last.fraction - from.fraction;
|
||||
if (gained <= 0) return null;
|
||||
const left = ((1 - last.fraction) * (last.at - from.at)) / gained - (now - last.at);
|
||||
return left > 0 ? left : 0;
|
||||
}
|
||||
|
||||
/** "about 40 s left", rounded so it does not flicker. */
|
||||
export function leftWords(ms: number): string {
|
||||
if (ms < 5000) return 'a few seconds left';
|
||||
if (ms < 60_000) return `about ${Math.round(ms / 5000) * 5} s left`;
|
||||
if (ms < 10 * 60_000) return `about ${Math.round(ms / 30_000) / 2} min left`;
|
||||
return `about ${Math.round(ms / 60_000)} min left`;
|
||||
}
|
||||
|
||||
/** Elapsed time as 0:36 or 12:05. */
|
||||
export function clock(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ---- text and group searches -------------------------------------------------------
|
||||
|
||||
export interface TextQuery {
|
||||
text: string;
|
||||
plain: boolean;
|
||||
wide: boolean;
|
||||
ignoreCase: boolean;
|
||||
/** Code and fixed data were searched too. */
|
||||
fixed: boolean;
|
||||
}
|
||||
|
||||
/** What a text search can report back; a subset of TextSearchResult. */
|
||||
export interface TextOutcome {
|
||||
hits: number;
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
bytesSearched: number;
|
||||
}
|
||||
|
||||
export function textFeedback(q: TextQuery, res: TextOutcome, where: Coverage, tookMs: number): Feedback {
|
||||
const as = q.plain && q.wide ? 'plain or wide text' : q.plain ? 'plain text' : 'wide text';
|
||||
const caps = q.ignoreCase ? ', in any mix of capitals' : ', with exactly those capitals';
|
||||
const what = `"${q.text}" as ${as}${caps}`;
|
||||
const tips: string[] = [];
|
||||
|
||||
if (res.canceled) {
|
||||
const far = `Canceled after searching ${ofBytes(res.bytesSearched, where.bytes)}.`;
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: res.hits
|
||||
? `${far} ${plural(res.hits, 'place', 'places')} found up to then ${res.hits === 1 ? 'is' : 'are'} listed below.`
|
||||
: `${far} Nothing had been found up to then.`,
|
||||
tips: ['The rest of the memory was not searched, so there may be more. Search again to cover all of it.'],
|
||||
};
|
||||
}
|
||||
if (res.hits === 0) {
|
||||
tips.push('Check the spelling, and try one distinctive word instead of a whole sentence: games often build a line from pieces.');
|
||||
if (!q.ignoreCase) tips.push('Check "Ignore capitals" to also find it written with other capitals.');
|
||||
if (!(q.plain && q.wide)) tips.push(`Only ${as} was searched. Check both "Plain text" and "Wide text".`);
|
||||
if (!q.fixed)
|
||||
tips.push(
|
||||
'Text that never changes (menu labels, built-in names) is usually in the game\'s fixed data. Check "Also look in the game\'s code and fixed data" and search again.',
|
||||
);
|
||||
if (where.bytes < where.total) tips.push(`Only ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)} was searched. Open "Where to search" and choose "Everything".`);
|
||||
tips.push('Text drawn as a picture, or in a language-specific encoding, cannot be found this way.');
|
||||
return { tone: 'warn', title: `Nothing found. No place in ${coverageWords(where)} holds ${what}. Took ${seconds(tookMs)}.`, tips };
|
||||
}
|
||||
if (res.capped) {
|
||||
tips.push(
|
||||
`The search stopped at the first ${n(res.hits)}, after ${ofBytes(res.bytesSearched, where.bytes)}, so there are probably more. Type a longer piece of the text, or search less memory, to get a shorter list.`,
|
||||
);
|
||||
}
|
||||
tips.push("Open a result's menu (the three dots) to see it in Memory or to find what points to it.");
|
||||
return {
|
||||
tone: res.capped ? 'warn' : 'info',
|
||||
title: res.capped
|
||||
? `Found at least ${plural(res.hits, 'place', 'places')} holding ${what}.`
|
||||
: `Found ${plural(res.hits, 'place', 'places')} holding ${what}, in ${coverageWords(where)}. Took ${seconds(tookMs)}.`,
|
||||
tips,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a group search can report back; a subset of GroupResult, with the values as typed. */
|
||||
export interface GroupOutcome {
|
||||
groups: number;
|
||||
/** The value that was searched for first, as typed, and in how many places it was found. */
|
||||
anchorText: string;
|
||||
anchorMatches: number;
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
bytesSearched: number;
|
||||
}
|
||||
|
||||
export function groupFeedback(values: string[], distance: number, res: GroupOutcome, where: Coverage, tookMs: number): Feedback {
|
||||
const list = values.map((v) => v.trim()).join(', ');
|
||||
if (res.canceled) {
|
||||
const during = res.bytesSearched < where.bytes ? `after searching ${ofBytes(res.bytesSearched, where.bytes)}` : 'while checking what is next to each place';
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `Canceled ${during}. ${res.groups ? `${plural(res.groups, 'group', 'groups')} found up to then ${res.groups === 1 ? 'is' : 'are'} listed below.` : 'No group had been found up to then.'}`,
|
||||
tips: ['The list is incomplete. Search again to cover everything.'],
|
||||
};
|
||||
}
|
||||
if (res.groups === 0) {
|
||||
if (res.anchorMatches === 0) {
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `Nothing found. ${res.anchorText} is not anywhere in ${coverageWords(where)} as that type, so no group can hold it. Took ${seconds(tookMs)}.`,
|
||||
tips: [
|
||||
`Check that ${res.anchorText} is what the game shows right now, and that its type is right: a number with a bar or decimals is often a Float.`,
|
||||
'If you are not sure of one of the values, remove its line and search with the others.',
|
||||
...(where.bytes < where.total
|
||||
? [`Only ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)} was searched. Open "Where to search" and choose "Everything".`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: `No group found. ${res.anchorText} is in ${plural(res.anchorMatches, 'place', 'places')}, but none of them has the other values (${list}) within ${n(distance)} bytes. Took ${seconds(tookMs)}.`,
|
||||
tips: [
|
||||
'They may be further apart: try a bigger distance, such as 256 or 1024.',
|
||||
'One of them may be stored as a different type (try Float), or may not be next to the others at all: remove one line and search again.',
|
||||
'Check that every number is what the game shows right now.',
|
||||
],
|
||||
};
|
||||
}
|
||||
const tips: string[] = [];
|
||||
if (res.capped) tips.push(`Only the first ${n(res.groups)} groups were kept. Add another value or make the distance smaller to cut the list down.`);
|
||||
if (res.groups === 1) tips.push('One place, so this is very likely it. Choose a value under "Keep narrowing with" and continue in Number search to change it.');
|
||||
else tips.push('To find the right one, choose a value under "Keep narrowing with", continue in Number search, change that value in the game and narrow down.');
|
||||
return {
|
||||
tone: res.capped ? 'warn' : 'info',
|
||||
title: `Found ${plural(res.groups, 'group', 'groups')} holding ${list} within ${n(distance)} bytes, in ${coverageWords(where)}. Took ${seconds(tookMs)}.`,
|
||||
tips,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+99
-28
@@ -19,7 +19,7 @@ import { readMulti } from './ps5debug-extra.ts';
|
||||
import { view } from './bytes.ts';
|
||||
import { mergeRanges } from './regions.ts';
|
||||
import type { MemRange } from './regions.ts';
|
||||
import { encodeValue } from './scanner.ts';
|
||||
import { ScanCanceledError, cancelable, encodeValue, liveDbg } from './scanner.ts';
|
||||
import type { ScanRegion, ValueKind } from './scanner.ts';
|
||||
|
||||
export interface GroupMember {
|
||||
@@ -46,13 +46,23 @@ export interface GroupResult {
|
||||
/** True when the list was cut at `maxGroups`. */
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
/** Bytes of memory searched for the first value, and how many there were to search. */
|
||||
bytesSearched: number;
|
||||
bytesTotal: number;
|
||||
}
|
||||
|
||||
export interface GroupProgress {
|
||||
/** 0..1 */
|
||||
/** 0..1 within the current step. */
|
||||
fraction: number;
|
||||
/** 1: searching memory for the rarest value. 2: checking what is next to each place found. */
|
||||
step: 1 | 2;
|
||||
detail: string;
|
||||
groups: number;
|
||||
/** What `fraction` is made of: bytes of memory in step 1, places to check in step 2. */
|
||||
done: number;
|
||||
total: number;
|
||||
/** Places holding the first value so far (step 1) or in all (step 2). */
|
||||
anchors: number;
|
||||
}
|
||||
|
||||
export interface GroupSearchOptions {
|
||||
@@ -134,16 +144,31 @@ const RUN_BYTES = 256 * 1024;
|
||||
/** Hits checked per batch of reads. */
|
||||
const BATCH = 2000;
|
||||
|
||||
export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult> {
|
||||
const { members } = opts;
|
||||
/**
|
||||
* Checks what was typed before anything is sent, and returns each value as
|
||||
* bytes. Throws a plain message naming the line that is wrong.
|
||||
*/
|
||||
export function checkGroup(members: GroupMember[], distance: number): Uint8Array[] {
|
||||
if (members.length < 2) throw new Error('A group needs at least two values');
|
||||
if (members.length > GROUP_MAX_MEMBERS) throw new Error(`A group can have at most ${GROUP_MAX_MEMBERS} values`);
|
||||
const want = members.map((m) => encodeValue(m.kind, m.text));
|
||||
const want = members.map((m, i) => {
|
||||
if (m.text.trim() === '') throw new Error(`Value ${i + 1} is empty. Type a number there, or remove that line.`);
|
||||
try {
|
||||
return encodeValue(m.kind, m.text);
|
||||
} catch (e) {
|
||||
throw new Error(`Value ${i + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
const needed = members.reduce((n, m) => n + m.kind.size, 0);
|
||||
if (!Number.isInteger(opts.distance) || opts.distance < needed) {
|
||||
throw new Error(`These values take up ${needed} bytes side by side, so the distance has to be at least ${needed}`);
|
||||
}
|
||||
if (opts.distance > GROUP_MAX_DISTANCE) throw new Error(`The distance can be at most ${GROUP_MAX_DISTANCE} bytes`);
|
||||
if (!Number.isInteger(distance) || distance <= 0) throw new Error('The distance is a whole number of bytes, such as 64');
|
||||
if (distance < needed) throw new Error(`These values take up ${needed} bytes side by side, so the distance has to be at least ${needed}`);
|
||||
if (distance > GROUP_MAX_DISTANCE) throw new Error(`The distance can be at most ${GROUP_MAX_DISTANCE.toLocaleString('en-US')} bytes`);
|
||||
return want;
|
||||
}
|
||||
|
||||
export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult> {
|
||||
const { members } = opts;
|
||||
const want = checkGroup(members, opts.distance);
|
||||
const maxAnchors = opts.maxAnchors ?? 100_000;
|
||||
const maxGroups = opts.maxGroups ?? 2000;
|
||||
const distance = BigInt(opts.distance);
|
||||
@@ -157,30 +182,64 @@ export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult
|
||||
let scanned = 0;
|
||||
const anchors: bigint[] = [];
|
||||
let canceled = false;
|
||||
const step1 = () =>
|
||||
opts.onProgress?.({
|
||||
step: 1,
|
||||
fraction: total ? scanned / total : 1,
|
||||
detail: `Looking for ${members[anchor].text}`,
|
||||
groups: 0,
|
||||
done: scanned,
|
||||
total,
|
||||
anchors: anchors.length,
|
||||
});
|
||||
step1();
|
||||
for (const region of opts.regions) {
|
||||
if (opts.signal?.aborted) {
|
||||
canceled = true;
|
||||
break;
|
||||
}
|
||||
const dbg = opts.dbg();
|
||||
const dbg = await liveDbg(opts.dbg);
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
let tooMany = false;
|
||||
await dbg.scanStart(
|
||||
{ pid: opts.pid, address: region.start, length: region.length, valueType: anchorKind.wire, compareType: COMPARE.EXACT, alignment: anchorKind.size, value: want[anchor] },
|
||||
(block) => {
|
||||
// Past the limit the rest is still read off the wire, so the stream stays in step.
|
||||
if (tooMany || anchors.length + block.count > maxAnchors) {
|
||||
tooMany = true;
|
||||
return;
|
||||
}
|
||||
const dv = view(block.records);
|
||||
const record = 4 + anchorKind.size;
|
||||
for (let i = 0; i < block.count; i++) anchors.push(region.start + BigInt(dv.getUint32(i * record, true)));
|
||||
},
|
||||
);
|
||||
const got: bigint[] = [];
|
||||
try {
|
||||
// A cancel gets through even when the console is slow to answer.
|
||||
await cancelable(
|
||||
dbg,
|
||||
opts.signal,
|
||||
() => null,
|
||||
() =>
|
||||
dbg.scanStart(
|
||||
{
|
||||
pid: opts.pid,
|
||||
address: region.start,
|
||||
length: region.length,
|
||||
valueType: anchorKind.wire,
|
||||
compareType: COMPARE.EXACT,
|
||||
alignment: anchorKind.size,
|
||||
value: want[anchor],
|
||||
},
|
||||
(block) => {
|
||||
// Past the limit the rest is still read off the wire, so the stream stays in step.
|
||||
if (tooMany || anchors.length + got.length + block.count > maxAnchors) {
|
||||
tooMany = true;
|
||||
return;
|
||||
}
|
||||
const dv = view(block.records);
|
||||
const record = 4 + anchorKind.size;
|
||||
for (let i = 0; i < block.count; i++) got.push(region.start + BigInt(dv.getUint32(i * record, true)));
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ScanCanceledError)) throw e;
|
||||
canceled = true;
|
||||
break;
|
||||
}
|
||||
for (const a of got) anchors.push(a);
|
||||
if (tooMany) throw new GroupTooBroadError(members[anchor], maxAnchors);
|
||||
scanned += region.length;
|
||||
opts.onProgress?.({ fraction: total ? (scanned / total) * 0.8 : 0.8, detail: `Looking for ${members[anchor].text}`, groups: 0 });
|
||||
step1();
|
||||
}
|
||||
|
||||
// Step 2: read around each hit and look for the rest.
|
||||
@@ -222,7 +281,7 @@ export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult
|
||||
}
|
||||
}
|
||||
const data = await readMulti(
|
||||
opts.dbg(),
|
||||
await liveDbg(opts.dbg),
|
||||
opts.pid,
|
||||
runs.map((r) => ({ address: r.start, length: Number(r.end - r.start) })),
|
||||
);
|
||||
@@ -230,18 +289,30 @@ export async function groupSearch(opts: GroupSearchOptions): Promise<GroupResult
|
||||
for (let i = run.first; i < run.first + run.count && !capped; i++) {
|
||||
const group = bestGroup(members, want, anchor, batch[i], windows[i], data[r], run.start, opts.distance);
|
||||
if (!group) continue;
|
||||
const key = group.members.map((m) => m.address.toString(16)).sort().join(',');
|
||||
const key = group.members
|
||||
.map((m) => m.address.toString(16))
|
||||
.sort()
|
||||
.join(',');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (hits.length >= maxGroups) capped = true;
|
||||
else hits.push(group);
|
||||
}
|
||||
});
|
||||
opts.onProgress?.({ fraction: 0.8 + 0.2 * Math.min(1, (done + batch.length) / anchors.length), detail: 'Checking what is next to each one', groups: hits.length });
|
||||
const checked = Math.min(anchors.length, done + batch.length);
|
||||
opts.onProgress?.({
|
||||
step: 2,
|
||||
fraction: checked / anchors.length,
|
||||
detail: 'Checking what is next to each one',
|
||||
groups: hits.length,
|
||||
done: checked,
|
||||
total: anchors.length,
|
||||
anchors: anchors.length,
|
||||
});
|
||||
}
|
||||
|
||||
hits.sort((a, b) => (a.base < b.base ? -1 : a.base > b.base ? 1 : 0));
|
||||
return { hits, anchor, anchorMatches: anchors.length, capped, canceled };
|
||||
return { hits, anchor, anchorMatches: anchors.length, capped, canceled, bytesSearched: scanned, bytesTotal: total };
|
||||
}
|
||||
|
||||
/** How many arrangements are tried around one hit before settling for the best so far. */
|
||||
|
||||
+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 };
|
||||
}
|
||||
+71
-15
@@ -447,9 +447,11 @@ function checkGoal(doc: unknown, where: string, game: GameDef, nested = false):
|
||||
}
|
||||
}
|
||||
|
||||
const NEWER = 'This was written for a newer version of CheatSpecter than the one you have. Update CheatSpecter to read it.';
|
||||
|
||||
function checkVersion(v: unknown, where: string): number {
|
||||
const version = num(v, `${where}.version`, 1, 1000, true);
|
||||
if (version > FORMAT_VERSION) throw new LearnError('newer', 'These lessons were written for a newer version of CheatSpecter. Update CheatSpecter to read them.');
|
||||
if (version > FORMAT_VERSION) throw new LearnError('newer', NEWER);
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -631,22 +633,57 @@ const MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
async function download(fetcher: Fetch, url: string): Promise<unknown> {
|
||||
/** How long a download may take before it is given up on, so "Downloading…" always ends. */
|
||||
const DOWNLOAD_PATIENCE_MS = 20000;
|
||||
|
||||
/**
|
||||
* Downloads one file and returns what it holds. `one`: a single lesson is
|
||||
* being fetched, after the list of lessons arrived fine.
|
||||
*/
|
||||
async function download(fetcher: Fetch, url: string, one = false, patienceMs = DOWNLOAD_PATIENCE_MS): Promise<unknown> {
|
||||
// A connection that stalls without failing would otherwise leave the page waiting for good.
|
||||
const stop = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const late = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new LearnError('offline', 'GitHub is taking too long to answer. Check the connection, then try again.'));
|
||||
stop?.abort();
|
||||
}, patienceMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fetchLesson(fetcher, url, one, stop ? stop.signal : undefined), late]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLesson(fetcher: Fetch, url: string, one: boolean, signal: AbortSignal | undefined): Promise<unknown> {
|
||||
let res: Response;
|
||||
try {
|
||||
// No cookies or referrer go to GitHub, and nothing of the answer is run.
|
||||
res = await fetcher(url, { cache: 'no-cache', credentials: 'omit', referrerPolicy: 'no-referrer' });
|
||||
res = await fetcher(url, { cache: 'no-cache', credentials: 'omit', referrerPolicy: 'no-referrer', ...(signal ? { signal } : {}) });
|
||||
} catch {
|
||||
throw new LearnError('offline', 'The lessons could not be downloaded. Check that this device is connected to the internet, then try again.');
|
||||
throw new LearnError('offline', `${one ? 'This lesson did not download.' : 'Nothing came back from GitHub.'} Check that this device is connected to the internet, then try again.`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new LearnError(
|
||||
'unpublished',
|
||||
one ? 'This lesson is in the list, but its file is not on GitHub yet. Try again later; the other lessons are not affected.' : 'The lessons are not on CheatSpecter’s GitHub page yet. They will appear here as soon as they are.',
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new LearnError('offline', `GitHub answered with an error (${res.status}) instead of the ${one ? 'lesson' : 'lessons'}. Try again in a little while.`);
|
||||
let text: string;
|
||||
try {
|
||||
text = await res.text();
|
||||
} catch {
|
||||
// The connection dropped partway through.
|
||||
throw new LearnError('offline', 'The download stopped partway. Check the connection, then try again.');
|
||||
}
|
||||
if (res.status === 404) throw new LearnError('unpublished', 'The lessons are not published yet. They will appear here once they are on CheatSpecter’s GitHub page.');
|
||||
if (!res.ok) throw new LearnError('offline', `The lessons could not be downloaded (GitHub answered ${res.status}). Try again in a little while.`);
|
||||
const text = await res.text();
|
||||
if (text.length > MAX_FILE_BYTES) throw new LearnError('invalid', 'The download is too large to be a lesson, so it was not opened.');
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new LearnError('invalid', 'The download is not a lesson file, so it was not opened.');
|
||||
throw new LearnError('invalid', 'What came back is not a lesson file, so it was not opened. A network that asks you to sign in first (a hotel or guest Wi-Fi) can cause this.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,14 +709,16 @@ export interface LoadedIndex {
|
||||
export class LessonLibrary {
|
||||
private fetcher: Fetch;
|
||||
private preferLocal: boolean;
|
||||
private patienceMs: number;
|
||||
private base: string | null = null;
|
||||
private index: Promise<LoadedIndex> | null = null;
|
||||
private lessons = new Map<string, Promise<Lesson>>();
|
||||
|
||||
/** `preferLocal`: look for a learn/ folder served next to the app before going to GitHub. */
|
||||
constructor(opts: { preferLocal?: boolean; fetch?: Fetch } = {}) {
|
||||
constructor(opts: { preferLocal?: boolean; fetch?: Fetch; patienceMs?: number } = {}) {
|
||||
this.fetcher = opts.fetch ?? ((url, init) => fetch(url, init));
|
||||
this.preferLocal = opts.preferLocal ?? false;
|
||||
this.patienceMs = opts.patienceMs ?? DOWNLOAD_PATIENCE_MS;
|
||||
}
|
||||
|
||||
loadIndex(): Promise<LoadedIndex> {
|
||||
@@ -687,14 +726,14 @@ export class LessonLibrary {
|
||||
this.index = (async (): Promise<LoadedIndex> => {
|
||||
if (this.preferLocal) {
|
||||
try {
|
||||
const index = checkIndex(await download(this.fetcher, LOCAL_URL + 'index.json'));
|
||||
const index = checkIndex(await download(this.fetcher, LOCAL_URL + 'index.json', false, this.patienceMs));
|
||||
this.base = LOCAL_URL;
|
||||
return { index, from: 'local' };
|
||||
} catch {
|
||||
// No local copy (or a broken one): the published lessons it is.
|
||||
}
|
||||
}
|
||||
const index = checked(checkIndex, await download(this.fetcher, LEARN_URL + 'index.json'));
|
||||
const index = checked(checkIndex, await download(this.fetcher, LEARN_URL + 'index.json', false, this.patienceMs));
|
||||
this.base = LEARN_URL;
|
||||
return { index, from: 'github' };
|
||||
})();
|
||||
@@ -710,7 +749,7 @@ export class LessonLibrary {
|
||||
lesson = (async () => {
|
||||
if (!ID.test(id)) throw new LearnError('invalid', 'That is not a lesson.');
|
||||
await this.loadIndex();
|
||||
const doc = checked(checkLesson, await download(this.fetcher, `${this.base}lessons/${id}.json`));
|
||||
const doc = checked(checkLesson, await download(this.fetcher, `${this.base}lessons/${id}.json`, true, this.patienceMs));
|
||||
if (doc.id !== id) throw new LearnError('invalid', 'The download is a different lesson from the one asked for, so it was not opened.');
|
||||
return doc;
|
||||
})();
|
||||
@@ -786,16 +825,33 @@ export class ProgressStore {
|
||||
/** null: not tried yet. False: the server has no such thing, so it is not asked again. */
|
||||
private remote: boolean | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private patienceMs: number;
|
||||
|
||||
constructor(opts: { fetch?: Fetch } = {}) {
|
||||
constructor(opts: { fetch?: Fetch; patienceMs?: number } = {}) {
|
||||
this.fetcher = opts.fetch ?? ((url, init) => fetch(url, init));
|
||||
this.patienceMs = opts.patienceMs ?? 4000;
|
||||
}
|
||||
|
||||
async load(): Promise<Progress> {
|
||||
/** `server` false: whatever serves the page is known not to keep settings (the bridge on a computer), so it is not asked. */
|
||||
async load(server = true): Promise<Progress> {
|
||||
const local = readProgress(loadJson<unknown>(STORAGE_KEY, null));
|
||||
if (!server) this.remote = false;
|
||||
if (this.remote === false) return local;
|
||||
try {
|
||||
const res = await this.fetcher(PREFS_URL, { cache: 'no-store' });
|
||||
// Whatever serves the page gets a few seconds. If it does not answer, the Learn section opens on the
|
||||
// browser's copy, and leaves the server's alone for this visit so that nothing there is overwritten unseen.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const res = await Promise.race([
|
||||
this.fetcher(PREFS_URL, { cache: 'no-store' }),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), this.patienceMs);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (!res) {
|
||||
this.remote = false;
|
||||
return local;
|
||||
}
|
||||
if (res.ok) {
|
||||
this.remote = true;
|
||||
return mergeProgress(local, readProgress(await res.json()));
|
||||
|
||||
+39
-4
@@ -357,17 +357,52 @@ export async function parseAddress(ctx: GameContext, text: string): Promise<bigi
|
||||
const t = text.trim().replace(/\s+/g, '');
|
||||
if (!t) throw new Error('Enter an address');
|
||||
const plus = t.lastIndexOf('+');
|
||||
if (plus <= 0) return hex(t);
|
||||
if (plus <= 0) return inRange(explained(t));
|
||||
const name = t.slice(0, plus);
|
||||
const offset = hex(t.slice(plus + 1));
|
||||
if (isMainName(ctx, name)) return (await imageBase(ctx)) + offset;
|
||||
if (plus === t.length - 1) throw new Error(`Type the offset after "${name}+", for example ${name}+1A2B3C.`);
|
||||
const offset = explained(t.slice(plus + 1));
|
||||
if (isMainName(ctx, name)) return inRange((await imageBase(ctx)) + offset);
|
||||
let range = await moduleRange(ctx, name);
|
||||
if (!range) {
|
||||
await ctx.maps(true);
|
||||
range = await moduleRange(ctx, name);
|
||||
}
|
||||
if (!range) throw new Error(`No module called "${name}" is loaded. Use "game+..." for the game itself, or a plain hex address.`);
|
||||
return range.start + offset;
|
||||
return inRange(range.start + offset);
|
||||
}
|
||||
|
||||
/** `hex`, with a hint of what an address looks like for someone who typed something else. */
|
||||
function explained(text: string): bigint {
|
||||
try {
|
||||
return hex(text);
|
||||
} catch (e) {
|
||||
throw new Error(`${e instanceof Error ? e.message : String(e)}. Addresses look like 880012340, or game+1A2B3C.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Addresses are 64 bits; anything longer was a slip of the keyboard, and would wrap around on the way to the console. */
|
||||
function inRange(address: bigint): bigint {
|
||||
if (address > 0xffff_ffff_ffff_ffffn) throw new Error('That address is too large. Addresses have at most 16 hex digits.');
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of `length` bytes at `address` the game has mapped as readable.
|
||||
* The console answers a read of unmapped memory with zeros instead of an
|
||||
* error, so screens that show raw memory ask this to tell real zeros from
|
||||
* "nothing is here".
|
||||
*/
|
||||
export async function readableAt(ctx: GameContext, address: bigint, length: number, refresh = false): Promise<'all' | 'part' | 'none'> {
|
||||
const maps = await ctx.maps(refresh);
|
||||
const end = address + BigInt(length);
|
||||
let covered = 0n;
|
||||
for (const m of maps) {
|
||||
if (!(m.prot & PROT.READ)) continue;
|
||||
const lo = m.start > address ? m.start : address;
|
||||
const hi = m.end < end ? m.end : end;
|
||||
if (hi > lo) covered += hi - lo;
|
||||
}
|
||||
return covered >= BigInt(length) ? 'all' : covered > 0n ? 'part' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-1
@@ -19,6 +19,14 @@ export interface MemSnapshot {
|
||||
takenAt: number;
|
||||
}
|
||||
|
||||
/** Thrown when a snapshot is canceled partway. Nothing of it is kept. */
|
||||
export class SnapshotCanceledError extends Error {
|
||||
constructor() {
|
||||
super('Canceled');
|
||||
this.name = 'SnapshotCanceledError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeSnapshot(
|
||||
dbg: () => Ps5Debug,
|
||||
pid: number,
|
||||
@@ -30,8 +38,9 @@ export async function takeSnapshot(
|
||||
if (!Number.isInteger(length) || length <= 0) throw new Error('Choose how much memory to compare');
|
||||
if (length > SNAPSHOT_MAX_BYTES) throw new Error(`A snapshot can cover at most ${SNAPSHOT_MAX_BYTES / (1024 * 1024)} MB. Choose a smaller area.`);
|
||||
const bytes = new Uint8Array(length);
|
||||
onProgress?.(0);
|
||||
for (let off = 0; off < length; off += READ_BYTES) {
|
||||
if (signal?.aborted) throw new Error('Canceled');
|
||||
if (signal?.aborted) throw new SnapshotCanceledError();
|
||||
const take = Math.min(READ_BYTES, length - off);
|
||||
bytes.set(await dbg().read(pid, start + BigInt(off), take), off);
|
||||
onProgress?.((off + take) / length);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ export interface PointerScanOptions {
|
||||
* minute on a big game, so this is where the time goes.
|
||||
*/
|
||||
clusterSpan?: number;
|
||||
/**
|
||||
* Lets the search be given up. A pass over one stretch of memory cannot be
|
||||
* interrupted on the console, so it ends after the stretch in hand, and
|
||||
* the paths found up to then are returned.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
@@ -91,6 +97,7 @@ export async function findPointerPaths(
|
||||
const found = new Map<Node, Candidate[]>(nodes.map((n) => [n, []]));
|
||||
let pass = 0;
|
||||
for (const cluster of clusters) {
|
||||
if (opts.signal?.aborted) break;
|
||||
onProgress(describe(++pass, clusters.length));
|
||||
const hi = cluster[cluster.length - 1].addr;
|
||||
const lo = cluster[0].addr > maxOffset ? cluster[0].addr - maxOffset : 0n;
|
||||
@@ -99,6 +106,7 @@ export async function findPointerPaths(
|
||||
view(value).setBigUint64(8, hi, true);
|
||||
let kept = 0;
|
||||
for (const region of regions) {
|
||||
if (opts.signal?.aborted) break;
|
||||
await dbg.scanStart(
|
||||
{ pid: ctx.pid, address: region.start, length: region.length, valueType: VALUE_TYPE.U64, compareType: COMPARE.BETWEEN, alignment: 8, value },
|
||||
(block) => {
|
||||
@@ -126,10 +134,12 @@ export async function findPointerPaths(
|
||||
// Breadth first, so the shortest paths come out first.
|
||||
let frontier: Node[] = [{ addr: target, tail: [] }];
|
||||
for (let depth = 1; depth <= maxDepth && frontier.length && results.length < maxResults; depth++) {
|
||||
if (opts.signal?.aborted) break;
|
||||
const next: Node[] = [];
|
||||
const found = await pointersTo(frontier, (pass, passes) =>
|
||||
passes === 1 ? `Level ${depth} of ${maxDepth}: searching memory` : `Level ${depth} of ${maxDepth}: search ${pass} of ${passes}`,
|
||||
);
|
||||
// A pass that was given up saw only part of memory; what it did find is still true, but nothing deeper is worth starting.
|
||||
for (const node of frontier) {
|
||||
let followed = 0;
|
||||
for (const c of found.get(node) ?? []) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// lesson, which is what proves each goal can be reached.
|
||||
|
||||
import { fieldSize, goalParts } from '../learn.ts';
|
||||
import type { FieldType, Goal, LeafGoal } from '../learn.ts';
|
||||
import type { FieldType, GameDef, Goal, LeafGoal } from '../learn.ts';
|
||||
import type { GameContext, Location } from '../locations.ts';
|
||||
import { resolveLocation } from '../locations.ts';
|
||||
import { nopEntry, valueEntry } from '../make.ts';
|
||||
@@ -153,6 +153,41 @@ export class GoalTracker {
|
||||
return this.index >= this.items.length;
|
||||
}
|
||||
|
||||
/** How far along the current part is, when it is one that counts something: "2 of 3". */
|
||||
get progress(): string | null {
|
||||
const g = this.current?.goal;
|
||||
if (!g) return null;
|
||||
if (g.check === 'held') return this.held.count > 0 ? `${Math.min(this.held.count, g.times)} of ${g.times}` : null;
|
||||
if (g.check === 'pressed') {
|
||||
const have = (this.bench.con.presses.get(g.action) ?? 0) - this.since.presses;
|
||||
return g.times > 1 && have > 0 ? `${Math.min(have, g.times)} of ${g.times}` : null;
|
||||
}
|
||||
if (g.check === 'steady') {
|
||||
const every = this.bench.con.game.def.actions.find((a) => a.id === this.heldAction(g))?.every ?? 1000;
|
||||
const seconds = Math.floor((this.held.count * every) / 1000);
|
||||
return seconds > 0 ? `${Math.min(seconds, g.seconds)} of ${g.seconds} seconds` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the learner cannot see for themselves and needs to know to get
|
||||
* on: their search has dropped the very value the goal is about. Null when
|
||||
* there is nothing of the kind to say.
|
||||
*/
|
||||
advice(): string | null {
|
||||
const g = this.current?.goal;
|
||||
const { con } = this.bench;
|
||||
if (!g || con.game.crashed || this.bench.ctx.pid !== con.game.pid) return null;
|
||||
// Only for goals that are reached through the learner's own list. Where a
|
||||
// lesson has them search for one value to get at another, the list is not expected to hold it.
|
||||
if (g.check !== 'narrowed' && g.check !== 'held' && g.check !== 'steady' && g.check !== 'caught') return null;
|
||||
const left = con.lastScan.size;
|
||||
if (left === 0 || con.lastScan.has(con.game.address(g.field))) return null;
|
||||
const what = labelOf(this.bench, g.field);
|
||||
return `Your last search left ${left.toLocaleString('en-US')} ${left === 1 ? 'match' : 'matches'}, and the ${what} is not among them. Something in that search did not fit what the game was showing: the number, the type, or the answer about how it changed. Press New search and start over.`;
|
||||
}
|
||||
|
||||
/** Stops watching the game. */
|
||||
stop(): void {
|
||||
this.unsubscribe();
|
||||
@@ -255,6 +290,25 @@ async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
|
||||
return cond();
|
||||
}
|
||||
|
||||
/** How many pointers the game itself follows from its fixed data to reach an object, or 0 when it has no such chain. */
|
||||
function pointerHops(def: GameDef, object: string): number {
|
||||
let at = def.objects.filter((o) => o.static).map((o) => o.id);
|
||||
const seen = new Set(at);
|
||||
for (let hops = 1; at.length && hops <= 8; hops++) {
|
||||
const next: string[] = [];
|
||||
for (const id of at) {
|
||||
for (const f of def.objects.find((o) => o.id === id)?.fields ?? []) {
|
||||
if (f.type !== 'pointer' || !f.to || seen.has(f.to)) continue;
|
||||
if (f.to === object) return hops;
|
||||
seen.add(f.to);
|
||||
next.push(f.to);
|
||||
}
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Asks the debugger which instruction writes a value, by watching it while the game changes it. */
|
||||
async function findWriter(bench: CoachBench, field: string): Promise<Insn> {
|
||||
const { con, ctx } = bench;
|
||||
@@ -389,6 +443,23 @@ export async function showMe(goal: LeafGoal, bench: CoachBench, opts: { settleMs
|
||||
}
|
||||
}
|
||||
if (reached()) return;
|
||||
// A cheat already made for this value: use that, as the learner would, changing the number it holds if it has to.
|
||||
const fits = (held: number) => (goal.is === 'atLeast' ? held >= goal.value : goal.is === 'atMost' ? held <= goal.value : held === goal.value);
|
||||
for (const c of bench.cheats()) {
|
||||
if (c.entry.type !== 'value' || !c.entry.freeze) continue;
|
||||
if ((await resolveLocation(bench.ctx, c.entry.at).catch(() => null)) !== game.address(goal.field)) continue;
|
||||
let entry: ValueEntry = c.entry;
|
||||
const changed = !fits(Number(entry.value));
|
||||
if (changed) {
|
||||
entry = { ...entry, value: String(goal.value) };
|
||||
await bench.replace(entry);
|
||||
}
|
||||
if (changed || !c.on) await bench.toggle(entry, true);
|
||||
await waitFor(reached, 1500);
|
||||
if (!reached()) break;
|
||||
const how = entry.at.kind === 'pointer' ? 'It followed its pointer path to' : 'It writes to';
|
||||
return bench.say(`${changed ? `Set "${entry.name}" in Your cheats to hold ${entry.value}, and switched it on` : `Switched "${entry.name}" on in Your cheats`}. ${how} the ${labelOf(bench, goal.field)}, wherever it is in this run of the game.`);
|
||||
}
|
||||
await bench.write(game.address(goal.field), encodeValue(valueKind(KIND[game.type(goal.field)]), String(goal.value)));
|
||||
return bench.say(`Found the ${labelOf(bench, goal.field)} and wrote ${goal.value} over it.`);
|
||||
}
|
||||
@@ -432,8 +503,20 @@ export async function showMe(goal: LeafGoal, bench: CoachBench, opts: { settleMs
|
||||
if (goal.location === 'pointer') {
|
||||
const paths = await findPointerPaths(bench.ctx, address, { maxDepth: 3 }, () => undefined);
|
||||
if (paths.length === 0) throw new Error('No pointer path leads to the value.');
|
||||
at = paths[0].location;
|
||||
bench.say(`Searched for a pointer path to the ${labelOf(bench, field)} and took the shortest of ${paths.length}.`);
|
||||
// The shortest is not always real: two things that happen to sit near each other today
|
||||
// give a path that breaks on the next start. The game's own chain has one pointer per
|
||||
// object on the way and ends with the value's place inside its object.
|
||||
const hops = pointerHops(game.def, field.split('.')[0]);
|
||||
const last = '0x' + game.fieldOffset(field).toString(16).toUpperCase();
|
||||
const real = paths.find((p) => p.depth === hops && p.location.offsets[p.location.offsets.length - 1]?.toUpperCase() === last.toUpperCase());
|
||||
at = (real ?? paths[0]).location;
|
||||
bench.say(
|
||||
paths.length === 1
|
||||
? `Searched for a pointer path to the ${labelOf(bench, field)} and found one.`
|
||||
: real && real !== paths[0]
|
||||
? `Searched for a pointer path to the ${labelOf(bench, field)}: ${paths.length} candidates. Took the one with ${hops} pointers, because the shorter one only works by coincidence and would break on a restart.`
|
||||
: `Searched for a pointer path to the ${labelOf(bench, field)} and took the shortest of ${paths.length}.`,
|
||||
);
|
||||
}
|
||||
const have = await cheatFor(bench, goal, false);
|
||||
const base: ValueEntry = have ? (have.entry as ValueEntry) : await valueEntry(bench.ctx, address, valueKind(KIND[game.type(field)]), shown(game.get(field)), at);
|
||||
|
||||
@@ -84,6 +84,18 @@ function hash(text: string): number {
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The title ID a lesson's practice game runs under: the same for a lesson and
|
||||
* game every time, so "Your cheats" for it has somewhere to live while the
|
||||
* lesson is open, and so the game is laid out the same way for every learner
|
||||
* (the layout is drawn from the title; lesson text can rely on it).
|
||||
*/
|
||||
export function practiceTitle(lesson: string, game: string): string {
|
||||
let h = 0;
|
||||
for (const ch of `${lesson}/${game}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
|
||||
return 'CSPR' + String(h % 100000).padStart(5, '0');
|
||||
}
|
||||
|
||||
function readNumber(dv: DataView, at: number, type: FieldType): number {
|
||||
switch (type) {
|
||||
case 'u8':
|
||||
@@ -334,6 +346,11 @@ export class PracticeGame {
|
||||
return this.placed(field).def.type;
|
||||
}
|
||||
|
||||
/** How far into its object a value sits, in bytes. */
|
||||
fieldOffset(field: string): number {
|
||||
return this.placed(field).def.offset!;
|
||||
}
|
||||
|
||||
get(field: string): number {
|
||||
const p = this.placed(field);
|
||||
return readNumber(new DataView(p.region.bytes.buffer), p.at, p.def.type);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The practice engine in one piece, so the lesson screen can load it (and only
|
||||
// it) the first time a practice step comes up.
|
||||
|
||||
export { PracticeGame } from './game.ts';
|
||||
export { PracticeGame, practiceTitle } from './game.ts';
|
||||
export { PracticeConsole, connectPractice } from './console.ts';
|
||||
export type { PracticeEvent } from './console.ts';
|
||||
export { GoalTracker, patience, showMe } from './coach.ts';
|
||||
|
||||
+4
-3
@@ -8,6 +8,7 @@
|
||||
import { PROT } from './ps5debug.ts';
|
||||
import type { MapEntry } from './ps5debug.ts';
|
||||
import { loadJson, saveJson } from './storage.ts';
|
||||
import { SCAN_SLICE_BYTES } from './scanner.ts';
|
||||
import type { ScanRegion } from './scanner.ts';
|
||||
|
||||
/** `end` is exclusive. */
|
||||
@@ -201,7 +202,7 @@ export function hugeRegionIds(items: RegionItem[], minBytes: number): string[] {
|
||||
|
||||
function parseHex(text: string, what: string): bigint {
|
||||
const t = text.trim().replace(/^0x/i, '');
|
||||
if (!/^[0-9a-fA-F]+$/.test(t)) throw new Error(t === '' ? `Enter the ${what} address` : `"${text}" is not a hex address`);
|
||||
if (!/^[0-9a-fA-F]+$/.test(t)) throw new Error(t === '' ? `Enter the ${what} address` : `"${text.trim()}" is not a hex address. An address looks like 0x880000000.`);
|
||||
return BigInt('0x' + t);
|
||||
}
|
||||
|
||||
@@ -233,10 +234,10 @@ export function totalBytes(items: RegionItem[], opts: { fixed?: boolean } = {}):
|
||||
* Cuts ranges into the slices a number scan is sent in: small enough that
|
||||
* offsets fit in a u32 and progress moves steadily.
|
||||
*/
|
||||
export function sliceRanges(ranges: MemRange[], sliceBytes = 64 * 1024 * 1024): ScanRegion[] {
|
||||
export function sliceRanges(ranges: MemRange[], sliceBytes = SCAN_SLICE_BYTES): ScanRegion[] {
|
||||
const out: ScanRegion[] = [];
|
||||
for (const r of mergeRanges(ranges)) {
|
||||
for (let start = r.start; start < r.end; ) {
|
||||
for (let start = r.start; start < r.end;) {
|
||||
const left = r.end - start;
|
||||
const take = left > BigInt(sliceBytes) ? sliceBytes : Number(left);
|
||||
out.push({ start, length: take });
|
||||
|
||||
@@ -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), []);
|
||||
}
|
||||
+275
-73
@@ -51,8 +51,12 @@ export interface Scan {
|
||||
readonly kind: ValueKind;
|
||||
readonly passes: number;
|
||||
readonly matches: number;
|
||||
first(regions: ScanRegion[], compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void>;
|
||||
next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void>;
|
||||
/**
|
||||
* Both passes can be canceled through `signal`. They then throw a
|
||||
* ScanCanceledError and leave the scan exactly as it was before the call.
|
||||
*/
|
||||
first(regions: ScanRegion[], compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void>;
|
||||
next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void>;
|
||||
hits(limit: number): Promise<ScanHit[]>;
|
||||
/** True when the last "narrow down" can be taken back. Only one step is kept. */
|
||||
readonly canUndo: boolean;
|
||||
@@ -92,15 +96,31 @@ export function compareKind(id: string): CompareKind {
|
||||
return k;
|
||||
}
|
||||
|
||||
/** The smallest and largest whole number a type can hold. */
|
||||
export function kindRange(kind: ValueKind): [bigint, bigint] {
|
||||
const bits = kind.size * 8;
|
||||
return kind.signed ? [-(1n << BigInt(bits - 1)), (1n << BigInt(bits - 1)) - 1n] : [0n, (1n << BigInt(bits)) - 1n];
|
||||
}
|
||||
|
||||
/** Why typed text is not a number, said in a way that tells the person what to change. */
|
||||
function notANumber(text: string, whole = false): string {
|
||||
const t = text.trim();
|
||||
if (/^[-+]?\d{1,3}([, ]\d{3})+(\.\d+)?$/.test(t)) return `Type "${t}" without commas or spaces: ${t.replace(/[, ]/g, '')}`;
|
||||
if (/^[-+]?\d+,\d+$/.test(t)) return `Use a point for decimals: ${t.replace(',', '.')}`;
|
||||
return `"${t}" is not a ${whole ? 'whole number' : 'number'}. Type digits only, such as 100.`;
|
||||
}
|
||||
|
||||
/** Parses what the user typed. Integers accept decimal or 0x hex. */
|
||||
export function encodeValue(kind: ValueKind, text: string): Uint8Array {
|
||||
const t = text.trim();
|
||||
if (t === '') throw new Error('Enter a value');
|
||||
if (t === '') throw new Error('Enter a value first');
|
||||
const out = new Uint8Array(kind.size);
|
||||
const dv = view(out);
|
||||
if (kind.float) {
|
||||
const n = Number(t);
|
||||
if (!Number.isFinite(n)) throw new Error(`"${text}" is not a number`);
|
||||
if (!Number.isFinite(n)) throw new Error(notANumber(text));
|
||||
// The console's own cheat engine reads numbers the same way (payload/src/engine.c), so what is
|
||||
// accepted here has to stay in step with it: a number too big for a float becomes infinity in both.
|
||||
if (kind.size === 4) dv.setFloat32(0, n, true);
|
||||
else dv.setFloat64(0, n, true);
|
||||
return out;
|
||||
@@ -109,12 +129,21 @@ export function encodeValue(kind: ValueKind, text: string): Uint8Array {
|
||||
try {
|
||||
big = BigInt(t);
|
||||
} catch {
|
||||
throw new Error(`"${text}" is not a whole number`);
|
||||
const n = Number(t);
|
||||
if (Number.isFinite(n) && !Number.isInteger(n)) {
|
||||
throw new Error(`${t} is not a whole number, and the type "${kind.label}" only holds whole numbers. Choose the type Float (or Auto) for a number with decimals.`);
|
||||
}
|
||||
throw new Error(notANumber(text, true));
|
||||
}
|
||||
const [min, max] = kindRange(kind);
|
||||
if (big < 0n && !kind.signed) {
|
||||
throw new Error(`The type "${kind.label}" cannot hold a negative number. Choose "${kind.label} (signed)" for ${t}.`);
|
||||
}
|
||||
if (big < min || big > max) {
|
||||
const bigger = kind.size < 8 ? ` Choose a bigger type for ${t}.` : '';
|
||||
throw new Error(`${t} does not fit in the type "${kind.label}", which holds ${min.toLocaleString('en-US')} to ${max.toLocaleString('en-US')}.${bigger}`);
|
||||
}
|
||||
const bits = kind.size * 8;
|
||||
const min = kind.signed ? -(1n << BigInt(bits - 1)) : 0n;
|
||||
const max = kind.signed ? (1n << BigInt(bits - 1)) - 1n : (1n << BigInt(bits)) - 1n;
|
||||
if (big < min || big > max) throw new Error(`${text} does not fit in ${kind.label}`);
|
||||
if (kind.size === 8) dv.setBigUint64(0, BigInt.asUintN(64, big), true);
|
||||
else writeSmallInt(dv, kind.size, Number(BigInt.asUintN(bits, big)));
|
||||
return out;
|
||||
@@ -128,7 +157,7 @@ export function encodeValue(kind: ValueKind, text: string): Uint8Array {
|
||||
export function roundedRange(text: string): [number, number] {
|
||||
const t = text.trim();
|
||||
const n = Number(t);
|
||||
if (t === '' || !Number.isFinite(n)) throw new Error(t === '' ? 'Enter a value' : `"${text}" is not a number`);
|
||||
if (t === '' || !Number.isFinite(n)) throw new Error(t === '' ? 'Enter a value first' : notANumber(text));
|
||||
const decimals = /^[-+]?\d*\.(\d+)$/.exec(t)?.[1].length ?? 0;
|
||||
const half = 0.5 / 10 ** decimals;
|
||||
return [n - half, n + half];
|
||||
@@ -143,14 +172,19 @@ export function encodeCompare(kind: ValueKind, compare: CompareKind, text: strin
|
||||
if (compare.id === 'rounded') {
|
||||
if (!kind.float) {
|
||||
// A whole-number type has nothing to round, so it is an exact match written as a range.
|
||||
const whole = encodeValue(kind, String(Math.round(Number(text.trim()))));
|
||||
const [lo] = roundedRange(text);
|
||||
const whole = encodeValue(kind, String(Math.round(lo + 0.5)));
|
||||
return concat(whole, whole);
|
||||
}
|
||||
const [lo, hi] = roundedRange(text);
|
||||
return concat(encodeValue(kind, String(lo)), encodeValue(kind, String(hi)));
|
||||
}
|
||||
const a = encodeValue(kind, text);
|
||||
return compare.inputs === 1 ? a : concat(a, encodeValue(kind, text2));
|
||||
if (compare.inputs === 1) return a;
|
||||
if (text2.trim() === '') throw new Error('Type both numbers: the smallest and the biggest the value can be');
|
||||
const b = encodeValue(kind, text2);
|
||||
// Either order is accepted; the console wants the smaller one first.
|
||||
return Number(decodeValue(kind, a)) <= Number(decodeValue(kind, b)) ? concat(a, b) : concat(b, a);
|
||||
}
|
||||
|
||||
function writeSmallInt(dv: DataView, size: number, v: number): void {
|
||||
@@ -194,10 +228,88 @@ export interface ScanRegion {
|
||||
}
|
||||
|
||||
export interface ScanProgress {
|
||||
/** 0..1 */
|
||||
/** 0..1 over the whole operation. Real work done over work to do, never an estimate. */
|
||||
fraction: number;
|
||||
matches: number;
|
||||
detail: string;
|
||||
/**
|
||||
* What `fraction` is made of, for "3.1 of 9.6 GB": bytes of memory on a
|
||||
* first pass, candidates when narrowing down. With `part`, they count
|
||||
* within that part.
|
||||
*/
|
||||
done?: number;
|
||||
total?: number;
|
||||
unit?: 'bytes' | 'candidates';
|
||||
/** An Auto search goes over the same memory once per type: which round this is. */
|
||||
part?: { index: number; count: number };
|
||||
}
|
||||
|
||||
/** Thrown by a pass that was canceled. Says how far it got; nothing of it is kept. */
|
||||
export class ScanCanceledError extends Error {
|
||||
progress: ScanProgress | null;
|
||||
constructor(progress: ScanProgress | null) {
|
||||
super('Canceled');
|
||||
this.name = 'ScanCanceledError';
|
||||
this.progress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scan connection, once it is usable. The console closes a connection
|
||||
* after most errors and after a cancel, and the session needs a moment to
|
||||
* open the next one; a search started in that moment waits here instead of
|
||||
* failing on the dead one.
|
||||
*/
|
||||
export async function liveDbg(getDbg: () => Ps5Debug, waitMs = 6000): Promise<Ps5Debug> {
|
||||
const until = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
const dbg = getDbg();
|
||||
if (!dbg.closed) return dbg;
|
||||
if (Date.now() >= until) throw new Error('The connection to the console was lost and has not come back. Check that the console is on, then try again.');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a canceled pass waits for the request in flight to finish on its
|
||||
* own. After that the connection is closed, which is the only way to stop a
|
||||
* request the console is still working on (the session opens a new one).
|
||||
*/
|
||||
const CANCEL_GRACE_MS = 2500;
|
||||
|
||||
/**
|
||||
* Runs one request so that a cancel always gets through: at once when the
|
||||
* request finishes in time, otherwise by closing the connection.
|
||||
*/
|
||||
export async function cancelable<T>(
|
||||
dbg: Ps5Debug,
|
||||
signal: AbortSignal | undefined,
|
||||
last: () => ScanProgress | null,
|
||||
run: () => Promise<T>,
|
||||
/** Return the result of a request that finished before the cancel could stop it, for work that cannot be thrown away. */
|
||||
keepFinished = false,
|
||||
): Promise<T> {
|
||||
if (!signal) return run();
|
||||
if (signal.aborted) throw new ScanCanceledError(last());
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const onAbort = () => {
|
||||
timer = setTimeout(() => {
|
||||
if (!dbg.closed) dbg.close('Canceled');
|
||||
}, CANCEL_GRACE_MS);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort);
|
||||
let result: T;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (e) {
|
||||
if (signal.aborted) throw new ScanCanceledError(last());
|
||||
throw e;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (signal.aborted && !keepFinished) throw new ScanCanceledError(last());
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ScanHit {
|
||||
@@ -207,8 +319,15 @@ export interface ScanHit {
|
||||
kind?: ValueKind;
|
||||
}
|
||||
|
||||
/** Slices are capped so each one's offsets fit in a u32 and progress moves steadily. */
|
||||
const SLICE_BYTES = 64 * 1024 * 1024;
|
||||
/**
|
||||
* Slices are capped so each one's offsets fit in a u32 and progress moves
|
||||
* steadily. The console answers a slice only when it has finished it, and a
|
||||
* real game is searched at roughly 100 MB a second, so 16 MB reports several
|
||||
* times a second and a cancel never waits long. A request costs the console
|
||||
* next to nothing (it reads 1 MB at a time whatever the slice size).
|
||||
*/
|
||||
export const SCAN_SLICE_BYTES = 16 * 1024 * 1024;
|
||||
const SLICE_BYTES = SCAN_SLICE_BYTES;
|
||||
|
||||
/**
|
||||
* Candidate limit. Past this the result set is not worth keeping in a browser
|
||||
@@ -363,46 +482,46 @@ export class ScanSession implements Scan {
|
||||
}
|
||||
|
||||
/** First pass over every scannable region. */
|
||||
async first(
|
||||
regions: ScanRegion[],
|
||||
compare: CompareKind,
|
||||
value: Uint8Array,
|
||||
onProgress: (p: ScanProgress) => void,
|
||||
): Promise<void> {
|
||||
async first(regions: ScanRegion[], compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (!compare.first) throw new Error(`"${compare.label}" needs an earlier scan to compare against`);
|
||||
const dbg = this.getDbg();
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
|
||||
const total = regions.reduce((n, r) => n + r.length, 0);
|
||||
let done = 0;
|
||||
let matches = 0;
|
||||
const slices: Slice[] = [];
|
||||
const at = (): ScanProgress => ({ fraction: total ? done / total : 1, matches, detail: '', done, total, unit: 'bytes' });
|
||||
// Before the first slice comes back, so the screen can say how much there is to do.
|
||||
onProgress(at());
|
||||
|
||||
for (const region of regions) {
|
||||
// Asked for again each slice: the connection is replaced when the console drops it.
|
||||
const dbg = await liveDbg(this.getDbg);
|
||||
const collector = new BlockCollector();
|
||||
await dbg.scanStart(
|
||||
{
|
||||
pid: this.pid,
|
||||
address: region.start,
|
||||
length: region.length,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
alignment: this.kind.size,
|
||||
value,
|
||||
},
|
||||
(block) => {
|
||||
// Keep draining after the limit so the stream stays in step.
|
||||
if (matches + collector.count <= MAX_MATCHES) collector.add(block);
|
||||
else collector.count += block.count;
|
||||
},
|
||||
);
|
||||
await cancelable(dbg, signal, at, async () => {
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
await dbg.scanStart(
|
||||
{
|
||||
pid: this.pid,
|
||||
address: region.start,
|
||||
length: region.length,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
alignment: this.kind.size,
|
||||
value,
|
||||
},
|
||||
(block) => {
|
||||
// Keep draining after the limit so the stream stays in step.
|
||||
if (matches + collector.count <= MAX_MATCHES) collector.add(block);
|
||||
else collector.count += block.count;
|
||||
},
|
||||
);
|
||||
});
|
||||
matches += collector.count;
|
||||
if (matches > MAX_MATCHES) throw new TooManyMatchesError();
|
||||
if (collector.count > 0) {
|
||||
slices.push({ base: region.start, length: region.length, records: collector.take(), count: collector.count });
|
||||
}
|
||||
done += region.length;
|
||||
onProgress({ fraction: total ? done / total : 1, matches, detail: '' });
|
||||
onProgress(at());
|
||||
}
|
||||
|
||||
this.slices = slices;
|
||||
@@ -415,37 +534,41 @@ export class ScanSession implements Scan {
|
||||
* to be in ascending address order with a common alignment; that holds
|
||||
* because first() scans aligned and results are never reordered.
|
||||
*/
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (this.passes === 0) throw new Error('Run a first scan before narrowing down');
|
||||
const dbg = this.getDbg();
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
const recordSize = 4 + this.kind.size;
|
||||
const withPrevious = compareNeedsPrevious(compare.wire);
|
||||
const before = this.matches;
|
||||
let processed = 0;
|
||||
let matches = 0;
|
||||
const kept: Slice[] = [];
|
||||
const at = (): ScanProgress => ({ fraction: before ? processed / before : 1, matches, detail: '', done: processed, total: before, unit: 'candidates' });
|
||||
onProgress(at());
|
||||
|
||||
for (const slice of this.slices) {
|
||||
const dbg = await liveDbg(this.getDbg);
|
||||
const candidates = withPrevious ? slice.records : stripValues(slice.records, recordSize);
|
||||
const collector = new BlockCollector();
|
||||
await dbg.scanCount(
|
||||
{
|
||||
pid: this.pid,
|
||||
base: slice.base,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
value,
|
||||
candidates,
|
||||
},
|
||||
(block) => collector.add(block),
|
||||
);
|
||||
await cancelable(dbg, signal, at, async () => {
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
await dbg.scanCount(
|
||||
{
|
||||
pid: this.pid,
|
||||
base: slice.base,
|
||||
valueType: this.kind.wire,
|
||||
compareType: compare.wire,
|
||||
value,
|
||||
candidates,
|
||||
},
|
||||
(block) => collector.add(block),
|
||||
);
|
||||
});
|
||||
if (collector.count > 0) {
|
||||
kept.push({ base: slice.base, length: slice.length, records: collector.take(), count: collector.count });
|
||||
matches += collector.count;
|
||||
}
|
||||
processed += slice.count;
|
||||
onProgress({ fraction: before ? processed / before : 1, matches, detail: '' });
|
||||
onProgress(at());
|
||||
}
|
||||
|
||||
// Records are never changed in place, so keeping the old list costs no copying.
|
||||
@@ -510,28 +633,76 @@ export class SnapshotScan implements Scan {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
async first(regions: ScanRegion[], _compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
const dbg = this.getDbg();
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
const count = await dbg.turboSnapshot(
|
||||
{ pid: this.pid, valueType: this.kind.wire, segments: regions.map((r) => ({ address: r.start, length: r.length })) },
|
||||
(done, total) => onProgress({ fraction: total ? done / total : 0, matches: 0, detail: '' }),
|
||||
);
|
||||
if (count === null) throw new Error('The console could not hold a snapshot of this much memory. Try a search for a known value instead.');
|
||||
async first(regions: ScanRegion[], _compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
const dbg = await liveDbg(this.getDbg);
|
||||
const bytes = regions.reduce((n, r) => n + r.length, 0);
|
||||
let last: ScanProgress = { fraction: 0, matches: 0, detail: '', done: 0, total: bytes, unit: 'bytes' };
|
||||
onProgress(last);
|
||||
// One long request; the console says how far it is as it goes.
|
||||
let count: number | null;
|
||||
try {
|
||||
count = await cancelable(
|
||||
dbg,
|
||||
signal,
|
||||
() => last,
|
||||
async () => {
|
||||
if (!dbg.authed) await dbg.auth();
|
||||
return dbg.turboSnapshot({ pid: this.pid, valueType: this.kind.wire, segments: regions.map((r) => ({ address: r.start, length: r.length })) }, (done, total) =>
|
||||
onProgress((last = { fraction: total ? Math.min(1, done / total) : 0, matches: 0, detail: '', done, total, unit: 'bytes' })),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Canceled just as the snapshot finished: it exists on the console but nobody will use it, so free it.
|
||||
if (e instanceof ScanCanceledError && !dbg.closed) await dbg.turboEnd().catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
if (count === null) {
|
||||
throw new Error(
|
||||
'The console could not hold a snapshot of this much memory. Open "Where to search" and uncheck the biggest blocks, or search for a number you can see instead.',
|
||||
);
|
||||
}
|
||||
this.owner = dbg;
|
||||
this.slots = regions.reduce((n, r) => n + Math.floor(r.length / this.kind.size), 0);
|
||||
this.matches = count;
|
||||
this.passes = 1;
|
||||
}
|
||||
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
/**
|
||||
* The console narrows its list in place, so a pass that is canceled (which
|
||||
* means closing the connection) or fails loses the whole search.
|
||||
*/
|
||||
async next(compare: CompareKind, value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
const dbg = this.checkOwner();
|
||||
this.matches = await dbg.turboNarrow({ pid: this.pid, valueType: this.kind.wire, compareType: compare.wire, value }, (done) =>
|
||||
onProgress({ fraction: this.slots ? Math.min(1, done / this.slots) : 0, matches: 0, detail: '' }),
|
||||
);
|
||||
// The console counts every value it started with, not the ones still in the list.
|
||||
let last: ScanProgress = { fraction: 0, matches: 0, detail: '' };
|
||||
onProgress(last);
|
||||
try {
|
||||
// If the console finishes before a cancel can stop it, its list is narrowed whether we like it or not: take the result.
|
||||
this.matches = await cancelable(
|
||||
dbg,
|
||||
signal,
|
||||
() => last,
|
||||
() =>
|
||||
dbg.turboNarrow({ pid: this.pid, valueType: this.kind.wire, compareType: compare.wire, value }, (done) =>
|
||||
onProgress((last = { fraction: this.slots ? Math.min(1, done / this.slots) : 0, matches: 0, detail: '' })),
|
||||
),
|
||||
true,
|
||||
);
|
||||
} catch (e) {
|
||||
if (dbg.closed) {
|
||||
this.owner = null;
|
||||
this.matches = 0;
|
||||
this.lost = true;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
this.passes++;
|
||||
}
|
||||
|
||||
/** True once the console has dropped the list (the connection it lived on closed). */
|
||||
lost = false;
|
||||
|
||||
async hits(limit: number): Promise<ScanHit[]> {
|
||||
if (this.matches === 0) return [];
|
||||
const rows = await this.checkOwner().turboGet(0, limit, this.kind.size);
|
||||
@@ -620,11 +791,13 @@ export class AutoScan implements Scan {
|
||||
}
|
||||
}
|
||||
|
||||
async first(regions: ScanRegion[], compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
async first(regions: ScanRegion[], compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (compare.inputs === 0) throw new Error('Auto needs a number to look for. Pick a type to search for an unknown value.');
|
||||
const plan = AUTO_TRY.map((id) => valueKind(id))
|
||||
.map((kind) => ({ kind, value: this.encode(kind, compare) }))
|
||||
.filter((p): p is { kind: ValueKind; value: Uint8Array } => p.value !== null);
|
||||
// No type can hold it: say why, in the words the widest type would use.
|
||||
if (plan.length === 0) encodeCompare(valueKind('double'), compare, this.texts[0], this.texts[1]);
|
||||
if (plan.length === 0) throw new Error(`"${this.texts[0]}" is not a number`);
|
||||
|
||||
const parts: ScanSession[] = [];
|
||||
@@ -634,13 +807,27 @@ export class AutoScan implements Scan {
|
||||
const { kind, value } = plan[i];
|
||||
const part = new ScanSession(this.getDbg, this.pid, kind);
|
||||
const report = (p: ScanProgress) =>
|
||||
onProgress({ fraction: (i + p.fraction) / plan.length, matches: found + p.matches, detail: `as ${kind.label.toLowerCase()}` });
|
||||
onProgress({
|
||||
...p,
|
||||
fraction: (i + p.fraction) / plan.length,
|
||||
matches: found + p.matches,
|
||||
detail: `as ${kind.label.toLowerCase()}`,
|
||||
part: { index: i + 1, count: plan.length },
|
||||
});
|
||||
try {
|
||||
await part.first(regions, compare, value, report);
|
||||
await part.first(regions, compare, value, report, signal);
|
||||
parts.push(part);
|
||||
found += part.matches;
|
||||
} catch (e) {
|
||||
// One type drowning in matches should not sink the others.
|
||||
if (e instanceof ScanCanceledError && e.progress) {
|
||||
e.progress = {
|
||||
...e.progress,
|
||||
fraction: (i + e.progress.fraction) / plan.length,
|
||||
matches: found + e.progress.matches,
|
||||
part: { index: i + 1, count: plan.length },
|
||||
};
|
||||
}
|
||||
if (!(e instanceof TooManyMatchesError)) throw e;
|
||||
dropped.push({ kind, reason: 'too many matches' });
|
||||
}
|
||||
@@ -652,9 +839,13 @@ export class AutoScan implements Scan {
|
||||
this.passes = 1;
|
||||
}
|
||||
|
||||
async next(compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void): Promise<void> {
|
||||
async next(compare: CompareKind, _value: Uint8Array, onProgress: (p: ScanProgress) => void, signal?: AbortSignal): Promise<void> {
|
||||
if (this.passes === 0) throw new Error('Run a first scan before narrowing down');
|
||||
const before = this.matches;
|
||||
// Every type that is left must be able to hold what was typed, or nothing could match: say so instead of emptying the list.
|
||||
if (compare.inputs > 0 && this.parts.every((p) => this.encode(p.kind, compare) === null)) {
|
||||
encodeCompare(this.parts[0]?.kind ?? valueKind('double'), compare, this.texts[0], this.texts[1]);
|
||||
}
|
||||
const kept: ScanSession[] = [];
|
||||
const dropped = [...this.dropped];
|
||||
const narrowed: ScanSession[] = [];
|
||||
@@ -669,8 +860,19 @@ export class AutoScan implements Scan {
|
||||
dropped.push({ kind: part.kind, reason: 'cannot hold that value' });
|
||||
} else if (had > 0) {
|
||||
const base = done;
|
||||
await part.next(compare, value, (p) =>
|
||||
onProgress({ fraction: before ? (base + p.fraction * had) / before : 1, matches: found + p.matches, detail: `as ${part.kind.label.toLowerCase()}` }),
|
||||
await part.next(
|
||||
compare,
|
||||
value,
|
||||
(p) =>
|
||||
onProgress({
|
||||
fraction: before ? (base + p.fraction * had) / before : 1,
|
||||
matches: found + p.matches,
|
||||
detail: `as ${part.kind.label.toLowerCase()}`,
|
||||
done: base + (p.done ?? 0),
|
||||
total: before,
|
||||
unit: 'candidates',
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
narrowed.push(part);
|
||||
if (part.matches > 0) kept.push(part);
|
||||
|
||||
+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);
|
||||
|
||||
+394
-20
@@ -24,7 +24,7 @@ import type { ActiveCode, CodeEntry, CodeRule } from './codecheat.ts';
|
||||
import { LocationError, describeLocation, imageBase, resolveLocation } from './locations.ts';
|
||||
import type { GameContext, Location } from './locations.ts';
|
||||
import { parsePattern } from './x86.ts';
|
||||
import { decodeValue, encodeValue, valueKind } from './scanner.ts';
|
||||
import { VALUE_KINDS, decodeValue, encodeValue, valueKind } from './scanner.ts';
|
||||
import type { ScriptHandle, ScriptStop } from './script.ts';
|
||||
import { loadJson, saveJson } from './storage.ts';
|
||||
|
||||
@@ -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,24 +442,170 @@ 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,
|
||||
} as Entry;
|
||||
// A file edited by hand, or cut short, can hold a cheat with its main parts
|
||||
// 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 {
|
||||
const l = v as Record<string, unknown> | null;
|
||||
if (!l || typeof l !== 'object') return false;
|
||||
if (l.module !== undefined && typeof l.module !== 'string') return false;
|
||||
switch (l.kind) {
|
||||
case 'module':
|
||||
return typeof l.offset === 'string';
|
||||
case 'signature':
|
||||
if (l.offset === undefined) l.offset = 0;
|
||||
return typeof l.pattern === 'string' && Number.isInteger(l.offset);
|
||||
case 'pointer':
|
||||
return typeof l.base === 'string' && Array.isArray(l.offsets) && l.offsets.every((o) => typeof o === 'string');
|
||||
case 'address':
|
||||
return typeof l.address === 'string';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an entry read from a file has the parts its kind cannot do without, each of the right shape. */
|
||||
function wellFormed(entry: Entry): boolean {
|
||||
const e = entry as Entry & Record<string, unknown>;
|
||||
const site = (x: unknown): boolean => {
|
||||
const p = x as PatchSite | null;
|
||||
return !!p && typeof p === 'object' && isLocation(p.at) && typeof p.on === 'string' && (p.off === undefined || typeof p.off === 'string');
|
||||
};
|
||||
switch (e.type) {
|
||||
case 'patch':
|
||||
if (!site(e)) return false;
|
||||
if (e.more !== undefined) {
|
||||
const more = Array.isArray(e.more) ? e.more.filter(site) : [];
|
||||
if (more.length) e.more = more;
|
||||
else delete e.more;
|
||||
}
|
||||
return true;
|
||||
case 'value':
|
||||
if (typeof e.value === 'number') e.value = String(e.value);
|
||||
return isLocation(e.at) && typeof e.value === 'string' && typeof e.valueType === 'string' && VALUE_KINDS.some((k) => k.id === e.valueType) && typeof e.freeze === 'boolean';
|
||||
case 'inject':
|
||||
return isLocation(e.at);
|
||||
case 'call':
|
||||
if (!Array.isArray(e.args)) return false;
|
||||
e.args = e.args.map((a) => (typeof a === 'number' ? String(a) : a));
|
||||
return isLocation(e.fn) && e.args.every((a) => typeof a === 'string');
|
||||
case 'script':
|
||||
return typeof e.code === 'string';
|
||||
case 'shared':
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops optional fields that are not the shape this version understands, so the rest of the app can trust them. */
|
||||
@@ -416,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.
|
||||
@@ -424,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;
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,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);
|
||||
}
|
||||
+108
-18
@@ -10,11 +10,13 @@
|
||||
// Ignoring case. The console's pattern search can ignore a whole byte but not
|
||||
// one bit of it, so "either case" cannot be asked for directly. Instead:
|
||||
// - with up to five ASCII letters, every mix of upper and lower case is
|
||||
// searched for (at most 32 spellings), which is exact;
|
||||
// - with more letters, four spellings are searched for: as typed, all lower,
|
||||
// ALL UPPER and Title Case. Odd mixes such as "hEaLtH" are then missed.
|
||||
// `allCases` in the result says which of the two happened.
|
||||
// Only the letters A to Z are treated this way; accented letters match as typed.
|
||||
// searched for (at most 32 spellings);
|
||||
// - with more letters, every mix of the first three is searched for and the
|
||||
// other letters are left open ("any byte here"). Each place the console
|
||||
// reports is then read back and kept only if it really is the text, in
|
||||
// whatever capitals. "godmode" finds "GodMode" this way.
|
||||
// Both are exact. Only the letters A to Z are treated this way; accented
|
||||
// letters match as typed.
|
||||
//
|
||||
// Limits. Plain text means UTF-8 and wide text means UTF-16 little-endian (two
|
||||
// bytes per character, what Unreal Engine uses); other encodings are not
|
||||
@@ -25,6 +27,7 @@ import type { Ps5Debug } from './ps5debug.ts';
|
||||
import { aobScanMulti, readMulti } from './ps5debug-extra.ts';
|
||||
import { mergeRanges, rangesBytes } from './regions.ts';
|
||||
import type { MemRange } from './regions.ts';
|
||||
import { ScanCanceledError, cancelable, liveDbg } from './scanner.ts';
|
||||
|
||||
export type TextEncoding = 'utf8' | 'utf16';
|
||||
|
||||
@@ -36,6 +39,10 @@ export interface TextNeedle {
|
||||
/** The spelling this pattern stands for. */
|
||||
text: string;
|
||||
bytes: Uint8Array;
|
||||
/** 1 where the byte has to match, 0 where any byte is accepted (a letter whose case is left open). */
|
||||
mask: Uint8Array;
|
||||
/** True when the mask leaves letters open, so each match has to be read back and checked. */
|
||||
loose: boolean;
|
||||
}
|
||||
|
||||
export interface TextHit {
|
||||
@@ -55,17 +62,20 @@ export interface TextSearchResult {
|
||||
/** True when the search stopped because it reached `maxHits`. The rest of memory was not searched, so there may be more. */
|
||||
capped: boolean;
|
||||
canceled: boolean;
|
||||
/** The spellings searched for. */
|
||||
/** The text searched for (kept as a list for older callers). */
|
||||
spellings: string[];
|
||||
/** False when ignore-case was asked for but only the common spellings could be searched. */
|
||||
/** Always true: ignoring case finds every mix of capitals. Kept for older callers. */
|
||||
allCases: boolean;
|
||||
bytesSearched: number;
|
||||
}
|
||||
|
||||
export interface TextProgress {
|
||||
/** 0..1 */
|
||||
/** 0..1: bytes searched over bytes to search. */
|
||||
fraction: number;
|
||||
hits: number;
|
||||
/** Bytes searched so far and in all. */
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TextSearchOptions {
|
||||
@@ -88,6 +98,8 @@ export interface TextSearchOptions {
|
||||
export const MAX_TEXT_CHARS = 128;
|
||||
/** With this many letters or fewer, ignore-case tries every mix of upper and lower case. */
|
||||
const ALL_CASES_LETTERS = 5;
|
||||
/** With more letters than that, this many leading letters are tried in every mix and the rest are left open. */
|
||||
const LEADING_LETTERS = 3;
|
||||
const DEFAULT_CHUNK = 16 * 1024 * 1024;
|
||||
/** Characters of context shown on each side of a hit. */
|
||||
const CONTEXT_CHARS = 40;
|
||||
@@ -134,10 +146,54 @@ export function textNeedles(text: string, opts: { encodings: TextEncoding[]; ign
|
||||
if ([...text].length < 2) throw new Error('One character is found almost everywhere. Type at least two.');
|
||||
if ([...text].length > MAX_TEXT_CHARS) throw new Error(`That is a lot of text. The first ${MAX_TEXT_CHARS} characters or fewer are enough to find it.`);
|
||||
if (opts.encodings.length === 0) throw new Error('Choose plain text, wide text, or both');
|
||||
const { spellings, allCases } = opts.ignoreCase ? caseSpellings(text) : { spellings: [text], allCases: true };
|
||||
const chars = [...text];
|
||||
const letters = chars.map((c, i) => (isLetter(c) ? i : -1)).filter((i) => i >= 0);
|
||||
const needles: TextNeedle[] = [];
|
||||
for (const encoding of opts.encodings) for (const s of spellings) needles.push({ encoding, text: s, bytes: encodeText(s, encoding) });
|
||||
return { needles, spellings, allCases };
|
||||
if (!opts.ignoreCase || letters.length <= ALL_CASES_LETTERS) {
|
||||
const spellings = opts.ignoreCase ? caseSpellings(text).spellings : [text];
|
||||
for (const encoding of opts.encodings) {
|
||||
for (const s of spellings) {
|
||||
const bytes = encodeText(s, encoding);
|
||||
needles.push({ encoding, text: s, bytes, mask: new Uint8Array(bytes.length).fill(1), loose: false });
|
||||
}
|
||||
}
|
||||
return { needles, spellings: [text], allCases: true };
|
||||
}
|
||||
// Many letters: every mix of the first few (so each pattern starts with fixed bytes, which
|
||||
// is what keeps the console's search fast), and any byte where the other letters are.
|
||||
const lead = letters.slice(0, LEADING_LETTERS);
|
||||
const open = new Set(letters.slice(LEADING_LETTERS));
|
||||
for (const encoding of opts.encodings) {
|
||||
for (let bits = 0; bits < 1 << lead.length; bits++) {
|
||||
const copy = [...chars];
|
||||
lead.forEach((at, k) => {
|
||||
copy[at] = bits & (1 << k) ? copy[at].toUpperCase() : copy[at].toLowerCase();
|
||||
});
|
||||
const parts = copy.map((c) => encodeText(c, encoding));
|
||||
const bytes = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
const mask = new Uint8Array(bytes.length).fill(1);
|
||||
let o = 0;
|
||||
parts.forEach((p, i) => {
|
||||
bytes.set(p, o);
|
||||
// A letter is one byte, or in wide text one byte and a zero: only the letter's own byte is left open.
|
||||
if (open.has(i)) mask[o] = 0;
|
||||
o += p.length;
|
||||
});
|
||||
needles.push({ encoding, text: copy.join(''), bytes, mask, loose: true });
|
||||
}
|
||||
}
|
||||
return { needles, spellings: [text], allCases: true };
|
||||
}
|
||||
|
||||
/** True when `mem` holds the needle's text at `at`, counting upper and lower case of an open letter as the same. */
|
||||
export function looseMatch(needle: TextNeedle, mem: Uint8Array, at = 0): boolean {
|
||||
if (at + needle.bytes.length > mem.length) return false;
|
||||
for (let i = 0; i < needle.bytes.length; i++) {
|
||||
const want = needle.bytes[i];
|
||||
const got = mem[at + i];
|
||||
if (needle.mask[i] === 1 ? got !== want : (got | 0x20) !== (want | 0x20)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface RawHit {
|
||||
@@ -162,10 +218,14 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
const perCall = needles.length > 16 ? 2 : 4;
|
||||
|
||||
const found = new Map<string, RawHit>();
|
||||
/** Places a loose pattern matched that turned out not to be the text. */
|
||||
const rejected = new Set<string>();
|
||||
let done = 0;
|
||||
let capped = false;
|
||||
let canceled = false;
|
||||
const report = (extra: number) => opts.onProgress?.({ fraction: Math.min(1, (done + extra) / total), hits: Math.min(found.size, maxHits) });
|
||||
const report = (extra: number) =>
|
||||
opts.onProgress?.({ fraction: Math.min(1, (done + extra) / total), hits: Math.min(found.size, maxHits), done: Math.min(total, done + extra), total });
|
||||
report(0);
|
||||
|
||||
/** Searches [start, end) completely, or until the cap or a cancel. */
|
||||
const searchPiece = async (range: MemRange, start: bigint, end: bigint): Promise<void> => {
|
||||
@@ -176,12 +236,25 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
canceled = true;
|
||||
return;
|
||||
}
|
||||
const queries = active.flatMap((i) =>
|
||||
Array.from({ length: perCall }, (_, n) => ({ pattern: needles[i].bytes, mask: new Uint8Array(needles[i].bytes.length).fill(1), nth: n + 1 })),
|
||||
);
|
||||
const result = await aobScanMulti(opts.dbg(), { pid: opts.pid, address: from, length: Number(end - from), queries });
|
||||
const queries = active.flatMap((i) => Array.from({ length: perCall }, (_, n) => ({ pattern: needles[i].bytes, mask: needles[i].mask, nth: n + 1 })));
|
||||
const dbg = await liveDbg(opts.dbg);
|
||||
let result: bigint[];
|
||||
try {
|
||||
// A cancel gets through even when the console is slow to answer.
|
||||
result = await cancelable(
|
||||
dbg,
|
||||
opts.signal,
|
||||
() => null,
|
||||
() => aobScanMulti(dbg, { pid: opts.pid, address: from, length: Number(end - from), queries }),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ScanCanceledError)) throw e;
|
||||
canceled = true;
|
||||
return;
|
||||
}
|
||||
const more: number[] = [];
|
||||
let next: bigint | null = null;
|
||||
const fresh: RawHit[] = [];
|
||||
active.forEach((needleIndex, a) => {
|
||||
let last = 0n;
|
||||
let full = true;
|
||||
@@ -192,7 +265,9 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
break;
|
||||
}
|
||||
const needle = needles[needleIndex];
|
||||
found.set(`${address.toString(16)}:${needle.encoding}`, { address, needle, range });
|
||||
const key = `${address.toString(16)}:${needle.encoding}`;
|
||||
if (!needle.loose) found.set(key, { address, needle, range });
|
||||
else if (!found.has(key) && !rejected.has(key)) fresh.push({ address, needle, range });
|
||||
last = address;
|
||||
}
|
||||
// Every slot filled: this pattern may have more matches after the last one reported.
|
||||
@@ -201,6 +276,21 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
if (next === null || last + 1n < next) next = last + 1n;
|
||||
}
|
||||
});
|
||||
// Loose patterns also match other words of the same shape: read each new place back and keep the real ones.
|
||||
if (fresh.length > 0) {
|
||||
const reader = await liveDbg(opts.dbg);
|
||||
if (!reader.authed) await reader.auth();
|
||||
const data = await readMulti(
|
||||
reader,
|
||||
opts.pid,
|
||||
fresh.map((h) => ({ address: h.address, length: h.needle.bytes.length })),
|
||||
);
|
||||
fresh.forEach((h, i) => {
|
||||
const key = `${h.address.toString(16)}:${h.needle.encoding}`;
|
||||
if (looseMatch(h.needle, data[i])) found.set(key, h);
|
||||
else rejected.add(key);
|
||||
});
|
||||
}
|
||||
if (found.size >= maxHits) {
|
||||
capped = true;
|
||||
return;
|
||||
@@ -213,7 +303,7 @@ export async function searchText(opts: TextSearchOptions): Promise<TextSearchRes
|
||||
};
|
||||
|
||||
outer: for (const range of ranges) {
|
||||
for (let start = range.start; start < range.end; ) {
|
||||
for (let start = range.start; start < range.end;) {
|
||||
const left = range.end - start;
|
||||
const end = left > BigInt(chunkBytes) ? start + BigInt(chunkBytes) : range.end;
|
||||
await searchPiece(range, start, end);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Notifications on the TV when a cheat is switched.
|
||||
//
|
||||
// Someone toggling from a phone, or with a hotkey, is looking at the game and
|
||||
// not at this page, so the console says what happened in its own pop-up.
|
||||
// Switching a group flips several cheats within a moment; those are gathered
|
||||
// into one message so the TV is not buried in pop-ups.
|
||||
|
||||
export type TvChange = 'on' | 'off' | 'ran';
|
||||
|
||||
const WORD: Record<TvChange, string> = { on: 'ON', off: 'OFF', ran: 'done' };
|
||||
/** Names listed in one message before the rest are counted. */
|
||||
const MAX_NAMES = 3;
|
||||
/** Long names are cut so the pop-up stays one or two lines. */
|
||||
const MAX_NAME = 40;
|
||||
|
||||
const short = (name: string) => (name.length > MAX_NAME ? `${name.slice(0, MAX_NAME - 1).trimEnd()}…` : name);
|
||||
|
||||
/** The text for a batch of changes: one cheat, several going the same way, or a mix. */
|
||||
export function tvMessage(changes: { name: string; change: TvChange }[]): string {
|
||||
// The last word on each cheat wins: on then off within one batch is "off".
|
||||
const latest = new Map<string, TvChange>();
|
||||
for (const c of changes) {
|
||||
latest.delete(c.name);
|
||||
latest.set(c.name, c.change);
|
||||
}
|
||||
const list = [...latest].map(([name, change]) => ({ name, change }));
|
||||
if (list.length === 0) return '';
|
||||
if (list.length === 1) return `${short(list[0].name)}: ${WORD[list[0].change]}`;
|
||||
|
||||
const names = (items: { name: string }[]) => {
|
||||
const shown = items.slice(0, MAX_NAMES).map((i) => short(i.name));
|
||||
const more = items.length - shown.length;
|
||||
return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
|
||||
};
|
||||
const parts: string[] = [];
|
||||
for (const change of ['on', 'off', 'ran'] as TvChange[]) {
|
||||
const items = list.filter((i) => i.change === change);
|
||||
if (items.length > 0) parts.push(`${WORD[change]}: ${names(items)}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** Gathers changes made close together and sends one notification for them. */
|
||||
export class TvNotices {
|
||||
private pending: { name: string; change: TvChange }[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly send: (text: string) => Promise<void>;
|
||||
private readonly waitMs: number;
|
||||
|
||||
constructor(send: (text: string) => Promise<void>, waitMs = 350) {
|
||||
this.send = send;
|
||||
this.waitMs = waitMs;
|
||||
}
|
||||
|
||||
/** Notes one change. The message goes out once nothing more has arrived for a moment. */
|
||||
add(name: string, change: TvChange): void {
|
||||
this.pending.push({ name, change });
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.flush(), this.waitMs);
|
||||
}
|
||||
|
||||
/** A message of its own, right away, replacing anything waiting (for "everything restored"). */
|
||||
say(text: string): void {
|
||||
this.drop();
|
||||
void this.send(text).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Forgets what is waiting without sending it. */
|
||||
drop(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.pending = [];
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
this.timer = null;
|
||||
const text = tvMessage(this.pending);
|
||||
this.pending = [];
|
||||
// A notification is a courtesy: failing to show one must never disturb the switch itself.
|
||||
if (text) void this.send(text).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
+231
-24
@@ -1,67 +1,137 @@
|
||||
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, Wordmark, errorText } from './bits.tsx';
|
||||
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.
|
||||
return (
|
||||
<Safe what="this page">
|
||||
<AppBody />
|
||||
</Safe>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (target: Target | null, opts: { quiet?: boolean } = {}) => {
|
||||
setPhase({ name: 'connecting', label: target ? target.label || target.host : 'your PS5' });
|
||||
// `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 } = {}) => {
|
||||
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);
|
||||
if (sessionRef.current) {
|
||||
// Another attempt got there first.
|
||||
session.close();
|
||||
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;
|
||||
if (shown < 700) await new Promise((r) => setTimeout(r, 700 - shown));
|
||||
setPhase({ name: 'pick', error: opts.quiet ? undefined : errorText(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -71,12 +141,51 @@ export function App() {
|
||||
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;
|
||||
@@ -90,7 +199,25 @@ export function App() {
|
||||
|
||||
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
|
||||
fallback={
|
||||
<p className="flex items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
@@ -101,26 +228,42 @@ export function App() {
|
||||
<LearnPage
|
||||
preferLocal={!!info?.demo || import.meta.env.DEV}
|
||||
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">
|
||||
@@ -138,10 +281,19 @@ export function App() {
|
||||
<h2 className="text-xl font-semibold">Connecting to {phase.label}…</h2>
|
||||
</div>
|
||||
)}
|
||||
{phase.name === 'stopped' && <Stopped onCheck={checkBack} />}
|
||||
{phase.name === 'pick' && (
|
||||
<Picker info={info} error={phase.error} onPick={(t) => void connect(t)} consoleMode={info?.mode === 'console'} />
|
||||
<Picker
|
||||
info={info}
|
||||
error={phase.error}
|
||||
onPick={(t) => void connect(t)}
|
||||
onRetryQuietly={() => void connect(null, { background: true })}
|
||||
consoleMode={info?.mode === 'console'}
|
||||
/>
|
||||
)}
|
||||
{(phase.name === 'pick' || phase.name === 'unavailable') && (
|
||||
<LearnDoor onOpen={() => navigate({ screen: 'learn', lesson: null, step: null })} />
|
||||
)}
|
||||
{(phase.name === 'pick' || phase.name === 'unavailable') && <LearnDoor onOpen={() => setLearn({ lesson: null })} />}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
@@ -151,6 +303,38 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
@@ -182,7 +366,14 @@ function ConsoleRow(props: { title: string; detail: string; mono?: boolean; onCl
|
||||
);
|
||||
}
|
||||
|
||||
function Picker(props: { info: ServerInfo | null; error?: string; consoleMode: boolean; onPick: (t: Target | null) => void }) {
|
||||
function Picker(props: {
|
||||
info: ServerInfo | null;
|
||||
error?: string;
|
||||
consoleMode: boolean;
|
||||
onPick: (t: Target | null) => void;
|
||||
/** Console mode only: try once more without changing the screen. */
|
||||
onRetryQuietly?: () => void;
|
||||
}) {
|
||||
const [found, setFound] = useState<Target[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [ip, setIp] = useState(() => loadJson<Target | null>('lastTarget', null)?.host ?? '');
|
||||
@@ -203,16 +394,32 @@ function Picker(props: { info: ServerInfo | null; error?: string; consoleMode: b
|
||||
if (!props.consoleMode) void search();
|
||||
}, [props.consoleMode, search]);
|
||||
|
||||
// Served by the PS5: there is one console, so the page keeps trying it by
|
||||
// itself. Loading the debugger is then all the person has to do.
|
||||
const { consoleMode, onRetryQuietly } = props;
|
||||
useEffect(() => {
|
||||
if (!consoleMode || !onRetryQuietly) return;
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') onRetryQuietly();
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [consoleMode, onRetryQuietly]);
|
||||
|
||||
if (props.consoleMode) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{props.error && <Notice kind="error">{props.error}</Notice>}
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<h2 className="text-lg font-semibold">The debugger on your PS5 is not answering</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The debugger is not answering. Load ps5debug-NG on the console, then try again.
|
||||
Load ps5debug-NG on the console; it shows a "loaded" notification when it is ready. This page connects by itself as soon as it
|
||||
is running.
|
||||
</p>
|
||||
<Button className="self-start" onClick={() => props.onPick(null)}>
|
||||
Try again
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Spinner className="size-4" /> Waiting for the debugger…
|
||||
</p>
|
||||
{props.error && <p className="text-xs break-words text-muted-foreground">What the console said: {props.error}</p>}
|
||||
<Button variant="outline" onClick={() => props.onPick(null)}>
|
||||
Try now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+928
-78
File diff suppressed because it is too large.
Load diff
+46
-14
@@ -40,7 +40,7 @@ import { hotkeyClashes } from '../lib/hotkeys.ts';
|
||||
import type { ScriptCommand, ScriptHandle } from '../lib/script.ts';
|
||||
import { groupNames } from '../lib/table.ts';
|
||||
import type { CodeSite, CodeVariable, Entry, InjectEntry, SharedEntry } from '../lib/table.ts';
|
||||
import { HotkeyField, LocationFields, checkLocation } from './EntryEditor.tsx';
|
||||
import { HotkeyField, LocationFields, checkLocation, useCloseGuard } from './EntryEditor.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
@@ -86,7 +86,13 @@ function CheckResult(props: { check: SourceCheck | null; checking: boolean }) {
|
||||
const good = check.status === 'ok';
|
||||
return (
|
||||
<div className="flex flex-col gap-2" role="status">
|
||||
<p className="text-sm">
|
||||
{/* What is below is about the place as it was a moment ago; say so until the new answer is in. */}
|
||||
{props.checking && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Looking at the game again…
|
||||
</p>
|
||||
)}
|
||||
<p className={cn('text-sm', props.checking && 'opacity-50')}>
|
||||
<span className={cn('font-semibold', good ? 'text-primary' : check.status === 'hooked' ? 'text-foreground' : 'text-destructive')}>
|
||||
{good && <CheckIcon className="mr-1 inline size-4 align-[-3px]" />}
|
||||
{STATUS_WORDS[check.status]}.
|
||||
@@ -123,8 +129,9 @@ function useSourceCheck(site: Pick<CodeSite, 'at' | 'off' | 'fallback'>): { chec
|
||||
try {
|
||||
checkLocation(site.at);
|
||||
} catch {
|
||||
// Still being typed.
|
||||
// Still being typed. A check that was under way for what was there before is dropped, so nothing is being looked at any more.
|
||||
setCheck(null);
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
setChecking(true);
|
||||
@@ -208,6 +215,9 @@ function VariablesEditor(props: { value: CodeVariable[]; onChange: (list: CodeVa
|
||||
|
||||
const OBJECT_FIELD = 'field';
|
||||
|
||||
/** For a list whose choices are whole phrases: on a narrow screen the chosen one wraps instead of being cut short. */
|
||||
const WRAPPING = 'h-auto min-h-9 w-full py-1.5 text-left leading-snug whitespace-normal *:data-[slot=select-value]:whitespace-normal';
|
||||
|
||||
function operandId(target: RuleTarget, op: RuleOperand): string {
|
||||
const known = target.operands.find((c) => JSON.stringify(c.operand) === JSON.stringify(op));
|
||||
if (known) return known.id;
|
||||
@@ -262,7 +272,7 @@ function ActionFields(props: { which: 'then' | 'otherwise'; action: RuleAction;
|
||||
return (
|
||||
<>
|
||||
<Select value={action.do} onValueChange={pick}>
|
||||
<SelectTrigger className="w-full" aria-label={`${label}: what happens`}>
|
||||
<SelectTrigger className={WRAPPING} aria-label={`${label}: what happens`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -336,7 +346,7 @@ function RuleBuilder(props: { rule: CodeRule; target: RuleTarget; onChange: (rul
|
||||
|
||||
<Step word="If">
|
||||
<Select value={test ? 'if' : 'always'} onValueChange={(v) => set(v === 'always' ? { test: undefined, otherwise: undefined } : { test: startTest(), otherwise: { do: 'original' } })}>
|
||||
<SelectTrigger className="w-full" aria-label="If: always, or only in some cases">
|
||||
<SelectTrigger className={WRAPPING} aria-label="If: always, or only in some cases">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -356,7 +366,7 @@ function RuleBuilder(props: { rule: CodeRule; target: RuleTarget; onChange: (rul
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label="If: what to look at">
|
||||
<SelectTrigger className={WRAPPING} aria-label="If: what to look at">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -468,7 +478,9 @@ function TryButton(props: { entry: CodeEntry }) {
|
||||
const [state, setState] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const key = JSON.stringify(props.entry);
|
||||
useEffect(() => setState(null), [key]);
|
||||
useEffect(() => {
|
||||
setState(null);
|
||||
}, [key]);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -585,11 +597,12 @@ export function CodeCheatEditor(props: { entry: CodeEntry; isNew: boolean }) {
|
||||
return props.entry.type === 'shared' ? <SharedDialog entry={props.entry} isNew={props.isNew} /> : <CheatDialog entry={props.entry} isNew={props.isNew} />;
|
||||
}
|
||||
|
||||
function Shell(props: { title: string; help: string; error: string | null; saving: boolean; onSave: () => void; children: ReactNode }) {
|
||||
function Shell(props: { title: string; help: string; error: string | null; saving: boolean; dirty: boolean; onSave: () => void; children: ReactNode }) {
|
||||
const wb = useWorkbench();
|
||||
const guard = useCloseGuard(props.dirty, wb.closeEditor);
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && wb.closeEditor()}>
|
||||
<DialogContent className="max-h-[92dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog open onOpenChange={guard.onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl" {...guard.contentProps}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
<DialogDescription>{props.help}</DialogDescription>
|
||||
@@ -597,6 +610,7 @@ function Shell(props: { title: string; help: string; error: string | null; savin
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{props.children}
|
||||
{props.error && <Notice kind="error">{props.error}</Notice>}
|
||||
{guard.warned && <Notice kind="warn">You have changes that are not saved. Press Escape again, or Cancel, to close without saving them.</Notice>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={wb.closeEditor}>
|
||||
@@ -639,7 +653,7 @@ function Filing(props: { draft: CodeEntry; set: (patch: Partial<CodeEntry>) => v
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="code-group">Group (optional)</Label>
|
||||
<Input id="code-group" list="code-groups" placeholder={groups.length ? 'Pick one of your groups, or type a new name' : 'e.g. Weapons'} maxLength={60} value={draft.group ?? ''} onChange={(e) => set({ group: e.target.value || undefined })} />
|
||||
<Input id="code-group" list="code-groups" placeholder={groups.length ? 'Pick a group, or type a new one' : 'e.g. Weapons'} maxLength={60} value={draft.group ?? ''} onChange={(e) => set({ group: e.target.value || undefined })} />
|
||||
<datalist id="code-groups">
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
@@ -670,6 +684,10 @@ function CheatDialog(props: { entry: InjectEntry; isNew: boolean }) {
|
||||
const [mode, setMode] = useState<'rule' | 'code'>(props.entry.rule || (!props.entry.code.trim() && !legacy) ? 'rule' : 'code');
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const set = (patch: Partial<InjectEntry>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
// What Save complained about is out of date as soon as anything is changed; left up, it would look like it still applies.
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
}, [draft, mode]);
|
||||
const { check, checking, again } = useSourceCheck(draft);
|
||||
|
||||
const target = useMemo(() => (check?.replaced?.[0] ? ruleTarget(check.replaced[0]) : null), [check]);
|
||||
@@ -697,7 +715,10 @@ function CheatDialog(props: { entry: InjectEntry; isNew: boolean }) {
|
||||
setError(null);
|
||||
let entry: InjectEntry;
|
||||
try {
|
||||
if (!draft.name.trim()) throw new Error('Give it a name');
|
||||
if (!draft.name.trim()) {
|
||||
document.getElementById('code-name')?.focus();
|
||||
throw new Error('Give it a name');
|
||||
}
|
||||
if (mode === 'rule') {
|
||||
if (!rule) throw new Error('The game has to be looked at before a rule can be made. Fill in where the game\'s line is, and wait for the check.');
|
||||
if (built?.problem) throw new Error(built.problem);
|
||||
@@ -746,6 +767,7 @@ function CheatDialog(props: { entry: InjectEntry; isNew: boolean }) {
|
||||
help="Sends one line of the game through new code that can decide what happens: who gets hurt, how much is spent, what number is stored."
|
||||
error={error}
|
||||
saving={saving}
|
||||
dirty={draft !== props.entry}
|
||||
onSave={() => void save()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -915,12 +937,18 @@ function SharedDialog(props: { entry: SharedEntry; isNew: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const set = (patch: Partial<SharedEntry>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
}, [draft]);
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
let entry: SharedEntry;
|
||||
try {
|
||||
if (!draft.name.trim()) throw new Error('Give it a name');
|
||||
if (!draft.name.trim()) {
|
||||
document.getElementById('code-name')?.focus();
|
||||
throw new Error('Give it a name');
|
||||
}
|
||||
if (!draft.symbol) throw new Error('Give it a name in code; that is what other cheats call.');
|
||||
if (nameProblem(draft.symbol)) throw new Error(nameProblem(draft.symbol)!);
|
||||
if (!draft.code.trim()) throw new Error('The code is empty');
|
||||
@@ -947,6 +975,7 @@ function SharedDialog(props: { entry: SharedEntry; isNew: boolean }) {
|
||||
help="Code and variables that several code cheats use. It changes nothing in the game by itself; it is put into the game when the first cheat that uses it is switched on."
|
||||
error={error}
|
||||
saving={saving}
|
||||
dirty={draft !== props.entry}
|
||||
onSave={() => void save()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1057,7 +1086,10 @@ function useScriptHandle(id: string): { handle: ScriptHandle | null; commands: S
|
||||
const wb = useWorkbench();
|
||||
const handle = wb.runner.scriptHandle(id);
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => handle?.subscribe(() => setTick((n) => n + 1)), [handle]);
|
||||
useEffect(() => {
|
||||
if (!handle) return;
|
||||
return handle.subscribe(() => setTick((n) => n + 1));
|
||||
}, [handle]);
|
||||
return { handle, commands: handle?.commands() ?? [], hotkeys: handle?.hotkeys() ?? [] };
|
||||
}
|
||||
|
||||
|
||||
+65
-15
@@ -13,13 +13,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { bytesToHex, fmtAddr } from '../lib/bytes.ts';
|
||||
import { findPattern, imageRange, parseAddress } from '../lib/locations.ts';
|
||||
import { findPattern, imageRange, parseAddress, readableAt } from '../lib/locations.ts';
|
||||
import { codeCheatEntry, nopEntry } from '../lib/make.ts';
|
||||
import { Tracer } from '../lib/tracer.ts';
|
||||
import type { TraceHit } from '../lib/tracer.ts';
|
||||
import { signatureFromCode } from '../lib/x86.ts';
|
||||
import type { Insn } from '../lib/x86.ts';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { copyText } from './copy.ts';
|
||||
import { useDebug } from './debughooks.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
@@ -49,6 +50,8 @@ function TracePanel() {
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyRow, setBusyRow] = useState<bigint | null>(null);
|
||||
/** A watch has been started and has ended, so the list (even an empty one) is an answer and not a blank form. */
|
||||
const [finished, setFinished] = useState(false);
|
||||
const tracer = useRef<Tracer | null>(null);
|
||||
/** Gives the debugger attach back; set while a trace holds it. */
|
||||
const release = useRef<(() => void) | null>(null);
|
||||
@@ -65,13 +68,24 @@ function TracePanel() {
|
||||
setError(null);
|
||||
setNote(null);
|
||||
setHits([]);
|
||||
setFinished(false);
|
||||
setStarting(true);
|
||||
try {
|
||||
await stop();
|
||||
// A watch on nothing never fires, and would sit there looking busy.
|
||||
if ((await readableAt(ctx, address, bytes, true)) === 'none') throw new Error(`The game has no memory at ${fmtAddr(address)}, so nothing can change it. Check the address.`);
|
||||
// Only one thing can hold the debugger. If pausing and stepping (or
|
||||
// the guide) has it, the user is asked whether to stop that first.
|
||||
await withAttach(async () => {
|
||||
const mine = gate.claim({ id: 'trace', label: 'What touches a value', stop });
|
||||
const mine = gate.claim({
|
||||
id: 'trace',
|
||||
label: 'What touches a value',
|
||||
// Something else asked for the debugger and the person agreed; say so here, or the list just stops growing.
|
||||
stop: async () => {
|
||||
await stop();
|
||||
setNote('Stopped, because another tool needed the debugger. What was found is still listed here.');
|
||||
},
|
||||
});
|
||||
const done = () => {
|
||||
mine();
|
||||
if (release.current === mine) release.current = null;
|
||||
@@ -84,6 +98,7 @@ function TracePanel() {
|
||||
if (ended !== undefined || !t.running) {
|
||||
done();
|
||||
setRunning(false);
|
||||
setFinished(true);
|
||||
if (ended) setNote(ended);
|
||||
}
|
||||
});
|
||||
@@ -114,8 +129,17 @@ function TracePanel() {
|
||||
void start(req.address, req.size, req.kind);
|
||||
}, [wb.trace, start]);
|
||||
|
||||
// Never leave the game attached when this goes away.
|
||||
useEffect(() => () => void stop(), [stop]);
|
||||
// Never leave the game attached when this goes away. Closing or reloading
|
||||
// the page does not unmount anything, so that gets the same goodbye: a
|
||||
// watchpoint left armed in a game nobody is debugging kills it on the next hit.
|
||||
useEffect(() => {
|
||||
const bye = () => void stop();
|
||||
window.addEventListener('pagehide', bye);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', bye);
|
||||
void stop();
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
@@ -196,6 +220,17 @@ function TracePanel() {
|
||||
)}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{note && <Notice>{note}</Notice>}
|
||||
{finished && !running && !starting && !error && hits.length === 0 && (
|
||||
<Notice kind="warn">
|
||||
Nothing {kind === 'write' ? 'changed' : 'touched'} the value while it was watched. Watch again and make it change in the game (get hit,
|
||||
fire, spend). If it changed and nothing showed up, the address is probably a copy of the value and not the value itself.
|
||||
</Notice>
|
||||
)}
|
||||
{finished && !running && !starting && hits.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
Stopped. {hits.length === 1 ? 'One instruction' : `${hits.length} instructions`} touched the value.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hits.length > 0 && (
|
||||
<ul className="divide-y" aria-label="Instructions that touched the value">
|
||||
@@ -249,11 +284,14 @@ function Listing() {
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [focus, setFocus] = useState<bigint | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
/** Whether the game has memory where the listing is; reads of nothing come back as zeros, which decode as real-looking instructions. */
|
||||
const [mapped, setMapped] = useState<'all' | 'part' | 'none'>('all');
|
||||
const bps = desk.breakpoints();
|
||||
const bpKey = bps.map((b) => `${b.address.toString(16)}${b.enabled ? '+' : '-'}`).join(',');
|
||||
const stoppedAt = desk.stop?.rip ?? null;
|
||||
|
||||
const go = useCallback((address: bigint) => {
|
||||
const go = useCallback((to: bigint) => {
|
||||
const address = to < 0n ? 0n : to;
|
||||
setStart(address);
|
||||
setFocus(address);
|
||||
setText(fmtAddr(address));
|
||||
@@ -289,7 +327,10 @@ function Listing() {
|
||||
const list = await ctx.x86.disassemble(bytes, start, 40);
|
||||
// The last instruction may be cut off by the end of the window.
|
||||
if (list.length > 1 && list[list.length - 1].invalid) list.pop();
|
||||
let there = await readableAt(ctx, start, WINDOW);
|
||||
if (there !== 'all') there = await readableAt(ctx, start, WINDOW, true);
|
||||
if (canceled) return;
|
||||
setMapped(there);
|
||||
setImage(range);
|
||||
setInsns(list);
|
||||
setError(null);
|
||||
@@ -324,10 +365,14 @@ function Listing() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const match = await findPattern(ctx, pattern, 'code');
|
||||
if (!match) setNote('That pattern is not in the game\'s code.');
|
||||
if (!match) setError('No match: that pattern is not anywhere in the game\'s code. Check the bytes, or put ?? where a byte may differ between game versions.');
|
||||
else {
|
||||
go(match.address);
|
||||
if (!match.unique) setNote('That pattern matches in more than one place; this is the first. Add more bytes to make it unique.');
|
||||
setNote(
|
||||
match.unique
|
||||
? `Found at ${label(match.address)}, the top line below. It is the only place this pattern matches.`
|
||||
: `Found at ${label(match.address)}, the top line below, but the pattern matches in more than one place; this is the first. Add more bytes to make it unique.`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
@@ -382,14 +427,12 @@ function Listing() {
|
||||
const copySignature = (index: number) =>
|
||||
act(async () => {
|
||||
const sig = signatureFromCode(insns!.slice(index), 12).text;
|
||||
// Copy first: browsers only allow it right after the click, and the search below can take a while.
|
||||
const copied = await copyText(sig);
|
||||
const match = await findPattern(ctx, sig, 'code');
|
||||
setPattern(sig);
|
||||
try {
|
||||
await navigator.clipboard.writeText(sig);
|
||||
setNote(`Copied: ${sig}${match && !match.unique ? ' (matches in more than one place; it needs more bytes)' : ''}`);
|
||||
} catch {
|
||||
setNote(`Signature: ${sig}`);
|
||||
}
|
||||
const caveat = match && !match.unique ? ' It matches in more than one place, so it needs more bytes to be of use.' : '';
|
||||
setNote(copied ? `Copied: ${sig}.${caveat}` : `This browser would not copy it. The signature is in the "Find a byte pattern" box above: ${sig}.${caveat}`);
|
||||
});
|
||||
|
||||
const last = insns && insns.length ? insns[insns.length - 1] : null;
|
||||
@@ -434,6 +477,12 @@ function Listing() {
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{note && <Notice>{note}</Notice>}
|
||||
{insns && mapped !== 'all' && (
|
||||
<Notice kind="warn">
|
||||
{mapped === 'none' ? 'The game has no memory at this address.' : 'The game has memory at only part of this page.'} Nothing there reads as
|
||||
zeros, which show up below as made-up instructions (usually "add [rax], al").
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{insns && (
|
||||
<>
|
||||
@@ -462,7 +511,8 @@ function Listing() {
|
||||
{!insn.invalid && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for ${insn.text}`}>
|
||||
{/* Bigger on a phone, where it is the only way into a line. */}
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for ${insn.text}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -489,7 +539,7 @@ function Listing() {
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={start === null} onClick={() => start !== null && go(start - 0x80n)}>
|
||||
<Button variant="outline" size="sm" disabled={start === null || start === 0n} onClick={() => start !== null && go(start - 0x80n)}>
|
||||
<ChevronLeftIcon /> Earlier
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!last} onClick={() => last && go(last.address + BigInt(last.length))}>
|
||||
|
||||
+181
-22
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LibraryIcon, TriangleAlertIcon } 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,12 +23,16 @@ 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, Wordmark, errorText } from "./bits.tsx";
|
||||
import { Ghost, Notice, Safe, Wordmark, errorText } from "./bits.tsx";
|
||||
import { CheatsTab } from "./CheatsTab.tsx";
|
||||
import { ScannerTab } from "./ScannerTab.tsx";
|
||||
import { MemoryTab } from "./MemoryTab.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,18 +182,55 @@ export function ConsoleView(props: {
|
||||
canceled = true;
|
||||
};
|
||||
}, [session, titleId, game?.pid]);
|
||||
// The library has done its job once the game it started is on screen.
|
||||
useEffect(() => setLibraryOpen(false), [titleId]);
|
||||
// The game in the diagnostics log, and at the head of a copied log.
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-wrap items-center gap-x-3 gap-y-2 py-1">
|
||||
{/* One line at every width: on a phone the buttons tighten up and "Disconnect" becomes an icon. */}
|
||||
<header className="flex items-center gap-x-1 py-1 sm:gap-x-3">
|
||||
<Wordmark />
|
||||
<Status
|
||||
status="online"
|
||||
@@ -184,19 +247,46 @@ export function ConsoleView(props: {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLibraryOpen(true)}
|
||||
className="shrink-0"
|
||||
onClick={() => navigate({ screen: "library" })}
|
||||
>
|
||||
<LibraryIcon /> Games
|
||||
</Button>
|
||||
)}
|
||||
{props.onLearn && (
|
||||
<Button variant="ghost" size="sm" onClick={props.onLearn}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 max-sm:px-2"
|
||||
onClick={props.onLearn}
|
||||
>
|
||||
Learn
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={props.onDisconnect}>
|
||||
Disconnect
|
||||
<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:hidden"
|
||||
aria-label="Disconnect"
|
||||
title="Disconnect"
|
||||
onClick={props.onDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
@@ -222,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>
|
||||
</>
|
||||
) : (
|
||||
@@ -251,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
|
||||
@@ -310,11 +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}>
|
||||
<DialogContent className="max-h-[90dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-xl">
|
||||
<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="mt-0 sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Your games</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -434,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
|
||||
@@ -457,50 +601,65 @@ function GameTabs(props: {
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<CheatsTab
|
||||
<Safe what="the Cheats tab">
|
||||
<CheatsTab
|
||||
session={wb.session}
|
||||
game={wb.game}
|
||||
localCheats={props.localCheats}
|
||||
onGameName={props.onGameName}
|
||||
/>
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="find"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScannerTab session={wb.session} game={wb.game} />
|
||||
<Safe what="the Find tab">
|
||||
<ScannerTab session={wb.session} game={wb.game} />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="memory"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<MemoryTab />
|
||||
<Safe what="the Memory tab">
|
||||
<MemoryTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="code"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<CodeTab />
|
||||
<Safe what="the Code tab">
|
||||
<CodeTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="debug"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<DebugTab />
|
||||
<Safe what="the Debug tab">
|
||||
<DebugTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="engine"
|
||||
forceMount
|
||||
className="data-[state=inactive]:hidden"
|
||||
>
|
||||
<EngineTab />
|
||||
<Safe what="the Engine tab">
|
||||
<EngineTab />
|
||||
</Safe>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<EntryEditor />
|
||||
{/* A fault in an editor closes that editor; the cheat being edited is untouched. */}
|
||||
<Safe key={wb.editing?.entry.id ?? "none"} what="the cheat editor">
|
||||
<EntryEditor />
|
||||
</Safe>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
-7
@@ -106,17 +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.
|
||||
// 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 fit = () => (document.body.style.paddingTop = `${el.offsetHeight}px`);
|
||||
const root = document.documentElement.style;
|
||||
const fit = () => {
|
||||
const h = el.offsetHeight;
|
||||
document.body.style.paddingTop = `${h}px`;
|
||||
root.setProperty('--cs-dialog-top', `${h}px`);
|
||||
};
|
||||
fit();
|
||||
const ro = new ResizeObserver(fit);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
root.removeProperty('--cs-dialog-top');
|
||||
document.body.style.paddingTop = before;
|
||||
};
|
||||
}, []);
|
||||
@@ -134,7 +143,14 @@ function PausedBanner(props: { onDebugTab: boolean }) {
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div ref={bar} role="alert" className="fixed inset-x-0 top-0 z-[70] border-b border-black/40 bg-primary text-primary-foreground shadow-lg">
|
||||
// pointer-events-auto: an open dialog switches clicks off for everything outside it, and Resume must never be out of reach.
|
||||
// The press is kept from the dialog, which would take it for a click outside itself and close, throwing away what was typed in it.
|
||||
<div
|
||||
ref={bar}
|
||||
role="alert"
|
||||
className="pointer-events-auto fixed inset-x-0 top-0 z-[70] border-b border-black/40 bg-primary text-primary-foreground shadow-lg"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-wrap items-center gap-x-3 gap-y-1.5 px-4 py-2">
|
||||
<PauseIcon className="size-5 shrink-0" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-sm leading-snug font-semibold">
|
||||
@@ -199,7 +215,10 @@ function ControlSection() {
|
||||
|
||||
const add = () =>
|
||||
run('add', async () => {
|
||||
await desk.addBreakpoint(await parseAddress(ctx, text));
|
||||
const address = await parseAddress(ctx, text);
|
||||
// The library hands back the one already there, which on screen looks like the button did nothing.
|
||||
if (desk.breakpoints().some((b) => b.address === address)) throw new Error('There is already a breakpoint on that instruction. It is in the list below.');
|
||||
await desk.addBreakpoint(address);
|
||||
setText('');
|
||||
});
|
||||
|
||||
@@ -384,7 +403,9 @@ function StoppedView(props: { stop: DebugStop; run: Run; busy: string | null })
|
||||
const shown = useRef<{ lwpid: number; regs: Registers } | null>(null);
|
||||
|
||||
// A new stop always shows the thread that stopped.
|
||||
useEffect(() => setPicked(null), [stop.thread.lwpid, stop.rip, stop.cause.kind]);
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
}, [stop.thread.lwpid, stop.rip, stop.cause.kind]);
|
||||
const lwpid = picked ?? stop.thread.lwpid;
|
||||
const own = lwpid === stop.thread.lwpid;
|
||||
|
||||
@@ -505,7 +526,7 @@ function StoppedView(props: { stop: DebugStop; run: Run; busy: string | null })
|
||||
{!insn.invalid && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for ${insn.text}`}>
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for ${insn.text}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -684,7 +705,9 @@ function VectorGrid(props: { vectors: FpRegisters; lwpid: number }) {
|
||||
const [editing, setEditing] = useState<{ index: number; kind: 'float' | 'double' } | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => setLocal(props.vectors), [props.vectors]);
|
||||
useEffect(() => {
|
||||
setLocal(props.vectors);
|
||||
}, [props.vectors]);
|
||||
|
||||
const commit = async () => {
|
||||
if (!editing || !desk.session) return;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
-6
@@ -23,9 +23,9 @@ import { valueKind } from '../lib/scanner.ts';
|
||||
import type { ValueChoice } from '../lib/table.ts';
|
||||
import type { ConsoleVariable } from '../lib/unreal-cvars.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { copyAndSay } from './copy.ts';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
|
||||
// ---- hidden settings ---------------------------------------------------------------
|
||||
|
||||
@@ -58,6 +58,8 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
const [shortcuts, setShortcuts] = useState<ConsoleVariable[] | null>(null);
|
||||
const [found, setFound] = useState<ConsoleVariable[] | null>(null);
|
||||
const [added, setAdded] = useState<string | null>(null);
|
||||
/** Settings added so far, by name, so each row can say so itself: the notice is at the top and the row may be far down a long list. */
|
||||
const [addedNames, setAddedNames] = useState<Set<string>>(new Set());
|
||||
const [cheats, setCheats] = useState<Awaited<ReturnType<NonNullable<WithCheatManager['cheatManager']>>> | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
@@ -122,6 +124,7 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
if (choices) entry.choices = choices;
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
}
|
||||
setAddedNames((prev) => new Set(prev).add(v.name));
|
||||
setAdded(
|
||||
places.length > 1
|
||||
? `Added "${title}" to your cheats as ${places.length} entries, because the engine keeps the value in ${places.length} places. Switch them on together.`
|
||||
@@ -162,7 +165,7 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
</div>
|
||||
{usable ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void addAsCheat(v, shortcut)}>
|
||||
Add as a cheat
|
||||
{addedNames.has(v.name) ? 'Added. Add again' : 'Add as a cheat'}
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
@@ -174,7 +177,7 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
<DropdownMenuContent align="end">
|
||||
{v.address !== null && <DropdownMenuItem onSelect={() => wb.showMemory(v.address!)}>Show the value in Memory</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(v.object)}>Show its object in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(v.name)}>Copy name</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(v.name, 'name')}>Copy name</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -276,7 +279,9 @@ export function HiddenSettings(props: { helper: SettingsHelper; switcher: ReactN
|
||||
<Card>
|
||||
<CardContent>
|
||||
{found.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No setting has "{query}" in its name.</p>
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{query.trim() ? `No setting has "${query.trim()}" in its name. Try a shorter piece, such as fps, shadow or blur.` : 'This game\'s engine lists no settings.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{found.length >= 100 && <p className="pb-2 text-sm text-muted-foreground">Showing the first 100. Type more of the name for a shorter list.</p>}
|
||||
@@ -463,8 +468,8 @@ export function HiddenSwitches(props: { switcher: ReactNode }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(h.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(h.text)}>Copy text</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(h.address))}>Copy address</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(h.text, 'text')}>Copy text</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(fmtAddr(h.address), 'address')}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
+63
-28
@@ -21,6 +21,7 @@ import { ENGINE_TITLES, hasSettings, helperNotes } from '../lib/engines.ts';
|
||||
import { valueEntry } from '../lib/make.ts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { copyAndSay } from './copy.ts';
|
||||
import { useDebug } from './debughooks.tsx';
|
||||
import { HiddenSettings, HiddenSwitches } from './EngineHidden.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
@@ -38,7 +39,6 @@ function reportId(report: object | null): number {
|
||||
return id;
|
||||
}
|
||||
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
const hex = (n: number) => '0x' + n.toString(16).toUpperCase();
|
||||
|
||||
export function EngineTab() {
|
||||
@@ -130,13 +130,17 @@ function Summary() {
|
||||
}
|
||||
|
||||
const engine = helper ? helper.engine : (report?.guess.engine ?? 'unknown');
|
||||
// Something went wrong on the way, which is not the same as a game that names nothing.
|
||||
const failed = !helper && report?.failed === true;
|
||||
const notes = helperNotes(helper);
|
||||
const evidence = report?.guess.evidence ?? [];
|
||||
const steps = report?.steps ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{engine === 'unknown' ? 'This game\'s engine was not recognized' : `Built with ${ENGINE_TITLES[engine]}`}</CardTitle>
|
||||
<CardTitle>
|
||||
{failed ? 'Looking for this game\'s engine did not finish' : engine === 'unknown' ? 'This game\'s engine was not recognized' : `Built with ${ENGINE_TITLES[engine]}`}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{helper ? (
|
||||
<>
|
||||
@@ -144,6 +148,8 @@ function Summary() {
|
||||
can read them. So instead of a bare address you get{' '}
|
||||
{helper.engine === 'cpp' ? 'the kind of object that lives there.' : '"a Weapon, and this number is its fire rate".'}
|
||||
</>
|
||||
) : failed ? (
|
||||
'Reading the game was interrupted part of the way (the details are under "What was tried"). That says nothing about the game yet. Check that it is still running, then look again.'
|
||||
) : engine === 'unknown' ? (
|
||||
'Nothing that names classes or fields was found in this game. That is common, and nothing is wrong with the game or your console.'
|
||||
) : (
|
||||
@@ -160,7 +166,7 @@ function Summary() {
|
||||
)}
|
||||
{evidence.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">How we can tell</p>
|
||||
<p className="font-medium">{failed ? 'What happened' : 'How we can tell'}</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||
{evidence.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
@@ -195,7 +201,7 @@ function Summary() {
|
||||
</ul>
|
||||
</details>
|
||||
<div>
|
||||
<Button variant="outline" size="sm" onClick={() => void wb.detectEngine(true)}>
|
||||
<Button variant={failed ? 'default' : 'outline'} size="sm" onClick={() => void wb.detectEngine(true)}>
|
||||
Look again
|
||||
</Button>
|
||||
</div>
|
||||
@@ -264,6 +270,20 @@ function Classes(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<ClassInfo[] | null>(null);
|
||||
const [open, setOpen] = useState<{ info: ClassInfo; object?: ObjectInfo } | null>(null);
|
||||
const top = useRef<HTMLDivElement | null>(null);
|
||||
/** How far down the list the person was when they opened a class, to put them back there. */
|
||||
const cameFrom = useRef<number | null>(null);
|
||||
|
||||
// A class picked from far down a long list would otherwise open in the middle of its fields, title out of sight.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const el = top.current;
|
||||
if (el && el.getBoundingClientRect().top < 0) el.scrollIntoView();
|
||||
} else if (cameFrom.current !== null) {
|
||||
window.scrollTo(0, cameFrom.current);
|
||||
cameFrom.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const search = async () => {
|
||||
setBusy(true);
|
||||
@@ -294,19 +314,22 @@ function Classes(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<ClassDetail
|
||||
key={`${open.info.fullName}:${open.object?.address ?? ''}`}
|
||||
helper={helper}
|
||||
info={open.info}
|
||||
object={open.object}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onBack={() => {
|
||||
setOpen(null);
|
||||
setError(null);
|
||||
}}
|
||||
onOpen={openClass}
|
||||
/>
|
||||
<>
|
||||
<div ref={top} className="-mb-4 scroll-mt-20" />
|
||||
<ClassDetail
|
||||
key={`${open.info.fullName}:${open.object?.address ?? ''}`}
|
||||
helper={helper}
|
||||
info={open.info}
|
||||
object={open.object}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onBack={() => {
|
||||
setOpen(null);
|
||||
setError(null);
|
||||
}}
|
||||
onOpen={openClass}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,7 +380,10 @@ function Classes(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
type="button"
|
||||
className="flex w-full flex-col gap-0.5 rounded-sm py-2 text-left hover:bg-accent/40 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
disabled={busy}
|
||||
onClick={() => void openClass(c.address ?? c.fullName)}
|
||||
onClick={() => {
|
||||
cameFrom.current = window.scrollY;
|
||||
void openClass(c.address ?? c.fullName);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium break-all">{c.name}</span>
|
||||
<span className="text-sm break-all text-muted-foreground">
|
||||
@@ -433,13 +459,18 @@ function ClassDetail(props: {
|
||||
|
||||
const useAsCheat = async (f: FieldInfo, v: FieldValue) => {
|
||||
if (!picked || !v.kind) return;
|
||||
const address = picked.address + BigInt(f.offset);
|
||||
const entry = await valueEntry(wb.ctx, address, v.kind, v.input ?? '0');
|
||||
entry.name = `${picked.name ?? info.name}: ${f.name}`;
|
||||
if (entry.at.kind === 'address') {
|
||||
entry.note = `Found in the Engine tab (${info.name}.${f.name}). This address works until the game unloads the object or restarts; "Find what points to this" in Find can make it last.`;
|
||||
setError(null);
|
||||
try {
|
||||
const address = picked.address + BigInt(f.offset);
|
||||
const entry = await valueEntry(wb.ctx, address, v.kind, v.input ?? '0');
|
||||
entry.name = `${picked.name ?? info.name}: ${f.name}`;
|
||||
if (entry.at.kind === 'address') {
|
||||
entry.note = `Found in the Engine tab (${info.name}.${f.name}). This address works until the game unloads the object or restarts; "Make it survive restarts" in Find can make it last.`;
|
||||
}
|
||||
wb.edit(entry, true);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
wb.edit(entry, true);
|
||||
};
|
||||
|
||||
const follow = async (p: bigint) => {
|
||||
@@ -522,7 +553,7 @@ function ClassDetail(props: {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(o.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => debug.showObjects([o.address])}>Look at it in Debug</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(o.address))}>Copy address</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(fmtAddr(o.address), 'address')}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -600,8 +631,8 @@ function ClassDetail(props: {
|
||||
{value?.kind && <DropdownMenuItem onSelect={() => void useAsCheat(f, value)}>Use this as a cheat</DropdownMenuItem>}
|
||||
{value?.pointer !== undefined && <DropdownMenuItem onSelect={() => void follow(value.pointer!)}>See what it points to</DropdownMenuItem>}
|
||||
{address !== null && <DropdownMenuItem onSelect={() => wb.showMemory(address)}>Show in Memory</DropdownMenuItem>}
|
||||
{address !== null && <DropdownMenuItem onSelect={() => copy(fmtAddr(address))}>Copy address</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => copy(hex(f.offset))}>Copy offset</DropdownMenuItem>
|
||||
{address !== null && <DropdownMenuItem onSelect={() => void copyAndSay(fmtAddr(address), 'address')}>Copy address</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => void copyAndSay(hex(f.offset), 'offset')}>Copy offset</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -672,7 +703,11 @@ function Functions(props: { helper: EngineHelper; switcher: ReactNode }) {
|
||||
tries={['Damage', 'Money', 'Ammo', 'Cheat', 'God']}
|
||||
/>
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{found && !busy && found.length === 0 && <Notice kind="warn">No function has "{query}" in its name. Try a shorter piece or a different word.</Notice>}
|
||||
{found && !busy && found.length === 0 && (
|
||||
<Notice kind="warn">
|
||||
{query.trim() ? `No function has "${query.trim()}" in its name. Try a shorter piece or a different word.` : 'This game\'s engine lists no functions by name.'}
|
||||
</Notice>
|
||||
)}
|
||||
{found && !busy && found.length >= LIST_LIMIT && <Notice>Showing the first {LIST_LIMIT}. Type more of the name for a shorter list.</Notice>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+124
-22
@@ -58,6 +58,17 @@ function checkHex(text: string, what: string): void {
|
||||
if (!/^(0x)?[0-9a-fA-F]+$/.test(text.trim())) throw new Error(`${what} must be a hex number`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes a hex field holds, or an error in plain words. Stricter than
|
||||
* hexToBytes, which skips over anything that is not a hex digit: here a typo
|
||||
* ("9O" for "90") is refused instead of being quietly turned into other bytes.
|
||||
*/
|
||||
export function fieldBytes(text: string, what: string): Uint8Array {
|
||||
const clean = text.replace(/0x/gi, '').replace(/[\s,]+/g, '');
|
||||
if (!/^([0-9a-fA-F]{2})*$/.test(clean)) throw new Error(`${what} must be pairs of hex digits (0-9, A-F), like 90 90 90`);
|
||||
return hexToBytes(clean);
|
||||
}
|
||||
|
||||
export function checkLocation(loc: Location): void {
|
||||
switch (loc.kind) {
|
||||
case 'signature':
|
||||
@@ -78,6 +89,54 @@ export function checkLocation(loc: Location): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A box for a whole number that may be negative. It keeps what is typed as
|
||||
* text, so "-" on the way to "-4" is not wiped out by being read as a number.
|
||||
*/
|
||||
function WholeNumberInput(props: { label: string; value: number; onChange: (n: number) => void }) {
|
||||
const [text, setText] = useState(String(props.value));
|
||||
const shown = parseInt(text, 10) === props.value || (props.value === 0 && !/\d/.test(text)) ? text : String(props.value);
|
||||
return (
|
||||
<Input
|
||||
className="w-24 font-mono"
|
||||
aria-label={props.label}
|
||||
inputMode="text"
|
||||
value={shown}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value.replace(/[^0-9-]/g, '');
|
||||
setText(next);
|
||||
props.onChange(parseInt(next, 10) || 0);
|
||||
}}
|
||||
onBlur={() => setText(String(props.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A box for a list typed with commas or spaces between the items. It keeps
|
||||
* the text as typed while it is being typed; turning it into a list and back
|
||||
* on every key would swallow the comma or space the moment it is pressed.
|
||||
*/
|
||||
function ListInput(props: { id?: string; label?: string; placeholder: string; value: string[]; onChange: (items: string[]) => void }) {
|
||||
const split = (t: string) => t.split(/[\s,]+/).filter(Boolean);
|
||||
const [text, setText] = useState(props.value.join(', '));
|
||||
const shown = split(text).join('\n') === props.value.join('\n') ? text : props.value.join(', ');
|
||||
return (
|
||||
<Input
|
||||
id={props.id}
|
||||
className="font-mono"
|
||||
aria-label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
value={shown}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
props.onChange(split(e.target.value));
|
||||
}}
|
||||
onBlur={() => setText(props.value.join(', '))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationFields(props: { label: string; value: Location; onChange: (loc: Location) => void; kinds?: Location['kind'][] }) {
|
||||
const loc = props.value;
|
||||
const kinds = LOCATION_KINDS.filter((k) => !props.kinds || props.kinds.includes(k.id));
|
||||
@@ -101,7 +160,7 @@ export function LocationFields(props: { label: string; value: Location; onChange
|
||||
<Input className="font-mono" aria-label="Byte pattern" placeholder="89 81 ?? ?? 00 00" value={loc.pattern} onChange={(e) => props.onChange({ ...loc, pattern: e.target.value })} />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">Bytes past the match</span>
|
||||
<Input className="w-24 font-mono" aria-label="Bytes past the match" inputMode="numeric" value={String(loc.offset)} onChange={(e) => props.onChange({ ...loc, offset: parseInt(e.target.value, 10) || 0 })} />
|
||||
<WholeNumberInput label="Bytes past the match" value={loc.offset} onChange={(offset) => props.onChange({ ...loc, offset })} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Use ?? for bytes that can be anything. Keeps working after most game updates.</p>
|
||||
</>
|
||||
@@ -117,13 +176,7 @@ export function LocationFields(props: { label: string; value: Location; onChange
|
||||
<>
|
||||
<ModuleChoice loc={loc} onChange={props.onChange} />
|
||||
<Input className="font-mono" aria-label="Base offset" placeholder="Base offset, e.g. 2F4A10" value={loc.base} onChange={(e) => props.onChange({ ...loc, base: e.target.value })} />
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Pointer offsets"
|
||||
placeholder="Offsets, e.g. 0x8, 0x340"
|
||||
value={loc.offsets.join(', ')}
|
||||
onChange={(e) => props.onChange({ ...loc, offsets: e.target.value.split(/[\s,]+/).filter(Boolean) })}
|
||||
/>
|
||||
<ListInput label="Pointer offsets" placeholder="Offsets, e.g. 0x8, 0x340" value={loc.offsets} onChange={(offsets) => props.onChange({ ...loc, offsets })} />
|
||||
<p className="text-xs text-muted-foreground">Reads the pointer stored at game+base, then follows each offset. For values that move every launch.</p>
|
||||
</>
|
||||
)}
|
||||
@@ -183,7 +236,7 @@ export function HotkeyField(props: { value: string | undefined; onChange: (hotke
|
||||
readOnly
|
||||
// Phones would open their keyboard for nothing; a hotkey needs a real one.
|
||||
inputMode="none"
|
||||
placeholder="Click here, then press the keys"
|
||||
placeholder="Click, then press the keys"
|
||||
value={value ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
// Tab still moves on, and Escape still closes the dialog, when pressed alone.
|
||||
@@ -254,6 +307,29 @@ function ChoicesEditor(props: { value: ValueChoice[] | undefined; onChange: (cho
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an editor from being closed by accident with work in it. A click or
|
||||
* tap outside never closes it, and Escape with unsaved changes asks once
|
||||
* first: the second Escape (or Cancel) closes.
|
||||
*/
|
||||
export function useCloseGuard(dirty: boolean, close: () => void) {
|
||||
const [warned, setWarned] = useState(false);
|
||||
return {
|
||||
warned: warned && dirty,
|
||||
contentProps: {
|
||||
onInteractOutside: (e: Event) => e.preventDefault(),
|
||||
onEscapeKeyDown: (e: KeyboardEvent) => {
|
||||
if (!dirty || warned) return;
|
||||
e.preventDefault();
|
||||
setWarned(true);
|
||||
},
|
||||
},
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function EntryEditor() {
|
||||
const wb = useWorkbench();
|
||||
const editing = wb.editing;
|
||||
@@ -270,19 +346,34 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const info = TITLES[draft.type];
|
||||
const set = (patch: Partial<Entry>) => setDraft((d) => ({ ...d, ...patch }) as Entry);
|
||||
// Changing anything clears what Save last complained about; left up, it would look like it still applies.
|
||||
const set = (patch: Partial<Entry>) => {
|
||||
setError(null);
|
||||
setDraft((d) => ({ ...d, ...patch }) as Entry);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
if (!draft.name.trim()) throw new Error('Give it a name');
|
||||
if (!draft.name.trim()) {
|
||||
// The name box is at the top, possibly scrolled out of sight; go to it.
|
||||
document.getElementById('entry-name')?.focus();
|
||||
throw new Error('Give it a name');
|
||||
}
|
||||
switch (draft.type) {
|
||||
case 'patch':
|
||||
patchSites(draft).forEach((site, i) => {
|
||||
const which = i === 0 ? '' : ` for place ${i + 1}`;
|
||||
checkLocation(site.at);
|
||||
if (hexToBytes(site.on).length === 0) throw new Error(`Enter the bytes to write${which}`);
|
||||
if (site.off && hexToBytes(site.off).length === 0) throw new Error(`The original bytes${which} are not valid hex`);
|
||||
const on = fieldBytes(site.on, `The bytes when on${which}`);
|
||||
if (on.length === 0) throw new Error(`Enter the bytes to write${which}`);
|
||||
if (site.off) {
|
||||
const off = fieldBytes(site.off, `The original bytes${which}`);
|
||||
// Switching off writes the original bytes back over the new ones, so a different count would leave the game's code half restored.
|
||||
if (off.length !== on.length) {
|
||||
throw new Error(`The original bytes${which} must be as many as the bytes when on: ${on.length} there, ${off.length} here`);
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'value': {
|
||||
@@ -348,13 +439,15 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const guard = useCloseGuard(draft !== props.entry, wb.closeEditor);
|
||||
const groups = groupNames(wb.table);
|
||||
const keepWhyNot = keepProblem({ ...draft, unreviewed: undefined } as Entry);
|
||||
const keepable = draft.type === 'patch' || draft.type === 'value';
|
||||
// Not offered on a lesson's practice game: there is no PS5 behind it to keep anything on.
|
||||
const keepable = (draft.type === 'patch' || draft.type === 'value') && wb.session.target?.label !== 'Practice game';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && wb.closeEditor()}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<Dialog open onOpenChange={guard.onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl" {...guard.contentProps}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{props.isNew ? 'New' : 'Edit'}: {info.title}
|
||||
@@ -362,7 +455,15 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
<DialogDescription>{info.help}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* A form, so Enter in a one-line box saves, as people expect. */}
|
||||
<form
|
||||
id="entry-form"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!saving) void save();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="entry-name">Name</Label>
|
||||
<Input id="entry-name" value={draft.name} placeholder="Infinite ammo" onChange={(e) => set({ name: e.target.value })} />
|
||||
@@ -465,7 +566,7 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
<LocationFields label="Function" value={draft.fn} onChange={(fn) => set({ fn })} kinds={['signature', 'module', 'address']} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="entry-args">Arguments</Label>
|
||||
<Input id="entry-args" className="font-mono" placeholder="0x1F, 1" value={draft.args.join(', ')} onChange={(e) => set({ args: e.target.value.split(/[\s,]+/).filter(Boolean) })} />
|
||||
<ListInput id="entry-args" placeholder="0x1F, 1" value={draft.args} onChange={(args) => set({ args })} />
|
||||
<p className="text-xs text-muted-foreground">Up to six whole numbers, decimal or 0x hex, in the order the function takes them.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The function has to return within about two seconds. If it returns -1 the console takes that as a failure and
|
||||
@@ -492,7 +593,7 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
<Input
|
||||
id="entry-group"
|
||||
list="entry-groups"
|
||||
placeholder={groups.length ? 'Pick one of your groups, or type a new name' : 'e.g. Weapons'}
|
||||
placeholder={groups.length ? 'Pick a group, or type a new one' : 'e.g. Weapons'}
|
||||
maxLength={60}
|
||||
value={draft.group ?? ''}
|
||||
onChange={(e) => set({ group: e.target.value || undefined })}
|
||||
@@ -541,13 +642,14 @@ function EditorDialog(props: { entry: Entry; isNew: boolean }) {
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
</div>
|
||||
{guard.warned && <Notice kind="warn">You have changes that are not saved. Press Escape again, or Cancel, to close without saving them.</Notice>}
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={wb.closeEditor}>
|
||||
<Button type="button" variant="outline" onClick={wb.closeEditor}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={() => void save()}>
|
||||
<Button type="submit" form="entry-form" disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+208
-110
@@ -1,7 +1,7 @@
|
||||
// Find tab, "Group" mode: several values that sit close together in memory.
|
||||
// The searching itself is in lib/groupscan.ts.
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontalIcon, PlusIcon, XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,13 +10,16 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { fmtAddr, fmtBytes } from '../lib/bytes.ts';
|
||||
import { GROUP_MAX_DISTANCE, GROUP_MAX_MEMBERS, groupSearch } from '../lib/groupscan.ts';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { coverageWords, failedFeedback, groupFeedback } from '../lib/findfeedback.ts';
|
||||
import type { Feedback } from '../lib/findfeedback.ts';
|
||||
import { GROUP_MAX_DISTANCE, GROUP_MAX_MEMBERS, GroupTooBroadError, checkGroup, groupSearch } from '../lib/groupscan.ts';
|
||||
import type { GroupMember, GroupProgress, GroupResult } from '../lib/groupscan.ts';
|
||||
import { sliceRanges } from '../lib/regions.ts';
|
||||
import { ScanSession, VALUE_KINDS, decodeValue, valueKind } from '../lib/scanner.ts';
|
||||
import type { Scan } from '../lib/scanner.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { FeedbackNotice, SearchProgress, gameGone, useCopy, useProgress } from './findbits.tsx';
|
||||
import { WhereToSearch } from './WhereToSearch.tsx';
|
||||
import type { ScopeControl } from './WhereToSearch.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
@@ -32,6 +35,8 @@ interface Row {
|
||||
export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; onContinue: (scan: Scan, note: string) => void }) {
|
||||
const wb = useWorkbench();
|
||||
const { session, game } = wb;
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const nextId = useRef(2);
|
||||
const [rows, setRows] = useState<Row[]>([
|
||||
{ id: 0, kindId: 'u32', text: '' },
|
||||
@@ -39,29 +44,61 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
]);
|
||||
const [distance, setDistance] = useState('64');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [scopeText, setScopeText] = useState('');
|
||||
const [progress, setProgress] = useState<GroupProgress | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [runInfo, setRunInfo] = useState({ startedAt: 0, what: '' });
|
||||
const [progress, setProgress] = useProgress<GroupProgress>();
|
||||
const [result, setResult] = useState<{ res: GroupResult; members: GroupMember[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Something wrong with what was typed or chosen, shown next to it. */
|
||||
const [formError, setFormError] = useState<{ field: 'values' | 'where'; text: string } | null>(null);
|
||||
/** How the last search ended. Shown where the results are. */
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const [carryError, setCarryError] = useState<string | null>(null);
|
||||
const [carry, setCarry] = useState('0');
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
const busy = useRef(false);
|
||||
const { copy, toast } = useCopy();
|
||||
|
||||
const setRow = (id: number, patch: Partial<Row>) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
// A complaint about "Where to search" goes away as soon as that choice is changed.
|
||||
const scopeChoice = props.scope.choice;
|
||||
useEffect(() => {
|
||||
setFormError((e) => (e?.field === 'where' ? null : e));
|
||||
}, [scopeChoice]);
|
||||
|
||||
const run = async () => {
|
||||
setError(null);
|
||||
if (busy.current) return;
|
||||
setFormError(null);
|
||||
setCarryError(null);
|
||||
const members: GroupMember[] = rows.map((r) => ({ kind: valueKind(r.kindId), text: r.text }));
|
||||
const dist = distance.trim() === '' ? NaN : Number(distance.trim());
|
||||
try {
|
||||
checkGroup(members, dist);
|
||||
} catch (e) {
|
||||
setFormError({ field: 'values', text: errorText(e) });
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
const startedAt = Date.now();
|
||||
setRunning(true);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
setScopeText('');
|
||||
// The last search's results go now, so they cannot be mistaken for this one's.
|
||||
setResult(null);
|
||||
setFeedback(null);
|
||||
setRunInfo({ startedAt, what: "Reading the list of the game's memory…" });
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
const members: GroupMember[] = rows.map((r) => ({ kind: valueKind(r.kindId), text: r.text }));
|
||||
if (members.some((m) => m.text.trim() === '')) throw new Error('Fill in every value, or remove the empty line');
|
||||
const dist = Number(distance.trim());
|
||||
if (!Number.isInteger(dist) || dist <= 0) throw new Error('The distance is a whole number of bytes, such as 64');
|
||||
const where = await props.scope.resolve();
|
||||
setScopeText(`Searching ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)}`);
|
||||
let where: Awaited<ReturnType<ScopeControl['resolve']>>;
|
||||
try {
|
||||
where = await props.scope.resolve();
|
||||
} catch (e) {
|
||||
setFormError({ field: 'where', text: errorText(e) });
|
||||
return;
|
||||
}
|
||||
const cover = { bytes: where.bytes, total: where.total };
|
||||
setRunInfo({ startedAt, what: `Looking for ${members.map((m) => m.text.trim()).join(', ')} close together in ${coverageWords(cover)}` });
|
||||
const res = await groupSearch({
|
||||
dbg: () => session.scan,
|
||||
pid: game.pid,
|
||||
@@ -73,11 +110,38 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
});
|
||||
setResult({ res, members });
|
||||
setCarry('0');
|
||||
// A game that has just closed reads as empty memory, which looks exactly like "nothing found".
|
||||
if (res.hits.length === 0 && !res.canceled && (await gameGone(session, game)))
|
||||
setFeedback(failedFeedback(true, 'The game closed while the search was running', null, true));
|
||||
else
|
||||
setFeedback(
|
||||
groupFeedback(
|
||||
members.map((m) => m.text),
|
||||
dist,
|
||||
{
|
||||
groups: res.hits.length,
|
||||
anchorText: members[res.anchor].text.trim(),
|
||||
anchorMatches: res.anchorMatches,
|
||||
capped: res.capped,
|
||||
canceled: res.canceled,
|
||||
bytesSearched: res.bytesSearched,
|
||||
},
|
||||
cover,
|
||||
Date.now() - startedAt,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
if (e instanceof GroupTooBroadError) {
|
||||
const [title, ...tips] = e.message.split('. ');
|
||||
setFeedback({ tone: 'warn', title: `${title}. Nothing was kept.`, tips: [tips.join('. ')] });
|
||||
} else {
|
||||
setFeedback(failedFeedback(true, errorText(e), null, await gameGone(session, game)));
|
||||
}
|
||||
} finally {
|
||||
stop.current = null;
|
||||
busy.current = false;
|
||||
setRunning(false);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
@@ -95,7 +159,7 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
);
|
||||
props.onContinue(scan, `These are the ${member.text} values from a group search. Change the value in the game, then narrow down as usual.`);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setCarryError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,8 +172,8 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
{props.switcher}
|
||||
<CardTitle>Find values that sit together</CardTitle>
|
||||
<CardDescription>
|
||||
A game keeps things about one character or item side by side. Type two or more numbers you can see right now, such as
|
||||
health 100, armor 50 and ammo 30, to find the places where all of them are close together, in any order.
|
||||
A game keeps things about one character or item side by side. Type two or more numbers you can see right now, such as health 100, armor 50 and ammo 30, to
|
||||
find the places where all of them are close together, in any order.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
@@ -130,12 +194,18 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
</Select>
|
||||
<Input
|
||||
className="min-w-0 flex-1 font-mono"
|
||||
inputMode="decimal"
|
||||
inputMode="text"
|
||||
aria-label={`Value ${i + 1}`}
|
||||
placeholder={i === 0 ? '100' : i === 1 ? '50' : '30'}
|
||||
value={r.text}
|
||||
disabled={running}
|
||||
onChange={(e) => setRow(r.id, { text: e.target.value })}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setRow(r.id, { text: e.target.value });
|
||||
if (formError?.field === 'values') setFormError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !running) void run();
|
||||
}}
|
||||
@@ -166,131 +236,159 @@ export function GroupSearch(props: { switcher: ReactNode; scope: ScopeControl; o
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="group-distance">All within this many bytes</Label>
|
||||
<Label htmlFor={`${uid}group-distance`}>All within this many bytes</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="group-distance"
|
||||
id={`${uid}group-distance`}
|
||||
className="w-28 font-mono"
|
||||
inputMode="numeric"
|
||||
value={distance}
|
||||
disabled={running}
|
||||
onChange={(e) => setDistance(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDistance(e.target.value);
|
||||
if (formError?.field === 'values') setFormError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !running) void run();
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">64 suits most games. Up to {GROUP_MAX_DISTANCE.toLocaleString()}.</span>
|
||||
<span className="text-sm text-muted-foreground">64 suits most games. Up to {GROUP_MAX_DISTANCE.toLocaleString('en-US')}.</span>
|
||||
</div>
|
||||
</div>
|
||||
{formError?.field === 'values' && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{formError.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<WhereToSearch scope={props.scope} disabled={running} />
|
||||
{formError?.field === 'where' && <Notice kind="error">{formError.text}</Notice>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button disabled={running} onClick={() => void run()}>
|
||||
{running ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
{running && (
|
||||
<Button variant="outline" onClick={() => stop.current?.abort()}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={canceling}
|
||||
onClick={() => {
|
||||
setCanceling(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
{canceling ? 'Canceling…' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
{result && !running && (
|
||||
<span className="text-sm text-muted-foreground max-sm:basis-full" aria-live="polite">
|
||||
<strong className="text-foreground">{hits.length.toLocaleString()}</strong> {hits.length === 1 ? 'group' : 'groups'}
|
||||
<strong className="text-foreground">{hits.length.toLocaleString('en-US')}</strong> {hits.length === 1 ? 'group' : 'groups'}
|
||||
{hits.length > SHOWN ? `, showing the first ${SHOWN}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div>
|
||||
<PelletTrack fraction={progress ? progress.fraction : null} label="Group search progress" />
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{scopeText || 'Reading the memory map…'}
|
||||
{progress ? ` · ${progress.detail}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{result && !running && result.res.canceled && <Notice kind="warn">Canceled. The list below may be incomplete.</Notice>}
|
||||
{result && !running && result.res.capped && (
|
||||
<Notice kind="warn">
|
||||
Only the first {hits.length.toLocaleString()} groups were kept. Add another value or make the distance smaller to cut
|
||||
the list down.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && hits.length === 0 && !result.res.canceled && (
|
||||
<Notice kind="warn">
|
||||
No place holds all of these within {distance} bytes. One of them may be stored as a different type (try Float), or
|
||||
they may be further apart: try a bigger distance.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && hits.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="group-carry">Keep narrowing with</Label>
|
||||
<Select value={carry} onValueChange={setCarry}>
|
||||
<SelectTrigger id="group-carry" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{result.members.map((m, i) => (
|
||||
<SelectItem key={i} value={String(i)}>
|
||||
{m.text} ({m.kind.label.toLowerCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" onClick={carryOn}>
|
||||
Continue in Number search
|
||||
</Button>
|
||||
</div>
|
||||
<SearchProgress
|
||||
label="Group search progress"
|
||||
startedAt={runInfo.startedAt}
|
||||
what={runInfo.what}
|
||||
canceling={canceling}
|
||||
progress={
|
||||
progress && {
|
||||
fraction: progress.fraction,
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
unit: progress.step === 1 ? 'bytes' : 'places',
|
||||
stage: progress.step === 1 ? `Step 1 of 2: ${progress.detail.toLowerCase()}` : 'Step 2 of 2: checking what is next to each one',
|
||||
stageOnly: true,
|
||||
found:
|
||||
progress.step === 1
|
||||
? `in ${progress.anchors.toLocaleString('en-US')} ${progress.anchors === 1 ? 'place' : 'places'} so far`
|
||||
: `${progress.groups.toLocaleString('en-US')} ${progress.groups === 1 ? 'group' : 'groups'} so far`,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hits.length > 0 && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{hits.slice(0, SHOWN).map((g) => (
|
||||
<li key={g.members.map((m) => m.address.toString(16)).join()} className="flex items-start gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-mono text-xs sm:text-sm">{fmtAddr(g.base)}</span>
|
||||
<span className="text-xs text-muted-foreground">{g.span} bytes from first to last</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{g.members.map((m, i) => (
|
||||
<span key={i} className="font-mono text-sm">
|
||||
{decodeValue(m.kind, m.value)}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
{m.address === g.base ? 'here' : `+${Number(m.address - g.base)}`} · {m.kind.label.toLowerCase()}
|
||||
{feedback && !running && <FeedbackNotice feedback={feedback} />}
|
||||
|
||||
{result && hits.length > 0 && !running && (
|
||||
<Card aria-label="Results">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{carryError && <Notice kind="error">{carryError}</Notice>}
|
||||
{result && hits.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-2 max-sm:w-full">
|
||||
<Label htmlFor={`${uid}group-carry`}>Keep narrowing with</Label>
|
||||
<Select value={carry} onValueChange={setCarry}>
|
||||
<SelectTrigger id={`${uid}group-carry`} className="w-full sm:w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{result.members.map((m, i) => (
|
||||
<SelectItem key={i} value={String(i)}>
|
||||
{m.text} ({m.kind.label.toLowerCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" onClick={carryOn}>
|
||||
Continue in Number search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{hits.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{hits.slice(0, SHOWN).map((g) => (
|
||||
<li key={g.members.map((m) => m.address.toString(16)).join()} className="flex items-start gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-mono text-xs sm:text-sm">{fmtAddr(g.base)}</span>
|
||||
<span className="text-xs text-muted-foreground">{g.span} bytes from first to last</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{g.members.map((m, i) => (
|
||||
<span key={i} className="font-mono text-sm">
|
||||
{decodeValue(m.kind, m.value)}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
{m.address === g.base ? 'here' : `+${Number(m.address - g.base)}`} · {m.kind.label.toLowerCase()}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0" aria-label={`More for ${fmtAddr(g.base)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(g.base)}>Show in Memory</DropdownMenuItem>
|
||||
{g.members.map((m, i) => (
|
||||
<DropdownMenuItem key={i} onSelect={() => void navigator.clipboard?.writeText(fmtAddr(m.address)).catch(() => undefined)}>
|
||||
Copy the address of {decodeValue(m.kind, m.value)} ({fmtAddr(m.address)})
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0" aria-label={`More for ${fmtAddr(g.base)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(g.base)}>Show in Memory</DropdownMenuItem>
|
||||
{g.members.map((m, i) => (
|
||||
<DropdownMenuItem key={i} onSelect={() => copy(fmtAddr(m.address))}>
|
||||
Copy the address of {decodeValue(m.kind, m.value)} ({fmtAddr(m.address)})
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hits.length > SHOWN && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing the first {SHOWN} of {hits.length.toLocaleString('en-US')} groups. Add another value or make the distance smaller to cut the list down.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+194
-47
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LearnError, LessonLibrary, ProgressStore, answerMatches, emptyProgress, parseText } from '../lib/learn.ts';
|
||||
import { LearnError, LessonLibrary, ProgressStore, answerMatches, emptyProgress, goalParts, parseText } from '../lib/learn.ts';
|
||||
import type { Course, Lesson, LessonRef, LoadedIndex, PracticeStep, Progress, Span, Step } from '../lib/learn.ts';
|
||||
import { Ghost, Notice, Wordmark } from './bits.tsx';
|
||||
|
||||
@@ -115,8 +115,19 @@ export interface LearnPageProps {
|
||||
preferLocal: boolean;
|
||||
/** A console is connected behind this page: the way out leads back to it. */
|
||||
connected: boolean;
|
||||
/** The page is served by the PS5 itself, which keeps the learner's progress. A computer's bridge does not, and is not asked. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -126,23 +137,30 @@ 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(() => {
|
||||
let canceled = false;
|
||||
void store.load().then((p) => !canceled && setProgress(p));
|
||||
void store.load(props.onConsole).then((p) => !canceled && setProgress(p));
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
// Where the page is served from does not change while it is open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const latest = useRef<Progress | null>(null);
|
||||
latest.current = progress;
|
||||
const change = useCallback((update: (p: Progress) => Progress) => {
|
||||
setProgress((prev) => {
|
||||
const next = { ...update(prev ?? emptyProgress()), at: Date.now() };
|
||||
store.save(next);
|
||||
return next;
|
||||
});
|
||||
const next = { ...update(latest.current ?? emptyProgress()), at: Date.now() };
|
||||
// Kept here as well as in the state, so that two changes in one go build on each other.
|
||||
latest.current = next;
|
||||
setProgress(next);
|
||||
store.save(next);
|
||||
}, []);
|
||||
|
||||
// Nothing is downloaded until the user has said yes.
|
||||
@@ -160,7 +178,10 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
};
|
||||
}, [wanted, library, attempt]);
|
||||
|
||||
useEffect(() => window.scrollTo(0, 0), [open]);
|
||||
useEffect(() => {
|
||||
// A block body on purpose: newer browsers return a promise from scrollTo, and an effect may only return a cleanup function.
|
||||
window.scrollTo(0, 0);
|
||||
}, [open]);
|
||||
|
||||
const leave = props.connected ? 'Back to your PS5' : 'Back';
|
||||
let body: ReactNode;
|
||||
@@ -169,11 +190,19 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
} else if (progress.lessons === null) {
|
||||
body = <OptIn onLoad={() => change((p) => ({ ...p, lessons: 'on' }))} onDecline={() => change((p) => ({ ...p, lessons: 'off' }))} />;
|
||||
} else if (progress.lessons === 'off') {
|
||||
body = <SwitchedOff onLoad={() => change((p) => ({ ...p, lessons: 'on' }))} />;
|
||||
body = <SwitchedOff started={Object.keys(progress.done).length > 0} onLoad={() => change((p) => ({ ...p, lessons: 'on' }))} />;
|
||||
} else if (index.state === 'loading') {
|
||||
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);
|
||||
@@ -189,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 {
|
||||
@@ -208,6 +239,24 @@ export default function LearnPage(props: LearnPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading of a screen that replaced another one. It takes the focus when
|
||||
* it appears, because the button that led here has just gone: someone using a
|
||||
* keyboard or a screen reader carries on from the top of what is new, not
|
||||
* from nowhere.
|
||||
*/
|
||||
function Arrived(props: { className: string; children: ReactNode }) {
|
||||
const ref = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
return (
|
||||
<h2 ref={ref} tabIndex={-1} className={cn('outline-none', props.className)}>
|
||||
{props.children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function Waiting(props: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 py-10 text-sm text-muted-foreground" role="status">
|
||||
@@ -224,7 +273,7 @@ function OptIn(props: { onLoad: () => void; onDecline: () => void }) {
|
||||
<div className="flex items-start gap-5">
|
||||
<Ghost size={56} floaty look className="mt-1 max-sm:hidden" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-3xl leading-tight font-bold tracking-tight text-balance">Learn to make your own cheats</h2>
|
||||
<Arrived className="text-3xl leading-tight font-bold tracking-tight text-balance">Learn to make your own cheats</Arrived>
|
||||
<p className="max-w-[60ch] text-base leading-relaxed text-foreground/85">
|
||||
Twelve short lessons, from "what is a game's memory?" to cheats that survive a game update. Each comes with a small practice game that
|
||||
runs here in the page, so you can try every step without a real game and without any risk to one.
|
||||
@@ -234,7 +283,7 @@ function OptIn(props: { onLoad: () => void; onDecline: () => void }) {
|
||||
<div className="rounded-lg border bg-card p-5">
|
||||
<h3 className="font-semibold">What loading the lessons does</h3>
|
||||
<ul className="mt-3 ml-5 flex max-w-[62ch] list-disc flex-col gap-2 text-sm leading-relaxed text-foreground/85 marker:text-primary">
|
||||
<li>Lessons are downloaded from CheatSpecter's page on GitHub, so this device needs to be online.</li>
|
||||
<li>Lessons are downloaded from CheatSpecter's page on GitHub, so this device needs an internet connection.</li>
|
||||
<li>They are text and descriptions of practice games. Nothing in them can run, on your PS5 or in this page.</li>
|
||||
<li>Nothing is stored on your PS5 except a note of which lessons you have finished.</li>
|
||||
<li>You can switch them off again from the lesson list.</li>
|
||||
@@ -252,12 +301,13 @@ function OptIn(props: { onLoad: () => void; onDecline: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchedOff(props: { onLoad: () => void }) {
|
||||
function SwitchedOff(props: { started: boolean; onLoad: () => void }) {
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-4 py-10">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Lessons are switched off</h2>
|
||||
<Arrived className="text-2xl font-bold tracking-tight">Lessons are switched off</Arrived>
|
||||
<p className="max-w-[56ch] text-base leading-relaxed text-muted-foreground">
|
||||
Nothing has been downloaded, and nothing will be until you ask. Loading them fetches the lessons from CheatSpecter's page on GitHub.
|
||||
Nothing is downloaded until you ask. Loading them fetches the lessons from CheatSpecter's page on GitHub.
|
||||
{props.started ? ' What you have already done in them is remembered.' : ''}
|
||||
</p>
|
||||
<Button variant="outline" onClick={props.onLoad}>
|
||||
<DownloadIcon /> Load lessons
|
||||
@@ -266,19 +316,20 @@ function SwitchedOff(props: { onLoad: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** What went wrong with a download, as a heading: for all the lessons, or for one. */
|
||||
const PROBLEM = {
|
||||
unpublished: '{what} {are} not published yet',
|
||||
offline: '{what} could not be downloaded',
|
||||
newer: '{what} {need} a newer CheatSpecter',
|
||||
invalid: 'The download was not a lesson',
|
||||
} as const;
|
||||
|
||||
function DownloadFailed(props: { error: LearnError; onRetry: () => void; onSwitchOff: () => void }) {
|
||||
const { problem } = props.error;
|
||||
const title =
|
||||
problem === 'unpublished'
|
||||
? 'The lessons are not published yet'
|
||||
: problem === 'offline'
|
||||
? 'The lessons could not be downloaded'
|
||||
: problem === 'newer'
|
||||
? 'These lessons need a newer CheatSpecter'
|
||||
: 'The download was not a lesson';
|
||||
const title = PROBLEM[problem].replace('{what}', 'The lessons').replace('{are}', 'are').replace('{need}', 'need');
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-4 py-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
|
||||
<Arrived className="text-2xl font-bold tracking-tight">{title}</Arrived>
|
||||
<Notice kind={problem === 'unpublished' || problem === 'newer' ? 'info' : 'warn'} className="max-w-[62ch]">
|
||||
{props.error.message}
|
||||
</Notice>
|
||||
@@ -328,7 +379,7 @@ function LessonList(props: { loaded: LoadedIndex; progress: Progress; onOpen: (i
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Learn</h2>
|
||||
<Arrived className="text-3xl font-bold tracking-tight">Learn</Arrived>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{finished === 0
|
||||
? `${all.length} lessons. Start at the top; each one builds on the last.`
|
||||
@@ -408,6 +459,29 @@ function LessonRow(props: { lesson: LessonRef; number: number; state: 'new' | 's
|
||||
|
||||
// ---- one lesson ---------------------------------------------------------------------------
|
||||
|
||||
/** Goals that leave something behind for a later step: a list of matches, a caught instruction, a cheat, a changed value. */
|
||||
const LASTING = ['narrowed', 'caught', 'off', 'saved', 'value'];
|
||||
|
||||
/**
|
||||
* The practice steps that lead up to this one in the same game, earliest
|
||||
* first, that leave something behind: the steps whose results this one builds
|
||||
* on. Empty when the step starts from a new game anyway, or is the first to
|
||||
* use its game.
|
||||
*/
|
||||
function stepsBefore(lesson: Lesson, index: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = index; i > 0; ) {
|
||||
const here = lesson.steps[i] as PracticeStep;
|
||||
if (here.fresh) break;
|
||||
let before = i - 1;
|
||||
while (before >= 0 && lesson.steps[before].kind !== 'practice') before--;
|
||||
if (before < 0 || (lesson.steps[before] as PracticeStep).game !== here.game) break;
|
||||
if (goalParts((lesson.steps[before] as PracticeStep).goal).some((g) => LASTING.includes(g.check))) out.unshift(before);
|
||||
i = before;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function LessonView(props: {
|
||||
library: LessonLibrary;
|
||||
id: string;
|
||||
@@ -419,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);
|
||||
@@ -427,17 +505,21 @@ function LessonView(props: {
|
||||
/** Steps completed during this visit; what was done on an earlier one is in the saved progress. */
|
||||
const [done, setDone] = useState<Set<number>>(new Set());
|
||||
const saved = useRef(props.progress.done[props.id]);
|
||||
/** The practice step last shown. Its game stays alive, out of sight, through the readings and questions that follow it. */
|
||||
const [kept, setKept] = useState<{ index: number; step: PracticeStep } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setError(null);
|
||||
setLesson(null);
|
||||
props.library.loadLesson(props.id).then(
|
||||
(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))),
|
||||
);
|
||||
@@ -446,7 +528,37 @@ function LessonView(props: {
|
||||
};
|
||||
}, [props.library, props.id, attempt]);
|
||||
|
||||
useEffect(() => window.scrollTo(0, 0), [at]);
|
||||
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);
|
||||
// The button that brought the learner here is gone or far below: carry on from the top of the new step.
|
||||
heading.current?.focus({ preventScroll: true });
|
||||
}, [at, lesson]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = lesson?.steps[at];
|
||||
if (now?.kind === 'practice') setKept({ index: at, step: now });
|
||||
}, [lesson, at]);
|
||||
|
||||
const { id, onProgress } = props;
|
||||
const reach = useCallback(
|
||||
@@ -466,12 +578,13 @@ function LessonView(props: {
|
||||
);
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
{back}
|
||||
<Notice kind={error.problem === 'offline' ? 'warn' : 'info'}>{error.message}</Notice>
|
||||
<Button className="self-start" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</Button>
|
||||
<Arrived className="text-2xl font-bold tracking-tight">{PROBLEM[error.problem].replace('{what}', `Lesson ${props.number}`).replace('{are}', 'is').replace('{need}', 'needs')}</Arrived>
|
||||
<Notice kind={error.problem === 'unpublished' || error.problem === 'newer' ? 'info' : 'warn'} className="max-w-[62ch]">
|
||||
{error.message}
|
||||
</Notice>
|
||||
<Button onClick={() => setAttempt((n) => n + 1)}>Try again</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -487,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);
|
||||
};
|
||||
|
||||
@@ -496,9 +610,11 @@ function LessonView(props: {
|
||||
{back}
|
||||
<div className="flex flex-col items-start gap-4 py-6">
|
||||
<Ghost size={64} floaty />
|
||||
<h2 className="text-3xl leading-tight font-bold tracking-tight text-balance">Lesson {props.number} done</h2>
|
||||
<h2 ref={heading} tabIndex={-1} className="text-3xl leading-tight font-bold tracking-tight text-balance outline-none">
|
||||
Lesson {props.number} done
|
||||
</h2>
|
||||
<p className="max-w-[56ch] text-base leading-relaxed text-foreground/85">
|
||||
That was "{lesson.title}". {props.next ? 'The next lesson picks up from here.' : 'That was the last lesson. The rest is practice, on games you enjoy.'}
|
||||
{props.next ? `That was "${lesson.title}". The next lesson picks up from here.` : `"${lesson.title}" was the last lesson. The rest is practice, on games you enjoy.`}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{props.next && (
|
||||
@@ -521,6 +637,10 @@ function LessonView(props: {
|
||||
}
|
||||
|
||||
const step = lesson.steps[at];
|
||||
// A practice step stays on the page while a reading or a question that follows it is showing,
|
||||
// hidden, so the next practice step in the same game finds everything as the learner left it.
|
||||
const practice = step.kind === 'practice' ? { index: at, step } : kept;
|
||||
const needs = practice ? stepsBefore(lesson, practice.index) : [];
|
||||
const stepDone = step.kind === 'read' || step.kind === 'soon' || done.has(at);
|
||||
const kind = step.kind === 'practice' ? (step.challenge ? 'on your own' : 'try it yourself') : step.kind === 'read' ? 'read' : step.kind === 'soon' ? 'coming soon' : 'a question';
|
||||
|
||||
@@ -534,26 +654,42 @@ function LessonView(props: {
|
||||
<Trail total={total} done={at} label="Steps of this lesson" onPick={go} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl leading-tight font-bold tracking-tight text-balance">{step.title}</h2>
|
||||
<h2 ref={heading} tabIndex={-1} className="text-2xl leading-tight font-bold tracking-tight text-balance outline-none">
|
||||
{step.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Step {at + 1} of {total}, {kind}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step.kind === 'practice' ? (
|
||||
<Suspense fallback={<Waiting>Starting the practice game…</Waiting>}>
|
||||
{/* Keyed by game, not by step: the next step of the same game carries on where this one stopped. */}
|
||||
<LearnPractice key={step.game} lesson={lesson} step={step as PracticeStep} index={at} done={done.has(at)} onDone={() => complete(at)} onNext={() => go(at + 1)} />
|
||||
</Suspense>
|
||||
) : (
|
||||
{step.kind !== 'practice' && (
|
||||
<>
|
||||
{step.text && <LearnText text={step.text} />}
|
||||
{step.kind === 'choice' && <ChoiceStep key={at} step={step} done={done.has(at)} onDone={() => complete(at)} />}
|
||||
{step.kind === 'answer' && <AnswerStep key={at} step={step} done={done.has(at)} onDone={() => complete(at)} />}
|
||||
</>
|
||||
)}
|
||||
{practice && (
|
||||
<div hidden={step.kind !== 'practice'}>
|
||||
<Suspense fallback={<Waiting>Starting the practice game…</Waiting>}>
|
||||
{/* Keyed by game, not by step: the next step of the same game carries on where this one stopped. */}
|
||||
<LearnPractice
|
||||
key={practice.step.game}
|
||||
lesson={lesson}
|
||||
step={practice.step}
|
||||
index={practice.index}
|
||||
done={done.has(practice.index)}
|
||||
last={practice.index + 1 === total}
|
||||
needs={needs}
|
||||
onDone={() => complete(practice.index)}
|
||||
onNext={() => go(practice.index + 1)}
|
||||
onGoto={go}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-4 flex items-center gap-2 border-t pt-4">
|
||||
<footer className="mt-4 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<Button variant="outline" disabled={at === 0} onClick={() => go(at - 1)}>
|
||||
<ArrowLeftIcon /> Back
|
||||
</Button>
|
||||
@@ -596,14 +732,17 @@ function ChoiceStep(props: { step: Extract<Step, { kind: 'choice' }>; done: bool
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={picked === i}
|
||||
disabled={right}
|
||||
// Not `disabled`: that would drop the focus from the option just chosen.
|
||||
aria-disabled={right}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-input bg-input/30 p-4 text-left text-base leading-snug outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
!right && 'hover:border-primary/60',
|
||||
picked === i && (right ? 'border-primary bg-primary/10' : 'border-destructive/70'),
|
||||
right && 'cursor-default',
|
||||
right && picked !== i && 'opacity-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (right) return;
|
||||
setPicked(i);
|
||||
if (i === step.answer) props.onDone();
|
||||
}}
|
||||
@@ -627,6 +766,7 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
const [typed, setTyped] = useState(props.done ? step.accept[0] : '');
|
||||
const [checked, setChecked] = useState<boolean | null>(props.done ? true : null);
|
||||
const [tries, setTries] = useState(0);
|
||||
const box = useRef<HTMLInputElement | null>(null);
|
||||
const check = () => {
|
||||
const right = answerMatches(step.accept, typed);
|
||||
setChecked(right);
|
||||
@@ -638,7 +778,7 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (typed.trim()) check();
|
||||
if (typed.trim() && checked !== true) check();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="learn-answer" className="max-w-[68ch] text-lg leading-snug font-semibold">
|
||||
@@ -646,13 +786,14 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
</label>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input
|
||||
ref={box}
|
||||
id="learn-answer"
|
||||
className="h-11 font-mono text-base md:text-base"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={typed}
|
||||
disabled={checked === true}
|
||||
readOnly={checked === true}
|
||||
onChange={(e) => {
|
||||
setTyped(e.target.value);
|
||||
setChecked(null);
|
||||
@@ -664,7 +805,13 @@ function AnswerStep(props: { step: Extract<Step, { kind: 'answer' }>; done: bool
|
||||
</div>
|
||||
{checked !== null && <Verdict right={checked} explain={step.explain} />}
|
||||
{checked === false && tries >= 2 && (
|
||||
<button type="button" className="self-start text-sm font-medium text-primary underline-offset-4 hover:underline" onClick={() => (setTyped(step.accept[0]), setChecked(null))}>
|
||||
<button type="button" className="self-start text-sm font-medium text-primary underline-offset-4 hover:underline" onClick={() => {
|
||||
setTyped(step.accept[0]);
|
||||
setChecked(null);
|
||||
// This button goes away; the answer it filled in is where to carry on (Enter checks it).
|
||||
box.current?.focus();
|
||||
}}
|
||||
>
|
||||
Show the answer
|
||||
</button>
|
||||
)}
|
||||
|
||||
+175
-46
@@ -1,6 +1,6 @@
|
||||
// A practice step of a lesson: the goal, the real tools pointed at a practice
|
||||
// game, and the game itself docked at the bottom of the window where a thumb
|
||||
// reaches it.
|
||||
// game, and the game itself: beside the lesson in a wide window, and docked
|
||||
// at the bottom of a narrow one, where a thumb reaches it.
|
||||
//
|
||||
// The tools are the same components the Find, Memory, Code and Cheats tabs
|
||||
// use on a console. They get a Session whose other end is the practice game
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { ArrowRightIcon, CheckIcon, CircleIcon, DownloadIcon, LightbulbIcon, PlayIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, CircleIcon, DownloadIcon, LightbulbIcon, PlayIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
@@ -38,13 +38,6 @@ const NO_TOOLS = { pid: -1 } as GameContext;
|
||||
/** How often the goal is looked at when nothing in the game has said it changed (a cheat renamed, a list narrowed). */
|
||||
const LOOK_MS = 400;
|
||||
|
||||
/** A title ID for a practice game: stable per lesson and game, so "Your cheats" for it has somewhere to live while the lesson is open. */
|
||||
function practiceTitle(lesson: string, game: string): string {
|
||||
let h = 0;
|
||||
for (const ch of `${lesson}/${game}`) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0;
|
||||
return 'CSPR' + String(h % 100000).padStart(5, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops everything the tools kept in the browser about a practice game (its
|
||||
* cheats, saved pointer paths, where to search), so practice leaves nothing behind.
|
||||
@@ -69,8 +62,13 @@ export default function LearnPractice(props: {
|
||||
index: number;
|
||||
/** Already completed earlier in this visit. */
|
||||
done: boolean;
|
||||
/** This is the lesson's last step, so what follows is the end of the lesson. */
|
||||
last: boolean;
|
||||
/** The earlier steps this one builds on: what was found or made there is what it works with. Empty when it stands alone. */
|
||||
needs: number[];
|
||||
onDone: () => void;
|
||||
onNext: () => void;
|
||||
onGoto: (index: number) => void;
|
||||
}) {
|
||||
const { lesson, step } = props;
|
||||
const [engine, setEngine] = useState<Engine | null>(null);
|
||||
@@ -83,11 +81,19 @@ export default function LearnPractice(props: {
|
||||
const [link, setLink] = useState<{ session: Session; game: Game } | null>(null);
|
||||
const [tracker, setTracker] = useState<GoalTracker | null>(null);
|
||||
const [reached, setReached] = useState<boolean[]>([]);
|
||||
/** How far along the part being worked on is ("2 of 3"), and anything the learner needs telling about their search. */
|
||||
const [count, setCount] = useState<string | null>(null);
|
||||
const [advice, setAdvice] = useState<string | null>(null);
|
||||
/** The steps whose goal was reached in the game that is running now, to know whether it has what a later step builds on. */
|
||||
const [doneHere, setDoneHere] = useState<number[]>([]);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [hints, setHints] = useState(0);
|
||||
const [showing, setShowing] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const tools = useRef<Workbench | null>(null);
|
||||
/** The tool tab the learner was on, so a restart of the game does not throw them back to the step's first tab. */
|
||||
const lastTab = useRef<{ step: number; tab: TabId } | null>(null);
|
||||
const trackerRef = useRef<GoalTracker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -104,7 +110,7 @@ export default function LearnPractice(props: {
|
||||
const roomKey = `${step.fresh ? props.index : 'shared'}/${resets}`;
|
||||
useEffect(() => {
|
||||
if (!engine) return;
|
||||
const title = practiceTitle(lesson.id, step.game);
|
||||
const title = engine.practiceTitle(lesson.id, step.game);
|
||||
const def = lesson.games[step.game];
|
||||
// A new game starts with no cheats; nothing from an earlier visit is carried in.
|
||||
forget(title);
|
||||
@@ -114,11 +120,14 @@ export default function LearnPractice(props: {
|
||||
if (e.type === 'restart' || e.type === 'update') {
|
||||
setCrashed(null);
|
||||
setLaunch((n) => n + 1);
|
||||
// A list of matches, a caught instruction and a value changed by hand belong to the game that just closed. Cheats stay.
|
||||
setDoneHere((prev) => prev.filter((i) => goalParts((lesson.steps[i] as PracticeStep).goal).some((g) => g.check === 'saved' || g.check === 'off')));
|
||||
} else if (e.type === 'crash') {
|
||||
setCrashed(e.reason);
|
||||
}
|
||||
});
|
||||
setCrashed(null);
|
||||
setDoneHere([]);
|
||||
setRoom({ con, def });
|
||||
return () => {
|
||||
stop();
|
||||
@@ -182,21 +191,34 @@ export default function LearnPractice(props: {
|
||||
useEffect(() => {
|
||||
if (!engine || !bench) return;
|
||||
const t = new engine.GoalTracker(step.goal, bench);
|
||||
const index = props.index;
|
||||
const sync = () => {
|
||||
setReached(t.items.map((i) => i.done));
|
||||
if (t.done) onDone();
|
||||
if (!t.done) return;
|
||||
setDoneHere((prev) => (prev.includes(index) ? prev : [...prev, index]));
|
||||
onDone();
|
||||
};
|
||||
const look = async () => {
|
||||
await t.check();
|
||||
if (trackerRef.current !== t) return;
|
||||
setCount(t.progress);
|
||||
setAdvice(t.advice());
|
||||
};
|
||||
t.onChange = sync;
|
||||
trackerRef.current = t;
|
||||
setTracker(t);
|
||||
setLines([]);
|
||||
setHints(0);
|
||||
setProblem(null);
|
||||
setCount(null);
|
||||
setAdvice(null);
|
||||
sync();
|
||||
const timer = setInterval(() => void t.check(), LOOK_MS);
|
||||
void t.check();
|
||||
const timer = setInterval(() => void look(), LOOK_MS);
|
||||
void look();
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
t.stop();
|
||||
if (trackerRef.current === t) trackerRef.current = null;
|
||||
setTracker(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -209,16 +231,19 @@ export default function LearnPractice(props: {
|
||||
try {
|
||||
// Part by part, waiting each time until the goal itself has seen it done.
|
||||
for (let guard = 0; !tracker.done && guard <= tracker.items.length; guard++) {
|
||||
// The learner moved to another step while this was going: it is no longer theirs to watch.
|
||||
if (trackerRef.current !== tracker) return;
|
||||
const item = tracker.current!;
|
||||
await engine.showMe(item.goal, bench);
|
||||
for (const end = Date.now() + engine.patience(item.goal); !item.done && Date.now() < end; ) {
|
||||
await tracker.check();
|
||||
if (!item.done) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (trackerRef.current !== tracker) return;
|
||||
if (!item.done) throw new Error('That did not get there. Press "Start this step again" and try once more.');
|
||||
}
|
||||
} catch (e) {
|
||||
setProblem(errorText(e));
|
||||
if (trackerRef.current === tracker) setProblem(errorText(e));
|
||||
} finally {
|
||||
setShowing(false);
|
||||
}
|
||||
@@ -228,11 +253,30 @@ export default function LearnPractice(props: {
|
||||
|
||||
const parts = goalParts(step.goal);
|
||||
const allDone = reached.length > 0 && reached.every(Boolean);
|
||||
const current = parts[Math.max(0, reached.indexOf(false))];
|
||||
const at = Math.max(0, reached.indexOf(false));
|
||||
const current = parts[at];
|
||||
const ready = !!link && !crashed;
|
||||
const next = props.last ? 'Finish the lesson' : 'Next step';
|
||||
// A step that builds on earlier ones, in a game where those were not done: the learner skipped
|
||||
// ahead, came back to the lesson later, or started the step again. A challenge always starts from nothing.
|
||||
const missing = allDone || step.challenge || !room ? undefined : props.needs.find((i) => !doneHere.includes(i));
|
||||
const behind = missing === undefined ? null : { index: missing, title: lesson.steps[missing].title };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{behind && (
|
||||
<Notice>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
This step carries on from step {behind.index + 1}, "{behind.title}", and that one has not been done in the practice game as it is running now. Go back and
|
||||
work forward from there, or do the same here before you follow the text below.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => props.onGoto(behind.index)}>
|
||||
<ArrowLeftIcon /> Go to step {behind.index + 1}
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
<LearnText text={step.text} />
|
||||
|
||||
<section aria-label="Your goal" className={cn('flex flex-col gap-3 rounded-lg border bg-card p-4', allDone && 'border-primary')}>
|
||||
@@ -241,7 +285,10 @@ export default function LearnPractice(props: {
|
||||
{parts.map((part, i) => (
|
||||
<li key={i} className={cn('flex items-start gap-2.5 text-sm leading-snug', reached[i] ? 'text-foreground' : 'text-foreground/80')}>
|
||||
{reached[i] ? <CheckIcon className="mt-0.5 size-4 shrink-0 text-primary" aria-label="Done" /> : <CircleIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-label="Not done yet" />}
|
||||
<span>{part.label}</span>
|
||||
<span>
|
||||
{part.label}
|
||||
{i === at && count && !allDone && <span className="whitespace-nowrap text-muted-foreground tabular-nums"> ({count} so far)</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -250,11 +297,17 @@ export default function LearnPractice(props: {
|
||||
<div className="flex flex-col items-start gap-3" role="status">
|
||||
<LearnText text={step.done} className="text-[0.95em]" />
|
||||
<Button onClick={props.onNext}>
|
||||
Next step <ArrowRightIcon />
|
||||
{next} <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advice && !allDone && !showing && (
|
||||
<p className="max-w-[68ch] border-l-2 border-destructive py-0.5 pl-3 text-sm leading-relaxed text-foreground/85" role="status">
|
||||
{advice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step.hints.slice(0, hints).map((hint, i) => (
|
||||
<p key={i} className="flex max-w-[68ch] gap-2.5 border-l-2 border-primary py-0.5 pl-3 text-sm leading-relaxed text-foreground/85">
|
||||
<span className="shrink-0 font-semibold text-primary">Hint {i + 1}</span>
|
||||
@@ -300,12 +353,12 @@ export default function LearnPractice(props: {
|
||||
|
||||
{link && room && !crashed ? (
|
||||
<WorkbenchProvider key={link.game.pid} session={link.session} game={link.game} gameName={room.def.name} practice>
|
||||
<PracticeTools tools={tools} tool={step.tool} step={props.index} session={link.session} game={link.game} />
|
||||
<PracticeTools tools={tools} lastTab={lastTab} tool={step.tool} step={props.index} session={link.session} game={link.game} />
|
||||
</WorkbenchProvider>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-dashed p-6 text-sm text-muted-foreground" role="status">
|
||||
{crashed ? (
|
||||
'The tools have nothing to look at: the practice game is not running. Restart it below.'
|
||||
'The tools have nothing to look at: the practice game is not running. Press "Restart game" on its panel.'
|
||||
) : (
|
||||
<>
|
||||
<Spinner className="size-4" /> Starting the practice game…
|
||||
@@ -314,7 +367,7 @@ export default function LearnPractice(props: {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{room && <GameDock con={room.con} def={room.def} crashed={crashed} goal={allDone ? null : current.label} onNext={allDone ? props.onNext : undefined} />}
|
||||
{room && <GameDock con={room.con} def={room.def} crashed={crashed} goal={allDone ? null : current.label} count={allDone ? null : count} next={next} onNext={allDone ? props.onNext : undefined} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -328,15 +381,35 @@ const TOOLS: { id: Tool; label: string }[] = [
|
||||
{ id: 'cheats', label: 'Your cheats' },
|
||||
];
|
||||
|
||||
function PracticeTools(props: { tools: MutableRefObject<Workbench | null>; tool: Tool; step: number; session: Session; game: Game }) {
|
||||
function PracticeTools(props: {
|
||||
tools: MutableRefObject<Workbench | null>;
|
||||
lastTab: MutableRefObject<{ step: number; tab: TabId } | null>;
|
||||
tool: Tool;
|
||||
step: number;
|
||||
session: Session;
|
||||
game: Game;
|
||||
}) {
|
||||
const wb = useWorkbench();
|
||||
// The lesson's "Show me" and goal checks reach the tools through this.
|
||||
props.tools.current = wb;
|
||||
useEffect(() => () => void (props.tools.current = null), [props.tools]);
|
||||
|
||||
// Each step opens on the tool it is about; after that the learner (and the tools' own "show in Memory" links) decide.
|
||||
// The tools are made anew when the game restarts, and then stay on the tab the learner was looking at.
|
||||
const { setTab } = wb;
|
||||
useEffect(() => setTab(props.tool), [props.tool, props.step]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const { lastTab } = props;
|
||||
const asked = useRef<TabId | null>(null);
|
||||
useEffect(() => {
|
||||
const was = lastTab.current;
|
||||
asked.current = was && was.step === props.step ? was.tab : props.tool;
|
||||
setTab(asked.current);
|
||||
}, [props.tool, props.step]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
// Not before the tab asked for above is the one showing: until then this is the tools' own starting tab, not the learner's choice.
|
||||
if (asked.current !== null && wb.tab !== asked.current) return;
|
||||
asked.current = null;
|
||||
lastTab.current = { step: props.step, tab: wb.tab };
|
||||
}, [lastTab, props.step, wb.tab]);
|
||||
|
||||
const tab: Tool = TOOLS.some((t) => t.id === wb.tab) ? (wb.tab as Tool) : props.tool;
|
||||
return (
|
||||
@@ -416,7 +489,7 @@ function HudValue(props: { hud: HudDef; value: number }) {
|
||||
const max = hud.max ?? 100;
|
||||
return (
|
||||
<div
|
||||
className="h-4 w-36 max-w-full overflow-hidden rounded-sm border border-primary/50 bg-background sm:w-48"
|
||||
className="h-4 w-36 max-w-full overflow-hidden rounded-sm border border-primary/50 bg-background sm:w-48 xl:w-full"
|
||||
role="meter"
|
||||
aria-label={hud.label}
|
||||
aria-valuemin={0}
|
||||
@@ -429,17 +502,24 @@ function HudValue(props: { hud: HudDef; value: number }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-2xl leading-none font-bold tabular-nums">
|
||||
<span className="font-mono text-2xl leading-none font-bold tabular-nums [@media(max-height:560px)_and_(max-width:1279px)]:text-lg">
|
||||
{hud.show === 'time' ? clock(value) : value.toLocaleString('en-US', { minimumFractionDigits: hud.digits ?? 0, maximumFractionDigits: hud.digits ?? 0, useGrouping: false })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The game's panel has three shapes, all from the classes below:
|
||||
// - beside the lesson, in a window wide enough to have room next to the text (Tailwind's xl, 1280px);
|
||||
// - docked at the bottom of anything narrower, over the end of the page;
|
||||
// - and docked but one line high when the window is short (a phone on its side, or one whose
|
||||
// keyboard has pushed the page up), so that it never takes the room the tools need.
|
||||
// SHORT is written out in full each time because the class names have to appear whole in the source.
|
||||
|
||||
/**
|
||||
* The practice game: what it shows, and its buttons. It stays at the bottom
|
||||
* of the window while the lesson and the tools scroll above it.
|
||||
* The practice game: what it shows, and its buttons. It stays in view while
|
||||
* the lesson and the tools scroll.
|
||||
*/
|
||||
const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: GameDef; crashed: string | null; goal: string | null; onNext?: () => void }) {
|
||||
const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: GameDef; crashed: string | null; goal: string | null; count: string | null; next: string; onNext?: () => void }) {
|
||||
const { con, def } = props;
|
||||
const { game } = con;
|
||||
// Redrawn whenever the game says something happened, and a few times a second for what changes it from outside.
|
||||
@@ -453,41 +533,83 @@ const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: Game
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [con]);
|
||||
|
||||
// Docked at the bottom, the panel lies over the last lines of the window. The browser is told
|
||||
// how tall it is, so that a box taking the focus (or anything else scrolled into view) stops above it.
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const root = document.documentElement;
|
||||
const apply = () => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
const docked = height > 0 && getComputedStyle(el).position === 'sticky';
|
||||
root.style.scrollPaddingBottom = docked ? `${Math.ceil(height) + 12}px` : '';
|
||||
};
|
||||
// Not every browser keeps to that when the box is already partly in view, so the same is done by hand.
|
||||
const clear = (e: FocusEvent) => {
|
||||
const target = e.target instanceof Element ? e.target : null;
|
||||
if (!target || el.contains(target) || getComputedStyle(el).position !== 'sticky') return;
|
||||
const under = target.getBoundingClientRect().bottom + 12 - el.getBoundingClientRect().top;
|
||||
// Only what lies under the panel: something below it altogether is the end of the page, where the panel is not over anything.
|
||||
if (under > 0 && target.getBoundingClientRect().top < el.getBoundingClientRect().bottom) window.scrollBy(0, under);
|
||||
};
|
||||
apply();
|
||||
const watch = typeof ResizeObserver === 'function' ? new ResizeObserver(apply) : null;
|
||||
watch?.observe(el);
|
||||
window.addEventListener('resize', apply);
|
||||
document.addEventListener('focusin', clear);
|
||||
return () => {
|
||||
watch?.disconnect();
|
||||
window.removeEventListener('resize', apply);
|
||||
document.removeEventListener('focusin', clear);
|
||||
root.style.scrollPaddingBottom = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const press = useCallback((id: string) => con.press(id), [con]);
|
||||
const buttons = def.actions.filter((a) => !a.hidden && !a.every);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
aria-label={`Practice game: ${def.name}`}
|
||||
className="sticky bottom-0 z-30 -mx-4 mt-2 flex flex-col gap-2.5 border-t-2 border-primary bg-[#0c0c0b] px-4 pt-2.5 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-12px_24px_rgba(0,0,0,0.6)] sm:mx-0 sm:rounded-t-lg sm:border-x"
|
||||
className={cn(
|
||||
'sticky bottom-0 z-30 -mx-4 mt-2 flex flex-col gap-2.5 border-t-2 border-primary bg-[#0c0c0b] px-4 pt-2.5 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-8px_16px_rgba(0,0,0,0.5)] sm:mx-0 sm:rounded-t-lg sm:border-x',
|
||||
'xl:fixed xl:top-20 xl:bottom-auto xl:left-[calc(50%+25rem)] xl:mt-0 xl:max-h-[calc(100dvh-6rem)] xl:w-[calc(50%-26rem)] xl:max-w-80 xl:overflow-y-auto xl:rounded-lg xl:border-2 xl:pb-3 xl:shadow-none',
|
||||
'[@media(max-height:560px)_and_(max-width:1279px)]:flex-row [@media(max-height:560px)_and_(max-width:1279px)]:flex-wrap [@media(max-height:560px)_and_(max-width:1279px)]:items-center [@media(max-height:560px)_and_(max-width:1279px)]:gap-x-3 [@media(max-height:560px)_and_(max-width:1279px)]:gap-y-1.5 [@media(max-height:560px)_and_(max-width:1279px)]:pt-1.5 [@media(max-height:560px)_and_(max-width:1279px)]:pb-[calc(0.375rem+env(safe-area-inset-bottom))]',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span className="font-semibold text-primary">Practice game</span>
|
||||
<span className="text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs [@media(max-height:560px)_and_(max-width:1279px)]:order-3">
|
||||
<span className="font-semibold text-primary [@media(max-height:560px)_and_(max-width:1279px)]:hidden">Practice game</span>
|
||||
<span className="text-muted-foreground [@media(max-height:560px)_and_(max-width:1279px)]:hidden">
|
||||
{def.name}
|
||||
{game.updates > 0 ? `, version ${game.version}` : ''}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="flex-1 [@media(max-height:560px)_and_(max-width:1279px)]:hidden" />
|
||||
{def.update && game.updates === 0 && (
|
||||
<Button variant="ghost" size="xs" onClick={() => con.update()}>
|
||||
<Button variant="ghost" size="xs" className="h-8" onClick={() => con.update()}>
|
||||
<DownloadIcon /> Install game update
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={() => con.restart()}>
|
||||
<Button variant="ghost" size="xs" className="h-8" onClick={() => con.restart()}>
|
||||
<RotateCcwIcon /> Restart game
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.crashed ? (
|
||||
<div className="flex flex-col gap-1 py-1" role="alert">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1" role="alert">
|
||||
<p className="font-semibold text-destructive">The practice game crashed</p>
|
||||
<p className="max-w-[68ch] text-sm leading-relaxed text-foreground/85">{props.crashed} Nothing is harmed: press "Restart game" and it is back as it shipped.</p>
|
||||
<Button size="sm" className="mt-1 self-start" onClick={() => con.restart()}>
|
||||
<RotateCcwIcon /> Restart game
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
|
||||
<dl className="flex flex-wrap items-end gap-x-6 gap-y-2">
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-x-6 gap-y-3 xl:flex-col xl:items-stretch [@media(max-height:560px)_and_(max-width:1279px)]:flex-1 [@media(max-height:560px)_and_(max-width:1279px)]:items-center [@media(max-height:560px)_and_(max-width:1279px)]:gap-x-4">
|
||||
<dl className="flex flex-wrap items-end gap-x-6 gap-y-2 [@media(max-height:560px)_and_(max-width:1279px)]:gap-x-4">
|
||||
{def.hud.map((hud) => (
|
||||
<div key={hud.field} className="flex flex-col gap-1">
|
||||
<div key={hud.field} className="flex flex-col gap-1 [@media(max-height:560px)_and_(max-width:1279px)]:gap-0">
|
||||
<dt className="text-xs text-muted-foreground">{hud.label}</dt>
|
||||
<dd>
|
||||
<HudValue hud={hud} value={game.get(hud.field)} />
|
||||
@@ -497,7 +619,11 @@ const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: Game
|
||||
</dl>
|
||||
<div className="flex min-w-[min(100%,12rem)] flex-1 flex-wrap justify-end gap-2">
|
||||
{buttons.map((a) => (
|
||||
<Button key={a.id} className="h-11 min-w-28 flex-1 px-4 text-base font-semibold whitespace-normal sm:flex-none" onClick={() => press(a.id)}>
|
||||
<Button
|
||||
key={a.id}
|
||||
className="h-auto min-h-11 min-w-28 flex-1 px-4 py-1.5 text-base leading-tight font-semibold whitespace-normal sm:flex-none xl:flex-1 [@media(max-height:560px)_and_(max-width:1279px)]:min-h-9 [@media(max-height:560px)_and_(max-width:1279px)]:min-w-0 [@media(max-height:560px)_and_(max-width:1279px)]:px-3 [@media(max-height:560px)_and_(max-width:1279px)]:py-1 [@media(max-height:560px)_and_(max-width:1279px)]:text-sm"
|
||||
onClick={() => press(a.id)}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
@@ -507,18 +633,21 @@ const GameDock = memo(function GameDock(props: { con: PracticeConsole; def: Game
|
||||
|
||||
{/* The goal, where it can be seen without scrolling back up. */}
|
||||
{props.onNext ? (
|
||||
<div className="flex items-center gap-2 border-t pt-2 text-sm">
|
||||
<div className="flex items-center gap-2 border-t pt-2 text-sm xl:flex-wrap xl:gap-y-2 [@media(max-height:560px)_and_(max-width:1279px)]:order-4 [@media(max-height:560px)_and_(max-width:1279px)]:border-t-0 [@media(max-height:560px)_and_(max-width:1279px)]:pt-0" role="status">
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 flex-1 font-medium">Goal reached</span>
|
||||
<Button size="sm" onClick={props.onNext}>
|
||||
Next step <ArrowRightIcon />
|
||||
<span className="min-w-0 flex-1 font-medium whitespace-nowrap [@media(max-height:560px)_and_(max-width:1279px)]:sr-only">Goal reached</span>
|
||||
<Button size="sm" className="xl:w-full" onClick={props.onNext}>
|
||||
{props.next} <ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
props.goal && (
|
||||
<p className="flex items-start gap-2 border-t pt-2 text-xs leading-snug text-muted-foreground">
|
||||
<p className="flex items-start gap-2 border-t pt-2 text-xs leading-snug text-muted-foreground [@media(max-height:560px)_and_(max-width:1279px)]:hidden">
|
||||
<CircleIcon className="mt-px size-3.5 shrink-0" />
|
||||
<span className="min-w-0">{props.goal}</span>
|
||||
<span className="min-w-0">
|
||||
{props.goal}
|
||||
{props.count && <span className="whitespace-nowrap tabular-nums"> ({props.count} so far)</span>}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
+39
-13
@@ -49,7 +49,10 @@ export function LibraryView(props: {
|
||||
const [query, setQuery] = useState('');
|
||||
const [confirm, setConfirm] = useState<LibraryTitle | null>(null);
|
||||
const [starting, setStarting] = useState<string | null>(null);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
/** What went wrong starting a game, and which one, so it can be said on that game's row. */
|
||||
const [startError, setStartError] = useState<{ id: string; text: string } | null>(null);
|
||||
/** The game that was asked for has still not shown up after a good while. */
|
||||
const [slow, setSlow] = useState(false);
|
||||
const [startedId, setStartedId] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
@@ -95,6 +98,14 @@ export function LibraryView(props: {
|
||||
if (startedId && running?.titleId === startedId) setStartedId(null);
|
||||
}, [running?.titleId, startedId]);
|
||||
|
||||
// A game normally shows up within seconds. If it has not after a minute, say so instead of "Starting" forever.
|
||||
useEffect(() => {
|
||||
setSlow(false);
|
||||
if (!startedId) return;
|
||||
const timer = setTimeout(() => setSlow(true), 60_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [startedId]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (state.name !== 'ready') return [];
|
||||
const needle = query.trim().toLowerCase();
|
||||
@@ -114,11 +125,13 @@ export function LibraryView(props: {
|
||||
setStartedId(title.id);
|
||||
props.onStarted?.();
|
||||
} catch (e) {
|
||||
setStartError(
|
||||
e instanceof LibraryUnavailable
|
||||
? 'The CheatSpecter payload on the console stopped answering. Send cheatspecter.elf to the PS5 again.'
|
||||
: `${metas[title.id]?.name ?? title.id} did not start. ${errorText(e)}`,
|
||||
);
|
||||
setStartError({
|
||||
id: title.id,
|
||||
text:
|
||||
e instanceof LibraryUnavailable
|
||||
? 'The CheatSpecter payload on the console stopped answering. Send cheatspecter.elf to the PS5 again.'
|
||||
: `${metas[title.id]?.name ?? title.id} did not start. ${errorText(e)}`,
|
||||
});
|
||||
} finally {
|
||||
setStarting(null);
|
||||
}
|
||||
@@ -170,9 +183,6 @@ export function LibraryView(props: {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{startError && <Notice kind="error">{startError}</Notice>}
|
||||
{startedId && <Notice>Starting {metas[startedId]?.name ?? startedId}. It will appear here as soon as it is running.</Notice>}
|
||||
|
||||
{state.titles.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">The console did not report any installed games.</p>
|
||||
) : rows.length === 0 ? (
|
||||
@@ -183,12 +193,11 @@ export function LibraryView(props: {
|
||||
const isRunning = running?.titleId === title.id;
|
||||
const size = formatSize(title.size);
|
||||
return (
|
||||
<li key={title.id} className="flex items-center gap-3 p-2.5">
|
||||
<li key={title.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2.5">
|
||||
<TitleIcon target={target} id={title.id} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-medium" title={name || title.id}>
|
||||
{name || title.id}
|
||||
</span>
|
||||
{/* Wraps rather than cutting off: on a phone there is no hovering to read the rest. */}
|
||||
<span className="font-medium break-words">{name || title.id}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Pill className={title.platform === 'PS5' ? 'border-primary/60 text-xs text-primary' : 'text-xs'} variant="outline">
|
||||
{title.platform}
|
||||
@@ -203,6 +212,11 @@ export function LibraryView(props: {
|
||||
</div>
|
||||
{isRunning ? (
|
||||
<span className="shrink-0 px-2 text-sm font-medium text-primary">Running</span>
|
||||
) : startedId === title.id && !slow ? (
|
||||
// Said on the row that was pressed: in a long list, a note at the top would be out of sight.
|
||||
<span className="flex shrink-0 items-center gap-1.5 px-2 text-sm font-medium text-primary" role="status">
|
||||
<Spinner className="size-4" /> Starting…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -215,6 +229,18 @@ export function LibraryView(props: {
|
||||
{starting === title.id ? <Spinner className="size-4" /> : <PlayIcon />} Start
|
||||
</Button>
|
||||
)}
|
||||
{startedId === title.id && (
|
||||
<p className="basis-full text-sm text-muted-foreground" role="status">
|
||||
{slow
|
||||
? 'The PS5 accepted it, but the game has not shown up yet. Look at the PS5: it may be asking you something, or the game may still be loading.'
|
||||
: 'The PS5 is starting it. This page moves on by itself as soon as the game is running.'}
|
||||
</p>
|
||||
)}
|
||||
{startError?.id === title.id && (
|
||||
<p className="basis-full text-sm text-destructive" role="alert">
|
||||
{startError.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
+91
-29
@@ -1,7 +1,8 @@
|
||||
// A live hex view of the game's memory, with a panel that reads the selected
|
||||
// bytes as the usual number types and lets them be changed.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -9,8 +10,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fmtAddr, hexToBytes, view } from '../lib/bytes.ts';
|
||||
import { imageRange, parseAddress } from '../lib/locations.ts';
|
||||
import { fmtAddr, parseTypedBytes, view } from '../lib/bytes.ts';
|
||||
import { imageRange, parseAddress, readableAt } from '../lib/locations.ts';
|
||||
import { valueEntry } from '../lib/make.ts';
|
||||
import { PROT } from '../lib/ps5debug.ts';
|
||||
import { VALUE_KINDS, decodeValue, encodeValue, valueKind } from '../lib/scanner.ts';
|
||||
@@ -19,6 +20,8 @@ import { useDebug } from './debughooks.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
const REFRESH_MS = 1000;
|
||||
/** The last address a page can start at: addresses are 64 bits. */
|
||||
const TOP = 0xffff_ffff_ffff_ffffn;
|
||||
|
||||
function useColumns(): number {
|
||||
const [cols, setCols] = useState(() => (typeof window !== 'undefined' && window.innerWidth < 640 ? 8 : 16));
|
||||
@@ -34,6 +37,8 @@ function useColumns(): number {
|
||||
|
||||
export function MemoryTab() {
|
||||
const wb = useWorkbench();
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const { ctx } = wb;
|
||||
const { desk, showObjects } = useDebug();
|
||||
const cols = useColumns();
|
||||
@@ -44,22 +49,36 @@ export function MemoryTab() {
|
||||
const [changed, setChanged] = useState<Set<number>>(new Set());
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** What was wrong with the address typed. Kept apart from `error`, which every successful refresh clears. */
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
/** Whether the game has memory where the page is; reads of nothing come back as zeros. */
|
||||
const [mapped, setMapped] = useState<'all' | 'part' | 'none'>('all');
|
||||
const [wrote, setWrote] = useState<string | null>(null);
|
||||
const [kindId, setKindId] = useState('u32');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newBytes, setNewBytes] = useState('');
|
||||
const [writeError, setWriteError] = useState<string | null>(null);
|
||||
const previous = useRef<Uint8Array | null>(null);
|
||||
// Read by `go` without making it a new function when the phone is turned, which would replay the last "show this address" request.
|
||||
const lengthRef = useRef(length);
|
||||
lengthRef.current = length;
|
||||
|
||||
const go = useCallback(
|
||||
(address: bigint, select = true) => {
|
||||
// Kept inside what an address can be, so paging past either end stops there.
|
||||
const top = TOP - BigInt(lengthRef.current) + 1n;
|
||||
const wanted = address < 0n ? 0n : address > top ? top : address;
|
||||
// Rows start on a 16-byte boundary so addresses are easy to read down the side.
|
||||
const start = address & ~0xfn;
|
||||
const start = wanted & ~0xfn;
|
||||
setBase(start);
|
||||
setText(fmtAddr(address));
|
||||
if (select) setSelected(Number(address - start));
|
||||
setText(fmtAddr(wanted));
|
||||
if (select) setSelected(Number(wanted - start));
|
||||
previous.current = null;
|
||||
setData(null);
|
||||
setError(null);
|
||||
setAddressError(null);
|
||||
setWriteError(null);
|
||||
setWrote(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -97,7 +116,11 @@ export function MemoryTab() {
|
||||
try {
|
||||
// Where a breakpoint sits the game holds a marker byte; show what the game's code really is.
|
||||
const bytes = desk.unpatch(base, await ctx.dbg().read(ctx.pid, base, length));
|
||||
// The memory map is in hand already (asking again every second would be wasteful); a page that looks empty is worth one fresh look.
|
||||
let there = await readableAt(ctx, base, length);
|
||||
if (there !== 'all') there = await readableAt(ctx, base, length, true);
|
||||
if (stopped) return;
|
||||
setMapped(there);
|
||||
const prev = previous.current;
|
||||
const diff = new Set<number>();
|
||||
if (prev && prev.length === bytes.length) for (let i = 0; i < bytes.length; i++) if (prev[i] !== bytes[i]) diff.add(i);
|
||||
@@ -121,7 +144,7 @@ export function MemoryTab() {
|
||||
try {
|
||||
go(await parseAddress(ctx, text));
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setAddressError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,13 +158,16 @@ export function MemoryTab() {
|
||||
const write = async (bytes: Uint8Array) => {
|
||||
if (selAddress === null) return;
|
||||
setWriteError(null);
|
||||
setWrote(null);
|
||||
try {
|
||||
if (mapped === 'none') throw new Error('The game has no memory at this address, so there is nothing to change.');
|
||||
if (desk.breakpointsIn(selAddress, bytes.length).length > 0) {
|
||||
throw new Error('There is a breakpoint inside these bytes. Remove it on the Debug tab first, or the first byte of what you write is undone when the breakpoint goes.');
|
||||
}
|
||||
// Through the workbench, which remembers what was there so "Restore everything" can put it back.
|
||||
await wb.write(selAddress, bytes);
|
||||
previous.current = null;
|
||||
setWrote(`Wrote ${bytes.length} ${bytes.length === 1 ? 'byte' : 'bytes'} at ${fmtAddr(selAddress)}. "Restore everything" on the Cheats tab puts back what was there.`);
|
||||
} catch (e) {
|
||||
setWriteError(errorText(e));
|
||||
}
|
||||
@@ -151,28 +177,62 @@ export function MemoryTab() {
|
||||
try {
|
||||
void write(encodeValue(kind, newValue));
|
||||
} catch (e) {
|
||||
setWrote(null);
|
||||
setWriteError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const writeRaw = () => {
|
||||
try {
|
||||
const bytes = hexToBytes(newBytes);
|
||||
if (bytes.length === 0) throw new Error('Enter some bytes, for example: 90 90 90');
|
||||
void write(bytes);
|
||||
void write(parseTypedBytes(newBytes));
|
||||
} catch (e) {
|
||||
setWrote(null);
|
||||
setWriteError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveCheat = async () => {
|
||||
const bytes = at(kind.size);
|
||||
if (!bytes || selAddress === null) return;
|
||||
setWriteError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const entry = await valueEntry(ctx, selAddress, kind, decodeValue(kind, bytes));
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
wb.edit(entry, false);
|
||||
} catch (e) {
|
||||
setWriteError(errorText(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = data ? Array.from({ length: data.length / cols }, (_, r) => r) : [];
|
||||
|
||||
// The grid is one stop for the Tab key (the selected byte), and the arrow
|
||||
// keys move within it. Without this a keyboard user had to tab through
|
||||
// every byte on the page to reach the panel below.
|
||||
const grid = useRef<HTMLTableElement | null>(null);
|
||||
const onGridKey = (e: KeyboardEvent) => {
|
||||
const step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : e.key === 'ArrowUp' ? -cols : e.key === 'ArrowDown' ? cols : 0;
|
||||
if (step === 0) return;
|
||||
e.preventDefault();
|
||||
const next = sel + step;
|
||||
if (next < 0 || next >= length) return;
|
||||
setSelected(next);
|
||||
grid.current?.querySelector<HTMLButtonElement>(`button[data-index="${next}"]`)?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Memory</CardTitle>
|
||||
<CardDescription>The game's memory as it is right now. Bytes that just changed are highlighted. Click one to read or change it.</CardDescription>
|
||||
<CardDescription>
|
||||
The game's memory as it is right now. Bytes that just changed are highlighted. Click one to read or change it.
|
||||
<span className="max-sm:hidden"> The arrow keys move from byte to byte.</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<form
|
||||
@@ -184,18 +244,26 @@ export function MemoryTab() {
|
||||
>
|
||||
<Input className="font-mono" aria-label="Address" placeholder="Address, or game+1A2B3C" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<Button type="submit">Go</Button>
|
||||
<Button type="button" variant="outline" size="icon" aria-label="Previous page" disabled={base === null} onClick={() => base !== null && go(base - BigInt(length), false)}>
|
||||
<Button type="button" variant="outline" size="icon" aria-label="Previous page" disabled={base === null || base === 0n} onClick={() => base !== null && go(base - BigInt(length), false)}>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="icon" aria-label="Next page" disabled={base === null} onClick={() => base !== null && go(base + BigInt(length), false)}>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</form>
|
||||
{addressError && <Notice kind="error">{addressError}</Notice>}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{data && mapped !== 'all' && (
|
||||
<Notice kind="warn">
|
||||
{mapped === 'none'
|
||||
? 'The game has no memory at this address. The zeros below are placeholders, not something the game holds.'
|
||||
: 'The game has memory at only part of this page. Zeros outside it are placeholders, not something the game holds.'}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{data && base !== null && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="font-mono text-xs leading-6 sm:text-sm" aria-label="Memory bytes">
|
||||
<table ref={grid} className="font-mono text-xs leading-8 sm:text-sm sm:leading-6" aria-label="Memory bytes" onKeyDown={onGridKey}>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r}>
|
||||
@@ -209,8 +277,11 @@ export function MemoryTab() {
|
||||
type="button"
|
||||
aria-label={`Byte at ${fmtAddr(base + BigInt(i))}`}
|
||||
aria-pressed={i === sel}
|
||||
data-index={i}
|
||||
tabIndex={i === sel ? 0 : -1}
|
||||
className={cn(
|
||||
'rounded-sm px-[3px] hover:bg-accent',
|
||||
// Wider on a phone, where a finger has to hit one byte.
|
||||
'rounded-sm px-[7px] hover:bg-accent sm:px-[3px]',
|
||||
c === 8 && 'ml-2',
|
||||
data[i] === 0 && 'text-muted-foreground/60',
|
||||
changed.has(i) && 'text-primary',
|
||||
@@ -272,7 +343,7 @@ export function MemoryTab() {
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mem-value">Change it as a number</Label>
|
||||
<Label htmlFor={`${uid}mem-value`}>Change it as a number</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={kindId} onValueChange={setKindId}>
|
||||
<SelectTrigger className="w-36 shrink-0" aria-label="Number type">
|
||||
@@ -286,16 +357,16 @@ export function MemoryTab() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input id="mem-value" className="min-w-0 font-mono" value={newValue} onChange={(e) => setNewValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeValue()} />
|
||||
<Input id={`${uid}mem-value`} className="min-w-0 font-mono" value={newValue} onChange={(e) => setNewValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeValue()} />
|
||||
<Button variant="outline" disabled={!newValue.trim()} onClick={writeValue}>
|
||||
Write
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mem-bytes">Or as raw bytes</Label>
|
||||
<Label htmlFor={`${uid}mem-bytes`}>Or as raw bytes</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="mem-bytes" className="min-w-0 font-mono" placeholder="90 90 90" value={newBytes} onChange={(e) => setNewBytes(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeRaw()} />
|
||||
<Input id={`${uid}mem-bytes`} className="min-w-0 font-mono" placeholder="90 90 90" value={newBytes} onChange={(e) => setNewBytes(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && writeRaw()} />
|
||||
<Button variant="outline" disabled={!newBytes.trim()} onClick={writeRaw}>
|
||||
Write
|
||||
</Button>
|
||||
@@ -303,22 +374,13 @@ export function MemoryTab() {
|
||||
</div>
|
||||
</div>
|
||||
{writeError && <Notice kind="error">{writeError}</Notice>}
|
||||
{wrote && !writeError && <Notice>{wrote}</Notice>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => wb.startTrace(selAddress, kind.size as 1 | 2 | 4 | 8, 'write')}>
|
||||
Find what writes to this
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const bytes = at(kind.size);
|
||||
if (!bytes) return;
|
||||
const entry = await valueEntry(ctx, selAddress, kind, decodeValue(kind, bytes));
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
wb.edit(entry, false);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={saving || !at(kind.size)} onClick={() => void saveCheat()}>
|
||||
Save to your cheats
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => wb.showCode(selAddress)}>
|
||||
|
||||
+112
-56
@@ -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,39 +111,23 @@ 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
|
||||
? '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)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const exported = (name: string, out: { included: number; skipped: number }) =>
|
||||
`Exported ${out.included} ${out.included === 1 ? 'cheat' : 'cheats'} as ${name}.` +
|
||||
(out.skipped
|
||||
? ` ${out.skipped} ${out.skipped === 1 ? 'was' : 'were'} left out: that format only holds patches at a fixed offset with known original bytes.`
|
||||
: '');
|
||||
|
||||
const exportCommunity = async () => {
|
||||
const out = exportCommunityTable(wb.fileTable());
|
||||
if (out.included === 0) {
|
||||
@@ -139,7 +135,7 @@ export function OwnCheats() {
|
||||
return;
|
||||
}
|
||||
const name = await saveToDevice(`${wb.game.titleId}_${wb.game.version || 'unknown'}.json`, out.text, 'GoldHEN / etaHEN cheats');
|
||||
if (name) say(out.skipped ? `Exported ${out.included}. ${out.skipped} could not be written in that format and were left out.` : `Exported as ${name}.`);
|
||||
if (name) say(exported(name, out));
|
||||
};
|
||||
|
||||
// The same cheats in the two older formats cheat menus read. Named the way the community collections name them.
|
||||
@@ -150,7 +146,7 @@ export function OwnCheats() {
|
||||
return;
|
||||
}
|
||||
const name = await saveToDevice(`${wb.game.titleId}_${wb.game.version || 'unknown'}.${kind}`, out.text, `${kind.toUpperCase()} cheats`);
|
||||
if (name) say(out.skipped ? `Exported ${out.included}. ${out.skipped} could not be written in that format and were left out.` : `Exported as ${name}.`);
|
||||
if (name) say(exported(name, out));
|
||||
};
|
||||
|
||||
const setGroup = (ids: string[], group: string | undefined) =>
|
||||
@@ -158,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);
|
||||
@@ -179,27 +179,28 @@ export function OwnCheats() {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>Your cheats</CardTitle>
|
||||
<CardDescription>
|
||||
{table.entries.length === 0
|
||||
? 'Cheats you make or import appear here. The quickest way to make one: find a value, ask what writes to it, and disable that.'
|
||||
: where}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* The description sits under the title and buttons, not beside them: on a phone the buttons would squeeze it into a sliver. */}
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-x-2 gap-y-1.5">
|
||||
<CardTitle>Your cheats</CardTitle>
|
||||
<CardDescription className="order-last basis-full break-words">
|
||||
{table.entries.length === 0
|
||||
? 'Cheats you make or import appear here. The quickest way to make one: find a value, ask what writes to it, and disable that.'
|
||||
: 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>
|
||||
@@ -241,16 +242,16 @@ 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 || misfit) && (
|
||||
{otherVersion && (
|
||||
<Notice kind="warn">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
@@ -287,6 +288,22 @@ export function OwnCheats() {
|
||||
onMove={(entry) => setGroupTask({ kind: 'move', entry })}
|
||||
/>
|
||||
))}
|
||||
{/* Under the list, not above it: it appears when a switch is pressed, and above would push the switches down under the finger. */}
|
||||
{misfit && !otherVersion && (
|
||||
<Notice kind="warn">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{otherVersion
|
||||
? `These cheats were made on v${table.version} of the game, and you are running v${wb.game.version}. An update moves the game's code, so some may have stopped working.`
|
||||
: 'A cheat no longer fits this game. That usually means the game was updated and its code moved.'}{' '}
|
||||
CheatSpecter can look for each one's new place. Nothing in the game is changed while it looks.
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setRefinding(true)}>
|
||||
Re-find my cheats
|
||||
</Button>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
{hasHotkeys && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hotkeys work while this page is open and in front, on the device showing it. They do not work from the controller.
|
||||
@@ -464,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> = {
|
||||
@@ -503,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') {
|
||||
@@ -550,7 +603,8 @@ function EntryRow(props: { entry: Entry; onGroup: () => void }) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={'truncate text-xs text-muted-foreground' + (plain ? '' : ' font-mono')} title={describeEntry(entry)}>
|
||||
{/* Wraps instead of being cut off: a phone has no hovering to read the rest. */}
|
||||
<div className={'text-xs break-words text-muted-foreground' + (plain ? '' : ' font-mono')} title={describeEntry(entry)}>
|
||||
{how}
|
||||
</div>
|
||||
{choices && !entry.unreviewed && <div className="mt-1.5 sm:hidden">{picker}</div>}
|
||||
@@ -602,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>
|
||||
@@ -625,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>
|
||||
@@ -700,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>;
|
||||
}
|
||||
}
|
||||
+59
-12
@@ -7,7 +7,7 @@
|
||||
// candidates lead to its new address. The candidates are kept per game in the
|
||||
// browser so that works across the restart.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -73,19 +73,35 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
const [checking, setChecking] = useState<string | null>(null);
|
||||
const [checked, setChecked] = useState<Checked | null>(null);
|
||||
const [notSaved, setNotSaved] = useState(false);
|
||||
const [canceled, setCanceled] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
|
||||
// If the dialog goes away while a search runs (the game closed), the search should not carry on behind it.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const search = async () => {
|
||||
setError(null);
|
||||
setPaths(null);
|
||||
setChecked(null);
|
||||
setIsStatic(false);
|
||||
setCanceled(false);
|
||||
setStopping(false);
|
||||
setRunning(true);
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
if (await moduleLocation(wb.ctx, props.address)) {
|
||||
// Already inside a module: its offset is all that is needed.
|
||||
setIsStatic(true);
|
||||
return;
|
||||
}
|
||||
const found = await findPointerPaths(wb.ctx, props.address, { maxDepth: Number(depth) }, setStatus);
|
||||
const found = await findPointerPaths(wb.ctx, props.address, { maxDepth: Number(depth), signal: controller.signal }, setStatus);
|
||||
setCanceled(controller.signal.aborted);
|
||||
setPaths(found);
|
||||
if (found.length > 0) {
|
||||
// Kept for the check after a restart; that check is what separates real paths from lucky ones.
|
||||
@@ -96,7 +112,9 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
stop.current = null;
|
||||
setRunning(false);
|
||||
setStopping(false);
|
||||
setStatus('');
|
||||
}
|
||||
};
|
||||
@@ -122,10 +140,15 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
};
|
||||
|
||||
const use = async (path?: PointerPath) => {
|
||||
const entry = await valueEntry(wb.ctx, props.address, props.kind, props.value, path?.location);
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
props.onClose();
|
||||
wb.edit(entry, false);
|
||||
setError(null);
|
||||
try {
|
||||
const entry = await valueEntry(wb.ctx, props.address, props.kind, props.value, path?.location);
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
props.onClose();
|
||||
wb.edit(entry, false);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const busy = running || checking !== null;
|
||||
@@ -233,12 +256,31 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant={saved.length > 0 ? 'outline' : 'default'} disabled={busy} onClick={() => void search()}>
|
||||
{running ? <Spinner className="size-4" /> : null}
|
||||
{running ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
{running ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={stopping}
|
||||
onClick={() => {
|
||||
setStopping(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
{stopping ? 'Stopping…' : 'Cancel'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={saved.length > 0 ? 'outline' : 'default'} disabled={busy} onClick={() => void search()}>
|
||||
Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{running && <p className="text-sm text-muted-foreground">{status || 'Starting…'} Each level scans the game's memory again.</p>}
|
||||
{running && (
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{stopping
|
||||
? 'Stopping after the part of memory being searched right now. On a large game that can take up to a minute.'
|
||||
: `${status || 'Starting…'} Each level scans the game's memory again, which can take minutes on a large game.`}
|
||||
</p>
|
||||
)}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
|
||||
{isStatic && (
|
||||
@@ -250,7 +292,12 @@ export function PointerFinder(props: { address: bigint; kind: ValueKind; value:
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{paths && paths.length === 0 && (
|
||||
{paths && canceled && (
|
||||
<Notice kind="warn">
|
||||
Canceled before all of memory was searched{paths.length > 0 ? '. What was found up to then is listed below.' : ', and nothing had been found yet.'}
|
||||
</Notice>
|
||||
)}
|
||||
{paths && paths.length === 0 && !canceled && (
|
||||
<Notice kind="warn">
|
||||
No path found at this depth. Try one level deeper, or find what writes to the value and patch that code instead, which does not depend on
|
||||
where the value lives.
|
||||
|
||||
+107
-33
@@ -2,7 +2,7 @@
|
||||
// happen in the game, copy it again and list what changed. The comparing is in
|
||||
// lib/memdiff.ts.
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useId, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,11 +14,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { fmtAddr, fmtBytes } from '../lib/bytes.ts';
|
||||
import { parseAddress } from '../lib/locations.ts';
|
||||
import { SNAPSHOT_MAX_BYTES, diffHits, diffSnapshots, takeSnapshot } from '../lib/memdiff.ts';
|
||||
import { failedFeedback } from '../lib/findfeedback.ts';
|
||||
import type { Feedback } from '../lib/findfeedback.ts';
|
||||
import { SNAPSHOT_MAX_BYTES, SnapshotCanceledError, diffHits, diffSnapshots, takeSnapshot } from '../lib/memdiff.ts';
|
||||
import type { DiffFilter, MemSnapshot } from '../lib/memdiff.ts';
|
||||
import { ScanSession, VALUE_KINDS, decodeValue, valueKind } from '../lib/scanner.ts';
|
||||
import type { Scan } from '../lib/scanner.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { FeedbackNotice, SearchProgress, gameGone, useCopy } from './findbits.tsx';
|
||||
import type { ScopeControl } from './WhereToSearch.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
|
||||
@@ -31,6 +34,8 @@ const clock = (ms: number) => new Date(ms).toLocaleTimeString();
|
||||
export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl; onContinue: (scan: Scan, note: string) => void }) {
|
||||
const wb = useWorkbench();
|
||||
const { session, game, ctx } = wb;
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const [where, setWhere] = useState(AROUND);
|
||||
const [address, setAddress] = useState('');
|
||||
const [size, setSize] = useState(String(64 * 1024));
|
||||
@@ -41,9 +46,15 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
const [kindId, setKindId] = useState('u32');
|
||||
const [filter, setFilter] = useState<DiffFilter>('changed');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fraction, setFraction] = useState<number | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [run, setRun] = useState<{ startedAt: number; what: string; bytes: number; fraction: number | null }>({ startedAt: 0, what: '', bytes: 0, fraction: null });
|
||||
/** Something wrong with the area or address chosen, shown next to it. */
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** How the last read ended when it did not produce a snapshot. */
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
const working = useRef(false);
|
||||
const { copy, toast } = useCopy();
|
||||
|
||||
// Whole areas small enough to snapshot in one go: the game's own data, a library's data, a named buffer.
|
||||
const areas = useMemo(
|
||||
@@ -58,23 +69,57 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
if (!area) throw new Error('That area is no longer there. Choose another one.');
|
||||
return { start: area.ranges[0].start, length: area.bytes };
|
||||
}
|
||||
const at = await parseAddress(ctx, address);
|
||||
if (address.trim() === '')
|
||||
throw new Error('Type the address to look around. You can copy one from a search result: open its menu (the three dots) and choose "Copy address".');
|
||||
let at: bigint;
|
||||
try {
|
||||
at = await parseAddress(ctx, address);
|
||||
} catch (e) {
|
||||
// "game+1A2B" and module names have their own, clearer messages.
|
||||
if (address.includes('+')) throw e;
|
||||
throw new Error(`"${address.trim()}" is not an address. An address looks like 0x880012340; copy one from a search result or the Memory tab.`);
|
||||
}
|
||||
const length = Number(size);
|
||||
// Center the block on the address, on a 16-byte boundary so every number type lines up.
|
||||
const half = BigInt(length / 2);
|
||||
const start = (at > half ? at - half : 0n) & ~0xfn;
|
||||
// Memory that does not exist reads as zeros, which would look like "nothing changed" forever.
|
||||
const end = start + BigInt(length);
|
||||
const items = await props.scope.refresh();
|
||||
if (!items.some((i) => i.ranges.some((r) => r.start < end && start < r.end))) {
|
||||
throw new Error(`The game has no memory at ${fmtAddr(at)}. Check the address; it should be one shown in a search result or in the Memory tab.`);
|
||||
}
|
||||
return { start, length };
|
||||
};
|
||||
|
||||
const snap = async () => {
|
||||
if (working.current) return;
|
||||
setError(null);
|
||||
setFeedback(null);
|
||||
let t: { start: bigint; length: number };
|
||||
try {
|
||||
t = await target();
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
return;
|
||||
}
|
||||
working.current = true;
|
||||
const startedAt = Date.now();
|
||||
const info = { startedAt, what: `Reading ${fmtBytes(t.length)} from ${fmtAddr(t.start)}`, bytes: t.length };
|
||||
setBusy(true);
|
||||
setFraction(null);
|
||||
setCanceling(false);
|
||||
setRun({ ...info, fraction: null });
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
const t = await target();
|
||||
const taken = await takeSnapshot(() => session.main, game.pid, t.start, t.length, setFraction, controller.signal);
|
||||
const taken = await takeSnapshot(
|
||||
() => session.main,
|
||||
game.pid,
|
||||
t.start,
|
||||
t.length,
|
||||
(fraction) => setRun({ ...info, fraction }),
|
||||
controller.signal,
|
||||
);
|
||||
if (!first) {
|
||||
setFirst(taken);
|
||||
} else {
|
||||
@@ -82,11 +127,21 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
setLatest(taken);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
const keptWords = !first
|
||||
? 'No snapshot was taken.'
|
||||
: latest
|
||||
? 'Your earlier snapshots are unchanged; the comparison below is still the last one.'
|
||||
: 'Your first snapshot is still there.';
|
||||
if (e instanceof SnapshotCanceledError) setFeedback({ tone: 'warn', title: `Canceled. ${keptWords}`, tips: [] });
|
||||
else {
|
||||
const f = failedFeedback(true, errorText(e), null, await gameGone(session, game));
|
||||
setFeedback({ ...f, title: f.title.replace('The search', 'Reading the memory'), tips: [keptWords, ...f.tips.slice(1)] });
|
||||
}
|
||||
} finally {
|
||||
stop.current = null;
|
||||
working.current = false;
|
||||
setBusy(false);
|
||||
setFraction(null);
|
||||
setCanceling(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,6 +151,7 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
setLatest(null);
|
||||
setAgainst('first');
|
||||
setError(null);
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const kind = valueKind(kindId);
|
||||
@@ -119,17 +175,17 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
{props.switcher}
|
||||
<CardTitle>See what changed</CardTitle>
|
||||
<CardDescription>
|
||||
Take a snapshot of one area of memory, do something in the game, then compare. Good for exploring around a value you
|
||||
already found: open its address here and watch what else moves with it.
|
||||
Take a snapshot of one area of memory, do something in the game, then compare. Good for exploring around a value you already found: open its address here and
|
||||
watch what else moves with it.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{!first ? (
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_150px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-where">Area</Label>
|
||||
<Label htmlFor={`${uid}cmp-where`}>Area</Label>
|
||||
<Select value={where} disabled={busy} onValueChange={setWhere}>
|
||||
<SelectTrigger id="cmp-where" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-where`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -145,25 +201,28 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
{where === AROUND && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-address">Address</Label>
|
||||
<Label htmlFor={`${uid}cmp-address`}>Address</Label>
|
||||
<Input
|
||||
id="cmp-address"
|
||||
id={`${uid}cmp-address`}
|
||||
className="font-mono"
|
||||
placeholder="0x880012340"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={address}
|
||||
disabled={busy}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAddress(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !busy) void snap();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-size">How much</Label>
|
||||
<Label htmlFor={`${uid}cmp-size`}>How much</Label>
|
||||
<Select value={size} disabled={busy} onValueChange={setSize}>
|
||||
<SelectTrigger id="cmp-size" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-size`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -180,8 +239,8 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
Comparing <strong>{fmtBytes(first.bytes.length)}</strong> from <span className="font-mono">{fmtAddr(first.start)}</span>. First
|
||||
snapshot taken at {clock(first.takenAt)}
|
||||
Comparing <strong>{fmtBytes(first.bytes.length)}</strong> from <span className="font-mono">{fmtAddr(first.start)}</span>. First snapshot taken at{' '}
|
||||
{clock(first.takenAt)}
|
||||
{latest ? `, latest at ${clock(latest.takenAt)}.` : '. Now do something in the game, then compare.'}
|
||||
</p>
|
||||
)}
|
||||
@@ -191,8 +250,15 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
{busy ? 'Reading…' : !first ? 'Take a snapshot' : latest ? 'Compare again' : 'Compare'}
|
||||
</Button>
|
||||
{busy && (
|
||||
<Button variant="outline" onClick={() => stop.current?.abort()}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={canceling}
|
||||
onClick={() => {
|
||||
setCanceling(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
{canceling ? 'Canceling…' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
{first && !busy && (
|
||||
@@ -202,16 +268,25 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
)}
|
||||
</div>
|
||||
|
||||
{busy && <PelletTrack fraction={fraction} label="Reading memory" />}
|
||||
{busy && (
|
||||
<SearchProgress
|
||||
label="Reading memory"
|
||||
startedAt={run.startedAt}
|
||||
what={run.what}
|
||||
canceling={canceling}
|
||||
progress={run.fraction === null ? null : { fraction: run.fraction, done: Math.round(run.fraction * run.bytes), total: run.bytes, unit: 'bytes' }}
|
||||
/>
|
||||
)}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{feedback && !busy && <FeedbackNotice feedback={feedback} />}
|
||||
|
||||
{diff && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[170px_1fr_190px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-kind">Read values as</Label>
|
||||
<Label htmlFor={`${uid}cmp-kind`}>Read values as</Label>
|
||||
<Select value={kindId} onValueChange={setKindId}>
|
||||
<SelectTrigger id="cmp-kind" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-kind`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -234,9 +309,9 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cmp-against">Compared with</Label>
|
||||
<Label htmlFor={`${uid}cmp-against`}>Compared with</Label>
|
||||
<Select value={against} onValueChange={(v) => setAgainst(v as 'first' | 'previous')}>
|
||||
<SelectTrigger id="cmp-against" className="w-full">
|
||||
<SelectTrigger id={`${uid}cmp-against`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -258,8 +333,8 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</div>
|
||||
{diff.total === 0 && (
|
||||
<Notice kind="warn">
|
||||
Nothing here {filter === 'changed' ? 'changed' : filter === 'up' ? 'went up' : 'went down'}. Do something in the game
|
||||
that should affect this area and compare again, or try another type.
|
||||
Nothing here {filter === 'changed' ? 'changed' : filter === 'up' ? 'went up' : 'went down'}. Do something in the game that should affect this area and
|
||||
compare again, or try another type.
|
||||
</Notice>
|
||||
)}
|
||||
</>
|
||||
@@ -291,9 +366,7 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(r.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void navigator.clipboard?.writeText(fmtAddr(r.address)).catch(() => undefined)}>
|
||||
Copy address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(r.address))}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -302,6 +375,7 @@ export function RegionCompare(props: { switcher: ReactNode; scope: ScopeControl;
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+588
-324
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
+54
-29
@@ -16,7 +16,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/kibo-ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { parseAddress } from '../lib/locations.ts';
|
||||
import { parseAddress, readableAt } from '../lib/locations.ts';
|
||||
import { valueEntry } from '../lib/make.ts';
|
||||
import { valueKind } from '../lib/scanner.ts';
|
||||
import { canBeWide, compareStructures, guessAsValue, guessLabel, readStructure, withPinnedKinds } from '../lib/structure.ts';
|
||||
@@ -53,6 +53,10 @@ export function StructureView(props: { visible: boolean }) {
|
||||
const [changed, setChanged] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** What was wrong with the last thing asked for. Kept apart from `error`, which every successful refresh clears. */
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
/** Objects at addresses where the game has no memory; they read as all zeros. */
|
||||
const [missing, setMissing] = useState<bigint[]>([]);
|
||||
const [className, setClassName] = useState<string | null>(null);
|
||||
const [engineFields, setEngineFields] = useState<FieldLabel[]>([]);
|
||||
const last = useRef<Map<number, string>>(new Map());
|
||||
@@ -65,6 +69,8 @@ export function StructureView(props: { visible: boolean }) {
|
||||
setChanged(new Set());
|
||||
last.current = new Map();
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
setMissing([]);
|
||||
};
|
||||
|
||||
// Another tab (or the access list) asked to look at these.
|
||||
@@ -134,6 +140,12 @@ export function StructureView(props: { visible: boolean }) {
|
||||
setCompared(c);
|
||||
setSingle(null);
|
||||
}
|
||||
if (firstRound) {
|
||||
const gone: bigint[] = [];
|
||||
for (const o of objects) if ((await readableAt(ctx, o, 8, true)) === 'none') gone.push(o);
|
||||
if (stopped) return;
|
||||
setMissing(gone);
|
||||
}
|
||||
const diff = new Set<number>();
|
||||
if (!firstRound) for (const [off, value] of now) if (last.current.has(off) && last.current.get(off) !== value) diff.add(off);
|
||||
last.current = now;
|
||||
@@ -155,7 +167,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
}, [props.visible, objectKey, sideKey, size, fields, ctx]);
|
||||
|
||||
const add = async () => {
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
const address = await parseAddress(ctx, text);
|
||||
if (objects.includes(address)) throw new Error('That object is already shown.');
|
||||
@@ -164,12 +176,12 @@ export function StructureView(props: { visible: boolean }) {
|
||||
if (objects.length === 1) setOnlyDiff(true);
|
||||
setText('');
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setFormError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (address: bigint, guess: Guess, name: string) => {
|
||||
setError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
const v = guessAsValue(guess);
|
||||
if (!v) throw new Error('Text cannot be held by a cheat. Change the row\'s type to a number first.');
|
||||
@@ -177,7 +189,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
await wb.addEntry(entry, { enable: false });
|
||||
wb.edit(entry, false);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setFormError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,7 +262,14 @@ export function StructureView(props: { visible: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <Notice kind="error">{formError}</Notice>}
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{missing.length > 0 && (
|
||||
<Notice kind="warn">
|
||||
The game has no memory at {missing.map((m) => fmtAddr(m)).join(', ')}. {missing.length === 1 ? 'It reads' : 'They read'} as nothing but zeros,
|
||||
which is not an object. Check the address.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{objects.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2" aria-label="Objects shown">
|
||||
@@ -308,8 +327,10 @@ export function StructureView(props: { visible: boolean }) {
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="py-1 pr-3 font-normal">Offset</th>
|
||||
{named && <th className="py-1 pr-3 font-normal">Name</th>}
|
||||
<th className="py-1 pr-3 font-normal">Read as</th>
|
||||
<th className="py-1 pr-3 font-normal">Value</th>
|
||||
<th className="py-1 pr-3 font-normal max-sm:hidden">Read as</th>
|
||||
<th className="py-1 pr-3 font-normal">
|
||||
Value<span className="sm:hidden">, and what it is read as</span>
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -384,7 +405,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
{row.verdict === 'groups' && <span className="mr-1 text-xs font-semibold text-primary">tells them apart</span>}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -407,6 +428,7 @@ export function StructureView(props: { visible: boolean }) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{compared.objects.length > 3 && <p className="text-xs text-muted-foreground sm:hidden">Swipe the table sideways to see every object.</p>}
|
||||
{shownRows.length === 0 && <Notice>These objects are identical in the part that was read. Read more of them, or check they are different objects.</Notice>}
|
||||
</>
|
||||
)}
|
||||
@@ -430,36 +452,39 @@ function FieldRow(props: {
|
||||
const g = row.guesses[0];
|
||||
const other = row.guesses[1];
|
||||
const target = g.kind === 'pointer' ? g.target : undefined;
|
||||
const readAs = (className: string) => (
|
||||
<Select value={props.pinned} onValueChange={(v) => props.onPin(v as PinnedKind | 'auto')}>
|
||||
<SelectTrigger size="sm" className={className} aria-label={`How to read the field at ${hexOff(row.offset)}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{guessLabel(g)} (guess)</SelectItem>
|
||||
<SelectItem value="int">Whole number</SelectItem>
|
||||
<SelectItem value="float">Decimal</SelectItem>
|
||||
{props.wide && <SelectItem value="double">Decimal, 8 bytes</SelectItem>}
|
||||
{props.wide && <SelectItem value="pointer">Pointer</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<tr className="border-b last:border-0">
|
||||
<td className="py-1 pr-3 font-mono whitespace-nowrap text-muted-foreground">{hexOff(row.offset)}</td>
|
||||
{props.named && <td className="py-1 pr-3 break-words">{row.field?.name}</td>}
|
||||
<td className="py-1 pr-3">
|
||||
<Select value={props.pinned} onValueChange={(v) => props.onPin(v as PinnedKind | 'auto')}>
|
||||
<SelectTrigger size="sm" className="h-7 w-[7.5rem] text-xs sm:w-[9.5rem]" aria-label={`How to read the field at ${hexOff(row.offset)}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{guessLabel(g)} (guess)</SelectItem>
|
||||
<SelectItem value="int">Whole number</SelectItem>
|
||||
<SelectItem value="float">Decimal</SelectItem>
|
||||
{props.wide && <SelectItem value="double">Decimal, 8 bytes</SelectItem>}
|
||||
{props.wide && <SelectItem value="pointer">Pointer</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="max-w-[16rem] py-1 pr-3">
|
||||
<span className={cn('block font-mono break-words', props.changed && 'text-primary')}>{g.text}</span>
|
||||
<tr className="border-b align-top last:border-0 sm:align-middle">
|
||||
<td className="py-1 pr-3 font-mono whitespace-nowrap text-muted-foreground max-sm:pt-1.5">{hexOff(row.offset)}</td>
|
||||
{props.named && <td className="py-1 pr-3 break-words max-sm:pt-1.5">{row.field?.name}</td>}
|
||||
<td className="py-1 pr-3 max-sm:hidden">{readAs('h-7 w-[9.5rem] text-xs')}</td>
|
||||
<td className="py-1 pr-3 sm:max-w-[16rem]">
|
||||
<span className={cn('block font-mono break-all', props.changed && 'text-primary')}>{g.text}</span>
|
||||
{props.pinned === 'auto' && other && other.kind !== g.kind && g.kind !== 'pointer' && !isEmpty(row) && (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<span className="block text-xs break-all text-muted-foreground sm:truncate">
|
||||
or {other.text} as {guessLabel(other).toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
{/* A phone has no room for a column of its own, so the choice sits under the value. */}
|
||||
<span className="mt-1 block sm:hidden">{readAs('h-8 w-full max-w-[11rem] text-xs')}</span>
|
||||
</td>
|
||||
<td className="py-0.5 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<Button variant="ghost" size="icon-xs" className="max-sm:size-8" aria-label={`Actions for the field at ${hexOff(row.offset)}`}>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
+127
-62
@@ -1,7 +1,7 @@
|
||||
// Find tab, "Text" mode: look for text (names, menu labels, messages) in the
|
||||
// game's memory. The searching itself is in lib/textsearch.ts.
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -10,10 +10,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { fmtAddr, fmtBytes } from '../lib/bytes.ts';
|
||||
import { TEXT_ENCODING_LABELS, searchText } from '../lib/textsearch.ts';
|
||||
import { fmtAddr } from '../lib/bytes.ts';
|
||||
import { coverageWords, failedFeedback, textFeedback } from '../lib/findfeedback.ts';
|
||||
import type { Feedback, TextQuery } from '../lib/findfeedback.ts';
|
||||
import { TEXT_ENCODING_LABELS, searchText, textNeedles } from '../lib/textsearch.ts';
|
||||
import type { TextEncoding, TextProgress, TextSearchResult } from '../lib/textsearch.ts';
|
||||
import { Notice, PelletTrack, errorText } from './bits.tsx';
|
||||
import { Notice, errorText } from './bits.tsx';
|
||||
import { FeedbackNotice, SearchProgress, gameGone, useCopy, useProgress } from './findbits.tsx';
|
||||
import { WhereToSearch } from './WhereToSearch.tsx';
|
||||
import type { ScopeControl } from './WhereToSearch.tsx';
|
||||
import { useWorkbench } from './workbench.tsx';
|
||||
@@ -23,29 +26,66 @@ const MAX_HITS = 200;
|
||||
export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; onPointsTo: (address: bigint) => void }) {
|
||||
const wb = useWorkbench();
|
||||
const { session, game } = wb;
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
const [text, setText] = useState('');
|
||||
const [plain, setPlain] = useState(true);
|
||||
const [wide, setWide] = useState(true);
|
||||
const [ignoreCase, setIgnoreCase] = useState(true);
|
||||
const [fixed, setFixed] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [scopeText, setScopeText] = useState('');
|
||||
const [progress, setProgress] = useState<TextProgress | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [runInfo, setRunInfo] = useState({ startedAt: 0, what: '' });
|
||||
const [progress, setProgress] = useProgress<TextProgress>();
|
||||
const [result, setResult] = useState<TextSearchResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Something wrong with what was typed or chosen, shown next to it. */
|
||||
const [formError, setFormError] = useState<{ field: 'text' | 'where'; text: string } | null>(null);
|
||||
/** How the last search ended. Shown where the results are. */
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const stop = useRef<AbortController | null>(null);
|
||||
const busy = useRef(false);
|
||||
const textInput = useRef<HTMLInputElement | null>(null);
|
||||
const { copy, toast } = useCopy();
|
||||
|
||||
// A complaint about "Where to search" goes away as soon as that choice is changed.
|
||||
const scopeChoice = props.scope.choice;
|
||||
useEffect(() => {
|
||||
setFormError((e) => (e?.field === 'where' ? null : e));
|
||||
}, [scopeChoice]);
|
||||
|
||||
const run = async () => {
|
||||
setError(null);
|
||||
if (busy.current) return;
|
||||
setFormError(null);
|
||||
const encodings: TextEncoding[] = [...(plain ? (['utf8'] as const) : []), ...(wide ? (['utf16'] as const) : [])];
|
||||
const query: TextQuery = { text, plain, wide, ignoreCase, fixed };
|
||||
try {
|
||||
textNeedles(text, { encodings, ignoreCase });
|
||||
} catch (e) {
|
||||
setFormError({ field: 'text', text: errorText(e) });
|
||||
textInput.current?.focus();
|
||||
return;
|
||||
}
|
||||
busy.current = true;
|
||||
const startedAt = Date.now();
|
||||
setRunning(true);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
setScopeText('');
|
||||
// The last search's results go now, so they cannot be mistaken for this one's.
|
||||
setResult(null);
|
||||
setFeedback(null);
|
||||
setRunInfo({ startedAt, what: "Reading the list of the game's memory…" });
|
||||
const controller = new AbortController();
|
||||
stop.current = controller;
|
||||
try {
|
||||
const encodings: TextEncoding[] = [...(plain ? (['utf8'] as const) : []), ...(wide ? (['utf16'] as const) : [])];
|
||||
const where = await props.scope.resolve({ fixed });
|
||||
setScopeText(`Searching ${fmtBytes(where.bytes)} of ${fmtBytes(where.total)}`);
|
||||
let where: Awaited<ReturnType<ScopeControl['resolve']>>;
|
||||
try {
|
||||
where = await props.scope.resolve({ fixed });
|
||||
} catch (e) {
|
||||
setFormError({ field: 'where', text: errorText(e) });
|
||||
return;
|
||||
}
|
||||
const cover = { bytes: where.bytes, total: where.total };
|
||||
setRunInfo({ startedAt, what: `Looking for "${text}" in ${coverageWords(cover)}` });
|
||||
const res = await searchText({
|
||||
dbg: () => session.scan,
|
||||
pid: game.pid,
|
||||
@@ -58,11 +98,17 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
onProgress: setProgress,
|
||||
});
|
||||
setResult(res);
|
||||
// A game that has just closed reads as empty memory, which looks exactly like "nothing found".
|
||||
if (res.hits.length === 0 && !res.canceled && (await gameGone(session, game)))
|
||||
setFeedback(failedFeedback(true, 'The game closed while the search was running', null, true));
|
||||
else setFeedback(textFeedback(query, { ...res, hits: res.hits.length }, cover, Date.now() - startedAt));
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
setFeedback(failedFeedback(true, errorText(e), null, await gameGone(session, game)));
|
||||
} finally {
|
||||
stop.current = null;
|
||||
busy.current = false;
|
||||
setRunning(false);
|
||||
setCanceling(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
@@ -80,96 +126,116 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
<CardHeader>
|
||||
{props.switcher}
|
||||
<CardTitle>Find text</CardTitle>
|
||||
<CardDescription>
|
||||
Type a word or name you can see in the game, such as an item name or a menu label, to find where the game keeps it.
|
||||
</CardDescription>
|
||||
<CardDescription>Type a word or name you can see in the game, such as an item name or a menu label, to find where the game keeps it.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="text-needle">Text</Label>
|
||||
<Label htmlFor={`${uid}text-needle`}>Text</Label>
|
||||
<Input
|
||||
id="text-needle"
|
||||
id={`${uid}text-needle`}
|
||||
placeholder="New Game"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
ref={textInput}
|
||||
aria-invalid={formError?.field === 'text' || undefined}
|
||||
aria-describedby={formError?.field === 'text' ? `${uid}text-needle-error` : undefined}
|
||||
value={text}
|
||||
disabled={running}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (formError?.field === 'text') setFormError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !running) void run();
|
||||
}}
|
||||
/>
|
||||
{formError?.field === 'text' && (
|
||||
<p id={`${uid}text-needle-error`} role="alert" className="text-sm text-destructive">
|
||||
{formError.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{check('text-plain', <>Plain text <span className="text-muted-foreground">(one byte per letter)</span></>, plain, setPlain)}
|
||||
{check('text-wide', <>Wide text <span className="text-muted-foreground">(two bytes per letter; Unreal Engine games)</span></>, wide, setWide)}
|
||||
{check('text-case', 'Ignore capitals', ignoreCase, setIgnoreCase)}
|
||||
{check(
|
||||
'text-fixed',
|
||||
<>Also look in the game's code and fixed data <span className="text-muted-foreground">(built-in names and menu text are usually there)</span></>,
|
||||
`${uid}text-plain`,
|
||||
<>
|
||||
Plain text <span className="text-muted-foreground">(one byte per letter)</span>
|
||||
</>,
|
||||
plain,
|
||||
setPlain,
|
||||
)}
|
||||
{check(
|
||||
`${uid}text-wide`,
|
||||
<>
|
||||
Wide text <span className="text-muted-foreground">(two bytes per letter; Unreal Engine games)</span>
|
||||
</>,
|
||||
wide,
|
||||
setWide,
|
||||
)}
|
||||
{check(`${uid}text-case`, 'Ignore capitals', ignoreCase, setIgnoreCase)}
|
||||
{check(
|
||||
`${uid}text-fixed`,
|
||||
<>
|
||||
Also look in the game's code and fixed data <span className="text-muted-foreground">(built-in names and menu text are usually there)</span>
|
||||
</>,
|
||||
fixed,
|
||||
setFixed,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WhereToSearch scope={props.scope} fixed={fixed} disabled={running} />
|
||||
{formError?.field === 'where' && <Notice kind="error">{formError.text}</Notice>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button disabled={running} onClick={() => void run()}>
|
||||
{running ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
{running && (
|
||||
<Button variant="outline" onClick={() => stop.current?.abort()}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={canceling}
|
||||
onClick={() => {
|
||||
setCanceling(true);
|
||||
stop.current?.abort();
|
||||
}}
|
||||
>
|
||||
{canceling ? 'Canceling…' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
{result && !running && (
|
||||
<span className="text-sm text-muted-foreground max-sm:basis-full" aria-live="polite">
|
||||
<strong className="text-foreground">{result.hits.length.toLocaleString()}</strong> {result.hits.length === 1 ? 'place' : 'places'} found
|
||||
<strong className="text-foreground">{result.hits.length.toLocaleString('en-US')}</strong> {result.hits.length === 1 ? 'place' : 'places'} found
|
||||
{result.capped ? ' (stopped there)' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div>
|
||||
<PelletTrack fraction={progress ? progress.fraction : null} label="Text search progress" />
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{scopeText || 'Reading the memory map…'}
|
||||
{progress ? ` · ${progress.hits.toLocaleString()} found so far` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{result && !running && result.canceled && (
|
||||
<Notice kind="warn">Canceled after {fmtBytes(result.bytesSearched)}. What was found up to then is listed below.</Notice>
|
||||
)}
|
||||
{result && !running && result.capped && (
|
||||
<Notice kind="warn">
|
||||
Stopped at the first {MAX_HITS} places; there may be more. Type a longer piece of the text, or search less memory, to
|
||||
get a shorter list.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && !result.allCases && (
|
||||
<Notice>
|
||||
Looked for these spellings: {result.spellings.map((s) => `"${s}"`).join(', ')}. Other mixes of capitals are not found. If
|
||||
you know the exact spelling, type it that way.
|
||||
</Notice>
|
||||
)}
|
||||
{result && !running && result.hits.length === 0 && !result.canceled && (
|
||||
<Notice kind="warn">
|
||||
Not found. The game may spell it differently or build it from pieces, so try one distinctive word.
|
||||
{!fixed && " Text that never changes is often in the game's fixed data: check that box and search again."}
|
||||
</Notice>
|
||||
<SearchProgress
|
||||
label="Text search progress"
|
||||
startedAt={runInfo.startedAt}
|
||||
what={runInfo.what}
|
||||
canceling={canceling}
|
||||
progress={
|
||||
progress && {
|
||||
fraction: progress.fraction,
|
||||
done: progress.done,
|
||||
total: progress.total,
|
||||
unit: 'bytes',
|
||||
found: `${progress.hits.toLocaleString('en-US')} found so far`,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result && result.hits.length > 0 && (
|
||||
<Card>
|
||||
{feedback && !running && <FeedbackNotice feedback={feedback} />}
|
||||
|
||||
{result && result.hits.length > 0 && !running && (
|
||||
<Card aria-label="Results">
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{result.hits.map((h) => (
|
||||
@@ -196,9 +262,7 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => wb.showMemory(h.address)}>Show in Memory</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => props.onPointsTo(h.address)}>Find what points to this</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void navigator.clipboard?.writeText(fmtAddr(h.address)).catch(() => undefined)}>
|
||||
Copy address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => copy(fmtAddr(h.address))}>Copy address</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
@@ -207,6 +271,7 @@ export function TextSearch(props: { switcher: ReactNode; scope: ScopeControl; on
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{toast}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+14
-13
@@ -1,7 +1,7 @@
|
||||
// "Where to search": which parts of the game's memory a search covers. Shared
|
||||
// by the number, text and group searches, and remembered per game.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -94,6 +94,8 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
const { scope, fixed, disabled } = props;
|
||||
const { choice, items } = scope;
|
||||
const [open, setOpen] = useState(false);
|
||||
// This tool can be on the page twice (a lesson's practice game in front of the real one), so its ids are per copy.
|
||||
const uid = useId();
|
||||
|
||||
const shown = useMemo(() => (items ?? []).filter((i) => i.writable || fixed), [items, fixed]);
|
||||
const summary = useMemo(() => {
|
||||
@@ -122,7 +124,7 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
: choice.mode === 'all'
|
||||
? `Everything (${fmtBytes(summary.total)})`
|
||||
: summary.error
|
||||
? 'Address range (not set yet)'
|
||||
? 'Address range: not set'
|
||||
: `${fmtBytes(summary.bytes)} of ${fmtBytes(summary.total)}`;
|
||||
|
||||
return (
|
||||
@@ -161,8 +163,8 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
|
||||
{choice.mode === 'all' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All the memory the game can change. This always works, but most of a big game's memory is pictures and sound. If
|
||||
searches are slow, choose parts and uncheck the biggest blocks.
|
||||
All the memory the game can change. This always works, but most of a big game's memory is pictures and sound. If searches are slow, choose parts and uncheck
|
||||
the biggest blocks.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -178,11 +180,12 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
Uncheck blocks over {fmtBytes(HUGE_BYTES)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={choice.off.length === 0} onClick={() => scope.setChoice({ ...choice, off: [] })}>
|
||||
Tick everything
|
||||
Check everything
|
||||
</Button>
|
||||
</div>
|
||||
{!items && <p className="text-sm text-muted-foreground">{scope.loadError ?? 'Reading the memory map…'}</p>}
|
||||
<div className="-mx-1 max-h-72 overflow-y-auto px-1">
|
||||
{/* A box of its own, cut off mid-row when there is more, so it reads as a list that scrolls. */}
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border px-2 py-1" role="group" aria-label="Parts of the game's memory">
|
||||
{GROUPS.map((group) => {
|
||||
const rows = shown.filter((i) => i.group === group);
|
||||
if (rows.length === 0) return null;
|
||||
@@ -214,9 +217,9 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
{choice.mode === 'range' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="scope-from">From address</Label>
|
||||
<Label htmlFor={`${uid}scope-from`}>From address</Label>
|
||||
<Input
|
||||
id="scope-from"
|
||||
id={`${uid}scope-from`}
|
||||
className="font-mono"
|
||||
placeholder="0x880000000"
|
||||
autoCapitalize="off"
|
||||
@@ -226,9 +229,9 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="scope-to">To address</Label>
|
||||
<Label htmlFor={`${uid}scope-to`}>To address</Label>
|
||||
<Input
|
||||
id="scope-to"
|
||||
id={`${uid}scope-to`}
|
||||
className="font-mono"
|
||||
placeholder="0x880040000"
|
||||
autoCapitalize="off"
|
||||
@@ -237,9 +240,7 @@ export function WhereToSearch(props: { scope: ScopeControl; fixed?: boolean; dis
|
||||
onChange={(e) => scope.setChoice({ ...choice, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-2 text-sm text-muted-foreground">
|
||||
Addresses as shown in the Memory tab. Only memory that exists between the two is searched.
|
||||
</p>
|
||||
<p className="col-span-2 text-sm text-muted-foreground">Addresses as shown in the Memory tab. Only memory that exists between the two is searched.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+10
-3
@@ -122,7 +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()}
|
||||
className="top-0 left-0 flex h-dvh max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:top-[50%] sm:left-[50%] sm:h-[min(46rem,92dvh)] sm:max-w-md sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm: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} />
|
||||
@@ -473,7 +477,8 @@ function Screens(props: { flow: FirstCheatFlow; state: WizardState; onClose: ()
|
||||
{state.sameAsBefore ? (
|
||||
<Notice kind="warn">
|
||||
That is the same number as last time, and it has to change for this to work. If the game is paused or in a menu, go back into
|
||||
it, {t.actOnce}, then type the new number.
|
||||
it, {t.actOnce}, then type the new number. If a cheat for your {t.noun} is already switched on, it will not change either: leave
|
||||
the guide, switch that cheat off, and start again.
|
||||
</Notice>
|
||||
) : (
|
||||
<Para className="text-sm text-muted-foreground">
|
||||
@@ -838,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>
|
||||
|
||||
+43
-2
@@ -1,6 +1,7 @@
|
||||
// Small shared pieces: the ghost, the pellet progress track, notices.
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -30,7 +31,7 @@ export function Wordmark() {
|
||||
return (
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2.5">
|
||||
<Ghost size={30} look />
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
<h1 className="truncate text-lg font-bold tracking-tight sm:text-xl">
|
||||
Cheat<span className="text-primary">Specter</span>
|
||||
</h1>
|
||||
</div>
|
||||
@@ -87,3 +88,43 @@ export function Notice(props: { kind?: 'error' | 'warn' | 'info'; children: Reac
|
||||
export function errorText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches a mistake in one part of the page so the rest keeps working. Without
|
||||
* it, React takes the whole page down to a blank screen, and whatever is
|
||||
* switched on in the game is left with nothing to switch it off.
|
||||
*/
|
||||
export class Safe extends Component<{ what: string; children: ReactNode }, { error: unknown }> {
|
||||
state: { error: unknown } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: unknown } {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||||
console.error(`CheatSpecter: ${this.props.what} stopped working`, error, info.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error === null) return this.props.children;
|
||||
return (
|
||||
<Notice kind="error">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
Something went wrong showing {this.props.what}. This is a fault in CheatSpecter, not something you did. Try again; if it keeps
|
||||
happening, reload the page. Cheats that are on stay on in the game either way.
|
||||
</span>
|
||||
<span className="font-mono text-xs break-all opacity-80">{errorText(this.state.error)}</span>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<button type="button" className="rounded-md border px-3 py-1.5 text-sm font-medium" onClick={() => this.setState({ error: null })}>
|
||||
Try again
|
||||
</button>
|
||||
<button type="button" className="rounded-md border px-3 py-1.5 text-sm font-medium" onClick={() => window.location.reload()}>
|
||||
Reload the page
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copying to the clipboard, and saying so.
|
||||
//
|
||||
// The page is usually served by the PS5 itself over plain http, where
|
||||
// browsers do not offer `navigator.clipboard` at all. A menu item that
|
||||
// quietly does nothing there looks broken, so this falls back to the old
|
||||
// select-and-copy way, and every copy ends with a short line on screen that
|
||||
// says what happened.
|
||||
|
||||
const SHOWN_MS = 2500;
|
||||
const SHOWN_FAILED_MS = 9000;
|
||||
|
||||
let line: HTMLDivElement | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** A short line at the bottom of the window that goes away by itself. One at a time; a new one replaces the old. */
|
||||
export function say(text: string, ms = SHOWN_MS): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!line) {
|
||||
line = document.createElement('div');
|
||||
line.setAttribute('role', 'status');
|
||||
line.setAttribute('aria-live', 'polite');
|
||||
// Above dialogs and menus, clear of the phone's home bar; text can be selected, for when copying failed.
|
||||
line.className =
|
||||
'fixed bottom-4 left-1/2 z-[80] w-max max-w-[calc(100%-2rem)] -translate-x-1/2 rounded-md border border-primary/50 bg-background px-3 py-2 text-sm break-all text-foreground shadow-lg select-text pointer-events-auto';
|
||||
}
|
||||
line.textContent = text;
|
||||
if (!line.isConnected) document.body.appendChild(line);
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
line?.remove();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/** The way that works without `navigator.clipboard`: put the text in a hidden box, select it, and ask the browser to copy the selection. */
|
||||
function copyBySelection(text: string): boolean {
|
||||
const box = document.createElement('textarea');
|
||||
box.value = text;
|
||||
box.setAttribute('readonly', '');
|
||||
// Kept inside the window so no browser scrolls to it, and big enough type that phones do not zoom in.
|
||||
box.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
// An open menu or dialog keeps focus to itself; the box has to be inside it to be selectable.
|
||||
const host = document.querySelector('[role="menu"][data-state="open"]') ?? document.querySelector('[role="dialog"][data-state="open"]') ?? document.body;
|
||||
const before = document.activeElement as HTMLElement | null;
|
||||
host.appendChild(box);
|
||||
let ok = false;
|
||||
try {
|
||||
box.focus();
|
||||
box.select();
|
||||
box.setSelectionRange(0, text.length);
|
||||
ok = document.execCommand('copy');
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
box.remove();
|
||||
try {
|
||||
before?.focus();
|
||||
} catch {
|
||||
// Whatever had focus is gone; nothing to put back.
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Copies `text`. Resolves to whether it worked; never throws. */
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Refused (no permission, page not focused): try the other way.
|
||||
}
|
||||
try {
|
||||
return copyBySelection(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies `text` and says so on screen. `what` names it for the person:
|
||||
* "address", "offset". When the browser will not copy at all, the text
|
||||
* itself is shown for long enough to select or write down.
|
||||
*/
|
||||
export async function copyAndSay(text: string, what = 'text'): Promise<boolean> {
|
||||
const ok = await copyText(text);
|
||||
const short = text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
||||
if (ok) say(`Copied the ${what}: ${short}`);
|
||||
else say(`This browser would not copy. The ${what} is: ${text}`, SHOWN_FAILED_MS);
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Pieces the four Find modes share: honest progress for a running search, the
|
||||
// box that says how a search ended, and "copy address" with its confirmation.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyText } from '../lib/clipboard.ts';
|
||||
import { clock, leftWords, ofBytes, timeLeft } from '../lib/findfeedback.ts';
|
||||
import type { Feedback, ProgressSample } from '../lib/findfeedback.ts';
|
||||
import type { Game, Session } from '../lib/session.ts';
|
||||
import { Notice, PelletTrack } from './bits.tsx';
|
||||
|
||||
/** Where a running search is. Every number is real work done, as reported by the search itself. */
|
||||
export interface RunProgress {
|
||||
/** 0..1 */
|
||||
fraction: number;
|
||||
done?: number;
|
||||
total?: number;
|
||||
unit?: 'bytes' | 'candidates' | 'places';
|
||||
/** "1,234 matches so far" */
|
||||
found?: string;
|
||||
/** "Round 2 of 3, as float": shown before the numbers. Time left is worked out afresh when it changes. */
|
||||
stage?: string;
|
||||
/** True when `fraction` covers only the current stage, so the time left does too. */
|
||||
stageOnly?: boolean;
|
||||
}
|
||||
|
||||
/** After a search failed: true when the reason is simply that the game it was searching is no longer running. */
|
||||
export async function gameGone(session: Session, game: Game): Promise<boolean> {
|
||||
const now = await session.currentGame().catch(() => undefined);
|
||||
return now === null || (now !== undefined && now.pid !== game.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of a running search as screen state. A search over a real game
|
||||
* reports thousands of times (once per block of memory), and redrawing for
|
||||
* each report would take longer than the searching, so reports that come
|
||||
* faster than the eye can follow are skipped. The last one is never older
|
||||
* than a tenth of a second.
|
||||
*/
|
||||
export function useProgress<T>(): [T | null, (p: T | null) => void] {
|
||||
const [shown, setShown] = useState<T | null>(null);
|
||||
const last = useRef(0);
|
||||
const report = useCallback((p: T | null) => {
|
||||
const now = Date.now();
|
||||
if (p !== null && now - last.current < 100) return;
|
||||
last.current = p === null ? 0 : now;
|
||||
setShown(p);
|
||||
}, []);
|
||||
return [shown, report];
|
||||
}
|
||||
|
||||
const STALL_MS = 6000;
|
||||
const STALL_LONG_MS = 45_000;
|
||||
|
||||
function amount(p: RunProgress): string | null {
|
||||
if (p.done === undefined || p.total === undefined) return null;
|
||||
if (p.unit === 'bytes') return ofBytes(p.done, p.total);
|
||||
return `${p.done.toLocaleString('en-US')} of ${p.total.toLocaleString('en-US')} ${p.unit === 'places' ? 'places' : 'matches'} checked`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of a running search. The bar only ever moves when the search
|
||||
* reports work done; between reports only the clock ticks, and a long silence
|
||||
* is said out loud instead of being covered with an animation.
|
||||
*/
|
||||
export function SearchProgress(props: { label: string; startedAt: number; what: string; progress: RunProgress | null; canceling?: boolean }) {
|
||||
const { progress, startedAt } = props;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const samples = useRef<ProgressSample[]>([]);
|
||||
const stage = useRef<string | undefined>(undefined);
|
||||
const lastNews = useRef(startedAt);
|
||||
|
||||
const box = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// On a phone the progress would otherwise start below the bottom of the screen.
|
||||
box.current?.scrollIntoView({ block: 'nearest' });
|
||||
const timer = setInterval(() => setNow(Date.now()), 500);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// One sample per report from the search.
|
||||
useEffect(() => {
|
||||
if (!progress) {
|
||||
samples.current = [];
|
||||
lastNews.current = startedAt;
|
||||
return;
|
||||
}
|
||||
const at = Date.now();
|
||||
const list = samples.current;
|
||||
const last = list[list.length - 1];
|
||||
if (stage.current !== progress.stage || (last && progress.fraction < last.fraction)) list.length = 0;
|
||||
stage.current = progress.stage;
|
||||
if (!last || progress.fraction !== last.fraction || list.length === 0) lastNews.current = at;
|
||||
list.push({ at, fraction: progress.fraction });
|
||||
if (list.length > 400) list.splice(0, list.length - 400);
|
||||
}, [progress, startedAt]);
|
||||
|
||||
const fraction = progress ? Math.max(0, Math.min(1, progress.fraction)) : 0;
|
||||
const percent = Math.floor(fraction * 100);
|
||||
const quiet = now - lastNews.current;
|
||||
const left = progress && fraction < 1 && !props.canceling ? timeLeft(samples.current, now) : null;
|
||||
const size = progress ? amount(progress) : null;
|
||||
const detail = progress ? [progress.stage, progress.found].filter(Boolean).join(' · ') : '';
|
||||
|
||||
// The block grows by a line when the first report arrives; keep all of it on screen.
|
||||
const tall = detail !== '';
|
||||
useEffect(() => {
|
||||
if (tall) box.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [tall]);
|
||||
|
||||
return (
|
||||
<div ref={box} className="flex scroll-mb-4 flex-col gap-1" data-testid="search-progress">
|
||||
<PelletTrack fraction={fraction} label={props.label} />
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 text-sm">
|
||||
<span className="min-w-0">
|
||||
<strong className="mr-2 inline-block min-w-[3ch] text-base tabular-nums text-primary">{percent}%</strong>
|
||||
{size && <span className="tabular-nums">{size}</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{clock(now - startedAt)} so far
|
||||
{left !== null && ` · ${leftWords(left)}${progress?.stageOnly ? ' in this step' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{detail && <p className="text-sm tabular-nums">{detail}</p>}
|
||||
<p className="text-sm text-muted-foreground">{props.what}</p>
|
||||
{props.canceling ? (
|
||||
<p className="text-sm text-primary" role="status">
|
||||
Canceling… waiting for the console to finish what it is on.
|
||||
</p>
|
||||
) : (
|
||||
quiet > STALL_MS && (
|
||||
<p className="text-sm text-primary" role="status">
|
||||
{!progress || progress.unit === 'bytes' ? 'Working on a large block' : 'Waiting for the console'}: no news from the console for {Math.floor(quiet / 1000)} s.
|
||||
The bar moves again when this piece is done.
|
||||
{quiet > STALL_LONG_MS && ' If this goes on much longer, press Cancel and check that the game is still running.'}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How a search ended: one plain sentence, then what to try next. */
|
||||
export function FeedbackNotice(props: { feedback: Feedback; children?: ReactNode }) {
|
||||
const { feedback } = props;
|
||||
const box = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// The answer to a search must not land below the bottom of the screen, where it looks like nothing happened.
|
||||
useEffect(() => {
|
||||
box.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [feedback]);
|
||||
|
||||
return (
|
||||
<Notice kind={feedback.tone === 'info' ? 'info' : feedback.tone} className="w-full scroll-my-4">
|
||||
<div ref={box} data-testid="search-outcome" className="flex w-full scroll-my-8 flex-col gap-1.5">
|
||||
<p className="font-medium text-foreground">{feedback.title}</p>
|
||||
{feedback.tips.length === 1 && <p>{feedback.tips[0]}</p>}
|
||||
{feedback.tips.length > 1 && (
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{feedback.tips.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyState {
|
||||
text: string;
|
||||
ok: boolean;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Copy address" that always ends in something the person can see: a short
|
||||
* confirmation, or, when the browser refuses, the text itself ready to be
|
||||
* selected by hand. Render `toast` once, anywhere in the component.
|
||||
*/
|
||||
export function useCopy(): { copy: (text: string) => void; toast: ReactNode } {
|
||||
const [state, setState] = useState<CopyState | null>(null);
|
||||
const field = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const copy = useCallback((text: string) => {
|
||||
void copyText(text).then((ok) => setState({ text, ok, nonce: Date.now() }));
|
||||
}, []);
|
||||
|
||||
// A confirmation goes away by itself; a failure stays until it is closed, because the text is needed from it.
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
if (!state.ok) {
|
||||
field.current?.select();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setState(null), 3500);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const toast = state ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||
<div
|
||||
role="status"
|
||||
data-testid="copy-toast"
|
||||
className="pointer-events-auto flex max-w-full items-center gap-2 rounded-lg border border-primary/50 bg-popover px-3 py-2 text-sm text-popover-foreground shadow-lg"
|
||||
>
|
||||
{state.ok ? (
|
||||
<>
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 break-all">
|
||||
Copied <span className="font-mono">{state.text}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="shrink-0">This browser would not copy. Select it here:</span>
|
||||
<input
|
||||
ref={field}
|
||||
readOnly
|
||||
aria-label="Text to copy"
|
||||
className="min-w-0 flex-1 rounded border bg-background px-2 py-1 font-mono text-sm"
|
||||
value={state.text}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0" aria-label="Close" onClick={() => setState(null)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { copy, toast };
|
||||
}
|
||||
+90
-12
@@ -2,15 +2,19 @@
|
||||
// user's own cheat table and the runner that applies it, plus "go and show
|
||||
// this address" navigation between tabs.
|
||||
|
||||
import { TvNotices } from '../lib/tvnotice.ts';
|
||||
import type { TvChange } from '../lib/tvnotice.ts';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
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';
|
||||
@@ -98,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
|
||||
@@ -135,6 +147,8 @@ export interface Workbench extends EngineAccess {
|
||||
* table undo themselves.
|
||||
*/
|
||||
write: (address: bigint, bytes: Uint8Array) => Promise<void>;
|
||||
/** The same for many values at once ("Set all" in a result list): the screen is refreshed once at the end, not once per value. */
|
||||
writeMany: (list: { address: bigint; bytes: Uint8Array }[], onProgress?: (done: number) => void) => Promise<void>;
|
||||
/**
|
||||
* Registers a function that stops whatever the caller keeps rewriting in
|
||||
* the game (frozen values). Called by "Restore everything". Returns a
|
||||
@@ -151,6 +165,8 @@ export interface Workbench extends EngineAccess {
|
||||
registerRestorer: (r: { active: () => string[]; restore: () => Promise<RestoreLine[]> }) => () => void;
|
||||
/** Says that something changed in the game just now, in words, for the "what was on when it closed" record. */
|
||||
noteChange: (text: string) => void;
|
||||
/** Shows on the TV that a cheat was switched on or off, or run. Changes made close together share one notification. */
|
||||
tellTv: (name: string, change: TvChange) => void;
|
||||
/** True when anything is on or was written, so there is something to restore. */
|
||||
canRestore: boolean;
|
||||
restoring: boolean;
|
||||
@@ -421,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;
|
||||
@@ -428,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);
|
||||
@@ -452,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;
|
||||
@@ -477,8 +502,15 @@ export function WorkbenchProvider(props: {
|
||||
[bump],
|
||||
);
|
||||
|
||||
// The practice game has no TV; everything else says what was switched on the console's own screen.
|
||||
const tv = useMemo(() => new TvNotices(practice ? async () => undefined : (text) => session.main.notify(text)), [session, practice]);
|
||||
useEffect(() => {
|
||||
return () => tv.drop();
|
||||
}, [tv]);
|
||||
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) => {
|
||||
@@ -489,20 +521,25 @@ 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]);
|
||||
await runner.disable(entry);
|
||||
}
|
||||
lastChange.current = { text: `${on ? (isAction(entry) ? 'Ran' : 'Switched on') : 'Switched off'} "${entry.name}"`, time: Date.now() };
|
||||
// Shared code is switched by the cheats that use it, not by the person.
|
||||
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],
|
||||
[runner, bump, tv, session, game, practice],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -536,12 +573,17 @@ export function WorkbenchProvider(props: {
|
||||
async (entry: Entry) => {
|
||||
// Undo the old version before replacing it, so nothing is left applied that can no longer be switched off.
|
||||
const old = tableRef.current.entries.find((e) => e.id === entry.id);
|
||||
if (old && runner.isOn(old.id)) await runner.disable(old).catch(() => undefined);
|
||||
const wasOn = !!old && runner.isOn(old.id);
|
||||
if (old && wasOn) await runner.disable(old).catch(() => undefined);
|
||||
setTable((t) => ({ ...t, entries: t.entries.map((e) => (e.id === entry.id ? entry : e)) }));
|
||||
bump();
|
||||
// It was on, so the edited version goes on in its place; left off, the
|
||||
// change would look like it silently broke the cheat. If the new
|
||||
// version does not fit, its row says why.
|
||||
if (wasOn && !isAction(entry) && entry.type !== 'shared' && !entry.unreviewed) await toggle(entry, true);
|
||||
proofLater(entry);
|
||||
},
|
||||
[runner, setTable, bump, proofLater],
|
||||
[runner, setTable, bump, proofLater, toggle],
|
||||
);
|
||||
|
||||
const removeEntry = useCallback(
|
||||
@@ -559,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);
|
||||
@@ -689,6 +732,23 @@ export function WorkbenchProvider(props: {
|
||||
},
|
||||
[journal, mem, bump],
|
||||
);
|
||||
const writeMany = useCallback(
|
||||
async (list: { address: bigint; bytes: Uint8Array }[], onProgress?: (done: number) => void) => {
|
||||
let done = 0;
|
||||
try {
|
||||
for (const w of list) {
|
||||
await journal.write(mem, w.address, w.bytes);
|
||||
onProgress?.(++done);
|
||||
}
|
||||
} finally {
|
||||
if (done > 0) {
|
||||
lastChange.current = { text: `Changed ${done} ${done === 1 ? 'value' : 'values'} by hand`, time: Date.now() };
|
||||
bump();
|
||||
}
|
||||
}
|
||||
},
|
||||
[journal, mem, bump],
|
||||
);
|
||||
|
||||
const freezers = useRef(new Set<() => void>());
|
||||
const onStopFreezing = useCallback((stop: () => void) => {
|
||||
@@ -795,6 +855,7 @@ export function WorkbenchProvider(props: {
|
||||
}
|
||||
}
|
||||
lastChange.current = { text: 'Restored everything', time: Date.now() };
|
||||
if (!closed && lines.some((l) => l.result === 'restored')) tv.say('Everything restored');
|
||||
} finally {
|
||||
setRestoreReport({ time: Date.now(), lines });
|
||||
setRestoring(false);
|
||||
@@ -821,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));
|
||||
@@ -857,6 +924,7 @@ export function WorkbenchProvider(props: {
|
||||
saveToPs5: writeToPs5,
|
||||
fileTable,
|
||||
setTable,
|
||||
dropTable,
|
||||
addEntry,
|
||||
replaceEntry,
|
||||
toggle,
|
||||
@@ -868,10 +936,12 @@ export function WorkbenchProvider(props: {
|
||||
keptState,
|
||||
setKeep,
|
||||
write,
|
||||
writeMany,
|
||||
onStopFreezing,
|
||||
noteFrozen,
|
||||
registerRestorer,
|
||||
noteChange,
|
||||
tellTv,
|
||||
canRestore,
|
||||
restoring,
|
||||
restoreEverything,
|
||||
@@ -905,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');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// A React effect may return a cleanup function and nothing else. An effect written as a bare
|
||||
// expression (`useEffect(() => window.scrollTo(0, 0))`) returns whatever the call returns, and
|
||||
// newer browsers return a promise from scroll methods: React then tries to call that promise as
|
||||
// the cleanup and the page breaks. So effects are written with a block body, or return a function.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src');
|
||||
|
||||
function* sources(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* sources(full);
|
||||
else if (/\.tsx?$/.test(entry.name)) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
test('no effect returns the value of an expression', () => {
|
||||
const bad = [];
|
||||
for (const file of sources(root)) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
// `() => {` (a block) and `() => () =>` (a cleanup function) are the two allowed openings.
|
||||
for (const m of text.matchAll(/use(?:Layout|Insertion)?Effect\(\s*(?:async\s*)?\(\)\s*=>\s*(?=\S)(?!\{|\(\)\s*=>)/g)) {
|
||||
bad.push(`${path.relative(root, file)}:${text.slice(0, m.index).split('\n').length}`);
|
||||
}
|
||||
for (const m of text.matchAll(/use(?:Layout|Insertion)?Effect\(\s*async\b/g)) {
|
||||
bad.push(`${path.relative(root, file)}:${text.slice(0, m.index).split('\n').length} (async effect)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], 'write these effects with a block body');
|
||||
});
|
||||
+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);
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
// The Find tab's honesty: progress that is real work done over work to do,
|
||||
// canceling at any point, input errors that say what to change, and the words
|
||||
// used for every way a search can end. Runs against the mock console with a
|
||||
// game enlarged to a few hundred megabytes and slowed down, so progress and
|
||||
// canceling can be seen.
|
||||
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startMock, blankRegion, GAME } from '../mock/mock-ps5.mjs';
|
||||
import { startBridge } from '../bridge/server.mjs';
|
||||
import { openWebSocketStream } from '../src/lib/transport.ts';
|
||||
import { Ps5Debug } from '../src/lib/ps5debug.ts';
|
||||
import {
|
||||
AUTO_KIND,
|
||||
AutoScan,
|
||||
SCAN_SLICE_BYTES,
|
||||
ScanCanceledError,
|
||||
ScanSession,
|
||||
SnapshotScan,
|
||||
compareKind,
|
||||
decodeValue,
|
||||
encodeCompare,
|
||||
encodeValue,
|
||||
liveDbg,
|
||||
valueKind,
|
||||
} from '../src/lib/scanner.ts';
|
||||
import { DEFAULT_SCOPE, hugeRegionIds, listRegions, rangesBytes, scopeRanges, sliceRanges, totalBytes } from '../src/lib/regions.ts';
|
||||
import { searchText } from '../src/lib/textsearch.ts';
|
||||
import { checkGroup, groupSearch } from '../src/lib/groupscan.ts';
|
||||
import { SnapshotCanceledError, takeSnapshot } from '../src/lib/memdiff.ts';
|
||||
import {
|
||||
canceledFeedback,
|
||||
checkWords,
|
||||
clock,
|
||||
failedFeedback,
|
||||
firstFeedback,
|
||||
groupFeedback,
|
||||
holdsWords,
|
||||
leftWords,
|
||||
narrowFeedback,
|
||||
ofBytes,
|
||||
textFeedback,
|
||||
timeLeft,
|
||||
tooManyFeedback,
|
||||
} from '../src/lib/findfeedback.ts';
|
||||
|
||||
const PORT_OFFSET = Number(process.env.CS_PORT_OFFSET ?? 0);
|
||||
const TCP = 17520 + PORT_OFFSET;
|
||||
const MB = 1024 * 1024;
|
||||
const BIG = 0x1000000000n;
|
||||
const BIG_BYTES = 100 * MB;
|
||||
let mock, bridge, bridgePort;
|
||||
|
||||
const connect = async () => {
|
||||
const stream = await openWebSocketStream(`ws://127.0.0.1:${bridgePort}/ws?host=127.0.0.1&port=${TCP}`);
|
||||
return new Ps5Debug(stream, { timeoutMs: 4000 });
|
||||
};
|
||||
const u32 = valueKind('u32');
|
||||
const exact = compareKind('exact');
|
||||
const enc = (text, kind = u32) => encodeValue(kind, text);
|
||||
const bigSlices = () => sliceRanges([{ start: BIG, end: BIG + BigInt(BIG_BYTES) }]);
|
||||
const heapSlices = () => sliceRanges([{ start: GAME.heapBase, end: GAME.heapBase + 0x40000n }]);
|
||||
/** Eight separate 256 KB pieces of the empty memory: eight slices of 65,536 zeros each. */
|
||||
const zeroSlices = () => sliceRanges(Array.from({ length: 8 }, (_, i) => ({ start: BIG + BigInt(i * 20 * MB), end: BIG + BigInt(i * 20 * MB + 0x40000) })));
|
||||
|
||||
before(async () => {
|
||||
mock = startMock({ tcpPort: TCP, udpPort: 0, tick: false });
|
||||
await mock.ready;
|
||||
// 100 MB of empty memory next to the real game: 7 slices to search.
|
||||
mock.game.regions.push(blankRegion('', BIG, BIG_BYTES));
|
||||
bridge = startBridge({ port: 0, host: '127.0.0.1', eventPort: null, demo: { host: '127.0.0.1', port: TCP, udpPort: 0, label: 'test' } });
|
||||
bridgePort = await bridge.ready;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await bridge.close();
|
||||
await mock.close();
|
||||
});
|
||||
|
||||
const calm = () => {
|
||||
mock.state.scanDelayMs = 0;
|
||||
mock.state.countDelayMs = 0;
|
||||
mock.state.scanHang = false;
|
||||
};
|
||||
|
||||
// ---- progress ----------------------------------------------------------------------
|
||||
|
||||
test('a first search reports real progress: bytes done of bytes to do, slice by slice', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const regions = bigSlices();
|
||||
assert.ok(regions.every((r) => r.length <= SCAN_SLICE_BYTES), 'no slice is bigger than 16 MB, so the bar moves steadily');
|
||||
assert.equal(regions.length, Math.ceil(BIG_BYTES / SCAN_SLICE_BYTES));
|
||||
const seen = [];
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await scan.first(regions, exact, enc('123456'), (p) => seen.push(p));
|
||||
// One report before anything is searched (so the screen can say how much there is), then one per slice.
|
||||
assert.equal(seen.length, regions.length + 1);
|
||||
assert.deepEqual([seen[0].fraction, seen[0].done, seen[0].total, seen[0].unit], [0, 0, BIG_BYTES, 'bytes']);
|
||||
assert.deepEqual([seen.at(-1).fraction, seen.at(-1).done], [1, BIG_BYTES]);
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
assert.ok(seen[i].done > seen[i - 1].done, 'progress only moves forward');
|
||||
assert.equal(seen[i].fraction, seen[i].done / BIG_BYTES, 'the fraction is bytes done over bytes to do, nothing else');
|
||||
}
|
||||
assert.equal(scan.matches, 0);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('narrowing down reports candidates checked of candidates to check', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await scan.first(zeroSlices(), compareKind('smaller'), enc('1'), () => {});
|
||||
const before = scan.matches;
|
||||
assert.equal(before, 8 * 65536);
|
||||
const seen = [];
|
||||
await scan.next(compareKind('unchanged'), new Uint8Array(0), (p) => seen.push(p));
|
||||
assert.deepEqual([seen[0].done, seen[0].total, seen[0].unit, seen[0].fraction], [0, before, 'candidates', 0]);
|
||||
assert.deepEqual([seen.at(-1).done, seen.at(-1).fraction], [before, 1]);
|
||||
assert.ok(seen.length > 5, 'several steps, not one jump');
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].done > seen[i - 1].done);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('an Auto search says which round it is on and counts within that round', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new AutoScan(() => dbg, GAME.pid);
|
||||
scan.input('100');
|
||||
const seen = [];
|
||||
await scan.first(heapSlices(), exact, new Uint8Array(0), (p) => seen.push(p));
|
||||
assert.deepEqual([...new Set(seen.map((p) => p.part.index))], [1, 2, 3]);
|
||||
assert.ok(seen.every((p) => p.part.count === 3 && p.total === 0x40000 && p.unit === 'bytes'));
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].fraction >= seen[i - 1].fraction, 'the bar never goes back between rounds');
|
||||
assert.equal(seen.at(-1).fraction, 1);
|
||||
assert.match(seen[0].detail, /as 4 bytes/);
|
||||
// A number no type can hold is refused with the reason, before anything is sent.
|
||||
scan.input('lots');
|
||||
await assert.rejects(scan.first(heapSlices(), exact, new Uint8Array(0), () => {}), /"lots" is not a number/);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('an unknown-value snapshot reports progress as the console goes, and can be canceled', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new SnapshotScan(() => dbg, GAME.pid, u32);
|
||||
const seen = [];
|
||||
await scan.first([...heapSlices(), ...bigSlices()], compareKind('unknown'), new Uint8Array(0), (p) => seen.push(p));
|
||||
assert.ok(seen.length > 5);
|
||||
assert.deepEqual([seen[0].done, seen[0].unit], [0, 'bytes']);
|
||||
assert.equal(seen.at(-1).fraction, 1);
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].done >= seen[i - 1].done);
|
||||
await scan.end();
|
||||
|
||||
// Canceling one long request means closing the connection: it comes back as a cancel, not as an error.
|
||||
mock.state.scanDelayMs = 600;
|
||||
const dbg2 = await connect();
|
||||
await dbg2.auth();
|
||||
const second = new SnapshotScan(() => dbg2, GAME.pid, u32);
|
||||
const stop = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = second.first(bigSlices(), compareKind('unknown'), new Uint8Array(0), (p) => {
|
||||
if (p.done > 0 && !stop.signal.aborted) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, (e) => e instanceof ScanCanceledError && e.progress.done > 0 && e.progress.done < BIG_BYTES);
|
||||
assert.ok(Date.now() - started < 5000, 'the cancel does not wait for the snapshot to finish');
|
||||
assert.equal(dbg2.closed, true);
|
||||
assert.equal(second.matches, 0);
|
||||
calm();
|
||||
|
||||
// Narrowing a list the console holds: a cancel that comes too late to stop it takes the result, because the
|
||||
// console's list is narrowed either way...
|
||||
const dbg3 = await connect();
|
||||
const third = new SnapshotScan(() => dbg3, GAME.pid, u32);
|
||||
await third.first(heapSlices(), compareKind('unknown'), new Uint8Array(0), () => {});
|
||||
const had = third.matches;
|
||||
const late = new AbortController();
|
||||
mock.state.countDelayMs = 300;
|
||||
setTimeout(() => late.abort(), 100);
|
||||
await third.next(compareKind('unchanged'), new Uint8Array(0), () => {}, late.signal);
|
||||
assert.equal(late.signal.aborted, true);
|
||||
calm();
|
||||
assert.equal(third.passes, 2);
|
||||
assert.ok(third.matches > 0 && third.matches <= had);
|
||||
assert.equal(third.lost, false);
|
||||
// ...and one that has to close the connection loses the list, and says so.
|
||||
mock.state.scanHang = true;
|
||||
const cut = new AbortController();
|
||||
const narrowing = third.next(compareKind('unchanged'), new Uint8Array(0), () => {}, cut.signal);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
cut.abort();
|
||||
await assert.rejects(narrowing, ScanCanceledError);
|
||||
assert.deepEqual([third.lost, third.matches, dbg3.closed], [true, 0, true]);
|
||||
await assert.rejects(third.next(compareKind('unchanged'), new Uint8Array(0), () => {}), /Start a new search/);
|
||||
calm();
|
||||
});
|
||||
|
||||
// ---- canceling -----------------------------------------------------------------------
|
||||
|
||||
test('a first search can be canceled between slices, keeps nothing and leaves the connection usable', async () => {
|
||||
calm();
|
||||
mock.state.scanDelayMs = 30;
|
||||
const dbg = await connect();
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
const stop = new AbortController();
|
||||
const pending = scan.first(bigSlices(), exact, enc('123456'), (p) => {
|
||||
if (p.done >= 3 * SCAN_SLICE_BYTES) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, (e) => {
|
||||
assert.ok(e instanceof ScanCanceledError);
|
||||
// It says how far it got, for "Canceled after searching 48 of 100 MB".
|
||||
assert.equal(e.progress.done, 3 * SCAN_SLICE_BYTES);
|
||||
assert.equal(e.progress.total, BIG_BYTES);
|
||||
assert.equal(e.progress.matches, 0);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([scan.matches, scan.passes, scan.canUndo], [0, 0, false]);
|
||||
assert.equal(dbg.closed, false, 'a slice that finishes in time needs no closed connection');
|
||||
// The same connection searches again straight away.
|
||||
calm();
|
||||
const again = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await again.first(heapSlices(), exact, enc('100'), () => {});
|
||||
assert.ok(again.matches >= 1);
|
||||
// Already canceled before it starts: nothing is sent at all.
|
||||
const dead = new AbortController();
|
||||
dead.abort();
|
||||
await assert.rejects(new ScanSession(() => dbg, GAME.pid, u32).first(heapSlices(), exact, enc('100'), () => {}, dead.signal), ScanCanceledError);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('canceling a narrowing leaves the candidate list exactly as it was', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const scan = new ScanSession(() => dbg, GAME.pid, u32);
|
||||
await scan.first(zeroSlices(), compareKind('smaller'), enc('1'), () => {});
|
||||
const before = scan.matches;
|
||||
const firstHits = await scan.hits(5);
|
||||
mock.state.countDelayMs = 30;
|
||||
const stop = new AbortController();
|
||||
const pending = scan.next(compareKind('changed'), new Uint8Array(0), (p) => {
|
||||
if (p.done > before / 4) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, (e) => e instanceof ScanCanceledError && e.progress.unit === 'candidates' && e.progress.done < before);
|
||||
assert.deepEqual([scan.matches, scan.passes, scan.canUndo], [before, 1, false]);
|
||||
assert.deepEqual(await scan.hits(5), firstHits);
|
||||
// And it can be narrowed for real afterwards.
|
||||
calm();
|
||||
await scan.next(compareKind('changed'), new Uint8Array(0), () => {});
|
||||
assert.equal(scan.matches, 0);
|
||||
scan.undo();
|
||||
assert.equal(scan.matches, before);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('cancel gets through when the console has stopped answering, and the next search waits for the new connection', async () => {
|
||||
calm();
|
||||
let dbg = await connect();
|
||||
await dbg.auth();
|
||||
const getDbg = () => dbg;
|
||||
mock.state.scanHang = true;
|
||||
const scan = new ScanSession(getDbg, GAME.pid, u32);
|
||||
const stop = new AbortController();
|
||||
const pending = scan.first(heapSlices(), exact, enc('100'), () => {}, stop.signal);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const at = Date.now();
|
||||
stop.abort();
|
||||
await assert.rejects(pending, ScanCanceledError);
|
||||
const waited = Date.now() - at;
|
||||
assert.ok(waited >= 2000 && waited < 5000, `gives the console a moment, then closes the connection (waited ${waited} ms)`);
|
||||
assert.equal(dbg.closed, true);
|
||||
calm();
|
||||
|
||||
// The session replaces a closed connection a moment later; a search started in between waits for it.
|
||||
const dead = dbg;
|
||||
setTimeout(async () => {
|
||||
dbg = await connect();
|
||||
}, 300);
|
||||
assert.equal(getDbg(), dead);
|
||||
const again = new ScanSession(getDbg, GAME.pid, u32);
|
||||
await again.first(heapSlices(), exact, enc('100'), () => {});
|
||||
assert.ok(again.matches >= 1);
|
||||
assert.notEqual(getDbg(), dead);
|
||||
// If it never comes back, the wait ends with something a person can act on.
|
||||
await assert.rejects(liveDbg(() => dead, 300), /connection to the console was lost/);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('text and group searches: bytes done of bytes to do, and cancel when the console hangs', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
// 6 MB, in pieces of 1 MB so there are several steps to see.
|
||||
const SIZE = 6 * MB;
|
||||
const ranges = [{ start: BIG, end: BIG + BigInt(SIZE) }];
|
||||
const seen = [];
|
||||
const res = await searchText({ dbg: () => dbg, pid: GAME.pid, ranges, text: 'zzqqxx', encodings: ['utf8', 'utf16'], ignoreCase: true, chunkBytes: MB, onProgress: (p) => seen.push(p) });
|
||||
assert.deepEqual([res.hits.length, res.canceled, res.bytesSearched], [0, false, SIZE]);
|
||||
assert.deepEqual([seen[0].done, seen[0].total, seen[0].fraction], [0, SIZE, 0]);
|
||||
assert.deepEqual([seen.at(-1).done, seen.at(-1).fraction], [SIZE, 1]);
|
||||
assert.ok(seen.length >= 5);
|
||||
for (let i = 1; i < seen.length; i++) assert.ok(seen[i].done >= seen[i - 1].done && seen[i].fraction === seen[i].done / seen[i].total);
|
||||
|
||||
const steps = [];
|
||||
const group = await groupSearch({
|
||||
dbg: () => dbg,
|
||||
pid: GAME.pid,
|
||||
regions: sliceRanges([{ start: GAME.heapBase, end: GAME.heapBase + 0x40000n }, ...ranges], MB),
|
||||
members: [{ kind: u32, text: '3' }, { kind: u32, text: '12' }, { kind: u32, text: '7' }],
|
||||
distance: 64,
|
||||
onProgress: (p) => steps.push(p),
|
||||
});
|
||||
assert.equal(group.hits.length, 1);
|
||||
assert.deepEqual([group.bytesSearched, group.bytesTotal], [SIZE + 0x40000, SIZE + 0x40000]);
|
||||
const one = steps.filter((p) => p.step === 1);
|
||||
const two = steps.filter((p) => p.step === 2);
|
||||
assert.deepEqual([one[0].fraction, one[0].done, one.at(-1).fraction], [0, 0, 1]);
|
||||
assert.ok(one.every((p) => p.total === SIZE + 0x40000) && one.length >= 5, 'step 1 counts bytes of memory');
|
||||
assert.ok(two.length >= 1 && two.at(-1).fraction === 1 && two.at(-1).total === group.anchorMatches, 'step 2 counts the places to check');
|
||||
|
||||
// The console stops answering in the middle of each.
|
||||
for (const run of [
|
||||
(signal) => searchText({ dbg: () => dbg2, pid: GAME.pid, ranges, text: 'zzqqxx', encodings: ['utf8'], signal }),
|
||||
(signal) => groupSearch({ dbg: () => dbg2, pid: GAME.pid, regions: sliceRanges(ranges), members: [{ kind: u32, text: '424242' }, { kind: u32, text: '7' }], distance: 64, signal }),
|
||||
]) {
|
||||
calm();
|
||||
var dbg2 = await connect();
|
||||
await dbg2.auth();
|
||||
mock.state.scanHang = true;
|
||||
const stop = new AbortController();
|
||||
const pending = run(stop.signal);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
stop.abort();
|
||||
const out = await pending;
|
||||
assert.equal(out.canceled, true, 'it ends as a cancel with whatever was found, not as an error and not as a hang');
|
||||
assert.deepEqual(out.hits, []);
|
||||
}
|
||||
calm();
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
test('a before/after snapshot can be canceled and says so', async () => {
|
||||
calm();
|
||||
const dbg = await connect();
|
||||
const seen = [];
|
||||
const stop = new AbortController();
|
||||
const pending = takeSnapshot(() => dbg, GAME.pid, BIG, 8 * MB, (f) => {
|
||||
seen.push(f);
|
||||
if (f >= 0.25) stop.abort();
|
||||
}, stop.signal);
|
||||
await assert.rejects(pending, SnapshotCanceledError);
|
||||
assert.equal(seen[0], 0);
|
||||
assert.ok(seen.at(-1) < 1);
|
||||
dbg.close();
|
||||
});
|
||||
|
||||
// ---- what was typed ---------------------------------------------------------------------
|
||||
|
||||
test('a wrong number is refused with what to change', () => {
|
||||
const bad = (kindId, text, pattern) => assert.throws(() => encodeValue(valueKind(kindId), text), pattern, `${kindId} ${JSON.stringify(text)}`);
|
||||
bad('u32', '', /Enter a value first/);
|
||||
bad('u32', ' ', /Enter a value first/);
|
||||
bad('u32', 'abc', /"abc" is not a whole number\. Type digits only/);
|
||||
bad('float', 'abc', /"abc" is not a number\. Type digits only/);
|
||||
// A decimal typed for a whole-number type: say which type would take it.
|
||||
bad('u32', '1.5', /1\.5 is not a whole number.*only holds whole numbers.*Float \(or Auto\)/);
|
||||
bad('u16', '99.25', /Float/);
|
||||
// Negative for a type without a sign: name the signed one.
|
||||
bad('u32', '-5', /cannot hold a negative number\. Choose "4 bytes \(signed\)"/);
|
||||
bad('u8', '-1', /"1 byte \(signed\)"/);
|
||||
// Out of range: say the range.
|
||||
bad('u8', '256', /256 does not fit in the type "1 byte", which holds 0 to 255\. Choose a bigger type/);
|
||||
bad('i8', '-129', /holds -128 to 127/);
|
||||
bad('u32', '4294967296', /holds 0 to 4,294,967,295/);
|
||||
bad('u64', '18446744073709551616', /does not fit in the type "8 bytes"/);
|
||||
// Thousands separators and a decimal comma.
|
||||
bad('u32', '1,000', /without commas or spaces: 1000/);
|
||||
bad('u32', '12 000 000', /12000000/);
|
||||
bad('float', '99,5', /Use a point for decimals: 99\.5/);
|
||||
bad('float', 'Infinity', /is not a number/);
|
||||
// What is fine stays fine.
|
||||
assert.equal(decodeValue(u32, encodeValue(u32, ' 0x10 ')), '16');
|
||||
assert.equal(decodeValue(valueKind('i32'), encodeValue(valueKind('i32'), '-5')), '-5');
|
||||
assert.equal(decodeValue(valueKind('double'), encodeValue(valueKind('double'), '1e39')), '1e+39');
|
||||
assert.equal(decodeValue(valueKind('float'), encodeValue(valueKind('float'), '-0.5')), '-0.5');
|
||||
|
||||
// "Between" takes the two numbers in either order, and needs both.
|
||||
const between = compareKind('between');
|
||||
assert.deepEqual(encodeCompare(u32, between, '50', '10'), encodeCompare(u32, between, '10', '50'));
|
||||
assert.deepEqual([...encodeCompare(u32, between, '50', '10')], [10, 0, 0, 0, 50, 0, 0, 0]);
|
||||
assert.throws(() => encodeCompare(u32, between, '10', ''), /Type both numbers/);
|
||||
assert.throws(() => encodeCompare(u32, between, '10', 'x'), /"x" is not a whole number/);
|
||||
|
||||
// A group names the line that is wrong.
|
||||
const m = (text, id = 'u32') => ({ kind: valueKind(id), text });
|
||||
assert.throws(() => checkGroup([m('100'), m('')], 64), /Value 2 is empty/);
|
||||
assert.throws(() => checkGroup([m('100'), m('1.5')], 64), /Value 2: 1\.5 is not a whole number/);
|
||||
assert.throws(() => checkGroup([m('100'), m('50')], NaN), /whole number of bytes, such as 64/);
|
||||
assert.throws(() => checkGroup([m('100'), m('50')], 4), /has to be at least 8/);
|
||||
assert.throws(() => checkGroup([m('100'), m('50')], 5000), /at most 4,096/);
|
||||
assert.equal(checkGroup([m('100'), m('50', 'float')], 64).length, 2);
|
||||
});
|
||||
|
||||
// ---- the words for every ending -----------------------------------------------------------
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const all = { bytes: 9.6 * GB, total: 9.6 * GB };
|
||||
const part = { bytes: 1.2 * GB, total: 9.6 * GB };
|
||||
const q = (kindId, compareId, value = '', value2 = '') => ({ kind: kindId === 'auto' ? AUTO_KIND : valueKind(kindId), compare: compareKind(compareId), value, value2 });
|
||||
|
||||
test('a first search that finds nothing says what was searched, where, and what to try', () => {
|
||||
const f = firstFeedback(q('u32', 'exact', '123456'), all, 0, 85_000);
|
||||
assert.equal(f.tone, 'warn');
|
||||
assert.equal(f.title, "Nothing found. No place in all 9.60 GB of the game's memory holds 123456 as 4 bytes. Took 85 s.");
|
||||
assert.ok(f.tips.length >= 3);
|
||||
assert.match(f.tips[0], /exactly what the game shows right now/);
|
||||
assert.ok(f.tips.some((t) => /Set Type to Auto/.test(t)));
|
||||
assert.ok(f.tips.some((t) => /"Unknown"/.test(t)));
|
||||
assert.ok(!f.tips.some((t) => /Where to search/.test(t)), 'everything was searched, so searching more is not suggested');
|
||||
// Only part of the memory was searched: say so and say how to widen it.
|
||||
const narrow = firstFeedback(q('u32', 'exact', '123456'), part, 0, 4000);
|
||||
assert.match(narrow.title, /No place in 1\.20 GB of the game's 9\.60 GB holds/);
|
||||
assert.ok(narrow.tips.some((t) => /Only 1\.20 GB of 9\.60 GB was searched\. Open "Where to search" and choose "Everything"/.test(t)));
|
||||
// A decimal searched exactly: point at "About".
|
||||
assert.ok(firstFeedback(q('float', 'exact', '99.5'), all, 0, 1000).tips.some((t) => /About \(shown rounded\)/.test(t)));
|
||||
// Auto already tried the other types.
|
||||
const auto = firstFeedback(q('auto', 'exact', '100'), all, 0, 1000, ['Double (too many matches)']);
|
||||
assert.match(auto.title, /holds 100 as 4 bytes, Float or Double/);
|
||||
assert.ok(!auto.tips.some((t) => /Set Type to Auto/.test(t)));
|
||||
assert.ok(auto.tips.some((t) => /Not counted here: Double \(too many matches\)/.test(t)));
|
||||
// Every comparison reads as a sentence.
|
||||
assert.equal(holdsWords(q('u32', 'bigger', '5')), 'a value bigger than 5 as 4 bytes');
|
||||
assert.equal(holdsWords(q('i16', 'between', '-5', '5')), 'a value between -5 and 5 as 2 bytes (signed)');
|
||||
assert.equal(holdsWords(q('float', 'rounded', '99')), 'about 99 (98.5 to 99.5) as Float');
|
||||
});
|
||||
|
||||
test('a search that finds something says how many and what to do with them', () => {
|
||||
const many = firstFeedback(q('u32', 'exact', '100'), all, 48213, 85_000);
|
||||
assert.equal(many.tone, 'info');
|
||||
assert.equal(many.title, "Found 48,213 places holding 100 as 4 bytes, in all 9.60 GB of the game's memory. Took 85 s.");
|
||||
assert.match(many.tips[0], /Too many to try one by one.*press "Narrow down"/);
|
||||
assert.match(firstFeedback(q('u32', 'exact', '100'), all, 1, 500).title, /^Found 1 place holding .* Took under a second\.$/);
|
||||
assert.match(firstFeedback(q('u32', 'exact', '100'), all, 1, 500).tips[0], /very likely it/);
|
||||
assert.match(firstFeedback(q('u32', 'exact', '100'), all, 4, 200_000).title, /Took 3 min\.$/);
|
||||
const snap = firstFeedback(q('u32', 'unknown'), all, 2_400_000_000, 30_000);
|
||||
assert.match(snap.title, /^Took note of every value in all 9\.60 GB of the game's memory, read as 4 bytes: 2,400,000,000 matches/);
|
||||
assert.match(snap.tips[0], /"Went up", "Went down" or "Changed"/);
|
||||
});
|
||||
|
||||
test('narrowing down: what was kept, and an empty list is explained', () => {
|
||||
const kept = narrowFeedback(q('u32', 'exact', '95'), 48213, 12, true);
|
||||
assert.equal(kept.title, '12 of 48,213 matches left: the ones whose value is now 95.');
|
||||
const none = narrowFeedback(q('u32', 'exact', '95'), 48213, 0, true);
|
||||
assert.equal(none.tone, 'warn');
|
||||
assert.equal(none.title, 'Nothing left. None of the 48,213 matches passed the check "value is now 95".');
|
||||
assert.match(none.tips[0], /Press "Undo last step" to get the 48,213 matches back/);
|
||||
assert.ok(none.tips.some((t) => /"Went up", "Went down" or "Changed" instead of a number/.test(t)));
|
||||
// No undo (a search that started from "Unknown"): do not offer it.
|
||||
const snap = narrowFeedback(q('u32', 'decreased'), 1000, 0, false);
|
||||
assert.ok(!snap.tips.some((t) => /Undo/.test(t)));
|
||||
assert.match(snap.tips.at(-1), /^Press "New search"/);
|
||||
assert.match(snap.title, /"value went down"/);
|
||||
// Nothing ruled out is worth saying.
|
||||
assert.match(narrowFeedback(q('u32', 'unchanged'), 500, 500, true).tips[0], /Nothing was ruled out/);
|
||||
assert.equal(narrowFeedback(q('u32', 'unchanged'), 1, 1, true).title, '1 of 1 match left: the ones whose value stayed the same.');
|
||||
for (const [id, words] of [['increased', 'value went up'], ['increasedBy', 'value went up by exactly 5'], ['decreasedBy', 'value went down by exactly 5'], ['changed', 'value changed'], ['smaller', 'value is now smaller than 5']]) {
|
||||
assert.equal(checkWords(q('u32', id, '5')), words);
|
||||
}
|
||||
});
|
||||
|
||||
test('canceled, failed and too many: each says what was kept', () => {
|
||||
const progress = { fraction: 0.32, matches: 17, detail: '', done: 3.1 * GB, total: 9.6 * GB, unit: 'bytes' };
|
||||
const first = canceledFeedback(true, progress, all, null);
|
||||
assert.equal(first.title, 'Canceled after searching 3.10 of 9.60 GB.');
|
||||
assert.match(first.tips[0], /^Nothing was kept/);
|
||||
assert.ok(first.tips.some((t) => t === '17 matches had been found up to then.'));
|
||||
assert.match(canceledFeedback(true, { ...progress, part: { index: 2, count: 3 } }, all, null).title, /on round 2 of 3\.$/);
|
||||
assert.equal(canceledFeedback(true, null, null, null).title, 'Canceled.');
|
||||
assert.equal(canceledFeedback(false, null, null, 48213).title, 'Canceled. Nothing changed: your list still has 48,213 matches.');
|
||||
// A list kept on the console is lost by a cancel, and that has to be said.
|
||||
assert.match(canceledFeedback(false, null, null, null).title, /This search is gone/);
|
||||
|
||||
const gone = failedFeedback(true, 'Starting the scan failed (the console reported an error)', null, true);
|
||||
assert.equal(gone.tone, 'error');
|
||||
assert.equal(gone.title, 'The search stopped before it finished. Starting the scan failed (the console reported an error).');
|
||||
assert.deepEqual(gone.tips[0], 'Nothing was kept.');
|
||||
assert.match(gone.tips[1], /The game is not running any more/);
|
||||
const still = failedFeedback(false, 'The console closed the connection.', 120, false);
|
||||
assert.match(still.title, /^Narrowing down stopped before it finished\. The console closed the connection\.$/);
|
||||
assert.equal(still.tips[0], 'Your list is unchanged: it still has 120 matches.');
|
||||
assert.match(still.tips[1], /Check that the game is still running/);
|
||||
|
||||
const tooMany = tooManyFeedback(q('u32', 'exact', '0'), 4_000_000);
|
||||
assert.match(tooMany.title, /More than 4,000,000 places hold 0 as 4 bytes.*nothing was kept/);
|
||||
assert.equal(tooMany.tips.length, 3);
|
||||
});
|
||||
|
||||
test('text and group searches say what happened in the same way', () => {
|
||||
const t = { text: 'godmode', plain: true, wide: true, ignoreCase: true, fixed: false };
|
||||
const none = textFeedback(t, { hits: 0, capped: false, canceled: false, bytesSearched: all.bytes }, all, 40_000);
|
||||
assert.equal(none.title, 'Nothing found. No place in all 9.60 GB of the game\'s memory holds "godmode" as plain or wide text, in any mix of capitals. Took 40 s.');
|
||||
assert.ok(none.tips.some((x) => /Also look in the game's code and fixed data/.test(x)));
|
||||
const strict = textFeedback({ ...t, wide: false, ignoreCase: false, fixed: true }, { hits: 0, capped: false, canceled: false, bytesSearched: all.bytes }, all, 1000);
|
||||
assert.match(strict.title, /as plain text, with exactly those capitals/);
|
||||
assert.ok(strict.tips.some((x) => /Check "Ignore capitals"/.test(x)));
|
||||
assert.ok(strict.tips.some((x) => /Only plain text was searched/.test(x)));
|
||||
assert.ok(!strict.tips.some((x) => /fixed data/.test(x)));
|
||||
assert.match(textFeedback(t, { hits: 3, capped: false, canceled: false, bytesSearched: all.bytes }, all, 1000).title, /^Found 3 places holding "godmode"/);
|
||||
const capped = textFeedback(t, { hits: 200, capped: true, canceled: false, bytesSearched: 2 * GB }, all, 1000);
|
||||
assert.match(capped.title, /^Found at least 200 places/);
|
||||
assert.match(capped.tips[0], /stopped at the first 200, after 2\.00 of 9\.60 GB/);
|
||||
assert.equal(
|
||||
textFeedback(t, { hits: 2, capped: false, canceled: true, bytesSearched: 2 * GB }, all, 1000).title,
|
||||
'Canceled after searching 2.00 of 9.60 GB. 2 places found up to then are listed below.',
|
||||
);
|
||||
assert.match(textFeedback(t, { hits: 0, capped: false, canceled: true, bytesSearched: 0 }, all, 1000).title, /Nothing had been found up to then/);
|
||||
|
||||
const g = (over) => ({ groups: 0, anchorText: '2500', anchorMatches: 0, capped: false, canceled: false, bytesSearched: all.bytes, ...over });
|
||||
assert.match(groupFeedback(['100', '2500'], 64, g(), all, 9000).title, /^Nothing found\. 2500 is not anywhere in all 9\.60 GB/);
|
||||
const apart = groupFeedback(['100', '2500'], 64, g({ anchorMatches: 2341 }), all, 9000);
|
||||
assert.match(apart.title, /^No group found\. 2500 is in 2,341 places, but none of them has the other values \(100, 2500\) within 64 bytes/);
|
||||
assert.match(apart.tips[0], /bigger distance/);
|
||||
assert.match(groupFeedback(['100', '2500'], 64, g({ groups: 1, anchorMatches: 9 }), all, 9000).title, /^Found 1 group holding 100, 2500 within 64 bytes/);
|
||||
assert.match(groupFeedback(['100', '2500'], 64, g({ canceled: true, bytesSearched: 1 * GB }), all, 9000).title, /^Canceled after searching 1\.00 of 9\.60 GB\. No group had been found/);
|
||||
});
|
||||
|
||||
test('amounts and times read the way a person would say them', () => {
|
||||
assert.equal(ofBytes(3.1 * GB, 9.6 * GB), '3.10 of 9.60 GB');
|
||||
assert.equal(ofBytes(31 * GB, 96 * GB), '31.0 of 96.0 GB');
|
||||
assert.equal(ofBytes(120 * MB, 480 * MB), '120 of 480 MB');
|
||||
assert.equal(ofBytes(0, 4 * MB), '0.0 of 4.0 MB');
|
||||
// Never "9.60 of 9.60" while there is still something to do, and exactly that at the end.
|
||||
assert.equal(ofBytes(9.6 * GB - 1, 9.6 * GB), '9.59 of 9.60 GB');
|
||||
assert.equal(ofBytes(9.6 * GB, 9.6 * GB), '9.60 of 9.60 GB');
|
||||
assert.equal(clock(36_400), '0:36');
|
||||
assert.equal(clock(725_000), '12:05');
|
||||
assert.equal(leftWords(3000), 'a few seconds left');
|
||||
assert.equal(leftWords(42_000), 'about 40 s left');
|
||||
assert.equal(leftWords(100_000), 'about 1.5 min left');
|
||||
assert.equal(leftWords(26 * 60_000), 'about 26 min left');
|
||||
|
||||
// Time left comes from how fast the search has really gone, and only once there is enough to go on.
|
||||
const steady = Array.from({ length: 11 }, (_, i) => ({ at: i * 1000, fraction: i / 100 })); // 1% a second
|
||||
assert.equal(timeLeft(steady.slice(0, 3), 2000), null, 'too early to say');
|
||||
assert.equal(Math.round(timeLeft(steady, 10_000) / 1000), 90);
|
||||
assert.equal(Math.round(timeLeft(steady, 14_000) / 1000), 86, 'it keeps counting down between reports');
|
||||
// It follows a search that slows down: the last 20 seconds count, not the fast start.
|
||||
const slowing = [...Array.from({ length: 11 }, (_, i) => ({ at: i * 1000, fraction: i / 20 })), ...Array.from({ length: 30 }, (_, i) => ({ at: 11_000 + i * 1000, fraction: 0.5 + (i + 1) / 1000 }))];
|
||||
const left = timeLeft(slowing, 40_000);
|
||||
assert.ok(left > 400_000, `a slow stretch is not hidden by a fast start (${left})`);
|
||||
assert.equal(timeLeft([{ at: 0, fraction: 0.2 }, { at: 1000, fraction: 0.2 }, { at: 2000, fraction: 0.2 }, { at: 9000, fraction: 0.2 }], 9000), null, 'no movement, no promise');
|
||||
assert.equal(timeLeft([...steady.slice(0, 10), { at: 10_000, fraction: 1 }], 10_000), null);
|
||||
});
|
||||
|
||||
// ---- a real-sized game ------------------------------------------------------------------
|
||||
|
||||
test('"where to search" stays a short list for a game with thousands of blocks and 9.6 GB', () => {
|
||||
const maps = [];
|
||||
let at = 0x1000000000n;
|
||||
const add = (name, bytes, prot = 3) => {
|
||||
maps.push({ name, start: at, end: at + BigInt(bytes), offset: 0n, prot });
|
||||
at += BigInt(bytes) + 0x10000n;
|
||||
};
|
||||
maps.push({ name: 'executable', start: 0x400000n, end: 0x400000n + BigInt(113 * MB), offset: 0n, prot: 5 });
|
||||
maps.push({ name: 'executable', start: 0x400000n + BigInt(113 * MB), end: 0x400000n + BigInt(140 * MB), offset: 0n, prot: 3 });
|
||||
for (const n of [2048, 1536, 1024, 1024, 768, 512, 512, 256, 256, 128, 96, 80]) add('', n * MB);
|
||||
for (let i = 0; i < 40; i++) add('', (16 + (i % 30)) * MB);
|
||||
for (let i = 0; i < 300; i++) add('', (1 + (i % 12)) * MB);
|
||||
for (let i = 0; i < 2200; i++) add('', 0x4000 * (1 + (i % 20)));
|
||||
for (let i = 0; i < 90; i++) {
|
||||
add(`libSceModule${i}.sprx`, 0x40000 * (1 + (i % 5)), 5);
|
||||
add(`libSceModule${i}.sprx`, 0x8000 * (1 + (i % 20)));
|
||||
}
|
||||
for (let i = 0; i < 300; i++) add(`thread stack ${i}`, 0x10000 * (1 + (i % 4)));
|
||||
assert.ok(maps.length > 3000);
|
||||
|
||||
const items = listRegions(maps, { start: 0x400000n, end: 0x400000n + BigInt(140 * MB) });
|
||||
const writable = items.filter((i) => i.writable);
|
||||
assert.ok(writable.length <= 25, `a list a person can read on a phone (${writable.length} lines)`);
|
||||
assert.ok(new Set(items.map((i) => i.id)).size === items.length, 'every line has its own id');
|
||||
const total = totalBytes(items);
|
||||
assert.ok(total > 9 * GB && total < 12 * GB);
|
||||
// Everything, as slices: none over 16 MB, and together exactly the memory there is.
|
||||
const everything = scopeRanges(items, DEFAULT_SCOPE);
|
||||
const slices = sliceRanges(everything);
|
||||
assert.ok(slices.every((s) => s.length <= SCAN_SLICE_BYTES && s.length > 0));
|
||||
assert.equal(slices.reduce((n, s) => n + s.length, 0), rangesBytes(everything));
|
||||
assert.equal(rangesBytes(everything), total);
|
||||
// One button unchecks the huge blocks, and the summary follows.
|
||||
const huge = hugeRegionIds(writable, 64 * MB);
|
||||
assert.equal(huge.length, 12);
|
||||
const picked = rangesBytes(scopeRanges(items, { ...DEFAULT_SCOPE, mode: 'pick', off: huge }));
|
||||
assert.ok(picked < total - 8 * GB, 'most of a big game is in a dozen huge blocks');
|
||||
assert.equal(rangesBytes(scopeRanges(items, { ...DEFAULT_SCOPE, mode: 'pick', off: writable.map((i) => i.id) })), 0);
|
||||
});
|
||||
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"/>
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user